fix(browser): keep elapsed capture timing independent of wall-clock changes

This commit is contained in:
morluto 2026-10-13 15:02:54 +08:00
parent d9e9fb2273
commit 6bd2b28161
9 changed files with 73 additions and 20 deletions

View File

@ -91,7 +91,7 @@ export const browserScenarioCaptureData = (input: {
network_content: input.scenario.capture.network,
event_families: [...new Set(input.scenario.capture.events)].sort(),
},
duration_ms: Date.now() - input.startedAt,
duration_ms: Math.max(0, Math.round(performance.now() - input.startedAt)),
steps: [...input.steps],
events,
limitations: [

View File

@ -303,6 +303,34 @@ describe("PlaywrightBrowserScenarioProvider", () => {
});
expect(result.value.limitations).toContain(session.eventGaps[0]);
});
it("keeps capture elapsed values valid across a backward wall-clock jump", async () => {
let wallClock = 10_000;
vi.spyOn(Date, "now").mockImplementation(() => wallClock);
const session = new FakeSession("launch");
session.onPerform = () => {
wallClock = 1;
};
const provider = new PlaywrightBrowserScenarioProvider(() =>
Promise.resolve(session),
);
try {
const result = await provider.captureScenario(scenario());
if (!result.ok) throw result.error;
expect(wallClock).toBe(1);
expect(Number.isInteger(result.value.duration_ms)).toBe(true);
expect(result.value.duration_ms).toBeGreaterThanOrEqual(0);
expect(
result.value.steps.every(
({ elapsed_ms }) => Number.isInteger(elapsed_ms) && elapsed_ms >= 0,
),
).toBe(true);
} finally {
vi.restoreAllMocks();
}
});
it("returns an initial state and every step beyond the former action ceiling", async () => {
const session = new FakeSession("launch");
const provider = new PlaywrightBrowserScenarioProvider(() =>

View File

@ -172,7 +172,7 @@ const executeStep = async (input: {
),
});
const actionStartedAt = Date.now();
const actionStartedAt = performance.now();
session.setStep(stepIndex);
const requestCancelled = (): boolean => signal?.aborted === true;
let outcome: BrowserScenarioStepOutcome = {
@ -200,7 +200,7 @@ const executeStep = async (input: {
stepId: action.step_id,
action: action.action,
...outcome,
elapsedMs: Date.now() - actionStartedAt,
elapsedMs: Math.max(0, Math.round(performance.now() - actionStartedAt)),
beforeUrl,
afterUrl: session.sanitizeUrl(session.currentUrl()),
eventStart,
@ -233,7 +233,7 @@ const runScenario = async (
): Promise<BrowserScenarioCapture> => {
if (options.signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
const startedAt = Date.now();
const startedAt = performance.now();
const session = await openSession(scenario, {
...(options.signal === undefined ? {} : { signal: options.signal }),
retainCleanup,
@ -250,7 +250,7 @@ const runScenario = async (
await initialStep({
session,
scenario,
elapsedMs: Date.now() - startedAt,
elapsedMs: Math.max(0, Math.round(performance.now() - startedAt)),
signal: options.signal,
}),
);

View File

@ -72,7 +72,7 @@ export const runElectronActions = async (
): Promise<ElectronActions> => {
const actions: ElectronActions = [];
for (const action of input.actions) {
const actionStartedAt = Date.now();
const actionStartedAt = performance.now();
const windowIndex = actionWindowIndex(action);
let selectedWindow: Page | undefined;
try {
@ -107,7 +107,10 @@ export const runElectronActions = async (
target:
selectedWindow === undefined ? null : `window:${String(windowIndex)}`,
status: "completed",
elapsed_ms: Date.now() - actionStartedAt,
elapsed_ms: Math.max(
0,
Math.round(performance.now() - actionStartedAt),
),
error: null,
});
} catch (cause: unknown) {
@ -118,7 +121,10 @@ export const runElectronActions = async (
target:
selectedWindow === undefined ? null : `window:${String(windowIndex)}`,
status: options.signal?.aborted ? "cancelled" : "failed",
elapsed_ms: Date.now() - actionStartedAt,
elapsed_ms: Math.max(
0,
Math.round(performance.now() - actionStartedAt),
),
error: safeActionErrorMessage(cause),
});
break;
@ -366,7 +372,7 @@ export const runWithExecutionLimits = async <Value>(
new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new BrowserObservationError(OPERATION, "timeout")),
Math.max(1, deadline - Date.now()),
Math.max(1, deadline - performance.now()),
);
}),
);

View File

@ -86,7 +86,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
if (options.signal?.aborted === true)
throw new BrowserObservationError(OPERATION, "cancelled");
await prepareProcessOwnershipInspection(options.signal);
const startupDeadline = Date.now() + STARTUP_TIMEOUT_MS;
const startupDeadline = performance.now() + STARTUP_TIMEOUT_MS;
paths = {
executable: input.executable_path,
application: input.application_path,
@ -101,7 +101,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
cwd: paths.root,
env: safeElectronEnvironment(this.environment, runId),
args: ["-r", hookPath, paths.application, ...input.args],
timeout: Math.max(1, startupDeadline - Date.now()),
timeout: Math.max(1, startupDeadline - performance.now()),
});
const leaderPid = application.process().pid;
if (
@ -128,7 +128,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
const state = await runWithExecutionLimits(
readApplicationState(application),
undefined,
options.signal?.aborted ? Date.now() + 5_000 : undefined,
options.signal?.aborted ? performance.now() + 5_000 : undefined,
);
capture = projectElectronActiveCapture(paths, actions, state);
outcome = options.signal?.aborted
@ -187,7 +187,7 @@ export class PlaywrightElectronActiveProvider implements ElectronActiveObservati
await runWithExecutionLimits(
application.close(),
undefined,
Date.now() + 5_000,
performance.now() + 5_000,
);
} catch (cause: unknown) {
closeError = cause;

View File

@ -7,7 +7,10 @@ import {
} from "../domain/analysisErrorCore.js";
describe("withPlaywrightExecutionBoundary", () => {
afterEach(() => vi.useRealTimers());
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("rejects work that exceeds its absolute remaining duration", async () => {
vi.useFakeTimers();
@ -31,4 +34,20 @@ describe("withPlaywrightExecutionBoundary", () => {
controller.abort();
await expect(result).rejects.toBeInstanceOf(AnalysisCancelledError);
});
it("keeps its timeout stable across a wall-clock jump", async () => {
vi.spyOn(Date, "now")
.mockReturnValueOnce(1_000)
.mockReturnValue(10_000_000);
await expect(
withPlaywrightExecutionBoundary(
() =>
new Promise<string>((resolve) =>
setTimeout(() => resolve("done"), 5),
),
1_000,
),
).resolves.toBe("done");
});
});

View File

@ -19,10 +19,10 @@ export const withPlaywrightExecutionBoundary = <Value>(
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline =
timeoutMs === undefined ? undefined : Date.now() + timeoutMs;
timeoutMs === undefined ? undefined : performance.now() + timeoutMs;
const scheduleTimeout = (): void => {
if (deadline === undefined) return;
const remaining = deadline - Date.now();
const remaining = deadline - performance.now();
if (remaining <= 0) {
fail(new AnalysisTimeoutError(OPERATION, timeoutMs ?? 0));
return;

View File

@ -133,7 +133,7 @@ export class PlaywrightScenarioSession implements BrowserScenarioSessionPort {
const secrets = BrowserScenarioSecrets.resolve(scenario, environment);
if (secrets === undefined)
throw new BrowserObservationError(OPERATION, "secret_unavailable");
const startedAt = Date.now();
const startedAt = performance.now();
const opening = openPlaywrightScenarioBrowser(scenario, environment, {
signal: options.signal,
...(options.retainCleanup === undefined

View File

@ -86,7 +86,7 @@ export const fetchWebSourceMaps = async (
signal?.addEventListener("abort", abortFromCaller, { once: true });
const operationSignal = operationController.signal;
const deadlineAt =
Date.now() + (host.timeoutMs ?? SOURCE_MAP_FETCH_TIMEOUT_MS);
performance.now() + (host.timeoutMs ?? SOURCE_MAP_FETCH_TIMEOUT_MS);
const budget = {
retainedBytes: 0,
deadlineAt,
@ -110,7 +110,7 @@ export const fetchWebSourceMaps = async (
}
const expandedBytesBeforeItem = budget.decodedRecords.expandedBytes;
budget.decodedRecords.expandedBytes += reservation;
if (operationSignal.aborted || Date.now() >= deadlineAt) {
if (operationSignal.aborted || performance.now() >= deadlineAt) {
items.push(
emptySourceMapItem(
request,
@ -789,7 +789,7 @@ const checkOperation = (
): void => {
if (callerSignal?.aborted === true) throw callerSignal.reason;
if (operationSignal?.aborted === true) throw operationSignal.reason;
if (Date.now() >= deadlineAt) throw new SourceMapDeadlineError();
if (performance.now() >= deadlineAt) throw new SourceMapDeadlineError();
};
const approvedUrl = (