Skip to content

Commit a8518d8

Browse files
committed
feat(cli): synthetic INP interactions via JSON script
Add --interact-script <path> to run an ordered list of browser interactions (scroll, click, hover, type, wait) after page load and before snippet evaluation, enabling INP measurement in headless mode. - cli/src/interactions.js: executeStep (exported) + runInteractions Unknown action types throw with a clear message - cli/src/runner.js: runSnippets and runMeasurement accept interactScript - cli/src/bin.js: --interact-script flag exposed with help text and example - 10 unit tests (mock page, each action, default y, unknown action, order) - 3 e2e tests (with script, without script regression, unknown action throws) Closes #78
1 parent 4a9d44f commit a8518d8

6 files changed

Lines changed: 233 additions & 2 deletions

File tree

cli/src/bin.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ Options:
6767
--wait <ms> Post-load wait before evaluating (default: 3000)
6868
--budget-lcp <ms> Exit 1 if LCP exceeds this value
6969
--budget-cls <score> Exit 1 if CLS exceeds this value
70+
--interact-script <path> JSON file with interactions to run before evaluation
71+
Actions: scroll, click, hover, type, wait
7072
--verbose Show all items, even for passing checks
7173
--headed Show the browser window (debug)
7274
-h, --help Show this help
@@ -79,6 +81,7 @@ Examples:
7981
npx webperf-snippets https://web.dev --snippet render-blocking
8082
npx webperf-snippets https://web.dev --snippet fonts
8183
npx webperf-snippets https://web.dev --budget-lcp 2500
84+
npx webperf-snippets https://web.dev --snippet INP --interact-script interactions.json
8285
`;
8386

8487
function fail(message, code = 2) {
@@ -124,6 +127,7 @@ async function main() {
124127
"budget-lcp": { type: "string" },
125128
"budget-cls": { type: "string" },
126129
viewport: { type: "string" },
130+
"interact-script": { type: "string" },
127131
verbose: { type: "boolean" },
128132
headed: { type: "boolean" },
129133
help: { type: "boolean", short: "h" },
@@ -154,15 +158,17 @@ async function main() {
154158
fail(`Unknown viewport preset: "${viewportName}". Choose from: ${Object.keys(VIEWPORT_PRESETS).join(", ")}`);
155159
}
156160

161+
const interactScript = values["interact-script"];
162+
157163
let payload;
158164
if (values.snippet) {
159165
const items = buildSnippetItem(values);
160-
payload = await runSnippets({ url, items, waitMs, headless: !values.headed, viewport });
166+
payload = await runSnippets({ url, items, waitMs, headless: !values.headed, viewport, interactScript });
161167
} else {
162168
const workflowName = values.workflow ?? "core-web-vitals";
163169
const workflow = WORKFLOWS[workflowName];
164170
if (!workflow) fail(`Unknown workflow: ${workflowName}`);
165-
payload = await runMeasurement({ url, workflow, rules: RULES, waitMs, headless: !values.headed, viewport });
171+
payload = await runMeasurement({ url, workflow, rules: RULES, waitMs, headless: !values.headed, viewport, interactScript });
166172
}
167173

168174
const output = values.json ? reportJson(payload) : reportHuman({ ...payload, verbose: values.verbose });

cli/src/interactions.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { readFileSync } from "node:fs";
2+
3+
export async function executeStep(page, step) {
4+
switch (step.action) {
5+
case "scroll":
6+
await page.evaluate((y) => window.scrollBy(0, y), step.y ?? 300);
7+
break;
8+
case "click":
9+
await page.click(step.selector);
10+
break;
11+
case "hover":
12+
await page.hover(step.selector);
13+
break;
14+
case "type":
15+
await page.type(step.selector, step.text);
16+
break;
17+
case "wait":
18+
await page.waitForTimeout(step.ms);
19+
break;
20+
default:
21+
throw new Error(`Unknown interaction action: "${step.action}"`);
22+
}
23+
}
24+
25+
export async function runInteractions(page, scriptPath) {
26+
const { interactions } = JSON.parse(readFileSync(scriptPath, "utf8"));
27+
for (const step of interactions) {
28+
await executeStep(page, step);
29+
}
30+
}

cli/src/runner.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { chromium } from "playwright";
22
import { loadSnippet } from "./load-snippet.js";
3+
import { runInteractions } from "./interactions.js";
34

45
export const VIEWPORT_PRESETS = {
56
mobile: { width: 375, height: 812 },
@@ -44,6 +45,7 @@ export async function runSnippets({
4445
headless = true,
4546
viewport = VIEWPORT_PRESETS.mobile,
4647
navTimeout = DEFAULT_NAV_TIMEOUT,
48+
interactScript,
4749
}) {
4850
const browser = await chromium.launch({ headless });
4951
try {
@@ -55,6 +57,7 @@ export async function runSnippets({
5557
await page.goto(url, { waitUntil: "load", timeout: navTimeout });
5658
if (waitMs > 0) await page.waitForTimeout(waitMs);
5759
const navMs = Date.now() - navStart;
60+
if (interactScript) await runInteractions(page, interactScript);
5861
const results = await evaluateItems(page, items);
5962
return { url, navMs, results, pageErrors };
6063
} finally {
@@ -70,6 +73,7 @@ export async function runMeasurement({
7073
headless = true,
7174
viewport = VIEWPORT_PRESETS.mobile,
7275
navTimeout = DEFAULT_NAV_TIMEOUT,
76+
interactScript,
7377
}) {
7478
const browser = await chromium.launch({ headless });
7579
try {
@@ -82,6 +86,7 @@ export async function runMeasurement({
8286
await page.goto(url, { waitUntil: "load", timeout: navTimeout });
8387
if (waitMs > 0) await page.waitForTimeout(waitMs);
8488
const navMs = Date.now() - navStart;
89+
if (interactScript) await runInteractions(page, interactScript);
8590

8691
const items = workflow.steps.map((step) => ({
8792
id: step.id,

cli/tests/e2e/interactions.test.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
2+
import { createServer } from "node:http";
3+
import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
4+
import { fileURLToPath } from "node:url";
5+
import { dirname, join } from "node:path";
6+
import { runSnippets, VIEWPORT_PRESETS } from "../../src/runner.js";
7+
import { loadSnippet } from "../../src/load-snippet.js";
8+
9+
const HERE = dirname(fileURLToPath(import.meta.url));
10+
const FIXTURE_HTML = readFileSync(join(HERE, "../fixtures/index.html"), "utf8");
11+
const INTERACT_FIXTURE = join(HERE, "../fixtures/interactions.json");
12+
13+
let server;
14+
let baseUrl;
15+
16+
beforeAll(
17+
() =>
18+
new Promise((resolve) => {
19+
server = createServer((_req, res) => {
20+
res.writeHead(200, { "Content-Type": "text/html" });
21+
res.end(FIXTURE_HTML);
22+
});
23+
server.listen(0, "127.0.0.1", () => {
24+
const { port } = server.address();
25+
baseUrl = `http://127.0.0.1:${port}`;
26+
resolve();
27+
});
28+
}),
29+
10000
30+
);
31+
32+
afterAll(
33+
() =>
34+
new Promise((resolve) => {
35+
server.close(resolve);
36+
})
37+
);
38+
39+
describe("runSnippets with interactScript", () => {
40+
it(
41+
"runs interactions before evaluating snippets and still returns results",
42+
async () => {
43+
const { results, pageErrors } = await runSnippets({
44+
url: baseUrl,
45+
items: [{ id: "LCP", path: "CoreWebVitals/LCP", source: loadSnippet("CoreWebVitals/LCP") }],
46+
waitMs: 500,
47+
viewport: VIEWPORT_PRESETS.mobile,
48+
interactScript: INTERACT_FIXTURE,
49+
});
50+
51+
expect(pageErrors).toHaveLength(0);
52+
const lcp = results.find((r) => r.id === "LCP");
53+
expect(lcp.status).toBe("ok");
54+
expect(typeof lcp.value).toBe("number");
55+
},
56+
30000
57+
);
58+
59+
it(
60+
"omitting interactScript does not change existing behaviour",
61+
async () => {
62+
const { results } = await runSnippets({
63+
url: baseUrl,
64+
items: [{ id: "LCP", path: "CoreWebVitals/LCP", source: loadSnippet("CoreWebVitals/LCP") }],
65+
waitMs: 500,
66+
viewport: VIEWPORT_PRESETS.mobile,
67+
});
68+
69+
const lcp = results.find((r) => r.id === "LCP");
70+
expect(lcp.status).toBe("ok");
71+
},
72+
30000
73+
);
74+
75+
it(
76+
"throws when interactScript contains an unknown action",
77+
async () => {
78+
const badScript = join(HERE, "../fixtures/bad-interactions.json");
79+
writeFileSync(badScript, JSON.stringify({ interactions: [{ action: "fly" }] }));
80+
try {
81+
await expect(
82+
runSnippets({
83+
url: baseUrl,
84+
items: [{ id: "LCP", path: "CoreWebVitals/LCP", source: loadSnippet("CoreWebVitals/LCP") }],
85+
waitMs: 500,
86+
viewport: VIEWPORT_PRESETS.mobile,
87+
interactScript: badScript,
88+
})
89+
).rejects.toThrow('Unknown interaction action: "fly"');
90+
} finally {
91+
unlinkSync(badScript);
92+
}
93+
},
94+
30000
95+
);
96+
});
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"interactions": [
3+
{ "action": "scroll", "y": 200 },
4+
{ "action": "wait", "ms": 50 }
5+
]
6+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { fileURLToPath } from "node:url";
3+
import { dirname, join } from "node:path";
4+
import { executeStep, runInteractions } from "../../src/interactions.js";
5+
6+
const HERE = dirname(fileURLToPath(import.meta.url));
7+
const FIXTURES = join(HERE, "../fixtures");
8+
9+
function mockPage() {
10+
return {
11+
evaluate: vi.fn().mockResolvedValue(undefined),
12+
click: vi.fn().mockResolvedValue(undefined),
13+
hover: vi.fn().mockResolvedValue(undefined),
14+
type: vi.fn().mockResolvedValue(undefined),
15+
waitForTimeout: vi.fn().mockResolvedValue(undefined),
16+
};
17+
}
18+
19+
describe("executeStep", () => {
20+
let page;
21+
22+
beforeEach(() => {
23+
page = mockPage();
24+
});
25+
26+
it("scroll — calls page.evaluate with the given y value", async () => {
27+
await executeStep(page, { action: "scroll", y: 500 });
28+
expect(page.evaluate).toHaveBeenCalledOnce();
29+
expect(page.evaluate).toHaveBeenCalledWith(expect.any(Function), 500);
30+
});
31+
32+
it("scroll — defaults y to 300 when omitted", async () => {
33+
await executeStep(page, { action: "scroll" });
34+
expect(page.evaluate).toHaveBeenCalledWith(expect.any(Function), 300);
35+
});
36+
37+
it("click — calls page.click with the selector", async () => {
38+
await executeStep(page, { action: "click", selector: "#btn" });
39+
expect(page.click).toHaveBeenCalledWith("#btn");
40+
});
41+
42+
it("hover — calls page.hover with the selector", async () => {
43+
await executeStep(page, { action: "hover", selector: ".menu" });
44+
expect(page.hover).toHaveBeenCalledWith(".menu");
45+
});
46+
47+
it("type — calls page.type with selector and text", async () => {
48+
await executeStep(page, { action: "type", selector: "input", text: "hello" });
49+
expect(page.type).toHaveBeenCalledWith("input", "hello");
50+
});
51+
52+
it("wait — calls page.waitForTimeout with the given ms", async () => {
53+
await executeStep(page, { action: "wait", ms: 500 });
54+
expect(page.waitForTimeout).toHaveBeenCalledWith(500);
55+
});
56+
57+
it("unknown action — throws with a clear message", async () => {
58+
await expect(executeStep(page, { action: "fly" })).rejects.toThrow(
59+
'Unknown interaction action: "fly"'
60+
);
61+
});
62+
63+
it("unknown action — no page methods are called before throwing", async () => {
64+
await expect(executeStep(page, { action: "teleport" })).rejects.toThrow();
65+
expect(page.click).not.toHaveBeenCalled();
66+
expect(page.evaluate).not.toHaveBeenCalled();
67+
});
68+
});
69+
70+
describe("runInteractions", () => {
71+
it("reads the JSON file and executes all steps in order", async () => {
72+
const page = mockPage();
73+
const calls = [];
74+
page.evaluate.mockImplementation(() => { calls.push("scroll"); });
75+
page.waitForTimeout.mockImplementation(() => { calls.push("wait"); });
76+
77+
await runInteractions(page, join(FIXTURES, "interactions.json"));
78+
79+
expect(calls).toEqual(["scroll", "wait"]);
80+
});
81+
82+
it("throws when the file does not exist", async () => {
83+
const page = mockPage();
84+
await expect(
85+
runInteractions(page, join(FIXTURES, "nonexistent.json"))
86+
).rejects.toThrow();
87+
});
88+
});

0 commit comments

Comments
 (0)