fix(browser): keep elapsed capture timing independent of wall-clock changes
This commit is contained in:
parent
d9e9fb2273
commit
6bd2b28161
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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(() =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
Loading…
Reference in New Issue