diff --git a/frontend-modern/browser-tests/diagnostics-integration.cjs b/frontend-modern/browser-tests/diagnostics-integration.cjs new file mode 100644 index 0000000000..cbdcaa1196 --- /dev/null +++ b/frontend-modern/browser-tests/diagnostics-integration.cjs @@ -0,0 +1,201 @@ +// Run the actual integration spec and full app shell offline. Only unrelated +// bootstrap/auth/state APIs are synthetic; the spec owns diagnostics responses. +// This is frontend integration proof, not backend authentication or appliance proof. +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const http = require('node:http'); +const { spawn } = require('node:child_process'); +const { chromium, webkit } = require('playwright'); +const { WebSocketServer } = require('/workspace/tests/integration/node_modules/ws'); +const root = '/workspace'; +const out = path.join(root, 'tmp/diagnostics-integration-proof'); +const origin = 'http://127.0.0.1:5393'; +const backendOrigin = 'http://127.0.0.1:5394'; +const state = { + resources: [], + connectedInfrastructure: [], + activeAlerts: [], + recentlyResolved: [], + lastUpdate: Date.now(), +}; +const bootstrap = { + '/api/health': { status: 'healthy' }, + '/api/state': state, + '/api/security/status': { + hasAuthentication: true, + requiresAuth: false, + settingsCapabilities: { + infrastructureRead: true, + systemSettingsRead: true, + apiAccessRead: true, + apiAccessWrite: true, + authenticationRead: true, + authenticationWrite: true, + }, + sessionCapabilities: { isAdmin: true }, + presentationPolicy: { + hideCommercial: true, + hideUpgrade: true, + demoMode: false, + readOnly: false, + }, + }, + '/api/config': { nodes: [], pbs: [], pmg: [], dockerHosts: [], theme: 'light' }, + '/api/version': { version: '6.4.5' }, + '/api/updates/check': { currentVersion: '6.4.5', latestVersion: '6.4.5', updateAvailable: false }, + '/api/license/status': { valid: false, tier: 'free', features: [] }, + '/api/ai/settings': { enabled: false }, + '/api/alerts/active': [], +}; +(async () => { + fs.mkdirSync(out, { recursive: true }); + const version = require('playwright/package.json').version; + const lock = JSON.parse(fs.readFileSync(path.join(root, 'tests/integration/package-lock.json'))); + if (version !== lock.packages['node_modules/@playwright/test'].version) + throw new Error('Playwright version mismatch'); + const paths = [ + 'tests/integration/tests/69-diagnostics-onboarding.spec.ts', + 'tests/integration/playwright.config.ts', + 'frontend-modern/src/App.tsx', + 'frontend-modern/src/components/Settings/DiagnosticsPanel.tsx', + 'frontend-modern/src/components/Settings/DiagnosticsResultsPanel.tsx', + 'frontend-modern/src/components/Settings/diagnosticsModel.ts', + 'frontend-modern/src/components/Settings/useDiagnosticsPanelState.ts', + 'frontend-modern/src/utils/diagnosticsPresentation.ts', + 'frontend-modern/package-lock.json', + 'tests/integration/package-lock.json', + 'frontend-modern/browser-tests/diagnostics-integration.cjs', + ]; + const source = Object.fromEntries( + paths.map((p) => [ + p, + crypto + .createHash('sha256') + .update(fs.readFileSync(path.join(root, p))) + .digest('hex'), + ]), + ); + const requests = []; + const backend = http.createServer((req, res) => { + const pathname = new URL(req.url, backendOrigin).pathname; + requests.push({ method: req.method, path: pathname }); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(bootstrap[pathname] ?? {})); + }); + const sockets = new WebSocketServer({ server: backend }); + sockets.on('connection', (socket) => { + socket.send(JSON.stringify({ type: 'initialState', data: state })); + socket.on('message', () => socket.send(JSON.stringify({ type: 'pong' }))); + }); + process.env.PULSE_DEV_API_URL = backendOrigin; + process.env.PULSE_DEV_WS_URL = backendOrigin.replace('http:', 'ws:'); + const frontend = path.join(root, 'frontend-modern'); + const { createServer } = await import( + path.join(frontend, 'node_modules/vite/dist/node/index.js') + ); + const server = await createServer({ + root: frontend, + configFile: path.join(frontend, 'vite.config.ts'), + cacheDir: path.join(out, 'vite-cache'), + server: { host: '127.0.0.1', port: 5393, strictPort: true }, + }); + // A cookie-backed synthetic bootstrap fixture avoids password/auth mutation. + const cookieState = path.join(out, 'synthetic-session.json'); + fs.writeFileSync( + cookieState, + JSON.stringify({ + cookies: [ + { + name: 'pulse_session', + value: 'synthetic-offline-only', + domain: '127.0.0.1', + path: '/', + expires: -1, + httpOnly: true, + secure: false, + sameSite: 'Lax', + }, + ], + origins: [], + }), + ); + const config = path.join(out, 'playwright.config.cjs'); + fs.writeFileSync( + config, + ` + const { devices } = require('/workspace/tests/integration/node_modules/@playwright/test'); + module.exports = { + testDir: '/workspace/tests/integration/tests', testMatch: '69-diagnostics-onboarding.spec.ts', + workers: 1, retries: 0, timeout: 180000, expect: { timeout: 20000 }, + outputDir: '${out}/results', reporter: [['list'], ['json', { outputFile: '${out}/result.json' }]], + use: { baseURL: '${origin}', trace: 'off', screenshot: 'only-on-failure', acceptDownloads: true }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'], launchOptions: { executablePath: ${JSON.stringify(chromium.executablePath())}, args: ['--no-sandbox'] } } }, + { name: 'mobile-chrome', use: { ...devices['Pixel 5'], launchOptions: { executablePath: ${JSON.stringify(chromium.executablePath())}, args: ['--no-sandbox'] } } }, + { name: 'mobile-safari', use: { ...devices['iPhone 12'], launchOptions: { executablePath: ${JSON.stringify(webkit.executablePath())} } } }, + ], + }; + `, + ); + let result, cleanup; + try { + await new Promise((resolve) => backend.listen(5394, '127.0.0.1', resolve)); + await server.listen(); + result = await new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [ + path.join(root, 'tests/integration/node_modules/@playwright/test/cli.js'), + 'test', + '--config', + config, + ], + { + cwd: path.join(root, 'tests/integration'), + env: { + ...process.env, + PLAYWRIGHT_BASE_URL: origin, + PULSE_E2E_COOKIE_STATE_PATH: cookieState, + PULSE_E2E_REQUIRE_DEFAULT_MOCK_READY: 'false', + PULSE_E2E_RUNTIME_STATE_PATH: path.join(out, 'unused-runtime.json'), + }, + stdio: 'inherit', + }, + ); + child.on('error', reject); + child.on('exit', (exit, signal) => resolve({ exit, signal })); + }); + } finally { + for (const socket of sockets.clients) socket.terminate(); + await new Promise((resolve) => sockets.close(resolve)); + await server.close(); + await new Promise((resolve) => backend.close(resolve)); + cleanup = { + frontendClosed: true, + socketsClosed: true, + backendClosed: true, + runnerTerminal: Boolean(result), + }; + fs.writeFileSync( + path.join(out, 'receipt.json'), + JSON.stringify( + { + source, + playwright: version, + result, + cleanup, + boundary: + 'Actual full app shell and exact integration spec; synthetic offline session/bootstrap/state, no native API/guest-agent requests or production authentication.', + requests, + }, + null, + 2, + ) + '\n', + ); + } + if (result.exit !== 0 || result.signal) throw new Error('Diagnostics integration failed'); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/integration/tests/69-diagnostics-onboarding.spec.ts b/tests/integration/tests/69-diagnostics-onboarding.spec.ts index 8f307545c0..6852c0833f 100644 --- a/tests/integration/tests/69-diagnostics-onboarding.spec.ts +++ b/tests/integration/tests/69-diagnostics-onboarding.spec.ts @@ -1,8 +1,8 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { expect, test as base, type Page } from '@playwright/test'; -import { createAuthenticatedStorageState } from './helpers'; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { expect, test as base, type Page } from "@playwright/test"; +import { createAuthenticatedStorageState } from "./helpers"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -10,6 +10,11 @@ type WorkerFixtures = { authStorageStatePath: string; }; +type DiagnosticsTraffic = { + reads: number; + writes: string[]; +}; + type OverflowAudit = { viewportWidth: number; pageWidth: number; @@ -18,22 +23,22 @@ type OverflowAudit = { }; const DIAGNOSTICS_PAYLOAD = { - version: '6.0.0', - runtime: 'go', + version: "6.0.0", + runtime: "go", uptime: 3600, nodes: [], pbs: [], system: { - os: 'linux', - arch: 'amd64', - goVersion: 'go1.25', + os: "linux", + arch: "amd64", + goVersion: "go1.25", numCPU: 8, numGoroutine: 32, memoryMB: 128, }, metricsStore: { enabled: true, - status: 'healthy', + status: "healthy", dbSize: 4 * 1024 * 1024, rawCount: 12, minuteCount: 24, @@ -45,7 +50,7 @@ const DIAGNOSTICS_PAYLOAD = { }, commercialFunnel: { enabled: true, - status: 'active', + status: "active", windowDays: 30, summary: { pricing_viewed: 3, @@ -58,13 +63,13 @@ const DIAGNOSTICS_PAYLOAD = { license_activated: 1, license_activation_failed: 0, period: { - from: '2026-03-19T00:00:00Z', - to: '2026-04-18T00:00:00Z', + from: "2026-03-19T00:00:00Z", + to: "2026-04-18T00:00:00Z", }, }, daily: [ { - day: '2026-04-17', + day: "2026-04-17", pricing_viewed: 1, paywall_viewed: 0, trial_started: 0, @@ -78,7 +83,7 @@ const DIAGNOSTICS_PAYLOAD = { ], surfaces: [ { - key: 'settings_self_hosted_billing_compare_prompt', + key: "settings_self_hosted_billing_compare_prompt", pricing_viewed: 0, paywall_viewed: 0, trial_started: 0, @@ -92,7 +97,7 @@ const DIAGNOSTICS_PAYLOAD = { ], capabilities: [ { - key: 'self_hosted_plan', + key: "self_hosted_plan", pricing_viewed: 3, paywall_viewed: 0, trial_started: 0, @@ -104,11 +109,13 @@ const DIAGNOSTICS_PAYLOAD = { license_activation_failed: 0, }, ], - notes: ['Local pricing and activation events show at least one completed conversion.'], + notes: [ + "Local pricing and activation events show at least one completed conversion.", + ], }, infrastructureOnboarding: { enabled: true, - status: 'warning', + status: "warning", windowDays: 30, summary: { opened: 4, @@ -120,13 +127,13 @@ const DIAGNOSTICS_PAYLOAD = { catalog_selected: 2, credentials_opened: 1, period: { - from: '2026-03-19T00:00:00Z', - to: '2026-04-18T00:00:00Z', + from: "2026-03-19T00:00:00Z", + to: "2026-04-18T00:00:00Z", }, }, daily: [ { - day: '2026-04-17', + day: "2026-04-17", opened: 2, api_path_selected: 1, agent_path_selected: 1, @@ -137,7 +144,7 @@ const DIAGNOSTICS_PAYLOAD = { credentials_opened: 0, }, { - day: '2026-04-18', + day: "2026-04-18", opened: 2, api_path_selected: 1, agent_path_selected: 0, @@ -149,18 +156,18 @@ const DIAGNOSTICS_PAYLOAD = { }, ], paths: [ - { key: 'api', count: 2 }, - { key: 'agent', count: 1 }, + { key: "api", count: 2 }, + { key: "agent", count: 1 }, ], - platforms: [ - { key: 'truenas', catalog_selected: 2, credentials_opened: 1 }, + platforms: [{ key: "truenas", catalog_selected: 2, credentials_opened: 1 }], + notes: [ + "More probed addresses miss than detect a supported API-backed platform.", ], - notes: ['More probed addresses miss than detect a supported API-backed platform.'], }, discovery: { enabled: true, - configuredSubnet: 'auto', - scanInterval: '5m', + configuredSubnet: "auto", + scanInterval: "5m", lastResultServers: 3, }, apiTokens: { @@ -202,79 +209,168 @@ const EXPORT_DIAGNOSTICS_PAYLOAD = { ...DIAGNOSTICS_PAYLOAD, nodes: [ { - id: 'node-raw-id', - name: 'pve-01', - host: '10.0.0.5', - type: 'pve', - authMethod: 'token', + id: "node-raw-id", + name: "pve-01", + host: "10.0.0.5", + type: "pve", + authMethod: "token", connected: false, - error: 'dial tcp 10.0.0.5:8006: connect: connection refused', + error: "dial tcp 10.0.0.5:8006: connect: connection refused", }, ], discovery: { enabled: true, - configuredSubnet: '10.0.0.0/24', - activeSubnet: '10.0.1.0/24', - environmentOverride: 'PULSE_DISCOVERY_SUBNET=10.0.2.0/24', - subnetAllowlist: ['10.0.0.0/24'], - subnetBlocklist: ['10.0.3.0/24'], + configuredSubnet: "10.0.0.0/24", + activeSubnet: "10.0.1.0/24", + environmentOverride: "PULSE_DISCOVERY_SUBNET=10.0.2.0/24", + subnetAllowlist: ["10.0.0.0/24"], + subnetBlocklist: ["10.0.3.0/24"], history: [ { - startedAt: '2026-04-17T10:00:00Z', - completedAt: '2026-04-17T10:00:05Z', - duration: '5s', + startedAt: "2026-04-17T10:00:00Z", + completedAt: "2026-04-17T10:00:05Z", + duration: "5s", durationMs: 5000, - subnet: '10.0.0.0/24', + subnet: "10.0.0.0/24", serverCount: 3, errorCount: 1, blocklistLength: 1, - status: 'completed', + status: "completed", }, ], }, - errors: ['probe failed for 10.0.0.10 after timeout'], + apiTokens: { + ...DIAGNOSTICS_PAYLOAD.apiTokens, + tokens: [ + { + id: "synthetic-private-token-a", + name: "synthetic-private-purpose-a", + hint: "synthetic-private-hint-a", + }, + { + id: "synthetic-private-token-b", + name: "synthetic-private-purpose-b", + hint: "synthetic-private-hint-b", + }, + ], + // Deliberately reverse token order: aliases must follow identity, not position. + usage: [ + { + tokenId: "synthetic-private-token-b", + agentCount: 1, + agents: ["synthetic-private-agent"], + }, + { + tokenId: "synthetic-private-token-a", + agentCount: 1, + agents: ["synthetic-private-agent"], + }, + ], + }, + dockerAgents: { + ...DIAGNOSTICS_PAYLOAD.dockerAgents, + attention: [ + { + agentId: "synthetic-private-agent-id", + name: "synthetic-private-agent", + tokenHint: "synthetic-private-hint", + issues: [], + }, + ], + }, + nodeSnapshots: [ + { + instance: "synthetic-private-instance", + node: "synthetic-private-node", + memorySource: "agent", + memory: { used: 256 }, + raw: { total: 512 }, + }, + ], + guestSnapshots: [ + { + instance: "synthetic-private-instance", + node: "synthetic-private-node", + name: "synthetic-private-guest", + vmid: 9501, + guestType: "vm", + memorySource: "agent", + memory: { used: 128 }, + raw: { hostAgentUsed: 128 }, + notes: [], + }, + ], + memorySources: [ + { instance: "synthetic-private-instance", scope: "node", count: 1 }, + ], + memorySourceBreakdown: [ + { + instance: "synthetic-private-instance", + scope: "node", + source: "agent", + trust: "host", + count: 1, + fallback: false, + fallbackReasons: [], + }, + ], + errors: ["probe failed for 10.0.0.10 after timeout"], }; const test = base.extend<{}, WorkerFixtures>({ storageState: async ({ authStorageStatePath }, use) => { await use(authStorageStatePath); }, - authStorageStatePath: [async ({ browser }, use, workerInfo) => { - const storageStatePath = path.resolve( - __dirname, - '..', - '..', - 'tmp', - 'playwright-auth', - `diagnostics-onboarding-${workerInfo.project.name}.json`, - ); - fs.mkdirSync(path.dirname(storageStatePath), { recursive: true }); - await createAuthenticatedStorageState(browser, storageStatePath); - try { - await use(storageStatePath); - } finally { - fs.rmSync(storageStatePath, { force: true }); - } - }, { scope: 'worker' }], + authStorageStatePath: [ + async ({ browser }, use, workerInfo) => { + const storageStatePath = path.resolve( + __dirname, + "..", + "..", + "tmp", + "playwright-auth", + `diagnostics-onboarding-${workerInfo.project.name}.json`, + ); + fs.mkdirSync(path.dirname(storageStatePath), { recursive: true }); + await createAuthenticatedStorageState(browser, storageStatePath); + try { + await use(storageStatePath); + } finally { + fs.rmSync(storageStatePath, { force: true }); + } + }, + { scope: "worker" }, + ], }); async function prepareDiagnosticsRoute( page: Page, - payload = DIAGNOSTICS_PAYLOAD, -): Promise { - await page.route('**/api/diagnostics', async (route) => { + payload: typeof DIAGNOSTICS_PAYLOAD | typeof EXPORT_DIAGNOSTICS_PAYLOAD = + DIAGNOSTICS_PAYLOAD, +): Promise { + const traffic: DiagnosticsTraffic = { reads: 0, writes: [] }; + page.on("request", (request) => { + if (!["GET", "HEAD"].includes(request.method())) + traffic.writes.push(new URL(request.url()).pathname); + }); + await page.route("**/api/diagnostics", async (route) => { const requestUrl = new URL(route.request().url()); - if (route.request().method() !== 'GET' || requestUrl.pathname !== '/api/diagnostics') { + if ( + route.request().method() !== "GET" || + requestUrl.pathname !== "/api/diagnostics" + ) { await route.continue(); return; } + traffic.reads += 1; await route.fulfill({ status: 200, - contentType: 'application/json', + contentType: "application/json", body: JSON.stringify(payload), }); }); + return traffic; } async function scrollToBottom(page: Page): Promise { @@ -299,7 +395,10 @@ async function scrollToBottom(page: Page): Promise { async function auditHorizontalOverflow(page: Page): Promise { return page.evaluate(() => { - const viewportWidth = Math.max(document.documentElement.clientWidth, window.innerWidth || 0); + const viewportWidth = Math.max( + document.documentElement.clientWidth, + window.innerWidth || 0, + ); const pageWidth = Math.max( document.body.scrollWidth, document.documentElement.scrollWidth, @@ -307,21 +406,27 @@ async function auditHorizontalOverflow(page: Page): Promise { document.documentElement.offsetWidth, ); - const offenders = Array.from(document.querySelectorAll('body *')) + const offenders = Array.from(document.querySelectorAll("body *")) .map((el) => { const rect = el.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return null; const style = window.getComputedStyle(el); - if (style.position === 'fixed' || style.position === 'absolute') return null; + if (style.position === "fixed" || style.position === "absolute") + return null; const overflow = rect.right - viewportWidth; if (overflow <= 1) return null; return { tag: el.tagName.toLowerCase(), - className: (el.getAttribute('class') || '').trim().slice(0, 120), + className: (el.getAttribute("class") || "").trim().slice(0, 120), overflow: Number(overflow.toFixed(1)), }; }) - .filter((entry): entry is { tag: string; className: string; overflow: number } => Boolean(entry)) + .filter( + ( + entry, + ): entry is { tag: string; className: string; overflow: number } => + Boolean(entry), + ) .slice(0, 8); return { @@ -333,130 +438,320 @@ async function auditHorizontalOverflow(page: Page): Promise { }); } -test.describe('Diagnostics onboarding analytics', () => { +async function openDiagnostics(page: Page): Promise { + await page.goto("/settings/support/diagnostics", { + waitUntil: "domcontentloaded", + }); + await page.waitForURL(/\/settings\/support\/diagnostics$/, { + timeout: 15_000, + }); + await expect( + page.getByRole("heading", { name: "Diagnostics & Health" }), + ).toBeVisible(); +} + +async function describedBy( + button: ReturnType, +): Promise { + return button.evaluate((element) => + (element.getAttribute("aria-describedby") || "") + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent || "") + .join(" "), + ); +} + +async function downloadJSON( + page: Page, + label: string, + kind: string, + keyboard = false, +) { + const button = page.getByRole("button", { name: label, exact: true }); + await expect(button).toBeEnabled(); + // Register the observer before activation. Keyboard and pointer use the same + // public accessible control, on desktop and both mobile projects. + const pending = page.waitForEvent("download"); + if (keyboard) { + await button.focus(); + await expect(button).toBeFocused(); + await button.press("Enter"); + } else { + await button.click(); + } + const download = await pending; + expect(download.suggestedFilename()).toMatch( + new RegExp(`^pulse-diagnostics-${kind}-\\d{4}-\\d{2}-\\d{2}\\.json$`), + ); + const file = await download.path(); + expect(file).not.toBeNull(); + return JSON.parse(fs.readFileSync(file!, "utf8")); +} + +async function assertControlsInViewport( + page: Page, + mobile: boolean, +): Promise { + const audit = await auditHorizontalOverflow(page); + expect( + audit.pageWidth, + `Diagnostics overflow: ${JSON.stringify(audit)}`, + ).toBeLessThanOrEqual(audit.viewportWidth + 1); + for (const button of await page + .getByRole("button", { + name: /Run Diagnostics|Full \(private\)|GitHub \(review first\)/, + }) + .all()) { + const bounds = await button.boundingBox(); + expect(bounds).not.toBeNull(); + expect(bounds!.x).toBeGreaterThanOrEqual(0); + expect(bounds!.x + bounds!.width).toBeLessThanOrEqual( + audit.viewportWidth + 1, + ); + if (mobile) expect(bounds!.height).toBeGreaterThanOrEqual(44); + } +} + +test.describe("Diagnostics safe collection and local exports", () => { test.setTimeout(180_000); - // The Commercial Funnel and Infrastructure Onboarding analytics cards were - // deliberately removed from the user-facing diagnostics page (c6bcad255, - // "Remove internal analytics from diagnostics"); the UI strips - // commercialFunnel and infrastructureOnboarding from the payload before both - // rendering and export. These specs pin that boundary. - test('keeps internal onboarding analytics out of the rendered diagnostics page', async ({ + test("keeps internal analytics absent and connects safety guidance to keyboard-accessible controls", async ({ page, }, testInfo) => { - test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop-only diagnostics shell coverage'); - - await prepareDiagnosticsRoute(page); - - await page.goto('/settings/support/diagnostics', { waitUntil: 'domcontentloaded' }); - await page.waitForURL(/\/settings\/support\/diagnostics$/, { timeout: 15_000 }); - - await expect(page.getByRole('heading', { name: 'Diagnostics & Health' })).toBeVisible(); - await page.getByRole('button', { name: 'Run Diagnostics', exact: true }).first().click(); - - // The run completed and the payload rendered (metrics-store card proves it). - await expect(page.getByText('Metrics Store', { exact: false }).first()).toBeVisible({ - timeout: 30_000, + const traffic = await prepareDiagnosticsRoute(page); + await openDiagnostics(page); + expect(traffic.reads).toBe(0); + await expect( + page.getByRole("button", { name: "Full (private)", exact: true }), + ).toHaveCount(0); + // Both the toolbar and empty-state run actions need the same warning. + const actions = page.getByRole("button", { + name: "Run Diagnostics", + exact: true, }); - - await expect(page.getByText('Commercial Funnel', { exact: true })).toHaveCount(0); - await expect(page.getByText('Infrastructure Onboarding', { exact: true })).toHaveCount(0); - await expect(page.getByText('Credentials Opened', { exact: true })).toHaveCount(0); + await expect(actions).toHaveCount(2); + for (const button of await actions.all()) { + expect(await describedBy(button)).toMatch( + /Do not run it during a backup, freeze\/thaw/, + ); + } + await expect( + page.getByRole("link", { name: "Safe diagnostics and sharing" }), + ).toHaveAttribute("href", /#collect-diagnostics-safely$/); + await actions.first().focus(); + await expect(actions.first()).toBeFocused(); + await actions.first().press("Enter"); + await expect( + page.getByText("Metrics Store", { exact: false }).first(), + ).toBeVisible(); + expect(traffic.reads).toBe(1); + for (const label of [ + "Commercial Funnel", + "Infrastructure Onboarding", + "Credentials Opened", + ]) { + await expect(page.getByText(label, { exact: true })).toHaveCount(0); + } + for (const label of ["Full (private)", "GitHub (review first)"]) { + expect( + await describedBy( + page.getByRole("button", { name: label, exact: true }), + ), + ).toMatch( + /Nothing is uploaded.*Keep the full file private.*Review even a sanitised file/, + ); + } + await assertControlsInViewport( + page, + testInfo.project.name.startsWith("mobile-"), + ); }); - test('desktop diagnostics exports full and sanitized onboarding analytics JSON', async ({ + test("downloads the displayed private and review-first JSON without another live request or upload", async ({ page, }, testInfo) => { - test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop-only diagnostics export coverage'); + const traffic = await prepareDiagnosticsRoute( + page, + EXPORT_DIAGNOSTICS_PAYLOAD, + ); + await openDiagnostics(page); + await page + .getByRole("button", { name: "Run Diagnostics", exact: true }) + .first() + .click(); + await expect( + page.getByText("Metrics Store", { exact: false }).first(), + ).toBeVisible(); + const writesBeforeExport = [...traffic.writes]; - await prepareDiagnosticsRoute(page, EXPORT_DIAGNOSTICS_PAYLOAD); + const full = await downloadJSON(page, "Full (private)", "full", true); + const { + infrastructureOnboarding: _onboarding, + commercialFunnel: _funnel, + ...expectedFull + } = EXPORT_DIAGNOSTICS_PAYLOAD; + expect(full).toEqual(expectedFull); + expect(traffic.reads).toBe(1); + expect(traffic.writes).toEqual(writesBeforeExport); + await expect( + page.getByRole("heading", { + name: "Full diagnostics downloaded — keep this file private", + exact: true, + }), + ).toBeVisible(); - await page.goto('/settings/support/diagnostics', { waitUntil: 'domcontentloaded' }); - await page.waitForURL(/\/settings\/support\/diagnostics$/, { timeout: 15_000 }); - - await page.getByRole('button', { name: 'Run Diagnostics', exact: true }).first().click(); - // Wait for a rendered card that survives the analytics strip. - await expect(page.getByText('Metrics Store', { exact: false }).first()).toBeVisible({ - timeout: 30_000, + const sanitised = await downloadJSON( + page, + "GitHub (review first)", + "sanitized", + ); + expect(sanitised.nodes[0]).toMatchObject({ + id: "node-1", + name: "node-1", + host: "node-1", }); - - const [fullDownload] = await Promise.all([ - page.waitForEvent('download'), - page.getByRole('button', { name: 'Full', exact: true }).click(), + expect(sanitised.discovery).toMatchObject({ + configuredSubnet: "[REDACTED_SUBNET]", + activeSubnet: "[REDACTED_SUBNET]", + environmentOverride: "[REDACTED]", + subnetAllowlist: ["[REDACTED_SUBNET]"], + subnetBlocklist: ["[REDACTED_SUBNET]"], + history: [ + expect.objectContaining({ + subnet: "[REDACTED_SUBNET]", + serverCount: 3, + }), + ], + }); + expect(sanitised.errors).toEqual([ + "probe failed for [REDACTED_IP] after timeout", ]); - - expect(fullDownload.suggestedFilename()).toMatch( - new RegExp(`^pulse-diagnostics-full-${new Date().toISOString().slice(0, 10)}\\.json$`), + expect(sanitised.apiTokens.tokens[0]).toMatchObject({ + id: "token-1", + name: "token-1", + hint: "[REDACTED]", + }); + expect(sanitised.apiTokens.usage[0].tokenId).toBe( + sanitised.apiTokens.tokens[1].id, ); - - const fullPath = await fullDownload.path(); - expect(fullPath).not.toBeNull(); - const fullPayload = JSON.parse(fs.readFileSync(fullPath!, 'utf8')) as typeof EXPORT_DIAGNOSTICS_PAYLOAD; - - expect(fullPayload.nodes[0]?.host).toBe('10.0.0.5'); - expect(fullPayload.discovery?.configuredSubnet).toBe('10.0.0.0/24'); - // Internal analytics are stripped from the full export too, not just the - // rendered card. - expect(fullPayload.infrastructureOnboarding).toBeUndefined(); - expect( - (fullPayload as Record).commercialFunnel, - ).toBeUndefined(); - - const [sanitizedDownload] = await Promise.all([ - page.waitForEvent('download'), - page.getByRole('button', { name: 'GitHub', exact: true }).click(), - ]); - - expect(sanitizedDownload.suggestedFilename()).toMatch( - new RegExp(`^pulse-diagnostics-sanitized-${new Date().toISOString().slice(0, 10)}\\.json$`), + expect(sanitised.apiTokens.usage[1].tokenId).toBe( + sanitised.apiTokens.tokens[0].id, ); - - const sanitizedPath = await sanitizedDownload.path(); - expect(sanitizedPath).not.toBeNull(); - const sanitizedPayload = JSON.parse(fs.readFileSync(sanitizedPath!, 'utf8')) as typeof EXPORT_DIAGNOSTICS_PAYLOAD; - - expect(sanitizedPayload.nodes[0]).toEqual( - expect.objectContaining({ - id: 'node-1', - name: 'node-1', - host: 'node-1', + expect(sanitised.apiTokens.usage[0].agents).toEqual( + sanitised.apiTokens.usage[1].agents, + ); + expect(sanitised.apiTokens.usage[0].agents[0]).toBe( + sanitised.dockerAgents.attention[0].name, + ); + expect(sanitised.guestSnapshots[0]).toMatchObject({ + instance: sanitised.nodeSnapshots[0].instance, + node: sanitised.nodeSnapshots[0].node, + }); + expect(sanitised.guestSnapshots[0].vmid).toBeUndefined(); + expect(sanitised.guestSnapshots[0].memory).toEqual( + EXPORT_DIAGNOSTICS_PAYLOAD.guestSnapshots[0].memory, + ); + expect(sanitised.memorySources[0].instance).toBe( + sanitised.nodeSnapshots[0].instance, + ); + expect(sanitised.memorySourceBreakdown[0].instance).toBe( + sanitised.nodeSnapshots[0].instance, + ); + for (const payload of [full, sanitised]) { + expect(payload.infrastructureOnboarding).toBeUndefined(); + expect(payload.commercialFunnel).toBeUndefined(); + expect(payload.metricsStore).toEqual( + EXPORT_DIAGNOSTICS_PAYLOAD.metricsStore, + ); + } + expect(JSON.stringify(sanitised)).not.toContain("synthetic-private-"); + expect(JSON.stringify(sanitised)).not.toMatch(/10\.0\.[0-3]\./); + expect(traffic.reads).toBe(1); + expect(traffic.writes).toEqual(writesBeforeExport); + await expect( + page.getByRole("heading", { + name: "Sanitised diagnostics downloaded — review before sharing", + exact: true, }), + ).toBeVisible(); + // A full export after sanitisation must still be the original displayed + // data: redaction may not mutate it. + expect(await downloadJSON(page, "Full (private)", "full")).toEqual( + expectedFull, ); - expect(sanitizedPayload.discovery).toEqual( - expect.objectContaining({ - configuredSubnet: '[REDACTED_SUBNET]', - activeSubnet: '[REDACTED_SUBNET]', - environmentOverride: '[REDACTED]', - }), + expect(traffic.reads).toBe(1); + expect(traffic.writes).toEqual(writesBeforeExport); + await assertControlsInViewport( + page, + testInfo.project.name.startsWith("mobile-"), ); - expect(sanitizedPayload.errors).toEqual(['probe failed for [REDACTED_IP] after timeout']); - expect(sanitizedPayload.infrastructureOnboarding).toBeUndefined(); - expect( - (sanitizedPayload as Record).commercialFunnel, - ).toBeUndefined(); + await page.screenshot({ + path: testInfo.outputPath("diagnostics-safe-export.png"), + fullPage: true, + }); }); - test('mobile diagnostics keeps the populated onboarding analytics page inside the viewport', async ({ + test("disables exports during a refresh and retains the previous result after a failed live check", async ({ page, }, testInfo) => { - test.skip(!testInfo.project.name.startsWith('mobile-'), 'Mobile-only diagnostics overflow coverage'); - - await prepareDiagnosticsRoute(page); - - await page.goto('/settings/support/diagnostics', { waitUntil: 'domcontentloaded' }); - await page.waitForURL(/\/settings\/support\/diagnostics$/, { timeout: 15_000 }); - - await page.getByRole('button', { name: 'Run', exact: true }).click(); - await expect(page.getByText('Metrics Store', { exact: false }).first()).toBeVisible({ - timeout: 30_000, + let reads = 0; + let release: (() => void) | undefined; + const held = new Promise((resolve) => { + release = resolve; }); - + await page.route("**/api/diagnostics", async (route) => { + reads += 1; + if (reads === 1) return route.fulfill({ json: DIAGNOSTICS_PAYLOAD }); + await held; + return route.fulfill({ + status: 400, + json: { error: "Synthetic live check failed" }, + }); + }); + await openDiagnostics(page); + const run = page + .getByRole("button", { name: "Run Diagnostics", exact: true }) + .first(); + await run.click(); + const full = page.getByRole("button", { + name: "Full (private)", + exact: true, + }); + const github = page.getByRole("button", { + name: "GitHub (review first)", + exact: true, + }); + await expect(full).toBeEnabled(); + await run.focus(); + await run.press("Space"); + await expect( + page.getByRole("button", { name: "Running...", exact: true }), + ).toBeDisabled(); + await expect(full).toBeDisabled(); + await expect(github).toBeDisabled(); + expect(reads).toBe(2); + release!(); + await expect( + page.getByRole("heading", { + name: "Synthetic live check failed", + exact: true, + }), + ).toBeVisible(); + await expect(full).toBeEnabled(); + await expect(github).toBeEnabled(); + const { + infrastructureOnboarding: _onboarding, + commercialFunnel: _funnel, + ...expectedFull + } = DIAGNOSTICS_PAYLOAD; + expect(await downloadJSON(page, "Full (private)", "full")).toEqual( + expectedFull, + ); + expect(reads).toBe(2); await scrollToBottom(page); - const audit = await auditHorizontalOverflow(page); - - expect( - audit.pageWidth, - `Mobile diagnostics overflow (viewport=${audit.viewportWidth}, page=${audit.pageWidth}, offenders=${JSON.stringify(audit.offenders)})`, - ).toBeLessThanOrEqual(audit.viewportWidth + 1); + await assertControlsInViewport( + page, + testInfo.project.name.startsWith("mobile-"), + ); }); }); diff --git a/tests/integration/tsconfig.json b/tests/integration/tsconfig.json index d3c14424b4..bb62f0ebbb 100644 --- a/tests/integration/tsconfig.json +++ b/tests/integration/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2020", "module": "ES2020", "lib": ["ES2020", "DOM"], - "moduleResolution": "node", + "moduleResolution": "bundler", "esModuleInterop": true, "strict": true, "skipLibCheck": true,