diff --git a/scripts/native-ingestion-h5.test.mjs b/scripts/native-ingestion-h5.test.mjs index beda14a1280..9ae2a9f7b9b 100644 --- a/scripts/native-ingestion-h5.test.mjs +++ b/scripts/native-ingestion-h5.test.mjs @@ -20,6 +20,25 @@ test('real frozen Playwright tiny poll exposes the matcher deadline shape used b // claim runtime qualification of H5's extended application path. }); +test('real frozen Playwright can report its configured timeout before the nominal deadline', async context => { + const require = createRequire(import.meta.url), { expect } = require('@playwright/test'); + assert.equal(require('@playwright/test/package.json').version, '1.62.1'); + const timeoutMs = 1200, intervalMs = 1500, startedAtMs = Date.now(), started = performance.now(); + let observations = 0, failure; + try { + await expect.poll(async () => { observations++; return 0; }, { timeout: timeoutMs, intervals: [intervalMs] }).toBeGreaterThanOrEqual(1); + } catch (error) { failure = error; } + const assessedAtMs = Date.now(), elapsedMs = performance.now() - started; + assert.equal(failure?.matcherResult?.name, 'toBeGreaterThanOrEqual'); + assert.equal(failure.matcherResult.pass, false); + assert.ok(failure.message.includes(`Timeout ${timeoutMs}ms exceeded while waiting on the predicate`)); + assert.equal(observations, 1); + assert.ok(assessedAtMs < startedAtMs + timeoutMs, 'The real poll ends before its nominal deadline when its next interval would cross it'); + context.diagnostic(JSON.stringify({ scope: 'Small real-library poll only; no app or120s runtime qualification', + playwrightVersion: '1.62.1', timeoutMs, intervalMs, startedAtMs, nominalDeadlineMs: startedAtMs + timeoutMs, + assessedAtMs, elapsedMs, observations, matcherName: failure.matcherResult.name, matcherPassed: failure.matcherResult.pass })); +}); + test('H5 requires explicit mode and leaves default H4c selected', () => { assert.equal(h5Enabled(undefined), false); assert.equal(h5Enabled(H5_MODE), true); assert.throws(() => h5Enabled('almost-h5')); @@ -33,19 +52,20 @@ test('first successful observation uses only the original wait with no collectio assert.equal(value.observation.complete, true); assert.equal(value.observation.completedAtMs, 1000); }); -test('known120s miss remains failed while the same observation finishes before600s', async () => { +test('known120s miss assessed before its nominal deadline stays failed and uses only the remaining600s observation', async () => { let clock = 1000, count = 30; const calls = [], records = []; const value = await observeStreaming({ readCount: async () => count, target: 512, now: () => clock, onOutcome: value => records.push(structuredClone(value)), poll: async (read, milliseconds) => { calls.push(milliseconds); await read(); - if (calls.length === 1) { clock += milliseconds; throw timeout(milliseconds); } + if (calls.length === 1) { clock += milliseconds - 750; throw timeout(milliseconds); } count = 512; clock += 90000; await read(); } }); - assert.deepEqual(calls, [120000, 480000]); + assert.deepEqual(calls, [120000, 480750]); assert.equal(value.original.status, 'failed'); assert.equal(value.original.lastObservedCount, 30); assert.equal(value.original.lastObservedAtMs, 1000); assert.equal(value.original.deadlineMs, 121000); - assert.equal(value.observation.completedAtMs, 211000); assert.equal(records[0].observation.complete, false); + assert.equal(value.original.assessedAtMs, 120250); assert.ok(value.original.assessedAtMs < value.original.deadlineMs); + assert.equal(value.observation.completedAtMs, 210250); assert.equal(records[0].observation.complete, false); assert.equal(value.original.reason, 'original_streaming_deadline'); }); diff --git a/scripts/native-ingestion-performance-gates.test.mjs b/scripts/native-ingestion-performance-gates.test.mjs index 281b16a9a52..b146615503e 100644 --- a/scripts/native-ingestion-performance-gates.test.mjs +++ b/scripts/native-ingestion-performance-gates.test.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { H5_MODE, H5_PROTOCOL, H5_LIMITS, H5_WORKLOAD_SHA256, H5_BASELINE_REVISIONS, originalTerminal } from "../tests/e2e/fixtures/native-ingestion-h5.mjs"; +import { H5_MODE, H5_PROTOCOL, H5_LIMITS, H5_WORKLOAD_SHA256, H5_BASELINE_REVISIONS, originalTerminal, assessH5Arm, requireH5Candidate } from "../tests/e2e/fixtures/native-ingestion-h5.mjs"; import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; @@ -199,6 +199,17 @@ function h5Report(revision, loadedLatency) { return report; } +test("H5 assessor retains an original SLA failure assessed before its nominal streaming deadline", () => { + const report = h5Report("B0", 180000), streaming = report.cases[3].h5.streaming.original; + streaming.assessedAtMs = streaming.deadlineMs - 750; + streaming.lastObservedAtMs = streaming.assessedAtMs - 1; + const outcome = assessH5Arm(report, { status: "passed", retry: 0, errors: [], timeoutMs: H5_LIMITS.fullInvocationMs, + startedAt: "2026-11-03T00:00:00.000Z", durationMs: 500000 }); + assert.equal(outcome.originalSlaPassed, false); + assert.deepEqual(outcome.misses, ["burst512:streaming120"]); + assert.throws(() => requireH5Candidate("final-b2", outcome, outcome), /every original SLA/); +}); + test("H5 CLI retains baseline SLA failure, rejects final SLA misses and requires complete owning evidence", async () => { const directory = await mkdtemp(path.join(os.tmpdir(), "native-ingestion-h5-gates-")); const script = new URL("./compare-native-ingestion-browser.ts", import.meta.url); diff --git a/tests/e2e/fixtures/native-ingestion-h5.md b/tests/e2e/fixtures/native-ingestion-h5.md index 064ac26f95f..7debd036dfd 100644 --- a/tests/e2e/fixtures/native-ingestion-h5.md +++ b/tests/e2e/fixtures/native-ingestion-h5.md @@ -45,8 +45,8 @@ is added. Fake-plugin streaming remains separate from real-cloud Stop proof. The first120s poll is the original matcher. Only that exact known matcher deadline can extend observation. Page/API errors, other assertions, terminal failures and cleanup failures never become an allowed SLA miss. There is no -collection wait when markers are available. The600s ceiling is absolute, so a -120s miss leaves at most480s additional observation, not another600s. +collection wait when markers are available. The600s ceiling is absolute from +the original wait start; continued observation uses only its remaining budget. Record the original wait start/deadline, actual last count and its observation time, assessment time and error before continuing. Keep the original failure @@ -54,6 +54,14 @@ after completion. The original120s clock starts before the common source epoch; it is not silently rebased to epoch+120s. Derive a separate paint-deadline count from retained2RAF timestamps; do not mislabel it as the original poll's count. +The recorded `deadlineMs` is the nominal configured boundary and +`assessedAtMs` is the actual matcher return time. Frozen Playwright1.62.1 can +report its timeout before that nominal boundary when the next polling interval +would cross it. Its original matcher outcome, known timeout error and incomplete +count establish the original failure; timestamp ordering against the nominal +deadline does not change that outcome. Retain both raw times and continue only +until the unchanged absolute600s observation ceiling. + If120s fails, original terminal processing was **not reached**. Later180s terminal observations are separately labeled and cannot make the original protocol pass. Keep epoch→all-terminal drainage unshortened. An additional diff --git a/tests/e2e/fixtures/native-ingestion-h5.mjs b/tests/e2e/fixtures/native-ingestion-h5.mjs index a38a5797da1..c6242d74d42 100644 --- a/tests/e2e/fixtures/native-ingestion-h5.mjs +++ b/tests/e2e/fixtures/native-ingestion-h5.mjs @@ -58,6 +58,8 @@ export async function observeStreaming({ readCount, poll, target, now = Date.now deadlineError = error; } if (readError) throw readError; + // deadlineMs is the nominal configured boundary. Playwright can report its + // timeout earlier when its next polling interval would cross that boundary. const original = { status: deadlineError ? 'failed' : 'passed', reason: deadlineError ? 'original_streaming_deadline' : null, startedAtMs, deadlineMs: startedAtMs + ORIGINAL_BUDGETS.streamingMs, assessedAtMs: now(), target, lastObservedCount, lastObservedAtMs, error: deadlineError?.message ?? null }; @@ -119,7 +121,8 @@ export function assessH5Arm(report, owner) { if (streaming.status === 'failed') { assert.equal(streaming.reason, 'original_streaming_deadline'); assert.ok(streaming.lastObservedCount < streaming.target); - assert.ok(streaming.assessedAtMs >= streaming.deadlineMs); + // The original matcher outcome is authoritative; assessedAtMs retains + // the actual return time even when it precedes the nominal deadlineMs. assert.equal(item.stop, false, 'Control readiness cannot inherit a streaming SLA miss'); assert.ok(streaming.error.includes('Timeout 120000ms exceeded while waiting on the predicate')); assert.equal(value.terminalOriginal.status, 'not-reached');