import { expect, test } from "@playwright/test";
import { performance as nodePerformance } from "node:perf_hooks";
import {
measureToolcraftInteraction,
startToolcraftFrameProbe,
} from "./performance-probe-helpers";
import { expectToolcraftPerformanceBudget } from "./performance-budget-helpers";
test("performance probes stop when the measured action fails", async ({ page }) => {
await page.setContent("
failed performance action
");
await expect(
measureToolcraftInteraction(page, async () => {
throw new Error("action failed");
}),
).rejects.toThrow("action failed");
expect(
await page.evaluate(
() =>
(window as Window & { __toolcraftFrameProbe?: { active: boolean } })
.__toolcraftFrameProbe?.active ?? false,
),
).toBe(false);
});
test("interaction measurements always observe at least one frame", async ({
page,
}) => {
await page.setContent("measured interaction
");
const result = await measureToolcraftInteraction(
page,
async () => undefined,
{ settleFrames: 0 },
);
expect(result.sampleCount).toBeGreaterThan(0);
});
test("frame probes record a main-thread gap before any navigation", async ({ page }) => {
await page.setContent("main-thread gap
");
const stopProbe = await startToolcraftFrameProbe(page);
await page.evaluate(() => {
const startedAt = performance.now();
while (performance.now() - startedAt < 80) {
// Deliberately block the document to verify the raw frame probe first.
}
});
await page.evaluate(
() => new Promise((resolve) => requestAnimationFrame(() => resolve())),
);
const result = await stopProbe();
expect(result.maxFrameGapMs).toBeGreaterThanOrEqual(60);
});
test("frame probes exclude gaps while the document is hidden", async ({ page }) => {
await page.setContent("hidden frame gap
");
await page.evaluate(() => {
const callbacks = new Map();
let sequence = 0;
const win = window as Window & {
__toolcraftRunNextFrame?: (now: number) => void;
};
window.requestAnimationFrame = (callback) => {
sequence += 1;
callbacks.set(sequence, callback);
return sequence;
};
window.cancelAnimationFrame = (id) => {
callbacks.delete(id);
};
win.__toolcraftRunNextFrame = (now) => {
const next = callbacks.entries().next().value as
| [number, FrameRequestCallback]
| undefined;
if (!next) throw new Error("Expected a queued animation frame.");
callbacks.delete(next[0]);
next[1](now);
};
});
const stopProbe = await startToolcraftFrameProbe(page);
await page.evaluate(() => {
const win = window as Window & {
__toolcraftRunNextFrame?: (now: number) => void;
};
const runNextFrame = win.__toolcraftRunNextFrame;
if (!runNextFrame) throw new Error("Controlled frame clock is missing.");
const startedAt = performance.now();
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "hidden",
});
runNextFrame(startedAt);
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "visible",
});
runNextFrame(startedAt + 16);
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "hidden",
});
runNextFrame(startedAt + 116);
Object.defineProperty(document, "visibilityState", {
configurable: true,
value: "visible",
});
runNextFrame(startedAt + 132);
});
const result = await stopProbe();
expect(result.frameGapsMs).toHaveLength(2);
expect(result.frameGapsMs).toEqual([16, 16]);
});
test("interaction duration includes latency until the first product outcome", async ({
page,
}) => {
await page.setContent('before
');
const result = await measureToolcraftInteraction(
page,
() =>
page.locator("#outcome").evaluate((node) => {
window.setTimeout(() => {
node.textContent = "after";
}, 120);
}),
{
observeOutcome: () => page.locator("#outcome").textContent(),
outcomeTimeoutMs: 1_000,
settleFrames: 1,
stabilityIntervalMs: 0,
stabilitySamples: 2,
},
);
expect(result.durationMs).toBeGreaterThanOrEqual(100);
expect(() =>
expectToolcraftPerformanceBudget(result, { maxInteractionMs: 50 }),
).toThrow(/50ms/);
});
test("interaction duration excludes post-outcome stability sampling", async ({
page,
}) => {
await page.setContent('before
');
const wallStartedAt = nodePerformance.now();
const result = await measureToolcraftInteraction(
page,
() =>
page.locator("#outcome").evaluate((node) => {
node.textContent = "after";
}),
{
observeOutcome: () => page.locator("#outcome").textContent(),
settleFrames: 1,
stabilityIntervalMs: 80,
stabilitySamples: 3,
},
);
const wallDurationMs = nodePerformance.now() - wallStartedAt;
const separatingBudgetMs =
result.durationMs + (wallDurationMs - result.durationMs) / 2;
expect(wallDurationMs - result.durationMs).toBeGreaterThan(250);
expect(wallDurationMs).toBeGreaterThan(separatingBudgetMs);
expect(() =>
expectToolcraftPerformanceBudget(result, {
maxInteractionMs: separatingBudgetMs,
}),
).not.toThrow();
});
test("interaction latency captures an outcome that appears during a longer action", async ({
page,
}) => {
await page.setContent('before
');
let actionDurationMs = 0;
const result = await measureToolcraftInteraction(
page,
async () => {
const actionStartedAt = nodePerformance.now();
await page.locator("#outcome").evaluate((node) => {
node.textContent = "after";
});
await page.waitForTimeout(180);
actionDurationMs = nodePerformance.now() - actionStartedAt;
},
{
observeOutcome: () => page.locator("#outcome").textContent(),
settleFrames: 1,
stabilityIntervalMs: 0,
stabilitySamples: 2,
},
);
expect(actionDurationMs - result.durationMs).toBeGreaterThan(100);
});
test("interaction stability is evaluated after a live action crosses its baseline", async ({
page,
}) => {
await page.setContent('before
');
let actionDurationMs = 0;
const result = await measureToolcraftInteraction(
page,
async () => {
const actionStartedAt = nodePerformance.now();
const outcome = page.locator("#outcome");
await outcome.evaluate((node) => {
node.textContent = "first-change";
});
await page.waitForTimeout(30);
await outcome.evaluate((node) => {
node.textContent = "before";
});
await page.waitForTimeout(30);
await outcome.evaluate((node) => {
node.textContent = "final-change";
});
actionDurationMs = nodePerformance.now() - actionStartedAt;
},
{
observeOutcome: () => page.locator("#outcome").textContent(),
outcomePollIntervalMs: 5,
settleFrames: 1,
stabilityIntervalMs: 10,
stabilitySamples: 3,
},
);
expect(actionDurationMs - result.durationMs).toBeGreaterThan(30);
await expect(page.locator("#outcome")).toHaveText("final-change");
});
test("an outcome timeout during a longer action is reported without leaking the probe", async ({
page,
}) => {
await page.setContent('before
');
await expect(
measureToolcraftInteraction(
page,
() => page.waitForTimeout(120),
{
observeOutcome: () => page.locator("#outcome").textContent(),
outcomePollIntervalMs: 5,
outcomeTimeoutMs: 30,
},
),
).rejects.toThrow(/should change/);
expect(
await page.evaluate(
() =>
(window as Window & { __toolcraftFrameProbe?: { active: boolean } })
.__toolcraftFrameProbe?.active ?? false,
),
).toBe(false);
});
test("interaction timing and frame sampling survive a document navigation", async ({
page,
}) => {
await page.goto('data:text/html,before
');
await page.waitForTimeout(150);
const result = await measureToolcraftInteraction(
page,
() => page.goto('data:text/html,after
').then(() => undefined),
{
observeOutcome: () => page.locator("#outcome").textContent(),
outcomePollIntervalMs: 5,
settleFrames: 2,
stabilityIntervalMs: 0,
stabilitySamples: 2,
},
);
expect(result.durationMs).toBeGreaterThanOrEqual(0);
expect(result.durationMs).toBeLessThan(500);
expect(result.sampleCount).toBeGreaterThan(0);
});
test("interaction measurements preserve frame gaps recorded before navigation", async ({
page,
}) => {
await page.goto('data:text/html,before
');
await page.waitForTimeout(50);
const result = await measureToolcraftInteraction(
page,
async () => {
await page.evaluate(() => {
const startedAt = performance.now();
while (performance.now() - startedAt < 80) {
// Deliberately block the old document so its frame gap must survive navigation.
}
});
await page.evaluate(
() => new Promise((resolve) => requestAnimationFrame(() => resolve())),
);
await page.goto('data:text/html,after
');
},
{
observeOutcome: () => page.locator("#outcome").textContent(),
outcomePollIntervalMs: 5,
settleFrames: 2,
stabilityIntervalMs: 0,
stabilitySamples: 2,
},
);
expect(result.maxFrameGapMs).toBeGreaterThanOrEqual(60);
expect(result.sampleCount).toBeGreaterThan(2);
});
test("restarting a performance probe disconnects the previous observer", async ({ page }) => {
await page.setContent("probe restart
");
await page.evaluate(() => {
class ProbeObserver {
static disconnectCount = 0;
disconnect() {
ProbeObserver.disconnectCount += 1;
}
observe() {}
takeRecords(): PerformanceEntryList {
return [];
}
}
Object.defineProperty(window, "PerformanceObserver", {
configurable: true,
value: ProbeObserver,
});
(window as Window & { __toolcraftProbeObserver?: typeof ProbeObserver })
.__toolcraftProbeObserver = ProbeObserver;
});
const stopFirst = await startToolcraftFrameProbe(page);
const stopSecond = await startToolcraftFrameProbe(page);
expect(
await page.evaluate(
() =>
(window as Window & {
__toolcraftProbeObserver?: { disconnectCount: number };
}).__toolcraftProbeObserver?.disconnectCount,
),
).toBe(1);
await stopFirst();
expect(
await page.evaluate(
() =>
(window as Window & { __toolcraftFrameProbe?: { active: boolean } })
.__toolcraftFrameProbe?.active,
),
).toBe(true);
await stopSecond();
expect(
await page.evaluate(() => {
const win = window as Window & {
__toolcraftCompletedFrameProbes?: Record;
__toolcraftFrameProbe?: { active: boolean };
};
return {
active: win.__toolcraftFrameProbe?.active ?? false,
completedProbeCount: Object.keys(
win.__toolcraftCompletedFrameProbes ?? {},
).length,
};
}),
).toEqual({ active: false, completedProbeCount: 0 });
});