wrangle 0.1.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.
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ require_relative "errors"
8
+
9
+ module Wrangle
10
+ # Asks Jev to pick one observed control.
11
+ #
12
+ # Jev answers multiple-choice questions in a single prefill pass, which is the whole reason it is
13
+ # worth wiring to a browser: an observation is already a numbered list of candidates. The model
14
+ # never writes JavaScript, a selector, or a coordinate — it returns a label Wrangle offered it.
15
+ class Jev
16
+ DEFAULT_ENDPOINT = "https://api.typesafe.ai/v1/systemone"
17
+ DEFAULT_MODEL = "jev-latest"
18
+ MAX_RESPONSE_BYTES = 5 * 1024 * 1024
19
+ RETRYABLE = [429, 529].freeze
20
+
21
+ def self.from_env(endpoint: nil, model: nil)
22
+ key = ENV["JEV_API_KEY"] || ENV.fetch("TYPESAFE_API_KEY", nil)
23
+ raise ConfigurationError, "Set JEV_API_KEY (or TYPESAFE_API_KEY) to ask Jev for a decision" if key.to_s.empty?
24
+
25
+ new(api_key: key,
26
+ endpoint: endpoint || ENV["JEV_ENDPOINT"] || DEFAULT_ENDPOINT,
27
+ model: model || ENV["JEV_MODEL"] || DEFAULT_MODEL)
28
+ end
29
+
30
+ attr_reader :model, :endpoint
31
+
32
+ def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, model: DEFAULT_MODEL, timeout: 20)
33
+ @api_key = api_key
34
+ @endpoint = URI.parse(endpoint)
35
+ @model = model
36
+ @timeout = timeout
37
+ end
38
+
39
+ def ask(state:, questions:)
40
+ body = JSON.generate("state" => state, "model" => @model, "questions" => questions)
41
+ response = with_retries(body)
42
+
43
+ unless response.code.to_i.between?(200, 299)
44
+ raise JevError.new("Jev returned HTTP #{response.code}", code: "http_#{response.code}")
45
+ end
46
+
47
+ parsed = JSON.parse(response.body.to_s)
48
+ raise JevError.new("Jev returned something that is not an object", code: "bad_response") unless parsed.is_a?(Hash)
49
+
50
+ parsed
51
+ rescue JSON::ParserError
52
+ raise JevError.new("Jev returned invalid JSON", code: "bad_response")
53
+ end
54
+
55
+ private
56
+
57
+ def with_retries(body)
58
+ response = nil
59
+ 3.times do |attempt|
60
+ response = post(body)
61
+ break unless RETRYABLE.include?(response.code.to_i) && attempt < 2
62
+
63
+ sleep(0.25 * (2**attempt))
64
+ end
65
+ response
66
+ end
67
+
68
+ def post(body)
69
+ request = Net::HTTP::Post.new(@endpoint)
70
+ request["Authorization"] = "Bearer #{@api_key}"
71
+ request["Content-Type"] = "application/json"
72
+ request["User-Agent"] = "wrangle/#{Wrangle::VERSION}"
73
+ request.body = body
74
+
75
+ options = { use_ssl: @endpoint.scheme == "https", open_timeout: @timeout, read_timeout: @timeout }
76
+ response = Net::HTTP.start(@endpoint.host, @endpoint.port, **options) { |http| http.request(request) }
77
+ raise JevError.new("Jev response exceeded the local limit", code: "oversized") if
78
+ response.body.to_s.bytesize > MAX_RESPONSE_BYTES
79
+
80
+ response
81
+ rescue Timeout::Error
82
+ raise JevError.new("Jev timed out", code: "timeout")
83
+ rescue SocketError, SystemCallError, IOError => e
84
+ raise JevError.new("Could not reach Jev: #{e.class}", code: "unreachable")
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,390 @@
1
+ // Persistent JXA bridge for one scoped Safari window. Newline-delimited JSON on stdio.
2
+ // Every request names the window it expects. The bridge never searches for a replacement.
3
+ ObjC.import('AppKit');
4
+
5
+ const SAFARI_BUNDLE = 'com.apple.Safari';
6
+ const MAX_EVAL_RESULT = 4000000;
7
+ const POLL_SECONDS = 0.05;
8
+
9
+ // The page function and the observation thunk are code this bridge is given once, never per call.
10
+ const scripts = { page: null, snapshot: null };
11
+
12
+ const stdinHandle = $.NSFileHandle.fileHandleWithStandardInput;
13
+ const stdoutHandle = $.NSFileHandle.fileHandleWithStandardOutput;
14
+
15
+ const escapeNonAscii = (text) =>
16
+ text.replace(/[\u007f-\uffff]/g, (character) => '\\u' + character.charCodeAt(0).toString(16).padStart(4, '0'));
17
+
18
+ function writeLine(value) {
19
+ const line = escapeNonAscii(JSON.stringify(value)) + '\n';
20
+ stdoutHandle.writeData($(line).dataUsingEncoding($.NSUTF8StringEncoding));
21
+ }
22
+
23
+ function fail(code, message) {
24
+ const error = new Error(message);
25
+ error.bridgeCode = code;
26
+ return error;
27
+ }
28
+
29
+ function sleep(seconds) {
30
+ $.NSThread.sleepForTimeInterval(seconds);
31
+ }
32
+
33
+ function safariInstances() {
34
+ return ObjC.unwrap($.NSWorkspace.sharedWorkspace.runningApplications)
35
+ .filter((application) => ObjC.unwrap(application.bundleIdentifier) === SAFARI_BUNDLE)
36
+ .map((application) => application.processIdentifier);
37
+ }
38
+
39
+ function safariIsRunning() {
40
+ return safariInstances().length > 0;
41
+ }
42
+
43
+ function safariApp(allowLaunch) {
44
+ if (!allowLaunch && !safariIsRunning()) throw fail('safari_not_running', 'Safari is not running');
45
+ const safari = Application(SAFARI_BUNDLE);
46
+ safari.includeStandardAdditions = false;
47
+ return safari;
48
+ }
49
+
50
+ function windowIds(safari) {
51
+ const ids = [];
52
+ for (const candidate of safari.windows()) {
53
+ try {
54
+ ids.push(candidate.id());
55
+ } catch (error) {
56
+ // A window that disappears mid-enumeration is simply not a candidate.
57
+ }
58
+ }
59
+ return ids;
60
+ }
61
+
62
+ function windowById(safari, id) {
63
+ const target = safari.windows.byId(id);
64
+ try {
65
+ target.id();
66
+ } catch (error) {
67
+ throw fail('window_gone', 'The scoped Safari window no longer exists');
68
+ }
69
+ return target;
70
+ }
71
+
72
+ // Apple Events cost about 17ms each, so the hot path reads unresolved specifiers and never more than it must.
73
+ function scopedTab(scope) {
74
+ if (!scope || typeof scope.window_id !== 'number') throw fail('bad_request', 'A scope needs a window_id');
75
+ const safari = safariApp(false);
76
+ const target = safari.windows.byId(scope.window_id);
77
+ const wantsUrl = typeof scope.url === 'string';
78
+ let tabCount;
79
+ let index;
80
+ let url = null;
81
+ try {
82
+ tabCount = target.tabs.length;
83
+ index = target.currentTab.index();
84
+ if (wantsUrl) url = target.currentTab.url() || '';
85
+ } catch (error) {
86
+ throw fail('window_gone', 'The scoped Safari window no longer answers');
87
+ }
88
+ if (scope.mode === 'dedicated' && tabCount !== 1) {
89
+ throw fail('scope_changed', `The dedicated Safari window now holds ${tabCount} tabs`);
90
+ }
91
+ if (typeof scope.tab_index === 'number' && index !== scope.tab_index) {
92
+ throw fail('scope_changed', 'A different tab is now current in the scoped Safari window');
93
+ }
94
+ if (wantsUrl && url !== scope.url) {
95
+ throw fail('scope_changed', 'The scoped Safari tab is showing a different page');
96
+ }
97
+ return { safari, target, tab: target.currentTab };
98
+ }
99
+
100
+ // AppKit measures y upward from the main screen's bottom-left; AppleScript measures it downward from its top-left.
101
+ function screenBounds(screen, mainHeight) {
102
+ const frame = screen.visibleFrame;
103
+ return {
104
+ x: Math.round(frame.origin.x),
105
+ y: Math.round(mainHeight - (frame.origin.y + frame.size.height)),
106
+ width: Math.round(frame.size.width),
107
+ height: Math.round(frame.size.height),
108
+ };
109
+ }
110
+
111
+ function displays() {
112
+ const screens = ObjC.unwrap($.NSScreen.screens);
113
+ const mainHeight = screens.length ? screens[0].frame.size.height : 0;
114
+ return screens.map((screen, index) => ({ display: index, main: index === 0, ...screenBounds(screen, mainHeight) }));
115
+ }
116
+
117
+ function boundsFor(request) {
118
+ if (Array.isArray(request.bounds)) {
119
+ const [x, y, width, height] = request.bounds;
120
+ if (![x, y, width, height].every((value) => typeof value === 'number' && isFinite(value))) {
121
+ throw fail('bad_request', 'bounds must be four finite numbers');
122
+ }
123
+ if (width <= 0 || height <= 0) throw fail('bad_request', 'bounds must have a positive size');
124
+ return { x, y, width, height };
125
+ }
126
+ if (request.display === null || request.display === undefined) return null;
127
+ const available = displays();
128
+ const chosen = available[request.display];
129
+ if (!chosen) throw fail('bad_request', `No display ${request.display}; ${available.length} attached`);
130
+ const { x, y, width, height } = chosen;
131
+ return { x, y, width, height };
132
+ }
133
+
134
+ // A window that is closing still appears in the element list but stops answering. It is not a usable target.
135
+ function readWindow(target) {
136
+ try {
137
+ const tabs = target.tabs();
138
+ const tab = target.currentTab();
139
+ if (!tabs || !tab) return null;
140
+ const placement = target.bounds();
141
+ return {
142
+ window_id: target.id(),
143
+ tabs: tabs.length,
144
+ tab_index: tab.index(),
145
+ bounds: [placement.x, placement.y, placement.width, placement.height],
146
+ tab,
147
+ };
148
+ } catch (error) {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ function tabFacts(target) {
154
+ return {
155
+ window_id: target.id(),
156
+ tab_index: target.currentTab.index(),
157
+ tabs: target.tabs.length,
158
+ url: target.currentTab.url() || '',
159
+ title: target.currentTab.name() || '',
160
+ };
161
+ }
162
+
163
+ function runPageCode(safari, tab, code) {
164
+ let result;
165
+ try {
166
+ result = safari.doJavaScript(code, { in: tab });
167
+ } catch (error) {
168
+ throw fail('javascript_failed', String(error && error.message ? error.message : error));
169
+ }
170
+ if (typeof result !== 'string') throw fail('bad_result', 'The page did not return a JSON string');
171
+ if (result.length > MAX_EVAL_RESULT) throw fail('bad_result', 'The page result exceeded the size limit');
172
+ return result;
173
+ }
174
+
175
+ function evaluate(request) {
176
+ if (!scripts.page || !scripts.snapshot) throw fail('bad_request', 'Install the page scripts first');
177
+ if (typeof request.payload !== 'string' || !request.payload) throw fail('bad_request', 'eval needs a payload');
178
+ // The caller's payload is already JSON. It becomes one argument, never part of the program's structure.
179
+ const code = `(${scripts.page})(${request.payload}, () => (${scripts.snapshot}))`;
180
+
181
+ // Binding a document or mutating one is checked against Safari itself before the code is delivered.
182
+ if (request.verify === true) {
183
+ const { safari, tab } = scopedTab(request.scope);
184
+ return { result: runPageCode(safari, tab, code) };
185
+ }
186
+
187
+ // A read pays for one guard event: the page epoch proves the document, this proves the user has not
188
+ // reclaimed the window or switched away from the tab that was handed over.
189
+ const scope = request.scope;
190
+ if (!scope || typeof scope.window_id !== 'number') throw fail('bad_request', 'A scope needs a window_id');
191
+ const safari = safariApp(false);
192
+ const target = safari.windows.byId(scope.window_id);
193
+ let tabCount = null;
194
+ let index = null;
195
+ try {
196
+ if (scope.mode === 'dedicated') tabCount = target.tabs.length;
197
+ else index = target.currentTab.index();
198
+ } catch (error) {
199
+ throw fail('window_gone', 'The scoped Safari window no longer answers');
200
+ }
201
+ if (tabCount !== null && tabCount !== 1) {
202
+ throw fail('scope_changed', `The dedicated Safari window now holds ${tabCount} tabs`);
203
+ }
204
+ if (index !== null && typeof scope.tab_index === 'number' && index !== scope.tab_index) {
205
+ throw fail('scope_changed', 'A different tab is now current in the scoped Safari window');
206
+ }
207
+ try {
208
+ return { result: runPageCode(safari, target.currentTab, code) };
209
+ } catch (error) {
210
+ scopedTab(scope); // Turn an unclear failure into an accurate scope verdict when one exists.
211
+ throw error;
212
+ }
213
+ }
214
+
215
+ function openWindow(request) {
216
+ if (typeof request.url !== 'string' || !request.url) throw fail('bad_request', 'open needs a url');
217
+ const safari = safariApp(request.allow_launch !== false);
218
+ const bounds = boundsFor(request);
219
+ const before = new Set(windowIds(safari));
220
+ const prior = request.restore_focus === false ? null : $.NSWorkspace.sharedWorkspace.frontmostApplication;
221
+
222
+ safari.documents.push(safari.Document({ url: request.url }));
223
+
224
+ let opened = null;
225
+ const deadline = Date.now() + 10000;
226
+ while (opened === null && Date.now() < deadline) {
227
+ for (const id of windowIds(safari)) {
228
+ if (!before.has(id)) {
229
+ opened = id;
230
+ break;
231
+ }
232
+ }
233
+ if (opened === null) sleep(POLL_SECONDS);
234
+ }
235
+ if (opened === null) throw fail('open_failed', 'Safari did not report a new window');
236
+
237
+ const target = windowById(safari, opened);
238
+ if (bounds) target.bounds = bounds;
239
+ // Give the keyboard back before waiting for the page; the agent window stays where it was put.
240
+ if (prior) prior.activateWithOptions(0);
241
+
242
+ const timeout = typeof request.timeout === 'number' && request.timeout > 0 ? request.timeout : 15;
243
+ const loadDeadline = Date.now() + timeout * 1000;
244
+ // A new window starts at about:blank, which is already readyState 'complete'. Waiting on
245
+ // readiness alone therefore returns the blank document whenever the real page is slower than the
246
+ // first poll. The tab's url is no help either: Safari updates it before the document is replaced,
247
+ // so the two facts must be read from inside the same document to avoid racing each other.
248
+ const wantsBlank = request.url === 'about:blank';
249
+ const probe = wantsBlank
250
+ ? "document.readyState === 'complete'"
251
+ : "document.readyState === 'complete' && location.href !== 'about:blank'";
252
+ let ready = false;
253
+ while (!ready && Date.now() < loadDeadline) {
254
+ try {
255
+ ready = safari.doJavaScript(probe, { in: target.currentTab() }) === true;
256
+ } catch (error) {
257
+ throw fail('javascript_failed', String(error && error.message ? error.message : error));
258
+ }
259
+ if (!ready) sleep(POLL_SECONDS);
260
+ }
261
+ const placement = target.bounds();
262
+ return {
263
+ ...tabFacts(target),
264
+ ready,
265
+ bounds: [placement.x, placement.y, placement.width, placement.height],
266
+ };
267
+ }
268
+
269
+ function handle(request) {
270
+ switch (request.op) {
271
+ case 'ping': {
272
+ const instances = safariInstances();
273
+ return {
274
+ pid: $.NSProcessInfo.processInfo.processIdentifier,
275
+ safari_running: instances.length > 0,
276
+ // More than one Safari process makes window ids ambiguous, so the caller decides whether to proceed.
277
+ safari_instances: instances.length,
278
+ };
279
+ }
280
+ case 'scripts': {
281
+ if (typeof request.page !== 'string' || typeof request.snapshot !== 'string') {
282
+ throw fail('bad_request', 'scripts needs page and snapshot sources');
283
+ }
284
+ scripts.page = request.page;
285
+ scripts.snapshot = request.snapshot;
286
+ return { installed: true };
287
+ }
288
+ case 'displays':
289
+ return { displays: displays() };
290
+ case 'windows': {
291
+ const safari = safariApp(false);
292
+ const available = displays();
293
+ const listed = [];
294
+ for (const target of safari.windows()) {
295
+ const facts = readWindow(target);
296
+ if (!facts) continue;
297
+ const [x, y, width, height] = facts.bounds;
298
+ const centerX = x + width / 2;
299
+ const centerY = y + height / 2;
300
+ const display = available.findIndex(
301
+ (screen) =>
302
+ centerX >= screen.x &&
303
+ centerX < screen.x + screen.width &&
304
+ centerY >= screen.y &&
305
+ centerY < screen.y + screen.height
306
+ );
307
+ const described = {
308
+ window_id: facts.window_id,
309
+ tabs: facts.tabs,
310
+ tab_index: facts.tab_index,
311
+ display: display < 0 ? null : display,
312
+ bounds: facts.bounds,
313
+ };
314
+ // Titles and URLs identify a tab to its owner, so they are opt-in and never implied.
315
+ listed.push(
316
+ request.titles === true
317
+ ? { ...described, url: facts.tab.url() || '', title: facts.tab.name() || '' }
318
+ : described
319
+ );
320
+ }
321
+ return { windows: listed };
322
+ }
323
+ case 'open':
324
+ return openWindow(request);
325
+ case 'attach': {
326
+ const { target } = scopedTab({ window_id: request.window_id, mode: 'attach', url: request.url });
327
+ return tabFacts(target);
328
+ }
329
+ case 'bounds': {
330
+ const safari = safariApp(false);
331
+ const target = windowById(safari, request.window_id);
332
+ const bounds = boundsFor(request);
333
+ if (!bounds) throw fail('bad_request', 'bounds needs a display or explicit bounds');
334
+ target.bounds = bounds;
335
+ const placement = target.bounds();
336
+ return { bounds: [placement.x, placement.y, placement.width, placement.height] };
337
+ }
338
+ case 'eval':
339
+ return evaluate(request);
340
+ case 'close': {
341
+ // Only a window this bridge opened may be closed, and only when the caller still owns it.
342
+ if (request.owned !== true) throw fail('bad_request', 'Refusing to close a window this bridge does not own');
343
+ const safari = safariApp(false);
344
+ const target = windowById(safari, request.window_id);
345
+ const facts = readWindow(target);
346
+ if (!facts) return { closed: null };
347
+ if (facts.tabs !== 1) throw fail('scope_changed', 'The owned window gained tabs; leaving it open');
348
+ target.close();
349
+ return { closed: request.window_id };
350
+ }
351
+ default:
352
+ throw fail('bad_request', `Unknown operation ${request.op}`);
353
+ }
354
+ }
355
+
356
+ function run() {
357
+ let buffer = '';
358
+ for (;;) {
359
+ const data = stdinHandle.availableData;
360
+ if (!data || data.length === 0) return;
361
+ buffer += ObjC.unwrap($.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding));
362
+ let newline;
363
+ while ((newline = buffer.indexOf('\n')) >= 0) {
364
+ const line = buffer.slice(0, newline);
365
+ buffer = buffer.slice(newline + 1);
366
+ if (!line.trim()) continue;
367
+ let request = null;
368
+ try {
369
+ request = JSON.parse(line);
370
+ } catch (error) {
371
+ writeLine({ id: null, ok: false, code: 'bad_request', error: 'Request was not valid JSON' });
372
+ continue;
373
+ }
374
+ if (request.op === 'exit') {
375
+ writeLine({ id: request.id ?? null, ok: true, value: { exiting: true } });
376
+ return;
377
+ }
378
+ try {
379
+ writeLine({ id: request.id ?? null, ok: true, value: handle(request) });
380
+ } catch (error) {
381
+ writeLine({
382
+ id: request.id ?? null,
383
+ ok: false,
384
+ code: error && error.bridgeCode ? error.bridgeCode : 'bridge_error',
385
+ error: String(error && error.message ? error.message : error),
386
+ });
387
+ }
388
+ }
389
+ }
390
+ }
@@ -0,0 +1,127 @@
1
+ // Runs inside the scoped Safari tab. Code-owned: the model never contributes JavaScript, only an observed action id.
2
+ // Receives the request as a JSON literal and `snapshot` as the shared observation thunk. Always returns a JSON string.
3
+ (request, snapshot) => {
4
+ const reply = (value) => JSON.stringify(value);
5
+ const observed = () => {
6
+ const state = snapshot();
7
+ return state ? reply({ status: 'ok', state }) : reply({ status: 'navigating' });
8
+ };
9
+
10
+ if (request.op === 'install') {
11
+ const state = snapshot();
12
+ if (!state) return reply({ status: 'navigating' });
13
+ // snapshot() creates the cache; the epoch marks this exact document as the one the session bound to.
14
+ window.__wrangle.epoch = request.epoch;
15
+ return reply({ status: 'ok', state });
16
+ }
17
+
18
+ const cache = window.__wrangle;
19
+ if (!cache || cache.epoch !== request.epoch) return reply({ status: 'epoch_lost' });
20
+
21
+ if (request.op === 'observe') return observed();
22
+ if (request.op === 'marker') {
23
+ const state = snapshot();
24
+ return state ? reply({ status: 'ok', marker: state.marker }) : reply({ status: 'navigating' });
25
+ }
26
+ if (request.op === 'guard') {
27
+ const element = cache.nodes.get(request.node);
28
+ return reply({ status: 'ok', guard: [cache.pageKey(), element ? cache.guard(element) : null] });
29
+ }
30
+ if (request.op === 'probe') return reply({ status: 'ok', act: cache.act || null });
31
+ if (request.op !== 'act') return reply({ status: 'unsupported' });
32
+
33
+ const action = request.action;
34
+ if (action.kind === 'scroll') {
35
+ cache.act = { nonce: request.nonce, kind: 'scroll', phase: 'started' };
36
+ scrollBy({ top: action.delta, left: 0, behavior: 'instant' });
37
+ cache.act.phase = 'finished';
38
+ return reply({ status: 'executed' });
39
+ }
40
+
41
+ const element = cache.nodes.get(action.node);
42
+ if (
43
+ !element ||
44
+ !element.isConnected ||
45
+ element.matches(':disabled') ||
46
+ element.closest('[aria-disabled="true"],[inert]') ||
47
+ !element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })
48
+ ) {
49
+ return reply({ status: 'blocked', reason: 'target' });
50
+ }
51
+ if (action.kind === 'fill' && (element.readOnly || element.getAttribute('aria-readonly') === 'true')) {
52
+ return reply({ status: 'blocked', reason: 'readonly' });
53
+ }
54
+ const rect = element.getBoundingClientRect();
55
+ const x = rect.x + rect.width / 2;
56
+ const y = rect.y + rect.height / 2;
57
+ if (!rect.width || !rect.height || x < 0 || y < 0 || x >= innerWidth || y >= innerHeight) {
58
+ return reply({ status: 'blocked', reason: 'offscreen' });
59
+ }
60
+ // Everything driven by a pointer must be the thing at those coordinates, or the click lands on
61
+ // whatever is on top. A select is not: it is driven by assigning value and dispatching
62
+ // input/change. Styled dropdowns hide the native control under an overlay — Amazon's sort is one —
63
+ // so hit-testing it would make the most common select on the web permanently unactionable.
64
+ if (action.kind !== 'select' && !element.contains(document.elementFromPoint(x, y))) {
65
+ return reply({ status: 'blocked', reason: 'covered' });
66
+ }
67
+ if (action.kind === 'select') {
68
+ const selectable =
69
+ element.tagName === 'SELECT' &&
70
+ [...element.options].some(
71
+ (option) => option.value === action.value && !option.disabled && !option.closest('optgroup[disabled]')
72
+ );
73
+ if (!selectable) return reply({ status: 'blocked', reason: 'option' });
74
+ }
75
+
76
+ // From here a mutation is about to happen. The nonce lets the caller learn how far it got if the bridge dies.
77
+ cache.act = { nonce: request.nonce, kind: action.kind, phase: 'started' };
78
+
79
+ if (action.kind === 'select') {
80
+ element.value = action.value;
81
+ element.dispatchEvent(new Event('input', { bubbles: true }));
82
+ element.dispatchEvent(new Event('change', { bubbles: true }));
83
+ cache.act.phase = 'finished';
84
+ return reply({ status: element.value === action.value ? 'executed' : 'unconfirmed' });
85
+ }
86
+
87
+ if (action.kind === 'fill') {
88
+ const text = request.text;
89
+ element.focus({ preventScroll: true });
90
+ if (element.isContentEditable) {
91
+ element.textContent = text;
92
+ } else {
93
+ const prototype =
94
+ element instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
95
+ const setter = Object.getOwnPropertyDescriptor(prototype, 'value')?.set;
96
+ if (setter) setter.call(element, text);
97
+ else element.value = text;
98
+ }
99
+ element.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true, inputType: 'insertText', data: text }));
100
+ element.dispatchEvent(new Event('change', { bubbles: true }));
101
+ cache.act.phase = 'finished';
102
+ const current = element.isContentEditable ? element.textContent : element.value;
103
+ return reply({ status: current === text ? 'executed' : 'unconfirmed' });
104
+ }
105
+
106
+ const pointer = {
107
+ bubbles: true,
108
+ cancelable: true,
109
+ composed: true,
110
+ view: window,
111
+ detail: 1,
112
+ clientX: x,
113
+ clientY: y,
114
+ button: 0,
115
+ pointerId: 1,
116
+ pointerType: 'mouse',
117
+ isPrimary: true,
118
+ };
119
+ if (typeof element.focus === 'function' && element.tabIndex >= 0) element.focus({ preventScroll: true });
120
+ element.dispatchEvent(new PointerEvent('pointerdown', { ...pointer, buttons: 1 }));
121
+ element.dispatchEvent(new MouseEvent('mousedown', { ...pointer, buttons: 1 }));
122
+ element.dispatchEvent(new PointerEvent('pointerup', { ...pointer, buttons: 0 }));
123
+ element.dispatchEvent(new MouseEvent('mouseup', { ...pointer, buttons: 0 }));
124
+ element.click();
125
+ cache.act.phase = 'finished';
126
+ return reply({ status: 'executed' });
127
+ }