From 7ccc384ebce5fb9bcd8c5cf4e81baa91e090ec64 Mon Sep 17 00:00:00 2001 From: Carsten Rehfeld Date: Sun, 17 May 2026 02:14:02 +0200 Subject: [PATCH] =?UTF-8?q?test(admin):=20Playwright=20chart=20tests=20?= =?UTF-8?q?=E2=80=94=2011=20scenarios,=20fully=20mocked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apix-admin/package-lock.json | 76 ++++++ apix-admin/package.json | 12 + apix-admin/playwright.config.js | 14 ++ apix-admin/tests/chart.spec.js | 408 ++++++++++++++++++++++++++++++++ 4 files changed, 510 insertions(+) create mode 100644 apix-admin/package-lock.json create mode 100644 apix-admin/package.json create mode 100644 apix-admin/playwright.config.js create mode 100644 apix-admin/tests/chart.spec.js 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) + + + + + +
+ ${D.name} + ${D.tier} + ${D.sandboxId} +
+ +
+
Rate limit
+
${D.ratePerMinute}
+
now: req/min
+
+
+
Expires
+
+
${D.expiresAt}
+
+ +
+
+ Live rate + — req/min + limit: ${D.ratePerMinute}/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 + }); +});