@ggui-ai/ui-visual-tester 0.16.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.
@@ -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":"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,4EAA4E;IAC5E,QAAQ,CAAC,WAAW,EAAE,OAAO,CAAC;IAC9B,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,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GACvE;IAAE,QAAQ,CAAC,MAAM,EAAE,kBAAkB,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAAE;AACtE,8FAA8F;GAC5F;IAAE,QAAQ,CAAC,MAAM,EAAE,oBAAoB,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,wBAAwB,GAAG,mBAAmB,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GACvI;IACE,QAAQ,CAAC,MAAM,EAAE,IAAI,CAAC;IACtB,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC,QAAQ,CAAC,UAAU,EAAE,OAAO,CAAC;IAC7B,qGAAqG;IACrG,QAAQ,CAAC,GAAG,CAAC,EAAE,OAAO,GAAG,UAAU,CAAC;IACpC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;CAC3B,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"}
@@ -70,27 +70,247 @@ 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
+ * #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;
91
+ /**
92
+ * ONE deadline covers both passes for an action: max(the caller's wait, this
93
+ * floor). A few loosely-matching decoys can no longer run an action for
94
+ * minutes; the floor keeps very short test waits from starving the fallback.
95
+ */
96
+ const ACTION_BUDGET_FLOOR_MS = 6000;
97
+ function norm(text) {
98
+ return text.toLowerCase().replace(/[^a-z0-9]/g, '');
99
+ }
100
+ /**
101
+ * The controls to try, the way the harness's render-check finds them (#996):
102
+ * pass 1 = controls that NAME the action — `data-action` by value (the one
103
+ * hook that names the action rather than its label), then aria-label / text
104
+ * carrying the label or the name; pass 2 = every other enabled clickable, in
105
+ * DOM order. A generated component that wires `useAction('toggleItem')` to an
106
+ * unnamed checkbox is found by pass 2 and judged by what its click dispatches.
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
+ }
112
+ function collectActionCandidates(root, name, label) {
113
+ const every = Array.from(root.querySelectorAll(CLICKABLE_SELECTOR)).filter((el) => el instanceof HTMLElement);
114
+ const isDisabled = isControlDisabled;
115
+ const lcName = name.toLowerCase();
116
+ const lcLabel = label.toLowerCase();
117
+ const nameKey = norm(name);
118
+ const labelKey = norm(label);
119
+ // How the harness names a control: `data-action` by value first (#996), then the aria label or the text.
120
+ const namesAction = (el) => {
121
+ const dataAction = (el.getAttribute('data-action') ?? '').toLowerCase();
122
+ if (dataAction === lcName)
123
+ return 'value';
124
+ const aria = (el.getAttribute('aria-label') ?? '').toLowerCase();
82
125
  const txt = (el.textContent ?? '').trim().toLowerCase();
83
- if (txt.length > 0 && txt.includes(lc))
84
- return el;
126
+ const ariaNames = aria.length > 0 && (aria.includes(lcLabel) || (nameKey.length > 0 && norm(aria).includes(nameKey)));
127
+ const textNames = txt.length > 0 &&
128
+ (txt.includes(lcLabel) ||
129
+ (nameKey.length > 0 && norm(txt).includes(nameKey)) ||
130
+ (labelKey.length > 0 && norm(txt).includes(labelKey)));
131
+ return ariaNames || textNames ? 'text' : null;
132
+ };
133
+ const disabledEls = every.filter(isDisabled);
134
+ const disabled = disabledEls.length;
135
+ const namedDisabled = disabledEls.filter((el) => namesAction(el) !== null).length;
136
+ const all = every.filter((el) => !isDisabled(el) &&
137
+ // an already-checked radio fires no change on click — nothing to observe
138
+ !(el instanceof HTMLInputElement && el.type === 'radio' && el.checked));
139
+ const byValue = [];
140
+ const byText = [];
141
+ const rest = [];
142
+ for (const el of all) {
143
+ const how = namesAction(el);
144
+ if (how === 'value')
145
+ byValue.push(el);
146
+ else if (how === 'text')
147
+ byText.push(el);
148
+ else
149
+ rest.push(el);
150
+ }
151
+ return { named: [...byValue, ...byText], fallback: rest, disabled, namedDisabled, allClickables: every };
152
+ }
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
+ function sampleValueFor(el) {
160
+ const type = el instanceof HTMLInputElement ? el.type : 'textarea';
161
+ switch (type) {
162
+ case 'number':
163
+ return '1';
164
+ case 'email':
165
+ return 'probe@example.com';
166
+ case 'url':
167
+ return 'https://example.com';
168
+ case 'tel':
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';
180
+ default:
181
+ return 'probe';
85
182
  }
86
- for (const el of Array.from(root.querySelectorAll('*'))) {
87
- if (!(el instanceof HTMLElement))
183
+ }
184
+ /** Set a value the way React notices it: through the prototype's native setter, then an input + change event. */
185
+ function setNativeValue(el, value) {
186
+ const proto = el instanceof HTMLTextAreaElement
187
+ ? HTMLTextAreaElement.prototype
188
+ : el instanceof HTMLSelectElement
189
+ ? HTMLSelectElement.prototype
190
+ : HTMLInputElement.prototype;
191
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
192
+ if (setter)
193
+ setter.call(el, value);
194
+ else
195
+ el.value = value;
196
+ el.dispatchEvent(new Event('input', { bubbles: true }));
197
+ el.dispatchEvent(new Event('change', { bubbles: true }));
198
+ }
199
+ /**
200
+ * The generic minimum a user does before pressing Send / Submit / Complete
201
+ * (#1021): give every empty text-like input and textarea a value, every
202
+ * unset select its first real option, every unchecked radio group its first
203
+ * radio. Checkboxes are never touched — they are often the action itself.
204
+ * Returns how many controls were primed.
205
+ */
206
+ function primeInputs(root) {
207
+ let primed = 0;
208
+ const editable = (el) => !el.disabled && !(el instanceof HTMLSelectElement) ? !el.readOnly : !el.disabled;
209
+ for (const el of Array.from(root.querySelectorAll('input, textarea'))) {
210
+ if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) || !editable(el))
88
211
  continue;
89
- const txt = (el.textContent ?? '').trim().toLowerCase();
90
- if (txt === lc)
91
- return el;
212
+ if (el instanceof HTMLInputElement && !TEXT_LIKE_INPUT_TYPES.has(el.type))
213
+ continue;
214
+ if (el.value.trim().length > 0)
215
+ continue;
216
+ setNativeValue(el, sampleValueFor(el));
217
+ if (el.value.trim().length > 0)
218
+ primed += 1;
219
+ }
220
+ for (const el of Array.from(root.querySelectorAll('select'))) {
221
+ if (!(el instanceof HTMLSelectElement) || el.disabled)
222
+ continue;
223
+ const current = el.options[el.selectedIndex];
224
+ if (current !== undefined && current.value.trim().length > 0)
225
+ continue;
226
+ const first = Array.from(el.options).find((o) => o.value.trim().length > 0 && !o.disabled);
227
+ if (first === undefined)
228
+ continue;
229
+ setNativeValue(el, first.value);
230
+ primed += 1;
231
+ }
232
+ const seenGroups = new Set();
233
+ for (const el of Array.from(root.querySelectorAll('input[type="radio"]'))) {
234
+ if (!(el instanceof HTMLInputElement) || el.disabled)
235
+ continue;
236
+ const group = el.name || '';
237
+ if (seenGroups.has(group))
238
+ continue;
239
+ seenGroups.add(group);
240
+ const members = group
241
+ ? Array.from(root.querySelectorAll(`input[type="radio"][name="${CSS.escape(group)}"]`)).filter((m) => m instanceof HTMLInputElement)
242
+ : [el];
243
+ if (members.some((m) => m.checked))
244
+ continue;
245
+ const first = members.find((m) => !m.disabled);
246
+ if (first === undefined)
247
+ continue;
248
+ first.click();
249
+ primed += 1;
250
+ }
251
+ return primed;
252
+ }
253
+ function countNamedDispatches(actionName) {
254
+ return (window.__ggui_test_dispatches__ ?? []).filter((d) => d.actionName === actionName).length;
255
+ }
256
+ function snapshot(container, actionName) {
257
+ return {
258
+ text: container.textContent ?? '',
259
+ html: container.innerHTML,
260
+ named: countNamedDispatches(actionName),
261
+ total: (window.__ggui_test_dispatches__ ?? []).length,
262
+ };
263
+ }
264
+ /** Poll for the required signal after a click. `dispatchFired` counts only dispatches of THIS action. */
265
+ async function observe(container, actionName, before, req, waitMs) {
266
+ const start = Date.now();
267
+ let dispatchFired = false;
268
+ let domChanged = false;
269
+ while (Date.now() - start < waitMs) {
270
+ await delay(50);
271
+ if (!dispatchFired && countNamedDispatches(actionName) > before.named) {
272
+ dispatchFired = true;
273
+ }
274
+ if (!domChanged) {
275
+ const afterText = container.textContent ?? '';
276
+ const afterHtml = container.innerHTML;
277
+ if (afterText !== before.text || afterHtml !== before.html) {
278
+ domChanged = true;
279
+ }
280
+ }
281
+ if (dispatchFired && domChanged)
282
+ break;
283
+ // Early-exit once the REQUIRED signal has fired (don't wait for the other one).
284
+ if (req.requireDispatch && dispatchFired)
285
+ break;
286
+ if (req.requireDom && domChanged)
287
+ break;
288
+ }
289
+ const otherActions = (window.__ggui_test_dispatches__ ?? [])
290
+ .slice(before.total)
291
+ .map((d) => d.actionName)
292
+ .filter((n) => n !== actionName);
293
+ return { dispatchFired, domChanged, otherActions: [...new Set(otherActions)] };
294
+ }
295
+ function satisfied(req, obs) {
296
+ return (req.requireDispatch ? obs.dispatchFired : true) && (req.requireDom ? obs.domChanged : true);
297
+ }
298
+ /**
299
+ * A control's identity across re-renders: its path from the mount root by tag
300
+ * and sibling index (plus an input's type). A re-rendered equivalent at the
301
+ * same position counts as already tried; a detached node never gets clicked.
302
+ */
303
+ function signatureOf(root, el) {
304
+ const parts = [];
305
+ let cur = el;
306
+ while (cur !== null && cur !== root) {
307
+ const parent = cur.parentElement;
308
+ const idx = parent === null ? 0 : Array.prototype.indexOf.call(parent.children, cur);
309
+ const type = cur instanceof HTMLInputElement ? `[${cur.type}]` : '';
310
+ parts.push(`${cur.tagName.toLowerCase()}${type}:${idx}`);
311
+ cur = parent;
92
312
  }
93
- return null;
313
+ return parts.reverse().join('/');
94
314
  }
95
315
  async function evaluateComponent(code) {
96
316
  const cleaned = stripMarkers(code);
@@ -104,7 +324,12 @@ async function evaluateComponent(code) {
104
324
  }
105
325
  return Comp;
106
326
  }
107
- function mountTree(Comp) {
327
+ function propsOf(value) {
328
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
329
+ ? value
330
+ : {};
331
+ }
332
+ function mountTree(Comp, props) {
108
333
  const container = document.createElement('div');
109
334
  container.id = 'ggui-vt-mount';
110
335
  document.body.replaceChildren(container);
@@ -127,7 +352,9 @@ function mountTree(Comp) {
127
352
  subscribe: () => () => { },
128
353
  };
129
354
  const root = ReactDomClient.createRoot(container);
130
- const componentElement = React.createElement(Comp, {});
355
+ // The commit's fixture props: a component that draws its controls from
356
+ // props (a board's tasks, a list's items) mounts EMPTY without them.
357
+ const componentElement = React.createElement(Comp, props);
131
358
  const tree = React.createElement(Wire.GguiWireProvider, {
132
359
  config: wireConfig,
133
360
  children: componentElement,
@@ -182,7 +409,7 @@ async function run(input) {
182
409
  let handles = null;
183
410
  try {
184
411
  try {
185
- handles = mountTree(Comp);
412
+ handles = mountTree(Comp, propsOf(input.sampleProps));
186
413
  }
187
414
  catch (err) {
188
415
  const msg = err instanceof Error ? err.message : String(err);
@@ -199,55 +426,162 @@ async function run(input) {
199
426
  diagnostic: `actionSpec.${input.actionName} missing`,
200
427
  };
201
428
  }
202
- const button = findActionButton(handles.container, entry.label);
203
- if (button === null) {
204
- return { status: 'action-not-rendered' };
429
+ const req = signalRequirement(input.classification);
430
+ const container = handles.container;
431
+ // Prime inputs first (#1021): Send/Submit/Complete are commonly disabled
432
+ // until something is typed or chosen — the minimum a user does before them.
433
+ const primed = primeInputs(container);
434
+ if (primed > 0)
435
+ await delay(150);
436
+ const first = collectActionCandidates(container, input.actionName, entry.label);
437
+ if (first.named.length === 0 && first.fallback.length === 0) {
438
+ if (first.namedDisabled > 0) {
439
+ // #1040: the action's own control is there, disabled, and nothing else is clickable —
440
+ // the action is gated on input the probe did not provide. Not missing, not a miss: not measured.
441
+ return {
442
+ status: 'action-unreachable',
443
+ reason: 'disabled-after-priming',
444
+ diagnostic: `${first.disabled} control(s) rendered, all disabled (${first.namedDisabled} naming the action) — the action is gated on input the probe did not provide ` +
445
+ `(primed ${primed} input(s)); actionSpec.${input.actionName} (label "${entry.label}")`,
446
+ };
447
+ }
448
+ return {
449
+ status: 'action-not-rendered',
450
+ diagnostic: `no clickable control in the rendered DOM for actionSpec.${input.actionName} (label "${entry.label}")` +
451
+ (first.disabled > 0 ? ` — ${first.disabled} control(s) rendered disabled, none naming the action` : '') +
452
+ ` (primed ${primed} input(s))`,
453
+ };
205
454
  }
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;
455
+ // One deadline for the whole action. Named controls get the caller's wait
456
+ // (or what is left of the budget); fallback controls get a short slice each.
457
+ const deadline = Date.now() + Math.max(input.waitMs, ACTION_BUDGET_FLOOR_MS);
458
+ const remaining = () => Math.max(0, deadline - Date.now());
459
+ let clicked = 0;
460
+ let bestDispatch = false;
461
+ let bestDom = false;
462
+ const otherActions = new Set();
463
+ const attempt = async (el, waitMs, via) => {
464
+ const before = snapshot(container, input.actionName);
465
+ clicked += 1;
466
+ el.click();
467
+ const obs = await observe(container, input.actionName, before, req, waitMs);
468
+ bestDispatch = bestDispatch || obs.dispatchFired;
469
+ bestDom = bestDom || obs.domChanged;
470
+ for (const a of obs.otherActions)
471
+ otherActions.add(a);
472
+ return satisfied(req, obs)
473
+ ? { status: 'ok', dispatchFired: obs.dispatchFired, domChanged: obs.domChanged, via, clicked }
474
+ : null;
475
+ };
476
+ // Re-collect LIVE candidates before EVERY click: an earlier click may have
477
+ // re-rendered the tree (a filter, a tab, an opened editor), detaching what
478
+ // was collected and adding what was not there. A control is tried once,
479
+ // by its DOM path; a detached node is never the one clicked.
480
+ const tried = new Set();
481
+ let exhausted = false;
482
+ // #1040: was the NAMED control ever clicked enabled (a real attempt), was it ever seen disabled (a
483
+ // gate), and did a click make NEW controls appear (a step, a tab, a revealed section — navigation,
484
+ // as distinct from a re-render of what was already there)?
485
+ let namedAttempted = false;
486
+ let namedDisabledSeen = first.namedDisabled;
487
+ let navigated = false;
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));
504
+ for (;;) {
505
+ if (remaining() === 0) {
506
+ exhausted = true;
507
+ break;
218
508
  }
219
- if (!domChanged) {
220
- const afterText = handles.container.textContent ?? '';
221
- const afterHtml = handles.container.innerHTML;
222
- if (afterText !== beforeText || afterHtml !== beforeHtml) {
223
- domChanged = true;
509
+ let live = collectActionCandidates(container, input.actionName, entry.label);
510
+ // Disabled controls count: a step that reveals a disabled Complete IS navigation into the gate.
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.
515
+ if (clicked > 0 && liveSignatures.some((sig) => !knownSignatures.has(sig)))
516
+ navigated = true;
517
+ const revealed = clicked > 0 && liveStates.some((state) => !knownStates.has(state));
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);
224
527
  }
225
528
  }
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)
232
- break;
233
- if (req.requireDom && domChanged)
529
+ namedDisabledSeen = Math.max(namedDisabledSeen, live.namedDisabled);
530
+ const untried = (list) => list.find((el) => el.isConnected && !tried.has(signatureOf(container, el)));
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;
542
+ if (next === undefined)
234
543
  break;
544
+ const via = nextNamed !== undefined ? 'named' : 'fallback';
545
+ if (via === 'named')
546
+ namedAttempted = true;
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;
554
+ const hit = await attempt(next, via === 'named' ? Math.min(input.waitMs, remaining()) : Math.min(FALLBACK_CLICK_WAIT_MS, remaining()), via);
555
+ if (hit !== null)
556
+ return hit;
235
557
  }
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');
558
+ const missing = [];
559
+ if (req.requireDispatch && !bestDispatch)
560
+ missing.push('dispatch');
561
+ if (req.requireDom && !bestDom)
562
+ missing.push('DOM change');
563
+ const others = otherActions.size > 0 ? `; other actions dispatched: ${[...otherActions].join(', ')}` : '';
564
+ const navigatorReclicks = [...navigators.values()].reduce((sum, n) => sum + n, 0);
565
+ // #1040: the named control was seen only disabled and never clicked enabled — the probe could not
566
+ // reach the action. Not measured, with the reason: a click made new controls appear (the gate sits
567
+ // behind navigation the probe walked into but could not finish) or none did (the gate is input the
568
+ // probe did not provide). An enabled named control that was clicked and did nothing stays the miss.
569
+ if (!namedAttempted && namedDisabledSeen > 0) {
570
+ const reason = navigated ? 'behind-navigation' : 'disabled-after-priming';
245
571
  return {
246
- status: 'action-no-effect',
247
- diagnostic: `${req.description}; observed dispatch=${dispatchFired}, domChanged=${domChanged}; missing=${missing.join('+')}`,
572
+ status: 'action-unreachable',
573
+ reason,
574
+ diagnostic: `actionSpec.${input.actionName} (label "${entry.label}") rendered disabled on every observation (${namedDisabledSeen} matching control(s)) and was never clicked enabled; ` +
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}` +
576
+ `${exhausted ? '; budget exhausted' : ''}${others}`,
248
577
  };
249
578
  }
250
- return { status: 'ok', dispatchFired, domChanged };
579
+ return {
580
+ status: 'action-no-effect',
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)` +
582
+ `; navigator re-clicks=${navigatorReclicks}${exhausted ? '; budget exhausted' : ''}); best observed dispatch=${bestDispatch}, domChanged=${bestDom}; ` +
583
+ `missing=${missing.join('+')}${others}`,
584
+ };
251
585
  }
252
586
  finally {
253
587
  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"}