@ggui-ai/ui-visual-tester 0.17.0 → 0.18.0

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.
@@ -82,6 +82,12 @@ const CLICKABLE_SELECTOR = 'button, [role="button"], input[type="submit"], input
82
82
  // parent <details> — a DOM change that says nothing about the action).
83
83
  /** Per-control wait in the fallback pass — enough for a synchronous dispatch + a render. */
84
84
  const FALLBACK_CLICK_WAIT_MS = 600;
85
+ /**
86
+ * #1040: how many times ONE control whose click revealed new controls (a wizard's Next, a tab) may be
87
+ * clicked again while the action's own control is still not visible. Bounds a walk through a
88
+ * multi-step form; a toggle that keeps revealing and hiding a panel cannot spin the probe.
89
+ */
90
+ const MAX_NAVIGATOR_RECLICKS = 8;
85
91
  /**
86
92
  * ONE deadline covers both passes for an action: max(the caller's wait, this
87
93
  * floor). A few loosely-matching decoys can no longer run an action for
@@ -99,9 +105,13 @@ function norm(text) {
99
105
  * DOM order. A generated component that wires `useAction('toggleItem')` to an
100
106
  * unnamed checkbox is found by pass 2 and judged by what its click dispatches.
101
107
  */
108
+ /** The one disabled predicate: the collector's and the walk's reveal signal must not drift apart. */
109
+ function isControlDisabled(el) {
110
+ return el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true';
111
+ }
102
112
  function collectActionCandidates(root, name, label) {
103
113
  const every = Array.from(root.querySelectorAll(CLICKABLE_SELECTOR)).filter((el) => el instanceof HTMLElement);
104
- const isDisabled = (el) => el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true';
114
+ const isDisabled = isControlDisabled;
105
115
  const lcName = name.toLowerCase();
106
116
  const lcLabel = label.toLowerCase();
107
117
  const nameKey = norm(name);
@@ -140,7 +150,12 @@ function collectActionCandidates(root, name, label) {
140
150
  }
141
151
  return { named: [...byValue, ...byText], fallback: rest, disabled, namedDisabled, allClickables: every };
142
152
  }
143
- const TEXT_LIKE_INPUT_TYPES = new Set(['text', 'email', 'search', 'url', 'tel', 'number', 'password', '']);
153
+ // #1040: date-like types too — a wizard's first step is routinely gated on one ("preferred follow-up
154
+ // date"), and a Next that never enables hides every step behind it from the probe.
155
+ const TEXT_LIKE_INPUT_TYPES = new Set([
156
+ 'text', 'email', 'search', 'url', 'tel', 'number', 'password', '',
157
+ 'date', 'time', 'month', 'week', 'datetime-local',
158
+ ]);
144
159
  function sampleValueFor(el) {
145
160
  const type = el instanceof HTMLInputElement ? el.type : 'textarea';
146
161
  switch (type) {
@@ -152,6 +167,16 @@ function sampleValueFor(el) {
152
167
  return 'https://example.com';
153
168
  case 'tel':
154
169
  return '5550100';
170
+ case 'date':
171
+ return '2026-01-15';
172
+ case 'time':
173
+ return '09:30';
174
+ case 'month':
175
+ return '2026-01';
176
+ case 'week':
177
+ return '2026-W03';
178
+ case 'datetime-local':
179
+ return '2026-01-15T09:30';
155
180
  default:
156
181
  return 'probe';
157
182
  }
@@ -189,7 +214,8 @@ function primeInputs(root) {
189
214
  if (el.value.trim().length > 0)
190
215
  continue;
191
216
  setNativeValue(el, sampleValueFor(el));
192
- primed += 1;
217
+ if (el.value.trim().length > 0)
218
+ primed += 1;
193
219
  }
194
220
  for (const el of Array.from(root.querySelectorAll('select'))) {
195
221
  if (!(el instanceof HTMLSelectElement) || el.disabled)
@@ -422,7 +448,8 @@ async function run(input) {
422
448
  return {
423
449
  status: 'action-not-rendered',
424
450
  diagnostic: `no clickable control in the rendered DOM for actionSpec.${input.actionName} (label "${entry.label}")` +
425
- (first.disabled > 0 ? ` — ${first.disabled} control(s) rendered disabled, none naming the action` : ''),
451
+ (first.disabled > 0 ? ` — ${first.disabled} control(s) rendered disabled, none naming the action` : '') +
452
+ ` (primed ${primed} input(s))`,
426
453
  };
427
454
  }
428
455
  // One deadline for the whole action. Named controls get the caller's wait
@@ -459,27 +486,71 @@ async function run(input) {
459
486
  let namedDisabledSeen = first.namedDisabled;
460
487
  let navigated = false;
461
488
  let knownSignatures = new Set(first.allClickables.map((el) => signatureOf(container, el)));
489
+ // #1040: the walk. A wizard's Next is ONE element at ONE DOM path on every step, so "tried once by
490
+ // path" stopped the probe at step 1 forever, and the terminal control never mounted. A control
491
+ // whose click revealed controls the probe had not seen is a NAVIGATOR: while the action's own
492
+ // control is not visible, the probe clicks it again (bounded) before exploring further — the way
493
+ // a user presses Next until the last step. Inputs a step reveals are primed like the first step's.
494
+ const navigators = new Map();
495
+ let lastClickedSignature;
496
+ // The action's OWN control is keyed by path AND text: a wizard renders Complete at the path Next
497
+ // occupied on every earlier step, and "tried by path" would read it as the Next already clicked.
498
+ const namedKey = (el) => `${signatureOf(container, el)}|${(el.textContent ?? '').trim()}`;
499
+ const triedNamed = new Set();
500
+ // A step is "revealed" when a control the probe had not seen appears OR a control it had only
501
+ // seen disabled becomes enabled (a wizard's Back on step 2, a Next a step's own field unlocked).
502
+ const stateOf = (el) => `${signatureOf(container, el)}|${isControlDisabled(el) ? 'off' : 'on'}`;
503
+ let knownStates = new Set(first.allClickables.map(stateOf));
462
504
  for (;;) {
463
505
  if (remaining() === 0) {
464
506
  exhausted = true;
465
507
  break;
466
508
  }
467
- const live = collectActionCandidates(container, input.actionName, entry.label);
468
- namedDisabledSeen = Math.max(namedDisabledSeen, live.namedDisabled);
509
+ let live = collectActionCandidates(container, input.actionName, entry.label);
469
510
  // Disabled controls count: a step that reveals a disabled Complete IS navigation into the gate.
470
511
  const liveSignatures = live.allClickables.map((el) => signatureOf(container, el));
512
+ const liveStates = live.allClickables.map(stateOf);
513
+ // `navigated` (the reason on an unreachable exit) keeps its meaning: controls the probe had not
514
+ // seen appeared. `revealed` (the walk) is wider: that, or a control seen only disabled enabling.
471
515
  if (clicked > 0 && liveSignatures.some((sig) => !knownSignatures.has(sig)))
472
516
  navigated = true;
517
+ const revealed = clicked > 0 && liveStates.some((state) => !knownStates.has(state));
473
518
  knownSignatures = new Set([...knownSignatures, ...liveSignatures]);
519
+ knownStates = new Set([...knownStates, ...liveStates]);
520
+ if (revealed) {
521
+ if (lastClickedSignature !== undefined && !navigators.has(lastClickedSignature))
522
+ navigators.set(lastClickedSignature, 0);
523
+ // The revealed step may carry its own gate ("name", "date") — give it what the first step got.
524
+ if (primeInputs(container) > 0) {
525
+ await delay(150);
526
+ live = collectActionCandidates(container, input.actionName, entry.label);
527
+ }
528
+ }
529
+ namedDisabledSeen = Math.max(namedDisabledSeen, live.namedDisabled);
474
530
  const untried = (list) => list.find((el) => el.isConnected && !tried.has(signatureOf(container, el)));
475
- const nextNamed = untried(live.named);
476
- const next = nextNamed ?? untried(live.fallback);
531
+ const nextNamed = live.named.find((el) => el.isConnected && !triedNamed.has(namedKey(el)));
532
+ const navigator = nextNamed === undefined
533
+ ? live.fallback.find((el) => {
534
+ const sig = signatureOf(container, el);
535
+ return el.isConnected && (navigators.get(sig) ?? MAX_NAVIGATOR_RECLICKS) < MAX_NAVIGATOR_RECLICKS;
536
+ })
537
+ : undefined;
538
+ // Untried controls first — including the ones the last click revealed — and only when nothing
539
+ // untried remains, the navigator again: a wizard walks (one extra Back click); an "Add item"
540
+ // or a tab is not clicked eight more times before what it revealed gets its turn.
541
+ const next = nextNamed ?? untried(live.fallback) ?? navigator;
477
542
  if (next === undefined)
478
543
  break;
479
544
  const via = nextNamed !== undefined ? 'named' : 'fallback';
480
545
  if (via === 'named')
481
546
  namedAttempted = true;
482
- tried.add(signatureOf(container, next));
547
+ const nextSignature = signatureOf(container, next);
548
+ if (navigator !== undefined && next === navigator)
549
+ navigators.set(nextSignature, (navigators.get(nextSignature) ?? 0) + 1);
550
+ if (via === 'named')
551
+ triedNamed.add(namedKey(next));
552
+ tried.add(nextSignature);
553
+ lastClickedSignature = nextSignature;
483
554
  const hit = await attempt(next, via === 'named' ? Math.min(input.waitMs, remaining()) : Math.min(FALLBACK_CLICK_WAIT_MS, remaining()), via);
484
555
  if (hit !== null)
485
556
  return hit;
@@ -490,6 +561,7 @@ async function run(input) {
490
561
  if (req.requireDom && !bestDom)
491
562
  missing.push('DOM change');
492
563
  const others = otherActions.size > 0 ? `; other actions dispatched: ${[...otherActions].join(', ')}` : '';
564
+ const navigatorReclicks = [...navigators.values()].reduce((sum, n) => sum + n, 0);
493
565
  // #1040: the named control was seen only disabled and never clicked enabled — the probe could not
494
566
  // reach the action. Not measured, with the reason: a click made new controls appear (the gate sits
495
567
  // behind navigation the probe walked into but could not finish) or none did (the gate is input the
@@ -500,14 +572,14 @@ async function run(input) {
500
572
  status: 'action-unreachable',
501
573
  reason,
502
574
  diagnostic: `actionSpec.${input.actionName} (label "${entry.label}") rendered disabled on every observation (${namedDisabledSeen} matching control(s)) and was never clicked enabled; ` +
503
- `clicked ${clicked} other control(s) (${tried.size} distinct; primed ${primed} input(s)); new controls appeared after a click=${navigated}; DOM changed=${bestDom}` +
575
+ `clicked ${clicked} other control(s) (${tried.size} distinct; primed ${primed} input(s); navigator re-clicks=${navigatorReclicks}); new controls appeared after a click=${navigated}; DOM changed=${bestDom}` +
504
576
  `${exhausted ? '; budget exhausted' : ''}${others}`,
505
577
  };
506
578
  }
507
579
  return {
508
580
  status: 'action-no-effect',
509
581
  diagnostic: `${req.description}; clicked ${clicked} control(s) (${tried.size} distinct; first collection ${first.named.length} named, ${first.fallback.length} fallback, ${first.disabled} disabled; primed ${primed} input(s)` +
510
- `${exhausted ? '; budget exhausted' : ''}); best observed dispatch=${bestDispatch}, domChanged=${bestDom}; ` +
582
+ `; navigator re-clicks=${navigatorReclicks}${exhausted ? '; budget exhausted' : ''}); best observed dispatch=${bestDispatch}, domChanged=${bestDom}; ` +
511
583
  `missing=${missing.join('+')}${others}`,
512
584
  };
513
585
  }
@@ -32820,15 +32820,19 @@
32820
32820
  }
32821
32821
  var CLICKABLE_SELECTOR = 'button, [role="button"], input[type="submit"], input[type="button"], input[type="checkbox"], input[type="radio"], [role="switch"], [role="checkbox"], [role="menuitem"], [role="tab"], [role="option"], [data-action]';
32822
32822
  var FALLBACK_CLICK_WAIT_MS = 600;
32823
+ var MAX_NAVIGATOR_RECLICKS = 8;
32823
32824
  var ACTION_BUDGET_FLOOR_MS = 6e3;
32824
32825
  function norm(text) {
32825
32826
  return text.toLowerCase().replace(/[^a-z0-9]/g, "");
32826
32827
  }
32828
+ function isControlDisabled(el) {
32829
+ return el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true";
32830
+ }
32827
32831
  function collectActionCandidates(root, name, label) {
32828
32832
  const every = Array.from(root.querySelectorAll(CLICKABLE_SELECTOR)).filter(
32829
32833
  (el) => el instanceof HTMLElement
32830
32834
  );
32831
- const isDisabled = (el) => el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true";
32835
+ const isDisabled = isControlDisabled;
32832
32836
  const lcName = name.toLowerCase();
32833
32837
  const lcLabel = label.toLowerCase();
32834
32838
  const nameKey = norm(name);
@@ -32860,7 +32864,21 @@
32860
32864
  }
32861
32865
  return { named: [...byValue, ...byText], fallback: rest, disabled, namedDisabled, allClickables: every };
32862
32866
  }
32863
- var TEXT_LIKE_INPUT_TYPES = /* @__PURE__ */ new Set(["text", "email", "search", "url", "tel", "number", "password", ""]);
32867
+ var TEXT_LIKE_INPUT_TYPES = /* @__PURE__ */ new Set([
32868
+ "text",
32869
+ "email",
32870
+ "search",
32871
+ "url",
32872
+ "tel",
32873
+ "number",
32874
+ "password",
32875
+ "",
32876
+ "date",
32877
+ "time",
32878
+ "month",
32879
+ "week",
32880
+ "datetime-local"
32881
+ ]);
32864
32882
  function sampleValueFor(el) {
32865
32883
  const type = el instanceof HTMLInputElement ? el.type : "textarea";
32866
32884
  switch (type) {
@@ -32872,6 +32890,16 @@
32872
32890
  return "https://example.com";
32873
32891
  case "tel":
32874
32892
  return "5550100";
32893
+ case "date":
32894
+ return "2026-01-15";
32895
+ case "time":
32896
+ return "09:30";
32897
+ case "month":
32898
+ return "2026-01";
32899
+ case "week":
32900
+ return "2026-W03";
32901
+ case "datetime-local":
32902
+ return "2026-01-15T09:30";
32875
32903
  default:
32876
32904
  return "probe";
32877
32905
  }
@@ -32892,7 +32920,7 @@
32892
32920
  if (el instanceof HTMLInputElement && !TEXT_LIKE_INPUT_TYPES.has(el.type)) continue;
32893
32921
  if (el.value.trim().length > 0) continue;
32894
32922
  setNativeValue(el, sampleValueFor(el));
32895
- primed += 1;
32923
+ if (el.value.trim().length > 0) primed += 1;
32896
32924
  }
32897
32925
  for (const el of Array.from(root.querySelectorAll("select"))) {
32898
32926
  if (!(el instanceof HTMLSelectElement) || el.disabled) continue;
@@ -33084,7 +33112,7 @@
33084
33112
  }
33085
33113
  return {
33086
33114
  status: "action-not-rendered",
33087
- diagnostic: `no clickable control in the rendered DOM for actionSpec.${input.actionName} (label "${entry.label}")` + (first.disabled > 0 ? ` \u2014 ${first.disabled} control(s) rendered disabled, none naming the action` : "")
33115
+ diagnostic: `no clickable control in the rendered DOM for actionSpec.${input.actionName} (label "${entry.label}")` + (first.disabled > 0 ? ` \u2014 ${first.disabled} control(s) rendered disabled, none naming the action` : "") + ` (primed ${primed} input(s))`
33088
33116
  };
33089
33117
  }
33090
33118
  const deadline = Date.now() + Math.max(input.waitMs, ACTION_BUDGET_FLOOR_MS);
@@ -33109,23 +33137,47 @@
33109
33137
  let namedDisabledSeen = first.namedDisabled;
33110
33138
  let navigated = false;
33111
33139
  let knownSignatures = new Set(first.allClickables.map((el) => signatureOf(container, el)));
33140
+ const navigators = /* @__PURE__ */ new Map();
33141
+ let lastClickedSignature;
33142
+ const namedKey = (el) => `${signatureOf(container, el)}|${(el.textContent ?? "").trim()}`;
33143
+ const triedNamed = /* @__PURE__ */ new Set();
33144
+ const stateOf = (el) => `${signatureOf(container, el)}|${isControlDisabled(el) ? "off" : "on"}`;
33145
+ let knownStates = new Set(first.allClickables.map(stateOf));
33112
33146
  for (; ; ) {
33113
33147
  if (remaining() === 0) {
33114
33148
  exhausted = true;
33115
33149
  break;
33116
33150
  }
33117
- const live = collectActionCandidates(container, input.actionName, entry.label);
33118
- namedDisabledSeen = Math.max(namedDisabledSeen, live.namedDisabled);
33151
+ let live = collectActionCandidates(container, input.actionName, entry.label);
33119
33152
  const liveSignatures = live.allClickables.map((el) => signatureOf(container, el));
33153
+ const liveStates = live.allClickables.map(stateOf);
33120
33154
  if (clicked > 0 && liveSignatures.some((sig) => !knownSignatures.has(sig))) navigated = true;
33155
+ const revealed = clicked > 0 && liveStates.some((state) => !knownStates.has(state));
33121
33156
  knownSignatures = /* @__PURE__ */ new Set([...knownSignatures, ...liveSignatures]);
33157
+ knownStates = /* @__PURE__ */ new Set([...knownStates, ...liveStates]);
33158
+ if (revealed) {
33159
+ if (lastClickedSignature !== void 0 && !navigators.has(lastClickedSignature)) navigators.set(lastClickedSignature, 0);
33160
+ if (primeInputs(container) > 0) {
33161
+ await delay(150);
33162
+ live = collectActionCandidates(container, input.actionName, entry.label);
33163
+ }
33164
+ }
33165
+ namedDisabledSeen = Math.max(namedDisabledSeen, live.namedDisabled);
33122
33166
  const untried = (list) => list.find((el) => el.isConnected && !tried.has(signatureOf(container, el)));
33123
- const nextNamed = untried(live.named);
33124
- const next = nextNamed ?? untried(live.fallback);
33167
+ const nextNamed = live.named.find((el) => el.isConnected && !triedNamed.has(namedKey(el)));
33168
+ const navigator2 = nextNamed === void 0 ? live.fallback.find((el) => {
33169
+ const sig = signatureOf(container, el);
33170
+ return el.isConnected && (navigators.get(sig) ?? MAX_NAVIGATOR_RECLICKS) < MAX_NAVIGATOR_RECLICKS;
33171
+ }) : void 0;
33172
+ const next = nextNamed ?? untried(live.fallback) ?? navigator2;
33125
33173
  if (next === void 0) break;
33126
33174
  const via = nextNamed !== void 0 ? "named" : "fallback";
33127
33175
  if (via === "named") namedAttempted = true;
33128
- tried.add(signatureOf(container, next));
33176
+ const nextSignature = signatureOf(container, next);
33177
+ if (navigator2 !== void 0 && next === navigator2) navigators.set(nextSignature, (navigators.get(nextSignature) ?? 0) + 1);
33178
+ if (via === "named") triedNamed.add(namedKey(next));
33179
+ tried.add(nextSignature);
33180
+ lastClickedSignature = nextSignature;
33129
33181
  const hit = await attempt(
33130
33182
  next,
33131
33183
  via === "named" ? Math.min(input.waitMs, remaining()) : Math.min(FALLBACK_CLICK_WAIT_MS, remaining()),
@@ -33137,17 +33189,18 @@
33137
33189
  if (req.requireDispatch && !bestDispatch) missing.push("dispatch");
33138
33190
  if (req.requireDom && !bestDom) missing.push("DOM change");
33139
33191
  const others = otherActions.size > 0 ? `; other actions dispatched: ${[...otherActions].join(", ")}` : "";
33192
+ const navigatorReclicks = [...navigators.values()].reduce((sum, n) => sum + n, 0);
33140
33193
  if (!namedAttempted && namedDisabledSeen > 0) {
33141
33194
  const reason = navigated ? "behind-navigation" : "disabled-after-priming";
33142
33195
  return {
33143
33196
  status: "action-unreachable",
33144
33197
  reason,
33145
- diagnostic: `actionSpec.${input.actionName} (label "${entry.label}") rendered disabled on every observation (${namedDisabledSeen} matching control(s)) and was never clicked enabled; clicked ${clicked} other control(s) (${tried.size} distinct; primed ${primed} input(s)); new controls appeared after a click=${navigated}; DOM changed=${bestDom}${exhausted ? "; budget exhausted" : ""}${others}`
33198
+ diagnostic: `actionSpec.${input.actionName} (label "${entry.label}") rendered disabled on every observation (${namedDisabledSeen} matching control(s)) and was never clicked enabled; clicked ${clicked} other control(s) (${tried.size} distinct; primed ${primed} input(s); navigator re-clicks=${navigatorReclicks}); new controls appeared after a click=${navigated}; DOM changed=${bestDom}${exhausted ? "; budget exhausted" : ""}${others}`
33146
33199
  };
33147
33200
  }
33148
33201
  return {
33149
33202
  status: "action-no-effect",
33150
- diagnostic: `${req.description}; clicked ${clicked} control(s) (${tried.size} distinct; first collection ${first.named.length} named, ${first.fallback.length} fallback, ${first.disabled} disabled; primed ${primed} input(s)${exhausted ? "; budget exhausted" : ""}); best observed dispatch=${bestDispatch}, domChanged=${bestDom}; missing=${missing.join("+")}${others}`
33203
+ diagnostic: `${req.description}; clicked ${clicked} control(s) (${tried.size} distinct; first collection ${first.named.length} named, ${first.fallback.length} fallback, ${first.disabled} disabled; primed ${primed} input(s); navigator re-clicks=${navigatorReclicks}${exhausted ? "; budget exhausted" : ""}); best observed dispatch=${bestDispatch}, domChanged=${bestDom}; missing=${missing.join("+")}${others}`
33151
33204
  };
33152
33205
  } finally {
33153
33206
  window.removeEventListener("error", onWindowError);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/ui-visual-tester",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Behavioral validator for ggui-generated component code. Renders a component in real Chromium via Playwright, clicks each action declared in its contract, and verifies the action actually does something — mutating bound state or dispatching to the agent. Catches buttons wired to no-op handlers that structural validators miss. Playwright is an optional peer dependency, injected by the caller.",
5
5
  "keywords": [
6
6
  "ggui",
@@ -48,9 +48,9 @@
48
48
  "dependencies": {
49
49
  "react": "^19.0.0",
50
50
  "react-dom": "^19.0.0",
51
- "@ggui-ai/design": "0.17.0",
52
- "@ggui-ai/protocol": "0.17.0",
53
- "@ggui-ai/wire": "0.17.0"
51
+ "@ggui-ai/design": "0.18.0",
52
+ "@ggui-ai/wire": "0.18.0",
53
+ "@ggui-ai/protocol": "0.18.0"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@playwright/test": "^1.60.0",