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