返回 DeepSeek-Reasonix
snapshot.test.ts
根目录 / desktop / electron / src / main / browser / snapshot.test.ts
1 import assert from "node:assert/strict";
2 import { test } from "node:test";
3 import { runInContext } from "node:vm";
4 import { JSDOM } from "jsdom";
5 import { DocumentRegistry } from "./documents.js";
6 import { BROWSER_ERR_STALE_REFERENCE } from "./errors.js";
7 import { FakeFrame, FakePage } from "./fakeGuestViews.js";
8 import { LOCATE_SCRIPT_SOURCE, RESOLVE_SCRIPT_SOURCE, scriptCall, SELECT_SCRIPT_SOURCE, type LocateOutput, type ResolveOutput, type SelectOutput } from "./pageScripts.js";
9 import { resolveRef } from "./refResolver.js";
10 import { RpcError } from "../rpc.js";
11 import { REGISTRY_KEY, takeSnapshot } from "./snapshot.js";
12 import { SNAPSHOT_SCRIPT_SOURCE, type SnapshotOutput } from "./snapshotScript.js";
13
14 // Script outputs are plain data but live in the page realm; deepStrictEqual
15 // compares prototypes, so JSON round-trip them before asserting.
16 const plain = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
17
18 // jsdom has no layout: rects come from data-rect="x,y,w,h" (default 10x10)
19 // and elementFromPoint answers with whatever the test pinned.
20 function page(html: string) {
21 const dom = new JSDOM(`<!doctype html><html><body>${html}</body></html>`, { runScripts: "outside-only", pretendToBeVisual: true, url: "https://site.test/page" });
22 const win = dom.window;
23 const hit: { element: Element | null } = { element: null };
24 win.Element.prototype.getBoundingClientRect = function (this: Element) {
25 const spec = this.getAttribute("data-rect");
26 const [x, y, width, height] = spec ? spec.split(",").map(Number) : [0, 0, 10, 10];
27 return { x, y, width, height, left: x, top: y, right: x + width, bottom: y + height, toJSON: () => ({}) } as DOMRect;
28 };
29 win.document.elementFromPoint = () => hit.element;
30 const context = dom.getInternalVMContext();
31 // tsx transpiles with esbuild keepNames, which inserts __name(...) calls
32 // into the serialised page scripts; the production bundle (esbuild without
33 // keepNames) never emits them, so the test page gets a no-op shim.
34 (context as Record<string, unknown>).__name = (fn: unknown) => fn;
35 const run = (code: string) => runInContext(code, context) as unknown;
36 const snapshot = (input: Partial<{ snapshotId: string; prefix: string; selector: string; budget: number }> = {}) =>
37 run(scriptCall(SNAPSHOT_SCRIPT_SOURCE, { key: REGISTRY_KEY, snapshotId: "snap-1", prefix: "", selector: "", budget: 4000, ...input })) as SnapshotOutput;
38 return { dom, win, hit, run, snapshot };
39 }
40
41 test("the snapshot walker emits roles, names, states and refs one node per line", () => {
42 const { snapshot } = page(`
43 <nav aria-label="Main"><a href="/home">Home</a><a>no href</a></nav>
44 <h2>Sign in</h2>
45 <form>
46 <label for="u">Username</label><input id="u" value="ann" placeholder="user">
47 <input type="password" aria-label="Password" value="secret">
48 <input type="checkbox" checked aria-label="Remember"> <button disabled>Go</button>
49 <select aria-label="Role"><option value="a">Admin</option><option value="b" selected>Basic</option></select>
50 <div hidden>hidden text</div><span aria-hidden="true">assistive only</span>
51 <p>Some <b>bold</b> text</p>
52 <div tabindex="0">Clickable card</div>
53 <textarea aria-label="Bio">hello</textarea>
54 <img alt="Logo"><img alt="">
55 <input type="file" aria-label="Attach">
56 <table><tr><th>Name</th><td>Ann</td></tr></table>
57 <ul><li>one</li><li data-rect="0,0,0,0">zero size</li></ul>
58 <details open><summary>More</summary>body</details>
59 </form>`);
60 const out = snapshot();
61 const lines = out.tree.split("\n");
62 assert.deepEqual(lines, [
63 'navigation "Main" ref=e1',
64 ' link "Home" ref=e2',
65 ' text "no href"',
66 'heading "Sign in" [level=2] ref=e3',
67 "form",
68 ' textbox "Username" [value="ann"] ref=e4',
69 ' textbox "Password" [password] ref=e5',
70 ' checkbox "Remember" [checked] ref=e6',
71 ' button "Go" [disabled] ref=e7',
72 ' combobox "Role" [value="Basic"] ref=e8',
73 ' option "Admin" ref=e9',
74 ' option "Basic" [selected] ref=e10',
75 ' text "Some"',
76 ' text "bold"',
77 ' text "text"',
78 ' generic "Clickable card" [clickable] ref=e11',
79 ' textbox "Bio" [value="hello"] ref=e12',
80 ' img "Logo" ref=e13',
81 " img",
82 ' button "Attach" [file] ref=e14',
83 " table",
84 " row",
85 ' columnheader "Name" ref=e15',
86 ' cell "Ann" ref=e16',
87 " list",
88 ' listitem "one" ref=e17',
89 " group [expanded]",
90 ' button "More" ref=e18',
91 ' text "body"',
92 ]);
93 assert.equal(out.refs, 18);
94 assert.match(out.docId, /^\d+(\.\d+)?:[a-z0-9]+$/);
95 assert.equal(out.tree.includes("secret"), false, "password values never appear");
96 });
97
98 test("the walker honours selector scoping, the node budget and a stable document identity", () => {
99 const { snapshot, run } = page(`<main><button>A</button><button>B</button><button>C</button></main><aside><a href="#">x</a></aside>`);
100 const first = snapshot({ budget: 2 });
101 assert.deepEqual(first.tree.split("\n"), ["main", ' button "A" ref=e1', "… (4 more nodes)"]);
102 assert.equal(first.truncated, 4);
103 assert.equal(first.nodes, 2);
104 const scoped = snapshot({ selector: "aside", prefix: "f2", snapshotId: "snap-2" });
105 assert.deepEqual(scoped.tree.split("\n"), ['link "x" [href="#"] ref=f2e1']);
106 assert.equal(snapshot({ selector: "#nope" }).tree, '(no element matches selector "#nope")');
107 assert.equal(scoped.docId, first.docId, "the identity survives re-snapshots of the same document");
108 const registry = run(`window[${JSON.stringify(REGISTRY_KEY)}]`) as { snapshotId: string; refs: Map<string, Element> };
109 assert.equal(registry.snapshotId, "snap-1", "the latest snapshot owns the registry");
110 assert.equal(run(`Object.keys(window).includes(${JSON.stringify(REGISTRY_KEY)})`), false, "the registry is not enumerable");
111 });
112
113 test("resolve, locate and select validate the snapshot token and the document identity", () => {
114 const { snapshot, run, hit, win } = page(`<button data-rect="100,50,80,30">Go</button><select aria-label="S"><option value="1">One</option><option value="2">Two</option></select><input type="file" style="display:none" aria-label="F">`);
115 const out = snapshot();
116 const resolve = (ref: string, extra: Record<string, unknown> = {}) =>
117 plain(run(scriptCall(RESOLVE_SCRIPT_SOURCE, { key: REGISTRY_KEY, snapshotId: "snap-1", docId: out.docId, ref, scroll: true, ...extra })) as ResolveOutput);
118 const resolved = resolve("e1");
119 assert.deepEqual(resolved, { ok: true, x: 100, y: 50, width: 80, height: 30, tag: "button", type: "", disabled: false, editable: false, frameOffsetKnown: true });
120 assert.deepEqual(resolve("e1", { snapshotId: "old" }), { ok: false, reason: "stale" });
121 assert.deepEqual(resolve("e1", { docId: "other" }), { ok: false, reason: "stale" });
122 assert.deepEqual(resolve("e99"), { ok: false, reason: "stale" });
123 hit.element = win.document.querySelector("select");
124 assert.deepEqual(resolve("e1"), { ok: false, reason: "element is covered by another element" });
125 hit.element = null;
126 win.document.querySelector("button")?.remove();
127 assert.deepEqual(resolve("e1"), { ok: false, reason: "element is no longer in the document" });
128
129 const located = plain(run(scriptCall(LOCATE_SCRIPT_SOURCE, { key: REGISTRY_KEY, snapshotId: "snap-1", docId: out.docId, ref: "e5" })) as LocateOutput);
130 assert.deepEqual(located, { ok: true, tag: "input", type: "file", path: "html > body:nth-child(2) > input:nth-child(2)" });
131
132 const changes: string[] = [];
133 win.document.querySelector("select")?.addEventListener("change", () => changes.push("change"));
134 const selected = plain(run(scriptCall(SELECT_SCRIPT_SOURCE, { key: REGISTRY_KEY, snapshotId: "snap-1", docId: out.docId, ref: "e2", options: ["Two"] })) as SelectOutput);
135 assert.deepEqual(selected, { ok: true, selected: ["2"] });
136 assert.equal((win.document.querySelector("select") as HTMLSelectElement).value, "2");
137 assert.deepEqual(changes, ["change"]);
138 assert.deepEqual(plain(run(scriptCall(SELECT_SCRIPT_SOURCE, { key: REGISTRY_KEY, snapshotId: "snap-1", docId: out.docId, ref: "e2", options: ["Nine"] }))), { ok: false, reason: "no option matches the requested values" });
139 });
140
141 test("takeSnapshot assembles the main frame and reachable child frames under one token", async () => {
142 const main = page(`<h1>Top</h1><iframe title="Login frame"></iframe>`);
143 const child = page(`<button>Inside</button>`);
144 const broken = page(`<p>never seen</p>`);
145 const fake = new FakePage(7);
146 fake.url = "https://site.test/page";
147 fake.title = "Site";
148 const childFrame = new FakeFrame(701, "https://login.test/", (code) => child.run(code));
149 const brokenFrame = new FakeFrame(702, "https://cross.test/", () => {
150 throw new Error("cross-origin");
151 });
152 fake.mainFrame.children.push(childFrame, brokenFrame);
153 fake.run = (code, frame) => (frame === fake.mainFrame ? main.run(code) : broken.run(code));
154 const documents = new DocumentRegistry(() => "tok-1");
155 const result = await takeSnapshot(fake, "tab-1", 3, "", documents);
156 assert.equal(result.documentToken, "tok-1");
157 assert.equal(result.url, "https://site.test/page");
158 assert.equal(result.title, "Site");
159 assert.equal(result.refs, 2);
160 assert.deepEqual(result.tree.split("\n"), ['heading "Top" [level=1] ref=e1', 'iframe "Login frame"', 'frame f1 "https://login.test/"', ' button "Inside" ref=f1e1']);
161 const binding = documents.lookup("tok-1");
162 assert.ok(binding);
163 assert.equal(binding.epoch, 3);
164 assert.deepEqual(binding.frames.map((frame) => [frame.prefix, frame.frameTreeNodeId]), [["", 700], ["f1", 701]]);
165
166 const inside = await resolveRef(fake, binding, "f1e1", false);
167 assert.ok(inside.ok);
168 assert.equal(inside.value.frame, childFrame);
169 assert.equal(inside.value.element.tag, "button");
170 await assert.rejects(resolveRef(fake, binding, "f3e1", false), (error: unknown) => error instanceof RpcError && error.code === BROWSER_ERR_STALE_REFERENCE);
171 await assert.rejects(resolveRef(fake, binding, "bogus", false), (error: unknown) => error instanceof RpcError && error.code === BROWSER_ERR_STALE_REFERENCE);
172
173 const documents2 = new DocumentRegistry(() => "tok-2");
174 await takeSnapshot(fake, "tab-1", 4, "", documents2);
175 await assert.rejects(resolveRef(fake, binding, "e1", false), (error: unknown) => error instanceof RpcError && error.code === BROWSER_ERR_STALE_REFERENCE, "the older snapshot's refs are stale once a newer one exists");
176 });
177
177 lines TYPESCRIPT