@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
|
@@ -10,7 +10,7 @@ import { JsonObject, DataContract } from '@ggui-ai/protocol';
|
|
|
10
10
|
*/
|
|
11
11
|
type CheckOutcome = "verified" | "failed" | "unverified" | "skipped";
|
|
12
12
|
interface RenderCheckIssue {
|
|
13
|
-
readonly check: "render-no-throw" | "action-wiring" | "prop-coverage" | "stream-rerender" | "optional-props-omitted";
|
|
13
|
+
readonly check: "render-no-throw" | "action-wiring" | "selection-identity" | "prop-coverage" | "prop-sensitivity" | "stream-rerender" | "optional-props-omitted";
|
|
14
14
|
readonly outcome: CheckOutcome;
|
|
15
15
|
readonly subject?: string;
|
|
16
16
|
readonly reason: string;
|
|
@@ -45,6 +45,26 @@ interface RunRenderCheckInput {
|
|
|
45
45
|
readonly mockupProps: JsonObject;
|
|
46
46
|
readonly contract?: DataContract;
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Run the in-loop runtime render check.
|
|
50
|
+
*
|
|
51
|
+
* Isolation dispatcher (#592): when the CALLING environment already
|
|
52
|
+
* owns a DOM (`window` + `document` on `globalThis` — e.g. a vitest
|
|
53
|
+
* `happy-dom` environment, where the environment owner installed the
|
|
54
|
+
* globals deliberately), the check runs in-process exactly as before.
|
|
55
|
+
* In a plain Node process it runs in an ISOLATED SUBPROCESS
|
|
56
|
+
* (`render-check-host.ts` → `render-check-worker.ts`), because the
|
|
57
|
+
* in-process path installs happy-dom globals on `globalThis`
|
|
58
|
+
* process-wide — a TOCTOU hazard for anything that sniffs `window`
|
|
59
|
+
* concurrently (provider SDKs throw "browser-like environment" at
|
|
60
|
+
* client construction; two overlapping checks corrupt each other's
|
|
61
|
+
* teardown). See #592 for the measured incident (4 bench cells hard-
|
|
62
|
+
* failed on the 2026-08-19 run).
|
|
63
|
+
*
|
|
64
|
+
* The host is loaded lazily so the worker bundle (which imports this
|
|
65
|
+
* module for `runRenderCheckInProcess`) never drags in the sandbox
|
|
66
|
+
* spawn machinery.
|
|
67
|
+
*/
|
|
48
68
|
declare function runRenderCheck(input: RunRenderCheckInput): Promise<RenderCheckResult>;
|
|
49
69
|
|
|
50
70
|
export { type RenderCheckIssue as R, type RenderCheckResult as a, runRenderCheck as r };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ggui-ai/ui-gen",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "UI generation harness for ggui — the LLM system prompt, multi-turn coding loop, and evaluation engine that turn a DataContract into a React component. Implements the UiGenerator contract from @ggui-ai/mcp-server-core; consumed by @ggui-ai/mcp-server.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"keywords": [
|
|
@@ -188,9 +188,9 @@
|
|
|
188
188
|
"puppeteer-core": "^24.40.0 || ^25.0.0",
|
|
189
189
|
"react": "^18.0.0 || ^19.0.0",
|
|
190
190
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
191
|
-
"@ggui-ai/mcp-server-core": "0.
|
|
192
|
-
"@ggui-ai/
|
|
193
|
-
"@ggui-ai/
|
|
191
|
+
"@ggui-ai/mcp-server-core": "0.12.0",
|
|
192
|
+
"@ggui-ai/protocol": "0.12.0",
|
|
193
|
+
"@ggui-ai/project-config": "0.12.0"
|
|
194
194
|
},
|
|
195
195
|
"peerDependenciesMeta": {
|
|
196
196
|
"@google/adk": {
|
|
@@ -229,11 +229,11 @@
|
|
|
229
229
|
"openai": "^6.29.0",
|
|
230
230
|
"typescript": "^5.0.0",
|
|
231
231
|
"zod": "^4.3.6",
|
|
232
|
-
"@ggui-ai/
|
|
233
|
-
"@ggui-ai/
|
|
234
|
-
"@ggui-ai/sandbox": "0.
|
|
235
|
-
"@ggui-ai/ui-visual-tester": "0.
|
|
236
|
-
"@ggui-ai/wire": "0.
|
|
232
|
+
"@ggui-ai/design": "0.12.0",
|
|
233
|
+
"@ggui-ai/gadgets": "0.12.0",
|
|
234
|
+
"@ggui-ai/sandbox": "0.12.0",
|
|
235
|
+
"@ggui-ai/ui-visual-tester": "0.12.0",
|
|
236
|
+
"@ggui-ai/wire": "0.12.0"
|
|
237
237
|
},
|
|
238
238
|
"devDependencies": {
|
|
239
239
|
"@google/adk": "^0.5.0",
|
|
@@ -248,9 +248,9 @@
|
|
|
248
248
|
"react-dom": "^19.0.0",
|
|
249
249
|
"tsup": "^8.5.0",
|
|
250
250
|
"vitest": "^3.2.6",
|
|
251
|
-
"@ggui-ai/mcp-server-core": "0.
|
|
252
|
-
"@ggui-ai/
|
|
253
|
-
"@ggui-ai/
|
|
251
|
+
"@ggui-ai/mcp-server-core": "0.12.0",
|
|
252
|
+
"@ggui-ai/protocol": "0.12.0",
|
|
253
|
+
"@ggui-ai/project-config": "0.12.0"
|
|
254
254
|
},
|
|
255
255
|
"engines": {
|
|
256
256
|
"node": ">=20.0.0"
|
|
@@ -572,15 +572,15 @@ EVERY color, spacing, typography, shadow, and radius value MUST come from design
|
|
|
572
572
|
|
|
573
573
|
MANDATORY:
|
|
574
574
|
1. NEVER use hardcoded hex colors like \`#7c3aed\` — ONLY \`var(--ggui-color-*)\` tokens.
|
|
575
|
-
2. NEVER use CSS gradients with custom colors. If you need a gradient: \`linear-gradient(to bottom, var(--ggui-color-primary-500
|
|
575
|
+
2. NEVER use CSS gradients with custom colors. If you need a gradient: \`linear-gradient(to bottom, var(--ggui-color-primary-500), var(--ggui-color-primary-700))\`.
|
|
576
576
|
3. NEVER invent your own palette. The system provides primary, neutral, success, warning, error, and info — use ONLY these.
|
|
577
|
-
4.
|
|
577
|
+
4. NEVER include literal fallback values in color tokens — write \`var(--ggui-color-primary-600)\` bare, never \`var(--ggui-color-primary-600, #0284c7)\`. The runtime injects every token; a literal fallback paints the wrong brand exactly when the operator's theme matters most.
|
|
578
578
|
|
|
579
579
|
Token categories:
|
|
580
|
-
- Brand: \`var(--ggui-color-primary-600
|
|
581
|
-
- Text: \`var(--ggui-color-onSurface
|
|
582
|
-
- Backgrounds: \`var(--ggui-color-surface
|
|
583
|
-
- Borders: \`var(--ggui-color-outline
|
|
580
|
+
- Brand: \`var(--ggui-color-primary-600)\`, \`var(--ggui-color-primary-50)\`
|
|
581
|
+
- Text: \`var(--ggui-color-onSurface)\`, \`var(--ggui-color-onSurfaceVariant)\`
|
|
582
|
+
- Backgrounds: \`var(--ggui-color-surface)\`, \`var(--ggui-color-surfaceVariant)\`
|
|
583
|
+
- Borders: \`var(--ggui-color-outline)\`
|
|
584
584
|
- Spacing: \`var(--ggui-spacing-4, 16px)\`, \`var(--ggui-spacing-6, 24px)\`
|
|
585
585
|
- Typography: \`var(--ggui-font-size-sm, 14px)\`, \`var(--ggui-font-weight-semibold, 600)\`
|
|
586
586
|
- Shadows: \`var(--ggui-shape-shadow-sm)\`, \`var(--ggui-shape-shadow-md)\`, \`var(--ggui-shape-shadow-lg)\`
|
|
@@ -765,7 +765,7 @@ Hero sections should feel airy. Use \`padding="xl"\` (32px) on the main card, no
|
|
|
765
765
|
|
|
766
766
|
- [ ] Imports ONLY from: react, @ggui-ai/design, @ggui-ai/wire
|
|
767
767
|
- [ ] No raw HTML elements (\`<button>\`, \`<input>\`, \`<div>\` for layout) — uses primitives
|
|
768
|
-
- [ ] ZERO hardcoded hex colors — every color is \`var(--ggui-color
|
|
768
|
+
- [ ] ZERO hardcoded hex colors — every color is a bare \`var(--ggui-color-*)\`, no literal fallback
|
|
769
769
|
- [ ] No raw pixel values for spacing — all via \`var(--ggui-spacing-*)\` tokens
|
|
770
770
|
- [ ] Primary palette used throughout (headers, borders, icons) — not just buttons
|
|
771
771
|
- [ ] Typed Props interface exported; request-specific data is a prop with default
|
package/src/check/run-tier0.ts
CHANGED
|
@@ -508,22 +508,41 @@ export async function runTier0Checks(
|
|
|
508
508
|
// typed escape on Box (`assetColor` + `assetSemantic`) — see
|
|
509
509
|
// T-4 follow-up.
|
|
510
510
|
const hexMatch = line.match(/#[0-9a-fA-F]{3,8}\b/);
|
|
511
|
-
if (
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
511
|
+
if (hexMatch && !assetEscapedLines.has(lineNum)) {
|
|
512
|
+
// ggui#598 slice 4 (origin-kill): hex inside a `var(--ggui-*, #hex)`
|
|
513
|
+
// fallback is ALSO a violation now — the runtime injects every
|
|
514
|
+
// token, so a literal fallback can only ever paint default-brand
|
|
515
|
+
// pixels under a failed injection, which must be visible (unset)
|
|
516
|
+
// rather than silently sky-blue under someone's brand. The old
|
|
517
|
+
// exemption plus a remediation text that suggested
|
|
518
|
+
// `var(--x, ${hex})` actively taught the model to launder its
|
|
519
|
+
// invented hex INTO fallbacks (adversarial-cycle find).
|
|
520
|
+
const fallbackVarMatch = line.match(
|
|
521
|
+
/var\((--ggui-[a-zA-Z0-9-]+)\s*,[^)]*#[0-9a-fA-F]{3,8}/,
|
|
522
|
+
);
|
|
523
|
+
if (fallbackVarMatch) {
|
|
524
|
+
issues.push({
|
|
525
|
+
tier: 0,
|
|
526
|
+
result: 'fail',
|
|
527
|
+
category: 'tokens',
|
|
528
|
+
subcategory: 'token-fallback',
|
|
529
|
+
severity: 'critical',
|
|
530
|
+
description: `Literal fallback "${hexMatch[0]}" inside a token reference — the runtime injects every token, and the fallback paints the wrong brand exactly when the theme matters most.`,
|
|
531
|
+
fix: `Drop the fallback: write var(${fallbackVarMatch[1]}) bare. If the token might not exist, that is a vocabulary problem to fix by choosing a token from the design-system docs, never by a literal.`,
|
|
532
|
+
line: lineNum,
|
|
533
|
+
});
|
|
534
|
+
} else if (!line.includes('// fallback')) {
|
|
535
|
+
issues.push({
|
|
536
|
+
tier: 0,
|
|
537
|
+
result: 'fail',
|
|
538
|
+
category: 'tokens',
|
|
539
|
+
subcategory: 'hex-color',
|
|
540
|
+
severity: 'critical',
|
|
541
|
+
description: `Hardcoded color "${hexMatch[0]}" breaks theme switching — use design tokens.`,
|
|
542
|
+
fix: `Replace with a primitive variant (Button variant="primary"|Badge variant="success"|...) OR a bare token reference like var(--ggui-color-primary-500) — never a literal fallback. Hardcoded colors mean the operator's theme has no effect on this surface.`,
|
|
543
|
+
line: lineNum,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
527
546
|
}
|
|
528
547
|
|
|
529
548
|
// Hardcoded rgba()/hsl() color functions — same reasoning as above.
|
|
@@ -250,6 +250,34 @@ function toEvalIssue(issue: RenderCheckIssue): EvalIssue | null {
|
|
|
250
250
|
};
|
|
251
251
|
}
|
|
252
252
|
|
|
253
|
+
case "selection-identity":
|
|
254
|
+
return {
|
|
255
|
+
tier: 0,
|
|
256
|
+
result,
|
|
257
|
+
category: "contract",
|
|
258
|
+
subcategory,
|
|
259
|
+
severity: "critical",
|
|
260
|
+
description: `${issue.reason}${elementHint}${diagSuffix}`,
|
|
261
|
+
fix:
|
|
262
|
+
`Key the action payload (and any selection state) on the item's ID from the data, never on its display text. ` +
|
|
263
|
+
`Common fix: map over items and pass the item's id into the handler — onClick={() => ${subject}({ ...payload, id: item.id })} — ` +
|
|
264
|
+
`and store selection as the id (setSelected(item.id)), not the label.`,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
case "prop-sensitivity":
|
|
268
|
+
return {
|
|
269
|
+
tier: 0,
|
|
270
|
+
result,
|
|
271
|
+
category: "contract",
|
|
272
|
+
subcategory,
|
|
273
|
+
severity: "critical",
|
|
274
|
+
description: `${issue.reason}${diagSuffix}`,
|
|
275
|
+
fix:
|
|
276
|
+
`Derive the display AND any logic from props.${subject} — render {props.${subject}} and branch on the prop's value, ` +
|
|
277
|
+
`never on the literal value the request happened to mention (e.g. props.${subject} ?? 'January' followed by ` +
|
|
278
|
+
`January-special-cased logic bakes the request into the component). The contract delivers the value; trust it.`,
|
|
279
|
+
};
|
|
280
|
+
|
|
253
281
|
case "prop-coverage":
|
|
254
282
|
return {
|
|
255
283
|
tier: 0,
|
|
@@ -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();
|