diff --git a/Cargo.lock b/Cargo.lock
index 28e94c47..aa44a6a6 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -154,6 +154,7 @@ dependencies = [
"nix",
"rand 0.8.6",
"reqwest",
+ "schemars",
"semver",
"serde",
"serde_json",
diff --git a/apps/extension/src/content/__tests__/record-capture.test.ts b/apps/extension/src/content/__tests__/record-capture.test.ts
index 30108d8c..77aee7dd 100644
--- a/apps/extension/src/content/__tests__/record-capture.test.ts
+++ b/apps/extension/src/content/__tests__/record-capture.test.ts
@@ -1517,4 +1517,59 @@ describe("record-capture semantic", () => {
expect(steps.map((s) => s.op)).toEqual(["click"]);
});
+ it("records a click on a control whose page happens to contain a search box", () => {
+ // MediaWiki Vector ships `
`, which
+ // `closest('[class*="search"]')` used to match for every click on the page.
+ document.body.className = "skin-vector skin-vector-search-vue";
+ document.body.innerHTML = `
+
+
+ `;
+ const capture = startRecordCapture("rec-search-body", (step) => steps.push(step));
+ click(document.querySelector("span")!);
+ capture.dispose();
+ document.body.className = "";
+
+ expect(steps).toEqual([
+ expect.objectContaining({
+ op: "click",
+ target: expect.objectContaining({ name: "\u9690\u85cf\u76ee\u5f55" }),
+ }),
+ ]);
+ });
+
+ it("records a link click inside a form that also holds a search box", () => {
+ document.body.innerHTML = `
+
+ `;
+ const capture = startRecordCapture("rec-search-form", (step) => steps.push(step));
+ click(document.querySelector("a")!);
+ capture.dispose();
+
+ expect(steps).toEqual([
+ expect.objectContaining({ op: "click", target: expect.objectContaining({ name: "Help" }) }),
+ ]);
+ });
+
+ it("still redirects a click on bare search chrome into a fill session", () => {
+ document.body.innerHTML = `
+
+
+
+
+ `;
+ const capture = startRecordCapture("rec-search-chrome", (step) => steps.push(step));
+ const icon = document.querySelector(".icon")!;
+ click(icon);
+ const input = document.querySelector("input")!;
+ input.value = "hello";
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ input.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
+ capture.dispose();
+
+ expect(steps.map((s) => s.op)).toEqual(["fill"]);
+ });
});
diff --git a/apps/extension/src/content/__tests__/record-frame-agent.test.ts b/apps/extension/src/content/__tests__/record-frame-agent.test.ts
index 7c55de3d..27c2bd7a 100644
--- a/apps/extension/src/content/__tests__/record-frame-agent.test.ts
+++ b/apps/extension/src/content/__tests__/record-frame-agent.test.ts
@@ -2,7 +2,14 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { RecordFramePortMessage } from "@/lib/recording/frame-bridge";
import { RECORD_FRAME_START } from "@/lib/recording/frame-bridge";
import { RECORD_DOCUMENT_ATTRIBUTE } from "@/shared/recording-document-identity";
-import { RecordFrameAgent } from "../recording/frame-agent";
+import { attachRecordFrameAgent, RecordFrameAgent } from "../recording/frame-agent";
+
+/** happy-dom's PageTransitionEvent drops `persisted`, so set it directly. */
+function pageShowEvent(persisted: boolean): Event {
+ const event = new Event("pageshow");
+ Object.defineProperty(event, "persisted", { value: persisted });
+ return event;
+}
class PortListeners unknown> {
readonly values = new Set();
@@ -37,6 +44,7 @@ function portHarness() {
return {
port,
outbound,
+ disconnectListeners: onDisconnect.values,
receive(message: RecordFramePortMessage) {
for (const listener of onMessage.values) listener(message);
},
@@ -121,4 +129,54 @@ describe("RecordFrameAgent", () => {
expect(sendMessage).toHaveBeenCalledTimes(3);
expect(document.documentElement.hasAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(false);
});
+ it("re-arms the restored Document after a back/forward-cache pageshow", async () => {
+ const harness = portHarness();
+ const connect = vi.fn(() => harness.port);
+ const sendMessage = vi.fn(async (message: { type?: string }) =>
+ message.type === "bsk-record-frame-query"
+ ? { active: true, requestId: "rec-bfcache", startedAtMs: 10 }
+ : undefined,
+ );
+ vi.stubGlobal("chrome", {
+ runtime: { connect, sendMessage, onMessage: new PortListeners() },
+ });
+
+ const dispose = attachRecordFrameAgent();
+ await vi.waitFor(() =>
+ expect(document.documentElement.hasAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(true),
+ );
+ expect(connect).toHaveBeenCalledTimes(1);
+
+ // Entering the cache drops the port, which tears capture down here.
+ for (const listener of harness.disconnectListeners) listener();
+ expect(document.documentElement.hasAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(false);
+
+ window.dispatchEvent(pageShowEvent(true));
+ await vi.waitFor(() => expect(connect).toHaveBeenCalledTimes(2));
+ expect(document.documentElement.hasAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(true);
+
+ dispose();
+ });
+
+ it("ignores a pageshow that is not a cache restore", async () => {
+ const harness = portHarness();
+ const connect = vi.fn(() => harness.port);
+ const sendMessage = vi.fn(async () => ({
+ active: true,
+ requestId: "rec-plain",
+ startedAtMs: 10,
+ }));
+ vi.stubGlobal("chrome", {
+ runtime: { connect, sendMessage, onMessage: new PortListeners() },
+ });
+
+ const dispose = attachRecordFrameAgent();
+ await vi.waitFor(() => expect(connect).toHaveBeenCalledTimes(1));
+
+ window.dispatchEvent(pageShowEvent(false));
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ expect(connect).toHaveBeenCalledTimes(1);
+
+ dispose();
+ });
});
diff --git a/apps/extension/src/content/record-capture.ts b/apps/extension/src/content/record-capture.ts
index 22f7001b..7b77f861 100644
--- a/apps/extension/src/content/record-capture.ts
+++ b/apps/extension/src/content/record-capture.ts
@@ -102,12 +102,56 @@ function fillableFromTarget(target: EventTarget | null): FillableElement | null
return null;
}
+const SEARCH_CHROME_SELECTOR =
+ '[id*="chat-input"], [id*="search"], [class*="search"], form, [role="search"]';
+
+/**
+ * A control the user can name is its own step, so it must never be rewritten
+ * into "focus the search box next to it".
+ */
+const SEARCH_CHROME_CONTROL_SELECTOR = [
+ "a[href]",
+ "button",
+ "select",
+ "textarea",
+ "input",
+ "summary",
+ '[role="button"]',
+ '[role="link"]',
+ '[role="tab"]',
+ '[role="menuitem"]',
+ '[role="menuitemcheckbox"]',
+ '[role="menuitemradio"]',
+ '[role="checkbox"]',
+ '[role="radio"]',
+ '[role="switch"]',
+ '[role="option"]',
+].join(", ");
+
+/**
+ * Search chrome is a small wrapper drawn around the input, so only a handful of
+ * ancestors may claim a click. Without the bound, `closest` happily returns a
+ * page-level node — MediaWiki Vector renders ``, and
+ * a site-wide `