@ggui-ai/ui-gen 0.25.0 → 0.26.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/README.md +2 -2
- package/dist/{adapter-Cg_rkpGq.d.ts → adapter-BJwn74rx.d.ts} +49 -1
- package/dist/adapters/generation-dispatch.d.ts +8 -5
- package/dist/adapters/generation-dispatch.js +1118 -193
- package/dist/adapters/generation-dispatch.js.map +1 -1
- package/dist/adapters/index.js +27 -6
- package/dist/adapters/index.js.map +1 -1
- package/dist/advanced/index.d.ts +10 -9
- package/dist/advanced/index.js +1126 -202
- package/dist/advanced/index.js.map +1 -1
- package/dist/blueprint-validator.js +416 -92
- package/dist/blueprint-validator.js.map +1 -1
- package/dist/boilerplate.d.ts +2 -2
- package/dist/boilerplate.js +10 -3
- package/dist/boilerplate.js.map +1 -1
- package/dist/check/index.d.ts +3 -2
- package/dist/check/index.js +9 -2
- package/dist/check/index.js.map +1 -1
- package/dist/{create-ui-generator-DFnnArrv.d.ts → create-ui-generator-N4gRXpcL.d.ts} +3 -4
- package/dist/{design-mode-DLufhrNp.d.ts → design-mode-DGWN_Gkm.d.ts} +1 -1
- package/dist/{dispatch-CStl9eAw.d.ts → dispatch-BUoXRj_r.d.ts} +2 -2
- package/dist/evaluation/axis-checks/index.d.ts +5 -4
- package/dist/evaluation/axis-checks/index.js.map +1 -1
- package/dist/evaluation/axis-checks/registry.d.ts +4 -3
- package/dist/evaluation/index.d.ts +107 -539
- package/dist/evaluation/index.js +552 -55
- package/dist/evaluation/index.js.map +1 -1
- package/dist/evaluation/types.d.ts +17 -0
- package/dist/evaluation/types.js.map +1 -1
- package/dist/{generate-9IeNT4ZY.d.ts → generate-tSO_shQ0.d.ts} +1 -1
- package/dist/harness/check/runtime-render/index.d.ts +5 -5
- package/dist/harness/check/runtime-render/index.js +416 -92
- package/dist/harness/check/runtime-render/index.js.map +1 -1
- package/dist/harness/check/runtime-render/render-check-worker.js +393 -88
- package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
- package/dist/harness/index.d.ts +4 -5
- package/dist/harness/index.js +36 -5
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/prompts.d.ts +2 -2
- package/dist/harness/prompts.js +10 -1
- package/dist/harness/prompts.js.map +1 -1
- package/dist/harness/result-types.d.ts +17 -3
- package/dist/harness/result-types.js.map +1 -1
- package/dist/harness/runtime.d.ts +14 -6
- package/dist/harness/runtime.js +11 -4
- package/dist/harness/runtime.js.map +1 -1
- package/dist/harness/types-public.d.ts +4 -2
- package/dist/harness/types-public.js +10 -3
- package/dist/harness/types-public.js.map +1 -1
- package/dist/harness/validator-trace-sink.d.ts +4 -3
- package/dist/index.d.ts +46 -11
- package/dist/index.js +1128 -200
- package/dist/index.js.map +1 -1
- package/dist/patch.d.ts +3 -3
- package/dist/{prompts-YU9piOgs.d.ts → prompts-BhNUuRoZ.d.ts} +1 -1
- package/dist/tools/render-check-worker.js +8 -1
- package/dist/tools/render-check-worker.js.map +1 -1
- package/dist/{types-public-BJTLpzWn.d.ts → types-public-B7wn4ce5.d.ts} +384 -3
- package/dist/validation/index.d.ts +1 -1
- package/dist/validation/index.js +1 -1
- package/dist/validation/index.js.map +1 -1
- package/dist/visual-evaluator-C9L-o6ap.d.ts +639 -0
- package/dist/workflows.d.ts +3 -3
- package/package.json +12 -12
- package/src/adapters/generation-dispatch.ts +2 -0
- package/src/boilerplate/free-design-prompt.ts +3 -3
- package/src/boilerplate/hard-sections.ts +1 -1
- package/src/boilerplate/templates/layouts/chat-mobile.tsx.tmpl +3 -1
- package/src/boilerplate/templates/layouts/chat-universal.tsx.tmpl +3 -1
- package/src/boilerplate/templates/layouts/fullscreen-desktop.tsx.tmpl +3 -1
- package/src/boilerplate/templates/layouts/fullscreen-universal.tsx.tmpl +3 -1
- package/src/check/run-tier0.ts +6 -3
- package/src/contract-context.ts +1 -1
- package/src/evaluation/cost-tracker.ts +54 -6
- package/src/evaluation/criteria/edge-probe.ts +286 -0
- package/src/evaluation/criteria/edge.ts +202 -0
- package/src/evaluation/criteria/resolve.ts +55 -7
- package/src/evaluation/index.ts +5 -1
- package/src/evaluation/judge-spend.ts +48 -0
- package/src/evaluation/types-public.ts +91 -0
- package/src/evaluation/types.ts +10 -0
- package/src/evaluation/visual-evaluator.ts +236 -40
- package/src/harness/check/runtime-render/adapter.ts +18 -0
- package/src/harness/check/runtime-render/probe-pending.ts +111 -0
- package/src/harness/check/runtime-render/render-check-host.ts +10 -6
- package/src/harness/check/runtime-render/render-check-worker.ts +6 -3
- package/src/harness/check/runtime-render/render-check.ts +417 -133
- package/src/harness/check/runtime-render/walk.ts +200 -0
- package/src/harness/coding/assemble-result.ts +2 -0
- package/src/harness/coding/generate-task-runner.ts +27 -0
- package/src/harness/coding/init-session.ts +5 -3
- package/src/harness/coding/run-coding-turn.ts +5 -1
- package/src/harness/coding/run-eval-round.ts +63 -3
- package/src/harness/in-loop-evaluation.ts +39 -0
- package/src/harness/llm-router.ts +58 -1
- package/src/harness/result-types.ts +9 -0
- package/src/harness/runtime.ts +9 -0
- package/src/harness/types-public.ts +10 -1
- package/src/index.ts +5 -0
- package/src/internal/verdict-line.ts +33 -0
- package/src/taught-tokens.ts +32 -0
- package/src/tools/render-check-worker.ts +2 -1
- package/src/tools/render-check.ts +8 -7
- package/src/validation/primitives.ts +1 -1
- package/dist/llm-router-nfO7wPec.d.ts +0 -267
- package/dist/{bank-CT4eEkta.d.ts → bank-Ctdp3FUA.d.ts} +3 -3
|
@@ -38,8 +38,10 @@ import {
|
|
|
38
38
|
type ParentLink,
|
|
39
39
|
} from "./host-boundary.js";
|
|
40
40
|
import { hostGlobals, openRecord } from "../../../internal/open-record.js";
|
|
41
|
-
import type { ProbeHostLoad } from "../../../evaluation/types-public.js";
|
|
41
|
+
import type { ProbeHostLoad, ProbePendingAffordance } from "../../../evaluation/types-public.js";
|
|
42
|
+
import { createProbePendingSource, lookChanged, lookOf, settlesWithin, type ProbePendingSource } from "./probe-pending.js";
|
|
42
43
|
import { primeInputs } from "@ggui-ai/ui-visual-tester/prime-inputs";
|
|
44
|
+
import { walkToAction, type WalkControl, type WalkMount, type WalkStop } from "./walk.js";
|
|
43
45
|
|
|
44
46
|
/**
|
|
45
47
|
* Module-namespace-shaped value injected into `loadComponent`'s
|
|
@@ -117,9 +119,25 @@ export interface RenderCheckIssue {
|
|
|
117
119
|
* beside `{ primed: 0 }` or `{ error }` than beside `{ primed: 1 }`.
|
|
118
120
|
*/
|
|
119
121
|
readonly inputPriming?: InputPrimingDiagnostic;
|
|
122
|
+
/** For action-wiring (ggui#1652): how far the walk over the card's screens got without dispatching the action. */
|
|
123
|
+
readonly walk?: ActionWalkDiagnostic;
|
|
120
124
|
};
|
|
121
125
|
}
|
|
122
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The action walk's account of an action it could not dispatch (ggui#1652):
|
|
129
|
+
* how many controls it pressed across how many screens, why it stopped,
|
|
130
|
+
* whether any screen rendered a control naming the action, and the last
|
|
131
|
+
* controls it pressed. Never the first button in the page.
|
|
132
|
+
*/
|
|
133
|
+
export interface ActionWalkDiagnostic {
|
|
134
|
+
readonly presses: number;
|
|
135
|
+
readonly screens: number;
|
|
136
|
+
readonly stoppedBy: Exclude<WalkStop, "dispatched">;
|
|
137
|
+
readonly namedControlSeen: boolean;
|
|
138
|
+
readonly lastPressed: readonly string[];
|
|
139
|
+
}
|
|
140
|
+
|
|
123
141
|
/**
|
|
124
142
|
* Outcome of `primeInputs` before the action-wiring probe (ggui#1187). A
|
|
125
143
|
* priming failure never aborts the check — it is recorded here, on every
|
|
@@ -152,6 +170,8 @@ export interface RenderCheckResult {
|
|
|
152
170
|
readonly renderMs: number;
|
|
153
171
|
/** Host load around an isolated check (absent in-process). */
|
|
154
172
|
readonly hostLoad?: ProbeHostLoad;
|
|
173
|
+
/** ggui#1398: the pending-affordance walk, report-only; absent when no action walk ran. */
|
|
174
|
+
readonly pendingAffordance?: ProbePendingAffordance;
|
|
155
175
|
};
|
|
156
176
|
}
|
|
157
177
|
|
|
@@ -159,8 +179,29 @@ export interface RunRenderCheckInput {
|
|
|
159
179
|
readonly sourceCode: string;
|
|
160
180
|
readonly mockupProps: JsonObject;
|
|
161
181
|
readonly contract?: DataContract;
|
|
182
|
+
/**
|
|
183
|
+
* Epoch ms by which the check must finish: set by the isolating host from
|
|
184
|
+
* its own bound, absent in-process. The report-only pending phase is
|
|
185
|
+
* skipped when less than {@link PENDING_PHASE_RESERVE_MS} remains, so it
|
|
186
|
+
* can never push a check past its bound (ggui#1398).
|
|
187
|
+
*/
|
|
188
|
+
readonly deadlineAt?: number;
|
|
162
189
|
}
|
|
163
190
|
|
|
191
|
+
/** The time the pending phase needs left before the check's deadline to run at all (ggui#1398). */
|
|
192
|
+
export const PENDING_PHASE_RESERVE_MS = 5000;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The action walk's bounds (ggui#1652), applied once a press has left the
|
|
196
|
+
* first screen (see walk.ts): at most this many presses and this long per
|
|
197
|
+
* action, and never within the pending phase's reserve plus
|
|
198
|
+
* {@link WALK_DEADLINE_MARGIN_MS} (for the checks that run after the walk)
|
|
199
|
+
* of the check's deadline.
|
|
200
|
+
*/
|
|
201
|
+
export const WALK_MAX_PRESSES = 60;
|
|
202
|
+
export const WALK_ACTION_CAP_MS = 3000;
|
|
203
|
+
const WALK_DEADLINE_MARGIN_MS = 2000;
|
|
204
|
+
|
|
164
205
|
/**
|
|
165
206
|
* Options of {@link runRenderCheck} (ggui#1380): the host's options —
|
|
166
207
|
* `bounds` are the isolated subprocess's wall-clock, heap and grace (see
|
|
@@ -341,7 +382,11 @@ export async function runRenderCheckInProcess(
|
|
|
341
382
|
// Must run before RTL's first import in this process — see
|
|
342
383
|
// production-act-shim.ts's docstring for why.
|
|
343
384
|
installProductionActShim();
|
|
344
|
-
const { render, cleanup } = await import("@testing-library/react");
|
|
385
|
+
const { render, cleanup, act } = await import("@testing-library/react");
|
|
386
|
+
// ggui#1398: the card mounts inside the runtime's pending context, driven
|
|
387
|
+
// by the probe (see probe-pending.ts). Same wire instance as the card's.
|
|
388
|
+
const { ActionPendingContext } = await import("@ggui-ai/wire/internal");
|
|
389
|
+
const pendingSource = createProbePendingSource();
|
|
345
390
|
// user-event v14 default export — see https://testing-library.com/docs/user-event/intro/
|
|
346
391
|
const userEventModule = await import("@testing-library/user-event");
|
|
347
392
|
const userEvent = userEventModule.default ?? userEventModule;
|
|
@@ -417,9 +462,10 @@ export async function runRenderCheckInProcess(
|
|
|
417
462
|
// Use object container so TS narrowing follows property access (not
|
|
418
463
|
// closure flow analysis, which can't see the componentDidCatch
|
|
419
464
|
// mutation and would narrow these to `null` at the use site).
|
|
420
|
-
const boundaryRef: { stack: string | null; error: Error | null } = {
|
|
465
|
+
const boundaryRef: { stack: string | null; error: Error | null; catches: number } = {
|
|
421
466
|
stack: null,
|
|
422
467
|
error: null,
|
|
468
|
+
catches: 0,
|
|
423
469
|
};
|
|
424
470
|
class ProbeErrorBoundary extends React.Component<
|
|
425
471
|
{ children: React.ReactNode },
|
|
@@ -430,6 +476,9 @@ export async function runRenderCheckInProcess(
|
|
|
430
476
|
return { caught: true };
|
|
431
477
|
}
|
|
432
478
|
componentDidCatch(error: Error, errorInfo: { componentStack?: string }): void {
|
|
479
|
+
// ggui#1398: every catch is counted, so the pending phase can tell a
|
|
480
|
+
// crash while pending from a control that simply went away.
|
|
481
|
+
boundaryRef.catches += 1;
|
|
433
482
|
if (errorInfo.componentStack && !boundaryRef.stack) {
|
|
434
483
|
boundaryRef.stack = errorInfo.componentStack;
|
|
435
484
|
}
|
|
@@ -443,6 +492,20 @@ export async function runRenderCheckInProcess(
|
|
|
443
492
|
}
|
|
444
493
|
}
|
|
445
494
|
|
|
495
|
+
// The card as the runtime mounts it: inside the wire, the probe's error
|
|
496
|
+
// boundary and the pending context. The main render and every action
|
|
497
|
+
// walk's mount (ggui#1652) are this same tree.
|
|
498
|
+
const cardTree = (): React.ReactElement =>
|
|
499
|
+
React.createElement(GguiWireProvider, {
|
|
500
|
+
config: wireConfig,
|
|
501
|
+
children: React.createElement(ProbeErrorBoundary, {
|
|
502
|
+
children: React.createElement(ActionPendingContext.Provider, {
|
|
503
|
+
value: pendingSource,
|
|
504
|
+
children: React.createElement(Component, input.mockupProps),
|
|
505
|
+
}),
|
|
506
|
+
}),
|
|
507
|
+
});
|
|
508
|
+
|
|
446
509
|
let renderResult: Awaited<ReturnType<typeof render>>;
|
|
447
510
|
const RENDER_TIMEOUT_MS = 5000;
|
|
448
511
|
try {
|
|
@@ -450,16 +513,7 @@ export async function runRenderCheckInProcess(
|
|
|
450
513
|
// Synchronous hangs would block the event loop and the timer never
|
|
451
514
|
// fires, but happy-dom effects are mostly microtask-scheduled so the
|
|
452
515
|
// timeout is actually effective for ~95% of the hang cases.
|
|
453
|
-
const renderPromise = Promise.resolve().then(() =>
|
|
454
|
-
render(
|
|
455
|
-
React.createElement(GguiWireProvider, {
|
|
456
|
-
config: wireConfig,
|
|
457
|
-
children: React.createElement(ProbeErrorBoundary, {
|
|
458
|
-
children: React.createElement(Component, input.mockupProps),
|
|
459
|
-
}),
|
|
460
|
-
})
|
|
461
|
-
)
|
|
462
|
-
);
|
|
516
|
+
const renderPromise = Promise.resolve().then(() => render(cardTree()));
|
|
463
517
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
464
518
|
setTimeout(
|
|
465
519
|
() =>
|
|
@@ -559,16 +613,25 @@ export async function runRenderCheckInProcess(
|
|
|
559
613
|
|
|
560
614
|
let actionsChecked = 0;
|
|
561
615
|
let streamsChecked = 0;
|
|
616
|
+
// ggui#1398 leg 3 — the pending-affordance tally, report-only. Set only
|
|
617
|
+
// when the action walk runs, so an absent field means "no walk".
|
|
618
|
+
let pendingTally: PendingTally | undefined;
|
|
619
|
+
// The controls whose click really dispatched their action, looked at in
|
|
620
|
+
// the pending phase after every other check (see runPendingPhase).
|
|
621
|
+
const pendingTargets: PendingTarget[] = [];
|
|
622
|
+
// The text of every screen the action walks reached (ggui#1652): a prop
|
|
623
|
+
// shown on a later step is shown, though the action check never moves
|
|
624
|
+
// the main render off its first screen.
|
|
625
|
+
const walkedTexts: string[] = [];
|
|
562
626
|
|
|
563
627
|
try {
|
|
564
628
|
// ggui#1187: prime form inputs before the action-wiring probe. A wired
|
|
565
629
|
// Send/Submit gated on a non-empty input (the chat-interface shape) is a
|
|
566
630
|
// no-op on empty, so an un-primed synthetic click never dispatches and
|
|
567
631
|
// the probe false-warns "did not dispatch it". Shared with the visual
|
|
568
|
-
// tester's primeInputs (#1021/#1040) so both prime identically.
|
|
569
|
-
//
|
|
570
|
-
//
|
|
571
|
-
// prop-sensitivity text baseline nor false-positives the wiring probe.
|
|
632
|
+
// tester's primeInputs (#1021/#1040) so both prime identically. The
|
|
633
|
+
// main render is primed here, on every card, before any check reads it;
|
|
634
|
+
// each action walk's mount (ggui#1652) primes every screen it reaches.
|
|
572
635
|
// A priming failure never aborts the check — it is RECORDED on every
|
|
573
636
|
// action-wiring issue as `diagnostics.inputPriming`. (The first cut
|
|
574
637
|
// swallowed it, and a `ReferenceError` in the check worker's realm —
|
|
@@ -581,6 +644,10 @@ export async function runRenderCheckInProcess(
|
|
|
581
644
|
inputPriming = { error: err instanceof Error ? err.message : String(err) };
|
|
582
645
|
}
|
|
583
646
|
// ── Check 2: Action wiring (BLOCK / unverified-as-warn) ───────────
|
|
647
|
+
// ggui#1652: each action is looked for in a mount of its own, walked
|
|
648
|
+
// screen by screen (walk.ts), so this check never presses the main
|
|
649
|
+
// render the later checks read. (The duplicate-label check below, #601,
|
|
650
|
+
// still makes its two clicks there, as it always did.)
|
|
584
651
|
if (input.contract?.actionSpec) {
|
|
585
652
|
const actionSpec = input.contract.actionSpec;
|
|
586
653
|
for (const [name, entry] of Object.entries(actionSpec)) {
|
|
@@ -590,15 +657,47 @@ export async function runRenderCheckInProcess(
|
|
|
590
657
|
hookName: "useAction",
|
|
591
658
|
hookArg: name,
|
|
592
659
|
});
|
|
660
|
+
// A priming failure on a screen the walk reached is recorded too,
|
|
661
|
+
// when the main render's priming did not already fail.
|
|
662
|
+
const walkPriming: { error?: string } = {};
|
|
663
|
+
const mount = async (): Promise<WalkMount | undefined> => {
|
|
664
|
+
let mounted: ReturnType<typeof render>;
|
|
665
|
+
try {
|
|
666
|
+
mounted = render(cardTree());
|
|
667
|
+
} catch {
|
|
668
|
+
return undefined;
|
|
669
|
+
}
|
|
670
|
+
await flushPromises();
|
|
671
|
+
const root = mounted.container;
|
|
672
|
+
return {
|
|
673
|
+
container: toMinimalElement(root),
|
|
674
|
+
prime: () => {
|
|
675
|
+
try {
|
|
676
|
+
primeInputs(root);
|
|
677
|
+
} catch (err) {
|
|
678
|
+
walkPriming.error ??= err instanceof Error ? err.message : String(err);
|
|
679
|
+
}
|
|
680
|
+
},
|
|
681
|
+
};
|
|
682
|
+
};
|
|
593
683
|
const issue = await checkActionWiring({
|
|
594
|
-
|
|
684
|
+
mount,
|
|
595
685
|
actionName: name,
|
|
596
686
|
actionLabel: entry.label,
|
|
597
687
|
wiring,
|
|
598
688
|
probe,
|
|
599
689
|
user,
|
|
690
|
+
pendingTargets,
|
|
691
|
+
stopAt: walkStopAt(input.deadlineAt),
|
|
692
|
+
walkedTexts,
|
|
600
693
|
});
|
|
601
|
-
if (issue)
|
|
694
|
+
if (issue) {
|
|
695
|
+
const priming: InputPrimingDiagnostic =
|
|
696
|
+
"primed" in inputPriming && walkPriming.error !== undefined
|
|
697
|
+
? { error: `on a screen the action walk reached: ${walkPriming.error}` }
|
|
698
|
+
: inputPriming;
|
|
699
|
+
issues.push({ ...issue, diagnostics: { ...issue.diagnostics, inputPriming: priming } });
|
|
700
|
+
}
|
|
602
701
|
}
|
|
603
702
|
}
|
|
604
703
|
|
|
@@ -650,6 +749,7 @@ export async function runRenderCheckInProcess(
|
|
|
650
749
|
if (input.contract?.propsSpec) {
|
|
651
750
|
for (const issue of checkPropCoverage({
|
|
652
751
|
container,
|
|
752
|
+
walkedTexts,
|
|
653
753
|
propsSpec: input.contract.propsSpec as PropsSpec,
|
|
654
754
|
mockupProps: input.mockupProps,
|
|
655
755
|
})) {
|
|
@@ -801,11 +901,27 @@ export async function runRenderCheckInProcess(
|
|
|
801
901
|
}
|
|
802
902
|
}
|
|
803
903
|
}
|
|
904
|
+
|
|
905
|
+
// ── Pending phase (ggui#1398, report-only) ────────────────────────
|
|
906
|
+
// LAST, while the main render is still mounted: every check above has
|
|
907
|
+
// already read the DOM, so a card that crashes or resets while pending
|
|
908
|
+
// cannot change what they found.
|
|
909
|
+
const timeLeft = input.deadlineAt === undefined ? Number.POSITIVE_INFINITY : input.deadlineAt - Date.now();
|
|
910
|
+
if (input.contract?.actionSpec && timeLeft >= PENDING_PHASE_RESERVE_MS) {
|
|
911
|
+
pendingTally = await runPendingPhase({
|
|
912
|
+
targets: pendingTargets,
|
|
913
|
+
source: pendingSource,
|
|
914
|
+
act,
|
|
915
|
+
catches: () => boundaryRef.catches,
|
|
916
|
+
actions: Object.entries(input.contract.actionSpec).map(([name, entry]) => ({ name, label: entry.label })),
|
|
917
|
+
...(input.deadlineAt !== undefined ? { deadlineAt: input.deadlineAt } : {}),
|
|
918
|
+
});
|
|
919
|
+
}
|
|
804
920
|
} finally {
|
|
805
921
|
cleanup();
|
|
806
922
|
}
|
|
807
923
|
|
|
808
|
-
return finalize(issues, t0, actionsChecked, streamsChecked);
|
|
924
|
+
return finalize(issues, t0, actionsChecked, streamsChecked, pendingTally);
|
|
809
925
|
} finally {
|
|
810
926
|
try {
|
|
811
927
|
uninstallSpy?.();
|
|
@@ -1169,7 +1285,8 @@ function finalize(
|
|
|
1169
1285
|
issues: RenderCheckIssue[],
|
|
1170
1286
|
t0: number,
|
|
1171
1287
|
actionsChecked: number,
|
|
1172
|
-
streamsChecked: number
|
|
1288
|
+
streamsChecked: number,
|
|
1289
|
+
pendingTally?: PendingTally
|
|
1173
1290
|
): RenderCheckResult {
|
|
1174
1291
|
const ok = !issues.some((i) => i.outcome === "failed");
|
|
1175
1292
|
return {
|
|
@@ -1179,25 +1296,191 @@ function finalize(
|
|
|
1179
1296
|
actionsChecked,
|
|
1180
1297
|
streamsChecked,
|
|
1181
1298
|
renderMs: Date.now() - t0,
|
|
1299
|
+
...(pendingTally !== undefined
|
|
1300
|
+
? {
|
|
1301
|
+
pendingAffordance: {
|
|
1302
|
+
dispatched: pendingTally.dispatched,
|
|
1303
|
+
visible: pendingTally.visible,
|
|
1304
|
+
missing: [...pendingTally.missing],
|
|
1305
|
+
gone: [...pendingTally.gone],
|
|
1306
|
+
},
|
|
1307
|
+
}
|
|
1308
|
+
: {}),
|
|
1182
1309
|
},
|
|
1183
1310
|
};
|
|
1184
1311
|
}
|
|
1185
1312
|
|
|
1313
|
+
/** The walk's running tally (ggui#1398); frozen into `stats.pendingAffordance` by {@link finalize}. */
|
|
1314
|
+
interface PendingTally {
|
|
1315
|
+
dispatched: number;
|
|
1316
|
+
visible: number;
|
|
1317
|
+
missing: string[];
|
|
1318
|
+
gone: string[];
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
/** A control whose click really dispatched its action (ggui#1398). */
|
|
1322
|
+
interface PendingTarget {
|
|
1323
|
+
readonly actionName: string;
|
|
1324
|
+
readonly actionLabel: string;
|
|
1325
|
+
readonly wiringKind: "click" | "submit";
|
|
1326
|
+
readonly element: MinimalElement;
|
|
1327
|
+
/** The mount the action's walk pressed it in (ggui#1652), where it is looked for again. */
|
|
1328
|
+
readonly container: MinimalElement;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
interface PendingPhaseInput {
|
|
1332
|
+
readonly targets: readonly PendingTarget[];
|
|
1333
|
+
readonly source: ProbePendingSource;
|
|
1334
|
+
/** React Testing Library's `act`, so the pending re-render commits before the control is read. */
|
|
1335
|
+
readonly act: (callback: () => Promise<void>) => Promise<unknown>;
|
|
1336
|
+
/** How many throws the probe's error boundary has caught so far. */
|
|
1337
|
+
readonly catches: () => number;
|
|
1338
|
+
/** Every action the contract declares: a control that now names another one is no longer this action's control. */
|
|
1339
|
+
readonly actions: readonly { readonly name: string; readonly label: string }[];
|
|
1340
|
+
/** The check's deadline (epoch ms), when the isolating host set one. */
|
|
1341
|
+
readonly deadlineAt?: number;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/** The longest the phase waits on one control's pending mark and read (ggui#1398). */
|
|
1345
|
+
export const PENDING_TARGET_CAP_MS = 2000;
|
|
1346
|
+
/** The time the phase always leaves before the check's deadline. */
|
|
1347
|
+
const PENDING_PHASE_TAIL_MS = 1000;
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* For each control whose click dispatched: mark its action pending (the
|
|
1351
|
+
* runtime does this when the dispatch commits), compare the control with
|
|
1352
|
+
* its look just before, then clear it (the agent's answer). Visible means
|
|
1353
|
+
* the pending state itself changed the control.
|
|
1354
|
+
*
|
|
1355
|
+
* - The pressed element stays the action's control while it is in the page,
|
|
1356
|
+
* whatever it now says ("Add to Cart" may relabel itself "Added"), unless
|
|
1357
|
+
* it now names another of the contract's actions. Only a control that left
|
|
1358
|
+
* the page is looked for again, by its own action identity (label,
|
|
1359
|
+
* aria-label, `data-action`); if none is found it is `gone`.
|
|
1360
|
+
* - A form target (submit wiring) is read through its control that names the
|
|
1361
|
+
* action, else its first submit-type control, else the form itself.
|
|
1362
|
+
* - A card that throws while pending counts that control as missing, and
|
|
1363
|
+
* every control after it is gone (the card renders nothing).
|
|
1364
|
+
* - Each mark-and-read is capped ({@link PENDING_TARGET_CAP_MS}, and never
|
|
1365
|
+
* past the check's deadline less a tail). A control whose read does not
|
|
1366
|
+
* settle in time counts as missing and ends the phase, so a stalled card
|
|
1367
|
+
* cannot push the check past its bound. A synchronous busy loop cannot be
|
|
1368
|
+
* cut short this way; React turns runaway update loops into throws.
|
|
1369
|
+
*/
|
|
1370
|
+
async function runPendingPhase(input: PendingPhaseInput): Promise<PendingTally> {
|
|
1371
|
+
const tally: PendingTally = { dispatched: 0, visible: 0, missing: [], gone: [] };
|
|
1372
|
+
let stopped = false;
|
|
1373
|
+
for (const target of input.targets) {
|
|
1374
|
+
const budget = Math.min(
|
|
1375
|
+
PENDING_TARGET_CAP_MS,
|
|
1376
|
+
input.deadlineAt === undefined ? Number.POSITIVE_INFINITY : input.deadlineAt - Date.now() - PENDING_PHASE_TAIL_MS
|
|
1377
|
+
);
|
|
1378
|
+
const found = stopped || budget <= 0 ? undefined : pendingControlOf(target, input.actions);
|
|
1379
|
+
if (found === undefined) {
|
|
1380
|
+
tally.gone.push(target.actionName);
|
|
1381
|
+
if (budget <= 0) stopped = true;
|
|
1382
|
+
continue;
|
|
1383
|
+
}
|
|
1384
|
+
const el = controlToRead(found, target);
|
|
1385
|
+
const before = lookOf(el);
|
|
1386
|
+
const caughtBefore = input.catches();
|
|
1387
|
+
const settled = await settlesWithin(
|
|
1388
|
+
budget,
|
|
1389
|
+
(async () => {
|
|
1390
|
+
await input.act(async () => {
|
|
1391
|
+
input.source.mark(target.actionName);
|
|
1392
|
+
});
|
|
1393
|
+
await flushPromises();
|
|
1394
|
+
})()
|
|
1395
|
+
);
|
|
1396
|
+
const crashed = input.catches() !== caughtBefore;
|
|
1397
|
+
tally.dispatched += 1;
|
|
1398
|
+
if (settled && !crashed && lookChanged(before, lookOf(el))) tally.visible += 1;
|
|
1399
|
+
else tally.missing.push(target.actionName);
|
|
1400
|
+
if (!settled) {
|
|
1401
|
+
stopped = true;
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
await input.act(async () => {
|
|
1405
|
+
input.source.clear();
|
|
1406
|
+
});
|
|
1407
|
+
await flushPromises();
|
|
1408
|
+
if (crashed) stopped = true;
|
|
1409
|
+
}
|
|
1410
|
+
return tally;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
/**
|
|
1414
|
+
* The action's control at the phase's turn: the pressed element while it is
|
|
1415
|
+
* in the page and does not now name another of the contract's actions;
|
|
1416
|
+
* otherwise, for click wiring, the action's control found again by its own
|
|
1417
|
+
* identity only. A submit target (a form) has no identity to look for.
|
|
1418
|
+
*/
|
|
1419
|
+
function pendingControlOf(
|
|
1420
|
+
target: PendingTarget,
|
|
1421
|
+
actions: readonly { readonly name: string; readonly label: string }[]
|
|
1422
|
+
): MinimalElement | undefined {
|
|
1423
|
+
const movedToAnother =
|
|
1424
|
+
!matchesActionIdentity(target.element, target.actionName, target.actionLabel) &&
|
|
1425
|
+
actions.some((a) => a.name !== target.actionName && matchesActionIdentity(target.element, a.name, a.label));
|
|
1426
|
+
if (lookOf(target.element).connected && !movedToAnother) return target.element;
|
|
1427
|
+
if (target.wiringKind !== "click") return undefined;
|
|
1428
|
+
return findActionElements(target.container, target.actionName, target.actionLabel, { identityOnly: true })[0];
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* The control a person sees for a target. A form is read through its
|
|
1433
|
+
* clickable that names the action (generated forms often submit through a
|
|
1434
|
+
* `type="button"` Button), else its first submit-type control, else itself.
|
|
1435
|
+
*/
|
|
1436
|
+
function controlToRead(el: MinimalElement, target: PendingTarget): MinimalElement {
|
|
1437
|
+
if (el.tagName.toLowerCase() !== "form") return el;
|
|
1438
|
+
for (const c of el.querySelectorAll<MinimalElement>('button, input[type="submit"], input[type="button"], [role="button"]')) {
|
|
1439
|
+
if (matchesActionIdentity(c, target.actionName, target.actionLabel)) return c;
|
|
1440
|
+
}
|
|
1441
|
+
for (const submitter of el.querySelectorAll<MinimalElement>(
|
|
1442
|
+
'button[type="submit"], input[type="submit"], button:not([type])'
|
|
1443
|
+
)) {
|
|
1444
|
+
return submitter;
|
|
1445
|
+
}
|
|
1446
|
+
return el;
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1186
1449
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1187
1450
|
// Check implementations
|
|
1188
1451
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1189
1452
|
|
|
1190
1453
|
interface CheckActionInput {
|
|
1191
|
-
|
|
1454
|
+
/** Mount a fresh copy of the card for this action's walk (ggui#1652). */
|
|
1455
|
+
mount: () => Promise<WalkMount | undefined>;
|
|
1192
1456
|
actionName: string;
|
|
1193
1457
|
actionLabel: string;
|
|
1194
1458
|
wiring: WiringDetection;
|
|
1195
1459
|
probe: Probe;
|
|
1196
1460
|
user: { click: (el: MinimalElement) => Promise<void> };
|
|
1461
|
+
/** ggui#1398: where a control whose press dispatched is recorded, for the pending phase. */
|
|
1462
|
+
pendingTargets?: PendingTarget[];
|
|
1463
|
+
/** Epoch ms after which the walk stops, once a press has left the first screen. */
|
|
1464
|
+
stopAt: number;
|
|
1465
|
+
/** Where the text of every screen the walk reaches is collected, for prop coverage (ggui#1652). */
|
|
1466
|
+
walkedTexts: string[];
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
/** When the walk stops, once a press has left the first screen (ggui#1652). */
|
|
1470
|
+
function walkStopAt(deadlineAt: number | undefined): number {
|
|
1471
|
+
const cap = Date.now() + WALK_ACTION_CAP_MS;
|
|
1472
|
+
return deadlineAt === undefined ? cap : Math.min(cap, deadlineAt - PENDING_PHASE_RESERVE_MS - WALK_DEADLINE_MARGIN_MS);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
type WiredKind = "click" | "submit" | "change" | "keyboard-enter";
|
|
1476
|
+
|
|
1477
|
+
/** A control the walk may press, and the trigger it is pressed with. */
|
|
1478
|
+
interface ActionControl extends WalkControl {
|
|
1479
|
+
readonly how: WiredKind;
|
|
1197
1480
|
}
|
|
1198
1481
|
|
|
1199
1482
|
async function checkActionWiring(input: CheckActionInput): Promise<RenderCheckIssue | null> {
|
|
1200
|
-
const {
|
|
1483
|
+
const { actionName, actionLabel, wiring, probe, user } = input;
|
|
1201
1484
|
|
|
1202
1485
|
const baseDiagnostics = {
|
|
1203
1486
|
observedJsxElements: wiring.observedJsxElements,
|
|
@@ -1231,32 +1514,73 @@ async function checkActionWiring(input: CheckActionInput): Promise<RenderCheckIs
|
|
|
1231
1514
|
};
|
|
1232
1515
|
}
|
|
1233
1516
|
|
|
1234
|
-
// Source-detected deterministic wiring →
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1517
|
+
// Source-detected deterministic wiring → walk the card's screens, pressing
|
|
1518
|
+
// the matching trigger, until one press dispatches the action (ggui#1652).
|
|
1519
|
+
const kind = wiring.kind;
|
|
1520
|
+
const walk = await walkToAction<ActionControl>({
|
|
1521
|
+
mount: input.mount,
|
|
1522
|
+
controls: (container) => actionControls(container, kind, actionName, actionLabel),
|
|
1523
|
+
press: async (control) => {
|
|
1524
|
+
const before = probe.getFireLog().length;
|
|
1525
|
+
try {
|
|
1526
|
+
await dispatchTrigger(control.element, control.how, user);
|
|
1527
|
+
} catch {
|
|
1528
|
+
return false;
|
|
1529
|
+
}
|
|
1530
|
+
await flushPromises();
|
|
1531
|
+
return probe
|
|
1532
|
+
.getFireLog()
|
|
1533
|
+
.slice(before)
|
|
1534
|
+
.some((e) => e.kind === "action.fired" && e.name === actionName);
|
|
1535
|
+
},
|
|
1536
|
+
describe: describeElement,
|
|
1537
|
+
stopAt: input.stopAt,
|
|
1538
|
+
maxPresses: WALK_MAX_PRESSES,
|
|
1243
1539
|
});
|
|
1540
|
+
input.walkedTexts.push(...walk.screenTexts);
|
|
1541
|
+
|
|
1542
|
+
if (walk.fired) {
|
|
1543
|
+
// ggui#1398: record the control for the pending phase; nothing is marked here.
|
|
1544
|
+
if (input.pendingTargets && walk.element && walk.container && (kind === "click" || kind === "submit")) {
|
|
1545
|
+
input.pendingTargets.push({
|
|
1546
|
+
actionName,
|
|
1547
|
+
actionLabel,
|
|
1548
|
+
wiringKind: kind,
|
|
1549
|
+
element: walk.element,
|
|
1550
|
+
container: walk.container,
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
return null; // verified — no issue
|
|
1554
|
+
}
|
|
1244
1555
|
|
|
1245
|
-
|
|
1556
|
+
// Capture what DID fire — useful diagnostic.
|
|
1557
|
+
const allFired = probe
|
|
1558
|
+
.getFireLog()
|
|
1559
|
+
.filter((e) => e.kind === "action.fired")
|
|
1560
|
+
.map((e) => e.name);
|
|
1561
|
+
const otherActionsFired = Array.from(new Set(allFired)).filter((n) => n !== actionName);
|
|
1562
|
+
const stoppedBy = walk.stoppedBy === "dispatched" ? "explored" : walk.stoppedBy;
|
|
1246
1563
|
|
|
1247
|
-
// The AST already confirmed the wiring exists in source. The
|
|
1248
|
-
//
|
|
1564
|
+
// The AST already confirmed the wiring exists in source. The walk
|
|
1565
|
+
// couldn't trigger it — most likely a conditional-render or
|
|
1249
1566
|
// required-form-fill case. Be conservative: downgrade to unverified
|
|
1250
1567
|
// rather than reporting a fake failure.
|
|
1251
1568
|
return {
|
|
1252
1569
|
check: "action-wiring",
|
|
1253
1570
|
outcome: "unverified",
|
|
1254
1571
|
subject: actionName,
|
|
1255
|
-
reason: `Source confirms ${
|
|
1256
|
-
elementHint:
|
|
1572
|
+
reason: `Source confirms ${kind} wiring exists for action '${actionName}', but synthetic ${kind} did not dispatch it. Likely cause: conditional rendering, required input/form fill, or a multi-step interaction the static probe cannot complete.`,
|
|
1573
|
+
...(walk.presses === 0 && stoppedBy === "explored" ? { elementHint: `No ${kind}-eligible element found in DOM` } : {}),
|
|
1257
1574
|
diagnostics: {
|
|
1258
1575
|
...baseDiagnostics,
|
|
1259
|
-
actionsFiredFromClicks:
|
|
1576
|
+
actionsFiredFromClicks: otherActionsFired,
|
|
1577
|
+
walk: {
|
|
1578
|
+
presses: walk.presses,
|
|
1579
|
+
screens: walk.screens,
|
|
1580
|
+
stoppedBy,
|
|
1581
|
+
namedControlSeen: walk.namedControlSeen,
|
|
1582
|
+
lastPressed: walk.lastPressed,
|
|
1583
|
+
},
|
|
1260
1584
|
},
|
|
1261
1585
|
};
|
|
1262
1586
|
}
|
|
@@ -1382,60 +1706,9 @@ async function checkSelectionIdentity(
|
|
|
1382
1706
|
// Trigger simulators — narrow set, deterministic only
|
|
1383
1707
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1384
1708
|
|
|
1385
|
-
interface SimulateAndCheckInput {
|
|
1386
|
-
container: MinimalElement;
|
|
1387
|
-
user: { click: (el: MinimalElement) => Promise<void> };
|
|
1388
|
-
probe: Probe;
|
|
1389
|
-
wiringKind: "click" | "submit" | "change" | "keyboard-enter";
|
|
1390
|
-
actionName: string;
|
|
1391
|
-
actionLabel: string;
|
|
1392
|
-
eventKind: "action.fired" | "wiredTool.called";
|
|
1393
|
-
}
|
|
1394
|
-
|
|
1395
|
-
interface SimulateResult {
|
|
1396
|
-
fired: boolean;
|
|
1397
|
-
attemptedHint?: string;
|
|
1398
|
-
otherActionsFired?: string[];
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
async function simulateAndCheck(input: SimulateAndCheckInput): Promise<SimulateResult> {
|
|
1402
|
-
const { container, user, probe, wiringKind, actionName, actionLabel, eventKind } = input;
|
|
1403
|
-
|
|
1404
|
-
const candidates = findCandidateElements(container, wiringKind, actionName, actionLabel);
|
|
1405
|
-
if (candidates.length === 0) {
|
|
1406
|
-
return { fired: false, attemptedHint: `No ${wiringKind}-eligible element found in DOM` };
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
for (const el of candidates) {
|
|
1410
|
-
const before = probe.getFireLog().length;
|
|
1411
|
-
try {
|
|
1412
|
-
await dispatchTrigger(el, wiringKind, user);
|
|
1413
|
-
} catch {
|
|
1414
|
-
continue;
|
|
1415
|
-
}
|
|
1416
|
-
await flushPromises();
|
|
1417
|
-
const newEvents = probe.getFireLog().slice(before);
|
|
1418
|
-
const matched = newEvents.some((e) => e.kind === eventKind && e.name === actionName);
|
|
1419
|
-
if (matched) return { fired: true };
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
// Capture what DID fire — useful diagnostic.
|
|
1423
|
-
const allFired = probe
|
|
1424
|
-
.getFireLog()
|
|
1425
|
-
.filter((e) => e.kind === "action.fired")
|
|
1426
|
-
.map((e) => e.name);
|
|
1427
|
-
const otherActionsFired = Array.from(new Set(allFired)).filter((n) => n !== actionName);
|
|
1428
|
-
|
|
1429
|
-
return {
|
|
1430
|
-
fired: false,
|
|
1431
|
-
attemptedHint: candidates.length ? describeElement(candidates[0]!) : undefined,
|
|
1432
|
-
otherActionsFired,
|
|
1433
|
-
};
|
|
1434
|
-
}
|
|
1435
|
-
|
|
1436
1709
|
async function dispatchTrigger(
|
|
1437
1710
|
el: MinimalElement,
|
|
1438
|
-
kind:
|
|
1711
|
+
kind: WiredKind,
|
|
1439
1712
|
user: { click: (el: MinimalElement) => Promise<void> }
|
|
1440
1713
|
): Promise<void> {
|
|
1441
1714
|
switch (kind) {
|
|
@@ -1560,41 +1833,44 @@ function closestForm(el: MinimalElement): MinimalElement | null {
|
|
|
1560
1833
|
return null;
|
|
1561
1834
|
}
|
|
1562
1835
|
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
);
|
|
1577
|
-
return Array.from(all);
|
|
1578
|
-
}
|
|
1579
|
-
case "change": {
|
|
1580
|
-
const all = container.querySelectorAll<MinimalElement>("select, input, textarea");
|
|
1581
|
-
return Array.from(all);
|
|
1582
|
-
}
|
|
1583
|
-
case "keyboard-enter":
|
|
1584
|
-
return findActionElements(container, name, label);
|
|
1836
|
+
/**
|
|
1837
|
+
* The controls an action's walk may press on the current screen, in the
|
|
1838
|
+
* order they are tried (ggui#1652). Click and Enter wiring: the controls
|
|
1839
|
+
* that name the action, then every other clickable, each pressed the way the
|
|
1840
|
+
* action is wired. Submit and change wiring: the eligible elements, pressed
|
|
1841
|
+
* with that trigger, then every clickable, pressed with a click, so the walk
|
|
1842
|
+
* can reach a screen where the eligible element appears.
|
|
1843
|
+
*/
|
|
1844
|
+
function actionControls(container: MinimalElement, kind: WiredKind, name: string, label: string): ActionControl[] {
|
|
1845
|
+
const clickables = findActionElements(container, name, label);
|
|
1846
|
+
const named = (el: MinimalElement): boolean => matchesActionIdentity(el, name, label);
|
|
1847
|
+
if (kind === "click" || kind === "keyboard-enter") {
|
|
1848
|
+
return clickables.map((element) => ({ element, namesAction: named(element), how: kind }));
|
|
1585
1849
|
}
|
|
1850
|
+
const eligible = Array.from(
|
|
1851
|
+
container.querySelectorAll<MinimalElement>(
|
|
1852
|
+
kind === "submit" ? 'form, button[type="submit"], input[type="submit"]' : "select, input, textarea"
|
|
1853
|
+
)
|
|
1854
|
+
);
|
|
1855
|
+
const seen = new Set(eligible);
|
|
1856
|
+
return [
|
|
1857
|
+
...eligible.map((element) => ({ element, namesAction: named(element), how: kind })),
|
|
1858
|
+
...clickables.filter((el) => !seen.has(el)).map((element) => ({ element, namesAction: named(element), how: "click" as const })),
|
|
1859
|
+
];
|
|
1586
1860
|
}
|
|
1587
1861
|
|
|
1588
1862
|
interface CheckPropCoverageInput {
|
|
1589
1863
|
container: MinimalElement;
|
|
1864
|
+
/** The text of the screens the action walks reached (ggui#1652). */
|
|
1865
|
+
walkedTexts: readonly string[];
|
|
1590
1866
|
propsSpec: PropsSpec;
|
|
1591
1867
|
mockupProps: JsonObject;
|
|
1592
1868
|
}
|
|
1593
1869
|
|
|
1594
1870
|
function checkPropCoverage(input: CheckPropCoverageInput): RenderCheckIssue[] {
|
|
1595
|
-
const { container, propsSpec, mockupProps } = input;
|
|
1871
|
+
const { container, walkedTexts, propsSpec, mockupProps } = input;
|
|
1596
1872
|
const issues: RenderCheckIssue[] = [];
|
|
1597
|
-
const
|
|
1873
|
+
const texts = [container.textContent ?? "", ...walkedTexts];
|
|
1598
1874
|
|
|
1599
1875
|
for (const [propName, entry] of Object.entries(propsSpec.properties)) {
|
|
1600
1876
|
if (!entry.required) continue;
|
|
@@ -1605,12 +1881,15 @@ function checkPropCoverage(input: CheckPropCoverageInput): RenderCheckIssue[] {
|
|
|
1605
1881
|
const marker = pickScalarMarker(value);
|
|
1606
1882
|
if (marker === null) continue;
|
|
1607
1883
|
|
|
1608
|
-
if (!text.includes(marker)) {
|
|
1884
|
+
if (!texts.some((text) => text.includes(marker))) {
|
|
1885
|
+
// Name the walked screens only where a walk ran (a card with actions);
|
|
1886
|
+
// a card without one reads exactly as it did before the walk.
|
|
1887
|
+
const where = walkedTexts.length > 0 ? ", on the first screen or any screen the action walk reached" : "";
|
|
1609
1888
|
issues.push({
|
|
1610
1889
|
check: "prop-coverage",
|
|
1611
1890
|
outcome: "unverified",
|
|
1612
1891
|
subject: propName,
|
|
1613
|
-
reason: `Required prop '${propName}' value (${JSON.stringify(marker).slice(0, 60)}) not visible in rendered DOM`,
|
|
1892
|
+
reason: `Required prop '${propName}' value (${JSON.stringify(marker).slice(0, 60)}) not visible in rendered DOM${where}`,
|
|
1614
1893
|
});
|
|
1615
1894
|
}
|
|
1616
1895
|
}
|
|
@@ -1676,35 +1955,38 @@ async function checkStreamRerender(
|
|
|
1676
1955
|
// Helpers
|
|
1677
1956
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1678
1957
|
|
|
1958
|
+
/** Whether `el` names the action itself: its text, aria-label or `data-action` carries the action's name or label. */
|
|
1959
|
+
function matchesActionIdentity(el: MinimalElement, name: string, label: string): boolean {
|
|
1960
|
+
const norm = (s: string) => s.toLowerCase().replace(/[\s_-]+/g, "");
|
|
1961
|
+
const aria = (el.getAttribute("aria-label") ?? "").toLowerCase();
|
|
1962
|
+
const txt = (el.textContent ?? "").toLowerCase();
|
|
1963
|
+
const dataAction = (el.getAttribute("data-action") ?? "").toLowerCase();
|
|
1964
|
+
return (
|
|
1965
|
+
aria.includes(label.toLowerCase()) ||
|
|
1966
|
+
txt.includes(label.toLowerCase()) ||
|
|
1967
|
+
norm(aria).includes(norm(name)) ||
|
|
1968
|
+
norm(txt).includes(norm(name)) ||
|
|
1969
|
+
dataAction === name.toLowerCase() ||
|
|
1970
|
+
norm(txt).includes(norm(label))
|
|
1971
|
+
);
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1679
1974
|
function findActionElements(
|
|
1680
1975
|
container: MinimalElement,
|
|
1681
1976
|
name: string,
|
|
1682
|
-
label: string
|
|
1977
|
+
label: string,
|
|
1978
|
+
options: { readonly identityOnly?: boolean } = {}
|
|
1683
1979
|
): MinimalElement[] {
|
|
1684
1980
|
const candidates: MinimalElement[] = [];
|
|
1685
1981
|
const seen = new Set<MinimalElement>();
|
|
1686
1982
|
|
|
1687
|
-
const norm = (s: string) => s.toLowerCase().replace(/[\s_-]+/g, "");
|
|
1688
|
-
const nameKey = norm(name);
|
|
1689
|
-
const labelKey = norm(label);
|
|
1690
|
-
|
|
1691
1983
|
const allClickable = container.querySelectorAll<MinimalElement>(
|
|
1692
1984
|
'button, [role="button"], input[type="submit"], input[type="button"], a[href]'
|
|
1693
1985
|
);
|
|
1694
1986
|
|
|
1695
1987
|
// Pass 1: text/aria-label/data-action match
|
|
1696
1988
|
for (const el of allClickable) {
|
|
1697
|
-
|
|
1698
|
-
const txt = (el.textContent ?? "").toLowerCase();
|
|
1699
|
-
const dataAction = (el.getAttribute("data-action") ?? "").toLowerCase();
|
|
1700
|
-
if (
|
|
1701
|
-
aria.includes(label.toLowerCase()) ||
|
|
1702
|
-
txt.includes(label.toLowerCase()) ||
|
|
1703
|
-
norm(aria).includes(nameKey) ||
|
|
1704
|
-
norm(txt).includes(nameKey) ||
|
|
1705
|
-
dataAction === name.toLowerCase() ||
|
|
1706
|
-
norm(txt).includes(labelKey)
|
|
1707
|
-
) {
|
|
1989
|
+
if (matchesActionIdentity(el, name, label)) {
|
|
1708
1990
|
if (!seen.has(el)) {
|
|
1709
1991
|
seen.add(el);
|
|
1710
1992
|
candidates.push(el);
|
|
@@ -1712,7 +1994,9 @@ function findActionElements(
|
|
|
1712
1994
|
}
|
|
1713
1995
|
}
|
|
1714
1996
|
|
|
1715
|
-
// Pass 2: any clickable as fallback (we'll click them all)
|
|
1997
|
+
// Pass 2: any clickable as fallback (we'll click them all). Skipped when
|
|
1998
|
+
// only the action's own control may answer (the pending phase's re-find).
|
|
1999
|
+
if (options.identityOnly) return candidates;
|
|
1716
2000
|
for (const el of allClickable) {
|
|
1717
2001
|
if (!seen.has(el)) {
|
|
1718
2002
|
seen.add(el);
|