mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 04:33:17 +00:00
Integrate reviewed Diagnostics export regression coverage
Change-source: pulse-maintainer
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
// 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,
|
||||
diagnosticsRead: 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',
|
||||
'tests/integration/tests/helpers.ts',
|
||||
'tests/integration/tsconfig.json',
|
||||
'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, maxFailures: 1, globalTimeout: 135000,
|
||||
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({ pid: child.pid, 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;
|
||||
});
|
||||
@@ -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<void> {
|
||||
await page.route('**/api/diagnostics', async (route) => {
|
||||
payload: typeof DIAGNOSTICS_PAYLOAD | typeof EXPORT_DIAGNOSTICS_PAYLOAD =
|
||||
DIAGNOSTICS_PAYLOAD,
|
||||
): Promise<DiagnosticsTraffic> {
|
||||
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<void> {
|
||||
@@ -299,7 +395,10 @@ async function scrollToBottom(page: Page): Promise<void> {
|
||||
|
||||
async function auditHorizontalOverflow(page: Page): Promise<OverflowAudit> {
|
||||
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<OverflowAudit> {
|
||||
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,333 @@ async function auditHorizontalOverflow(page: Page): Promise<OverflowAudit> {
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('Diagnostics onboarding analytics', () => {
|
||||
async function openDiagnostics(page: Page): Promise<void> {
|
||||
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<Page["getByRole"]>,
|
||||
): Promise<string> {
|
||||
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<void> {
|
||||
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 ({
|
||||
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,
|
||||
});
|
||||
|
||||
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);
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
if (testInfo.status === "passed") {
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("diagnostics-completed-state.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('desktop diagnostics exports full and sanitized onboarding analytics JSON', 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 export coverage');
|
||||
|
||||
await prepareDiagnosticsRoute(page, EXPORT_DIAGNOSTICS_PAYLOAD);
|
||||
|
||||
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 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,
|
||||
});
|
||||
|
||||
const [fullDownload] = await Promise.all([
|
||||
page.waitForEvent('download'),
|
||||
page.getByRole('button', { name: 'Full', exact: true }).click(),
|
||||
]);
|
||||
|
||||
expect(fullDownload.suggestedFilename()).toMatch(
|
||||
new RegExp(`^pulse-diagnostics-full-${new Date().toISOString().slice(0, 10)}\\.json$`),
|
||||
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-"),
|
||||
);
|
||||
|
||||
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<string, unknown>).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$`),
|
||||
);
|
||||
|
||||
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(sanitizedPayload.discovery).toEqual(
|
||||
expect.objectContaining({
|
||||
configuredSubnet: '[REDACTED_SUBNET]',
|
||||
activeSubnet: '[REDACTED_SUBNET]',
|
||||
environmentOverride: '[REDACTED]',
|
||||
}),
|
||||
);
|
||||
expect(sanitizedPayload.errors).toEqual(['probe failed for [REDACTED_IP] after timeout']);
|
||||
expect(sanitizedPayload.infrastructureOnboarding).toBeUndefined();
|
||||
expect(
|
||||
(sanitizedPayload as Record<string, unknown>).commercialFunnel,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('mobile diagnostics keeps the populated onboarding analytics page inside the viewport', 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-'), 'Mobile-only diagnostics overflow 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);
|
||||
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', exact: true }).click();
|
||||
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",
|
||||
});
|
||||
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(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,
|
||||
);
|
||||
expect(sanitised.apiTokens.usage[1].tokenId).toBe(
|
||||
sanitised.apiTokens.tokens[0].id,
|
||||
);
|
||||
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(traffic.reads).toBe(1);
|
||||
expect(traffic.writes).toEqual(writesBeforeExport);
|
||||
await assertControlsInViewport(
|
||||
page,
|
||||
testInfo.project.name.startsWith("mobile-"),
|
||||
);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("diagnostics-safe-export.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("disables exports during a refresh and retains the previous result after a failed live check", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
let reads = 0;
|
||||
let release: (() => void) | undefined;
|
||||
const held = new Promise<void>((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);
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("diagnostics-refresh-disabled.png"),
|
||||
fullPage: true,
|
||||
});
|
||||
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-"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"target": "ES2020",
|
||||
"module": "ES2020",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
Reference in New Issue
Block a user