@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 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
- // Use Playwright's selectOption by locating the element at the position
864
- const selectEl = page.locator('select').filter({
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
- if (selectAtPoint) {
875
- await selectAtPoint.selectOption({ label: value });
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
- throw new Error(`No select element found at position for ${element_id}`);
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
- screenshot_base64 = response.dig('result', 'screenshot_base64')
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
- screenshot_base64 = response.dig('result', 'screenshot_base64')
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
- screenshot_base64 = response.dig('result', 'screenshot_base64')
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
- screenshot_base64 = response.dig('result', 'screenshot_base64')
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
- screenshot_base64 = response.dig('result', 'screenshot_base64')
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
- screenshot_base64 = response.dig('result', 'screenshot_base64')
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
- screenshot_base64 = response.dig('result', 'screenshot_base64')
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
- screenshot_base64 = response.dig('result', 'screenshot_base64')
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
- screenshot_base64 = response.dig('result', 'screenshot_base64')
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
 
@@ -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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specsage/cli",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "SpecSage CLI - AI-powered end-to-end testing automation (Node wrapper for Ruby CLI)",
5
5
  "type": "module",
6
6
  "bin": {