@ggui-ai/ui-visual-tester 0.17.0 → 0.19.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,10 +150,54 @@ 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
+ ]);
159
+ /**
160
+ * #1040: dates are primed RELATIVE TO NOW — a form routinely refuses a date in the past
161
+ * ("preferred follow-up date"), and a constant sample date is a past date. Thirty days out,
162
+ * clamped into the input's own [min, max] when those are set in the input's format (ISO
163
+ * strings compare lexically). Time-of-day values have no past to fall into.
164
+ */
165
+ const pad2 = (n) => String(n).padStart(2, '0');
166
+ const partsOf = (at) => ({ y: at.getFullYear(), m: at.getMonth() + 1, d: at.getDate() });
167
+ /** Today and thirty days out, as the parts a date-like value is built from. */
168
+ function todayAndThirtyDaysOut() {
169
+ const now = new Date();
170
+ const at = new Date(now);
171
+ at.setDate(at.getDate() + 30);
172
+ return { today: partsOf(now), later: partsOf(at) };
173
+ }
174
+ /**
175
+ * Clamp a value into the input's [min, max] when each is set in the input's own format
176
+ * (ISO-shaped strings compare lexically). `min` later than the value wins; `max` earlier than
177
+ * the value wins only while it is not before `floor` (today in the same format) — a max in
178
+ * the past is a form that can never be satisfied, and the probe reports that as it is.
179
+ */
180
+ function clampToRange(el, value, floor, format) {
181
+ let out = value;
182
+ if (format.test(el.min) && el.min > out)
183
+ out = el.min;
184
+ if (format.test(el.max) && el.max < out && el.max >= floor)
185
+ out = el.max;
186
+ return out;
187
+ }
188
+ function isoWeekOf(y, m, d) {
189
+ // ISO-8601 week: Thursday of the same week decides the year.
190
+ const date = new Date(Date.UTC(y, m - 1, d));
191
+ const day = date.getUTCDay() || 7;
192
+ date.setUTCDate(date.getUTCDate() + 4 - day);
193
+ const jan1 = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
194
+ const week = Math.ceil(((date.getTime() - jan1.getTime()) / 86_400_000 + 1) / 7);
195
+ return `${date.getUTCFullYear()}-W${pad2(week)}`;
196
+ }
144
197
  function sampleValueFor(el) {
145
- const type = el instanceof HTMLInputElement ? el.type : 'textarea';
146
- switch (type) {
198
+ if (!(el instanceof HTMLInputElement))
199
+ return 'probe'; // a textarea has no type to switch on
200
+ switch (el.type) {
147
201
  case 'number':
148
202
  return '1';
149
203
  case 'email':
@@ -152,6 +206,27 @@ function sampleValueFor(el) {
152
206
  return 'https://example.com';
153
207
  case 'tel':
154
208
  return '5550100';
209
+ case 'date': {
210
+ const { today, later } = todayAndThirtyDaysOut();
211
+ const iso = (p) => `${p.y}-${pad2(p.m)}-${pad2(p.d)}`;
212
+ return clampToRange(el, iso(later), iso(today), /^\d{4}-\d{2}-\d{2}$/);
213
+ }
214
+ case 'time':
215
+ return '09:30';
216
+ case 'month': {
217
+ const { today, later } = todayAndThirtyDaysOut();
218
+ const ym = (p) => `${p.y}-${pad2(p.m)}`;
219
+ return clampToRange(el, ym(later), ym(today), /^\d{4}-\d{2}$/);
220
+ }
221
+ case 'week': {
222
+ const { today, later } = todayAndThirtyDaysOut();
223
+ return clampToRange(el, isoWeekOf(later.y, later.m, later.d), isoWeekOf(today.y, today.m, today.d), /^\d{4}-W\d{2}$/);
224
+ }
225
+ case 'datetime-local': {
226
+ const { today, later } = todayAndThirtyDaysOut();
227
+ const at = (p, hm) => `${p.y}-${pad2(p.m)}-${pad2(p.d)}T${hm}`;
228
+ return clampToRange(el, at(later, '09:30'), at(today, '00:00'), /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
229
+ }
155
230
  default:
156
231
  return 'probe';
157
232
  }
@@ -189,7 +264,8 @@ function primeInputs(root) {
189
264
  if (el.value.trim().length > 0)
190
265
  continue;
191
266
  setNativeValue(el, sampleValueFor(el));
192
- primed += 1;
267
+ if (el.value.trim().length > 0)
268
+ primed += 1;
193
269
  }
194
270
  for (const el of Array.from(root.querySelectorAll('select'))) {
195
271
  if (!(el instanceof HTMLSelectElement) || el.disabled)
@@ -422,7 +498,8 @@ async function run(input) {
422
498
  return {
423
499
  status: 'action-not-rendered',
424
500
  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` : ''),
501
+ (first.disabled > 0 ? ` — ${first.disabled} control(s) rendered disabled, none naming the action` : '') +
502
+ ` (primed ${primed} input(s))`,
426
503
  };
427
504
  }
428
505
  // One deadline for the whole action. Named controls get the caller's wait
@@ -459,27 +536,71 @@ async function run(input) {
459
536
  let namedDisabledSeen = first.namedDisabled;
460
537
  let navigated = false;
461
538
  let knownSignatures = new Set(first.allClickables.map((el) => signatureOf(container, el)));
539
+ // #1040: the walk. A wizard's Next is ONE element at ONE DOM path on every step, so "tried once by
540
+ // path" stopped the probe at step 1 forever, and the terminal control never mounted. A control
541
+ // whose click revealed controls the probe had not seen is a NAVIGATOR: while the action's own
542
+ // control is not visible, the probe clicks it again (bounded) before exploring further — the way
543
+ // a user presses Next until the last step. Inputs a step reveals are primed like the first step's.
544
+ const navigators = new Map();
545
+ let lastClickedSignature;
546
+ // The action's OWN control is keyed by path AND text: a wizard renders Complete at the path Next
547
+ // occupied on every earlier step, and "tried by path" would read it as the Next already clicked.
548
+ const namedKey = (el) => `${signatureOf(container, el)}|${(el.textContent ?? '').trim()}`;
549
+ const triedNamed = new Set();
550
+ // A step is "revealed" when a control the probe had not seen appears OR a control it had only
551
+ // seen disabled becomes enabled (a wizard's Back on step 2, a Next a step's own field unlocked).
552
+ const stateOf = (el) => `${signatureOf(container, el)}|${isControlDisabled(el) ? 'off' : 'on'}`;
553
+ let knownStates = new Set(first.allClickables.map(stateOf));
462
554
  for (;;) {
463
555
  if (remaining() === 0) {
464
556
  exhausted = true;
465
557
  break;
466
558
  }
467
- const live = collectActionCandidates(container, input.actionName, entry.label);
468
- namedDisabledSeen = Math.max(namedDisabledSeen, live.namedDisabled);
559
+ let live = collectActionCandidates(container, input.actionName, entry.label);
469
560
  // Disabled controls count: a step that reveals a disabled Complete IS navigation into the gate.
470
561
  const liveSignatures = live.allClickables.map((el) => signatureOf(container, el));
562
+ const liveStates = live.allClickables.map(stateOf);
563
+ // `navigated` (the reason on an unreachable exit) keeps its meaning: controls the probe had not
564
+ // seen appeared. `revealed` (the walk) is wider: that, or a control seen only disabled enabling.
471
565
  if (clicked > 0 && liveSignatures.some((sig) => !knownSignatures.has(sig)))
472
566
  navigated = true;
567
+ const revealed = clicked > 0 && liveStates.some((state) => !knownStates.has(state));
473
568
  knownSignatures = new Set([...knownSignatures, ...liveSignatures]);
569
+ knownStates = new Set([...knownStates, ...liveStates]);
570
+ if (revealed) {
571
+ if (lastClickedSignature !== undefined && !navigators.has(lastClickedSignature))
572
+ navigators.set(lastClickedSignature, 0);
573
+ // The revealed step may carry its own gate ("name", "date") — give it what the first step got.
574
+ if (primeInputs(container) > 0) {
575
+ await delay(150);
576
+ live = collectActionCandidates(container, input.actionName, entry.label);
577
+ }
578
+ }
579
+ namedDisabledSeen = Math.max(namedDisabledSeen, live.namedDisabled);
474
580
  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);
581
+ const nextNamed = live.named.find((el) => el.isConnected && !triedNamed.has(namedKey(el)));
582
+ const navigator = nextNamed === undefined
583
+ ? live.fallback.find((el) => {
584
+ const sig = signatureOf(container, el);
585
+ return el.isConnected && (navigators.get(sig) ?? MAX_NAVIGATOR_RECLICKS) < MAX_NAVIGATOR_RECLICKS;
586
+ })
587
+ : undefined;
588
+ // Untried controls first — including the ones the last click revealed — and only when nothing
589
+ // untried remains, the navigator again: a wizard walks (one extra Back click); an "Add item"
590
+ // or a tab is not clicked eight more times before what it revealed gets its turn.
591
+ const next = nextNamed ?? untried(live.fallback) ?? navigator;
477
592
  if (next === undefined)
478
593
  break;
479
594
  const via = nextNamed !== undefined ? 'named' : 'fallback';
480
595
  if (via === 'named')
481
596
  namedAttempted = true;
482
- tried.add(signatureOf(container, next));
597
+ const nextSignature = signatureOf(container, next);
598
+ if (navigator !== undefined && next === navigator)
599
+ navigators.set(nextSignature, (navigators.get(nextSignature) ?? 0) + 1);
600
+ if (via === 'named')
601
+ triedNamed.add(namedKey(next));
602
+ tried.add(nextSignature);
603
+ lastClickedSignature = nextSignature;
483
604
  const hit = await attempt(next, via === 'named' ? Math.min(input.waitMs, remaining()) : Math.min(FALLBACK_CLICK_WAIT_MS, remaining()), via);
484
605
  if (hit !== null)
485
606
  return hit;
@@ -490,6 +611,7 @@ async function run(input) {
490
611
  if (req.requireDom && !bestDom)
491
612
  missing.push('DOM change');
492
613
  const others = otherActions.size > 0 ? `; other actions dispatched: ${[...otherActions].join(', ')}` : '';
614
+ const navigatorReclicks = [...navigators.values()].reduce((sum, n) => sum + n, 0);
493
615
  // #1040: the named control was seen only disabled and never clicked enabled — the probe could not
494
616
  // reach the action. Not measured, with the reason: a click made new controls appear (the gate sits
495
617
  // behind navigation the probe walked into but could not finish) or none did (the gate is input the
@@ -500,14 +622,14 @@ async function run(input) {
500
622
  status: 'action-unreachable',
501
623
  reason,
502
624
  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}` +
625
+ `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
626
  `${exhausted ? '; budget exhausted' : ''}${others}`,
505
627
  };
506
628
  }
507
629
  return {
508
630
  status: 'action-no-effect',
509
631
  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}; ` +
632
+ `; navigator re-clicks=${navigatorReclicks}${exhausted ? '; budget exhausted' : ''}); best observed dispatch=${bestDispatch}, domChanged=${bestDom}; ` +
511
633
  `missing=${missing.join('+')}${others}`,
512
634
  };
513
635
  }
@@ -20658,7 +20658,7 @@
20658
20658
  alignItems: "center",
20659
20659
  justifyContent: "center",
20660
20660
  gap: "var(--ggui-spacing-2, 8px)",
20661
- borderRadius: "var(--ggui-shape-radius-md, 8px)",
20661
+ borderRadius: "var(--ggui-shape-radius-control, var(--ggui-shape-radius-md, 8px))",
20662
20662
  fontWeight: "var(--ggui-font-weight-medium, 500)",
20663
20663
  cursor: isDisabled ? "not-allowed" : "pointer",
20664
20664
  opacity: isDisabled ? 0.5 : 1,
@@ -20704,7 +20704,7 @@
20704
20704
  }, children: [label, required && (0, import_jsx_runtime9.jsx)("span", { "aria-hidden": "true", style: { color: "var(--ggui-color-error-500, #ef4444)", marginLeft: "2px" }, children: "*" })] }), (0, import_jsx_runtime9.jsx)("input", { id: inputId, type, placeholder, value, onChange: (e) => onChange?.(e.target.value), disabled, required, "aria-invalid": hasError || void 0, "aria-describedby": hasMessage ? messageId : void 0, style: {
20705
20705
  ...sizeStyles2[size],
20706
20706
  width: "100%",
20707
- borderRadius: "var(--ggui-shape-radius-md, 8px)",
20707
+ borderRadius: "var(--ggui-shape-radius-control, var(--ggui-shape-radius-md, 8px))",
20708
20708
  border: `1px solid ${hasError ? "var(--ggui-color-error-500, #ef4444)" : "var(--ggui-color-outline, #d4d4d8)"}`,
20709
20709
  backgroundColor: disabled ? "var(--ggui-color-sunken, #fafafa)" : "var(--ggui-color-sunken, #ffffff)",
20710
20710
  color: "var(--ggui-color-onContainer, #18181b)",
@@ -20738,7 +20738,7 @@
20738
20738
  }, children: [label, required && (0, import_jsx_runtime10.jsx)("span", { "aria-hidden": "true", style: { color: "var(--ggui-color-error-500, #ef4444)", marginLeft: "2px" }, children: "*" })] }), (0, import_jsx_runtime10.jsx)("textarea", { id: textareaId, placeholder, value, onChange: (e) => onChange?.(e.target.value), rows, disabled, required, maxLength, "aria-invalid": hasError || void 0, "aria-describedby": hasMessage ? messageId : void 0, style: {
20739
20739
  padding: "10px 12px",
20740
20740
  width: "100%",
20741
- borderRadius: "var(--ggui-shape-radius-md, 8px)",
20741
+ borderRadius: "var(--ggui-shape-radius-control, var(--ggui-shape-radius-md, 8px))",
20742
20742
  border: `1px solid ${hasError ? "var(--ggui-color-error-500, #ef4444)" : "var(--ggui-color-outline, #d4d4d8)"}`,
20743
20743
  backgroundColor: disabled ? "var(--ggui-color-sunken, #fafafa)" : "var(--ggui-color-sunken, #ffffff)",
20744
20744
  color: "var(--ggui-color-onContainer, #18181b)",
@@ -20778,7 +20778,7 @@
20778
20778
  }, children: [label, required && (0, import_jsx_runtime11.jsx)("span", { "aria-hidden": "true", style: { color: "var(--ggui-color-error-500, #ef4444)", marginLeft: "2px" }, children: "*" })] }), (0, import_jsx_runtime11.jsxs)("select", { id: selectId, value, onChange: (e) => onChange?.(e.target.value), disabled, required, "aria-invalid": hasError || void 0, "aria-describedby": hasMessage ? messageId : void 0, style: {
20779
20779
  ...sizeStyles3[size],
20780
20780
  width: "100%",
20781
- borderRadius: "var(--ggui-shape-radius-md, 8px)",
20781
+ borderRadius: "var(--ggui-shape-radius-control, var(--ggui-shape-radius-md, 8px))",
20782
20782
  border: `1px solid ${hasError ? "var(--ggui-color-error-500, #ef4444)" : "var(--ggui-color-outline, #d4d4d8)"}`,
20783
20783
  backgroundColor: disabled ? "var(--ggui-color-sunken, #fafafa)" : "var(--ggui-color-sunken, #ffffff)",
20784
20784
  color: value ? "var(--ggui-color-onContainer, #18181b)" : "var(--ggui-color-onSunken, #52525b)",
@@ -28618,6 +28618,7 @@
28618
28618
  if (onDispatchSuppressed) {
28619
28619
  try {
28620
28620
  onDispatchSuppressed({
28621
+ reason: "duplicate-dispatch",
28621
28622
  actionName,
28622
28623
  payloadSignature: decision.signature,
28623
28624
  payload: data,
@@ -29372,11 +29373,26 @@
29372
29373
  return internalSeq;
29373
29374
  });
29374
29375
  const validateEnvelope = opts.validateEnvelope ?? validateOutboundActionEnvelope;
29376
+ const spentOneShots = /* @__PURE__ */ new Set();
29375
29377
  return {
29376
29378
  app: opts.app,
29377
29379
  render: opts.render,
29378
29380
  auth: opts.auth,
29381
+ ...opts.onDispatchSuppressed !== void 0 ? { onDispatchSuppressed: opts.onDispatchSuppressed } : {},
29379
29382
  dispatch: (actionName, data) => {
29383
+ const actionSpec = opts.getActiveActionSpec();
29384
+ const isOneShot = actionSpec?.[actionName]?.oneShot === true;
29385
+ if (isOneShot && spentOneShots.has(actionName)) {
29386
+ console.warn(`[ggui] dispatch('${actionName}') suppressed: this action is declared oneShot and already fired for this render. A second gesture is NOT sent to the agent \u2014 the submitted control is spent for the render's lifetime. If the action is meant to repeat, remove \`oneShot\` from its contract entry.`);
29387
+ opts.onDispatchSuppressed?.({
29388
+ reason: "one-shot-spent",
29389
+ actionName,
29390
+ payloadSignature: payloadSignature(actionName, data),
29391
+ payload: data,
29392
+ suppressedAt: Date.now()
29393
+ });
29394
+ return;
29395
+ }
29380
29396
  const envelope = buildActionEnvelope({
29381
29397
  sessionId: opts.render.sessionId,
29382
29398
  type: "data:submit",
@@ -29386,11 +29402,13 @@
29386
29402
  },
29387
29403
  clientSeq: nextClientSeq()
29388
29404
  });
29389
- const result = validateEnvelope(opts.getActiveActionSpec(), envelope);
29405
+ const result = validateEnvelope(actionSpec, envelope);
29390
29406
  if (!result.valid) {
29391
29407
  opts.onViolation(new ClientContractViolationError("outbound-action", result.violations));
29392
29408
  return;
29393
29409
  }
29410
+ if (isOneShot)
29411
+ spentOneShots.add(actionName);
29394
29412
  opts.emitEnvelope(envelope);
29395
29413
  },
29396
29414
  subscribe: (channelName, handler) => {
@@ -29543,6 +29561,7 @@
29543
29561
  "--ggui-font-weight-normal",
29544
29562
  "--ggui-font-weight-semibold",
29545
29563
  "--ggui-letter-spacing-heading",
29564
+ "--ggui-shape-radius-control",
29546
29565
  "--ggui-shape-radius-full",
29547
29566
  "--ggui-shape-radius-lg",
29548
29567
  "--ggui-shape-radius-md",
@@ -32820,15 +32839,19 @@
32820
32839
  }
32821
32840
  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
32841
  var FALLBACK_CLICK_WAIT_MS = 600;
32842
+ var MAX_NAVIGATOR_RECLICKS = 8;
32823
32843
  var ACTION_BUDGET_FLOOR_MS = 6e3;
32824
32844
  function norm(text) {
32825
32845
  return text.toLowerCase().replace(/[^a-z0-9]/g, "");
32826
32846
  }
32847
+ function isControlDisabled(el) {
32848
+ return el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true";
32849
+ }
32827
32850
  function collectActionCandidates(root, name, label) {
32828
32851
  const every = Array.from(root.querySelectorAll(CLICKABLE_SELECTOR)).filter(
32829
32852
  (el) => el instanceof HTMLElement
32830
32853
  );
32831
- const isDisabled = (el) => el.hasAttribute("disabled") || el.getAttribute("aria-disabled") === "true";
32854
+ const isDisabled = isControlDisabled;
32832
32855
  const lcName = name.toLowerCase();
32833
32856
  const lcLabel = label.toLowerCase();
32834
32857
  const nameKey = norm(name);
@@ -32860,10 +32883,46 @@
32860
32883
  }
32861
32884
  return { named: [...byValue, ...byText], fallback: rest, disabled, namedDisabled, allClickables: every };
32862
32885
  }
32863
- var TEXT_LIKE_INPUT_TYPES = /* @__PURE__ */ new Set(["text", "email", "search", "url", "tel", "number", "password", ""]);
32886
+ var TEXT_LIKE_INPUT_TYPES = /* @__PURE__ */ new Set([
32887
+ "text",
32888
+ "email",
32889
+ "search",
32890
+ "url",
32891
+ "tel",
32892
+ "number",
32893
+ "password",
32894
+ "",
32895
+ "date",
32896
+ "time",
32897
+ "month",
32898
+ "week",
32899
+ "datetime-local"
32900
+ ]);
32901
+ var pad2 = (n) => String(n).padStart(2, "0");
32902
+ var partsOf = (at) => ({ y: at.getFullYear(), m: at.getMonth() + 1, d: at.getDate() });
32903
+ function todayAndThirtyDaysOut() {
32904
+ const now = /* @__PURE__ */ new Date();
32905
+ const at = new Date(now);
32906
+ at.setDate(at.getDate() + 30);
32907
+ return { today: partsOf(now), later: partsOf(at) };
32908
+ }
32909
+ function clampToRange(el, value, floor, format) {
32910
+ let out = value;
32911
+ if (format.test(el.min) && el.min > out) out = el.min;
32912
+ if (format.test(el.max) && el.max < out && el.max >= floor) out = el.max;
32913
+ return out;
32914
+ }
32915
+ function isoWeekOf(y, m, d) {
32916
+ const date = new Date(Date.UTC(y, m - 1, d));
32917
+ const day = date.getUTCDay() || 7;
32918
+ date.setUTCDate(date.getUTCDate() + 4 - day);
32919
+ const jan1 = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
32920
+ const week = Math.ceil(((date.getTime() - jan1.getTime()) / 864e5 + 1) / 7);
32921
+ return `${date.getUTCFullYear()}-W${pad2(week)}`;
32922
+ }
32864
32923
  function sampleValueFor(el) {
32865
- const type = el instanceof HTMLInputElement ? el.type : "textarea";
32866
- switch (type) {
32924
+ if (!(el instanceof HTMLInputElement)) return "probe";
32925
+ switch (el.type) {
32867
32926
  case "number":
32868
32927
  return "1";
32869
32928
  case "email":
@@ -32872,6 +32931,27 @@
32872
32931
  return "https://example.com";
32873
32932
  case "tel":
32874
32933
  return "5550100";
32934
+ case "date": {
32935
+ const { today, later } = todayAndThirtyDaysOut();
32936
+ const iso = (p) => `${p.y}-${pad2(p.m)}-${pad2(p.d)}`;
32937
+ return clampToRange(el, iso(later), iso(today), /^\d{4}-\d{2}-\d{2}$/);
32938
+ }
32939
+ case "time":
32940
+ return "09:30";
32941
+ case "month": {
32942
+ const { today, later } = todayAndThirtyDaysOut();
32943
+ const ym = (p) => `${p.y}-${pad2(p.m)}`;
32944
+ return clampToRange(el, ym(later), ym(today), /^\d{4}-\d{2}$/);
32945
+ }
32946
+ case "week": {
32947
+ const { today, later } = todayAndThirtyDaysOut();
32948
+ return clampToRange(el, isoWeekOf(later.y, later.m, later.d), isoWeekOf(today.y, today.m, today.d), /^\d{4}-W\d{2}$/);
32949
+ }
32950
+ case "datetime-local": {
32951
+ const { today, later } = todayAndThirtyDaysOut();
32952
+ const at = (p, hm) => `${p.y}-${pad2(p.m)}-${pad2(p.d)}T${hm}`;
32953
+ return clampToRange(el, at(later, "09:30"), at(today, "00:00"), /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
32954
+ }
32875
32955
  default:
32876
32956
  return "probe";
32877
32957
  }
@@ -32892,7 +32972,7 @@
32892
32972
  if (el instanceof HTMLInputElement && !TEXT_LIKE_INPUT_TYPES.has(el.type)) continue;
32893
32973
  if (el.value.trim().length > 0) continue;
32894
32974
  setNativeValue(el, sampleValueFor(el));
32895
- primed += 1;
32975
+ if (el.value.trim().length > 0) primed += 1;
32896
32976
  }
32897
32977
  for (const el of Array.from(root.querySelectorAll("select"))) {
32898
32978
  if (!(el instanceof HTMLSelectElement) || el.disabled) continue;
@@ -33084,7 +33164,7 @@
33084
33164
  }
33085
33165
  return {
33086
33166
  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` : "")
33167
+ 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
33168
  };
33089
33169
  }
33090
33170
  const deadline = Date.now() + Math.max(input.waitMs, ACTION_BUDGET_FLOOR_MS);
@@ -33109,23 +33189,47 @@
33109
33189
  let namedDisabledSeen = first.namedDisabled;
33110
33190
  let navigated = false;
33111
33191
  let knownSignatures = new Set(first.allClickables.map((el) => signatureOf(container, el)));
33192
+ const navigators = /* @__PURE__ */ new Map();
33193
+ let lastClickedSignature;
33194
+ const namedKey = (el) => `${signatureOf(container, el)}|${(el.textContent ?? "").trim()}`;
33195
+ const triedNamed = /* @__PURE__ */ new Set();
33196
+ const stateOf = (el) => `${signatureOf(container, el)}|${isControlDisabled(el) ? "off" : "on"}`;
33197
+ let knownStates = new Set(first.allClickables.map(stateOf));
33112
33198
  for (; ; ) {
33113
33199
  if (remaining() === 0) {
33114
33200
  exhausted = true;
33115
33201
  break;
33116
33202
  }
33117
- const live = collectActionCandidates(container, input.actionName, entry.label);
33118
- namedDisabledSeen = Math.max(namedDisabledSeen, live.namedDisabled);
33203
+ let live = collectActionCandidates(container, input.actionName, entry.label);
33119
33204
  const liveSignatures = live.allClickables.map((el) => signatureOf(container, el));
33205
+ const liveStates = live.allClickables.map(stateOf);
33120
33206
  if (clicked > 0 && liveSignatures.some((sig) => !knownSignatures.has(sig))) navigated = true;
33207
+ const revealed = clicked > 0 && liveStates.some((state) => !knownStates.has(state));
33121
33208
  knownSignatures = /* @__PURE__ */ new Set([...knownSignatures, ...liveSignatures]);
33209
+ knownStates = /* @__PURE__ */ new Set([...knownStates, ...liveStates]);
33210
+ if (revealed) {
33211
+ if (lastClickedSignature !== void 0 && !navigators.has(lastClickedSignature)) navigators.set(lastClickedSignature, 0);
33212
+ if (primeInputs(container) > 0) {
33213
+ await delay(150);
33214
+ live = collectActionCandidates(container, input.actionName, entry.label);
33215
+ }
33216
+ }
33217
+ namedDisabledSeen = Math.max(namedDisabledSeen, live.namedDisabled);
33122
33218
  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);
33219
+ const nextNamed = live.named.find((el) => el.isConnected && !triedNamed.has(namedKey(el)));
33220
+ const navigator2 = nextNamed === void 0 ? live.fallback.find((el) => {
33221
+ const sig = signatureOf(container, el);
33222
+ return el.isConnected && (navigators.get(sig) ?? MAX_NAVIGATOR_RECLICKS) < MAX_NAVIGATOR_RECLICKS;
33223
+ }) : void 0;
33224
+ const next = nextNamed ?? untried(live.fallback) ?? navigator2;
33125
33225
  if (next === void 0) break;
33126
33226
  const via = nextNamed !== void 0 ? "named" : "fallback";
33127
33227
  if (via === "named") namedAttempted = true;
33128
- tried.add(signatureOf(container, next));
33228
+ const nextSignature = signatureOf(container, next);
33229
+ if (navigator2 !== void 0 && next === navigator2) navigators.set(nextSignature, (navigators.get(nextSignature) ?? 0) + 1);
33230
+ if (via === "named") triedNamed.add(namedKey(next));
33231
+ tried.add(nextSignature);
33232
+ lastClickedSignature = nextSignature;
33129
33233
  const hit = await attempt(
33130
33234
  next,
33131
33235
  via === "named" ? Math.min(input.waitMs, remaining()) : Math.min(FALLBACK_CLICK_WAIT_MS, remaining()),
@@ -33137,17 +33241,18 @@
33137
33241
  if (req.requireDispatch && !bestDispatch) missing.push("dispatch");
33138
33242
  if (req.requireDom && !bestDom) missing.push("DOM change");
33139
33243
  const others = otherActions.size > 0 ? `; other actions dispatched: ${[...otherActions].join(", ")}` : "";
33244
+ const navigatorReclicks = [...navigators.values()].reduce((sum, n) => sum + n, 0);
33140
33245
  if (!namedAttempted && namedDisabledSeen > 0) {
33141
33246
  const reason = navigated ? "behind-navigation" : "disabled-after-priming";
33142
33247
  return {
33143
33248
  status: "action-unreachable",
33144
33249
  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}`
33250
+ 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
33251
  };
33147
33252
  }
33148
33253
  return {
33149
33254
  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}`
33255
+ 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
33256
  };
33152
33257
  } finally {
33153
33258
  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.19.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.19.0",
52
+ "@ggui-ai/wire": "0.19.0",
53
+ "@ggui-ai/protocol": "0.19.0"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@playwright/test": "^1.60.0",