Files
Carsten Rehfeld 7ccc384ebc test(admin): Playwright chart tests — 11 scenarios, fully mocked
Covers axes, live label format, path update on delta, 1 s poll cadence,
rate decay after CHART_TICK, burst overflow clamp, and header elements.
All network I/O mocked via page.route(); no live Quarkus server required.

Co-Authored-By: Mira Rehfeld <noreply@anthropic.com>
2026-05-17 02:14:02 +02:00

409 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${D.name} · APIX Admin (test)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0d1117; color: #c9d1d9;
font-family: 'SF Mono','Consolas','Fira Code',monospace; font-size: 14px; }
a { color: #58a6ff; text-decoration: none; }
nav { display: flex; align-items: center; gap: 1.5rem;
padding: 0.75rem 1.5rem; border-bottom: 1px solid #21262d; background: #161b22; }
.nav-logo { color: #8b949e; font-size: 0.75rem; }
.nav-logo span { color: #e6edf3; font-weight: 600; font-size: 0.9rem; }
.nav-link { font-size: 0.8rem; color: #8b949e; }
.nav-right { margin-left: auto; }
.header { padding: 1rem 1.5rem; border-bottom: 1px solid #21262d;
display: flex; align-items: center; gap: 1rem; }
.sb-name { font-size: 1rem; color: #e6edf3; font-weight: 600; }
.tier-badge { background: #161b22; border: 1px solid #30363d; color: #8b949e;
padding: 0.1rem 0.5rem; border-radius: 12px;
font-size: 0.7rem; letter-spacing: 0.05em; }
.sb-id { font-size: 0.75rem; color: #484f58; }
.stat { background: #0d1117; padding: 1rem 1.5rem; }
.stat-label { font-size: 0.65rem; color: #484f58; text-transform: uppercase;
letter-spacing: 0.06em; margin-bottom: 0.3rem; }
.stat-value { font-size: 1.4rem; color: #e6edf3; font-weight: 600; }
.stat-sub { font-size: 0.7rem; color: #484f58; margin-top: 0.2rem; }
#map-wrap { background: #060d18; }
#world-map { display: block; width: 100%; }
#rate-wrap { background: #060d18; padding: 0 1.5rem 0.5rem; border-bottom: 1px solid #21262d; }
.rate-header { display: flex; align-items: baseline; gap: 0.75rem; padding: 0.6rem 0 0.4rem; }
.rate-title { font-size: 0.65rem; color: #484f58; text-transform: uppercase; letter-spacing: 0.06em; }
.rate-live { font-size: 0.75rem; color: #388bfd; }
.rate-limit-label { font-size: 0.65rem; color: #f8514966; margin-left: auto; }
#rate-chart { display: block; width: 100%; overflow: visible; }
.rate-area { fill: #1f6feb33; stroke: #388bfd; stroke-width: 1.5; }
.rate-limit-line { stroke: #f85149; stroke-width: 0.8; stroke-dasharray: 4 3; opacity: 0.5; }
.axis-label { font-size: 9px; fill: #484f58;
font-family: 'SF Mono','Consolas','Fira Code',monospace; }
.axis-line { stroke: #21262d; stroke-width: 0.5; }
@keyframes star-glow {
0%,100% { filter: drop-shadow(0 0 3px #ffd700); }
50% { filter: drop-shadow(0 0 8px #ffd700); }
}
.registrar-star { font-size: 14px; fill: #ffd700; dominant-baseline: central;
text-anchor: middle; animation: star-glow 2.4s ease-in-out infinite; }
@keyframes blink-pulse {
0% { opacity:0; r:2; } 25% { opacity:0.9; r:5; } 65% { opacity:0.4; r:4; } 100% { opacity:0; r:2; }
}
.agent-blink { fill: #3d8bfd; opacity:0; animation: blink-pulse 2.8s ease-in-out infinite; }
</style>
</head>
<body>
<nav>
<div class="nav-logo">APIX · <span>Admin</span></div>
<a class="nav-link" href="/">Dashboard</a>
<a class="nav-link" href="/sandboxes">Sandboxes</a>
<div class="nav-right"><a class="nav-link" href="/login/logout">Sign out</a></div>
</nav>
<div class="header">
<span class="sb-name">${D.name}</span>
<span class="tier-badge">${D.tier}</span>
<span class="sb-id">${D.sandboxId}</span>
</div>
<div class="stat">
<div class="stat-label">Rate limit</div>
<div class="stat-value">${D.ratePerMinute}</div>
<div class="stat-sub">now: <span id="rate-now">—</span> req/min</div>
</div>
<div class="stat">
<div class="stat-label">Expires</div>
<div class="stat-value" id="expires-val">—</div>
<div class="stat-sub" id="expires-raw">${D.expiresAt}</div>
</div>
<div id="rate-wrap">
<div class="rate-header">
<span class="rate-title">Live rate</span>
<span class="rate-live" id="rate-live-val">— req/min</span>
<span class="rate-limit-label">limit: ${D.ratePerMinute}/min ──</span>
</div>
<svg id="rate-chart" height="96"></svg>
</div>
<div id="map-wrap"><svg id="world-map"></svg></div>
<script>var __D = ${JSON.stringify(D)};</script>
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/topojson-client@3/dist/topojson-client.min.js"></script>
<script src="/admin.js"></script>
</body>
</html>`;
}
// ── 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
});
});