From 6667bd8ad0e07b47418dd4e8a1ddb8a693f4fff0 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:02:44 +0100 Subject: [PATCH] test: capture bounded paired Settings bootstrap timings The hosted Settings failure stalls during bootstrap without enough evidence to separate server delay from browser scheduling. Attach redacted, bounded cookie-session HTTP timings alongside browser response timings without weakening readiness or layout assertions. Change-source: pulse-maintainer --- .../scripts/bootstrap-timing.d.mts | 9 ++++ tests/integration/scripts/bootstrap-timing.md | 23 +++++++++ .../integration/scripts/bootstrap-timing.mjs | 50 ++++++++++++++++++ .../scripts/bootstrap-timing.test.mjs | 44 ++++++++++++++++ .../15-settings-shell-consistency.spec.ts | 51 +++++++++++-------- 5 files changed, 156 insertions(+), 21 deletions(-) create mode 100644 tests/integration/scripts/bootstrap-timing.d.mts create mode 100644 tests/integration/scripts/bootstrap-timing.md create mode 100644 tests/integration/scripts/bootstrap-timing.mjs create mode 100644 tests/integration/scripts/bootstrap-timing.test.mjs diff --git a/tests/integration/scripts/bootstrap-timing.d.mts b/tests/integration/scripts/bootstrap-timing.d.mts new file mode 100644 index 000000000..b602f5924 --- /dev/null +++ b/tests/integration/scripts/bootstrap-timing.d.mts @@ -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; diff --git a/tests/integration/scripts/bootstrap-timing.md b/tests/integration/scripts/bootstrap-timing.md new file mode 100644 index 000000000..3efb886f1 --- /dev/null +++ b/tests/integration/scripts/bootstrap-timing.md @@ -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. diff --git a/tests/integration/scripts/bootstrap-timing.mjs b/tests/integration/scripts/bootstrap-timing.mjs new file mode 100644 index 000000000..0214c9a3c --- /dev/null +++ b/tests/integration/scripts/bootstrap-timing.mjs @@ -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; + }; +} diff --git a/tests/integration/scripts/bootstrap-timing.test.mjs b/tests/integration/scripts/bootstrap-timing.test.mjs new file mode 100644 index 000000000..39dad48d0 --- /dev/null +++ b/tests/integration/scripts/bootstrap-timing.test.mjs @@ -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/); +}); diff --git a/tests/integration/tests/15-settings-shell-consistency.spec.ts b/tests/integration/tests/15-settings-shell-consistency.spec.ts index e87e09689..3cd376ca2 100644 --- a/tests/integration/tests/15-settings-shell-consistency.spec.ts +++ b/tests/integration/tests/15-settings-shell-consistency.spec.ts @@ -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', + }); + } }); });