runline 0.23.0 → 0.24.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.
- package/dist/plugins/shiftTranscription/src/index.js +1 -1
- package/dist/plugins/shiftTranscription/src/transcription.js +12 -0
- package/dist/plugins/steel/src/browser.js +11 -79
- package/dist/plugins/steel/src/index.js +2 -0
- package/dist/plugins/steel/src/page/bundle.generated.js +11 -0
- package/dist/plugins/steel/src/page/cdp.js +232 -0
- package/dist/plugins/steel/src/page/driver.js +286 -0
- package/dist/plugins/steel/src/page/entry.js +91 -0
- package/dist/plugins/steel/src/page/install.js +3 -0
- package/dist/plugins/steel/src/page/page-snapshot.js +330 -0
- package/dist/plugins/steel/src/page/tools.js +649 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/ariaSnapshot.js +411 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/ariaSnapshotDistiller.js +207 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/ariaTypes.js +62 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/cssTokenizer.js +949 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/domUtils.js +157 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/roleUtils.js +1193 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/stringUtils.js +194 -0
- package/dist/plugins/steel/src/page/vendor/aria-snapshot/yaml.js +80 -0
- package/dist/plugins/steel/src/page/wait-for.js +30 -0
- package/dist/plugins/steel/src/sessions.js +2 -7
- package/dist/plugins/steel/src/shared.js +12 -0
- package/package.json +1 -1
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trusted browser input over raw CDP.
|
|
3
|
+
*
|
|
4
|
+
* CDP domains are addressed directly rather than through a browser
|
|
5
|
+
* automation library, because the plugin must work in hosts that have
|
|
6
|
+
* none installed. The events are the real ones — `Input.dispatchMouseEvent`
|
|
7
|
+
* and friends produce `isTrusted` events, so pages that reject synthetic
|
|
8
|
+
* input behave as they do for a person.
|
|
9
|
+
*
|
|
10
|
+
* Targets arrive as a `locatorToken` the page bridge stamped onto the
|
|
11
|
+
* element. Resolving that token to coordinates happens here, immediately
|
|
12
|
+
* before dispatch, so a layout shift between preparation and action is
|
|
13
|
+
* caught rather than clicked through.
|
|
14
|
+
*/
|
|
15
|
+
import { CdpError } from "./cdp.js";
|
|
16
|
+
import { TARGET_ATTRIBUTE } from "./page-snapshot.js";
|
|
17
|
+
const MODIFIER_BITS = {
|
|
18
|
+
Alt: 1,
|
|
19
|
+
Control: 2,
|
|
20
|
+
Meta: 4,
|
|
21
|
+
Shift: 8,
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* CDP wants a bitmask; the tools speak in names. Unknown names are
|
|
25
|
+
* refused rather than folded in as `undefined`, which would turn the
|
|
26
|
+
* whole mask into NaN and silently strip every modifier.
|
|
27
|
+
*/
|
|
28
|
+
function modifierMask(modifiers = []) {
|
|
29
|
+
let mask = 0;
|
|
30
|
+
for (const name of modifiers) {
|
|
31
|
+
const bit = MODIFIER_BITS[name];
|
|
32
|
+
if (bit === undefined) {
|
|
33
|
+
throw new CdpError("invalid_arguments", `Unknown modifier ${name}; expected Alt, Control, Meta, or Shift`, false);
|
|
34
|
+
}
|
|
35
|
+
mask |= bit;
|
|
36
|
+
}
|
|
37
|
+
return mask;
|
|
38
|
+
}
|
|
39
|
+
/** A page expression selecting the element the bridge stamped for us. */
|
|
40
|
+
function elementExpression(locatorToken) {
|
|
41
|
+
return `document.querySelector(${JSON.stringify(`[${TARGET_ATTRIBUTE}=${JSON.stringify(locatorToken)}]`)})`;
|
|
42
|
+
}
|
|
43
|
+
export class CdpDriver {
|
|
44
|
+
page;
|
|
45
|
+
openDialog = null;
|
|
46
|
+
detachDialogs;
|
|
47
|
+
constructor(page) {
|
|
48
|
+
this.page = page;
|
|
49
|
+
// Dialogs block the renderer: every subsequent evaluate would hang
|
|
50
|
+
// until one is handled, so the tools need to see it as a result
|
|
51
|
+
// rather than as a timeout.
|
|
52
|
+
this.detachDialogs = page.onEvent((method, params) => {
|
|
53
|
+
if (method === "Page.javascriptDialogOpening") {
|
|
54
|
+
this.openDialog = {
|
|
55
|
+
type: String(params.type ?? "dialog"),
|
|
56
|
+
message: String(params.message ?? ""),
|
|
57
|
+
defaultValue: String(params.defaultPrompt ?? ""),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (method === "Page.javascriptDialogClosed")
|
|
61
|
+
this.openDialog = null;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
dialog() {
|
|
65
|
+
return this.openDialog;
|
|
66
|
+
}
|
|
67
|
+
dispose() {
|
|
68
|
+
this.detachDialogs();
|
|
69
|
+
}
|
|
70
|
+
async handleDialog(options) {
|
|
71
|
+
if (!this.openDialog) {
|
|
72
|
+
throw new CdpError("no_dialog", "No dialog is currently open", false);
|
|
73
|
+
}
|
|
74
|
+
await this.page.send("Page.handleJavaScriptDialog", {
|
|
75
|
+
accept: options.accept,
|
|
76
|
+
...(options.promptText === undefined
|
|
77
|
+
? {}
|
|
78
|
+
: { promptText: options.promptText }),
|
|
79
|
+
});
|
|
80
|
+
this.openDialog = null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolve a prepared token to viewport coordinates. Runs in the page so
|
|
84
|
+
* the element's own scroll container is honoured, and fails loudly when
|
|
85
|
+
* the element has gone or moved out of view.
|
|
86
|
+
*/
|
|
87
|
+
async point(target) {
|
|
88
|
+
const box = await this.page.evaluate(`(() => {
|
|
89
|
+
const el = ${elementExpression(target.locatorToken)};
|
|
90
|
+
if (!el) return { error: 'detached' };
|
|
91
|
+
el.scrollIntoView({ block: 'center', inline: 'center' });
|
|
92
|
+
const r = el.getBoundingClientRect();
|
|
93
|
+
if (r.width <= 0 || r.height <= 0) return { error: 'not_visible' };
|
|
94
|
+
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
|
95
|
+
})()`);
|
|
96
|
+
if ("error" in box) {
|
|
97
|
+
throw box.error === "detached"
|
|
98
|
+
? new CdpError("stale_ref", "The target element left the page between preparing and acting on it. Capture a fresh snapshot and retry.")
|
|
99
|
+
: new CdpError("element_not_visible", "The target element is not visible at action time");
|
|
100
|
+
}
|
|
101
|
+
return box;
|
|
102
|
+
}
|
|
103
|
+
async mouse(type, point, options = {}) {
|
|
104
|
+
await this.page.send("Input.dispatchMouseEvent", {
|
|
105
|
+
type,
|
|
106
|
+
x: point.x,
|
|
107
|
+
y: point.y,
|
|
108
|
+
button: options.button ?? "left",
|
|
109
|
+
clickCount: options.clickCount ?? 0,
|
|
110
|
+
modifiers: options.modifiers ?? 0,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
async click(target, options) {
|
|
114
|
+
const point = await this.point(target);
|
|
115
|
+
const modifiers = modifierMask(options.modifiers);
|
|
116
|
+
const button = options.button ?? "left";
|
|
117
|
+
await this.mouse("mouseMoved", point, { modifiers });
|
|
118
|
+
const clicks = options.doubleClick ? 2 : 1;
|
|
119
|
+
for (let index = 1; index <= clicks; index += 1) {
|
|
120
|
+
await this.mouse("mousePressed", point, {
|
|
121
|
+
button,
|
|
122
|
+
clickCount: index,
|
|
123
|
+
modifiers,
|
|
124
|
+
});
|
|
125
|
+
await this.mouse("mouseReleased", point, {
|
|
126
|
+
button,
|
|
127
|
+
clickCount: index,
|
|
128
|
+
modifiers,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async hover(target) {
|
|
133
|
+
await this.mouse("mouseMoved", await this.point(target));
|
|
134
|
+
}
|
|
135
|
+
async drag(start, end) {
|
|
136
|
+
const from = await this.point(start);
|
|
137
|
+
const to = await this.point(end);
|
|
138
|
+
await this.mouse("mouseMoved", from);
|
|
139
|
+
await this.mouse("mousePressed", from, { clickCount: 1 });
|
|
140
|
+
// Intermediate moves: drag handlers commonly ignore a single jump.
|
|
141
|
+
for (let step = 1; step <= 4; step += 1) {
|
|
142
|
+
await this.mouse("mouseMoved", {
|
|
143
|
+
x: from.x + ((to.x - from.x) * step) / 4,
|
|
144
|
+
y: from.y + ((to.y - from.y) * step) / 4,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
await this.mouse("mouseReleased", to, { clickCount: 1 });
|
|
148
|
+
}
|
|
149
|
+
async scroll(deltaY, target) {
|
|
150
|
+
const point = target
|
|
151
|
+
? await this.point(target)
|
|
152
|
+
: await this.viewportCentre();
|
|
153
|
+
await this.page.send("Input.dispatchMouseEvent", {
|
|
154
|
+
type: "mouseWheel",
|
|
155
|
+
x: point.x,
|
|
156
|
+
y: point.y,
|
|
157
|
+
deltaX: 0,
|
|
158
|
+
deltaY,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async viewportCentre() {
|
|
162
|
+
return await this.page.evaluate("({ x: window.innerWidth / 2, y: window.innerHeight / 2 })");
|
|
163
|
+
}
|
|
164
|
+
async type(target, text, options) {
|
|
165
|
+
// The bridge already focused the element and set the selection for
|
|
166
|
+
// replace/append, so typing here just replays keys into it.
|
|
167
|
+
if (options.slowly) {
|
|
168
|
+
for (const char of text)
|
|
169
|
+
await this.insertKey(char);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (text.length === 0) {
|
|
173
|
+
// Replace-mode with empty text means "clear": the selection is
|
|
174
|
+
// set, so a delete is what removes it.
|
|
175
|
+
await this.press("Delete");
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
await this.page.send("Input.insertText", { text });
|
|
179
|
+
}
|
|
180
|
+
async insertKey(char) {
|
|
181
|
+
await this.page.send("Input.dispatchKeyEvent", {
|
|
182
|
+
type: "keyDown",
|
|
183
|
+
text: char,
|
|
184
|
+
unmodifiedText: char,
|
|
185
|
+
});
|
|
186
|
+
await this.page.send("Input.dispatchKeyEvent", {
|
|
187
|
+
type: "keyUp",
|
|
188
|
+
text: char,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
async press(key) {
|
|
192
|
+
const parts = key.split("+");
|
|
193
|
+
const main = parts[parts.length - 1] ?? key;
|
|
194
|
+
const modifiers = modifierMask(parts.slice(0, -1));
|
|
195
|
+
const descriptor = keyDescriptor(main);
|
|
196
|
+
await this.page.send("Input.dispatchKeyEvent", {
|
|
197
|
+
type: descriptor.text ? "keyDown" : "rawKeyDown",
|
|
198
|
+
modifiers,
|
|
199
|
+
...descriptor,
|
|
200
|
+
});
|
|
201
|
+
await this.page.send("Input.dispatchKeyEvent", {
|
|
202
|
+
type: "keyUp",
|
|
203
|
+
modifiers,
|
|
204
|
+
...descriptor,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
async select(target, values) {
|
|
208
|
+
const ok = await this.page.evaluate(`(() => {
|
|
209
|
+
const el = ${elementExpression(target.locatorToken)};
|
|
210
|
+
if (!el) return false;
|
|
211
|
+
const wanted = new Set(${JSON.stringify(values)});
|
|
212
|
+
for (const option of el.options) option.selected = wanted.has(option.value);
|
|
213
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
214
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
215
|
+
return true;
|
|
216
|
+
})()`);
|
|
217
|
+
if (!ok) {
|
|
218
|
+
throw new CdpError("stale_ref", "The select element left the page between preparing and acting on it. Capture a fresh snapshot and retry.");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async screenshot(options) {
|
|
222
|
+
const params = {
|
|
223
|
+
format: "jpeg",
|
|
224
|
+
quality: 80,
|
|
225
|
+
};
|
|
226
|
+
if (options.fullPage)
|
|
227
|
+
params.captureBeyondViewport = true;
|
|
228
|
+
const result = (await this.page.send("Page.captureScreenshot", params));
|
|
229
|
+
return { data: result.data, mimeType: "image/jpeg" };
|
|
230
|
+
}
|
|
231
|
+
/** Let the page react to the action before it is snapshotted. */
|
|
232
|
+
async settle() {
|
|
233
|
+
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
234
|
+
if (this.openDialog)
|
|
235
|
+
return;
|
|
236
|
+
await this.page
|
|
237
|
+
.evaluate(`new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))`)
|
|
238
|
+
.catch(() => {
|
|
239
|
+
// A navigation or dialog can tear down the world mid-settle;
|
|
240
|
+
// the caller's snapshot reports the real state either way.
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const NAMED_KEYS = {
|
|
245
|
+
Enter: { code: "Enter", keyCode: 13 },
|
|
246
|
+
Tab: { code: "Tab", keyCode: 9 },
|
|
247
|
+
Escape: { code: "Escape", keyCode: 27 },
|
|
248
|
+
Backspace: { code: "Backspace", keyCode: 8 },
|
|
249
|
+
Delete: { code: "Delete", keyCode: 46 },
|
|
250
|
+
ArrowUp: { code: "ArrowUp", keyCode: 38 },
|
|
251
|
+
ArrowDown: { code: "ArrowDown", keyCode: 40 },
|
|
252
|
+
ArrowLeft: { code: "ArrowLeft", keyCode: 37 },
|
|
253
|
+
ArrowRight: { code: "ArrowRight", keyCode: 39 },
|
|
254
|
+
Home: { code: "Home", keyCode: 36 },
|
|
255
|
+
End: { code: "End", keyCode: 35 },
|
|
256
|
+
PageUp: { code: "PageUp", keyCode: 33 },
|
|
257
|
+
PageDown: { code: "PageDown", keyCode: 34 },
|
|
258
|
+
Space: { code: "Space", keyCode: 32 },
|
|
259
|
+
};
|
|
260
|
+
/**
|
|
261
|
+
* CDP needs `key`/`code`/`windowsVirtualKeyCode` for non-printable keys;
|
|
262
|
+
* printable ones also need `text` or they arrive as bare keydowns.
|
|
263
|
+
*/
|
|
264
|
+
function keyDescriptor(key) {
|
|
265
|
+
const match = NAMED_KEYS[key];
|
|
266
|
+
if (match) {
|
|
267
|
+
return {
|
|
268
|
+
key: key === "Space" ? " " : key,
|
|
269
|
+
code: match.code,
|
|
270
|
+
windowsVirtualKeyCode: match.keyCode,
|
|
271
|
+
...(key === "Enter" ? { text: "\r" } : {}),
|
|
272
|
+
...(key === "Space" ? { text: " " } : {}),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
if (key.length === 1) {
|
|
276
|
+
const upper = key.toUpperCase();
|
|
277
|
+
return {
|
|
278
|
+
key,
|
|
279
|
+
code: /[a-z]/i.test(key) ? `Key${upper}` : `Digit${key}`,
|
|
280
|
+
windowsVirtualKeyCode: upper.charCodeAt(0),
|
|
281
|
+
text: key,
|
|
282
|
+
unmodifiedText: key,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return { key };
|
|
286
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-page half of the Steel page tools.
|
|
3
|
+
*
|
|
4
|
+
* This module is bundled (see `scripts/build-steel-page-bundle.ts`) into
|
|
5
|
+
* one self-contained IIFE and evaluated inside the Steel session's page.
|
|
6
|
+
* Everything here is pure DOM code — it never sees the Steel API key, the
|
|
7
|
+
* CDP socket, or any plugin state.
|
|
8
|
+
*
|
|
9
|
+
* Why a resident global rather than a fresh evaluate per call: element
|
|
10
|
+
* refs (`e12`) are only stable while the aria-snapshot module's counter
|
|
11
|
+
* and each element's cached `_ariaRef` survive between calls. A new
|
|
12
|
+
* bundle per call would renumber every element and silently invalidate
|
|
13
|
+
* refs the agent is holding. So the bundle installs itself once per
|
|
14
|
+
* execution context and the driver re-injects only when the world is
|
|
15
|
+
* gone — which is exactly navigation, where fresh refs are correct.
|
|
16
|
+
*/
|
|
17
|
+
import { PageSnapshotService, BrowserToolError, } from "./page-snapshot.js";
|
|
18
|
+
import { waitForPage } from "./wait-for.js";
|
|
19
|
+
/** Bumped when the in-page contract changes, so a stale world is replaced. */
|
|
20
|
+
export const PAGE_BRIDGE_VERSION = 1;
|
|
21
|
+
export const PAGE_BRIDGE_GLOBAL = "__steelPageBridge";
|
|
22
|
+
export function createPageBridge(document) {
|
|
23
|
+
let service = new PageSnapshotService(document);
|
|
24
|
+
let maxChars;
|
|
25
|
+
return {
|
|
26
|
+
version: PAGE_BRIDGE_VERSION,
|
|
27
|
+
async handle(request) {
|
|
28
|
+
try {
|
|
29
|
+
return { ok: true, value: await dispatch(request) };
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error instanceof BrowserToolError) {
|
|
33
|
+
return {
|
|
34
|
+
ok: false,
|
|
35
|
+
error: {
|
|
36
|
+
code: error.code,
|
|
37
|
+
message: error.message,
|
|
38
|
+
retryable: error.retryable,
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
error: {
|
|
45
|
+
code: "page_error",
|
|
46
|
+
message: error instanceof Error ? error.message : String(error),
|
|
47
|
+
retryable: true,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
async function dispatch(request) {
|
|
54
|
+
switch (request.action) {
|
|
55
|
+
case "snapshot": {
|
|
56
|
+
// maxChars is a construction option upstream; honour a per-call
|
|
57
|
+
// override by rebuilding rather than reaching into the service.
|
|
58
|
+
if (request.maxChars !== undefined && request.maxChars !== maxChars) {
|
|
59
|
+
maxChars = request.maxChars;
|
|
60
|
+
service = new PageSnapshotService(document, { maxChars });
|
|
61
|
+
}
|
|
62
|
+
return service.capture({
|
|
63
|
+
...(request.depth !== undefined ? { depth: request.depth } : {}),
|
|
64
|
+
...(request.boxes !== undefined ? { boxes: request.boxes } : {}),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
case "prepare_target":
|
|
68
|
+
return await service.prepareTarget(request.target);
|
|
69
|
+
case "prepare_editable":
|
|
70
|
+
return await service.prepareEditableTarget(request.target, request.mode);
|
|
71
|
+
case "editable_value":
|
|
72
|
+
return service.readEditableValue(request.target);
|
|
73
|
+
case "resolve_options":
|
|
74
|
+
return { selected: service.resolveOptions(request.target, request.values) };
|
|
75
|
+
case "wait_for":
|
|
76
|
+
return await waitForPage(document, request);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Install idempotently. Re-evaluating the bundle in a world that already
|
|
82
|
+
* has a current bridge must not reset the ref counter.
|
|
83
|
+
*/
|
|
84
|
+
export function installPageBridge() {
|
|
85
|
+
const existing = globalThis[PAGE_BRIDGE_GLOBAL];
|
|
86
|
+
if (existing?.version === PAGE_BRIDGE_VERSION)
|
|
87
|
+
return existing;
|
|
88
|
+
const bridge = createPageBridge(document);
|
|
89
|
+
globalThis[PAGE_BRIDGE_GLOBAL] = bridge;
|
|
90
|
+
return bridge;
|
|
91
|
+
}
|