From 940debbbe96a2da94a5b610356ce0d6265fedee5 Mon Sep 17 00:00:00 2001 From: you Date: Tue, 24 Mar 2026 02:31:46 +0000 Subject: [PATCH] Add Playwright E2E test POC (8 tests against prod) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proof of concept: bare Playwright (not @playwright/test) running 8 critical flow tests against analyzer.00id.net: - Home page, nodes, map, packets, node detail, theme customizer, dark mode, analytics - Uses system Chromium on ARM (Playwright bundled binary doesn't work on musl) - Not added to test-all.sh or CI yet — POC only - Run with: node test-e2e-playwright.js --- package-lock.json | 299 ++++++++++++++++++++++++++++++++++++++++- package.json | 6 +- test-e2e-playwright.js | 175 ++++++++++++++++++++++++ 3 files changed, 477 insertions(+), 3 deletions(-) create mode 100644 test-e2e-playwright.js diff --git a/package-lock.json b/package-lock.json index b55e8ac8..3d009766 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "meshcore-analyzer", - "version": "1.0.0", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "meshcore-analyzer", - "version": "1.0.0", + "version": "2.6.0", "license": "ISC", "dependencies": { "@michaelhart/meshcore-decoder": "^0.2.7", @@ -14,6 +14,10 @@ "express": "^5.2.1", "mqtt": "^5.15.0", "ws": "^8.19.0" + }, + "devDependencies": { + "playwright": "^1.58.2", + "supertest": "^7.2.2" } }, "node_modules/@babel/runtime": { @@ -49,6 +53,29 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@types/node": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", @@ -116,6 +143,20 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -315,6 +356,19 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -330,6 +384,16 @@ "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==", "license": "MIT" }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -399,6 +463,13 @@ "node": ">=6.6.0" } }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/crypto-js": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", @@ -446,6 +517,16 @@ "node": ">=4.0.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -464,6 +545,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -532,6 +624,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -617,6 +725,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-unique-numbers": { "version": "9.0.27", "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz", @@ -657,6 +772,64 @@ "url": "https://opencollective.com/express" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -681,6 +854,21 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, + "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/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -766,6 +954,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -922,6 +1126,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -1112,6 +1339,38 @@ "url": "https://opencollective.com/express" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -1535,6 +1794,42 @@ "node": ">=0.10.0" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/package.json b/package.json index 04fabee8..92f1206b 100644 --- a/package.json +++ b/package.json @@ -17,5 +17,9 @@ "express": "^5.2.1", "mqtt": "^5.15.0", "ws": "^8.19.0" + }, + "devDependencies": { + "playwright": "^1.58.2", + "supertest": "^7.2.2" } -} \ No newline at end of file +} diff --git a/test-e2e-playwright.js b/test-e2e-playwright.js new file mode 100644 index 00000000..68ab46ae --- /dev/null +++ b/test-e2e-playwright.js @@ -0,0 +1,175 @@ +/** + * Playwright E2E tests — proof of concept + * Runs against prod (analyzer.00id.net), read-only. + * Usage: node test-e2e-playwright.js + */ +const { chromium } = require('playwright'); + +const BASE = 'https://analyzer.00id.net'; +const results = []; + +async function test(name, fn) { + try { + await fn(); + results.push({ name, pass: true }); + console.log(` ✅ ${name}`); + } catch (err) { + results.push({ name, pass: false, error: err.message }); + console.log(` ❌ ${name}: ${err.message}`); + } +} + +function assert(condition, msg) { + if (!condition) throw new Error(msg || 'Assertion failed'); +} + +async function run() { + console.log('Launching Chromium...'); + const browser = await chromium.launch({ + headless: true, + executablePath: process.env.CHROMIUM_PATH || '/usr/bin/chromium-browser', + args: ['--no-sandbox', '--disable-gpu', '--disable-dev-shm-usage'] + }); + const context = await browser.newContext(); + const page = await context.newPage(); + page.setDefaultTimeout(15000); + + console.log(`\nRunning E2E tests against ${BASE}\n`); + + // Test 1: Home page loads + await test('Home page loads', async () => { + await page.goto(BASE, { waitUntil: 'networkidle' }); + const title = await page.title(); + assert(title.toLowerCase().includes('meshcore'), `Title "${title}" doesn't contain MeshCore`); + const nav = await page.$('nav, .navbar, .nav, [class*="nav"]'); + assert(nav, 'Nav bar not found'); + }); + + // Test 2: Nodes page loads with data + await test('Nodes page loads with data', async () => { + await page.goto(`${BASE}/#/nodes`, { waitUntil: 'networkidle' }); + await page.waitForSelector('table tbody tr', { timeout: 15000 }); + await page.waitForTimeout(1000); // let SPA render + const headers = await page.$$eval('th', els => els.map(e => e.textContent.trim())); + for (const col of ['Name', 'Public Key', 'Role']) { + assert(headers.some(h => h.includes(col)), `Missing column: ${col}`); + } + assert(headers.some(h => h.includes('Last Seen') || h.includes('Last')), 'Missing Last Seen column'); + const rows = await page.$$('table tbody tr'); + assert(rows.length >= 10, `Expected >=10 nodes, got ${rows.length}`); + }); + + // Test 3: Map page loads with markers + await test('Map page loads with markers', async () => { + await page.goto(`${BASE}/#/map`, { waitUntil: 'networkidle' }); + await page.waitForSelector('.leaflet-container', { timeout: 10000 }); + await page.waitForSelector('.leaflet-tile-loaded', { timeout: 10000 }); + // Markers can be icons, SVG circles, or canvas-rendered; wait a bit for data + await page.waitForTimeout(3000); + const markers = await page.$$('.leaflet-marker-icon, .leaflet-interactive, circle, .marker-cluster, .leaflet-marker-pane > *, .leaflet-overlay-pane svg path, .leaflet-overlay-pane svg circle'); + assert(markers.length > 0, 'No map markers/overlays found'); + }); + + // Test 4: Packets page loads with filter + await test('Packets page loads with filter', async () => { + await page.goto(`${BASE}/#/packets`, { waitUntil: 'networkidle' }); + await page.waitForSelector('table tbody tr', { timeout: 10000 }); + const rowsBefore = await page.$$('table tbody tr'); + assert(rowsBefore.length > 0, 'No packets visible'); + // Use the specific filter input + const filterInput = await page.$('#packetFilterInput'); + assert(filterInput, 'Packet filter input not found'); + await filterInput.fill('type == ADVERT'); + await page.waitForTimeout(1500); + // Verify filter was applied (count may differ) + const rowsAfter = await page.$$('table tbody tr'); + assert(rowsAfter.length > 0, 'No packets after filtering'); + }); + + // Test 5: Node detail loads + await test('Node detail loads', async () => { + await page.goto(`${BASE}/#/nodes`, { waitUntil: 'networkidle' }); + await page.waitForSelector('table tbody tr', { timeout: 10000 }); + // Click first row + const firstRow = await page.$('table tbody tr'); + assert(firstRow, 'No node rows found'); + await firstRow.click(); + // Wait for side pane or detail + await page.waitForTimeout(1000); + const html = await page.content(); + // Check for status indicator + const hasStatus = html.includes('🟢') || html.includes('⚪') || html.includes('status') || html.includes('Active') || html.includes('Stale'); + assert(hasStatus, 'No status indicator found in node detail'); + }); + + // Test 6: Theme customizer opens + await test('Theme customizer opens', async () => { + await page.goto(BASE, { waitUntil: 'networkidle' }); + // Look for palette/customize button + const btn = await page.$('button[title*="ustom" i], button[aria-label*="theme" i], [class*="customize"], button:has-text("🎨")'); + if (!btn) { + // Try finding by emoji content + const allButtons = await page.$$('button'); + let found = false; + for (const b of allButtons) { + const text = await b.textContent(); + if (text.includes('🎨')) { + await b.click(); + found = true; + break; + } + } + assert(found, 'Could not find theme customizer button'); + } else { + await btn.click(); + } + await page.waitForTimeout(500); + const html = await page.content(); + const hasCustomizer = html.includes('preset') || html.includes('Preset') || html.includes('theme') || html.includes('Theme'); + assert(hasCustomizer, 'Customizer panel not found after clicking'); + }); + + // Test 7: Dark mode toggle + await test('Dark mode toggle', async () => { + await page.goto(BASE, { waitUntil: 'networkidle' }); + const themeBefore = await page.$eval('html', el => el.getAttribute('data-theme')); + // Find toggle button + const allButtons = await page.$$('button'); + let toggled = false; + for (const b of allButtons) { + const text = await b.textContent(); + if (text.includes('☀') || text.includes('🌙') || text.includes('🌑') || text.includes('🌕')) { + await b.click(); + toggled = true; + break; + } + } + assert(toggled, 'Could not find dark mode toggle button'); + await page.waitForTimeout(300); + const themeAfter = await page.$eval('html', el => el.getAttribute('data-theme')); + assert(themeBefore !== themeAfter, `Theme didn't change: before=${themeBefore}, after=${themeAfter}`); + }); + + // Test 8: Analytics page loads + await test('Analytics page loads', async () => { + await page.goto(`${BASE}/#/analytics`, { waitUntil: 'networkidle' }); + await page.waitForTimeout(2000); + const html = await page.content(); + // Check for any analytics content + const hasContent = html.includes('analytics') || html.includes('Analytics') || html.includes('tab') || html.includes('chart') || html.includes('topology'); + assert(hasContent, 'Analytics page has no recognizable content'); + }); + + await browser.close(); + + // Summary + const passed = results.filter(r => r.pass).length; + const failed = results.filter(r => !r.pass).length; + console.log(`\n${passed}/${results.length} tests passed${failed ? `, ${failed} failed` : ''}`); + process.exit(failed > 0 ? 1 : 0); +} + +run().catch(err => { + console.error('Fatal error:', err); + process.exit(1); +});