@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.
- package/dist/fixture-runtime.d.ts +12 -0
- package/dist/fixture-runtime.d.ts.map +1 -1
- package/dist/fixture-runtime.js +324 -62
- package/dist/index.d.ts +49 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +33 -3
- package/fixture/fixture-runtime.js +1565 -879
- package/package.json +4 -4
|
@@ -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,
|
|
1
|
+
{"version":3,"file":"fixture-runtime.d.ts","sourceRoot":"","sources":["../src/fixture-runtime.ts"],"names":[],"mappings":"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"}
|
package/dist/fixture-runtime.js
CHANGED
|
@@ -70,27 +70,221 @@ function installRegistry() {
|
|
|
70
70
|
window.__GGUI_APP_COMPONENTS = registry.appComponents;
|
|
71
71
|
return registry;
|
|
72
72
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|
|
237
|
-
|
|
238
|
-
(
|
|
239
|
-
if (!
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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-
|
|
247
|
-
|
|
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 {
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
|
|
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;
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA
|
|
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"}
|
package/dist/validate.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EAAE,YAAY,
|
|
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"}
|