@specsage/cli 0.1.16 → 0.1.18
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 +63 -11
- package/lib/runner.rb +23 -27
- package/lib/step_client.rb +2 -1
- package/package.json +1 -1
package/lib/browser.js
CHANGED
|
@@ -704,6 +704,7 @@ const DIALOG_BLOCKING_COMMANDS = ['navigate', 'click_element', 'select_option',
|
|
|
704
704
|
|
|
705
705
|
async function handleCommand(msg) {
|
|
706
706
|
const { request_id, command, params } = msg;
|
|
707
|
+
const startTime = Date.now();
|
|
707
708
|
|
|
708
709
|
try {
|
|
709
710
|
let result = {};
|
|
@@ -860,21 +861,70 @@ async function handleCommand(msg) {
|
|
|
860
861
|
const centerX = x + w / 2;
|
|
861
862
|
centerY = y + h / 2;
|
|
862
863
|
|
|
863
|
-
//
|
|
864
|
-
const
|
|
865
|
-
has: page.locator(`text="${value}"`)
|
|
866
|
-
}).or(page.locator('select')).first();
|
|
867
|
-
|
|
868
|
-
// Try to find the exact select element by position
|
|
869
|
-
const selectAtPoint = await page.evaluateHandle(
|
|
864
|
+
// Try to find a native <select> element by position
|
|
865
|
+
const selectHandle = await page.evaluateHandle(
|
|
870
866
|
({ x, y }) => document.elementFromPoint(x, y)?.closest('select'),
|
|
871
867
|
{ x: centerX, y: centerY }
|
|
872
868
|
);
|
|
873
869
|
|
|
874
|
-
|
|
875
|
-
|
|
870
|
+
// evaluateHandle returns a JSHandle even for null — check the underlying value
|
|
871
|
+
const isNativeSelect = await selectHandle.evaluate(el => el !== null).catch(() => false);
|
|
872
|
+
|
|
873
|
+
if (isNativeSelect) {
|
|
874
|
+
await selectHandle.selectOption({ label: value });
|
|
876
875
|
} else {
|
|
877
|
-
|
|
876
|
+
// Custom combobox/select: click to open, then poll for the option.
|
|
877
|
+
// Options may load asynchronously (e.g. MUI selects that fetch data
|
|
878
|
+
// after a dependent field changes), so we retry instead of a fixed wait.
|
|
879
|
+
|
|
880
|
+
// Grab aria-controls before clicking so we can scope to the correct listbox
|
|
881
|
+
const listboxId = await page.evaluate(
|
|
882
|
+
({ x, y }) => {
|
|
883
|
+
const el = document.elementFromPoint(x, y)?.closest('[role="combobox"]');
|
|
884
|
+
return el?.getAttribute('aria-controls') || null;
|
|
885
|
+
},
|
|
886
|
+
{ x: centerX, y: centerY }
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
await page.mouse.click(centerX, centerY);
|
|
890
|
+
|
|
891
|
+
const SELECT_TIMEOUT = 10000;
|
|
892
|
+
const POLL_INTERVAL = 300;
|
|
893
|
+
const deadline = Date.now() + SELECT_TIMEOUT;
|
|
894
|
+
let clicked = false;
|
|
895
|
+
|
|
896
|
+
// Build a scoped listbox locator: prefer aria-controls ID, fall back to last visible listbox
|
|
897
|
+
const listboxLocator = listboxId
|
|
898
|
+
? page.locator(`#${CSS.escape(listboxId)}`)
|
|
899
|
+
: page.locator('[role="listbox"]').last();
|
|
900
|
+
|
|
901
|
+
while (Date.now() < deadline) {
|
|
902
|
+
await new Promise(r => setTimeout(r, POLL_INTERVAL));
|
|
903
|
+
|
|
904
|
+
// Try scoped role=option first (MUI, Radix, Headless UI, etc.)
|
|
905
|
+
const optionLocator = listboxLocator.getByRole('option', { name: value, exact: true });
|
|
906
|
+
const optionCount = await optionLocator.count();
|
|
907
|
+
if (optionCount > 0) {
|
|
908
|
+
await optionLocator.first().click();
|
|
909
|
+
clicked = true;
|
|
910
|
+
break;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// Fallback: text match inside the scoped listbox or any ul within it
|
|
914
|
+
const textLocator = listboxLocator.locator(`text="${value}"`).or(
|
|
915
|
+
page.locator(`ul >> text="${value}"`)
|
|
916
|
+
).first();
|
|
917
|
+
const textCount = await textLocator.count();
|
|
918
|
+
if (textCount > 0) {
|
|
919
|
+
await textLocator.click();
|
|
920
|
+
clicked = true;
|
|
921
|
+
break;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (!clicked) {
|
|
926
|
+
throw new Error(`Option "${value}" not found in custom select after ${SELECT_TIMEOUT}ms`);
|
|
927
|
+
}
|
|
878
928
|
}
|
|
879
929
|
|
|
880
930
|
// Wait for page to settle after selection (form updates, validation)
|
|
@@ -1087,13 +1137,15 @@ async function handleCommand(msg) {
|
|
|
1087
1137
|
}
|
|
1088
1138
|
}
|
|
1089
1139
|
|
|
1140
|
+
if (page) result.page_url = page.url();
|
|
1141
|
+
result.duration_ms = Date.now() - startTime;
|
|
1090
1142
|
send({ request_id, ok: true, result, error: null });
|
|
1091
1143
|
|
|
1092
1144
|
} catch (err) {
|
|
1093
1145
|
send({
|
|
1094
1146
|
request_id,
|
|
1095
1147
|
ok: false,
|
|
1096
|
-
result: {},
|
|
1148
|
+
result: { duration_ms: Date.now() - startTime },
|
|
1097
1149
|
error: {
|
|
1098
1150
|
code: "BROWSER_ERROR",
|
|
1099
1151
|
message: err.message
|
package/lib/runner.rb
CHANGED
|
@@ -157,6 +157,7 @@ class Runner
|
|
|
157
157
|
interactive_elements_by_id = build_elements_by_id(interactive_elements)
|
|
158
158
|
previous_action = nil
|
|
159
159
|
action_result = nil
|
|
160
|
+
browser_result = nil
|
|
160
161
|
|
|
161
162
|
# Reset credentials and max_steps for each scenario (each has its own)
|
|
162
163
|
scenario_credentials = {}
|
|
@@ -175,6 +176,7 @@ class Runner
|
|
|
175
176
|
elements: interactive_elements,
|
|
176
177
|
previous_action: previous_action,
|
|
177
178
|
action_result: action_result,
|
|
179
|
+
browser_result: browser_result,
|
|
178
180
|
pre_scenario_for_id: pre_scenario_for_id,
|
|
179
181
|
execution_order: execution_order
|
|
180
182
|
)
|
|
@@ -231,6 +233,7 @@ class Runner
|
|
|
231
233
|
|
|
232
234
|
previous_action = action
|
|
233
235
|
action_result = result[:result]
|
|
236
|
+
browser_result = { page_url: result[:page_url], duration_ms: result[:duration_ms] }
|
|
234
237
|
|
|
235
238
|
# Check if step count has reached max_steps limit
|
|
236
239
|
step_number = step_result[:step_number] || 0
|
|
@@ -505,16 +508,12 @@ class Runner
|
|
|
505
508
|
url = substitute_credentials(url) if contains_credential_placeholder?(url)
|
|
506
509
|
|
|
507
510
|
response = send_to_node('navigate', { url: url }, eval_capture_data: eval_capture_data)
|
|
508
|
-
|
|
509
|
-
elements = response.dig('result', 'elements') || []
|
|
510
|
-
{ result: "Navigated to #{display_url}", screenshot_base64: screenshot_base64, elements: elements }
|
|
511
|
+
extract_result(response, "Navigated to #{display_url}")
|
|
511
512
|
|
|
512
513
|
when 'click'
|
|
513
514
|
element_id = action['element_id']
|
|
514
515
|
response = send_to_node('click_element', { element_id: element_id }, eval_capture_data: eval_capture_data)
|
|
515
|
-
|
|
516
|
-
elements = response.dig('result', 'elements') || []
|
|
517
|
-
{ result: "Clicked element #{element_id}", screenshot_base64: screenshot_base64, elements: elements }
|
|
516
|
+
extract_result(response, "Clicked element #{element_id}")
|
|
518
517
|
|
|
519
518
|
when 'select'
|
|
520
519
|
element_id = action['element_id']
|
|
@@ -523,9 +522,7 @@ class Runner
|
|
|
523
522
|
value = substitute_credentials(value) if contains_credential_placeholder?(value)
|
|
524
523
|
|
|
525
524
|
response = send_to_node('select_option', { element_id: element_id, value: value }, eval_capture_data: eval_capture_data)
|
|
526
|
-
|
|
527
|
-
elements = response.dig('result', 'elements') || []
|
|
528
|
-
{ result: "Selected '#{display_value}' in element #{element_id}", screenshot_base64: screenshot_base64, elements: elements }
|
|
525
|
+
extract_result(response, "Selected '#{display_value}' in element #{element_id}")
|
|
529
526
|
|
|
530
527
|
when 'type'
|
|
531
528
|
# Substitute credential placeholders at the last moment before browser execution
|
|
@@ -535,47 +532,46 @@ class Runner
|
|
|
535
532
|
keys = substitute_credentials(keys) if contains_credential_placeholder?(keys)
|
|
536
533
|
|
|
537
534
|
response = send_to_node('type', { keys: keys }, eval_capture_data: eval_capture_data)
|
|
538
|
-
|
|
539
|
-
elements = response.dig('result', 'elements') || []
|
|
540
|
-
{ result: "Typed: #{display_keys}", screenshot_base64: screenshot_base64, elements: elements }
|
|
535
|
+
extract_result(response, "Typed: #{display_keys}")
|
|
541
536
|
|
|
542
537
|
when 'hotkey'
|
|
543
538
|
keys = action['keys']
|
|
544
539
|
response = send_to_node('hotkey', { keys: keys }, eval_capture_data: eval_capture_data)
|
|
545
|
-
|
|
546
|
-
elements = response.dig('result', 'elements') || []
|
|
547
|
-
{ result: "Hotkey: #{keys}", screenshot_base64: screenshot_base64, elements: elements }
|
|
540
|
+
extract_result(response, "Hotkey: #{keys}")
|
|
548
541
|
|
|
549
542
|
when 'wait'
|
|
550
543
|
response = send_to_node('wait', { ms: action['ms'] }, eval_capture_data: eval_capture_data)
|
|
551
|
-
|
|
552
|
-
elements = response.dig('result', 'elements') || []
|
|
553
|
-
{ result: "Waited #{action['ms']}ms", screenshot_base64: screenshot_base64, elements: elements }
|
|
544
|
+
extract_result(response, "Waited #{action['ms']}ms")
|
|
554
545
|
|
|
555
546
|
when 'scroll'
|
|
556
547
|
response = send_to_node('scroll', { direction: action['direction'] }, eval_capture_data: eval_capture_data)
|
|
557
|
-
|
|
558
|
-
elements = response.dig('result', 'elements') || []
|
|
559
|
-
{ result: "Scrolled #{action['direction']}", screenshot_base64: screenshot_base64, elements: elements }
|
|
548
|
+
extract_result(response, "Scrolled #{action['direction']}")
|
|
560
549
|
|
|
561
550
|
when 'accept_dialog'
|
|
562
551
|
value = action['value'] # Optional, for prompt dialogs
|
|
563
552
|
response = send_to_node('accept_dialog', { value: value }, eval_capture_data: eval_capture_data)
|
|
564
|
-
|
|
565
|
-
elements = response.dig('result', 'elements') || []
|
|
566
|
-
{ result: "Accepted dialog#{value ? " with value '#{value}'" : ''}", screenshot_base64: screenshot_base64, elements: elements }
|
|
553
|
+
extract_result(response, "Accepted dialog#{value ? " with value '#{value}'" : ''}")
|
|
567
554
|
|
|
568
555
|
when 'dismiss_dialog'
|
|
569
556
|
response = send_to_node('dismiss_dialog', {}, eval_capture_data: eval_capture_data)
|
|
570
|
-
|
|
571
|
-
elements = response.dig('result', 'elements') || []
|
|
572
|
-
{ result: "Dismissed dialog", screenshot_base64: screenshot_base64, elements: elements }
|
|
557
|
+
extract_result(response, "Dismissed dialog")
|
|
573
558
|
|
|
574
559
|
else
|
|
575
560
|
{ result: "Unknown action: #{action['action']}", screenshot_base64: nil, elements: nil }
|
|
576
561
|
end
|
|
577
562
|
end
|
|
578
563
|
|
|
564
|
+
def extract_result(response, description)
|
|
565
|
+
r = response['result'] || {}
|
|
566
|
+
{
|
|
567
|
+
result: description,
|
|
568
|
+
screenshot_base64: r['screenshot_base64'],
|
|
569
|
+
elements: r['elements'] || [],
|
|
570
|
+
page_url: r['page_url'],
|
|
571
|
+
duration_ms: r['duration_ms']
|
|
572
|
+
}
|
|
573
|
+
end
|
|
574
|
+
|
|
579
575
|
def build_elements_by_id(elements)
|
|
580
576
|
return {} unless elements
|
|
581
577
|
|
package/lib/step_client.rb
CHANGED
|
@@ -48,7 +48,7 @@ class StepClient
|
|
|
48
48
|
# Submit a step to the server
|
|
49
49
|
# Returns: { action: Hash, step_number: Integer, continue: Boolean }
|
|
50
50
|
def submit_step(scenario_id:, screenshot_base64:, elements:, previous_action: nil, action_result: nil,
|
|
51
|
-
pre_scenario_for_id: nil, execution_order: 0)
|
|
51
|
+
browser_result: nil, pre_scenario_for_id: nil, execution_order: 0)
|
|
52
52
|
body = {
|
|
53
53
|
scenario_id: scenario_id,
|
|
54
54
|
screenshot_base64: screenshot_base64,
|
|
@@ -58,6 +58,7 @@ class StepClient
|
|
|
58
58
|
|
|
59
59
|
body[:previous_action] = previous_action if previous_action
|
|
60
60
|
body[:action_result] = action_result if action_result
|
|
61
|
+
body[:browser_result] = browser_result if browser_result
|
|
61
62
|
body[:pre_scenario_for_id] = pre_scenario_for_id if pre_scenario_for_id
|
|
62
63
|
|
|
63
64
|
response = post("/api/runs/#{@server_run_id}/step", body)
|