// @ts-check /** * Playwright tests for the sandbox live rate chart (admin.js). * * Strategy * ───────── * Tests are fully self-contained — no live Quarkus server required. * * page.route() intercepts: * /sandbox/:id → minimal HTML fixture with __D baked in * /admin.js → real admin.js from local disk * /sandbox/:id/poll → controlled JSON per test * CDN (d3, topojson) → pass-through (real CDN, requires internet) * world-atlas CDN → empty TopoJSON stub (no countries, no network dep) * * Environment variables * ───────────────────── * APIX_ADMIN_URL Base URL used as origin for route interception * (default: http://localhost:8084). Does not need to be live. */ const { test, expect } = require('@playwright/test'); const path = require('path'); const fs = require('fs'); const SANDBOX_ID = '29a22236-d2fa-471a-902d-caec592f19b0'; const RATE_LIMIT = 60; const POLL_PATH = `/sandbox/${SANDBOX_ID}/poll`; const PAGE_PATH = `/sandbox/${SANDBOX_ID}`; const ADMIN_JS_PATH = path.resolve( __dirname, '../src/main/resources/META-INF/resources/admin.js' ); // Minimal world-atlas stub — valid TopoJSON, no countries, no CDN hit. const TOPO_STUB = JSON.stringify({ type: 'Topology', objects: { countries: { type: 'GeometryCollection', geometries: [] } }, arcs: [], transform: { scale: [1, 1], translate: [0, 0] }, }); // Default __D payload (mirrors SandboxDashboardResponse). const BASE_D = { sandboxId: SANDBOX_ID, name: 'chart-demo', tier: 'FREE', ratePerMinute: RATE_LIMIT, expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), registrarLat: 52.52, registrarLon: 13.405, recentVisits: [], usage: {}, }; /** * Renders the minimal sandbox HTML fixture with __D embedded. * Mirrors the structure of sandbox.html just enough for admin.js to work. */ function buildFixture(D) { return ` ${D.name} · APIX Admin (test)
${D.name} ${D.tier} ${D.sandboxId}
Rate limit
${D.ratePerMinute}
now: req/min
Expires
${D.expiresAt}
Live rate — req/min limit: ${D.ratePerMinute}/min ──
`; } // ── helpers ─────────────────────────────────────────────────────────────────── /** * Wire common routes (page HTML, admin.js, CDN stubs) and navigate. * * @param {import('@playwright/test').Page} page * @param {object} [dOverride] Fields merged into BASE_D for this test */ async function loadPage(page, dOverride = {}) { const D = { ...BASE_D, ...dOverride }; const html = buildFixture(D); const js = fs.readFileSync(ADMIN_JS_PATH, 'utf8'); await page.route(PAGE_PATH, route => route.fulfill({ contentType: 'text/html; charset=utf-8', body: html }) ); await page.route('**/admin.js**', route => route.fulfill({ contentType: 'application/javascript', body: js }) ); // Stub world-atlas so map renders without internet await page.route('**/world-atlas*/**', route => route.fulfill({ contentType: 'application/json', body: TOPO_STUB }) ); await page.goto(PAGE_PATH); // Wait for D3 and admin.js to initialise await page.waitForFunction(() => typeof d3 !== 'undefined', { timeout: 15000 }); } /** * Wire a controllable poll mock and return control handles. * * @param {import('@playwright/test').Page} page * @returns {{ setTotal: (n: number) => void, getCallCount: () => number }} */ function mockPoll(page) { let total = 0; let callCount = 0; page.route(`**${POLL_PATH}`, route => { callCount++; route.fulfill({ contentType: 'application/json', body: JSON.stringify({ totalRequests: total, ratePerMinute: RATE_LIMIT }), }); }); return { setTotal: (n) => { total = n; }, getCallCount: () => callCount, }; } // ── tests ───────────────────────────────────────────────────────────────────── test.describe('Sandbox live rate chart', () => { // ── 1. Static structure ─────────────────────────────────────────────────── test('axes render — Y labels 0/50/100% and X labels -72s/-36s/now', async ({ page }) => { mockPoll(page); await loadPage(page); const chart = page.locator('#rate-chart'); await expect(chart).toBeVisible(); for (const label of ['0%', '50%', '100%']) { await expect( chart.locator(`text.axis-label:text-is("${label}")`) ).toBeVisible(); } for (const label of ['-72s', '-36s', 'now']) { await expect( chart.locator(`text.axis-label:text-is("${label}")`) ).toBeVisible(); } }); test('rate-limit line is rendered', async ({ page }) => { mockPoll(page); await loadPage(page); const limitLine = page.locator('#rate-chart line.rate-limit-line'); await expect(limitLine).toHaveCount(1); const y = await limitLine.getAttribute('y1'); expect(Number(y)).toBeGreaterThan(0); }); test('area path exists with clip-path attribute', async ({ page }) => { mockPoll(page); await loadPage(page); const areaPath = page.locator('#rate-chart path.rate-area'); await expect(areaPath).toHaveCount(1); const clip = await areaPath.getAttribute('clip-path'); expect(clip).toMatch(/url\(#rc-/); }); // ── 2. Live label ───────────────────────────────────────────────────────── test('live label shows "req/min (N%)" format after traffic', async ({ page }) => { const poll = mockPoll(page); await loadPage(page); // First poll sets lastTotal = 0; second poll with total=30 → delta=30 → rpm > 0 poll.setTotal(0); await page.waitForTimeout(1300); // first poll fires poll.setTotal(30); await page.waitForFunction( () => { const el = document.getElementById('rate-live-val'); return el && el.textContent.includes('req/min') && el.textContent.includes('%'); }, { timeout: 6000 } ); const label = await page.locator('#rate-live-val').textContent(); expect(label).toMatch(/\d+\s*req\/min\s*\(\d+(\.\d+)?%\)/); }); test('live label starts as placeholder before any traffic', async ({ page }) => { mockPoll(page); await loadPage(page); const label = await page.locator('#rate-live-val').textContent(); expect(label).toMatch(/—|0\s*req\/min/); }); // ── 3. Chart path updates ───────────────────────────────────────────────── test('area path d attribute fills in after positive delta', async ({ page }) => { const poll = mockPoll(page); await loadPage(page); poll.setTotal(0); await page.waitForTimeout(1300); // first poll → lastTotal = 0 poll.setTotal(10); await page.waitForFunction( () => { const p = document.querySelector('#rate-chart path.rate-area'); const d = p && p.getAttribute('d'); return d && d.length > 10; }, { timeout: 5000 } ); const d = await page.locator('#rate-chart path.rate-area').getAttribute('d'); expect(d).toBeTruthy(); expect(d.length).toBeGreaterThan(10); }); test('poll fires at ~1s intervals when traffic is active', async ({ page }) => { const poll = mockPoll(page); await loadPage(page); poll.setTotal(5); await page.waitForTimeout(1300); poll.setTotal(10); await page.waitForTimeout(3500); // ~3 more 1s intervals // At 1s cadence, 4.8s total → ≥ 4 calls expect(poll.getCallCount()).toBeGreaterThanOrEqual(4); }); // ── 4. Rate decay ───────────────────────────────────────────────────────── test('live rpm decays to 0 after CHART_TICK (5s) with no new requests', async ({ page }) => { const poll = mockPoll(page); await loadPage(page); // Prime: two polls with delta=10 each poll.setTotal(10); await page.waitForTimeout(1300); poll.setTotal(20); await page.waitForTimeout(1300); // Confirm non-zero before cutting traffic const before = await page.locator('#rate-live-val').textContent(); expect(before).not.toMatch(/^0\s*req/); // Total stays at 20 → all subsequent deltas = 0 // After CHART_TICK (5000ms) the sliding window expires await page.waitForFunction( () => { const el = document.getElementById('rate-live-val'); return el && el.textContent.startsWith('0'); }, { timeout: 9000 } ); const after = await page.locator('#rate-live-val').textContent(); expect(after).toMatch(/^0\s*req\/min/); }); // ── 5. Overflow clamping ────────────────────────────────────────────────── test('area path stays within chart bounds under burst traffic', async ({ page }) => { const poll = mockPoll(page); await loadPage(page); poll.setTotal(0); await page.waitForTimeout(1300); poll.setTotal(RATE_LIMIT * 10); // 600 req — 10× rate limit burst await page.waitForTimeout(2000); const inBounds = await page.evaluate(() => { const p = document.querySelector('#rate-chart path.rate-area'); if (!p) return true; // path not drawn yet — not a clipping failure const svgEl = document.getElementById('rate-chart'); const chartH = svgEl ? Number(svgEl.getAttribute('height')) : 96; const bbox = p.getBBox(); return bbox.y >= 0 && bbox.y + bbox.height <= chartH; }); expect(inBounds).toBe(true); }); // ── 6. Header elements ──────────────────────────────────────────────────── test('sandbox name and tier badge visible in header', async ({ page }) => { mockPoll(page); await loadPage(page); await expect(page.locator('.sb-name')).toContainText('chart-demo'); await expect(page.locator('.tier-badge')).toContainText('FREE'); }); test('expires stat shows days-left with green color when > 30d out', async ({ page }) => { mockPoll(page); await loadPage(page); const expiresVal = page.locator('#expires-val'); await expect(expiresVal).toBeVisible(); const text = await expiresVal.textContent(); expect(text).toMatch(/\d+d left/); const color = await expiresVal.evaluate(el => window.getComputedStyle(el).color ); expect(color).toBe('rgb(63, 185, 80)'); // #3fb950 }); });