diff --git a/apix-admin/package-lock.json b/apix-admin/package-lock.json
new file mode 100644
index 0000000..8366338
--- /dev/null
+++ b/apix-admin/package-lock.json
@@ -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"
+ }
+ }
+ }
+}
diff --git a/apix-admin/package.json b/apix-admin/package.json
new file mode 100644
index 0000000..f28fc3e
--- /dev/null
+++ b/apix-admin/package.json
@@ -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"
+ }
+}
diff --git a/apix-admin/playwright.config.js b/apix-admin/playwright.config.js
new file mode 100644
index 0000000..e53cbf4
--- /dev/null
+++ b/apix-admin/playwright.config.js
@@ -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,
+ },
+});
diff --git a/apix-admin/tests/chart.spec.js b/apix-admin/tests/chart.spec.js
new file mode 100644
index 0000000..ffba8aa
--- /dev/null
+++ b/apix-admin/tests/chart.spec.js
@@ -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 `
+
+
+
+${D.name} · APIX Admin (test)
+
+
+
+
+
+
+
+
+
Rate limit
+
${D.ratePerMinute}
+
now: — req/min
+
+
+
Expires
+
—
+
${D.expiresAt}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
+}
+
+// ── 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
+ });
+});