Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 55 additions & 0 deletions apps/extension/src/content/__tests__/record-capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<body class="…skin-vector-search-vue…">`, 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 = `
<form id="searchform"><input type="search" name="search" /></form>
<nav><button type="button" aria-label="\u9690\u85cf\u76ee\u5f55"><span>x</span></button></nav>
`;
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 = `
<form>
<input type="search" name="q" />
<a href="/help">Help</a>
</form>
`;
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 = `
<div class="search-box">
<span class="icon"></span>
<input type="search" name="q" />
</div>
`;
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"]);
});
});
60 changes: 59 additions & 1 deletion apps/extension/src/content/__tests__/record-frame-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends (...args: never[]) => unknown> {
readonly values = new Set<T>();
Expand Down Expand Up @@ -37,6 +44,7 @@ function portHarness() {
return {
port,
outbound,
disconnectListeners: onDisconnect.values,
receive(message: RecordFramePortMessage) {
for (const listener of onMessage.values) listener(message);
},
Expand Down Expand Up @@ -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();
});
});
52 changes: 48 additions & 4 deletions apps/extension/src/content/record-capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<body class="…search-vue…">`, and
* a site-wide `<form>` is just as common — and every click on the page would be
* swallowed into a fill session on whatever search box the page happens to have.
*/
const SEARCH_CHROME_MAX_DEPTH = 4;

function isSearchChromeWrapper(container: Element, target: Element): boolean {
if (container === document.body || container === document.documentElement) return false;
let node: Element | null = target;
for (let depth = 0; node && depth <= SEARCH_CHROME_MAX_DEPTH; depth += 1) {
if (node === container) return true;
node = node.parentElement;
}
return false;
}

/** Clicks on search chrome that only focus the nearby input should not become steps. */
function nearbyFillableFromSearchChrome(target: Element): FillableElement | null {
const container = target.closest(
'[id*="chat-input"], [id*="search"], [class*="search"], form, [role="search"]',
);
if (!container) return null;
if (target.closest(SEARCH_CHROME_CONTROL_SELECTOR)) return null;
const container = target.closest(SEARCH_CHROME_SELECTOR);
if (!container || !isSearchChromeWrapper(container, target)) return null;
const fillable = container.querySelector(
'textarea, input[type="search"], input[name="q"], #chat-textarea',
);
Expand Down
33 changes: 22 additions & 11 deletions apps/extension/src/content/recording/frame-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,19 +192,30 @@ export function attachRecordFrameAgent(): () => void {
return true;
};
chrome.runtime.onMessage.addListener(onMessage);
void chrome.runtime
.sendMessage({ type: RECORD_FRAME_QUERY })
.then((response: RecordFrameQueryResponse | undefined) => {
if (!response?.active || !response.requestId || response.startedAtMs === undefined) return;
return agent.start({
type: RECORD_FRAME_START,
requestId: response.requestId,
startedAtMs: response.startedAtMs,
});
})
.catch(() => {});
const armFromBackground = () =>
chrome.runtime
.sendMessage({ type: RECORD_FRAME_QUERY })
.then((response: RecordFrameQueryResponse | undefined) => {
if (!response?.active || !response.requestId || response.startedAtMs === undefined) return;
return agent.start({
type: RECORD_FRAME_START,
requestId: response.requestId,
startedAtMs: response.startedAtMs,
});
})
.catch(() => {});
void armFromBackground();
// Entering the back/forward cache disconnects the recording port, which tears
// capture down in this Document. The content script does not run again on
// restore, so the restored Document has to re-arm itself. `start` is a no-op
// while the same recording is already active.
const onPageShow = (event: PageTransitionEvent) => {
if (event.persisted) void armFromBackground();
};
window.addEventListener("pageshow", onPageShow);
return () => {
chrome.runtime.onMessage.removeListener(onMessage);
window.removeEventListener("pageshow", onPageShow);
agent.dispose();
};
}
53 changes: 53 additions & 0 deletions apps/extension/src/tools/__tests__/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,11 @@ function makeFakeCdp(opts?: {
const payload = { name, frameId, loaderId };
for (const listener of [...events]) listener({ tabId: 4 }, "Page.lifecycleEvent", payload);
},
fireNavigatedWithinDocument(frameId = opts?.navigateFrameId ?? "frame-1", url = "#anchor") {
for (const listener of [...events]) {
listener({ tabId: 4 }, "Page.navigatedWithinDocument", { frameId, url });
}
},
fireFrameNavigated(
frameId = opts?.navigateFrameId ?? "frame-1",
loaderId = opts?.navigateLoaderId ?? "loader-after",
Expand Down Expand Up @@ -522,6 +527,54 @@ describe("handleNavigateBack / Forward", () => {
expect(res.previous_url).toBe("https://b.example/");
});

it("finishes a fragment-only back hop on the same-document event", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
historyIndex: 1,
historyEntries: [
{ id: 11, url: "https://a.example/page" },
{ id: 12, url: "https://a.example/page#section" },
],
});
const navP = handleNavigateBack(
sm,
{ session_id: "aa11", wait_until: "load", timeout_ms: 1_000 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
await new Promise((r) => setTimeout(r, 5));
// No new Document, so no lifecycle event will ever arrive for this hop.
fake.fireNavigatedWithinDocument();
const res = await navP;
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.reached).toBe("same_document");
expect(res.error_text).toBeUndefined();
});

it("keeps waiting for load when the history hop changes more than the fragment", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const fake = makeFakeCdp({
historyIndex: 1,
historyEntries: [
{ id: 11, url: "https://a.example/" },
{ id: 12, url: "https://b.example/#x" },
],
});
const navP = handleNavigateBack(
sm,
{ session_id: "aa11", wait_until: "load", timeout_ms: 1_000 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
await new Promise((r) => setTimeout(r, 5));
fake.fireNavigatedWithinDocument();
await new Promise((r) => setTimeout(r, 5));
fake.fireLifecycle("load");
const res = await navP;
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.reached).toBe("load");
});

it("returns invalid_params when there is no previous history", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
Expand Down
Loading