// @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)
Rate limit
${D.ratePerMinute}
now: — req/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
});
});