Skip to content

Commit 4b6cdde

Browse files
authored
Merge pull request #115 from Tencent/fix/recorder-iframe
fix(recording): support user action recording in iframes and OOPIFs
2 parents d05279f + 4aade4b commit 4b6cdde

89 files changed

Lines changed: 14379 additions & 1642 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/extension/src/content/__tests__/record-capture.test.ts

Lines changed: 19 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { beforeEach, describe, expect, it, vi } from "vitest";
2-
import { RECORD_STOP, type RecordStepPayload } from "@/lib/record-bridge";
3-
import { handleRecordContentMessage, startRecordCapture } from "../record-capture";
2+
import type { RecordStepPayload } from "@/lib/record-bridge";
3+
import { startRecordCapture } from "../record-capture";
44

55
vi.stubGlobal("chrome", {
66
runtime: {
7-
sendMessage: vi.fn(() => Promise.resolve()),
7+
sendMessage: vi.fn((message: { sequence?: number }) =>
8+
Promise.resolve({ ok: true, sequence: message.sequence }),
9+
),
810
},
911
});
1012

@@ -46,58 +48,6 @@ function click(el: Element): void {
4648
el.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
4749
}
4850

49-
describe("handleRecordContentMessage stop/cancel", () => {
50-
it("ignores STOP when no recording is active", () => {
51-
const dispose = vi.fn();
52-
const onStop = vi.fn();
53-
const setActiveRequestId = vi.fn();
54-
const setCapture = vi.fn();
55-
const sendResponse = vi.fn();
56-
57-
const needsAsync = handleRecordContentMessage(
58-
{ type: RECORD_STOP, requestId: "rec-stale" },
59-
{
60-
activeRequestId: null,
61-
capture: { dispose },
62-
setActiveRequestId,
63-
setCapture,
64-
onStart: vi.fn(),
65-
onStop,
66-
},
67-
sendResponse,
68-
);
69-
70-
expect(needsAsync).toBe(false);
71-
expect(dispose).not.toHaveBeenCalled();
72-
expect(onStop).not.toHaveBeenCalled();
73-
expect(setActiveRequestId).not.toHaveBeenCalled();
74-
expect(setCapture).not.toHaveBeenCalled();
75-
expect(sendResponse).not.toHaveBeenCalled();
76-
});
77-
78-
it("ignores STOP for a mismatched requestId", () => {
79-
const dispose = vi.fn();
80-
const onStop = vi.fn();
81-
82-
const needsAsync = handleRecordContentMessage(
83-
{ type: RECORD_STOP, requestId: "rec-other" },
84-
{
85-
activeRequestId: "rec-1",
86-
capture: { dispose },
87-
setActiveRequestId: vi.fn(),
88-
setCapture: vi.fn(),
89-
onStart: vi.fn(),
90-
onStop,
91-
},
92-
vi.fn(),
93-
);
94-
95-
expect(needsAsync).toBe(false);
96-
expect(dispose).not.toHaveBeenCalled();
97-
expect(onStop).not.toHaveBeenCalled();
98-
});
99-
});
100-
10151
describe("record-capture semantic", () => {
10252
let steps: RecordStepPayload[];
10353

@@ -174,6 +124,19 @@ describe("record-capture semantic", () => {
174124
]);
175125
});
176126

127+
it("does not emit page navigation from a child Document capture", () => {
128+
const originalUrl = location.href;
129+
const capture = startRecordCapture("rec-child", (step) => steps.push(step), {
130+
captureNavigation: false,
131+
});
132+
133+
history.pushState({}, "", "#inside-frame");
134+
expect(steps).toEqual([]);
135+
136+
capture.dispose();
137+
history.replaceState({}, "", originalUrl);
138+
});
139+
177140
it("does not record clicks on anonymous layout divs", () => {
178141
document.body.innerHTML = `
179142
<div id="chrome">page chrome</div>
@@ -228,6 +191,7 @@ describe("record-capture semantic", () => {
228191
expect(steps[0]).toMatchObject({
229192
op: "hover",
230193
target: { role: "button", name: "Open user navigation menu" },
194+
geometry: { tag: "button", rect: { x: 900, y: 8, w: 32, h: 32 } },
231195
});
232196
expect(steps[1]).toMatchObject({
233197
op: "click",
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import type { RecordFramePortMessage } from "@/lib/recording/frame-bridge";
3+
import { RECORD_FRAME_START } from "@/lib/recording/frame-bridge";
4+
import { RECORD_DOCUMENT_ATTRIBUTE } from "@/shared/recording-document-identity";
5+
import { RecordFrameAgent } from "../recording/frame-agent";
6+
7+
class PortListeners<T extends (...args: never[]) => unknown> {
8+
readonly values = new Set<T>();
9+
addListener = (listener: T) => this.values.add(listener);
10+
removeListener = (listener: T) => this.values.delete(listener);
11+
}
12+
13+
function portHarness() {
14+
const onMessage = new PortListeners<(message: unknown) => void>();
15+
const onDisconnect = new PortListeners<() => void>();
16+
const outbound: RecordFramePortMessage[] = [];
17+
const port = {
18+
name: "bsk-record-frame",
19+
onMessage,
20+
onDisconnect,
21+
postMessage(message: RecordFramePortMessage) {
22+
outbound.push(message);
23+
if (message.type === "ready") {
24+
queueMicrotask(() => {
25+
for (const listener of onMessage.values) {
26+
listener({
27+
type: "ready_ack",
28+
requestId: message.requestId,
29+
producerId: message.producerId,
30+
});
31+
}
32+
});
33+
}
34+
},
35+
disconnect: vi.fn(),
36+
} as unknown as chrome.runtime.Port;
37+
return {
38+
port,
39+
outbound,
40+
receive(message: RecordFramePortMessage) {
41+
for (const listener of onMessage.values) listener(message);
42+
},
43+
};
44+
}
45+
46+
describe("RecordFrameAgent", () => {
47+
afterEach(() => {
48+
document.documentElement.removeAttribute(RECORD_DOCUMENT_ATTRIBUTE);
49+
document.body.replaceChildren();
50+
});
51+
52+
it("keeps a failed stop retryable and flushes the final dirty fill", async () => {
53+
const harness = portHarness();
54+
const sendMessage = vi
55+
.fn<(message: { sequence: number }) => Promise<unknown>>()
56+
.mockRejectedValueOnce(new Error("offline"))
57+
.mockRejectedValueOnce(new Error("still offline"))
58+
.mockImplementation(async (message) => ({ ok: true, sequence: message.sequence }));
59+
vi.stubGlobal("chrome", {
60+
runtime: {
61+
connect: vi.fn(() => harness.port),
62+
sendMessage,
63+
},
64+
});
65+
document.body.innerHTML = `<label for="draft">Draft</label><input id="draft" />`;
66+
const agent = new RecordFrameAgent();
67+
68+
await expect(
69+
agent.start({ type: RECORD_FRAME_START, requestId: "rec-1", startedAtMs: 10 }),
70+
).resolves.toEqual({ ok: true });
71+
expect(document.documentElement.hasAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(true);
72+
73+
const input = document.querySelector("input")!;
74+
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
75+
input.value = "final value";
76+
input.dispatchEvent(new Event("input", { bubbles: true }));
77+
78+
harness.receive({ type: "stop", requestId: "rec-1", commandId: "stop-1" });
79+
await vi.waitFor(() =>
80+
expect(harness.outbound).toContainEqual({
81+
type: "stopped",
82+
requestId: "rec-1",
83+
commandId: "stop-1",
84+
ok: false,
85+
error: "failed to deliver one or more recorded steps",
86+
}),
87+
);
88+
expect(document.documentElement.hasAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(true);
89+
90+
harness.receive({ type: "stop", requestId: "rec-1", commandId: "stop-2" });
91+
await vi.waitFor(() =>
92+
expect(harness.outbound).toContainEqual({
93+
type: "stopped",
94+
requestId: "rec-1",
95+
commandId: "stop-2",
96+
ok: true,
97+
}),
98+
);
99+
expect(sendMessage).toHaveBeenCalledTimes(3);
100+
expect(document.documentElement.hasAttribute(RECORD_DOCUMENT_ATTRIBUTE)).toBe(false);
101+
});
102+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import type { RecordStepMessage } from "@/lib/record-bridge";
3+
import { RecordStepDelivery } from "../record-step-delivery";
4+
5+
describe("RecordStepDelivery", () => {
6+
it("retries from the first unacknowledged sequence before sending later steps", async () => {
7+
const sent: RecordStepMessage[] = [];
8+
const send = vi.fn(async (message: RecordStepMessage) => {
9+
sent.push(message);
10+
if (sent.length === 1) throw new Error("service worker unavailable");
11+
return { ok: true, sequence: message.sequence };
12+
});
13+
const delivery = new RecordStepDelivery("rec-ordered", send, "document-1");
14+
15+
delivery.enqueue({ op: "click", target: { tag: "button", name: "First" } });
16+
delivery.enqueue({ op: "click", target: { tag: "button", name: "Second" } });
17+
18+
await expect(delivery.flush()).resolves.toBe(true);
19+
expect(sent.map((message) => message.sequence)).toEqual([1, 1, 2]);
20+
expect(sent.map((message) => message.step.target?.name)).toEqual(["First", "First", "Second"]);
21+
});
22+
23+
it("keeps unacknowledged steps pending for a later flush", async () => {
24+
const send = vi
25+
.fn<(message: RecordStepMessage) => Promise<unknown>>()
26+
.mockRejectedValueOnce(new Error("offline"))
27+
.mockRejectedValueOnce(new Error("still offline"))
28+
.mockImplementation(async (message) => ({ ok: true, sequence: message.sequence }));
29+
const delivery = new RecordStepDelivery("rec-retry", send, "document-1");
30+
delivery.enqueue({ op: "click", target: { tag: "button", name: "Save" } });
31+
32+
await expect(delivery.flush()).resolves.toBe(false);
33+
await expect(delivery.flush()).resolves.toBe(true);
34+
expect(send).toHaveBeenCalledTimes(3);
35+
});
36+
});

0 commit comments

Comments
 (0)