@ggui-ai/ui-gen 0.10.0 → 0.12.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/adapters/generation-dispatch.js +328 -24
- package/dist/adapters/generation-dispatch.js.map +1 -1
- package/dist/advanced/index.d.ts +1 -1
- package/dist/advanced/index.js +333 -29
- package/dist/advanced/index.js.map +1 -1
- package/dist/blueprint-validator.js +307 -3
- package/dist/blueprint-validator.js.map +1 -1
- package/dist/boilerplate.js +7 -7
- package/dist/boilerplate.js.map +1 -1
- package/dist/check/index.js +27 -11
- package/dist/check/index.js.map +1 -1
- package/dist/harness/check/runtime-render/index.d.ts +1 -1
- package/dist/harness/check/runtime-render/index.js +307 -3
- package/dist/harness/check/runtime-render/index.js.map +1 -1
- package/dist/harness/check/runtime-render/render-check-worker.d.ts +2 -0
- package/dist/harness/check/runtime-render/render-check-worker.js +1458 -0
- package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -0
- package/dist/harness/index.js +7 -7
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/runtime.js +7 -7
- package/dist/harness/runtime.js.map +1 -1
- package/dist/harness/types-public.js +7 -7
- package/dist/harness/types-public.js.map +1 -1
- package/dist/index.js +327 -23
- package/dist/index.js.map +1 -1
- package/dist/provider-adapter-contract.js +1161 -387
- package/dist/provider-adapter-contract.js.map +1 -1
- package/dist/{render-check-BNYGVDJd.d.ts → render-check-WfOVO9kU.d.ts} +21 -1
- package/package.json +12 -12
- package/src/boilerplate/system-prompt.ts +7 -7
- package/src/check/run-tier0.ts +35 -16
- package/src/harness/check/runtime-render/adapter.ts +28 -0
- package/src/harness/check/runtime-render/render-check-host.ts +201 -0
- package/src/harness/check/runtime-render/render-check-worker.ts +80 -0
- package/src/harness/check/runtime-render/render-check.ts +264 -1
|
@@ -21,7 +21,12 @@ import type {
|
|
|
21
21
|
PropsSpec,
|
|
22
22
|
} from "@ggui-ai/protocol";
|
|
23
23
|
import { HOOK_NAME_RE, listContractGadgets } from "@ggui-ai/protocol";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
createProbe,
|
|
26
|
+
createProbeWireConfig,
|
|
27
|
+
type ActionFiredEvent,
|
|
28
|
+
type Probe,
|
|
29
|
+
} from "./probe.js";
|
|
25
30
|
import { loadComponent } from "./load-component.js";
|
|
26
31
|
import { findWiring, type WiringDetection } from "./find-wiring.js";
|
|
27
32
|
import { installProductionActShim } from "./production-act-shim.js";
|
|
@@ -70,7 +75,9 @@ export interface RenderCheckIssue {
|
|
|
70
75
|
readonly check:
|
|
71
76
|
| "render-no-throw"
|
|
72
77
|
| "action-wiring"
|
|
78
|
+
| "selection-identity"
|
|
73
79
|
| "prop-coverage"
|
|
80
|
+
| "prop-sensitivity"
|
|
74
81
|
| "stream-rerender"
|
|
75
82
|
| "optional-props-omitted";
|
|
76
83
|
readonly outcome: CheckOutcome;
|
|
@@ -114,8 +121,48 @@ export interface RunRenderCheckInput {
|
|
|
114
121
|
// Public entry
|
|
115
122
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
116
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Run the in-loop runtime render check.
|
|
126
|
+
*
|
|
127
|
+
* Isolation dispatcher (#592): when the CALLING environment already
|
|
128
|
+
* owns a DOM (`window` + `document` on `globalThis` — e.g. a vitest
|
|
129
|
+
* `happy-dom` environment, where the environment owner installed the
|
|
130
|
+
* globals deliberately), the check runs in-process exactly as before.
|
|
131
|
+
* In a plain Node process it runs in an ISOLATED SUBPROCESS
|
|
132
|
+
* (`render-check-host.ts` → `render-check-worker.ts`), because the
|
|
133
|
+
* in-process path installs happy-dom globals on `globalThis`
|
|
134
|
+
* process-wide — a TOCTOU hazard for anything that sniffs `window`
|
|
135
|
+
* concurrently (provider SDKs throw "browser-like environment" at
|
|
136
|
+
* client construction; two overlapping checks corrupt each other's
|
|
137
|
+
* teardown). See #592 for the measured incident (4 bench cells hard-
|
|
138
|
+
* failed on the 2026-08-19 run).
|
|
139
|
+
*
|
|
140
|
+
* The host is loaded lazily so the worker bundle (which imports this
|
|
141
|
+
* module for `runRenderCheckInProcess`) never drags in the sandbox
|
|
142
|
+
* spawn machinery.
|
|
143
|
+
*/
|
|
117
144
|
export async function runRenderCheck(
|
|
118
145
|
input: RunRenderCheckInput,
|
|
146
|
+
): Promise<RenderCheckResult> {
|
|
147
|
+
const g = hostGlobals();
|
|
148
|
+
if ("window" in g && "document" in g) {
|
|
149
|
+
return runRenderCheckInProcess(input);
|
|
150
|
+
}
|
|
151
|
+
const { runRenderCheckViaWorker } = await import("./render-check-host.js");
|
|
152
|
+
return runRenderCheckViaWorker(input);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The actual check, executed IN THE CURRENT PROCESS — installs
|
|
157
|
+
* happy-dom globals on `globalThis` for the duration when the
|
|
158
|
+
* environment doesn't already provide a DOM. Call sites outside this
|
|
159
|
+
* module should use {@link runRenderCheck}; this export exists for the
|
|
160
|
+
* isolation worker (which IS the private process the globals may own)
|
|
161
|
+
* and for DOM-owning test environments that want to bypass the
|
|
162
|
+
* dispatcher explicitly.
|
|
163
|
+
*/
|
|
164
|
+
export async function runRenderCheckInProcess(
|
|
165
|
+
input: RunRenderCheckInput,
|
|
119
166
|
): Promise<RenderCheckResult> {
|
|
120
167
|
const t0 = Date.now();
|
|
121
168
|
const issues: RenderCheckIssue[] = [];
|
|
@@ -479,6 +526,35 @@ export async function runRenderCheck(
|
|
|
479
526
|
}
|
|
480
527
|
}
|
|
481
528
|
|
|
529
|
+
// ── Check 2b: Selection identity — duplicate-label cells (#601) ───
|
|
530
|
+
// The live bug: a slot grid rendered several cells with the same
|
|
531
|
+
// visible label ("09:00" across stylist rows); clicking one
|
|
532
|
+
// selected ALL of them because the generated code keyed
|
|
533
|
+
// selection/payload on the DISPLAY VALUE instead of the cell's
|
|
534
|
+
// identity — even though the action schema carried an id field.
|
|
535
|
+
// Wire-behavior check, DOM-shape independent: click two
|
|
536
|
+
// same-label clickables and require their fired payloads to
|
|
537
|
+
// differ on the id-like field. Conservative: any ambiguity
|
|
538
|
+
// (no duplicate labels, clicks that fire nothing, payloads
|
|
539
|
+
// missing the field) yields NO issue — those belong to other
|
|
540
|
+
// checks.
|
|
541
|
+
if (input.contract?.actionSpec) {
|
|
542
|
+
for (const [name, entry] of Object.entries(input.contract.actionSpec)) {
|
|
543
|
+
const idField = idLikeRequiredStringField(
|
|
544
|
+
entry.schema as Record<string, unknown> | undefined,
|
|
545
|
+
);
|
|
546
|
+
if (idField === null) continue;
|
|
547
|
+
const issue = await checkSelectionIdentity({
|
|
548
|
+
container,
|
|
549
|
+
probe,
|
|
550
|
+
user,
|
|
551
|
+
actionName: name,
|
|
552
|
+
idField,
|
|
553
|
+
});
|
|
554
|
+
if (issue) issues.push(issue);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
482
558
|
// ── Check 3: Agent-tool wiring — RETIRED ──────────────────────────
|
|
483
559
|
// `useWiredTool` is retired. The contract's `agentCapabilities.tools`
|
|
484
560
|
// is a catalog of tools the AGENT invokes — there is no component hook
|
|
@@ -505,6 +581,72 @@ export async function runRenderCheck(
|
|
|
505
581
|
}
|
|
506
582
|
}
|
|
507
583
|
|
|
584
|
+
// ── Check 5c: Prop sensitivity (#563) ─────────────────────────────
|
|
585
|
+
// The measured class (economy/001 run 6): generated code renders a
|
|
586
|
+
// value the contract delivers via props but BAKES the request's
|
|
587
|
+
// literal — `props.month ?? 'January'` date math, hardcoded display
|
|
588
|
+
// text — so a cached blueprint reused under a different value serves
|
|
589
|
+
// the wrong surface. Deterministic detection for the DISPLAYED-value
|
|
590
|
+
// half: re-render with a distinct value for each required scalar
|
|
591
|
+
// prop whose original value visibly appears in the DOM; if the
|
|
592
|
+
// rendered text is IDENTICAL under the change, the prop's value is
|
|
593
|
+
// dead — baked. Scope honesty: value-ANCHORED logic that still
|
|
594
|
+
// reacts (differently-wrong branches) is not deterministically
|
|
595
|
+
// decidable here; the matcher-side pair armor and the judge track
|
|
596
|
+
// that half. Conservative nulls: non-displayed props (ids are
|
|
597
|
+
// legitimately non-visual), varied-value crashes (the code READ the
|
|
598
|
+
// prop — not dead), and non-scalar props are all skipped.
|
|
599
|
+
if (input.contract?.propsSpec) {
|
|
600
|
+
const propsSpec5c = input.contract.propsSpec as PropsSpec;
|
|
601
|
+
const normalize = (s: string | null): string =>
|
|
602
|
+
(s ?? "").replace(/\s+/g, " ").trim();
|
|
603
|
+
const baseText = normalize(container.textContent);
|
|
604
|
+
for (const [propName, entry] of Object.entries(propsSpec5c.properties)) {
|
|
605
|
+
if (!entry.required) continue;
|
|
606
|
+
const orig = input.mockupProps[propName];
|
|
607
|
+
if (typeof orig !== "string" && typeof orig !== "number") continue;
|
|
608
|
+
const marker = pickScalarMarker(orig);
|
|
609
|
+
if (marker === null || !baseText.includes(marker)) continue;
|
|
610
|
+
// Distinct same-type value. A token that never substring-matches
|
|
611
|
+
// the original, so value-anchored branches (`includes(orig)`)
|
|
612
|
+
// genuinely flip.
|
|
613
|
+
const varied: string | number =
|
|
614
|
+
typeof orig === "string" ? "Zzyzx Qw" : (orig as number) + 137731;
|
|
615
|
+
// Isolated probe/wire per varied mount — events must not leak
|
|
616
|
+
// into the primary probe's fire log.
|
|
617
|
+
const variedProbe = createProbe();
|
|
618
|
+
const variedConfig = createProbeWireConfig(variedProbe);
|
|
619
|
+
let variedResult: ReturnType<typeof render> | undefined;
|
|
620
|
+
try {
|
|
621
|
+
variedResult = render(
|
|
622
|
+
React.createElement(GguiWireProvider, {
|
|
623
|
+
config: variedConfig,
|
|
624
|
+
children: React.createElement(ProbeErrorBoundary, {
|
|
625
|
+
children: React.createElement(Component, {
|
|
626
|
+
...input.mockupProps,
|
|
627
|
+
[propName]: varied,
|
|
628
|
+
}),
|
|
629
|
+
}),
|
|
630
|
+
}),
|
|
631
|
+
);
|
|
632
|
+
} catch {
|
|
633
|
+
continue; // crash on varied value ⇒ the prop is read, not dead
|
|
634
|
+
}
|
|
635
|
+
await flushPromises();
|
|
636
|
+
const variedText = normalize(variedResult.container.textContent);
|
|
637
|
+
variedResult.unmount();
|
|
638
|
+
if (variedText === baseText) {
|
|
639
|
+
issues.push({
|
|
640
|
+
check: "prop-sensitivity",
|
|
641
|
+
outcome: "failed",
|
|
642
|
+
subject: propName,
|
|
643
|
+
reason:
|
|
644
|
+
`props.${propName} is displayed ('${marker}' appears in the DOM) but changing its value to '${String(varied)}' left the rendered output BYTE-IDENTICAL — the value is baked into the component instead of derived from the prop. A cached reuse of this blueprint under a different ${propName} would show the original value.`,
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
508
650
|
// ── Check 6: Stream re-render (WARN) ──────────────────────────────
|
|
509
651
|
if (input.contract?.streamSpec) {
|
|
510
652
|
const streamSpec = input.contract.streamSpec;
|
|
@@ -1041,6 +1183,127 @@ async function checkActionWiring(input: CheckActionInput): Promise<RenderCheckIs
|
|
|
1041
1183
|
// `streamSpec[*].source.tool` ride through the action / stream check
|
|
1042
1184
|
// arms in `runRenderCheck` instead.
|
|
1043
1185
|
|
|
1186
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1187
|
+
// Selection identity (#601) — duplicate-label cells must carry their own id
|
|
1188
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* Find the id-like required string property in an action's payload
|
|
1192
|
+
* schema, or null. "id-like" = a required property whose name ends in
|
|
1193
|
+
* `id`/`Id`/`ID` and whose declared type is string — the shape the
|
|
1194
|
+
* matcher's booking/selection contracts carry (`bookSlot { slotId }`).
|
|
1195
|
+
*/
|
|
1196
|
+
function idLikeRequiredStringField(
|
|
1197
|
+
schema: Record<string, unknown> | undefined,
|
|
1198
|
+
): string | null {
|
|
1199
|
+
if (schema === undefined) return null;
|
|
1200
|
+
const properties = schema['properties'];
|
|
1201
|
+
const required = schema['required'];
|
|
1202
|
+
if (properties === null || typeof properties !== 'object') return null;
|
|
1203
|
+
if (!Array.isArray(required)) return null;
|
|
1204
|
+
for (const [propName, propSchema] of Object.entries(
|
|
1205
|
+
properties as Record<string, unknown>,
|
|
1206
|
+
)) {
|
|
1207
|
+
if (!/id$/i.test(propName)) continue;
|
|
1208
|
+
if (!required.includes(propName)) continue;
|
|
1209
|
+
const t =
|
|
1210
|
+
propSchema !== null && typeof propSchema === 'object'
|
|
1211
|
+
? (propSchema as Record<string, unknown>)['type']
|
|
1212
|
+
: undefined;
|
|
1213
|
+
if (t === 'string') return propName;
|
|
1214
|
+
}
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
interface CheckSelectionIdentityInput {
|
|
1219
|
+
container: MinimalElement;
|
|
1220
|
+
probe: Probe;
|
|
1221
|
+
user: { click: (el: MinimalElement) => Promise<void> };
|
|
1222
|
+
actionName: string;
|
|
1223
|
+
idField: string;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* Click two clickables that share a visible label and require the two
|
|
1228
|
+
* fired payloads to DIFFER on the id-like field. Identical values mean
|
|
1229
|
+
* the component keys the action on the display value, not the element's
|
|
1230
|
+
* identity — the ggui#601 class (every "09:00" cell selecting together).
|
|
1231
|
+
*
|
|
1232
|
+
* Emits at most one `failed` issue; every ambiguous situation returns
|
|
1233
|
+
* null (covered by other checks or genuinely unjudgeable here).
|
|
1234
|
+
*/
|
|
1235
|
+
async function checkSelectionIdentity(
|
|
1236
|
+
input: CheckSelectionIdentityInput,
|
|
1237
|
+
): Promise<RenderCheckIssue | null> {
|
|
1238
|
+
const { container, probe, user, actionName, idField } = input;
|
|
1239
|
+
|
|
1240
|
+
const clickables = Array.from(
|
|
1241
|
+
container.querySelectorAll<MinimalElement>('button, [role="button"]'),
|
|
1242
|
+
);
|
|
1243
|
+
const byLabel = new Map<string, MinimalElement[]>();
|
|
1244
|
+
for (const el of clickables) {
|
|
1245
|
+
const label = (el.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
1246
|
+
if (label.length === 0) continue;
|
|
1247
|
+
const group = byLabel.get(label);
|
|
1248
|
+
if (group) group.push(el);
|
|
1249
|
+
else byLabel.set(label, [el]);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
for (const [label, group] of byLabel) {
|
|
1253
|
+
if (group.length < 2) continue;
|
|
1254
|
+
|
|
1255
|
+
const payloadFor = async (
|
|
1256
|
+
el: MinimalElement,
|
|
1257
|
+
): Promise<unknown | undefined> => {
|
|
1258
|
+
const before = probe.getFireLog().length;
|
|
1259
|
+
try {
|
|
1260
|
+
await user.click(el);
|
|
1261
|
+
} catch {
|
|
1262
|
+
return undefined;
|
|
1263
|
+
}
|
|
1264
|
+
await flushPromises();
|
|
1265
|
+
const events = probe
|
|
1266
|
+
.getFireLog()
|
|
1267
|
+
.slice(before)
|
|
1268
|
+
.filter(
|
|
1269
|
+
(e): e is ActionFiredEvent =>
|
|
1270
|
+
e.kind === 'action.fired' && e.name === actionName,
|
|
1271
|
+
);
|
|
1272
|
+
return events.length > 0 ? events[events.length - 1]!.payload : undefined;
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
const first = await payloadFor(group[0]!);
|
|
1276
|
+
const second = await payloadFor(group[1]!);
|
|
1277
|
+
// Either click firing nothing for this action = not this check's
|
|
1278
|
+
// question (wiring check owns "does it fire").
|
|
1279
|
+
if (first === undefined || second === undefined) continue;
|
|
1280
|
+
|
|
1281
|
+
const idOf = (payload: unknown): string | undefined => {
|
|
1282
|
+
if (payload === null || typeof payload !== 'object') return undefined;
|
|
1283
|
+
const v = (payload as Record<string, unknown>)[idField];
|
|
1284
|
+
return typeof v === 'string' ? v : undefined;
|
|
1285
|
+
};
|
|
1286
|
+
const firstId = idOf(first);
|
|
1287
|
+
const secondId = idOf(second);
|
|
1288
|
+
// Payloads missing the required field = schema territory, not ours.
|
|
1289
|
+
if (firstId === undefined || secondId === undefined) continue;
|
|
1290
|
+
|
|
1291
|
+
if (firstId === secondId) {
|
|
1292
|
+
return {
|
|
1293
|
+
check: 'selection-identity',
|
|
1294
|
+
outcome: 'failed',
|
|
1295
|
+
subject: actionName,
|
|
1296
|
+
reason:
|
|
1297
|
+
`Two clickable elements share the visible label '${label}', and clicking EACH fired '${actionName}' with the same ${idField} ('${firstId}') — the action payload is keyed on the display value, not the element's identity. Distinct cells must send their own ${idField} (the contract requires it), or selecting one "${label}" acts on every "${label}".`,
|
|
1298
|
+
elementHint: describeElement(group[0]!),
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
// Distinct ids on the first duplicate group — identity keying holds.
|
|
1302
|
+
return null;
|
|
1303
|
+
}
|
|
1304
|
+
return null;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1044
1307
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1045
1308
|
// Trigger simulators — narrow set, deterministic only
|
|
1046
1309
|
// ─────────────────────────────────────────────────────────────────────────────
|