@miller-tech/uap 1.54.0 → 1.56.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/.tsbuildinfo +1 -1
- package/dist/bin/cli.js +23 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/browser/web-browser.d.ts +17 -0
- package/dist/browser/web-browser.d.ts.map +1 -1
- package/dist/browser/web-browser.js +50 -0
- package/dist/browser/web-browser.js.map +1 -1
- package/dist/cli/hooks.d.ts.map +1 -1
- package/dist/cli/hooks.js +21 -0
- package/dist/cli/hooks.js.map +1 -1
- package/dist/cli/verify.d.ts +41 -0
- package/dist/cli/verify.d.ts.map +1 -0
- package/dist/cli/verify.js +96 -0
- package/dist/cli/verify.js.map +1 -0
- package/dist/delivery/execution-gate-runner.d.ts +12 -0
- package/dist/delivery/execution-gate-runner.d.ts.map +1 -0
- package/dist/delivery/execution-gate-runner.js +35 -0
- package/dist/delivery/execution-gate-runner.js.map +1 -0
- package/dist/delivery/execution-gate.d.ts +83 -0
- package/dist/delivery/execution-gate.d.ts.map +1 -0
- package/dist/delivery/execution-gate.js +746 -0
- package/dist/delivery/execution-gate.js.map +1 -0
- package/dist/delivery/verifier-ladder.d.ts +1 -1
- package/dist/delivery/verifier-ladder.d.ts.map +1 -1
- package/dist/delivery/verifier-ladder.js +12 -0
- package/dist/delivery/verifier-ladder.js.map +1 -1
- package/package.json +1 -1
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/templates/hooks/stop.sh +43 -3
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Execution gate — proves generated code actually RUNS, not just that files
|
|
3
|
+
* exist and parse.
|
|
4
|
+
*
|
|
5
|
+
* The build/type-check/test rungs in verifier-ladder catch syntax and (for TS)
|
|
6
|
+
* type errors, but a vanilla-JS project has none of those gates, so crash-class
|
|
7
|
+
* bugs — a temporal-dead-zone ReferenceError, an undefined cross-file global, a
|
|
8
|
+
* forgotten draw call — ship green. This gate closes that hole by executing the
|
|
9
|
+
* artifact in a type-appropriate runtime and failing on any uncaught error.
|
|
10
|
+
*
|
|
11
|
+
* It is exposed two ways:
|
|
12
|
+
* - `runExecutionGate(projectRoot)` — async, directly callable/testable.
|
|
13
|
+
* - `synthesizeExecutionRung(projectRoot)` — a GateRung that shells out to the
|
|
14
|
+
* sibling runner (execution-gate-runner) so the SYNC verifier ladder can run
|
|
15
|
+
* it via the normal spawn path (mirrors how deploy-dev is a special rung).
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from 'child_process';
|
|
18
|
+
import { createServer } from 'http';
|
|
19
|
+
import { createReadStream, existsSync, readdirSync, readFileSync, realpathSync, statSync } from 'fs';
|
|
20
|
+
import { extname, join, resolve, sep } from 'path';
|
|
21
|
+
import { fileURLToPath } from 'url';
|
|
22
|
+
import vm from 'vm';
|
|
23
|
+
/**
|
|
24
|
+
* Env for spawned smoke-runs with secret-bearing vars stripped, mirroring
|
|
25
|
+
* verifier-ladder's sanitizedEnv. Inlined (not imported) so the type-only
|
|
26
|
+
* back-edge to verifier-ladder stays type-only and no runtime import cycle
|
|
27
|
+
* forms. Generated code runs in this child, so it must never inherit
|
|
28
|
+
* provider credentials it could exfiltrate.
|
|
29
|
+
*/
|
|
30
|
+
function gateEnv() {
|
|
31
|
+
const out = {};
|
|
32
|
+
for (const [k, v] of Object.entries(process.env)) {
|
|
33
|
+
if (/API_KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL/i.test(k))
|
|
34
|
+
continue;
|
|
35
|
+
out[k] = v;
|
|
36
|
+
}
|
|
37
|
+
out.CI = 'true';
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
41
|
+
const DEFAULT_SETTLE_MS = 1_500;
|
|
42
|
+
/**
|
|
43
|
+
* Browser globals the vm-DOM harness does not model. A `ReferenceError` naming
|
|
44
|
+
* one of these is an environment limitation, NOT the app's bug, so the harness
|
|
45
|
+
* fails OPEN (advisory) rather than hard-blocking working code. The most common
|
|
46
|
+
* ones are stubbed in buildDomSandbox so apps that use them still execute; this
|
|
47
|
+
* list catches the long tail.
|
|
48
|
+
*/
|
|
49
|
+
const BROWSER_GLOBALS = new Set([
|
|
50
|
+
'Image', 'fetch', 'localStorage', 'sessionStorage', 'WebSocket', 'Worker',
|
|
51
|
+
'SharedWorker', 'IntersectionObserver', 'ResizeObserver', 'MutationObserver',
|
|
52
|
+
'requestIdleCallback', 'matchMedia', 'location', 'screen', 'history',
|
|
53
|
+
'alert', 'confirm', 'prompt', 'FileReader', 'Blob', 'File', 'indexedDB',
|
|
54
|
+
'crypto', 'OffscreenCanvas', 'WebGLRenderingContext', 'WebGL2RenderingContext',
|
|
55
|
+
'getComputedStyle', 'customElements', 'Notification', 'Audio', 'XMLHttpRequest',
|
|
56
|
+
'navigator', 'caches', 'BroadcastChannel', 'speechSynthesis', 'gtag', 'dataLayer',
|
|
57
|
+
]);
|
|
58
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'coverage', '.uap', 'agents']);
|
|
59
|
+
/** Resolve the compiled sibling runner so a GateRung can `node` it. */
|
|
60
|
+
export function executionRunnerPath() {
|
|
61
|
+
const p = fileURLToPath(new URL('./execution-gate-runner.js', import.meta.url));
|
|
62
|
+
if (existsSync(p))
|
|
63
|
+
return p;
|
|
64
|
+
// Running from TS source (vitest / tsx): the sibling .js lives in dist.
|
|
65
|
+
const distP = p.replace(`${sep}src${sep}`, `${sep}dist${sep}`);
|
|
66
|
+
return existsSync(distP) ? distP : p;
|
|
67
|
+
}
|
|
68
|
+
/** Find the directory containing an index.html within depth 2 (root + subdirs). */
|
|
69
|
+
export function findWebEntryDir(projectRoot) {
|
|
70
|
+
const root = resolve(projectRoot);
|
|
71
|
+
if (existsSync(join(root, 'index.html')))
|
|
72
|
+
return root;
|
|
73
|
+
let entries;
|
|
74
|
+
try {
|
|
75
|
+
entries = readdirSync(root);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
for (const e of entries) {
|
|
81
|
+
if (SKIP_DIRS.has(e) || e.startsWith('.'))
|
|
82
|
+
continue;
|
|
83
|
+
const sub = join(root, e);
|
|
84
|
+
try {
|
|
85
|
+
if (statSync(sub).isDirectory() && existsSync(join(sub, 'index.html')))
|
|
86
|
+
return sub;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
/* unreadable — skip */
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
/** Best-effort classification of what kind of artifact lives in the project. */
|
|
95
|
+
export function detectArtifactType(projectRoot) {
|
|
96
|
+
if (findWebEntryDir(projectRoot))
|
|
97
|
+
return 'web';
|
|
98
|
+
const pkgPath = join(projectRoot, 'package.json');
|
|
99
|
+
if (existsSync(pkgPath)) {
|
|
100
|
+
try {
|
|
101
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
102
|
+
if (pkg.bin)
|
|
103
|
+
return 'cli';
|
|
104
|
+
if (pkg.main || pkg.module || pkg.exports)
|
|
105
|
+
return 'lib';
|
|
106
|
+
// A scripts-only / bare package.json has no clear runnable entrypoint; such
|
|
107
|
+
// projects already carry build/test gates, so don't synthesize a redundant
|
|
108
|
+
// (and entryless) execution rung. Only declared entrypoints are gated.
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Static file server (web path) — no python dependency, binds to an ephemeral
|
|
119
|
+
// loopback port and serves a single directory.
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
const MIME = {
|
|
122
|
+
'.html': 'text/html',
|
|
123
|
+
'.js': 'text/javascript',
|
|
124
|
+
'.mjs': 'text/javascript',
|
|
125
|
+
'.css': 'text/css',
|
|
126
|
+
'.json': 'application/json',
|
|
127
|
+
'.png': 'image/png',
|
|
128
|
+
'.jpg': 'image/jpeg',
|
|
129
|
+
'.svg': 'image/svg+xml',
|
|
130
|
+
'.wasm': 'application/wasm',
|
|
131
|
+
};
|
|
132
|
+
export function startStaticServer(dir) {
|
|
133
|
+
// realpath the root so the symlink guard below compares like-for-like.
|
|
134
|
+
let rootReal;
|
|
135
|
+
try {
|
|
136
|
+
rootReal = realpathSync(resolve(dir));
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
rootReal = resolve(dir);
|
|
140
|
+
}
|
|
141
|
+
return new Promise((res, rej) => {
|
|
142
|
+
let server;
|
|
143
|
+
try {
|
|
144
|
+
server = createServer((req, response) => {
|
|
145
|
+
try {
|
|
146
|
+
let urlPath;
|
|
147
|
+
try {
|
|
148
|
+
urlPath = decodeURIComponent((req.url ?? '/').split('?')[0]);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
response.statusCode = 400;
|
|
152
|
+
response.end('bad request');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// Browsers auto-request /favicon.ico; answer it so the missing-favicon
|
|
156
|
+
// 404 is not mistaken for a real broken asset and failing the gate.
|
|
157
|
+
if (urlPath === '/favicon.ico') {
|
|
158
|
+
response.statusCode = 204;
|
|
159
|
+
response.end();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const rel = urlPath === '/' ? 'index.html' : urlPath.replace(/^\/+/, '');
|
|
163
|
+
const target = resolve(rootReal, rel);
|
|
164
|
+
// Lexical path-escape guard.
|
|
165
|
+
if (target !== rootReal && !target.startsWith(rootReal + '/')) {
|
|
166
|
+
response.statusCode = 403;
|
|
167
|
+
response.end('forbidden');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (!existsSync(target) || statSync(target).isDirectory()) {
|
|
171
|
+
response.statusCode = 404;
|
|
172
|
+
response.end('not found');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Symlink guard: resolve the real path and confirm it stays in-tree, so
|
|
176
|
+
// a model-planted symlink (link -> /etc) cannot read out-of-tree files.
|
|
177
|
+
let realTarget;
|
|
178
|
+
try {
|
|
179
|
+
realTarget = realpathSync(target);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
response.statusCode = 404;
|
|
183
|
+
response.end('not found');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (realTarget !== rootReal && !realTarget.startsWith(rootReal + '/')) {
|
|
187
|
+
response.statusCode = 403;
|
|
188
|
+
response.end('forbidden');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
response.setHeader('Content-Type', MIME[extname(realTarget).toLowerCase()] ?? 'application/octet-stream');
|
|
192
|
+
createReadStream(realTarget).pipe(response);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
// Never let a handler exception hang the request (would stall the gate).
|
|
196
|
+
try {
|
|
197
|
+
response.statusCode = 500;
|
|
198
|
+
response.end('error');
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
/* response already sent */
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
rej(e);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
server.on('error', rej);
|
|
211
|
+
server.listen(0, '127.0.0.1', () => {
|
|
212
|
+
const addr = server.address();
|
|
213
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
214
|
+
res({ url: `http://127.0.0.1:${port}/index.html`, close: () => server.close() });
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// Web execution — headless browser, with a vm+mocked-DOM fallback so the gate
|
|
220
|
+
// never silently no-ops when a real browser/chromium is unavailable.
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
async function runWeb(entryDir, opts) {
|
|
223
|
+
const start = Date.now();
|
|
224
|
+
const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
|
|
225
|
+
let server = null;
|
|
226
|
+
try {
|
|
227
|
+
server = await startStaticServer(entryDir);
|
|
228
|
+
let browser = null;
|
|
229
|
+
try {
|
|
230
|
+
browser = opts.browserFactory ? opts.browserFactory() : await loadWebBrowser();
|
|
231
|
+
await browser.launch({ headless: true });
|
|
232
|
+
}
|
|
233
|
+
catch (e) {
|
|
234
|
+
// Browser unavailable (e.g. no chromium) — fall back to the vm harness.
|
|
235
|
+
if (browser) {
|
|
236
|
+
try {
|
|
237
|
+
await browser.close();
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
/* ignore */
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return runVmDomHarness(entryDir, start, `browser launch failed: ${String(e).slice(0, 120)}`);
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
247
|
+
// Wall-clock guard so a hung goto/load never wedges a direct caller (the
|
|
248
|
+
// spawned-runner path also has the rung-level spawn timeout as a backstop).
|
|
249
|
+
const status = await withTimeout(browser.goto(server.url), timeoutMs, 'goto');
|
|
250
|
+
await Promise.race([browser.waitForLoadState('load'), delay(timeoutMs)]).catch(() => undefined);
|
|
251
|
+
await delay(settleMs);
|
|
252
|
+
const errors = browser.getErrors();
|
|
253
|
+
const durationMs = Date.now() - start;
|
|
254
|
+
// Accept any 2xx (and 304 cached) — exact '200' false-fails on benign codes.
|
|
255
|
+
const loaded = /^2\d\d$/.test(status) || status === '304';
|
|
256
|
+
if (!loaded) {
|
|
257
|
+
return {
|
|
258
|
+
passed: false,
|
|
259
|
+
exitCode: 1,
|
|
260
|
+
failureReason: `entry did not load (HTTP ${status})`,
|
|
261
|
+
outputTail: `GET index.html -> ${status}`,
|
|
262
|
+
durationMs,
|
|
263
|
+
via: 'browser',
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
// Fail ONLY on uncaught exceptions (pageerror) — the crash-class signal
|
|
267
|
+
// this gate exists for. console.error and failed sub-resource requests are
|
|
268
|
+
// common in working apps (handled errors, missing decorative assets), so
|
|
269
|
+
// they are advisory: surfaced in the output but never fail the gate.
|
|
270
|
+
const fatal = errors.filter((e) => e.kind === 'pageerror');
|
|
271
|
+
const advisory = errors.filter((e) => e.kind !== 'pageerror');
|
|
272
|
+
if (fatal.length > 0) {
|
|
273
|
+
return {
|
|
274
|
+
passed: false,
|
|
275
|
+
exitCode: 1,
|
|
276
|
+
failureReason: `${fatal.length} uncaught error(s) in the page`,
|
|
277
|
+
outputTail: [...fatal, ...advisory].map((e) => `[${e.kind}] ${e.message}`).join('\n').slice(0, 4000),
|
|
278
|
+
durationMs,
|
|
279
|
+
via: 'browser',
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
passed: true,
|
|
284
|
+
exitCode: 0,
|
|
285
|
+
outputTail: advisory.length > 0
|
|
286
|
+
? `page loaded and ran (no uncaught errors). advisory: ${advisory.map((e) => e.kind).join(', ')}`
|
|
287
|
+
: 'page loaded and ran with no console/page errors',
|
|
288
|
+
durationMs,
|
|
289
|
+
via: 'browser',
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
await browser.close().catch(() => undefined);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
297
|
+
return {
|
|
298
|
+
passed: false,
|
|
299
|
+
exitCode: 1,
|
|
300
|
+
failureReason: 'web execution gate error',
|
|
301
|
+
outputTail: String(e).slice(0, 2000),
|
|
302
|
+
durationMs: Date.now() - start,
|
|
303
|
+
via: 'browser',
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
server?.close();
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** Lazy-load the real WebBrowser (cloakbrowser) only when actually needed. */
|
|
311
|
+
async function loadWebBrowser() {
|
|
312
|
+
const mod = await import('../browser/web-browser.js');
|
|
313
|
+
return new mod.WebBrowser();
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* No-browser fallback: load the page's classic scripts in order inside a vm with
|
|
317
|
+
* a mocked DOM/canvas/AudioContext/requestAnimationFrame, boot, dispatch a click
|
|
318
|
+
* + mousemove, and tick a few animation frames. Catches the exact class of bug
|
|
319
|
+
* the octopus build had (TDZ/undefined-global/init throw). Only handles classic
|
|
320
|
+
* (non-module) <script src> tags — ES modules return a clean skip.
|
|
321
|
+
*/
|
|
322
|
+
export function runVmDomHarness(entryDir, startedAt, note) {
|
|
323
|
+
const start = startedAt ?? Date.now();
|
|
324
|
+
const indexPath = join(entryDir, 'index.html');
|
|
325
|
+
let html;
|
|
326
|
+
try {
|
|
327
|
+
html = readFileSync(indexPath, 'utf-8');
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return { passed: false, exitCode: null, failureReason: 'no index.html', outputTail: '', durationMs: Date.now() - start, via: 'none' };
|
|
331
|
+
}
|
|
332
|
+
// ES-module pages need real module semantics — the vm harness can't run them.
|
|
333
|
+
// Tolerate unquoted attrs (type=module) too.
|
|
334
|
+
if (/<script[^>]+type=["']?module/i.test(html)) {
|
|
335
|
+
return {
|
|
336
|
+
passed: true,
|
|
337
|
+
exitCode: 0,
|
|
338
|
+
failureReason: 'skipped (ES modules need a real browser)',
|
|
339
|
+
outputTail: 'vm-dom harness only runs classic scripts; install a headless browser for module apps',
|
|
340
|
+
durationMs: Date.now() - start,
|
|
341
|
+
via: 'none',
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
// Build the bundle from external <script src> (in order) AND inline <script>
|
|
345
|
+
// bodies, mirroring how the browser concatenates classic scripts into one
|
|
346
|
+
// shared global lexical scope. Missing src files are a real (broken) reference.
|
|
347
|
+
let bundle = '';
|
|
348
|
+
let scriptCount = 0;
|
|
349
|
+
const scriptRe = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
|
|
350
|
+
for (const m of html.matchAll(scriptRe)) {
|
|
351
|
+
const attrs = m[1] ?? '';
|
|
352
|
+
const srcMatch = /\bsrc\s*=\s*["']?([^"'\s>]+)/i.exec(attrs);
|
|
353
|
+
if (srcMatch) {
|
|
354
|
+
const s = srcMatch[1];
|
|
355
|
+
const p = join(entryDir, s.replace(/^\//, ''));
|
|
356
|
+
if (!existsSync(p)) {
|
|
357
|
+
return {
|
|
358
|
+
passed: false,
|
|
359
|
+
exitCode: 1,
|
|
360
|
+
failureReason: `script not found: ${s}`,
|
|
361
|
+
outputTail: `index.html references ${s} which does not exist`,
|
|
362
|
+
durationMs: Date.now() - start,
|
|
363
|
+
via: 'vm-dom',
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
bundle += `\n//=== ${s} ===\n${readFileSync(p, 'utf-8')}\n`;
|
|
367
|
+
scriptCount++;
|
|
368
|
+
}
|
|
369
|
+
else if (m[2] && m[2].trim()) {
|
|
370
|
+
bundle += `\n//=== inline ===\n${m[2]}\n`;
|
|
371
|
+
scriptCount++;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (scriptCount === 0) {
|
|
375
|
+
return { passed: true, exitCode: 0, outputTail: 'no scripts to execute', durationMs: Date.now() - start, via: 'vm-dom' };
|
|
376
|
+
}
|
|
377
|
+
const srcs = { length: scriptCount };
|
|
378
|
+
const sandbox = buildDomSandbox();
|
|
379
|
+
try {
|
|
380
|
+
vm.createContext(sandbox);
|
|
381
|
+
vm.runInContext(bundle, sandbox, { filename: 'bundle.js', timeout: 8000 });
|
|
382
|
+
// Drive a few frames + a start interaction to exercise the playing path.
|
|
383
|
+
const tick = (n) => {
|
|
384
|
+
let t = 0;
|
|
385
|
+
for (let i = 0; i < n && sandbox.__raf.cb; i++) {
|
|
386
|
+
const cb = sandbox.__raf.cb;
|
|
387
|
+
sandbox.__raf.cb = null;
|
|
388
|
+
cb(t += 16);
|
|
389
|
+
}
|
|
390
|
+
};
|
|
391
|
+
tick(3);
|
|
392
|
+
sandbox.__fire('click');
|
|
393
|
+
sandbox.__fire('mousedown');
|
|
394
|
+
sandbox.__fire('mousemove', { clientX: 100, clientY: 100 });
|
|
395
|
+
tick(60);
|
|
396
|
+
return {
|
|
397
|
+
passed: true,
|
|
398
|
+
exitCode: 0,
|
|
399
|
+
outputTail: `vm-dom: booted ${srcs.length} script(s), ran menu→playing frames clean${note ? ` (${note})` : ''}`,
|
|
400
|
+
durationMs: Date.now() - start,
|
|
401
|
+
via: 'vm-dom',
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
catch (e) {
|
|
405
|
+
const stack = e instanceof Error ? (e.stack ?? e.message) : String(e);
|
|
406
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
407
|
+
const tail = stack.split('\n').slice(0, 6).join('\n').slice(0, 4000);
|
|
408
|
+
// The vm sandbox is a partial browser. To never hard-block WORKING code, only
|
|
409
|
+
// fail on high-confidence real-bug signatures; treat env limitations as a
|
|
410
|
+
// fail-open advisory (the gate exists to catch crashes, not to punish apps
|
|
411
|
+
// for using a browser API the mock lacks).
|
|
412
|
+
const undefName = /(\w+) is not defined/.exec(msg)?.[1];
|
|
413
|
+
// A missing global is the harness's limit (fail-open), not the app's bug, when
|
|
414
|
+
// it's a known browser global OR a PascalCase name — browser/library globals
|
|
415
|
+
// are overwhelmingly constructors (Image, WebSocket, SpeechRecognition, THREE,
|
|
416
|
+
// …). An app's own missing symbol in a real bug is almost always a
|
|
417
|
+
// function/var (lowercase/camelCase), which we DO hard-fail on below.
|
|
418
|
+
const isLikelyBrowserOrLib = undefName !== undefined && (BROWSER_GLOBALS.has(undefName) || /^[A-Z]/.test(undefName));
|
|
419
|
+
if (isLikelyBrowserOrLib) {
|
|
420
|
+
return {
|
|
421
|
+
passed: true,
|
|
422
|
+
exitCode: 0,
|
|
423
|
+
failureReason: `inconclusive: '${undefName}' is not available in the harness`,
|
|
424
|
+
outputTail: `vm-dom can't model '${undefName}'. Install a headless browser for full coverage.`,
|
|
425
|
+
durationMs: Date.now() - start,
|
|
426
|
+
via: 'none',
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
const isTDZ = /before initialization/.test(msg);
|
|
430
|
+
const isSyntax = e instanceof SyntaxError;
|
|
431
|
+
const isAppUndefined = undefName !== undefined; // a lowercase/camelCase own symbol
|
|
432
|
+
if (isTDZ || isSyntax || isAppUndefined) {
|
|
433
|
+
return {
|
|
434
|
+
passed: false,
|
|
435
|
+
exitCode: 1,
|
|
436
|
+
failureReason: 'runtime error while executing the page',
|
|
437
|
+
outputTail: tail,
|
|
438
|
+
durationMs: Date.now() - start,
|
|
439
|
+
via: 'vm-dom',
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
// Ambiguous (e.g. a TypeError that could stem from the stub's fidelity rather
|
|
443
|
+
// than a real bug) → fail open with the detail surfaced, so we never wedge a
|
|
444
|
+
// session on a harness artifact. A real browser run would adjudicate these.
|
|
445
|
+
return {
|
|
446
|
+
passed: true,
|
|
447
|
+
exitCode: 0,
|
|
448
|
+
failureReason: `inconclusive (vm-dom): ${msg.slice(0, 120)}`,
|
|
449
|
+
outputTail: tail,
|
|
450
|
+
durationMs: Date.now() - start,
|
|
451
|
+
via: 'none',
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
/** A mocked browser global scope sufficient to boot a canvas app. */
|
|
456
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
457
|
+
function buildDomSandbox() {
|
|
458
|
+
const listeners = {
|
|
459
|
+
window: {},
|
|
460
|
+
document: {},
|
|
461
|
+
canvas: {},
|
|
462
|
+
};
|
|
463
|
+
const reg = (bag) => (type, fn) => {
|
|
464
|
+
(bag[type] ||= []).push(fn);
|
|
465
|
+
};
|
|
466
|
+
const ctxStub = new Proxy({}, {
|
|
467
|
+
get(_t, p) {
|
|
468
|
+
if (p === 'canvas')
|
|
469
|
+
return canvas;
|
|
470
|
+
if (p === 'measureText')
|
|
471
|
+
return () => ({ width: 10 });
|
|
472
|
+
if (p === 'createLinearGradient' || p === 'createRadialGradient')
|
|
473
|
+
return () => ({ addColorStop() { } });
|
|
474
|
+
if (p === 'getImageData')
|
|
475
|
+
return () => ({ data: new Uint8ClampedArray(4) });
|
|
476
|
+
if (['globalAlpha', 'lineWidth', 'font', 'fillStyle', 'strokeStyle', 'globalCompositeOperation', 'textAlign', 'textBaseline', 'shadowBlur', 'shadowColor', 'lineCap', 'lineJoin'].includes(String(p)))
|
|
477
|
+
return 0;
|
|
478
|
+
return () => { };
|
|
479
|
+
},
|
|
480
|
+
set() {
|
|
481
|
+
return true;
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
const canvas = {
|
|
485
|
+
width: 1280,
|
|
486
|
+
height: 720,
|
|
487
|
+
style: {},
|
|
488
|
+
getContext: () => ctxStub,
|
|
489
|
+
addEventListener: reg(listeners.canvas),
|
|
490
|
+
getBoundingClientRect: () => ({ left: 0, top: 0, width: 1280, height: 720 }),
|
|
491
|
+
};
|
|
492
|
+
const audioStub = new Proxy({}, {
|
|
493
|
+
get(_t, p) {
|
|
494
|
+
if (p === 'createOscillator')
|
|
495
|
+
return () => ({ connect() { }, start() { }, stop() { }, frequency: { setValueAtTime() { }, exponentialRampToValueAtTime() { }, linearRampToValueAtTime() { }, value: 0 }, type: '', detune: { setValueAtTime() { } } });
|
|
496
|
+
if (p === 'createGain')
|
|
497
|
+
return () => ({ connect() { }, gain: { setValueAtTime() { }, exponentialRampToValueAtTime() { }, linearRampToValueAtTime() { }, value: 0 } });
|
|
498
|
+
if (p === 'createBuffer')
|
|
499
|
+
return () => ({ getChannelData: () => new Float32Array(64) });
|
|
500
|
+
if (p === 'createBufferSource')
|
|
501
|
+
return () => ({ connect() { }, start() { }, stop() { }, buffer: null });
|
|
502
|
+
if (p === 'createBiquadFilter')
|
|
503
|
+
return () => ({ connect() { }, frequency: { setValueAtTime() { }, value: 0 }, type: '', Q: { value: 0 } });
|
|
504
|
+
if (p === 'destination')
|
|
505
|
+
return {};
|
|
506
|
+
if (p === 'currentTime')
|
|
507
|
+
return 0;
|
|
508
|
+
if (p === 'sampleRate')
|
|
509
|
+
return 44100;
|
|
510
|
+
return () => { };
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
const raf = { cb: null };
|
|
514
|
+
const win = {
|
|
515
|
+
innerWidth: 1280,
|
|
516
|
+
innerHeight: 720,
|
|
517
|
+
devicePixelRatio: 1,
|
|
518
|
+
addEventListener: reg(listeners.window),
|
|
519
|
+
requestAnimationFrame: (cb) => {
|
|
520
|
+
raf.cb = cb;
|
|
521
|
+
return 1;
|
|
522
|
+
},
|
|
523
|
+
cancelAnimationFrame: () => { },
|
|
524
|
+
AudioContext: function () {
|
|
525
|
+
return audioStub;
|
|
526
|
+
},
|
|
527
|
+
webkitAudioContext: function () {
|
|
528
|
+
return audioStub;
|
|
529
|
+
},
|
|
530
|
+
performance: { now: () => 0 },
|
|
531
|
+
};
|
|
532
|
+
const doc = {
|
|
533
|
+
getElementById: () => canvas,
|
|
534
|
+
querySelector: () => canvas,
|
|
535
|
+
addEventListener: reg(listeners.document),
|
|
536
|
+
createElement: () => canvas,
|
|
537
|
+
body: { appendChild() { }, style: {} },
|
|
538
|
+
};
|
|
539
|
+
const fire = (type, ev = {}) => {
|
|
540
|
+
const e = Object.assign({ preventDefault() { }, stopPropagation() { }, clientX: 640, clientY: 360, button: 0, key: '' }, ev);
|
|
541
|
+
for (const bag of [listeners.canvas, listeners.document, listeners.window]) {
|
|
542
|
+
(bag[type] || []).forEach((fn) => fn(e));
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
// Stubs for the most common browser globals so apps that use them actually run
|
|
546
|
+
// (rather than fail-open immediately). Long-tail globals are handled by the
|
|
547
|
+
// BROWSER_GLOBALS fail-open path in runVmDomHarness.
|
|
548
|
+
const makeStorage = () => {
|
|
549
|
+
const m = new Map();
|
|
550
|
+
return {
|
|
551
|
+
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
|
552
|
+
setItem: (k, v) => void m.set(String(k), String(v)),
|
|
553
|
+
removeItem: (k) => void m.delete(k),
|
|
554
|
+
clear: () => m.clear(),
|
|
555
|
+
key: (i) => [...m.keys()][i] ?? null,
|
|
556
|
+
get length() {
|
|
557
|
+
return m.size;
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
};
|
|
561
|
+
function ImageStub() {
|
|
562
|
+
this.src = '';
|
|
563
|
+
this.onload = null;
|
|
564
|
+
this.onerror = null;
|
|
565
|
+
this.width = 0;
|
|
566
|
+
this.height = 0;
|
|
567
|
+
this.addEventListener = () => { };
|
|
568
|
+
this.removeEventListener = () => { };
|
|
569
|
+
}
|
|
570
|
+
const common = {
|
|
571
|
+
localStorage: makeStorage(),
|
|
572
|
+
sessionStorage: makeStorage(),
|
|
573
|
+
Image: ImageStub,
|
|
574
|
+
fetch: () => Promise.resolve({
|
|
575
|
+
ok: true,
|
|
576
|
+
status: 200,
|
|
577
|
+
json: () => Promise.resolve({}),
|
|
578
|
+
text: () => Promise.resolve(''),
|
|
579
|
+
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
|
580
|
+
blob: () => Promise.resolve({}),
|
|
581
|
+
}),
|
|
582
|
+
location: { href: 'http://localhost/', protocol: 'http:', host: 'localhost', hostname: 'localhost', pathname: '/', search: '', hash: '', reload: () => { }, assign: () => { }, replace: () => { } },
|
|
583
|
+
screen: { width: 1280, height: 720, availWidth: 1280, availHeight: 720 },
|
|
584
|
+
matchMedia: () => ({ matches: false, media: '', addEventListener: () => { }, removeEventListener: () => { }, addListener: () => { }, removeListener: () => { } }),
|
|
585
|
+
getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
|
586
|
+
requestIdleCallback: (cb) => setTimeout(() => cb({ timeRemaining: () => 0, didTimeout: false }), 0),
|
|
587
|
+
cancelIdleCallback: () => { },
|
|
588
|
+
Audio: function () {
|
|
589
|
+
return { play: () => Promise.resolve(), pause: () => { }, addEventListener: () => { }, load: () => { }, currentTime: 0, volume: 1, loop: false };
|
|
590
|
+
},
|
|
591
|
+
URL,
|
|
592
|
+
URLSearchParams,
|
|
593
|
+
TextEncoder,
|
|
594
|
+
TextDecoder,
|
|
595
|
+
};
|
|
596
|
+
Object.assign(win, common);
|
|
597
|
+
return {
|
|
598
|
+
window: win,
|
|
599
|
+
document: doc,
|
|
600
|
+
console,
|
|
601
|
+
requestAnimationFrame: win.requestAnimationFrame,
|
|
602
|
+
cancelAnimationFrame: win.cancelAnimationFrame,
|
|
603
|
+
AudioContext: win.AudioContext,
|
|
604
|
+
webkitAudioContext: win.webkitAudioContext,
|
|
605
|
+
performance: win.performance,
|
|
606
|
+
navigator: { userAgent: 'uap-execution-gate', language: 'en-US', platform: 'uap', maxTouchPoints: 0 },
|
|
607
|
+
setTimeout,
|
|
608
|
+
clearTimeout,
|
|
609
|
+
setInterval: () => 0,
|
|
610
|
+
clearInterval,
|
|
611
|
+
Math,
|
|
612
|
+
Date,
|
|
613
|
+
isNaN,
|
|
614
|
+
parseInt,
|
|
615
|
+
parseFloat,
|
|
616
|
+
...common,
|
|
617
|
+
__raf: raf,
|
|
618
|
+
__fire: fire,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
function delay(ms) {
|
|
622
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
623
|
+
}
|
|
624
|
+
/** Reject if `p` does not settle within `ms` (bounds a hung browser call). */
|
|
625
|
+
function withTimeout(p, ms, label) {
|
|
626
|
+
return Promise.race([
|
|
627
|
+
p,
|
|
628
|
+
new Promise((_, rej) => setTimeout(() => rej(new Error(`${label} timed out after ${ms}ms`)), ms)),
|
|
629
|
+
]);
|
|
630
|
+
}
|
|
631
|
+
// ---------------------------------------------------------------------------
|
|
632
|
+
// Node / CLI / lib execution — run the entrypoint in a child process and fail
|
|
633
|
+
// on a non-zero exit or a thrown error at import/run time.
|
|
634
|
+
// ---------------------------------------------------------------------------
|
|
635
|
+
function runNodeLike(projectRoot, type, opts) {
|
|
636
|
+
const start = Date.now();
|
|
637
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
638
|
+
let entry = null;
|
|
639
|
+
let mode = 'import';
|
|
640
|
+
try {
|
|
641
|
+
const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf-8'));
|
|
642
|
+
if (type === 'cli' && pkg.bin) {
|
|
643
|
+
entry = typeof pkg.bin === 'string' ? pkg.bin : Object.values(pkg.bin)[0];
|
|
644
|
+
mode = 'help';
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
entry = pkg.main ?? pkg.module ?? 'index.js';
|
|
648
|
+
mode = 'import';
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
entry = 'index.js';
|
|
653
|
+
}
|
|
654
|
+
if (!entry || !existsSync(join(projectRoot, entry))) {
|
|
655
|
+
return {
|
|
656
|
+
passed: true,
|
|
657
|
+
exitCode: 0,
|
|
658
|
+
failureReason: `skipped (no runnable entry '${entry ?? '?'}')`,
|
|
659
|
+
outputTail: 'no entrypoint to smoke-run',
|
|
660
|
+
durationMs: Date.now() - start,
|
|
661
|
+
via: 'none',
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
const args = mode === 'help' ? [entry, '--help'] : ['-e', `import(${JSON.stringify('./' + entry)}).then(()=>process.exit(0)).catch(e=>{console.error(e&&e.stack||e);process.exit(1)})`];
|
|
665
|
+
const r = spawnSync('node', args, {
|
|
666
|
+
cwd: projectRoot,
|
|
667
|
+
encoding: 'utf-8',
|
|
668
|
+
timeout: timeoutMs,
|
|
669
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
670
|
+
env: gateEnv(),
|
|
671
|
+
});
|
|
672
|
+
const out = `${r.stdout ?? ''}${r.stderr ?? ''}`.slice(-4000);
|
|
673
|
+
const passed = r.status === 0;
|
|
674
|
+
return {
|
|
675
|
+
passed,
|
|
676
|
+
exitCode: r.status ?? 1,
|
|
677
|
+
failureReason: passed ? undefined : `entrypoint ${mode === 'help' ? '--help' : 'import'} exited ${r.status ?? 'null'}`,
|
|
678
|
+
outputTail: out || (passed ? 'ran clean' : 'no output'),
|
|
679
|
+
durationMs: Date.now() - start,
|
|
680
|
+
via: 'child-process',
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Execute the project's artifact and report whether it runs without error.
|
|
685
|
+
* Auto-detects the artifact type; returns a skip-pass when there is nothing
|
|
686
|
+
* runnable (the ladder's fail-closed floor lives in detectRungs, not here).
|
|
687
|
+
*/
|
|
688
|
+
export async function runExecutionGate(projectRoot, opts = {}) {
|
|
689
|
+
const type = detectArtifactType(projectRoot);
|
|
690
|
+
if (type === 'web') {
|
|
691
|
+
const dir = findWebEntryDir(projectRoot);
|
|
692
|
+
if (dir) {
|
|
693
|
+
// Classic-script apps: the vm-DOM harness is deterministic and reliably
|
|
694
|
+
// catches crash-class bugs (TDZ / undefined globals / init throws). A
|
|
695
|
+
// headless browser's async error capture is wrapper-dependent and has been
|
|
696
|
+
// observed to MISS uncaught errors (false-pass), so the browser is reserved
|
|
697
|
+
// for ES-module apps the vm harness cannot execute. Tests that inject a
|
|
698
|
+
// browserFactory still exercise the browser path explicitly.
|
|
699
|
+
let isModule = false;
|
|
700
|
+
try {
|
|
701
|
+
isModule = /<script[^>]+type=["']module["']/i.test(readFileSync(join(dir, 'index.html'), 'utf-8'));
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
/* missing index.html — runWeb/vm report it */
|
|
705
|
+
}
|
|
706
|
+
if (opts.browserFactory || isModule)
|
|
707
|
+
return runWeb(dir, opts);
|
|
708
|
+
return runVmDomHarness(dir);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (type === 'node' || type === 'cli' || type === 'lib') {
|
|
712
|
+
return runNodeLike(projectRoot, type, opts);
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
passed: true,
|
|
716
|
+
exitCode: 0,
|
|
717
|
+
failureReason: 'skipped (no detectable artifact)',
|
|
718
|
+
outputTail: '',
|
|
719
|
+
durationMs: 0,
|
|
720
|
+
via: 'none',
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* Build a GateRung that runs the execution gate via the sibling runner so the
|
|
725
|
+
* synchronous verifier ladder can spawn it like any other rung. Returns null
|
|
726
|
+
* when there is no runnable artifact to gate (caller decides fail-closed).
|
|
727
|
+
*/
|
|
728
|
+
export function synthesizeExecutionRung(projectRoot, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
729
|
+
const type = detectArtifactType(projectRoot);
|
|
730
|
+
if (!type)
|
|
731
|
+
return null;
|
|
732
|
+
// A smoke-run is quick; cap the budget well under a Stop hook's outer timeout
|
|
733
|
+
// so the rung's own timeout fires first (a clean 'timeout' failureReason →
|
|
734
|
+
// fail-open) and the spawned runner can't outlive the caller and orphan.
|
|
735
|
+
const budget = Math.min(timeoutMs, DEFAULT_TIMEOUT_MS) + 15_000;
|
|
736
|
+
return {
|
|
737
|
+
id: 'execution',
|
|
738
|
+
name: `Execution smoke (runs the ${type} artifact)`,
|
|
739
|
+
command: 'node',
|
|
740
|
+
args: [executionRunnerPath(), projectRoot],
|
|
741
|
+
required: true,
|
|
742
|
+
timeoutMs: budget,
|
|
743
|
+
tier: 'runtime',
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
//# sourceMappingURL=execution-gate.js.map
|