@@ -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
} ) ;
} ) ;