@specsage/cli 0.1.16 → 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.
Files changed (2) hide show
  1. package/lib/browser.js +59 -10
  2. package/package.json +1 -1
package/lib/browser.js CHANGED
@@ -860,21 +860,70 @@ async function handleCommand(msg) {
860
860
  const centerX = x + w / 2;
861
861
  centerY = y + h / 2;
862
862
 
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(
863
+ // Try to find a native <select> element by position
864
+ const selectHandle = await page.evaluateHandle(
870
865
  ({ x, y }) => document.elementFromPoint(x, y)?.closest('select'),
871
866
  { x: centerX, y: centerY }
872
867
  );
873
868
 
874
- if (selectAtPoint) {
875
- 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 });
876
874
  } else {
877
- 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
+ }
878
927
  }
879
928
 
880
929
  // Wait for page to settle after selection (form updates, validation)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specsage/cli",
3
- "version": "0.1.16",
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": {