From ed168c8d7ed9e0ba77f1c09e6cafbf4b6be05461 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:14:06 +0100 Subject: [PATCH 1/3] Verify safe Diagnostics exports across desktop and mobile journeys Replace obsolete exact selectors with the existing accessible privacy controls. Exercise full and review-first file contents, identity redaction and joins, displayed-result-only downloads, keyboard and viewport access, and failed-refresh retention in the real integration spec. Add an offline full-app driver with explicit synthetic bootstrap boundaries. Contract-Neutral: Integration verification only; no runtime, API, permissions or product contract change. Change-source: pulse-maintainer --- .../browser-tests/diagnostics-integration.cjs | 201 ++++++ .../tests/69-diagnostics-onboarding.spec.ts | 642 +++++++++++++----- 2 files changed, 669 insertions(+), 174 deletions(-) create mode 100644 frontend-modern/browser-tests/diagnostics-integration.cjs 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..a6b01273c4 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,167 @@ 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) => { +): 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 +394,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 +405,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 +437,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-"), + ); }); }); From 09455d69a63dd448d0b9692d82019725ba08308a Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:27:56 +0100 Subject: [PATCH 2/3] Align integration type resolution with its modern compiler The locked TypeScript compiler rejects the removed node10 resolution mode before checking any integration tests. Use bundler resolution for the extensionless imports transpiled by Playwright, without changing test runtime or product behaviour. Contract-Neutral: Test tooling only; no runtime, API, permissions or product contract change. Change-source: pulse-maintainer --- tests/integration/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 6548b2e23c94d8813de02bf0c442c0489e48a068 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:36:34 +0100 Subject: [PATCH 3/3] Type both Diagnostics response fixtures explicitly The full-export response includes populated identity arrays, so its route fixture must not inherit the empty response's never-array inference. Keep both payload shapes type-checked without suppressing compiler diagnostics. Contract-Neutral: Integration fixture typing only; no runtime, API, permissions or product contract change. Change-source: pulse-maintainer --- tests/integration/tests/69-diagnostics-onboarding.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration/tests/69-diagnostics-onboarding.spec.ts b/tests/integration/tests/69-diagnostics-onboarding.spec.ts index a6b01273c4..6852c0833f 100644 --- a/tests/integration/tests/69-diagnostics-onboarding.spec.ts +++ b/tests/integration/tests/69-diagnostics-onboarding.spec.ts @@ -345,7 +345,8 @@ const test = base.extend<{}, WorkerFixtures>({ async function prepareDiagnosticsRoute( page: Page, - payload = DIAGNOSTICS_PAYLOAD, + payload: typeof DIAGNOSTICS_PAYLOAD | typeof EXPORT_DIAGNOSTICS_PAYLOAD = + DIAGNOSTICS_PAYLOAD, ): Promise { const traffic: DiagnosticsTraffic = { reads: 0, writes: [] }; page.on("request", (request) => {