@specsage/cli 0.1.15 → 0.1.17
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/lib/browser.js +99 -11
- package/lib/runner.rb +26 -13
- package/lib/step_client.rb +2 -1
- package/package.json +1 -1
package/lib/browser.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { chromium } from "playwright";
|
|
7
7
|
import fs from "fs";
|
|
8
8
|
import path from "path";
|
|
9
|
-
import { fileURLToPath } from "url";
|
|
9
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
10
10
|
import {
|
|
11
11
|
setupDialogHandler,
|
|
12
12
|
hasDialog,
|
|
@@ -35,6 +35,15 @@ const tmpDir = tempDirArgIndex !== -1 && process.argv[tempDirArgIndex + 1]
|
|
|
35
35
|
? process.argv[tempDirArgIndex + 1]
|
|
36
36
|
: path.join(scriptDir, '..', 'tmp');
|
|
37
37
|
|
|
38
|
+
// Parse --eval-capture argument for eval capture module path
|
|
39
|
+
const evalCaptureArgIndex = process.argv.indexOf('--eval-capture');
|
|
40
|
+
const evalCapturePath = evalCaptureArgIndex !== -1 && process.argv[evalCaptureArgIndex + 1]
|
|
41
|
+
? process.argv[evalCaptureArgIndex + 1]
|
|
42
|
+
: null;
|
|
43
|
+
|
|
44
|
+
let evalCapture = null;
|
|
45
|
+
let evalCaptureStepIndex = 0;
|
|
46
|
+
|
|
38
47
|
async function init() {
|
|
39
48
|
const visible = process.argv.includes('--visible');
|
|
40
49
|
const record = process.argv.includes('--record');
|
|
@@ -76,6 +85,19 @@ async function init() {
|
|
|
76
85
|
|
|
77
86
|
// Set up dialog handling - captures dialogs for AI to see and respond to
|
|
78
87
|
setupDialogHandler(page);
|
|
88
|
+
|
|
89
|
+
// Load eval capture module if specified
|
|
90
|
+
if (evalCapturePath) {
|
|
91
|
+
try {
|
|
92
|
+
const evalCaptureModule = await import(pathToFileURL(evalCapturePath));
|
|
93
|
+
evalCapture = evalCaptureModule.default || evalCaptureModule;
|
|
94
|
+
console.error(`[browser.js] Eval capture loaded: ${evalCapturePath}`);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`[browser.js] Failed to load eval capture: ${evalCapturePath}`);
|
|
97
|
+
console.error(err);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
79
101
|
}
|
|
80
102
|
|
|
81
103
|
function send(response) {
|
|
@@ -838,21 +860,70 @@ async function handleCommand(msg) {
|
|
|
838
860
|
const centerX = x + w / 2;
|
|
839
861
|
centerY = y + h / 2;
|
|
840
862
|
|
|
841
|
-
//
|
|
842
|
-
const
|
|
843
|
-
has: page.locator(`text="${value}"`)
|
|
844
|
-
}).or(page.locator('select')).first();
|
|
845
|
-
|
|
846
|
-
// Try to find the exact select element by position
|
|
847
|
-
const selectAtPoint = await page.evaluateHandle(
|
|
863
|
+
// Try to find a native <select> element by position
|
|
864
|
+
const selectHandle = await page.evaluateHandle(
|
|
848
865
|
({ x, y }) => document.elementFromPoint(x, y)?.closest('select'),
|
|
849
866
|
{ x: centerX, y: centerY }
|
|
850
867
|
);
|
|
851
868
|
|
|
852
|
-
|
|
853
|
-
|
|
869
|
+
// evaluateHandle returns a JSHandle even for null — check the underlying value
|
|
870
|
+
const isNativeSelect = await selectHandle.evaluate(el => el !== null).catch(() => false);
|
|
871
|
+
|
|
872
|
+
if (isNativeSelect) {
|
|
873
|
+
await selectHandle.selectOption({ label: value });
|
|
854
874
|
} else {
|
|
855
|
-
|
|
875
|
+
// Custom combobox/select: click to open, then poll for the option.
|
|
876
|
+
// Options may load asynchronously (e.g. MUI selects that fetch data
|
|
877
|
+
// after a dependent field changes), so we retry instead of a fixed wait.
|
|
878
|
+
|
|
879
|
+
// Grab aria-controls before clicking so we can scope to the correct listbox
|
|
880
|
+
const listboxId = await page.evaluate(
|
|
881
|
+
({ x, y }) => {
|
|
882
|
+
const el = document.elementFromPoint(x, y)?.closest('[role="combobox"]');
|
|
883
|
+
return el?.getAttribute('aria-controls') || null;
|
|
884
|
+
},
|
|
885
|
+
{ x: centerX, y: centerY }
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
await page.mouse.click(centerX, centerY);
|
|
889
|
+
|
|
890
|
+
const SELECT_TIMEOUT = 10000;
|
|
891
|
+
const POLL_INTERVAL = 300;
|
|
892
|
+
const deadline = Date.now() + SELECT_TIMEOUT;
|
|
893
|
+
let clicked = false;
|
|
894
|
+
|
|
895
|
+
// Build a scoped listbox locator: prefer aria-controls ID, fall back to last visible listbox
|
|
896
|
+
const listboxLocator = listboxId
|
|
897
|
+
? page.locator(`#${CSS.escape(listboxId)}`)
|
|
898
|
+
: page.locator('[role="listbox"]').last();
|
|
899
|
+
|
|
900
|
+
while (Date.now() < deadline) {
|
|
901
|
+
await new Promise(r => setTimeout(r, POLL_INTERVAL));
|
|
902
|
+
|
|
903
|
+
// Try scoped role=option first (MUI, Radix, Headless UI, etc.)
|
|
904
|
+
const optionLocator = listboxLocator.getByRole('option', { name: value, exact: true });
|
|
905
|
+
const optionCount = await optionLocator.count();
|
|
906
|
+
if (optionCount > 0) {
|
|
907
|
+
await optionLocator.first().click();
|
|
908
|
+
clicked = true;
|
|
909
|
+
break;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// Fallback: text match inside the scoped listbox or any ul within it
|
|
913
|
+
const textLocator = listboxLocator.locator(`text="${value}"`).or(
|
|
914
|
+
page.locator(`ul >> text="${value}"`)
|
|
915
|
+
).first();
|
|
916
|
+
const textCount = await textLocator.count();
|
|
917
|
+
if (textCount > 0) {
|
|
918
|
+
await textLocator.click();
|
|
919
|
+
clicked = true;
|
|
920
|
+
break;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (!clicked) {
|
|
925
|
+
throw new Error(`Option "${value}" not found in custom select after ${SELECT_TIMEOUT}ms`);
|
|
926
|
+
}
|
|
856
927
|
}
|
|
857
928
|
|
|
858
929
|
// Wait for page to settle after selection (form updates, validation)
|
|
@@ -1048,6 +1119,23 @@ async function handleCommand(msg) {
|
|
|
1048
1119
|
throw new Error(`Unknown command: ${command}`);
|
|
1049
1120
|
}
|
|
1050
1121
|
|
|
1122
|
+
// Call eval capture hook after any command that produced real page elements
|
|
1123
|
+
if (evalCapture?.afterStep && result.elements && !result.dialog_blocking) {
|
|
1124
|
+
try {
|
|
1125
|
+
await evalCapture.afterStep({
|
|
1126
|
+
page,
|
|
1127
|
+
stepIndex: evalCaptureStepIndex,
|
|
1128
|
+
elements: result.elements,
|
|
1129
|
+
command,
|
|
1130
|
+
params,
|
|
1131
|
+
evalCaptureData: msg.eval_capture_data || null
|
|
1132
|
+
});
|
|
1133
|
+
evalCaptureStepIndex++;
|
|
1134
|
+
} catch (err) {
|
|
1135
|
+
console.error('[browser.js] Eval capture afterStep error:', err);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1051
1139
|
send({ request_id, ok: true, result, error: null });
|
|
1052
1140
|
|
|
1053
1141
|
} catch (err) {
|
package/lib/runner.rb
CHANGED
|
@@ -27,7 +27,7 @@ class Runner
|
|
|
27
27
|
# Initialize runner with scenario data from server
|
|
28
28
|
# @param all_scenarios [Hash] optional map of scenario_id => scenario_data for pre-scenario lookup
|
|
29
29
|
# @param step_client [StepClient, DirectStepClient] optional pre-configured client for step processing
|
|
30
|
-
def initialize(scenario_data, visible: false, record: false, publisher: nil, server_run_id: nil, all_scenarios: nil, step_client: nil)
|
|
30
|
+
def initialize(scenario_data, visible: false, record: false, publisher: nil, server_run_id: nil, all_scenarios: nil, step_client: nil, eval_capture: nil)
|
|
31
31
|
@scenario = normalize_scenario_data(scenario_data)
|
|
32
32
|
@scenario_id = @scenario['id']
|
|
33
33
|
@scenario_name = @scenario['name'] || @scenario['id'] || 'unnamed'
|
|
@@ -46,6 +46,7 @@ class Runner
|
|
|
46
46
|
@max_steps = nil # Max browser actions allowed, received from server on first step
|
|
47
47
|
@temp_dir = nil # Unique temp directory for this runner's video recording
|
|
48
48
|
@all_scenarios = all_scenarios || {} # Map of scenario_id => scenario_data for pre-scenario lookup
|
|
49
|
+
@eval_capture = eval_capture # Path to eval capture JS module (passed as --eval-capture to browser.js)
|
|
49
50
|
end
|
|
50
51
|
|
|
51
52
|
def run
|
|
@@ -161,6 +162,11 @@ class Runner
|
|
|
161
162
|
scenario_credentials = {}
|
|
162
163
|
scenario_max_steps = nil
|
|
163
164
|
|
|
165
|
+
# Build eval capture metadata (only when eval capture is active)
|
|
166
|
+
eval_capture_data = if @eval_capture
|
|
167
|
+
{ 'scenario_name' => scenario_name }
|
|
168
|
+
end
|
|
169
|
+
|
|
164
170
|
loop do
|
|
165
171
|
# Get next action from server
|
|
166
172
|
step_result = @step_client.submit_step(
|
|
@@ -207,7 +213,12 @@ class Runner
|
|
|
207
213
|
next
|
|
208
214
|
end
|
|
209
215
|
|
|
210
|
-
|
|
216
|
+
# Add eval instruction to eval capture data
|
|
217
|
+
if eval_capture_data && step_result[:eval_instruction]
|
|
218
|
+
eval_capture_data['eval_instruction'] = step_result[:eval_instruction]
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
result = execute_action(action, eval_capture_data: eval_capture_data)
|
|
211
222
|
|
|
212
223
|
# Update state from action result
|
|
213
224
|
if result[:screenshot_base64]
|
|
@@ -281,6 +292,7 @@ class Runner
|
|
|
281
292
|
args << '--visible' if @visible
|
|
282
293
|
args << '--record' if @record
|
|
283
294
|
args.push('--temp-dir', @temp_dir) if @temp_dir
|
|
295
|
+
args.push('--eval-capture', @eval_capture) if @eval_capture
|
|
284
296
|
@node_stdin, @node_stdout, @node_stderr, @node_wait_thread = Open3.popen3(*args)
|
|
285
297
|
|
|
286
298
|
# Wait for ready signal from browser.js
|
|
@@ -417,11 +429,12 @@ class Runner
|
|
|
417
429
|
@node_wait_thread = nil
|
|
418
430
|
end
|
|
419
431
|
|
|
420
|
-
def send_to_node(command, params = {}, timeout: NODE_IO_TIMEOUT_SECONDS)
|
|
432
|
+
def send_to_node(command, params = {}, timeout: NODE_IO_TIMEOUT_SECONDS, eval_capture_data: nil)
|
|
421
433
|
raise 'Protocol error: Node channel is poisoned, cannot send' if @node_channel_poisoned
|
|
422
434
|
|
|
423
435
|
request_id = @next_request_id
|
|
424
436
|
request = { request_id: request_id, command: command, params: params }
|
|
437
|
+
request[:eval_capture_data] = eval_capture_data if eval_capture_data
|
|
425
438
|
|
|
426
439
|
begin
|
|
427
440
|
response = Timeout.timeout(timeout) do
|
|
@@ -483,7 +496,7 @@ class Runner
|
|
|
483
496
|
end
|
|
484
497
|
end
|
|
485
498
|
|
|
486
|
-
def execute_action(action)
|
|
499
|
+
def execute_action(action, eval_capture_data: nil)
|
|
487
500
|
case action['action']
|
|
488
501
|
when 'navigate'
|
|
489
502
|
# Substitute credentials in URL (e.g., https://<<API_KEY>>@api.example.com)
|
|
@@ -491,14 +504,14 @@ class Runner
|
|
|
491
504
|
display_url = url # For logging (shows placeholders, not actual values)
|
|
492
505
|
url = substitute_credentials(url) if contains_credential_placeholder?(url)
|
|
493
506
|
|
|
494
|
-
response = send_to_node('navigate', { url: url })
|
|
507
|
+
response = send_to_node('navigate', { url: url }, eval_capture_data: eval_capture_data)
|
|
495
508
|
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
496
509
|
elements = response.dig('result', 'elements') || []
|
|
497
510
|
{ result: "Navigated to #{display_url}", screenshot_base64: screenshot_base64, elements: elements }
|
|
498
511
|
|
|
499
512
|
when 'click'
|
|
500
513
|
element_id = action['element_id']
|
|
501
|
-
response = send_to_node('click_element', { element_id: element_id })
|
|
514
|
+
response = send_to_node('click_element', { element_id: element_id }, eval_capture_data: eval_capture_data)
|
|
502
515
|
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
503
516
|
elements = response.dig('result', 'elements') || []
|
|
504
517
|
{ result: "Clicked element #{element_id}", screenshot_base64: screenshot_base64, elements: elements }
|
|
@@ -509,7 +522,7 @@ class Runner
|
|
|
509
522
|
display_value = value # For logging (shows placeholders, not actual values)
|
|
510
523
|
value = substitute_credentials(value) if contains_credential_placeholder?(value)
|
|
511
524
|
|
|
512
|
-
response = send_to_node('select_option', { element_id: element_id, value: value })
|
|
525
|
+
response = send_to_node('select_option', { element_id: element_id, value: value }, eval_capture_data: eval_capture_data)
|
|
513
526
|
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
514
527
|
elements = response.dig('result', 'elements') || []
|
|
515
528
|
{ result: "Selected '#{display_value}' in element #{element_id}", screenshot_base64: screenshot_base64, elements: elements }
|
|
@@ -521,39 +534,39 @@ class Runner
|
|
|
521
534
|
display_keys = keys # For logging (shows placeholders, not actual values)
|
|
522
535
|
keys = substitute_credentials(keys) if contains_credential_placeholder?(keys)
|
|
523
536
|
|
|
524
|
-
response = send_to_node('type', { keys: keys })
|
|
537
|
+
response = send_to_node('type', { keys: keys }, eval_capture_data: eval_capture_data)
|
|
525
538
|
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
526
539
|
elements = response.dig('result', 'elements') || []
|
|
527
540
|
{ result: "Typed: #{display_keys}", screenshot_base64: screenshot_base64, elements: elements }
|
|
528
541
|
|
|
529
542
|
when 'hotkey'
|
|
530
543
|
keys = action['keys']
|
|
531
|
-
response = send_to_node('hotkey', { keys: keys })
|
|
544
|
+
response = send_to_node('hotkey', { keys: keys }, eval_capture_data: eval_capture_data)
|
|
532
545
|
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
533
546
|
elements = response.dig('result', 'elements') || []
|
|
534
547
|
{ result: "Hotkey: #{keys}", screenshot_base64: screenshot_base64, elements: elements }
|
|
535
548
|
|
|
536
549
|
when 'wait'
|
|
537
|
-
response = send_to_node('wait', { ms: action['ms'] })
|
|
550
|
+
response = send_to_node('wait', { ms: action['ms'] }, eval_capture_data: eval_capture_data)
|
|
538
551
|
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
539
552
|
elements = response.dig('result', 'elements') || []
|
|
540
553
|
{ result: "Waited #{action['ms']}ms", screenshot_base64: screenshot_base64, elements: elements }
|
|
541
554
|
|
|
542
555
|
when 'scroll'
|
|
543
|
-
response = send_to_node('scroll', { direction: action['direction'] })
|
|
556
|
+
response = send_to_node('scroll', { direction: action['direction'] }, eval_capture_data: eval_capture_data)
|
|
544
557
|
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
545
558
|
elements = response.dig('result', 'elements') || []
|
|
546
559
|
{ result: "Scrolled #{action['direction']}", screenshot_base64: screenshot_base64, elements: elements }
|
|
547
560
|
|
|
548
561
|
when 'accept_dialog'
|
|
549
562
|
value = action['value'] # Optional, for prompt dialogs
|
|
550
|
-
response = send_to_node('accept_dialog', { value: value })
|
|
563
|
+
response = send_to_node('accept_dialog', { value: value }, eval_capture_data: eval_capture_data)
|
|
551
564
|
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
552
565
|
elements = response.dig('result', 'elements') || []
|
|
553
566
|
{ result: "Accepted dialog#{value ? " with value '#{value}'" : ''}", screenshot_base64: screenshot_base64, elements: elements }
|
|
554
567
|
|
|
555
568
|
when 'dismiss_dialog'
|
|
556
|
-
response = send_to_node('dismiss_dialog', {})
|
|
569
|
+
response = send_to_node('dismiss_dialog', {}, eval_capture_data: eval_capture_data)
|
|
557
570
|
screenshot_base64 = response.dig('result', 'screenshot_base64')
|
|
558
571
|
elements = response.dig('result', 'elements') || []
|
|
559
572
|
{ result: "Dismissed dialog", screenshot_base64: screenshot_base64, elements: elements }
|
package/lib/step_client.rb
CHANGED
|
@@ -71,7 +71,8 @@ class StepClient
|
|
|
71
71
|
step_number: response["step_number"],
|
|
72
72
|
max_steps: response["max_steps"],
|
|
73
73
|
continue: response["continue"],
|
|
74
|
-
credentials: response["credentials"] || {}
|
|
74
|
+
credentials: response["credentials"] || {},
|
|
75
|
+
eval_instruction: response["eval_instruction"]
|
|
75
76
|
}
|
|
76
77
|
end
|
|
77
78
|
|