@vmz/test 0.0.0 → 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -1
- package/dist/browser.d.ts +27 -0
- package/dist/browser.js +585 -0
- package/dist/compile.d.ts +52 -0
- package/dist/compile.js +453 -0
- package/dist/deployment.d.ts +19 -0
- package/dist/deployment.js +181 -0
- package/dist/discover.d.ts +12 -0
- package/dist/discover.js +63 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +13 -0
- package/dist/logic.d.ts +40 -0
- package/dist/logic.js +378 -0
- package/dist/protocol.d.ts +54 -0
- package/dist/protocol.js +100 -0
- package/dist/resume.d.ts +19 -0
- package/dist/resume.js +256 -0
- package/dist/run.d.ts +45 -0
- package/dist/run.js +97 -0
- package/dist/ssr.d.ts +19 -0
- package/dist/ssr.js +249 -0
- package/package.json +46 -5
- package/src/browser.ts +602 -0
- package/src/compile.ts +509 -0
- package/src/deployment.ts +204 -0
- package/src/discover.ts +74 -0
- package/src/index.ts +37 -0
- package/src/logic.ts +427 -0
- package/src/protocol.ts +128 -0
- package/src/resume.ts +275 -0
- package/src/run.ts +124 -0
- package/src/ssr.ts +265 -0
package/README.md
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
1
|
# @vmz/test
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A test that only says “the button now says 2” leaves important product questions unanswered. Did the write also wake
|
|
4
|
+
unrelated UI? Did navigation dispose the old async task? Did the browser replay work that SSR had already done? Did a
|
|
5
|
+
server-only dependency find its way into the client?
|
|
6
|
+
|
|
7
|
+
`@vmz/test` is VMZ's answer to those questions. It tests against the same Program Graph and execution plan that produce
|
|
8
|
+
browser output, SSR, resumption, and deployment. That lets a scenario cover ordinary logic and real browser input, but
|
|
9
|
+
also routes, loading, cancellation, server capabilities, SSR/resume identity, and traces of the regions that actually
|
|
10
|
+
performed work.
|
|
11
|
+
|
|
12
|
+
What that gives a product team:
|
|
13
|
+
|
|
14
|
+
- **Behavioral confidence:** users can navigate, submit, and interact as expected.
|
|
15
|
+
- **Boundary confidence:** server-only work remains server-only; routes and capabilities resolve where they should.
|
|
16
|
+
- **Delivery confidence:** SSR and resume describe the same application without accidental replay.
|
|
17
|
+
- **Performance confidence:** a trace can show whether unrelated regions woke up.
|
|
18
|
+
|
|
19
|
+
The point is not to reject useful tools such as Vitest, Jest, or Playwright. They can remain valuable during migration
|
|
20
|
+
or as browser transport. They do not, however, define what VMZ means by a correct application. VMZ keeps that meaning in
|
|
21
|
+
one graph and one execution plan, so a failure can be traced to the actual boundary that failed rather than flattened
|
|
22
|
+
into a generic test callback.
|
|
23
|
+
|
|
24
|
+
Choose `@vmz/test` when you want tests to protect the architectural promises that made you choose VMZ: precise work,
|
|
25
|
+
safe server boundaries, SSR that does not needlessly replay, and application behavior that can be explained. 🧪
|
|
26
|
+
|
|
27
|
+
## One scenario, several kinds of evidence
|
|
28
|
+
|
|
29
|
+
| Surface | Evidence VMZ can collect |
|
|
30
|
+
|---|---|
|
|
31
|
+
| Compile | Diagnostics, graph identities, and generated boundaries |
|
|
32
|
+
| Logic | State transitions, derived work, and cancellation |
|
|
33
|
+
| Browser | Real input, semantic locators, DOM, accessibility, and screenshots |
|
|
34
|
+
| Router | Matched RouteId, layout retention, loading, errors, and disposal |
|
|
35
|
+
| SSR / Resume | HTML, serialized state, event entries, and replay avoidance |
|
|
36
|
+
| Deployment | Client/server reachability, capabilities, artifacts, and traces |
|
|
37
|
+
|
|
38
|
+
### UI automation without a borrowed worldview
|
|
39
|
+
|
|
40
|
+
VMZ can use a real browser protocol as transport while owning locator, action, waiting, and expectation semantics. Tests can find elements by role, label, text, RouteId, or stable test identity; CSS remains an escape hatch rather than the default contract.
|
|
41
|
+
|
|
42
|
+
Auto-waiting can observe navigation, pending work, region commits, and server activity instead of guessing with arbitrary delays. A failure can connect the visible symptom to the application work that produced it.
|
|
43
|
+
|
|
44
|
+
### Precision is testable
|
|
45
|
+
|
|
46
|
+
A fine-grained compiler should prove that nothing unrelated ran. VMZ tests can check that the intended binding changed, unrelated computations stayed idle, an obsolete generation was cancelled, and a disposed owner received no late write. That turns testing into a competitive feature rather than a bundled convenience.
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Host for `vmz test --mode browser` (T2 close slice).
|
|
3
|
+
*
|
|
4
|
+
* Real Chromium/Chrome via CDP. Transport may use puppeteer-core as a CDP
|
|
5
|
+
* client — that is NOT the Playwright/Puppeteer *test model*. Manifest actions
|
|
6
|
+
* and assertions remain the VMZ Browser Host protocol; same Direct schedule as
|
|
7
|
+
* production (`__vmzCreate` in a real document).
|
|
8
|
+
*
|
|
9
|
+
* Design: 规划设计/vmz/16 §T2 · §5 浏览器连接
|
|
10
|
+
*/
|
|
11
|
+
type Diag = {
|
|
12
|
+
severity: string;
|
|
13
|
+
message: string;
|
|
14
|
+
[k: string]: unknown;
|
|
15
|
+
};
|
|
16
|
+
export type BrowserResult = {
|
|
17
|
+
status: 'passed' | 'failed' | 'error';
|
|
18
|
+
diagnostics: Diag[];
|
|
19
|
+
planId: string | null;
|
|
20
|
+
programId: string | null;
|
|
21
|
+
};
|
|
22
|
+
export declare function runBrowserManifest(manifest: Record<string, unknown>, ctx: {
|
|
23
|
+
outDir: string;
|
|
24
|
+
}): Promise<BrowserResult>;
|
|
25
|
+
/** Resolve chrome path (for gates / diagnostics). */
|
|
26
|
+
export declare function resolveBrowserExecutable(): string | null;
|
|
27
|
+
export {};
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Host for `vmz test --mode browser` (T2 close slice).
|
|
3
|
+
*
|
|
4
|
+
* Real Chromium/Chrome via CDP. Transport may use puppeteer-core as a CDP
|
|
5
|
+
* client — that is NOT the Playwright/Puppeteer *test model*. Manifest actions
|
|
6
|
+
* and assertions remain the VMZ Browser Host protocol; same Direct schedule as
|
|
7
|
+
* production (`__vmzCreate` in a real document).
|
|
8
|
+
*
|
|
9
|
+
* Design: 规划设计/vmz/16 §T2 · §5 浏览器连接
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import http from 'node:http';
|
|
14
|
+
import net from 'node:net';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { resolveChunkArtifacts } from './compile.js';
|
|
18
|
+
const MIME = {
|
|
19
|
+
'.html': 'text/html; charset=utf-8',
|
|
20
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
21
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
22
|
+
'.json': 'application/json; charset=utf-8',
|
|
23
|
+
'.css': 'text/css; charset=utf-8',
|
|
24
|
+
'.map': 'application/json; charset=utf-8',
|
|
25
|
+
};
|
|
26
|
+
function findChromeExecutable() {
|
|
27
|
+
if (process.env.VMZ_BROWSER && fs.existsSync(process.env.VMZ_BROWSER)) {
|
|
28
|
+
return process.env.VMZ_BROWSER;
|
|
29
|
+
}
|
|
30
|
+
if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
|
|
31
|
+
return process.env.CHROME_PATH;
|
|
32
|
+
}
|
|
33
|
+
const candidates = process.platform === 'win32'
|
|
34
|
+
? [
|
|
35
|
+
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
36
|
+
path.join(process.env['PROGRAMFILES(X86)'] || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
37
|
+
path.join(process.env.LOCALAPPDATA || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
|
|
38
|
+
path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
|
|
39
|
+
]
|
|
40
|
+
: process.platform === 'darwin'
|
|
41
|
+
? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium']
|
|
42
|
+
: ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
|
|
43
|
+
for (const c of candidates) {
|
|
44
|
+
if (c && fs.existsSync(c))
|
|
45
|
+
return c;
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
function startStaticServer(rootDir) {
|
|
50
|
+
const server = http.createServer((req, res) => {
|
|
51
|
+
try {
|
|
52
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
53
|
+
let rel = decodeURIComponent(url.pathname);
|
|
54
|
+
if (rel === '/' || rel === '/__vmz/harness') {
|
|
55
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
56
|
+
res.end(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>vmz browser host</title></head><body><div id="app"></div></body></html>`);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (rel.startsWith('/'))
|
|
60
|
+
rel = rel.slice(1);
|
|
61
|
+
const filePath = path.normalize(path.join(rootDir, rel));
|
|
62
|
+
if (!filePath.startsWith(path.normalize(rootDir))) {
|
|
63
|
+
res.writeHead(403);
|
|
64
|
+
res.end('forbidden');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
|
|
68
|
+
res.writeHead(404);
|
|
69
|
+
res.end('not found');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
73
|
+
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
|
|
74
|
+
fs.createReadStream(filePath).pipe(res);
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
res.writeHead(500);
|
|
78
|
+
res.end(e instanceof Error ? e.message : String(e));
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
server.listen(0, '127.0.0.1', () => {
|
|
83
|
+
const addr = server.address();
|
|
84
|
+
if (!addr || typeof addr === 'string') {
|
|
85
|
+
reject(new Error('browser host: failed to bind'));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
resolve({
|
|
89
|
+
port: addr.port,
|
|
90
|
+
close: () => new Promise((res, rej) => {
|
|
91
|
+
server.close((err) => (err ? rej(err) : res()));
|
|
92
|
+
}),
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
server.on('error', reject);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
async function loadPuppeteerCore() {
|
|
99
|
+
try {
|
|
100
|
+
const mod = await import('puppeteer-core');
|
|
101
|
+
const puppeteer = mod?.default ?? mod;
|
|
102
|
+
if (typeof puppeteer?.launch !== 'function') {
|
|
103
|
+
throw new Error('puppeteer-core.launch missing');
|
|
104
|
+
}
|
|
105
|
+
if (typeof puppeteer?.connect !== 'function') {
|
|
106
|
+
throw new Error('puppeteer-core.connect missing');
|
|
107
|
+
}
|
|
108
|
+
return puppeteer;
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
throw new Error(`puppeteer-core required for browser mode (CDP transport). Install in @vmz/test or set workspace dep. (${err instanceof Error ? err.message : err})`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function waitLocalPort(port, ms = 20_000) {
|
|
115
|
+
const start = Date.now();
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
const tick = () => {
|
|
118
|
+
const socket = net.connect({ port, host: '127.0.0.1' }, () => {
|
|
119
|
+
socket.end();
|
|
120
|
+
resolve();
|
|
121
|
+
});
|
|
122
|
+
socket.on('error', () => {
|
|
123
|
+
if (Date.now() - start > ms)
|
|
124
|
+
reject(new Error(`port ${port} not open within ${ms}ms`));
|
|
125
|
+
else
|
|
126
|
+
setTimeout(tick, 100);
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
tick();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/** Spawn Chrome with remote debugging and connect — more reliable on GHA than puppeteer.launch. */
|
|
133
|
+
async function connectChromeViaDebugPort(puppeteer, chromePath, args) {
|
|
134
|
+
const profileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vmz-browser-'));
|
|
135
|
+
const port = 9200 + Math.floor(Math.random() * 700);
|
|
136
|
+
const child = spawn(chromePath, [...args, `--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-first-run', 'about:blank'], {
|
|
137
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
138
|
+
env: { ...process.env, HOME: profileDir },
|
|
139
|
+
});
|
|
140
|
+
child.stderr?.on('data', () => { });
|
|
141
|
+
child.stdout?.on('data', () => { });
|
|
142
|
+
try {
|
|
143
|
+
await waitLocalPort(port);
|
|
144
|
+
const browser = await puppeteer.connect({
|
|
145
|
+
browserURL: `http://127.0.0.1:${port}`,
|
|
146
|
+
protocolTimeout: 60_000,
|
|
147
|
+
});
|
|
148
|
+
return { browser, child, profileDir };
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
try {
|
|
152
|
+
child.kill('SIGKILL');
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* ignore */
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
fs.rmSync(profileDir, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
/* ignore */
|
|
162
|
+
}
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
export async function runBrowserManifest(manifest, ctx) {
|
|
167
|
+
const diagnostics = [];
|
|
168
|
+
const fail = (message, extra = {}) => {
|
|
169
|
+
diagnostics.push({ severity: 'error', message, ...extra });
|
|
170
|
+
};
|
|
171
|
+
const program = manifest.program && typeof manifest.program === 'object' ? manifest.program : {};
|
|
172
|
+
const chunkId = String(program.chunkId || '');
|
|
173
|
+
const programId = chunkId || null;
|
|
174
|
+
if (!chunkId) {
|
|
175
|
+
fail('program.chunkId missing');
|
|
176
|
+
return { status: 'error', diagnostics, planId: null, programId: null };
|
|
177
|
+
}
|
|
178
|
+
const arts = resolveChunkArtifacts(ctx.outDir, chunkId);
|
|
179
|
+
if (!arts.clientPath) {
|
|
180
|
+
fail(`missing ${chunkId}.client.js`);
|
|
181
|
+
return { status: 'failed', diagnostics, planId: null, programId };
|
|
182
|
+
}
|
|
183
|
+
const chrome = findChromeExecutable();
|
|
184
|
+
if (!chrome) {
|
|
185
|
+
fail('browser host: Chrome/Edge not found (set VMZ_BROWSER or CHROME_PATH to a Chromium binary)');
|
|
186
|
+
return { status: 'error', diagnostics, planId: null, programId };
|
|
187
|
+
}
|
|
188
|
+
let server = null;
|
|
189
|
+
let browser = null;
|
|
190
|
+
let profileDir = null;
|
|
191
|
+
let chromeChild = null;
|
|
192
|
+
try {
|
|
193
|
+
const puppeteer = await loadPuppeteerCore();
|
|
194
|
+
server = await startStaticServer(ctx.outDir);
|
|
195
|
+
const origin = `http://127.0.0.1:${server.port}`;
|
|
196
|
+
// CI: spawn+connect first (puppeteer.launch often "Connection closed" on Chrome for Testing).
|
|
197
|
+
const ci = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true';
|
|
198
|
+
const commonArgs = [
|
|
199
|
+
'--no-sandbox',
|
|
200
|
+
'--disable-setuid-sandbox',
|
|
201
|
+
'--disable-dev-shm-usage',
|
|
202
|
+
'--disable-gpu',
|
|
203
|
+
'--font-render-hinting=none',
|
|
204
|
+
'--mute-audio',
|
|
205
|
+
'--disable-extensions',
|
|
206
|
+
];
|
|
207
|
+
let lastLaunchErr;
|
|
208
|
+
if (ci) {
|
|
209
|
+
try {
|
|
210
|
+
if (process.env.VMZ_BROWSER_DEBUG === '1') {
|
|
211
|
+
console.error(`[vmz-test browser] chrome=${chrome} try=spawn+connect`);
|
|
212
|
+
}
|
|
213
|
+
const connected = await connectChromeViaDebugPort(puppeteer, chrome, [...commonArgs, '--headless=new']);
|
|
214
|
+
browser = connected.browser;
|
|
215
|
+
chromeChild = connected.child;
|
|
216
|
+
profileDir = connected.profileDir;
|
|
217
|
+
lastLaunchErr = null;
|
|
218
|
+
}
|
|
219
|
+
catch (err) {
|
|
220
|
+
lastLaunchErr = err;
|
|
221
|
+
console.error(`[vmz-test browser] spawn+connect failed: ${err instanceof Error ? err.message : err}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (!browser) {
|
|
225
|
+
const launchAttempts = ci
|
|
226
|
+
? [
|
|
227
|
+
{ label: 'pipe', pipe: true, args: [...commonArgs, '--headless=new'] },
|
|
228
|
+
{ label: 'ws', pipe: false, args: [...commonArgs, '--headless=new'] },
|
|
229
|
+
{
|
|
230
|
+
label: 'pipe+single-process',
|
|
231
|
+
pipe: true,
|
|
232
|
+
args: [...commonArgs, '--headless=new', '--single-process', '--disable-software-rasterizer'],
|
|
233
|
+
},
|
|
234
|
+
]
|
|
235
|
+
: [{ label: 'local', pipe: false, args: commonArgs }];
|
|
236
|
+
for (const attempt of launchAttempts) {
|
|
237
|
+
profileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vmz-browser-'));
|
|
238
|
+
try {
|
|
239
|
+
if (ci || process.env.VMZ_BROWSER_DEBUG === '1') {
|
|
240
|
+
console.error(`[vmz-test browser] chrome=${chrome} try=${attempt.label} args=${attempt.args.join(' ')}`);
|
|
241
|
+
}
|
|
242
|
+
browser = await puppeteer.launch({
|
|
243
|
+
executablePath: chrome,
|
|
244
|
+
headless: true,
|
|
245
|
+
pipe: attempt.pipe,
|
|
246
|
+
protocolTimeout: 60_000,
|
|
247
|
+
dumpio: process.env.VMZ_BROWSER_DEBUG === '1',
|
|
248
|
+
args: [...attempt.args, `--user-data-dir=${profileDir}`, '--no-first-run'],
|
|
249
|
+
});
|
|
250
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
251
|
+
lastLaunchErr = null;
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
lastLaunchErr = err;
|
|
256
|
+
browser = null;
|
|
257
|
+
try {
|
|
258
|
+
fs.rmSync(profileDir, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
/* ignore */
|
|
262
|
+
}
|
|
263
|
+
profileDir = null;
|
|
264
|
+
console.error(`[vmz-test browser] launch ${attempt.label} failed: ${err instanceof Error ? err.message : err}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (!browser) {
|
|
269
|
+
throw lastLaunchErr instanceof Error ? lastLaunchErr : new Error(`browser launch failed: ${String(lastLaunchErr)}`);
|
|
270
|
+
}
|
|
271
|
+
const page = await browser.newPage();
|
|
272
|
+
page.setDefaultTimeout(15000);
|
|
273
|
+
await page.goto(`${origin}/__vmz/harness`, { waitUntil: 'domcontentloaded' });
|
|
274
|
+
const components = program.components && typeof program.components === 'object' ? program.components : {};
|
|
275
|
+
const boot = await page.evaluate(async (cfg) => {
|
|
276
|
+
const dom = await import(/* @vite-ignore */ `${cfg.origin}/vmz-dom.js`);
|
|
277
|
+
const Comp = (await import(/* @vite-ignore */ `${cfg.origin}/${cfg.chunkPath}.client.js`)).default;
|
|
278
|
+
const map = {};
|
|
279
|
+
for (const [name, chunk] of Object.entries(cfg.components)) {
|
|
280
|
+
map[name] = (await import(/* @vite-ignore */ `${cfg.origin}/${chunk}.client.js`)).default;
|
|
281
|
+
}
|
|
282
|
+
if (Object.keys(map).length && typeof dom.registerComponents === 'function') {
|
|
283
|
+
dom.registerComponents(map);
|
|
284
|
+
}
|
|
285
|
+
const app = document.getElementById('app');
|
|
286
|
+
if (!app)
|
|
287
|
+
return { ok: false, error: '#app missing' };
|
|
288
|
+
if (!Comp?.__vmzDirect || typeof Comp.__vmzCreate !== 'function') {
|
|
289
|
+
return { ok: false, error: 'Direct __vmzCreate required' };
|
|
290
|
+
}
|
|
291
|
+
window.__vmzBrowser = {
|
|
292
|
+
dom,
|
|
293
|
+
Comp,
|
|
294
|
+
app,
|
|
295
|
+
inst: null,
|
|
296
|
+
buttonBefore: null,
|
|
297
|
+
capturedChild: null,
|
|
298
|
+
lastPrecision: null,
|
|
299
|
+
};
|
|
300
|
+
return { ok: true };
|
|
301
|
+
}, {
|
|
302
|
+
origin,
|
|
303
|
+
chunkPath: chunkId.replace(/\\/g, '/'),
|
|
304
|
+
components,
|
|
305
|
+
});
|
|
306
|
+
if (!boot?.ok) {
|
|
307
|
+
fail(`browser boot: ${boot?.error || 'unknown'}`);
|
|
308
|
+
return {
|
|
309
|
+
status: 'error',
|
|
310
|
+
diagnostics,
|
|
311
|
+
planId: null,
|
|
312
|
+
programId,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const actions = Array.isArray(manifest.actions) ? manifest.actions : [];
|
|
316
|
+
for (const raw of actions) {
|
|
317
|
+
const a = raw && typeof raw === 'object' ? raw : {};
|
|
318
|
+
const kind = String(a.kind || '');
|
|
319
|
+
try {
|
|
320
|
+
if (kind === 'mount') {
|
|
321
|
+
const props = a.props && typeof a.props === 'object' ? a.props : {};
|
|
322
|
+
const r = await page.evaluate(async (p) => {
|
|
323
|
+
const ctx = window.__vmzBrowser;
|
|
324
|
+
let createHits = 0;
|
|
325
|
+
const orig = ctx.Comp.__vmzCreate;
|
|
326
|
+
ctx.Comp.__vmzCreate = function (api) {
|
|
327
|
+
createHits += 1;
|
|
328
|
+
return orig.call(this, api);
|
|
329
|
+
};
|
|
330
|
+
ctx.inst = await ctx.dom.mount(ctx.Comp, ctx.app, p);
|
|
331
|
+
ctx.Comp.__vmzCreate = orig;
|
|
332
|
+
ctx.buttonBefore = ctx.app.querySelector('button');
|
|
333
|
+
return { createHits, text: ctx.app.textContent || '' };
|
|
334
|
+
}, props);
|
|
335
|
+
if (r.createHits !== 1)
|
|
336
|
+
fail(`mount must call __vmzCreate once, got ${r.createHits}`);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (kind === 'click') {
|
|
340
|
+
const selector = typeof a.selector === 'string' ? a.selector : 'button';
|
|
341
|
+
// Real browser input path: Element.click in page (not linkedom).
|
|
342
|
+
const ok = await page.evaluate((sel) => {
|
|
343
|
+
const ctx = window.__vmzBrowser;
|
|
344
|
+
const el = ctx.app.querySelector(sel);
|
|
345
|
+
if (!el)
|
|
346
|
+
return false;
|
|
347
|
+
el.click();
|
|
348
|
+
return true;
|
|
349
|
+
}, selector);
|
|
350
|
+
if (!ok)
|
|
351
|
+
fail(`click: no element for ${JSON.stringify(selector)}`);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (kind === 'write') {
|
|
355
|
+
const field = String(a.field || '');
|
|
356
|
+
await page.evaluate((args) => {
|
|
357
|
+
const ctx = window.__vmzBrowser;
|
|
358
|
+
if (!ctx.inst)
|
|
359
|
+
throw new Error('write before mount');
|
|
360
|
+
ctx.inst[args.field] = args.value;
|
|
361
|
+
}, { field, value: a.value });
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (kind === 'flush') {
|
|
365
|
+
await page.evaluate(async () => {
|
|
366
|
+
const ctx = window.__vmzBrowser;
|
|
367
|
+
if (!ctx.inst)
|
|
368
|
+
throw new Error('flush before mount');
|
|
369
|
+
await ctx.dom.flushPending(ctx.inst);
|
|
370
|
+
});
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (kind === 'destroy') {
|
|
374
|
+
await page.evaluate(() => {
|
|
375
|
+
const ctx = window.__vmzBrowser;
|
|
376
|
+
if (!ctx.inst)
|
|
377
|
+
throw new Error('destroy before mount');
|
|
378
|
+
ctx.dom.destroy(ctx.inst);
|
|
379
|
+
});
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (kind === 'capture_child') {
|
|
383
|
+
const selector = typeof a.selector === 'string' ? a.selector : '';
|
|
384
|
+
const ok = await page.evaluate((sel) => {
|
|
385
|
+
const ctx = window.__vmzBrowser;
|
|
386
|
+
const el = ctx.app.querySelector(sel);
|
|
387
|
+
if (!el?.__vmzInst)
|
|
388
|
+
return false;
|
|
389
|
+
ctx.capturedChild = el.__vmzInst;
|
|
390
|
+
return true;
|
|
391
|
+
}, selector);
|
|
392
|
+
if (!ok)
|
|
393
|
+
fail(`capture_child: no inst for ${JSON.stringify(selector)}`);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (kind === 'precision_reset') {
|
|
397
|
+
await page.evaluate(() => {
|
|
398
|
+
const ctx = window.__vmzBrowser;
|
|
399
|
+
if (typeof ctx.dom.__vmzPrecisionEnable === 'function')
|
|
400
|
+
ctx.dom.__vmzPrecisionEnable(true);
|
|
401
|
+
if (typeof ctx.dom.__vmzPrecisionReset === 'function')
|
|
402
|
+
ctx.dom.__vmzPrecisionReset();
|
|
403
|
+
});
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
fail(`unknown browser action ${JSON.stringify(kind)}`);
|
|
407
|
+
}
|
|
408
|
+
catch (e) {
|
|
409
|
+
fail(`action ${kind}: ${e instanceof Error ? e.message : String(e)}`);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const assertions = Array.isArray(manifest.assertions) ? manifest.assertions : [];
|
|
413
|
+
for (const raw of assertions) {
|
|
414
|
+
const a = raw && typeof raw === 'object' ? raw : {};
|
|
415
|
+
const kind = String(a.kind || '');
|
|
416
|
+
const expect = a.expect && typeof a.expect === 'object' ? a.expect : {};
|
|
417
|
+
if (kind === 'text') {
|
|
418
|
+
const text = await page.evaluate(() => {
|
|
419
|
+
const ctx = window.__vmzBrowser;
|
|
420
|
+
return ctx.app.textContent || '';
|
|
421
|
+
});
|
|
422
|
+
if (expect.equals != null && text !== String(expect.equals)) {
|
|
423
|
+
fail(`text equals want ${JSON.stringify(expect.equals)}, got ${JSON.stringify(text)}`);
|
|
424
|
+
}
|
|
425
|
+
if (expect.contains != null && !text.includes(String(expect.contains))) {
|
|
426
|
+
fail(`text contains want ${JSON.stringify(expect.contains)}, got ${JSON.stringify(text)}`);
|
|
427
|
+
}
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (kind === 'nodeIdentity') {
|
|
431
|
+
const sel = typeof expect.selector === 'string' ? expect.selector : 'button';
|
|
432
|
+
const same = await page.evaluate((s) => {
|
|
433
|
+
const ctx = window.__vmzBrowser;
|
|
434
|
+
const after = ctx.app.querySelector(s);
|
|
435
|
+
return !!(ctx.buttonBefore && after && after === ctx.buttonBefore);
|
|
436
|
+
}, sel);
|
|
437
|
+
if (!same)
|
|
438
|
+
fail(`nodeIdentity failed for ${sel} (real browser document)`);
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (kind === 'state') {
|
|
442
|
+
const state = await page.evaluate((keys) => {
|
|
443
|
+
const ctx = window.__vmzBrowser;
|
|
444
|
+
const out = {};
|
|
445
|
+
for (const k of keys)
|
|
446
|
+
out[k] = ctx.inst?.[k];
|
|
447
|
+
return out;
|
|
448
|
+
}, Object.keys(expect));
|
|
449
|
+
for (const [k, v] of Object.entries(expect)) {
|
|
450
|
+
if (state[k] !== v) {
|
|
451
|
+
fail(`state.${k} want ${JSON.stringify(v)}, got ${JSON.stringify(state[k])}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
if (kind === 'host') {
|
|
457
|
+
if (expect.kind === 'browser' || expect.realDocument === true) {
|
|
458
|
+
const ok = await page.evaluate(() => typeof document !== 'undefined' && !!document.createElement);
|
|
459
|
+
if (!ok)
|
|
460
|
+
fail('host.realDocument failed');
|
|
461
|
+
}
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (kind === 'destroyed') {
|
|
465
|
+
const want = expect.value !== false;
|
|
466
|
+
const got = await page.evaluate(() => {
|
|
467
|
+
const ctx = window.__vmzBrowser;
|
|
468
|
+
return Boolean(ctx.inst?.__vmzDestroyed);
|
|
469
|
+
});
|
|
470
|
+
if (got !== want)
|
|
471
|
+
fail(`__vmzDestroyed want ${want}, got ${got}`);
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (kind === 'childDestroyed') {
|
|
475
|
+
const want = expect.value !== false;
|
|
476
|
+
const got = await page.evaluate(() => {
|
|
477
|
+
const ctx = window.__vmzBrowser;
|
|
478
|
+
if (!ctx.capturedChild)
|
|
479
|
+
return null;
|
|
480
|
+
return Boolean(ctx.capturedChild.__vmzDestroyed);
|
|
481
|
+
});
|
|
482
|
+
if (got == null)
|
|
483
|
+
fail('childDestroyed: no captured child (use capture_child action)');
|
|
484
|
+
else if (got !== want)
|
|
485
|
+
fail(`child __vmzDestroyed want ${want}, got ${got}`);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (kind === 'precision') {
|
|
489
|
+
const snap = await page.evaluate(() => {
|
|
490
|
+
const ctx = window.__vmzBrowser;
|
|
491
|
+
if (typeof ctx.dom.__vmzPrecisionSnapshot !== 'function')
|
|
492
|
+
return null;
|
|
493
|
+
return ctx.dom.__vmzPrecisionSnapshot();
|
|
494
|
+
});
|
|
495
|
+
if (!snap) {
|
|
496
|
+
fail('precision snapshot unavailable');
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (expect.minWrites != null && Number(snap.writes || 0) < Number(expect.minWrites)) {
|
|
500
|
+
fail(`precision.writes want >= ${expect.minWrites}, got ${snap.writes}`);
|
|
501
|
+
}
|
|
502
|
+
if (expect.maxWrites != null && Number(snap.writes || 0) > Number(expect.maxWrites)) {
|
|
503
|
+
fail(`precision.writes want <= ${expect.maxWrites}, got ${snap.writes}`);
|
|
504
|
+
}
|
|
505
|
+
if (expect.maxBindingEvals != null && Number(snap.bindingEvals || 0) > Number(expect.maxBindingEvals)) {
|
|
506
|
+
fail(`precision.bindingEvals want <= ${expect.maxBindingEvals}, got ${snap.bindingEvals}`);
|
|
507
|
+
}
|
|
508
|
+
if (expect.maxPatchExecs != null && Number(snap.patchExecs || 0) > Number(expect.maxPatchExecs)) {
|
|
509
|
+
fail(`precision.patchExecs want <= ${expect.maxPatchExecs}, got ${snap.patchExecs}`);
|
|
510
|
+
}
|
|
511
|
+
if (expect.patchesIncludeDep != null) {
|
|
512
|
+
const dep = String(expect.patchesIncludeDep);
|
|
513
|
+
const map = snap.patchesByDep || {};
|
|
514
|
+
if (!map[dep])
|
|
515
|
+
fail(`precision.patchesByDep missing ${dep}: ${JSON.stringify(map)}`);
|
|
516
|
+
}
|
|
517
|
+
if (expect.writesIncludeRoot != null) {
|
|
518
|
+
const rootKey = String(expect.writesIncludeRoot);
|
|
519
|
+
const map = snap.writesByRoot || {};
|
|
520
|
+
if (!map[rootKey])
|
|
521
|
+
fail(`precision.writesByRoot missing ${rootKey}: ${JSON.stringify(map)}`);
|
|
522
|
+
}
|
|
523
|
+
if (expect.domCreates === 0 || expect.domCreates === false) {
|
|
524
|
+
if (Number(snap.domCreates || 0) !== 0) {
|
|
525
|
+
fail(`precision.domCreates want 0 after action window, got ${snap.domCreates}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
if (kind === 'graph' || kind === 'plan' || kind === 'diagnostic' || kind === 'view') {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
fail(`unknown browser assertion ${JSON.stringify(kind)}`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
catch (e) {
|
|
537
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
538
|
+
}
|
|
539
|
+
finally {
|
|
540
|
+
try {
|
|
541
|
+
if (browser) {
|
|
542
|
+
if (chromeChild)
|
|
543
|
+
await browser.disconnect();
|
|
544
|
+
else
|
|
545
|
+
await browser.close();
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
/* ignore */
|
|
550
|
+
}
|
|
551
|
+
try {
|
|
552
|
+
if (chromeChild)
|
|
553
|
+
chromeChild.kill('SIGKILL');
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
/* ignore */
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
if (profileDir) {
|
|
560
|
+
fs.rmSync(profileDir, { recursive: true, force: true });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
/* ignore */
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
if (server)
|
|
568
|
+
await server.close();
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
/* ignore */
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const failed = diagnostics.some((d) => d.severity === 'error');
|
|
575
|
+
return {
|
|
576
|
+
status: failed ? 'failed' : 'passed',
|
|
577
|
+
diagnostics,
|
|
578
|
+
planId: null,
|
|
579
|
+
programId,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/** Resolve chrome path (for gates / diagnostics). */
|
|
583
|
+
export function resolveBrowserExecutable() {
|
|
584
|
+
return findChromeExecutable();
|
|
585
|
+
}
|