@itmetsam/samurai-framework 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +4 -4
  2. package/bin/samurai.mjs +25 -0
  3. package/dist/api.d.ts +1 -0
  4. package/dist/api.js +1 -0
  5. package/dist/browser/browser-finder.d.ts +1 -0
  6. package/dist/browser/browser-finder.js +1 -0
  7. package/dist/browser/browser.d.ts +8 -0
  8. package/dist/browser/browser.js +33 -12
  9. package/dist/browser/evidence.d.ts +19 -0
  10. package/dist/browser/evidence.js +35 -0
  11. package/dist/browser/page.d.ts +6 -0
  12. package/dist/browser/page.js +20 -3
  13. package/dist/browser/screenshot.d.ts +2 -0
  14. package/dist/browser/screenshot.js +5 -2
  15. package/dist/browser/warm-up.d.ts +14 -0
  16. package/dist/browser/warm-up.js +37 -0
  17. package/dist/browsers/cache-dir.d.ts +2 -0
  18. package/dist/browsers/cache-dir.js +15 -0
  19. package/dist/browsers/downloader.d.ts +23 -0
  20. package/dist/browsers/downloader.js +52 -0
  21. package/dist/browsers/index.d.ts +6 -0
  22. package/dist/browsers/index.js +5 -0
  23. package/dist/browsers/overrides.d.ts +17 -0
  24. package/dist/browsers/overrides.js +15 -0
  25. package/dist/browsers/pins.d.ts +16 -0
  26. package/dist/browsers/pins.js +14 -0
  27. package/dist/browsers/resolve.d.ts +26 -0
  28. package/dist/browsers/resolve.js +56 -0
  29. package/dist/browsers/store.d.ts +52 -0
  30. package/dist/browsers/store.js +170 -0
  31. package/dist/cli/args.d.ts +2 -0
  32. package/dist/cli/args.js +2 -0
  33. package/dist/cli/browsers.d.ts +6 -0
  34. package/dist/cli/browsers.js +117 -0
  35. package/dist/cli/main.d.ts +1 -1
  36. package/dist/cli/main.js +26 -2
  37. package/dist/project/config-edit.d.ts +16 -0
  38. package/dist/project/config-edit.js +218 -0
  39. package/dist/project/index.d.ts +3 -0
  40. package/dist/project/index.js +3 -0
  41. package/dist/project/secrets-file.d.ts +8 -0
  42. package/dist/project/secrets-file.js +69 -0
  43. package/dist/project/variables.d.ts +6 -0
  44. package/dist/project/variables.js +17 -0
  45. package/dist/recorder/candidates.d.ts +7 -0
  46. package/dist/recorder/candidates.js +118 -0
  47. package/dist/recorder/capture.d.ts +8 -1
  48. package/dist/recorder/capture.js +25 -116
  49. package/dist/recorder/index.d.ts +1 -1
  50. package/dist/recorder/normalise.d.ts +28 -1
  51. package/dist/recorder/normalise.js +40 -1
  52. package/dist/recorder/recorder.d.ts +54 -0
  53. package/dist/recorder/recorder.js +195 -10
  54. package/dist/recorder/session.d.ts +15 -0
  55. package/dist/recorder/session.js +61 -7
  56. package/dist/recorder/toolbar/controller.d.ts +136 -0
  57. package/dist/recorder/toolbar/controller.js +471 -0
  58. package/dist/recorder/toolbar/inspect.d.ts +12 -0
  59. package/dist/recorder/toolbar/inspect.js +94 -0
  60. package/dist/recorder/toolbar/protocol.d.ts +135 -0
  61. package/dist/recorder/toolbar/protocol.js +4 -0
  62. package/dist/recorder/toolbar/source.d.ts +32 -0
  63. package/dist/recorder/toolbar/source.js +856 -0
  64. package/dist/recorder/toolbar/styles.d.ts +6 -0
  65. package/dist/recorder/toolbar/styles.js +95 -0
  66. package/dist/runner/reporter.d.ts +5 -1
  67. package/dist/runner/reporter.js +4 -3
  68. package/dist/runner/test-runner.d.ts +9 -0
  69. package/dist/runner/test-runner.js +44 -4
  70. package/dist/steps/emit.js +4 -0
  71. package/dist/steps/model.d.ts +11 -0
  72. package/dist/steps/parse.js +22 -3
  73. package/dist/transport/bidi-connection.d.ts +2 -0
  74. package/dist/transport/bidi-connection.js +33 -2
  75. package/dist/types/bidi-modules/script.d.ts +20 -3
  76. package/dist/types/config.d.ts +5 -0
  77. package/dist/types/test.d.ts +4 -0
  78. package/package.json +10 -1
@@ -0,0 +1,856 @@
1
+ import { CANDIDATES_JS } from "../candidates.js";
2
+ import { TOOLBAR_KEY } from "./protocol.js";
3
+ import { TOOLBAR_CSS } from "./styles.js";
4
+ /**
5
+ * The recorder toolbar: a function declaration `(send) => { … }` installed as a preload script in the
6
+ * `samurai-toolbar` sandbox realm (the caller names the sandbox), top-level documents only. `send` is the
7
+ * toolbar's own channel (not the capture script's); it stays in this closure, which page script cannot
8
+ * reach, and the recorder takes toolbar messages only from this sandbox's realm. It draws a
9
+ * closed shadow root on a `<samurai-recorder-toolbar>` element and is a thin view: it renders the
10
+ * `ToolbarState` the recorder pushes and sends `ToolbarMessage`s; every step is decided by the recorder.
11
+ *
12
+ * `globalThis[Symbol.for("samurai.toolbar")]` in the sandbox holds the hook:
13
+ * - `update(state)`: the recorder's state, after every change and every page load.
14
+ * - `reply(id, result)`: the answer to `pick` (a `PickInfo`) or to `assert`, `insert` and `add-insertable`
15
+ * (an `ActionReply`).
16
+ * - `root`: the shadow root, for tests.
17
+ * - `press(action, detail?)`: runs the handler a click on that control runs (for tests). Actions:
18
+ * `pause`, `resume`, `stop`, `collapse`, `expand`, `resume-yes`, `resume-no` (rail);
19
+ * `assert`, `insert`, `wait`, `screenshot` (open or close the tool);
20
+ * `pick { target }` (the element a picking click hit);
21
+ * `matcher { matcher }`, `not`, `assert-add` (Assert panel);
22
+ * `insert-item { kind, name }`, `insert-first`, `insert-new { kind }` (Insert list);
23
+ * `new-kind { kind }`, `new-save`, `new-cancel` (New variable or secret);
24
+ * `wait-add`, `screenshot-add`; `escape` (the Esc key).
25
+ * Form inputs carry `data-field` (`expected`, `attribute`, `countIndex`, `filter`, `name`, `value`,
26
+ * `waitKind`, `seconds`, `shotName`, `fullPage`) and update on `input`/`change`.
27
+ * - `rebind(send)`: used by a second run in the same document (a new recording), which keeps the host and
28
+ * swaps in that recording's toolbar channel.
29
+ * - `state()`, `picking()`: the last state from `update` and whether the Assert picker is on (for tests).
30
+ * - `dispose()`: removes the host and every listener the toolbar put on the window and document (the
31
+ * recording ended while the document stays open).
32
+ *
33
+ * A string, so no transpiler helper can end up in it.
34
+ */
35
+ export const TOOLBAR_SOURCE = String.raw `(send) => {
36
+ if (window !== window.top) return;
37
+ const KEY = Symbol.for("${TOOLBAR_KEY}");
38
+ if (globalThis[KEY]) {
39
+ globalThis[KEY].rebind(send);
40
+ return;
41
+ }
42
+
43
+ ${CANDIDATES_JS}
44
+
45
+ const CSS = ${JSON.stringify(TOOLBAR_CSS)};
46
+ const DEFAULTS = {
47
+ status: "recording", collapsed: false, position: null, steps: 0, environment: "", canAddVariables: false,
48
+ variables: [], secrets: [], secretsIgnored: true, resumeQuestion: false, nextScreenshot: "screenshot-1",
49
+ };
50
+ const MATCHERS = [
51
+ ["toBeVisible", "Visible"], ["toHaveText", "Has text"], ["toContainText", "Contains text"],
52
+ ["toHaveValue", "Has value"], ["toHaveAttribute", "Has attribute"], ["toHaveCount", "Count"],
53
+ ];
54
+
55
+ let channel = send;
56
+ let state = Object.assign({}, DEFAULTS);
57
+ let nextId = Date.now();
58
+ let railBox = { x: 0, y: 0, w: 0, h: 0 };
59
+ let toastTimer = 0;
60
+ // Local UI state; everything about steps lives in the recorder
61
+ const ui = {
62
+ panel: null, picking: false, busy: false, closeOnUpdate: false, pending: 0, focus: null,
63
+ field: null, assert: null, insert: null, form: null,
64
+ wait: { kind: "idle", seconds: "2" }, shot: { name: "", fullPage: false },
65
+ drag: null, dropped: null, toastId: null,
66
+ };
67
+
68
+ const post = (message) => {
69
+ try { channel(Object.assign({ type: "toolbar" }, message)); } catch (err) { /* the channel is gone */ }
70
+ };
71
+ // Listeners on the page's window and document, so dispose() can take them all off again
72
+ const listening = [];
73
+ const listen = (target, type, handler, capture) => {
74
+ target.addEventListener(type, handler, capture === true);
75
+ listening.push([target, type, handler, capture === true]);
76
+ };
77
+ const paused = () => state.status === "paused";
78
+ // Whole pixels, so a rail at the far edge does not overhang it by a rounding error
79
+ const clamp = (value, max) => Math.max(0, Math.min(value, Math.floor(max)));
80
+
81
+ // ---- DOM ----
82
+ const h = (tag, props, ...kids) => {
83
+ const node = document.createElement(tag);
84
+ for (const key in props || {}) {
85
+ const value = props[key];
86
+ if (value === undefined || value === null || value === false) continue;
87
+ if (key === "class") node.className = value;
88
+ else if (key === "value" || key === "checked" || key === "disabled" || key === "selected") node[key] = value;
89
+ else node.setAttribute(key, value === true ? "" : String(value));
90
+ }
91
+ for (const kid of kids.flat(Infinity)) {
92
+ if (kid === null || kid === undefined || kid === false || kid === "") continue;
93
+ node.append(typeof kid === "string" ? document.createTextNode(kid) : kid);
94
+ }
95
+ return node;
96
+ };
97
+ const ico = (text) => h("span", { class: "ico" }, text);
98
+
99
+ // Styles go through the CSSOM (property by property, and a constructed sheet), which a page's CSP does not
100
+ // block, unlike a style attribute or a <style> element without 'unsafe-inline'
101
+ const host = document.createElement("samurai-recorder-toolbar");
102
+ const HOST_STYLE = [
103
+ ["all", "initial"], ["display", "block"], ["position", "fixed"], ["top", "0"], ["left", "0"], ["width", "0"],
104
+ ["height", "0"], ["overflow", "visible"], ["z-index", "2147483647"],
105
+ ];
106
+ for (const [name, value] of HOST_STYLE) host.style.setProperty(name, value, "important");
107
+ const root = host.attachShadow({ mode: "closed" });
108
+ const frame = h("div", { class: "frame" });
109
+ const container = h("div", { class: "ui" });
110
+ const outline = h("div", { class: "outline", hidden: true });
111
+ const toast = h("div", { class: "toast ui", hidden: true });
112
+ let adopted = false;
113
+ try {
114
+ const sheet = new CSSStyleSheet();
115
+ sheet.replaceSync(CSS);
116
+ root.adoptedStyleSheets = [sheet];
117
+ adopted = root.adoptedStyleSheets.length === 1;
118
+ } catch (err) {
119
+ adopted = false;
120
+ }
121
+ // Without constructed sheets: a <style> element, which a strict CSP blocks
122
+ root.append(...(adopted ? [] : [h("style", null, CSS)]), frame, outline, container, toast);
123
+
124
+ const viewport = () => ({ w: frame.clientWidth || innerWidth, h: frame.clientHeight || innerHeight });
125
+
126
+ // ---- Names and labels ----
127
+ const words = (text) => String(text).replace(/([a-z0-9])([A-Z])/g, "$1 $2").split(/[^A-Za-z0-9]+/).filter(Boolean);
128
+ const camel = (text) => words(text).map((w, i) => (i ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w.toLowerCase())).join("");
129
+ const snake = (text) => words(text).map((w) => w.toUpperCase()).join("_");
130
+ const variableName = (text) => (/^[A-Za-z_$][\w$]*$/.test(text.trim()) ? text.trim() : camel(text));
131
+ const fieldName = (el) => labelTexts(el)[0] || el.getAttribute("name") || el.id || "";
132
+ const fieldLabel = (el) => {
133
+ const text = labelTexts(el)[0] || el.getAttribute("placeholder") || el.getAttribute("name") || el.id || "field";
134
+ return text.length > 40 ? text.slice(0, 39) + "…" : text;
135
+ };
136
+
137
+ // ---- Placement ----
138
+ const place = (node, want) => {
139
+ const rect = node.getBoundingClientRect();
140
+ const v = viewport();
141
+ const x = clamp(want ? want.x : v.w - rect.width - 12, v.w - rect.width);
142
+ const y = clamp(want ? want.y : 12, v.h - rect.height);
143
+ node.style.left = x + "px";
144
+ node.style.top = y + "px";
145
+ return { x, y, w: rect.width, h: rect.height };
146
+ };
147
+ const wantedSpot = () => (ui.drag ? ui.drag.pos : ui.dropped || state.position);
148
+ const placePanel = (panel) => {
149
+ const rect = panel.getBoundingClientRect();
150
+ const v = viewport();
151
+ let x;
152
+ let y;
153
+ if (ui.panel === "assert" && ui.assert) {
154
+ const t = ui.assert.rect;
155
+ // Beside the element; below it (or above) when it is too wide for either side
156
+ if (t.right + 8 + rect.width <= v.w) [x, y] = [t.right + 8, t.top];
157
+ else if (t.left - 8 - rect.width >= 0) [x, y] = [t.left - 8 - rect.width, t.top];
158
+ else [x, y] = [t.left, t.bottom + 8 + rect.height <= v.h ? t.bottom + 8 : t.top - 8 - rect.height];
159
+ } else {
160
+ x = railBox.x + railBox.w / 2 > v.w / 2 ? railBox.x - 8 - rect.width : railBox.x + railBox.w + 8;
161
+ y = railBox.y;
162
+ }
163
+ panel.style.left = clamp(x, v.w - rect.width) + "px";
164
+ panel.style.top = clamp(y, v.h - rect.height) + "px";
165
+ };
166
+ const placeAll = () => {
167
+ const [rail, panel] = container.children;
168
+ if (rail) railBox = place(rail, wantedSpot());
169
+ if (panel) placePanel(panel);
170
+ };
171
+ const showOutline = (el) => {
172
+ const r = el.getBoundingClientRect();
173
+ outline.style.left = r.left + "px";
174
+ outline.style.top = r.top + "px";
175
+ outline.style.width = r.width + "px";
176
+ outline.style.height = r.height + "px";
177
+ outline.hidden = false;
178
+ };
179
+ const hideOutline = () => { outline.hidden = true; };
180
+
181
+ // ---- Views ----
182
+ const toolOn = (tool) =>
183
+ tool === "assert" ? ui.picking || ui.panel === "assert" : tool === "insert" ? ui.panel === "insert" || ui.panel === "new" : ui.panel === tool;
184
+
185
+ const chipView = () =>
186
+ h("div", { class: "chip" },
187
+ h("span", { "data-grip": true, title: "Drag to move" }, "⋮⋮"),
188
+ h("span", { class: paused() ? "dot pause" : "dot" }),
189
+ (paused() ? "Paused" : "Rec") + " · " + state.steps + (state.steps === 1 ? " step" : " steps"),
190
+ h("button", { class: "chev", "data-action": "expand", title: "Expand" }, "⌄"));
191
+
192
+ const railView = () => {
193
+ const tool = (action, icon, label) =>
194
+ h("button", { class: toolOn(action) ? "b on" : "b", "data-action": action, disabled: paused() }, ico(icon), label);
195
+ return h("div", { class: paused() || state.resumeQuestion ? "rail wide" : "rail" },
196
+ h("div", { class: "top" },
197
+ h("span", { "data-grip": true, title: "Drag to move" }, "⋮⋮⋮"),
198
+ h("button", { class: "chev", "data-action": "collapse", title: "Collapse" }, "⌃")),
199
+ h("div", { class: "st" }, h("span", { class: paused() ? "dot pause" : "dot" }), paused() ? "Paused" : "Recording"),
200
+ paused() && h("div", { class: "note" }, "Nothing you do now is recorded."),
201
+ state.resumeQuestion && [
202
+ h("div", { class: "note" }, "Add a step that opens this page?"),
203
+ h("div", { class: "ask" },
204
+ h("button", { class: "yes", "data-action": "resume-yes" }, "Yes"),
205
+ h("button", { "data-action": "resume-no" }, "No")),
206
+ ],
207
+ h("div", { class: "sep" }),
208
+ paused()
209
+ ? h("button", { class: "b on", "data-action": "resume" }, ico("▶"), "Resume")
210
+ : h("button", { class: "b", "data-action": "pause" }, ico("❚❚"), "Pause"),
211
+ tool("assert", "✓", "Assert"),
212
+ ui.picking && h("div", { class: "note" }, "Click an element · Esc cancels"),
213
+ tool("insert", "{x}", "Insert"),
214
+ tool("wait", "⏱", "Wait"),
215
+ tool("screenshot", "◉", "Screenshot"),
216
+ h("div", { class: "sep" }),
217
+ h("button", { class: "b stop", "data-action": "stop" }, ico("■"), "Stop"));
218
+ };
219
+
220
+ const unavailable = (info, matcher) =>
221
+ (matcher === "toHaveValue" && info.value === undefined) ||
222
+ (matcher === "toHaveAttribute" && info.attributes.length === 0) ||
223
+ (matcher === "toHaveCount" && info.counts.length === 0);
224
+ const prefill = (a, matcher) => {
225
+ const info = a.info;
226
+ if (matcher === "toHaveAttribute") {
227
+ const found = info.attributes.find((at) => at.name === a.attribute) || info.attributes[0];
228
+ return found ? found.value : "";
229
+ }
230
+ if (matcher === "toHaveCount") {
231
+ const found = info.counts[a.countIndex];
232
+ return found ? String(found.count) : "";
233
+ }
234
+ if (matcher === info.first.matcher && info.first.expected !== undefined) return info.first.expected;
235
+ if (matcher === "toHaveText" || matcher === "toContainText") return info.text;
236
+ if (matcher === "toHaveValue") return info.value || "";
237
+ return "";
238
+ };
239
+ const assertValue = (a) => {
240
+ const info = a.info;
241
+ const expected = (type) => h("input", { type, class: "in", "data-field": "expected", value: a.expected, "data-enter": "assert-add", autocomplete: "off" });
242
+ if (a.matcher === "toHaveText" || a.matcher === "toContainText" || a.matcher === "toHaveValue") return h("div", { class: "val" }, expected("text"));
243
+ if (a.matcher === "toHaveAttribute") {
244
+ return h("div", { class: "val" },
245
+ h("select", { class: "in", "data-field": "attribute" }, info.attributes.map((at) => {
246
+ const shown = at.name + '="' + at.value + '"';
247
+ return h("option", { value: at.name, selected: at.name === a.attribute }, shown.length > 44 ? shown.slice(0, 43) + "…" : shown);
248
+ })),
249
+ expected("text"));
250
+ }
251
+ if (a.matcher === "toHaveCount") {
252
+ return h("div", { class: "val" },
253
+ h("select", { class: "in", "data-field": "countIndex" }, info.counts.map((c, i) =>
254
+ h("option", { value: String(i), selected: i === a.countIndex }, c.label + " (" + c.count + ")"))),
255
+ expected("number"));
256
+ }
257
+ return null;
258
+ };
259
+ const assertPanel = () => {
260
+ const a = ui.assert;
261
+ const info = a.info;
262
+ const head = h("div", { class: "h" }, "Assert", info && info.ok && h("code", { title: info.label }, info.label));
263
+ if (!info) return [head, h("div", { class: "msg" }, "…")];
264
+ if (!info.ok) return [head, h("div", { class: "err" }, info.error)];
265
+ return [
266
+ head,
267
+ h("div", { class: "opts" }, MATCHERS.map(([matcher, label]) =>
268
+ h("button", { class: a.matcher === matcher ? "o sel" : "o", "data-action": "matcher", "data-matcher": matcher, disabled: unavailable(info, matcher) }, label))),
269
+ assertValue(a),
270
+ a.error && h("div", { class: "err" }, a.error),
271
+ h("div", { class: "row" },
272
+ h("label", { class: "neg" }, h("input", { type: "checkbox", "data-action": "not", checked: a.not }), "Not"),
273
+ h("button", { class: "add", "data-action": "assert-add", disabled: ui.busy }, "Add")),
274
+ ];
275
+ };
276
+
277
+ const matches = () => {
278
+ const filter = ui.insert.filter.trim().toLowerCase();
279
+ const hit = (item) => !filter || item.name.toLowerCase().indexOf(filter) >= 0;
280
+ return {
281
+ filter,
282
+ variables: state.canAddVariables ? state.variables.filter(hit) : [],
283
+ secrets: state.secrets.filter(hit),
284
+ };
285
+ };
286
+ const insertPanel = () => {
287
+ const ins = ui.insert;
288
+ if (!ins.field) return [h("div", { class: "h" }, "Insert"), h("div", { class: "msg" }, "Click into a field first")];
289
+ const found = matches();
290
+ const none = found.filter && found.variables.length === 0 && found.secrets.length === 0;
291
+ const item = (kind, name, value) =>
292
+ h("button", { class: "it", "data-action": "insert-item", "data-kind": kind, "data-name": name, disabled: ui.busy }, h("code", null, name), value);
293
+ return [
294
+ h("div", { class: "h stack" }, 'Insert into "' + fieldLabel(ins.field) + '"', h("small", null, "Environment: " + state.environment)),
295
+ h("input", { type: "text", class: "in search", "data-field": "filter", placeholder: "Filter…", value: ins.filter, "data-enter": "insert-first", autocomplete: "off", spellcheck: "false" }),
296
+ state.canAddVariables && [
297
+ h("div", { class: "grp" }, "Variables"),
298
+ found.variables.length
299
+ ? found.variables.map((v) => item("env", v.name, h("span", { class: "v", title: v.value }, v.value)))
300
+ : h("div", { class: "empty" }, found.filter ? "No variable matches." : "No variables yet."),
301
+ ],
302
+ h("div", { class: "grp" }, "Secrets"),
303
+ found.secrets.length
304
+ ? found.secrets.map((s) => item("secret", s.name, s.set ? h("span", { class: "v" }, "••••••") : h("span", { class: "warn" }, "not set")))
305
+ : h("div", { class: "empty" }, found.filter ? "No secret matches." : "No secrets yet."),
306
+ ins.error && h("div", { class: "err" }, ins.error),
307
+ h("button", { class: "act", "data-action": "insert-new", "data-kind": "env" }, "+ Add variable" + (none ? ' "' + variableName(ins.filter) + '"' : "")),
308
+ h("button", { class: "act", "data-action": "insert-new", "data-kind": "secret" }, "+ Add secret" + (none ? ' "' + snake(ins.filter) + '"' : "")),
309
+ ];
310
+ };
311
+
312
+ const newHint = (secret, env, name) => {
313
+ if (!secret) return ["Saved to ", h("code", null, "environments." + env + ".variables"), " in samurai.config.ts. Other environments don't get it — add it there before running against them."];
314
+ if (!state.secretsIgnored) return h("span", { class: "warn" }, "Add .env." + env + " to .gitignore before committing.");
315
+ return ["Saved as ", h("code", null, "SAMURAI_SECRET_" + (name.trim() || "<NAME>")), " in ", h("code", null, ".env." + env), " (git-ignored)."];
316
+ };
317
+ const newPanel = () => {
318
+ const f = ui.form;
319
+ const secret = f.kind === "secret";
320
+ const blocked = !secret && !state.canAddVariables;
321
+ const env = state.environment;
322
+ return [
323
+ h("div", { class: "h stack" }, f.setValue ? "Set value" : secret ? "New secret" : "New variable", h("small", null, 'For "' + fieldLabel(ui.insert.field) + '"')),
324
+ h("div", { class: "seg" },
325
+ h("button", { class: secret ? "" : "sel", "data-action": "new-kind", "data-kind": "env" }, "Variable"),
326
+ h("button", { class: secret ? "sel" : "", "data-action": "new-kind", "data-kind": "secret" }, "Secret")),
327
+ blocked
328
+ ? h("div", { class: "msg" }, "This recording has no environment")
329
+ : [
330
+ h("div", { class: "lbl" }, "Name"),
331
+ h("div", { class: "f" }, h("input", { type: "text", class: "in code", "data-field": "name", value: f.name, "data-enter": "new-save", autocomplete: "off", spellcheck: "false" })),
332
+ f.nameError && h("div", { class: "field-error" }, f.nameError),
333
+ h("div", { class: "lbl" }, "Value in " + env),
334
+ h("div", { class: "f" }, h("input", { type: secret ? "password" : "text", class: "in", "data-field": "value", value: f.value, "data-enter": "new-save", autocomplete: secret ? "new-password" : "off" })),
335
+ h("div", { class: "hint" }, newHint(secret, env, f.name)),
336
+ f.error && h("div", { class: "err" }, f.error),
337
+ ],
338
+ h("div", { class: "foot plain" },
339
+ h("button", { class: "cancel", "data-action": "new-cancel" }, "Cancel"),
340
+ !blocked && h("button", { class: "add", "data-action": "new-save", disabled: ui.busy || !f.name.trim() }, "Save and insert")),
341
+ ];
342
+ };
343
+
344
+ const secondsValid = () => {
345
+ const seconds = Number(ui.wait.seconds);
346
+ return ui.wait.seconds.trim() !== "" && isFinite(seconds) && seconds >= 0.1 && seconds <= 60;
347
+ };
348
+ const radio = (field, value, checked, ...label) =>
349
+ h("label", { class: "radio" }, h("input", { type: "radio", name: field, "data-field": field, value, checked }), label);
350
+ const waitPanel = () => [
351
+ h("div", { class: "h" }, "Wait"),
352
+ radio("waitKind", "idle", ui.wait.kind === "idle", "Until the page settles"),
353
+ h("div", { class: "sub" }, "No requests for 500 ms"),
354
+ radio("waitKind", "time", ui.wait.kind === "time", "For ",
355
+ h("input", { type: "number", class: "num", "data-field": "seconds", min: "0.1", max: "60", step: "0.1", value: ui.wait.seconds, "data-enter": "wait-add" }),
356
+ " seconds"),
357
+ h("div", { class: "foot" }, h("button", { class: "add", "data-action": "wait-add", disabled: ui.busy || (ui.wait.kind === "time" && !secondsValid()) }, "Add")),
358
+ ];
359
+ const screenshotPanel = () => [
360
+ h("div", { class: "h" }, "Screenshot"),
361
+ h("div", { class: "grp" }, "Name"),
362
+ h("div", { class: "f" }, h("input", { type: "text", class: "in", "data-field": "shotName", value: ui.shot.name, "data-enter": "screenshot-add", autocomplete: "off", spellcheck: "false" })),
363
+ radio("fullPage", "false", !ui.shot.fullPage, "Visible part"),
364
+ radio("fullPage", "true", ui.shot.fullPage, "Whole page"),
365
+ h("div", { class: "foot" }, h("button", { class: "add", "data-action": "screenshot-add", disabled: ui.busy }, "Add")),
366
+ ];
367
+ const PANELS = { assert: assertPanel, insert: insertPanel, new: newPanel, wait: waitPanel, screenshot: screenshotPanel };
368
+
369
+ const render = () => {
370
+ const active = root.activeElement;
371
+ const kept = active && active.type !== "radio" ? active.getAttribute("data-field") : null;
372
+ let selection = null;
373
+ try { selection = kept ? [active.selectionStart, active.selectionEnd] : null; } catch (err) { selection = null; }
374
+ const panel = ui.panel && !state.collapsed ? h("div", { class: ui.panel === "new" || ui.panel === "insert" ? "panel wide" : "panel" }, PANELS[ui.panel]()) : null;
375
+ container.replaceChildren(state.collapsed ? chipView() : railView(), ...(panel ? [panel] : []));
376
+ placeAll();
377
+ const focus = ui.focus || kept;
378
+ ui.focus = null;
379
+ const target = focus && root.querySelector('[data-field="' + focus + '"]');
380
+ if (!target) return;
381
+ target.focus({ preventScroll: true });
382
+ if (focus === kept && selection && selection[0] !== null) {
383
+ try { target.setSelectionRange(selection[0], selection[1]); } catch (err) { /* not a text input */ }
384
+ }
385
+ };
386
+
387
+ // ---- Panels and picking ----
388
+ const closePanel = () => {
389
+ ui.panel = null;
390
+ ui.assert = null;
391
+ ui.busy = false;
392
+ ui.closeOnUpdate = false;
393
+ ui.pending = 0;
394
+ hideOutline();
395
+ };
396
+ const stopPicking = () => {
397
+ ui.picking = false;
398
+ if (ui.panel !== "assert") hideOutline();
399
+ };
400
+ const usable = () => !paused() && !state.collapsed;
401
+ const openTool = (tool) => {
402
+ if (!usable()) return;
403
+ const wasOpen = toolOn(tool);
404
+ stopPicking();
405
+ closePanel();
406
+ if (!wasOpen) {
407
+ ui.panel = tool;
408
+ if (tool === "insert") {
409
+ // The field focused now, else the last one (a toolbar button may have taken the focus)
410
+ const active = document.activeElement;
411
+ const focused = active && active !== host && active.nodeType === 1 && isTextEntry(active) ? active : null;
412
+ const field = focused || (ui.field && ui.field.isConnected ? ui.field : null);
413
+ ui.field = field;
414
+ ui.insert = { field, filter: "", error: null };
415
+ if (field) ui.focus = "filter";
416
+ } else if (tool === "wait") {
417
+ ui.wait = { kind: "idle", seconds: "2" };
418
+ } else if (tool === "screenshot") {
419
+ ui.shot = { name: state.nextScreenshot, fullPage: false };
420
+ ui.focus = "shotName";
421
+ }
422
+ }
423
+ render();
424
+ };
425
+ const pick = (target) => {
426
+ if (!ui.picking || !target || target.nodeType !== 1) return;
427
+ ui.picking = false;
428
+ closePanel();
429
+ const id = nextId++;
430
+ const candidates = candidatesOf(target);
431
+ const r = target.getBoundingClientRect();
432
+ ui.assert = {
433
+ id, target, candidates, info: null, matcher: "toBeVisible", expected: "", attribute: null, countIndex: 0, not: false, error: null,
434
+ rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom },
435
+ };
436
+ ui.panel = "assert";
437
+ ui.pending = id;
438
+ showOutline(target);
439
+ post({ action: "pick", id, target, candidates });
440
+ render();
441
+ };
442
+ const openForm = (kind, name, setValue) => {
443
+ ui.form = { kind, name, value: "", nameError: null, error: null, setValue };
444
+ ui.panel = "new";
445
+ ui.focus = name ? "value" : "name";
446
+ render();
447
+ };
448
+ const sendInsert = (kind, name) => {
449
+ const field = ui.insert.field;
450
+ const id = nextId++;
451
+ ui.pending = id;
452
+ ui.busy = true;
453
+ ui.insert.error = null;
454
+ post({ action: "insert", id, target: field, candidates: candidatesOf(field), kind, name });
455
+ render();
456
+ };
457
+
458
+ // Every action is what a click on its control does; press() runs the same functions
459
+ const handlers = {
460
+ pause: () => { if (!paused()) post({ action: "pause" }); },
461
+ resume: () => { if (paused()) post({ action: "resume" }); },
462
+ stop: () => {
463
+ stopPicking();
464
+ closePanel();
465
+ post({ action: "stop" });
466
+ render();
467
+ },
468
+ collapse: () => {
469
+ stopPicking();
470
+ closePanel();
471
+ post({ action: "collapse", collapsed: true });
472
+ render();
473
+ },
474
+ expand: () => post({ action: "collapse", collapsed: false }),
475
+ "resume-yes": () => { if (state.resumeQuestion) post({ action: "resume-goto", add: true }); },
476
+ "resume-no": () => { if (state.resumeQuestion) post({ action: "resume-goto", add: false }); },
477
+ assert: () => {
478
+ if (!usable()) return;
479
+ const wasOn = toolOn("assert");
480
+ stopPicking();
481
+ closePanel();
482
+ ui.picking = !wasOn;
483
+ render();
484
+ },
485
+ insert: () => openTool("insert"),
486
+ wait: () => openTool("wait"),
487
+ screenshot: () => openTool("screenshot"),
488
+ pick: (detail) => pick(detail.target),
489
+ matcher: (detail) => {
490
+ const a = ui.panel === "assert" && ui.assert;
491
+ if (!a || !a.info || !a.info.ok || unavailable(a.info, detail.matcher)) return;
492
+ a.matcher = detail.matcher;
493
+ a.expected = prefill(a, detail.matcher);
494
+ render();
495
+ },
496
+ not: () => {
497
+ if (ui.panel !== "assert" || !ui.assert || !ui.assert.info) return;
498
+ ui.assert.not = !ui.assert.not;
499
+ render();
500
+ },
501
+ "assert-add": () => {
502
+ const a = ui.panel === "assert" && ui.assert;
503
+ if (!a || ui.busy || !a.info || !a.info.ok) return;
504
+ const message = { action: "assert", id: a.id, target: a.target, candidates: a.candidates, matcher: a.matcher, not: a.not };
505
+ if (a.matcher !== "toBeVisible") message.expected = a.expected;
506
+ if (a.matcher === "toHaveAttribute") message.attribute = a.attribute;
507
+ if (a.matcher === "toHaveCount") message.countIndex = a.countIndex;
508
+ ui.busy = true;
509
+ a.error = null;
510
+ post(message);
511
+ render();
512
+ },
513
+ "insert-item": (detail) => {
514
+ if (ui.panel !== "insert" || ui.busy || !ui.insert.field) return;
515
+ if (detail.kind === "secret") {
516
+ const secret = state.secrets.find((s) => s.name === detail.name);
517
+ if (!secret) return;
518
+ if (!secret.set) return openForm("secret", secret.name, true);
519
+ } else if (!state.canAddVariables || !state.variables.some((v) => v.name === detail.name)) {
520
+ return;
521
+ }
522
+ sendInsert(detail.kind, detail.name);
523
+ },
524
+ "insert-first": () => {
525
+ if (ui.panel !== "insert" || !ui.insert.field) return;
526
+ const found = matches();
527
+ const first = found.variables[0] ? { kind: "env", name: found.variables[0].name } : found.secrets[0] && { kind: "secret", name: found.secrets[0].name };
528
+ if (first) handlers["insert-item"](first);
529
+ },
530
+ "insert-new": (detail) => {
531
+ if (ui.panel !== "insert" || !ui.insert.field) return;
532
+ const filter = ui.insert.filter.trim();
533
+ const base = filter || fieldName(ui.insert.field);
534
+ const name = detail.kind === "secret" ? snake(base) : filter ? variableName(filter) : camel(base);
535
+ openForm(detail.kind === "secret" ? "secret" : "env", name, false);
536
+ },
537
+ "new-kind": (detail) => {
538
+ const f = ui.panel === "new" && ui.form;
539
+ if (!f || ui.busy || f.kind === detail.kind) return;
540
+ f.kind = detail.kind === "secret" ? "secret" : "env";
541
+ f.name = f.kind === "secret" ? snake(f.name) : camel(f.name);
542
+ f.nameError = null;
543
+ f.error = null;
544
+ render();
545
+ },
546
+ "new-save": () => {
547
+ const f = ui.panel === "new" && ui.form;
548
+ if (!f || ui.busy || !f.name.trim() || (f.kind === "env" && !state.canAddVariables)) return;
549
+ const field = ui.insert.field;
550
+ const id = nextId++;
551
+ ui.pending = id;
552
+ ui.busy = true;
553
+ f.nameError = null;
554
+ f.error = null;
555
+ post({ action: "add-insertable", id, target: field, candidates: candidatesOf(field), kind: f.kind, name: f.name.trim(), value: f.value });
556
+ render();
557
+ },
558
+ "new-cancel": () => {
559
+ if (ui.panel !== "new") return;
560
+ ui.panel = "insert";
561
+ ui.form = null;
562
+ ui.busy = false;
563
+ ui.pending = 0;
564
+ ui.focus = "filter";
565
+ render();
566
+ },
567
+ "wait-add": () => {
568
+ if (ui.panel !== "wait" || ui.busy) return;
569
+ if (ui.wait.kind === "time" && !secondsValid()) return;
570
+ ui.busy = true;
571
+ ui.closeOnUpdate = true;
572
+ post(ui.wait.kind === "time" ? { action: "wait", kind: "time", ms: Math.round(Number(ui.wait.seconds) * 1000) } : { action: "wait", kind: "idle" });
573
+ render();
574
+ },
575
+ "screenshot-add": () => {
576
+ if (ui.panel !== "screenshot" || ui.busy) return;
577
+ ui.busy = true;
578
+ ui.closeOnUpdate = true;
579
+ post({ action: "screenshot", name: ui.shot.name.trim(), fullPage: ui.shot.fullPage });
580
+ render();
581
+ },
582
+ escape: () => {
583
+ if (!ui.picking && !ui.panel) return false;
584
+ stopPicking();
585
+ closePanel();
586
+ render();
587
+ return true;
588
+ },
589
+ };
590
+ const press = (action, detail) => {
591
+ const handler = handlers[action];
592
+ if (handler) handler(detail || {});
593
+ };
594
+
595
+ const onField = (event, target) => {
596
+ const t = target || event.target;
597
+ const field = t && t.getAttribute && t.getAttribute("data-field");
598
+ if (!field) return;
599
+ const value = t.value;
600
+ if (field === "expected" && ui.assert) ui.assert.expected = value;
601
+ else if (field === "attribute" && ui.assert) {
602
+ ui.assert.attribute = value;
603
+ ui.assert.expected = prefill(ui.assert, "toHaveAttribute");
604
+ render();
605
+ } else if (field === "countIndex" && ui.assert) {
606
+ ui.assert.countIndex = Number(value);
607
+ ui.assert.expected = prefill(ui.assert, "toHaveCount");
608
+ render();
609
+ } else if (field === "filter" && ui.insert) {
610
+ if (ui.insert.filter === value) return;
611
+ ui.insert.filter = value;
612
+ render();
613
+ } else if (field === "name" && ui.form) {
614
+ if (ui.form.name === value) return;
615
+ ui.form.name = value;
616
+ ui.form.nameError = null;
617
+ render();
618
+ } else if (field === "value" && ui.form) ui.form.value = value;
619
+ else if (field === "waitKind") {
620
+ ui.wait.kind = value === "time" ? "time" : "idle";
621
+ render();
622
+ } else if (field === "seconds") {
623
+ ui.wait.seconds = value;
624
+ ui.wait.kind = "time";
625
+ render();
626
+ } else if (field === "shotName") ui.shot.name = value;
627
+ else if (field === "fullPage") {
628
+ ui.shot.fullPage = value === "true";
629
+ render();
630
+ }
631
+ };
632
+
633
+ // ---- Drag ----
634
+ const onDragMove = (event) => {
635
+ if (!ui.drag) return;
636
+ ui.drag.pos = { x: event.clientX - ui.drag.dx, y: event.clientY - ui.drag.dy };
637
+ ui.drag.moved = true;
638
+ placeAll();
639
+ };
640
+ const endDrag = () => {
641
+ if (!ui.drag) return;
642
+ const { moved, pointer } = ui.drag;
643
+ ui.drag = null;
644
+ window.removeEventListener("pointermove", onDragMove, true);
645
+ window.removeEventListener("pointerup", endDrag, true);
646
+ window.removeEventListener("pointercancel", endDrag, true);
647
+ try { host.releasePointerCapture(pointer); } catch (err) { /* already released */ }
648
+ if (!moved) return;
649
+ ui.dropped = { x: Math.round(railBox.x), y: Math.round(railBox.y) };
650
+ post({ action: "move", x: ui.dropped.x, y: ui.dropped.y });
651
+ placeAll();
652
+ };
653
+ const startDrag = (event) => {
654
+ if (event.button !== 0) return;
655
+ event.preventDefault();
656
+ ui.drag = { dx: event.clientX - railBox.x, dy: event.clientY - railBox.y, pos: { x: railBox.x, y: railBox.y }, moved: false, pointer: event.pointerId };
657
+ // Captured on the host, which re-renders never replace
658
+ try { host.setPointerCapture(event.pointerId); } catch (err) { /* moves still arrive while over the window */ }
659
+ window.addEventListener("pointermove", onDragMove, true);
660
+ window.addEventListener("pointerup", endDrag, true);
661
+ window.addEventListener("pointercancel", endDrag, true);
662
+ };
663
+
664
+ // ---- Events inside the toolbar; none of them reach the page's bubbling listeners ----
665
+ root.addEventListener("click", (event) => {
666
+ event.stopPropagation();
667
+ const control = event.target.closest && event.target.closest("[data-action]");
668
+ if (!control || control.disabled) return;
669
+ press(control.getAttribute("data-action"), {
670
+ kind: control.getAttribute("data-kind"),
671
+ name: control.getAttribute("data-name"),
672
+ matcher: control.getAttribute("data-matcher"),
673
+ });
674
+ });
675
+ root.addEventListener("pointerdown", (event) => {
676
+ event.stopPropagation();
677
+ if (event.target.closest && event.target.closest("[data-grip]")) startDrag(event);
678
+ });
679
+ const onKeydown = (event, target) => {
680
+ const enter = event.key === "Enter" && target && target.getAttribute && target.getAttribute("data-enter");
681
+ if (!enter || event.isComposing) return;
682
+ event.preventDefault();
683
+ if (target.getAttribute("data-field")) onField(event, target);
684
+ press(enter);
685
+ };
686
+ // Typing reaches these only when it does not cross the shadow boundary (the window listeners below take the rest)
687
+ root.addEventListener("input", (event) => { event.stopPropagation(); onField(event); });
688
+ root.addEventListener("change", (event) => { event.stopPropagation(); onField(event); });
689
+ root.addEventListener("keydown", (event) => {
690
+ event.stopPropagation();
691
+ onKeydown(event, event.target);
692
+ });
693
+ for (const type of ["keyup", "keypress", "mousedown", "mouseup", "pointerup", "dblclick", "focusin", "focusout"]) {
694
+ root.addEventListener(type, (event) => event.stopPropagation());
695
+ }
696
+
697
+ // ---- Events on the page (window capture runs before the page's and the capture script's listeners) ----
698
+ const swallow = (event) => {
699
+ event.preventDefault();
700
+ event.stopImmediatePropagation();
701
+ };
702
+ // Typing in the toolbar stays in the toolbar: these run first on the window (registered at document start,
703
+ // before any page script), so no page listener sees a key, typed text or the clipboard of a toolbar input
704
+ // (the New secret value among them). The browser's default action, the text going into the input, still
705
+ // happens. Outside the shadow root the event's target is the host, so the input is the focused element.
706
+ const TYPING = ["keydown", "keyup", "keypress", "beforeinput", "input", "compositionstart", "compositionupdate", "compositionend", "paste", "cut", "copy"];
707
+ for (const type of TYPING) {
708
+ listen(window, type, (event) => {
709
+ // An untrusted event was dispatched by the page itself: it neither carries the tester's typing nor acts
710
+ if (!event.isTrusted || !inToolbar(event)) return;
711
+ event.stopImmediatePropagation();
712
+ const target = root.activeElement;
713
+ if (type === "keydown") {
714
+ if (event.key === "Escape" && (ui.picking || ui.panel)) {
715
+ event.preventDefault();
716
+ handlers.escape();
717
+ } else onKeydown(event, target);
718
+ } else if (type === "input" && target && target.type !== "radio" && target.type !== "checkbox") {
719
+ // A radio or checkbox the pointer changed need not have the focus; its change event (below) updates it
720
+ onField(event, target);
721
+ }
722
+ }, true);
723
+ }
724
+ listen(window, "keydown", (event) => {
725
+ if (event.isTrusted && event.key === "Escape" && (ui.picking || ui.panel)) {
726
+ swallow(event);
727
+ handlers.escape();
728
+ }
729
+ }, true);
730
+ listen(window, "pointerover", (event) => {
731
+ if (!ui.picking) return;
732
+ if (inToolbar(event) || !event.target || event.target.nodeType !== 1) hideOutline();
733
+ else showOutline(event.target);
734
+ }, true);
735
+ for (const type of ["pointerdown", "mousedown", "pointerup", "mouseup", "dblclick", "auxclick", "contextmenu"]) {
736
+ listen(window, type, (event) => {
737
+ if (inToolbar(event)) return;
738
+ if (ui.picking) return swallow(event);
739
+ if (type === "pointerdown" && ui.panel) {
740
+ closePanel();
741
+ render();
742
+ }
743
+ }, true);
744
+ }
745
+ listen(window, "click", (event) => {
746
+ if (!ui.picking || inToolbar(event)) return;
747
+ swallow(event);
748
+ pick(event.target);
749
+ }, true);
750
+ listen(document, "focusin", (event) => {
751
+ if (inToolbar(event) || !event.target || event.target.nodeType !== 1) return;
752
+ ui.field = isTextEntry(event.target) ? event.target : null;
753
+ if (ui.panel === "insert" && !ui.insert.field && ui.field) {
754
+ ui.insert.field = ui.field;
755
+ render();
756
+ }
757
+ }, true);
758
+ listen(window, "resize", () => render());
759
+ listen(window, "scroll", () => {
760
+ if (ui.panel === "assert" && ui.assert && !outline.hidden) showOutline(ui.assert.target);
761
+ }, true);
762
+
763
+ // ---- The recorder's side ----
764
+ const showToast = (text) => {
765
+ toast.textContent = text;
766
+ toast.hidden = false;
767
+ toast.classList.remove("gone");
768
+ clearTimeout(toastTimer);
769
+ toastTimer = setTimeout(() => {
770
+ toast.classList.add("gone");
771
+ toastTimer = setTimeout(() => { toast.hidden = true; }, 300);
772
+ }, 2500);
773
+ };
774
+ const update = (next) => {
775
+ state = Object.assign({}, DEFAULTS, next);
776
+ const dropped = ui.dropped;
777
+ if (dropped && state.position && Math.round(state.position.x) === dropped.x && Math.round(state.position.y) === dropped.y) ui.dropped = null;
778
+ if (ui.closeOnUpdate) closePanel();
779
+ if (!usable()) {
780
+ stopPicking();
781
+ closePanel();
782
+ }
783
+ if (state.toast && state.toast.id !== ui.toastId) {
784
+ ui.toastId = state.toast.id;
785
+ showToast(state.toast.text);
786
+ }
787
+ attach();
788
+ render();
789
+ };
790
+ const reply = (id, result) => {
791
+ if (!ui.pending || id !== ui.pending || !result) return;
792
+ ui.busy = false;
793
+ const ok = result.ok === true;
794
+ if (ui.panel === "assert" && ui.assert && !ui.assert.info) {
795
+ const a = ui.assert;
796
+ a.info = result;
797
+ if (ok) {
798
+ a.matcher = result.first.matcher;
799
+ a.attribute = result.attributes[0] ? result.attributes[0].name : null;
800
+ a.countIndex = 0;
801
+ a.expected = prefill(a, a.matcher);
802
+ }
803
+ } else if (ok) {
804
+ closePanel();
805
+ } else if (ui.panel === "assert" && ui.assert) {
806
+ ui.assert.error = result.error;
807
+ } else if (ui.panel === "insert") {
808
+ ui.insert.error = result.error;
809
+ } else if (ui.panel === "new") {
810
+ if (result.field === "name") ui.form.nameError = result.error;
811
+ else ui.form.error = result.error;
812
+ }
813
+ render();
814
+ };
815
+
816
+ // ---- Attach ----
817
+ let announced = false;
818
+ const attach = () => {
819
+ const html = document.documentElement;
820
+ if (!html) return false;
821
+ if (host.parentNode !== html) html.appendChild(host);
822
+ if (!announced) {
823
+ announced = true;
824
+ render();
825
+ post({ action: "ready", url: location.href });
826
+ }
827
+ return true;
828
+ };
829
+ const rebind = (fresh) => {
830
+ channel = fresh;
831
+ announced = false;
832
+ attach();
833
+ };
834
+ let watcher = null;
835
+ // The recording ended while this document stays open: take every listener and the host away
836
+ const dispose = () => {
837
+ endDrag();
838
+ for (const [target, type, handler, capture] of listening.splice(0)) target.removeEventListener(type, handler, capture);
839
+ if (watcher) watcher.disconnect();
840
+ clearTimeout(toastTimer);
841
+ channel = () => {};
842
+ host.remove();
843
+ if (globalThis[KEY] && globalThis[KEY].root === root) delete globalThis[KEY];
844
+ };
845
+ globalThis[KEY] = { root, update, reply, press, rebind, dispose, state: () => state, picking: () => ui.picking };
846
+
847
+ const focused = document.activeElement;
848
+ if (focused && focused.nodeType === 1 && isTextEntry(focused)) ui.field = focused;
849
+ if (!attach()) {
850
+ watcher = new MutationObserver(() => { if (attach()) watcher.disconnect(); });
851
+ watcher.observe(document, { childList: true });
852
+ }
853
+ // The page may replace the root element while it loads
854
+ listen(document, "DOMContentLoaded", attach);
855
+ listen(window, "load", attach);
856
+ }`;