@ggui-ai/ui-visual-tester 0.16.0 → 0.17.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.
@@ -29,6 +29,8 @@ type ActionClassification = 'agent-bound' | 'context-bound';
29
29
  interface RunInput {
30
30
  readonly componentCode: string;
31
31
  readonly contract: unknown;
32
+ /** Parsed JSON of the commit's fixture props; a plain object or nothing. */
33
+ readonly sampleProps: unknown;
32
34
  readonly actionName: string;
33
35
  readonly classification: ActionClassification;
34
36
  readonly settleMs: number;
@@ -39,13 +41,23 @@ type RunOutcome = {
39
41
  readonly diagnostic: string;
40
42
  } | {
41
43
  readonly status: 'action-not-rendered';
44
+ readonly diagnostic: string;
42
45
  } | {
43
46
  readonly status: 'action-no-effect';
44
47
  readonly diagnostic: string;
48
+ }
49
+ /** #1040: the named control was never reachable enabled — not measured; `reason` says why. */
50
+ | {
51
+ readonly status: 'action-unreachable';
52
+ readonly reason: 'disabled-after-priming' | 'behind-navigation';
53
+ readonly diagnostic: string;
45
54
  } | {
46
55
  readonly status: 'ok';
47
56
  readonly dispatchFired: boolean;
48
57
  readonly domChanged: boolean;
58
+ /** Which pass found the control that produced the signal, and how many were clicked to get there. */
59
+ readonly via?: 'named' | 'fallback';
60
+ readonly clicked?: number;
49
61
  };
50
62
  declare global {
51
63
  interface Window {
@@ -1 +1 @@
1
- {"version":3,"file":"fixture-runtime.d.ts","sourceRoot":"","sources":["../src/fixture-runtime.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAC/B,OAAO,KAAK,cAAc,MAAM,kBAAkB,CAAC;AACnD,OAAO,KAAK,gBAAgB,MAAM,4BAA4B,CAAC;AAC/D,OAAO,KAAK,gBAAgB,MAAM,4BAA4B,CAAC;AAC/D,OAAO,KAAK,kBAAkB,MAAM,8BAA8B,CAAC;AACnE,OAAO,KAAK,cAAc,MAAM,0BAA0B,CAAC;AAC3D,OAAO,KAAK,IAAI,MAAM,eAAe,CAAC;AA0CtC,UAAU,cAAc;IACtB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,KAAK,oBAAoB,GAAG,aAAa,GAAG,eAAe,CAAC;AAE5D,UAAU,QAAQ;IAChB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,cAAc,EAAE,oBAAoB,CAAC;IAC9C,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,KAAK,UAAU,GACX;IAAE,QAAQ,CAAC,MAAM,EAAE,eAAe,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GACjE;IAAE,QAAQ,CAAC,MAAM,EAAE,qBAAqB,CAAA;CAAE,GAC1C;IAAE,QAAQ,CAAC,MAAM,EAAE,kBAAkB,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GACpE;IACE,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAC;IACtB,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;CAC9B,CAAC;AAEN,OAAO,CAAC,MAAM,CAAC;IACb,UAAU,MAAM;QACd,wBAAwB,CAAC,EAAE,cAAc,EAAE,CAAC;QAC5C,gCAAgC,CAAC,EAAE,CAAC,KAAK,EAAE,QAAQ,KAAK,OAAO,CAAC,UAAU,CAAC,CAAC;QAC5E,QAAQ,CAAC,EAAE,YAAY,CAAC;QACxB,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;QACvB,iBAAiB,CAAC,EAAE,OAAO,gBAAgB,CAAC;QAC5C,iBAAiB,CAAC,EAAE,OAAO,gBAAgB,CAAC;QAC5C,mBAAmB,CAAC,EAAE,OAAO,kBAAkB,CAAC;QAChD,eAAe,CAAC,EAAE,OAAO,cAAc,CAAC;QACxC,qBAAqB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KACjD;CACF;AAED,UAAU,YAAY;IACpB,KAAK,EAAE,OAAO,KAAK,CAAC;IACpB,QAAQ,EAAE,OAAO,cAAc,CAAC;IAChC,UAAU,EAAE,OAAO,gBAAgB,CAAC;IACpC,UAAU,EAAE,OAAO,gBAAgB,CAAC;IACpC,YAAY,EAAE,OAAO,kBAAkB,CAAC;IACxC,QAAQ,EAAE,OAAO,cAAc,CAAC;IAChC;;;;;;;;OAQG;IACH,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACvC,IAAI,EAAE,OAAO,IAAI,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC"}
1
+ {"version":3,"file":"fixture-runtime.d.ts","sourceRoot":"","sources":["../src/fixture-runtime.ts"],"names":[],"mappings":"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"}
@@ -70,27 +70,221 @@ function installRegistry() {
70
70
  window.__GGUI_APP_COMPONENTS = registry.appComponents;
71
71
  return registry;
72
72
  }
73
- function findActionButton(root, label) {
74
- const ariaMatch = root.querySelector(`[aria-label="${CSS.escape(label)}"]`);
75
- if (ariaMatch instanceof HTMLElement)
76
- return ariaMatch;
77
- const lc = label.toLowerCase();
78
- const candidates = root.querySelectorAll('button, [role="button"], a, [data-action]');
79
- for (const el of Array.from(candidates)) {
80
- if (!(el instanceof HTMLElement))
81
- continue;
73
+ /**
74
+ * What a generated component may wire an action to. Kept to real controls
75
+ * (a `<div onClick>` that names nothing is not a contract affordance).
76
+ */
77
+ const CLICKABLE_SELECTOR = 'button, [role="button"], input[type="submit"], input[type="button"], ' +
78
+ 'input[type="checkbox"], input[type="radio"], [role="switch"], [role="checkbox"], ' +
79
+ '[role="menuitem"], [role="tab"], [role="option"], [data-action]';
80
+ // Deliberately NOT candidates: <a href> (a click navigates the fixture page
81
+ // away and aborts every remaining check) and <summary> (its click toggles the
82
+ // parent <details> — a DOM change that says nothing about the action).
83
+ /** Per-control wait in the fallback pass — enough for a synchronous dispatch + a render. */
84
+ const FALLBACK_CLICK_WAIT_MS = 600;
85
+ /**
86
+ * ONE deadline covers both passes for an action: max(the caller's wait, this
87
+ * floor). A few loosely-matching decoys can no longer run an action for
88
+ * minutes; the floor keeps very short test waits from starving the fallback.
89
+ */
90
+ const ACTION_BUDGET_FLOOR_MS = 6000;
91
+ function norm(text) {
92
+ return text.toLowerCase().replace(/[^a-z0-9]/g, '');
93
+ }
94
+ /**
95
+ * The controls to try, the way the harness's render-check finds them (#996):
96
+ * pass 1 = controls that NAME the action — `data-action` by value (the one
97
+ * hook that names the action rather than its label), then aria-label / text
98
+ * carrying the label or the name; pass 2 = every other enabled clickable, in
99
+ * DOM order. A generated component that wires `useAction('toggleItem')` to an
100
+ * unnamed checkbox is found by pass 2 and judged by what its click dispatches.
101
+ */
102
+ function collectActionCandidates(root, name, label) {
103
+ 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';
105
+ const lcName = name.toLowerCase();
106
+ const lcLabel = label.toLowerCase();
107
+ const nameKey = norm(name);
108
+ const labelKey = norm(label);
109
+ // How the harness names a control: `data-action` by value first (#996), then the aria label or the text.
110
+ const namesAction = (el) => {
111
+ const dataAction = (el.getAttribute('data-action') ?? '').toLowerCase();
112
+ if (dataAction === lcName)
113
+ return 'value';
114
+ const aria = (el.getAttribute('aria-label') ?? '').toLowerCase();
82
115
  const txt = (el.textContent ?? '').trim().toLowerCase();
83
- if (txt.length > 0 && txt.includes(lc))
84
- return el;
116
+ const ariaNames = aria.length > 0 && (aria.includes(lcLabel) || (nameKey.length > 0 && norm(aria).includes(nameKey)));
117
+ const textNames = txt.length > 0 &&
118
+ (txt.includes(lcLabel) ||
119
+ (nameKey.length > 0 && norm(txt).includes(nameKey)) ||
120
+ (labelKey.length > 0 && norm(txt).includes(labelKey)));
121
+ return ariaNames || textNames ? 'text' : null;
122
+ };
123
+ const disabledEls = every.filter(isDisabled);
124
+ const disabled = disabledEls.length;
125
+ const namedDisabled = disabledEls.filter((el) => namesAction(el) !== null).length;
126
+ const all = every.filter((el) => !isDisabled(el) &&
127
+ // an already-checked radio fires no change on click — nothing to observe
128
+ !(el instanceof HTMLInputElement && el.type === 'radio' && el.checked));
129
+ const byValue = [];
130
+ const byText = [];
131
+ const rest = [];
132
+ for (const el of all) {
133
+ const how = namesAction(el);
134
+ if (how === 'value')
135
+ byValue.push(el);
136
+ else if (how === 'text')
137
+ byText.push(el);
138
+ else
139
+ rest.push(el);
140
+ }
141
+ return { named: [...byValue, ...byText], fallback: rest, disabled, namedDisabled, allClickables: every };
142
+ }
143
+ const TEXT_LIKE_INPUT_TYPES = new Set(['text', 'email', 'search', 'url', 'tel', 'number', 'password', '']);
144
+ function sampleValueFor(el) {
145
+ const type = el instanceof HTMLInputElement ? el.type : 'textarea';
146
+ switch (type) {
147
+ case 'number':
148
+ return '1';
149
+ case 'email':
150
+ return 'probe@example.com';
151
+ case 'url':
152
+ return 'https://example.com';
153
+ case 'tel':
154
+ return '5550100';
155
+ default:
156
+ return 'probe';
85
157
  }
86
- for (const el of Array.from(root.querySelectorAll('*'))) {
87
- if (!(el instanceof HTMLElement))
158
+ }
159
+ /** Set a value the way React notices it: through the prototype's native setter, then an input + change event. */
160
+ function setNativeValue(el, value) {
161
+ const proto = el instanceof HTMLTextAreaElement
162
+ ? HTMLTextAreaElement.prototype
163
+ : el instanceof HTMLSelectElement
164
+ ? HTMLSelectElement.prototype
165
+ : HTMLInputElement.prototype;
166
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
167
+ if (setter)
168
+ setter.call(el, value);
169
+ else
170
+ el.value = value;
171
+ el.dispatchEvent(new Event('input', { bubbles: true }));
172
+ el.dispatchEvent(new Event('change', { bubbles: true }));
173
+ }
174
+ /**
175
+ * The generic minimum a user does before pressing Send / Submit / Complete
176
+ * (#1021): give every empty text-like input and textarea a value, every
177
+ * unset select its first real option, every unchecked radio group its first
178
+ * radio. Checkboxes are never touched — they are often the action itself.
179
+ * Returns how many controls were primed.
180
+ */
181
+ function primeInputs(root) {
182
+ let primed = 0;
183
+ const editable = (el) => !el.disabled && !(el instanceof HTMLSelectElement) ? !el.readOnly : !el.disabled;
184
+ for (const el of Array.from(root.querySelectorAll('input, textarea'))) {
185
+ if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) || !editable(el))
88
186
  continue;
89
- const txt = (el.textContent ?? '').trim().toLowerCase();
90
- if (txt === lc)
91
- return el;
187
+ if (el instanceof HTMLInputElement && !TEXT_LIKE_INPUT_TYPES.has(el.type))
188
+ continue;
189
+ if (el.value.trim().length > 0)
190
+ continue;
191
+ setNativeValue(el, sampleValueFor(el));
192
+ primed += 1;
193
+ }
194
+ for (const el of Array.from(root.querySelectorAll('select'))) {
195
+ if (!(el instanceof HTMLSelectElement) || el.disabled)
196
+ continue;
197
+ const current = el.options[el.selectedIndex];
198
+ if (current !== undefined && current.value.trim().length > 0)
199
+ continue;
200
+ const first = Array.from(el.options).find((o) => o.value.trim().length > 0 && !o.disabled);
201
+ if (first === undefined)
202
+ continue;
203
+ setNativeValue(el, first.value);
204
+ primed += 1;
205
+ }
206
+ const seenGroups = new Set();
207
+ for (const el of Array.from(root.querySelectorAll('input[type="radio"]'))) {
208
+ if (!(el instanceof HTMLInputElement) || el.disabled)
209
+ continue;
210
+ const group = el.name || '';
211
+ if (seenGroups.has(group))
212
+ continue;
213
+ seenGroups.add(group);
214
+ const members = group
215
+ ? Array.from(root.querySelectorAll(`input[type="radio"][name="${CSS.escape(group)}"]`)).filter((m) => m instanceof HTMLInputElement)
216
+ : [el];
217
+ if (members.some((m) => m.checked))
218
+ continue;
219
+ const first = members.find((m) => !m.disabled);
220
+ if (first === undefined)
221
+ continue;
222
+ first.click();
223
+ primed += 1;
92
224
  }
93
- return null;
225
+ return primed;
226
+ }
227
+ function countNamedDispatches(actionName) {
228
+ return (window.__ggui_test_dispatches__ ?? []).filter((d) => d.actionName === actionName).length;
229
+ }
230
+ function snapshot(container, actionName) {
231
+ return {
232
+ text: container.textContent ?? '',
233
+ html: container.innerHTML,
234
+ named: countNamedDispatches(actionName),
235
+ total: (window.__ggui_test_dispatches__ ?? []).length,
236
+ };
237
+ }
238
+ /** Poll for the required signal after a click. `dispatchFired` counts only dispatches of THIS action. */
239
+ async function observe(container, actionName, before, req, waitMs) {
240
+ const start = Date.now();
241
+ let dispatchFired = false;
242
+ let domChanged = false;
243
+ while (Date.now() - start < waitMs) {
244
+ await delay(50);
245
+ if (!dispatchFired && countNamedDispatches(actionName) > before.named) {
246
+ dispatchFired = true;
247
+ }
248
+ if (!domChanged) {
249
+ const afterText = container.textContent ?? '';
250
+ const afterHtml = container.innerHTML;
251
+ if (afterText !== before.text || afterHtml !== before.html) {
252
+ domChanged = true;
253
+ }
254
+ }
255
+ if (dispatchFired && domChanged)
256
+ break;
257
+ // Early-exit once the REQUIRED signal has fired (don't wait for the other one).
258
+ if (req.requireDispatch && dispatchFired)
259
+ break;
260
+ if (req.requireDom && domChanged)
261
+ break;
262
+ }
263
+ const otherActions = (window.__ggui_test_dispatches__ ?? [])
264
+ .slice(before.total)
265
+ .map((d) => d.actionName)
266
+ .filter((n) => n !== actionName);
267
+ return { dispatchFired, domChanged, otherActions: [...new Set(otherActions)] };
268
+ }
269
+ function satisfied(req, obs) {
270
+ return (req.requireDispatch ? obs.dispatchFired : true) && (req.requireDom ? obs.domChanged : true);
271
+ }
272
+ /**
273
+ * A control's identity across re-renders: its path from the mount root by tag
274
+ * and sibling index (plus an input's type). A re-rendered equivalent at the
275
+ * same position counts as already tried; a detached node never gets clicked.
276
+ */
277
+ function signatureOf(root, el) {
278
+ const parts = [];
279
+ let cur = el;
280
+ while (cur !== null && cur !== root) {
281
+ const parent = cur.parentElement;
282
+ const idx = parent === null ? 0 : Array.prototype.indexOf.call(parent.children, cur);
283
+ const type = cur instanceof HTMLInputElement ? `[${cur.type}]` : '';
284
+ parts.push(`${cur.tagName.toLowerCase()}${type}:${idx}`);
285
+ cur = parent;
286
+ }
287
+ return parts.reverse().join('/');
94
288
  }
95
289
  async function evaluateComponent(code) {
96
290
  const cleaned = stripMarkers(code);
@@ -104,7 +298,12 @@ async function evaluateComponent(code) {
104
298
  }
105
299
  return Comp;
106
300
  }
107
- function mountTree(Comp) {
301
+ function propsOf(value) {
302
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
303
+ ? value
304
+ : {};
305
+ }
306
+ function mountTree(Comp, props) {
108
307
  const container = document.createElement('div');
109
308
  container.id = 'ggui-vt-mount';
110
309
  document.body.replaceChildren(container);
@@ -127,7 +326,9 @@ function mountTree(Comp) {
127
326
  subscribe: () => () => { },
128
327
  };
129
328
  const root = ReactDomClient.createRoot(container);
130
- const componentElement = React.createElement(Comp, {});
329
+ // The commit's fixture props: a component that draws its controls from
330
+ // props (a board's tasks, a list's items) mounts EMPTY without them.
331
+ const componentElement = React.createElement(Comp, props);
131
332
  const tree = React.createElement(Wire.GguiWireProvider, {
132
333
  config: wireConfig,
133
334
  children: componentElement,
@@ -182,7 +383,7 @@ async function run(input) {
182
383
  let handles = null;
183
384
  try {
184
385
  try {
185
- handles = mountTree(Comp);
386
+ handles = mountTree(Comp, propsOf(input.sampleProps));
186
387
  }
187
388
  catch (err) {
188
389
  const msg = err instanceof Error ? err.message : String(err);
@@ -199,55 +400,116 @@ async function run(input) {
199
400
  diagnostic: `actionSpec.${input.actionName} missing`,
200
401
  };
201
402
  }
202
- const button = findActionButton(handles.container, entry.label);
203
- if (button === null) {
204
- return { status: 'action-not-rendered' };
205
- }
206
- const beforeText = handles.container.textContent ?? '';
207
- const beforeHtml = handles.container.innerHTML;
208
- const dispatchesBefore = (window.__ggui_test_dispatches__ ?? []).length;
209
- button.click();
210
- const start = Date.now();
211
- let dispatchFired = false;
212
- let domChanged = false;
213
- while (Date.now() - start < input.waitMs) {
214
- await delay(50);
215
- if (!dispatchFired &&
216
- (window.__ggui_test_dispatches__ ?? []).length > dispatchesBefore) {
217
- dispatchFired = true;
218
- }
219
- if (!domChanged) {
220
- const afterText = handles.container.textContent ?? '';
221
- const afterHtml = handles.container.innerHTML;
222
- if (afterText !== beforeText || afterHtml !== beforeHtml) {
223
- domChanged = true;
224
- }
403
+ const req = signalRequirement(input.classification);
404
+ const container = handles.container;
405
+ // Prime inputs first (#1021): Send/Submit/Complete are commonly disabled
406
+ // until something is typed or chosen — the minimum a user does before them.
407
+ const primed = primeInputs(container);
408
+ if (primed > 0)
409
+ await delay(150);
410
+ const first = collectActionCandidates(container, input.actionName, entry.label);
411
+ if (first.named.length === 0 && first.fallback.length === 0) {
412
+ if (first.namedDisabled > 0) {
413
+ // #1040: the action's own control is there, disabled, and nothing else is clickable —
414
+ // the action is gated on input the probe did not provide. Not missing, not a miss: not measured.
415
+ return {
416
+ status: 'action-unreachable',
417
+ reason: 'disabled-after-priming',
418
+ diagnostic: `${first.disabled} control(s) rendered, all disabled (${first.namedDisabled} naming the action) — the action is gated on input the probe did not provide ` +
419
+ `(primed ${primed} input(s)); actionSpec.${input.actionName} (label "${entry.label}")`,
420
+ };
225
421
  }
226
- if (dispatchFired && domChanged)
227
- break;
228
- // Early-exit if the REQUIRED signal has fired (don't wait for the
229
- // other one — speeds up the slow stage when the wiring is correct).
230
- const req = signalRequirement(input.classification);
231
- if (req.requireDispatch && dispatchFired)
422
+ return {
423
+ status: 'action-not-rendered',
424
+ 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` : ''),
426
+ };
427
+ }
428
+ // One deadline for the whole action. Named controls get the caller's wait
429
+ // (or what is left of the budget); fallback controls get a short slice each.
430
+ const deadline = Date.now() + Math.max(input.waitMs, ACTION_BUDGET_FLOOR_MS);
431
+ const remaining = () => Math.max(0, deadline - Date.now());
432
+ let clicked = 0;
433
+ let bestDispatch = false;
434
+ let bestDom = false;
435
+ const otherActions = new Set();
436
+ const attempt = async (el, waitMs, via) => {
437
+ const before = snapshot(container, input.actionName);
438
+ clicked += 1;
439
+ el.click();
440
+ const obs = await observe(container, input.actionName, before, req, waitMs);
441
+ bestDispatch = bestDispatch || obs.dispatchFired;
442
+ bestDom = bestDom || obs.domChanged;
443
+ for (const a of obs.otherActions)
444
+ otherActions.add(a);
445
+ return satisfied(req, obs)
446
+ ? { status: 'ok', dispatchFired: obs.dispatchFired, domChanged: obs.domChanged, via, clicked }
447
+ : null;
448
+ };
449
+ // Re-collect LIVE candidates before EVERY click: an earlier click may have
450
+ // re-rendered the tree (a filter, a tab, an opened editor), detaching what
451
+ // was collected and adding what was not there. A control is tried once,
452
+ // by its DOM path; a detached node is never the one clicked.
453
+ const tried = new Set();
454
+ let exhausted = false;
455
+ // #1040: was the NAMED control ever clicked enabled (a real attempt), was it ever seen disabled (a
456
+ // gate), and did a click make NEW controls appear (a step, a tab, a revealed section — navigation,
457
+ // as distinct from a re-render of what was already there)?
458
+ let namedAttempted = false;
459
+ let namedDisabledSeen = first.namedDisabled;
460
+ let navigated = false;
461
+ let knownSignatures = new Set(first.allClickables.map((el) => signatureOf(container, el)));
462
+ for (;;) {
463
+ if (remaining() === 0) {
464
+ exhausted = true;
232
465
  break;
233
- if (req.requireDom && domChanged)
466
+ }
467
+ const live = collectActionCandidates(container, input.actionName, entry.label);
468
+ namedDisabledSeen = Math.max(namedDisabledSeen, live.namedDisabled);
469
+ // Disabled controls count: a step that reveals a disabled Complete IS navigation into the gate.
470
+ const liveSignatures = live.allClickables.map((el) => signatureOf(container, el));
471
+ if (clicked > 0 && liveSignatures.some((sig) => !knownSignatures.has(sig)))
472
+ navigated = true;
473
+ knownSignatures = new Set([...knownSignatures, ...liveSignatures]);
474
+ 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);
477
+ if (next === undefined)
234
478
  break;
479
+ const via = nextNamed !== undefined ? 'named' : 'fallback';
480
+ if (via === 'named')
481
+ namedAttempted = true;
482
+ tried.add(signatureOf(container, next));
483
+ const hit = await attempt(next, via === 'named' ? Math.min(input.waitMs, remaining()) : Math.min(FALLBACK_CLICK_WAIT_MS, remaining()), via);
484
+ if (hit !== null)
485
+ return hit;
235
486
  }
236
- const req = signalRequirement(input.classification);
237
- const required = (req.requireDispatch ? dispatchFired : true) &&
238
- (req.requireDom ? domChanged : true);
239
- if (!required) {
240
- const missing = [];
241
- if (req.requireDispatch && !dispatchFired)
242
- missing.push('dispatch');
243
- if (req.requireDom && !domChanged)
244
- missing.push('DOM change');
487
+ const missing = [];
488
+ if (req.requireDispatch && !bestDispatch)
489
+ missing.push('dispatch');
490
+ if (req.requireDom && !bestDom)
491
+ missing.push('DOM change');
492
+ const others = otherActions.size > 0 ? `; other actions dispatched: ${[...otherActions].join(', ')}` : '';
493
+ // #1040: the named control was seen only disabled and never clicked enabled — the probe could not
494
+ // reach the action. Not measured, with the reason: a click made new controls appear (the gate sits
495
+ // behind navigation the probe walked into but could not finish) or none did (the gate is input the
496
+ // probe did not provide). An enabled named control that was clicked and did nothing stays the miss.
497
+ if (!namedAttempted && namedDisabledSeen > 0) {
498
+ const reason = navigated ? 'behind-navigation' : 'disabled-after-priming';
245
499
  return {
246
- status: 'action-no-effect',
247
- diagnostic: `${req.description}; observed dispatch=${dispatchFired}, domChanged=${domChanged}; missing=${missing.join('+')}`,
500
+ status: 'action-unreachable',
501
+ reason,
502
+ 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}` +
504
+ `${exhausted ? '; budget exhausted' : ''}${others}`,
248
505
  };
249
506
  }
250
- return { status: 'ok', dispatchFired, domChanged };
507
+ return {
508
+ status: 'action-no-effect',
509
+ 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}; ` +
511
+ `missing=${missing.join('+')}${others}`,
512
+ };
251
513
  }
252
514
  finally {
253
515
  window.removeEventListener('error', onWindowError);
package/dist/index.d.ts CHANGED
@@ -52,20 +52,27 @@
52
52
  *
53
53
  * For each entry in `contract.actionSpec`:
54
54
  *
55
- * 1. Render the component in a real Chromium tab via Playwright.
56
- * 2. Find a button matching the action's `label` (aria-label first,
57
- * then visible-text contains, case-insensitive).
58
- * 3. Snapshot the current DOM state.
59
- * 4. Click the button.
60
- * 5. Wait up to `timeoutMs` for the required signal (per the gate
61
- * above) to fire.
55
+ * 1. Render the component in a real Chromium tab via Playwright, with
56
+ * `sampleProps` (the commit's fixture props) — a component that draws its
57
+ * controls from props has nothing to click without them.
58
+ * 2. Collect the controls to try, the way the harness's render-check
59
+ * does (#996): first those that NAME the action (`data-action` by
60
+ * value, then aria-label / text carrying the label or the name), then
61
+ * every other enabled clickable in DOM order.
62
+ * 3. For each candidate: snapshot the DOM, click, wait up to `timeoutMs`
63
+ * (named) or a bounded slice of it (fallback) for the required signal
64
+ * (per the gate above); stop at the first control that produces it.
65
+ * Only dispatches of THIS action count; others are reported.
66
+ * 4. `action-not-rendered` means no clickable control exists at all.
67
+ * 5. `action-no-effect` means controls were clicked and none produced
68
+ * the required signal — the diagnostic says how many, and what was seen.
62
69
  * 6. Classify: ok / action-no-effect / action-not-rendered /
63
70
  * render-failed.
64
71
  *
65
72
  * Empty `actionSpec` returns `{ ok: true, failures: [] }` (not
66
73
  * applicable — nothing to test).
67
74
  */
68
- import type { DataContract } from '@ggui-ai/protocol';
75
+ import type { DataContract, JsonObject } from '@ggui-ai/protocol';
69
76
  import type { Browser as PlaywrightBrowser, BrowserContext as PlaywrightBrowserContext, Page as PlaywrightPage, LaunchOptions as PlaywrightLaunchOptions } from 'playwright-core';
70
77
  /**
71
78
  * Minimal Playwright `chromium` surface the validator depends on.
@@ -85,6 +92,13 @@ export type { PlaywrightBrowser, PlaywrightBrowserContext, PlaywrightPage, };
85
92
  export interface ValidateContractBehaviorInput {
86
93
  readonly componentCode: string;
87
94
  readonly contract: DataContract;
95
+ /**
96
+ * The props the component was generated for (the commit's fixture props —
97
+ * the same object the visual judge receives). Without them a component that
98
+ * renders its controls from props (a board's tasks, a list's items) mounts
99
+ * EMPTY and has nothing to click, which reads as action-not-rendered.
100
+ */
101
+ readonly sampleProps?: JsonObject;
88
102
  readonly timeoutMs?: number;
89
103
  /**
90
104
  * Playwright module. Pass `{ chromium }` from `playwright-core` or
@@ -93,11 +107,37 @@ export interface ValidateContractBehaviorInput {
93
107
  */
94
108
  readonly playwright: PlaywrightModule;
95
109
  }
96
- export type BehaviorFailureKind = 'action-no-effect' | 'action-not-rendered' | 'render-failed' | 'timeout';
110
+ /**
111
+ * Extensibly-closed (docs/protocol/VERSION-POLICY.md §1.2): members are added
112
+ * in MINOR releases of this package. A reader that meets a kind it does not
113
+ * name MUST treat it as a failure of unknown kind — never as success, never
114
+ * as "not rendered". An exhaustive `switch` over this union stops compiling
115
+ * at the next member; that is the intended nudge at upgrade, not a wire
116
+ * break. The report wire (`benchmark-report.v2`) carries the kind as a
117
+ * string and its readers copy it verbatim (ggui#1014, ggui#1040).
118
+ */
119
+ export type BehaviorFailureKind = 'action-no-effect' | 'action-not-rendered'
120
+ /**
121
+ * The named action's control was present but the static probe could not reach it enabled
122
+ * (ggui#1040). NOT MEASURED — never a miss, never "not rendered"; symmetric across arms by
123
+ * construction. `BehaviorFailure.reason` says why.
124
+ */
125
+ | 'action-unreachable' | 'render-failed' | 'timeout';
126
+ /**
127
+ * Why an action was unreachable (ggui#1040). `disabled-after-priming`: the action's control rendered
128
+ * disabled and no click of the probe's made new controls appear — the gate is input the probe did not
129
+ * provide. `behind-navigation`: a click made new controls appear (a step, a tab, a revealed section)
130
+ * and the action's control was still never observed enabled — the gate sits behind navigation the
131
+ * probe walked into but could not finish. Measured by the clickable-candidate set changing, not by a
132
+ * DOM diff.
133
+ */
134
+ export type BehaviorUnreachableReason = 'disabled-after-priming' | 'behind-navigation';
97
135
  export interface BehaviorFailure {
98
136
  readonly kind: BehaviorFailureKind;
99
137
  readonly actionName?: string;
100
138
  readonly diagnostic: string;
139
+ /** Present when `kind` is `action-unreachable` (ggui#1040). */
140
+ readonly reason?: BehaviorUnreachableReason;
101
141
  }
102
142
  export interface ValidateContractBehaviorResult {
103
143
  readonly ok: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkEG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,KAAK,EACV,OAAO,IAAI,iBAAiB,EAC5B,cAAc,IAAI,wBAAwB,EAC1C,IAAI,IAAI,cAAc,EACtB,aAAa,IAAI,uBAAuB,EACzC,MAAM,iBAAiB,CAAC;AAEzB;;;;;;;GAOG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,QAAQ,EAAE;QACjB,MAAM,CAAC,OAAO,CAAC,EAAE,uBAAuB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;KACvE,CAAC;CACH;AAED,yDAAyD;AACzD,YAAY,EACV,iBAAiB,EACjB,wBAAwB,EACxB,cAAc,GACf,CAAC;AAEF,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;IAChC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,gBAAgB,CAAC;CACvC;AAED,MAAM,MAAM,mBAAmB,GAC3B,kBAAkB,GAClB,qBAAqB,GACrB,eAAe,GACf,SAAS,CAAC;AAEd,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;IACnC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,8BAA8B;IAC7C,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,QAAQ,EAAE,SAAS,eAAe,EAAE,CAAC;CAC/C;AAED,OAAO,EAAE,wBAAwB,EAAE,MAAM,eAAe,CAAC;AAEzD;;;;GAIG;AACH,qBAAa,2BAA4B,SAAQ,KAAK;IACpD,SAAkB,IAAI,iCAAiC;;CASxD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyEG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAClE,OAAO,KAAK,EACV,OAAO,IAAI,iBAAiB,EAC5B,cAAc,IAAI,wBAAwB,EAC1C,IAAI,IAAI,cAAc,EACtB,aAAa,IAAI,uBAAuB,EACzC,MAAM,iBAAiB,CAAC;AAEzB;;;;;;;GAOG;AACH,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,QAAQ,EAAE;QACjB,MAAM,CAAC,OAAO,CAAC,EAAE,uBAAuB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAAC;KACvE,CAAC;CACH;AAED,yDAAyD;AACzD,YAAY,EACV,iBAAiB,EACjB,wBAAwB,EACxB,cAAc,GACf,CAAC;AAEF,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;IAChC;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,UAAU,CAAC;IAClC,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B;;;;OAIG;IACH,QAAQ,CAAC,UAAU,EAAE,gBAAgB,CAAC;CACvC;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,mBAAmB,GAC3B,kBAAkB,GAClB,qBAAqB;AACvB;;;;GAIG;GACD,oBAAoB,GACpB,eAAe,GACf,SAAS,CAAC;AAEd;;;;;;;GAOG;AACH,MAAM,MAAM,yBAAyB,GAAG,wBAAwB,GAAG,mBAAmB,CAAC;AAEvF,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,mBAAmB,CAAC;IACnC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,+DAA+D;IAC/D,QAAQ,CAAC,MAAM,CAAC,EAAE,yBAAyB,CAAC;CAC7C;AAED,MAAM,WAAW,8BAA8B;IAC7C,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,QAAQ,EAAE,SAAS,eAAe,EAAE,CAAC;CAC/C;AAED,OAAO,EAAE,wBAAwB,EAAE,MAAM,eAAe,CAAC;AAEzD;;;;GAIG;AACH,qBAAa,2BAA4B,SAAQ,KAAK;IACpD,SAAkB,IAAI,iCAAiC;;CASxD"}
@@ -1 +1 @@
1
- {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACtD,OAAO,KAAK,EAIV,6BAA6B,EAC7B,8BAA8B,EAC/B,MAAM,YAAY,CAAC;AAYpB;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,oBAAoB,GAAG,aAAa,GAAG,eAAe,CAAC;AAqKnE;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,YAAY,EACtB,UAAU,EAAE,MAAM,GACjB,oBAAoB,CAMtB;AAeD,wBAAsB,wBAAwB,CAC5C,KAAK,EAAE,6BAA6B,GACnC,OAAO,CAAC,8BAA8B,CAAC,CAsCzC"}
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,YAAY,EAAc,MAAM,mBAAmB,CAAC;AAClE,OAAO,KAAK,EAIV,6BAA6B,EAC7B,8BAA8B,EAC/B,MAAM,YAAY,CAAC;AAapB;;;;;;;;;;;;;;GAcG;AACH,MAAM,MAAM,oBAAoB,GAAG,aAAa,GAAG,eAAe,CAAC;AAsMnE;;;;;;;;;GASG;AACH,wBAAgB,cAAc,CAC5B,QAAQ,EAAE,YAAY,EACtB,UAAU,EAAE,MAAM,GACjB,oBAAoB,CAMtB;AAeD,wBAAsB,wBAAwB,CAC5C,KAAK,EAAE,6BAA6B,GACnC,OAAO,CAAC,8BAA8B,CAAC,CAuCzC"}