@ggui-ai/ui-gen 0.9.0 → 0.11.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.d.ts +9 -2
- package/dist/adapters/generation-dispatch.js +693 -89
- package/dist/adapters/generation-dispatch.js.map +1 -1
- package/dist/adapters/index.js +28 -11
- package/dist/adapters/index.js.map +1 -1
- package/dist/advanced/index.d.ts +5 -3
- package/dist/advanced/index.js +714 -101
- package/dist/advanced/index.js.map +1 -1
- package/dist/blueprint-validator.js +267 -3
- package/dist/blueprint-validator.js.map +1 -1
- package/dist/boilerplate.js +14 -2
- package/dist/boilerplate.js.map +1 -1
- package/dist/check/index.d.ts +1 -1
- package/dist/check/index.js +11 -1
- package/dist/check/index.js.map +1 -1
- package/dist/{create-ui-generator-B9kV4U9h.d.ts → create-ui-generator-CF0EL0Li.d.ts} +13 -1
- package/dist/{dispatch-Deo9qzrG.d.ts → dispatch-D75M-OfZ.d.ts} +1 -1
- package/dist/evaluation/axis-checks/index.d.ts +3 -3
- package/dist/evaluation/axis-checks/index.js +101 -0
- package/dist/evaluation/axis-checks/index.js.map +1 -1
- package/dist/evaluation/axis-checks/registry.d.ts +1 -1
- package/dist/evaluation/axis-checks/registry.js +101 -0
- package/dist/evaluation/axis-checks/registry.js.map +1 -1
- package/dist/evaluation/index.d.ts +46 -3
- package/dist/evaluation/index.js +358 -17
- package/dist/evaluation/index.js.map +1 -1
- package/dist/harness/check/runtime-render/index.d.ts +2 -2
- package/dist/harness/check/runtime-render/index.js +268 -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 +1332 -0
- package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -0
- package/dist/harness/index.d.ts +2 -2
- package/dist/harness/index.js +263 -18
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/llm-trace-sink.d.ts +1 -1
- package/dist/harness/llm-trace-sink.js.map +1 -1
- package/dist/harness/prompts.js +17 -3
- package/dist/harness/prompts.js.map +1 -1
- package/dist/harness/result-types.d.ts +1 -1
- package/dist/harness/runtime.d.ts +9 -2
- package/dist/harness/runtime.js +20 -6
- package/dist/harness/runtime.js.map +1 -1
- package/dist/harness/types-public.d.ts +1 -1
- package/dist/harness/types-public.js +14 -2
- package/dist/harness/types-public.js.map +1 -1
- package/dist/harness/validator-trace-sink.d.ts +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +708 -95
- package/dist/index.js.map +1 -1
- package/dist/{llm-router-1TyI0_TV.d.ts → llm-router-BEVtbAb9.d.ts} +61 -4
- package/dist/patch.d.ts +1 -1
- package/dist/provider-adapter.d.ts +16 -6
- package/dist/provider-adapter.js.map +1 -1
- package/dist/providers/index.d.ts +19 -6
- package/dist/providers/index.js.map +1 -1
- package/dist/{render-check-CNZjHD5g.d.ts → render-check-BZoIE8-u.d.ts} +21 -1
- package/dist/{types-public-C36k1q6s.d.ts → types-public-DIpjC-OA.d.ts} +1 -1
- package/dist/workflows.d.ts +1 -1
- package/package.json +12 -12
- package/src/adapters/generation-dispatch.ts +9 -0
- package/src/adapters/openrouter/client.ts +2 -2
- package/src/adapters/openrouter/types.ts +10 -0
- package/src/boilerplate/generate.ts +10 -2
- package/src/boilerplate/json-schema-ts.ts +22 -2
- package/src/boilerplate/system-prompt.ts +1 -0
- package/src/compile.ts +1 -1
- package/src/contract-context.ts +10 -2
- package/src/create-ui-generator.ts +14 -0
- package/src/design-system-docs.ts +6 -4
- package/src/evaluation/axis-checks/checks/state-ui-affordance.ts +162 -0
- package/src/evaluation/axis-checks/registry.ts +2 -0
- package/src/evaluation/index.ts +11 -0
- package/src/evaluation/llm-evaluator.ts +14 -1
- package/src/evaluation/visual-evaluator.ts +44 -65
- package/src/harness/check/runtime-render/adapter.ts +18 -0
- package/src/harness/check/runtime-render/index.ts +8 -0
- package/src/harness/check/runtime-render/prepare-mockup.ts +14 -0
- package/src/harness/check/runtime-render/production-act-shim.ts +158 -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 +112 -1
- package/src/harness/coding/run-eval-round.ts +61 -5
- package/src/harness/index.ts +1 -1
- package/src/harness/llm-router.ts +397 -18
- package/src/harness/llm-trace-sink.ts +2 -1
- package/src/harness/runtime.ts +7 -0
- package/src/index.ts +5 -5
- package/src/provider-adapter.ts +16 -6
- package/src/providers/bedrock.ts +5 -4
- package/src/providers/index.ts +16 -4
- package/src/tools/render-check.ts +19 -7
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// packages/ui-gen/src/harness/check/runtime-render/production-act-shim.ts
|
|
2
|
+
//
|
|
3
|
+
// Minimal adaptation to two upstream constraints, neither of which
|
|
4
|
+
// this package controls:
|
|
5
|
+
//
|
|
6
|
+
// 1. React intentionally strips `act` from its PRODUCTION build.
|
|
7
|
+
// `react/cjs/react.production.js` does not export it — only the
|
|
8
|
+
// development build does (`typeof require('react').act` is
|
|
9
|
+
// `'function'` under a dev build, `undefined` under production).
|
|
10
|
+
// 2. `@testing-library/react`'s `act-compat` module hard-requires
|
|
11
|
+
// `act` at MODULE SCOPE: it snapshots
|
|
12
|
+
// `typeof React.act === 'function' ? React.act : DeprecatedReactTestUtils.act`
|
|
13
|
+
// into a `const` the first time it is imported in the process.
|
|
14
|
+
// Under a production build, that snapshot resolves to React
|
|
15
|
+
// DOM's `react-dom/test-utils` production `act` stub, which
|
|
16
|
+
// THROWS `React.act is not a function` the moment it is called.
|
|
17
|
+
//
|
|
18
|
+
// Hosts that run this probe inside a production Node process
|
|
19
|
+
// (server-side blueprint validation) hit this: every render throws
|
|
20
|
+
// through RTL's `act` wrapper before the probe ever sees the
|
|
21
|
+
// component. Hosts that run it under a development/test build (every
|
|
22
|
+
// CI/vitest run in this repo — `NODE_ENV=test` resolves the dev
|
|
23
|
+
// build) never see it; `act` is real there and this shim is a no-op.
|
|
24
|
+
//
|
|
25
|
+
// **Load-order constraint**: this MUST run before the FIRST import of
|
|
26
|
+
// `@testing-library/react` anywhere in the process — act-compat binds
|
|
27
|
+
// its `act` reference into a module-scope `const` at import time, so
|
|
28
|
+
// patching `react-dom/test-utils` AFTER that import has already
|
|
29
|
+
// happened does nothing (the snapshot already captured the broken
|
|
30
|
+
// stub). Patch `react-dom/test-utils`, never `react` itself — that
|
|
31
|
+
// entry point is deprecated and test-only, so this patch cannot
|
|
32
|
+
// perturb any production rendering path; nothing else in a server
|
|
33
|
+
// process legitimately imports it.
|
|
34
|
+
//
|
|
35
|
+
// `createRequire` (not `import`) reaches these modules deliberately:
|
|
36
|
+
// ESM namespace objects are frozen, so a normal `import` gives no way
|
|
37
|
+
// to mutate `act` on it. The CJS `module.exports` objects `require`
|
|
38
|
+
// resolves are mutable, and RTL's act-compat interop-copies THAT
|
|
39
|
+
// object's properties at its own load time — so mutating it here,
|
|
40
|
+
// before RTL loads, is what makes the patch visible to RTL at all.
|
|
41
|
+
|
|
42
|
+
import { createRequire } from "node:module";
|
|
43
|
+
|
|
44
|
+
let installed = false;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The one property this module reads/mutates on `react` and
|
|
48
|
+
* `react-dom/test-utils`'s CJS exports. A minimal local shape rather
|
|
49
|
+
* than the real (deprecated, possibly absent) type declarations for
|
|
50
|
+
* `react-dom/test-utils` — narrow enough to type-check the mutation
|
|
51
|
+
* without `as any` / `Record<string, unknown>`.
|
|
52
|
+
*/
|
|
53
|
+
interface MutableActCarrier {
|
|
54
|
+
act?: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The one React DOM export this module needs — typed narrowly for the same reason. */
|
|
58
|
+
interface FlushSyncCarrier {
|
|
59
|
+
flushSync: (fn: () => void) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Patch `react-dom/test-utils`'s `act` with a `flushSync`-backed
|
|
64
|
+
* replacement, but ONLY when the current process is running React's
|
|
65
|
+
* production build (no real `act` exported). Idempotent — safe to
|
|
66
|
+
* call more than once or from more than one call site; the actual
|
|
67
|
+
* patch only ever applies once per process.
|
|
68
|
+
*
|
|
69
|
+
* No-op (by design, not by accident) whenever `react`'s own `act`
|
|
70
|
+
* export is a real function — every dev/test build, including every
|
|
71
|
+
* vitest run in this repo. This shim changes zero behavior in CI.
|
|
72
|
+
*/
|
|
73
|
+
export function installProductionActShim(): void {
|
|
74
|
+
if (installed) return;
|
|
75
|
+
installed = true;
|
|
76
|
+
|
|
77
|
+
const require_ = createRequire(import.meta.url);
|
|
78
|
+
|
|
79
|
+
let reactCjs: MutableActCarrier;
|
|
80
|
+
try {
|
|
81
|
+
reactCjs = require_("react");
|
|
82
|
+
} catch {
|
|
83
|
+
// React itself isn't resolvable in this process — the probe
|
|
84
|
+
// surfaces its own load error; nothing for this shim to patch.
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (typeof reactCjs.act === "function") {
|
|
89
|
+
// Dev/test builds export a real `act` — strict no-op.
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const testUtils: MutableActCarrier = require_("react-dom/test-utils");
|
|
95
|
+
const reactDom: FlushSyncCarrier = require_("react-dom");
|
|
96
|
+
testUtils.act = createFlushSyncAct(reactDom.flushSync);
|
|
97
|
+
} catch {
|
|
98
|
+
// Frozen/locked exports (or a react-dom build without flushSync)
|
|
99
|
+
// — leave it. The probe then surfaces the raw upstream error,
|
|
100
|
+
// which is the pre-fix behavior: an honest failure, not a
|
|
101
|
+
// silently-broken patch.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build an `act`-compatible replacement backed by `flushSync`, which
|
|
107
|
+
* — unlike `act` — DOES exist in React's production build.
|
|
108
|
+
*
|
|
109
|
+
* `flushSync` is the single property this shim relies on from dev
|
|
110
|
+
* `act`: it forces the scheduled render to commit SYNCHRONOUSLY
|
|
111
|
+
* before returning, so a render throw propagates synchronously to the
|
|
112
|
+
* caller and the DOM is fully committed by the time this resolves.
|
|
113
|
+
*
|
|
114
|
+
* Returned function is act-style: if the callback itself returned a
|
|
115
|
+
* thenable (RTL's async act-wrapped interactions), the result
|
|
116
|
+
* resolves once that thenable settles (rejecting on its rejection);
|
|
117
|
+
* otherwise it resolves after one microtask, mirroring `act`'s own
|
|
118
|
+
* "flush the microtask queue" contract for the synchronous case.
|
|
119
|
+
*
|
|
120
|
+
* **Known, documented delta vs. a real dev `act`**: passive effects
|
|
121
|
+
* (`useEffect`) flush on a LATER task under `flushSync`, not
|
|
122
|
+
* synchronously at act-exit — `flushSync` only guarantees the
|
|
123
|
+
* synchronous commit, not effect flushing. The probe's uncaught-error
|
|
124
|
+
* handlers plus its 100ms teardown grace window (`render-check.ts`'s
|
|
125
|
+
* `cleanupAsyncInfra`) already cover a late effect throwing after this
|
|
126
|
+
* resolves, so this delta does not reopen the crash class the probe
|
|
127
|
+
* exists to catch.
|
|
128
|
+
*
|
|
129
|
+
* Exported separately so the flushSync/thenable semantics are unit
|
|
130
|
+
* testable without a real React runtime.
|
|
131
|
+
*/
|
|
132
|
+
export function createFlushSyncAct(
|
|
133
|
+
flushSync: (fn: () => void) => void,
|
|
134
|
+
): (callback: () => unknown) => Promise<void> {
|
|
135
|
+
return (callback: () => unknown): Promise<void> => {
|
|
136
|
+
let callbackResult: unknown;
|
|
137
|
+
flushSync(() => {
|
|
138
|
+
callbackResult = callback();
|
|
139
|
+
});
|
|
140
|
+
if (isThenable(callbackResult)) {
|
|
141
|
+
const pending = callbackResult;
|
|
142
|
+
return new Promise<void>((resolve, reject) => {
|
|
143
|
+
pending.then(() => resolve(), reject);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return new Promise<void>((resolve) => {
|
|
147
|
+
queueMicrotask(resolve);
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
153
|
+
return (
|
|
154
|
+
typeof value === "object" &&
|
|
155
|
+
value !== null &&
|
|
156
|
+
typeof (value as { then?: unknown }).then === "function"
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host side of the isolated runtime render check (#592).
|
|
3
|
+
*
|
|
4
|
+
* Spawns `render-check-worker.ts` in a sandboxed subprocess and maps
|
|
5
|
+
* every sandbox outcome onto the check's own `RenderCheckResult`
|
|
6
|
+
* vocabulary, so callers see one result shape whether the check ran
|
|
7
|
+
* in-process (DOM-owning environments) or isolated (plain Node).
|
|
8
|
+
*
|
|
9
|
+
* Outcome mapping policy (mirrors the adapter's escaped-error
|
|
10
|
+
* doctrine — component faults are `failed`, harness faults are
|
|
11
|
+
* `unverified`, and nothing throws):
|
|
12
|
+
*
|
|
13
|
+
* - worker exit 0 + parseable verdict → the verdict, verbatim.
|
|
14
|
+
* - timeout → `failed` (runaway component: the
|
|
15
|
+
* in-process check has no comparable wall-clock bound, the
|
|
16
|
+
* subprocess finally gives us one).
|
|
17
|
+
* - spawn-error / overflow / non-zero exit / unparseable stdout
|
|
18
|
+
* → `unverified` (the harness could not
|
|
19
|
+
* run the check; never blame the component for our plumbing).
|
|
20
|
+
*
|
|
21
|
+
* Spawn resolution copies the proven `src/tools/render-check.ts`
|
|
22
|
+
* pattern: prefer the built `dist/.../render-check-worker.js`; in
|
|
23
|
+
* dev/test (vitest imports `src/`), fall back to `node --import tsx`
|
|
24
|
+
* on the `.ts` source. tsup inlines THIS module into several dist
|
|
25
|
+
* entries (`dist/index.js`, `dist/harness/index.js`,
|
|
26
|
+
* `dist/check/index.js`, `dist/advanced/index.js`, …), so the
|
|
27
|
+
* candidate list walks from each of those locations to the one real
|
|
28
|
+
* `dist/harness/check/runtime-render/render-check-worker.js`.
|
|
29
|
+
*/
|
|
30
|
+
import { existsSync } from 'node:fs';
|
|
31
|
+
import { createRequire } from 'node:module';
|
|
32
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
33
|
+
import { runSandboxed } from '@ggui-ai/sandbox';
|
|
34
|
+
import type { SandboxResult } from '@ggui-ai/sandbox';
|
|
35
|
+
import type { RenderCheckResult, RunRenderCheckInput } from './render-check.js';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wall-clock bound for one isolated check. The rich probe renders,
|
|
39
|
+
* clicks every declared action, and re-renders streams — slower than
|
|
40
|
+
* the smoke worker's 12s budget; 30s bounds runaway components while
|
|
41
|
+
* staying far under any generation-turn budget.
|
|
42
|
+
*/
|
|
43
|
+
const CHECK_TIMEOUT_MS = 30_000;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* stdout cap. The verdict JSON carries issue lists with diagnostics
|
|
47
|
+
* arrays — bigger than the smoke worker's one-liner, still tiny in
|
|
48
|
+
* absolute terms. 2 MiB is far above any legitimate verdict.
|
|
49
|
+
*/
|
|
50
|
+
const CHECK_STDOUT_CAP = 2 * 1024 * 1024;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* V8 heap cap. The worker realm loads react-dom, happy-dom and
|
|
54
|
+
* testing-library on top of the component itself; 512 MB leaves
|
|
55
|
+
* headroom while still bounding pathological allocation.
|
|
56
|
+
*/
|
|
57
|
+
const CHECK_NODE_HEAP_MB = 512;
|
|
58
|
+
|
|
59
|
+
const WORKER_BASENAME = 'render-check-worker';
|
|
60
|
+
|
|
61
|
+
function resolveWorkerSpawn(): { command: string; args: string[] } {
|
|
62
|
+
const jsCandidates = [
|
|
63
|
+
// Sibling — when import.meta.url is already .../harness/check/runtime-render/.
|
|
64
|
+
new URL(`./${WORKER_BASENAME}.js`, import.meta.url),
|
|
65
|
+
// From dist/index.js.
|
|
66
|
+
new URL(`./harness/check/runtime-render/${WORKER_BASENAME}.js`, import.meta.url),
|
|
67
|
+
// From dist/harness/index.js.
|
|
68
|
+
new URL(`./check/runtime-render/${WORKER_BASENAME}.js`, import.meta.url),
|
|
69
|
+
// From dist/check/index.js, dist/advanced/index.js, dist/<flat>.js.
|
|
70
|
+
new URL(`../harness/check/runtime-render/${WORKER_BASENAME}.js`, import.meta.url),
|
|
71
|
+
];
|
|
72
|
+
for (const jsUrl of jsCandidates) {
|
|
73
|
+
const jsPath = fileURLToPath(jsUrl);
|
|
74
|
+
if (existsSync(jsPath)) {
|
|
75
|
+
return { command: process.execPath, args: [jsPath] };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Dev / vitest: only the .ts source exists — run it through tsx.
|
|
79
|
+
const tsCandidates = [
|
|
80
|
+
new URL(`./${WORKER_BASENAME}.ts`, import.meta.url),
|
|
81
|
+
new URL(`../harness/check/runtime-render/${WORKER_BASENAME}.ts`, import.meta.url),
|
|
82
|
+
];
|
|
83
|
+
for (const tsUrl of tsCandidates) {
|
|
84
|
+
const tsPath = fileURLToPath(tsUrl);
|
|
85
|
+
if (existsSync(tsPath)) {
|
|
86
|
+
const require_ = createRequire(import.meta.url);
|
|
87
|
+
const tsxLoader = pathToFileURL(require_.resolve('tsx')).href;
|
|
88
|
+
return { command: process.execPath, args: ['--import', tsxLoader, tsPath] };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const tried = [...jsCandidates, ...tsCandidates]
|
|
92
|
+
.map((u) => fileURLToPath(u))
|
|
93
|
+
.join(', ');
|
|
94
|
+
throw new Error(
|
|
95
|
+
`render-check: isolated worker not found at any of: ${tried}. Did \`pnpm build\` run?`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function failed(reason: string, t0: number): RenderCheckResult {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
issues: [{ check: 'render-no-throw', outcome: 'failed', reason }],
|
|
103
|
+
stats: { actionsChecked: 0, streamsChecked: 0, renderMs: Date.now() - t0 },
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function unverified(reason: string, t0: number): RenderCheckResult {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
issues: [{ check: 'render-no-throw', outcome: 'unverified', reason }],
|
|
111
|
+
stats: { actionsChecked: 0, streamsChecked: 0, renderMs: Date.now() - t0 },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Map one sandbox result onto a `RenderCheckResult`. Exported for the
|
|
117
|
+
* unit pins — the spawn itself is exercised by the integration test.
|
|
118
|
+
*/
|
|
119
|
+
export function mapSandboxResultToCheckResult(
|
|
120
|
+
result: SandboxResult,
|
|
121
|
+
t0: number,
|
|
122
|
+
): RenderCheckResult {
|
|
123
|
+
if (result.outcome === 'timeout') {
|
|
124
|
+
return failed(
|
|
125
|
+
`render check timed out after ${CHECK_TIMEOUT_MS}ms in the isolated worker (likely infinite loop or runaway effect in the component).`,
|
|
126
|
+
t0,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (result.outcome === 'overflow-stdout' || result.outcome === 'overflow-stderr') {
|
|
130
|
+
return failed(
|
|
131
|
+
`render check worker produced excessive output (${result.outcome}) — the component is likely in a pathological logging or error loop.`,
|
|
132
|
+
t0,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
if (result.outcome === 'spawn-error') {
|
|
136
|
+
return unverified(
|
|
137
|
+
`render check worker failed to start — ${result.errorMessage}`,
|
|
138
|
+
t0,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (result.outcome === 'canceled') {
|
|
142
|
+
return unverified('render check worker was canceled before completing.', t0);
|
|
143
|
+
}
|
|
144
|
+
if (result.outcome !== 'exit') {
|
|
145
|
+
return unverified(
|
|
146
|
+
`render check worker ended with unexpected sandbox outcome '${result.outcome}'.`,
|
|
147
|
+
t0,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
if (result.exitCode !== 0) {
|
|
151
|
+
const tail = result.stderr.trim() || result.stdout.trim();
|
|
152
|
+
return unverified(
|
|
153
|
+
`render check worker exited ${result.exitCode}${tail ? ` — ${tail.slice(0, 600)}` : ''}`,
|
|
154
|
+
t0,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const stdout = result.stdout.trim();
|
|
158
|
+
if (stdout.length === 0) {
|
|
159
|
+
return unverified('render check worker exited without producing a verdict.', t0);
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(stdout) as RenderCheckResult;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return unverified(
|
|
165
|
+
`render check worker verdict was not valid JSON — ${
|
|
166
|
+
err instanceof Error ? err.message : String(err)
|
|
167
|
+
}`,
|
|
168
|
+
t0,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Run the render check in an isolated subprocess. See module doc.
|
|
175
|
+
*/
|
|
176
|
+
export async function runRenderCheckViaWorker(
|
|
177
|
+
input: RunRenderCheckInput,
|
|
178
|
+
): Promise<RenderCheckResult> {
|
|
179
|
+
const t0 = Date.now();
|
|
180
|
+
let spawn: { command: string; args: string[] };
|
|
181
|
+
try {
|
|
182
|
+
spawn = resolveWorkerSpawn();
|
|
183
|
+
} catch (err) {
|
|
184
|
+
return unverified(err instanceof Error ? err.message : String(err), t0);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const result = await runSandboxed({
|
|
188
|
+
command: spawn.command,
|
|
189
|
+
args: spawn.args,
|
|
190
|
+
timeoutMs: CHECK_TIMEOUT_MS,
|
|
191
|
+
maxStdoutBytes: CHECK_STDOUT_CAP,
|
|
192
|
+
nodeHeapMb: CHECK_NODE_HEAP_MB,
|
|
193
|
+
stdin: JSON.stringify(input),
|
|
194
|
+
// NODE_ENV steers React's production vs development build — keep
|
|
195
|
+
// parity with the caller; everything else stays on the sandbox's
|
|
196
|
+
// default allowlist.
|
|
197
|
+
env: { NODE_ENV: process.env.NODE_ENV ?? 'production' },
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return mapSandboxResultToCheckResult(result, t0);
|
|
201
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Isolated worker for the in-loop runtime render check (#592).
|
|
3
|
+
*
|
|
4
|
+
* Runs `runRenderCheckInProcess` in a SUBPROCESS so the check's
|
|
5
|
+
* happy-dom global install (`window`, `document`, `navigator`, … onto
|
|
6
|
+
* `globalThis`) lands on THIS process's globals, never the caller's.
|
|
7
|
+
*
|
|
8
|
+
* Why this exists — the process-global install is a TOCTOU hazard in
|
|
9
|
+
* any concurrent host: provider SDKs (OpenAI and Anthropic alike)
|
|
10
|
+
* sniff `typeof window !== 'undefined' && window.document && navigator`
|
|
11
|
+
* at client construction and refuse with "browser-like environment"
|
|
12
|
+
* when another cell's check-window happens to be open (#592, the
|
|
13
|
+
* 2026-08-19 bench run's gpt-5.6-sol hard failures). Two overlapping
|
|
14
|
+
* in-process checks also corrupt each other's install/teardown. A
|
|
15
|
+
* subprocess gives one private global scope per check plus real kill
|
|
16
|
+
* semantics via `@ggui-ai/sandbox` on the host side.
|
|
17
|
+
*
|
|
18
|
+
* Wire protocol (single JSON document each way):
|
|
19
|
+
*
|
|
20
|
+
* stdin: RunRenderCheckInput ({ sourceCode, mockupProps, contract? })
|
|
21
|
+
* stdout: RenderCheckResult ({ ok, issues, stats })
|
|
22
|
+
* exit: 0 on any verdict (issues are data, not status); non-zero
|
|
23
|
+
* only when the worker itself is broken (malformed input,
|
|
24
|
+
* harness crash) — the host maps that to an `unverified`
|
|
25
|
+
* issue, mirroring the adapter's escaped-error semantics.
|
|
26
|
+
*
|
|
27
|
+
* Same file-path distribution model as `src/tools/render-check-worker.ts`
|
|
28
|
+
* (the render SMOKE worker): reached by spawn path, never by import
|
|
29
|
+
* specifier; registered as a tsup entry so `dist/harness/check/
|
|
30
|
+
* runtime-render/render-check-worker.js` ships.
|
|
31
|
+
*/
|
|
32
|
+
import { runRenderCheckInProcess } from './render-check.js';
|
|
33
|
+
import type { RunRenderCheckInput } from './render-check.js';
|
|
34
|
+
|
|
35
|
+
async function readAllStdin(): Promise<string> {
|
|
36
|
+
const chunks: Buffer[] = [];
|
|
37
|
+
for await (const chunk of process.stdin) {
|
|
38
|
+
chunks.push(chunk as Buffer);
|
|
39
|
+
}
|
|
40
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function emit(value: unknown): void {
|
|
44
|
+
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function main(): Promise<void> {
|
|
48
|
+
const raw = await readAllStdin();
|
|
49
|
+
let input: RunRenderCheckInput;
|
|
50
|
+
try {
|
|
51
|
+
input = JSON.parse(raw) as RunRenderCheckInput;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
process.stderr.write(
|
|
54
|
+
`render-check worker: malformed input JSON — ${
|
|
55
|
+
err instanceof Error ? err.message : String(err)
|
|
56
|
+
}\n`,
|
|
57
|
+
);
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (typeof input.sourceCode !== 'string' || input.sourceCode.length === 0) {
|
|
62
|
+
process.stderr.write(
|
|
63
|
+
'render-check worker: input.sourceCode must be a non-empty string\n',
|
|
64
|
+
);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The in-process check never throws for component failures (they are
|
|
70
|
+
// reported as issues); an escape here is a harness defect — let it
|
|
71
|
+
// crash the worker so the host maps it to `unverified` with the
|
|
72
|
+
// stderr tail as the reason.
|
|
73
|
+
const result = await runRenderCheckInProcess(input);
|
|
74
|
+
emit(result);
|
|
75
|
+
// The check's async-infra grace period (React scheduler drain) holds
|
|
76
|
+
// handlers for ~100ms; exit through the event loop naturally so that
|
|
77
|
+
// drain completes inside the worker instead of leaking crashes.
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
void main();
|
|
@@ -24,6 +24,7 @@ import { HOOK_NAME_RE, listContractGadgets } from "@ggui-ai/protocol";
|
|
|
24
24
|
import { createProbe, createProbeWireConfig, type Probe } from "./probe.js";
|
|
25
25
|
import { loadComponent } from "./load-component.js";
|
|
26
26
|
import { findWiring, type WiringDetection } from "./find-wiring.js";
|
|
27
|
+
import { installProductionActShim } from "./production-act-shim.js";
|
|
27
28
|
// All happy-dom / RTL / user-event narrowing flows through the typed
|
|
28
29
|
// host boundary (one set of structural interfaces + validating guards;
|
|
29
30
|
// DOM-lib-free — some downstream packages, e.g. cloud/amplify, exclude
|
|
@@ -70,7 +71,8 @@ export interface RenderCheckIssue {
|
|
|
70
71
|
| "render-no-throw"
|
|
71
72
|
| "action-wiring"
|
|
72
73
|
| "prop-coverage"
|
|
73
|
-
| "stream-rerender"
|
|
74
|
+
| "stream-rerender"
|
|
75
|
+
| "optional-props-omitted";
|
|
74
76
|
readonly outcome: CheckOutcome;
|
|
75
77
|
readonly subject?: string;
|
|
76
78
|
readonly reason: string;
|
|
@@ -112,8 +114,48 @@ export interface RunRenderCheckInput {
|
|
|
112
114
|
// Public entry
|
|
113
115
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
114
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Run the in-loop runtime render check.
|
|
119
|
+
*
|
|
120
|
+
* Isolation dispatcher (#592): when the CALLING environment already
|
|
121
|
+
* owns a DOM (`window` + `document` on `globalThis` — e.g. a vitest
|
|
122
|
+
* `happy-dom` environment, where the environment owner installed the
|
|
123
|
+
* globals deliberately), the check runs in-process exactly as before.
|
|
124
|
+
* In a plain Node process it runs in an ISOLATED SUBPROCESS
|
|
125
|
+
* (`render-check-host.ts` → `render-check-worker.ts`), because the
|
|
126
|
+
* in-process path installs happy-dom globals on `globalThis`
|
|
127
|
+
* process-wide — a TOCTOU hazard for anything that sniffs `window`
|
|
128
|
+
* concurrently (provider SDKs throw "browser-like environment" at
|
|
129
|
+
* client construction; two overlapping checks corrupt each other's
|
|
130
|
+
* teardown). See #592 for the measured incident (4 bench cells hard-
|
|
131
|
+
* failed on the 2026-08-19 run).
|
|
132
|
+
*
|
|
133
|
+
* The host is loaded lazily so the worker bundle (which imports this
|
|
134
|
+
* module for `runRenderCheckInProcess`) never drags in the sandbox
|
|
135
|
+
* spawn machinery.
|
|
136
|
+
*/
|
|
115
137
|
export async function runRenderCheck(
|
|
116
138
|
input: RunRenderCheckInput,
|
|
139
|
+
): Promise<RenderCheckResult> {
|
|
140
|
+
const g = hostGlobals();
|
|
141
|
+
if ("window" in g && "document" in g) {
|
|
142
|
+
return runRenderCheckInProcess(input);
|
|
143
|
+
}
|
|
144
|
+
const { runRenderCheckViaWorker } = await import("./render-check-host.js");
|
|
145
|
+
return runRenderCheckViaWorker(input);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The actual check, executed IN THE CURRENT PROCESS — installs
|
|
150
|
+
* happy-dom globals on `globalThis` for the duration when the
|
|
151
|
+
* environment doesn't already provide a DOM. Call sites outside this
|
|
152
|
+
* module should use {@link runRenderCheck}; this export exists for the
|
|
153
|
+
* isolation worker (which IS the private process the globals may own)
|
|
154
|
+
* and for DOM-owning test environments that want to bypass the
|
|
155
|
+
* dispatcher explicitly.
|
|
156
|
+
*/
|
|
157
|
+
export async function runRenderCheckInProcess(
|
|
158
|
+
input: RunRenderCheckInput,
|
|
117
159
|
): Promise<RenderCheckResult> {
|
|
118
160
|
const t0 = Date.now();
|
|
119
161
|
const issues: RenderCheckIssue[] = [];
|
|
@@ -237,6 +279,9 @@ export async function runRenderCheck(
|
|
|
237
279
|
// internals.
|
|
238
280
|
uninstallSpy = probe.installPostMessageSpy();
|
|
239
281
|
|
|
282
|
+
// Must run before RTL's first import in this process — see
|
|
283
|
+
// production-act-shim.ts's docstring for why.
|
|
284
|
+
installProductionActShim();
|
|
240
285
|
const { render, cleanup } = await import("@testing-library/react");
|
|
241
286
|
// user-event v14 default export — see https://testing-library.com/docs/user-event/intro/
|
|
242
287
|
const userEventModule = await import("@testing-library/user-event");
|
|
@@ -514,6 +559,72 @@ export async function runRenderCheck(
|
|
|
514
559
|
if (issue) issues.push(issue);
|
|
515
560
|
}
|
|
516
561
|
}
|
|
562
|
+
|
|
563
|
+
// ── Check 7: Optional-props omission (BLOCK on crash, ggui#528) ──
|
|
564
|
+
// The main pass renders with EVERY declared prop filled, so a
|
|
565
|
+
// component that assumes an optional prop is present always
|
|
566
|
+
// passes here and crashes only live, when an agent legitimately
|
|
567
|
+
// omits it (the wire gate requires only `required === true`
|
|
568
|
+
// props). Render a second instance with the optional props
|
|
569
|
+
// STRIPPED — a throw is exactly the latent-undefined class this
|
|
570
|
+
// check exists to surface. Runs only when the contract declares
|
|
571
|
+
// at least one optional prop that the mockup filled; zero cost
|
|
572
|
+
// otherwise.
|
|
573
|
+
const declaredProps = input.contract?.propsSpec?.properties;
|
|
574
|
+
if (declaredProps) {
|
|
575
|
+
const optionalFilled = Object.entries(declaredProps)
|
|
576
|
+
.filter(([name, entry]) =>
|
|
577
|
+
(entry as { required?: boolean }).required !== true &&
|
|
578
|
+
input.mockupProps[name] !== undefined,
|
|
579
|
+
)
|
|
580
|
+
.map(([name]) => name);
|
|
581
|
+
if (optionalFilled.length > 0) {
|
|
582
|
+
const requiredOnlyProps: JsonObject = { ...input.mockupProps };
|
|
583
|
+
for (const name of optionalFilled) delete requiredOnlyProps[name];
|
|
584
|
+
const omitBoundaryRef: { error?: Error } = {};
|
|
585
|
+
class OmitErrorBoundary extends React.Component<
|
|
586
|
+
{ children?: React.ReactNode },
|
|
587
|
+
{ caught: boolean }
|
|
588
|
+
> {
|
|
589
|
+
state = { caught: false };
|
|
590
|
+
static getDerivedStateFromError(): { caught: boolean } {
|
|
591
|
+
return { caught: true };
|
|
592
|
+
}
|
|
593
|
+
componentDidCatch(error: Error): void {
|
|
594
|
+
omitBoundaryRef.error ??= error;
|
|
595
|
+
}
|
|
596
|
+
render(): React.ReactNode {
|
|
597
|
+
if (this.state.caught) return null;
|
|
598
|
+
return this.props.children;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
render(
|
|
603
|
+
React.createElement(GguiWireProvider, {
|
|
604
|
+
config: wireConfig,
|
|
605
|
+
children: React.createElement(OmitErrorBoundary, {
|
|
606
|
+
children: React.createElement(Component, requiredOnlyProps),
|
|
607
|
+
}),
|
|
608
|
+
}),
|
|
609
|
+
);
|
|
610
|
+
} catch (e) {
|
|
611
|
+
omitBoundaryRef.error ??=
|
|
612
|
+
e instanceof Error ? e : new Error(String(e));
|
|
613
|
+
}
|
|
614
|
+
if (omitBoundaryRef.error) {
|
|
615
|
+
issues.push({
|
|
616
|
+
check: "optional-props-omitted",
|
|
617
|
+
outcome: "failed",
|
|
618
|
+
subject: optionalFilled.join(", "),
|
|
619
|
+
reason:
|
|
620
|
+
`Render crashed when the optional prop(s) ${optionalFilled
|
|
621
|
+
.map((n) => `'${n}'`)
|
|
622
|
+
.join(", ")} were omitted: ${omitBoundaryRef.error.message}. ` +
|
|
623
|
+
`The contract marks them optional (no \`required: true\`), so a legitimate render may leave them out.`,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
517
628
|
} finally {
|
|
518
629
|
cleanup();
|
|
519
630
|
}
|