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>
This commit is contained in:
Generated
+76
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"name": "apix-admin-tests",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "apix-admin-tests",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.44.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "apix-admin-tests",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:headed": "playwright test --headed",
|
||||||
|
"test:report": "playwright show-report"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.44.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// @ts-check
|
||||||
|
const { defineConfig } = require('@playwright/test');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
timeout: 30_000,
|
||||||
|
retries: 0,
|
||||||
|
reporter: [['line'], ['html', { open: 'never' }]],
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.APIX_ADMIN_URL || 'http://localhost:8084',
|
||||||
|
// Cookie set by the auth filter; value must match APIX_API_KEY on the registry
|
||||||
|
storageState: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
// @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
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user