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