mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Merge pull request #2002 from rcourtman/maintainer/20260909T090949Z
Make Settings startup failures actionable without weakening checks
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
export type BootstrapTiming = {
|
||||
transport: string;
|
||||
path: string;
|
||||
started: number;
|
||||
status: number | null;
|
||||
duration: number | null;
|
||||
};
|
||||
export function observeBootstrapTiming(page: Page, now?: () => number): () => Promise<BootstrapTiming[]>;
|
||||
@@ -0,0 +1,23 @@
|
||||
# Settings bootstrap timing diagnostic
|
||||
|
||||
The desktop-to-390px Settings test attaches `bootstrap-paired-timing` to its
|
||||
Playwright report, including on assertion failure. It issues at most one extra
|
||||
GET each for summary and runtime capabilities, when the browser first requests
|
||||
that path. The HTTP client shares the test's cookie session but not the browser's
|
||||
network scheduler. Redirect following is disabled; each probe has a five-second
|
||||
limit. No readiness condition, capability requirement or assertion is relaxed.
|
||||
|
||||
`started` is host epoch milliseconds. Browser duration ends at response headers;
|
||||
HTTP duration includes response receipt. Null browser status/duration is censored,
|
||||
not a server error. Null HTTP status is a transport failure/timeout without a
|
||||
received response; the exception is deliberately not retained. Reports exclude
|
||||
headers, credentials, query strings and payloads.
|
||||
|
||||
For the next hosted observation, retain this attachment, the browser trace and
|
||||
application startup timestamps from the same job/source. Compare overlapping
|
||||
summary/capabilities requests; a quick HTTP response with stalled browser narrows
|
||||
the question towards browser scheduling, while both delayed supports further
|
||||
server/startup measurement. Neither proves a particular lock is responsible.
|
||||
Extra probes can warm caches or contend themselves, so do not claim a controlled
|
||||
effect size or historical causality. Do not select favourable repeats, widen the
|
||||
assertion timeout, or treat a successful diagnostic as probation promotion.
|
||||
@@ -0,0 +1,50 @@
|
||||
// Bounded paired probes for the Settings bootstrap diagnostic. Never retain
|
||||
// headers, cookies, bodies, query strings, tenant IDs or exception messages.
|
||||
const paths = new Set(['/api/state/summary', '/api/license/runtime-capabilities']);
|
||||
|
||||
export function observeBootstrapTiming(page, now = Date.now) {
|
||||
const rows = [];
|
||||
const pending = [];
|
||||
const seen = new Set();
|
||||
const starts = new Map();
|
||||
const onRequest = (request) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (!paths.has(path) || seen.has(path) || request.method() !== 'GET') return;
|
||||
seen.add(path);
|
||||
const started = now();
|
||||
const row = { transport: 'browser', path, started, status: null, duration: null };
|
||||
rows.push(row);
|
||||
starts.set(request, row);
|
||||
// page.request uses the same cookie session without the browser network
|
||||
// scheduler. This extra request can perturb startup: timings are diagnostic,
|
||||
// not a controlled effect size or a substitute for the original assertion.
|
||||
pending.push((async () => {
|
||||
const probe = { transport: 'http', path, started: now(), status: null, duration: null };
|
||||
rows.push(probe);
|
||||
let response;
|
||||
try {
|
||||
response = await page.request.get(path, { timeout: 5000, maxRedirects: 0 });
|
||||
probe.status = response.status();
|
||||
} catch {
|
||||
// Null means no observed response, not an HTTP error status.
|
||||
} finally {
|
||||
probe.duration = now() - probe.started;
|
||||
await response?.dispose().catch(() => {});
|
||||
}
|
||||
})());
|
||||
};
|
||||
const onResponse = (response) => {
|
||||
const row = starts.get(response.request());
|
||||
if (!row) return;
|
||||
row.status = response.status();
|
||||
row.duration = now() - row.started;
|
||||
};
|
||||
page.on('request', onRequest);
|
||||
page.on('response', onResponse);
|
||||
return async () => {
|
||||
page.off('request', onRequest);
|
||||
page.off('response', onResponse);
|
||||
await Promise.all(pending);
|
||||
return rows;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import test from 'node:test';
|
||||
import { observeBootstrapTiming } from './bootstrap-timing.mjs';
|
||||
|
||||
const request = (path) => ({ url: () => `http://localhost${path}`, method: () => 'GET' });
|
||||
test('pairs each allowed browser path once, with bounded cookie-context HTTP and no payload', async () => {
|
||||
const page = new EventEmitter();
|
||||
const calls = [];
|
||||
let disposed = 0;
|
||||
page.request = { get: async (...args) => {
|
||||
calls.push(args);
|
||||
return { status: () => 200, dispose: async () => { disposed++; } };
|
||||
} };
|
||||
let time = 100;
|
||||
const finish = observeBootstrapTiming(page, () => time++);
|
||||
page.emit('request', request('/api/state?secret=private'));
|
||||
const summary = request('/api/state/summary?secret=private');
|
||||
page.emit('request', summary);
|
||||
page.emit('request', summary);
|
||||
page.emit('response', { request: () => summary, status: () => 200 });
|
||||
page.emit('request', request('/api/license/runtime-capabilities'));
|
||||
const rows = await finish();
|
||||
assert.equal(calls.length, 2);
|
||||
assert.deepEqual(calls[0], ['/api/state/summary', { timeout: 5000, maxRedirects: 0 }]);
|
||||
assert.equal(disposed, 2);
|
||||
assert.equal(rows.length, 4);
|
||||
assert.equal(rows[0].status, 200);
|
||||
assert.equal(rows[2].status, null); // censored browser request
|
||||
assert.equal(rows[2].duration, null);
|
||||
assert.doesNotMatch(JSON.stringify(rows), /secret|private/);
|
||||
assert.equal(page.listenerCount('request'), 0);
|
||||
assert.equal(page.listenerCount('response'), 0);
|
||||
});
|
||||
test('transport failure is censored and never retains exception text', async () => {
|
||||
const page = new EventEmitter();
|
||||
page.request = { get: async () => { throw new Error('credential=private'); } };
|
||||
const finish = observeBootstrapTiming(page);
|
||||
page.emit('request', request('/api/state/summary'));
|
||||
const rows = await finish();
|
||||
assert.equal(rows[1].status, null);
|
||||
assert.equal(typeof rows[1].duration, 'number');
|
||||
assert.doesNotMatch(JSON.stringify(rows), /credential|private/);
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import { observeBootstrapTiming } from '../scripts/bootstrap-timing.mjs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { test as base, expect } from '@playwright/test';
|
||||
@@ -175,30 +176,38 @@ test.describe('Settings shell consistency', () => {
|
||||
});
|
||||
}
|
||||
|
||||
test('keeps direct Settings content inside a 390px viewport after desktop resize', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto('/settings/pulse-intelligence/billing/plan', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
test('keeps direct Settings content inside a 390px viewport after desktop resize', async ({ page }, testInfo) => {
|
||||
const finishTiming = observeBootstrapTiming(page);
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await page.goto('/settings/pulse-intelligence/billing/plan', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
|
||||
const content = page.locator('[data-settings-content]');
|
||||
await expect(content).toBeVisible();
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Plans & Billing' })).toBeVisible();
|
||||
const content = page.locator('[data-settings-content]');
|
||||
await expect(content).toBeVisible();
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Plans & Billing' })).toBeVisible();
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
|
||||
const layout = await content.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return {
|
||||
animationName: getComputedStyle(element).animationName,
|
||||
left: bounds.left,
|
||||
right: bounds.right,
|
||||
viewportWidth: document.documentElement.clientWidth,
|
||||
};
|
||||
});
|
||||
const layout = await content.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return {
|
||||
animationName: getComputedStyle(element).animationName,
|
||||
left: bounds.left,
|
||||
right: bounds.right,
|
||||
viewportWidth: document.documentElement.clientWidth,
|
||||
};
|
||||
});
|
||||
|
||||
expect(layout.animationName).toBe('none');
|
||||
expect(layout.left).toBeGreaterThanOrEqual(0);
|
||||
expect(layout.right).toBeLessThanOrEqual(layout.viewportWidth);
|
||||
expect(layout.animationName).toBe('none');
|
||||
expect(layout.left).toBeGreaterThanOrEqual(0);
|
||||
expect(layout.right).toBeLessThanOrEqual(layout.viewportWidth);
|
||||
} finally {
|
||||
await testInfo.attach('bootstrap-paired-timing', {
|
||||
body: JSON.stringify(await finishTiming(), null, 2),
|
||||
contentType: 'application/json',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user