@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/dist/resume.js
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume host for `vmz test --mode resume` (T2/T3 first slice).
|
|
3
|
+
* SSR shell → resume adopt → event patch; onMount must not run.
|
|
4
|
+
*/
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { resolveChunkArtifacts } from './compile.js';
|
|
8
|
+
import { installHeadlessDocument } from './logic.js';
|
|
9
|
+
export async function runResumeManifest(manifest, ctx) {
|
|
10
|
+
const diagnostics = [];
|
|
11
|
+
const program = manifest.program && typeof manifest.program === 'object' ? manifest.program : {};
|
|
12
|
+
const chunkId = String(program.chunkId || '');
|
|
13
|
+
const programId = chunkId || null;
|
|
14
|
+
const fail = (message, extra = {}) => {
|
|
15
|
+
diagnostics.push({ severity: 'error', message, ...extra });
|
|
16
|
+
};
|
|
17
|
+
if (!chunkId) {
|
|
18
|
+
fail('program.chunkId missing');
|
|
19
|
+
return { status: 'error', diagnostics, planId: null, programId: null };
|
|
20
|
+
}
|
|
21
|
+
const arts = resolveChunkArtifacts(ctx.outDir, chunkId);
|
|
22
|
+
if (!arts.clientPath) {
|
|
23
|
+
fail(`missing ${chunkId}.client.js`);
|
|
24
|
+
return { status: 'failed', diagnostics, planId: null, programId };
|
|
25
|
+
}
|
|
26
|
+
installHeadlessDocument();
|
|
27
|
+
globalThis.requestIdleCallback = (cb) => setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 1 }), 0);
|
|
28
|
+
let dom;
|
|
29
|
+
let Page;
|
|
30
|
+
try {
|
|
31
|
+
dom = await import(pathToFileURL(path.join(ctx.outDir, 'vmz-dom.js')).href);
|
|
32
|
+
Page = (await import(pathToFileURL(arts.clientPath).href)).default;
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
fail(`import dist: ${e instanceof Error ? e.message : String(e)}`);
|
|
36
|
+
return { status: 'error', diagnostics, planId: null, programId };
|
|
37
|
+
}
|
|
38
|
+
const components = program.components && typeof program.components === 'object' ? program.components : undefined;
|
|
39
|
+
const loaded = {};
|
|
40
|
+
if (components) {
|
|
41
|
+
for (const [name, chunk] of Object.entries(components)) {
|
|
42
|
+
const cArts = resolveChunkArtifacts(ctx.outDir, chunk);
|
|
43
|
+
if (!cArts.clientPath) {
|
|
44
|
+
fail(`missing component ${chunk}`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
loaded[name] = (await import(pathToFileURL(cArts.clientPath).href)).default;
|
|
48
|
+
}
|
|
49
|
+
dom.registerComponents(loaded);
|
|
50
|
+
}
|
|
51
|
+
let html = '';
|
|
52
|
+
let island = null;
|
|
53
|
+
let inst = null;
|
|
54
|
+
let buttonBefore = null;
|
|
55
|
+
let onMountHits = 0;
|
|
56
|
+
let createHits = 0;
|
|
57
|
+
const resumeTarget = String(program.resumeComponent || Object.keys(loaded)[0] || '');
|
|
58
|
+
const actions = Array.isArray(manifest.actions) ? manifest.actions : [];
|
|
59
|
+
for (const raw of actions) {
|
|
60
|
+
const a = raw && typeof raw === 'object' ? raw : {};
|
|
61
|
+
const kind = String(a.kind || '');
|
|
62
|
+
try {
|
|
63
|
+
if (kind === 'ssr' || kind === 'renderToString') {
|
|
64
|
+
const props = a.props && typeof a.props === 'object' ? a.props : {};
|
|
65
|
+
html = await dom.renderToString(Page, props);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (kind === 'attachEventEntries') {
|
|
69
|
+
if (!html)
|
|
70
|
+
html = await dom.renderToString(Page, {});
|
|
71
|
+
document.body.innerHTML = `<div id="app">${html}</div>`;
|
|
72
|
+
const islandName = String(a.component || resumeTarget);
|
|
73
|
+
island = document.querySelector(`[data-vmz-island="${islandName}"]`);
|
|
74
|
+
if (!island) {
|
|
75
|
+
fail(`EventEntry host missing for ${islandName}`);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
buttonBefore = island.querySelector('button');
|
|
79
|
+
if (typeof dom.attachEventEntries !== 'function') {
|
|
80
|
+
fail('attachEventEntries missing from vmz-dom');
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
dom.attachEventEntries(document);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (kind === 'resumeIslands') {
|
|
87
|
+
if (!html)
|
|
88
|
+
html = await dom.renderToString(Page, {});
|
|
89
|
+
document.body.innerHTML = `<div id="app">${html}</div>`;
|
|
90
|
+
const islandName = String(a.component || resumeTarget);
|
|
91
|
+
island = document.querySelector(`[data-vmz-island="${islandName}"]`);
|
|
92
|
+
if (!island) {
|
|
93
|
+
fail(`island host missing for ${islandName}`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
buttonBefore = island.querySelector('button');
|
|
97
|
+
dom.resumeIslands(document);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (kind === 'resume') {
|
|
101
|
+
if (!html)
|
|
102
|
+
html = await dom.renderToString(Page, {});
|
|
103
|
+
document.body.innerHTML = `<div id="app">${html}</div>`;
|
|
104
|
+
const islandName = String(a.component || resumeTarget);
|
|
105
|
+
island = document.querySelector(`[data-vmz-island="${islandName}"]`);
|
|
106
|
+
if (!island) {
|
|
107
|
+
fail(`island host missing for ${islandName}`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
buttonBefore = island.querySelector('button');
|
|
111
|
+
const Comp = loaded[islandName];
|
|
112
|
+
if (!Comp) {
|
|
113
|
+
fail(`resume component ${islandName} not registered`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const prevMount = Comp.prototype.onMount;
|
|
117
|
+
Comp.prototype.onMount = function () {
|
|
118
|
+
onMountHits += 1;
|
|
119
|
+
if (typeof prevMount === 'function')
|
|
120
|
+
return prevMount.call(this);
|
|
121
|
+
};
|
|
122
|
+
const origCreate = Comp.__vmzCreate;
|
|
123
|
+
Comp.__vmzCreate = function (api) {
|
|
124
|
+
createHits += 1;
|
|
125
|
+
return origCreate.call(this, api);
|
|
126
|
+
};
|
|
127
|
+
inst = await dom.resume(Comp, island);
|
|
128
|
+
Comp.__vmzCreate = origCreate;
|
|
129
|
+
Comp.prototype.onMount = prevMount;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (kind === 'clickHost' || kind === 'clickIsland') {
|
|
133
|
+
const sel = typeof a.selector === 'string' ? a.selector : `[data-vmz-island="${String(a.component || resumeTarget)}"]`;
|
|
134
|
+
const el = document.querySelector(sel);
|
|
135
|
+
if (!el) {
|
|
136
|
+
fail(`clickHost: no ${sel}`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
island = el;
|
|
140
|
+
el.click();
|
|
141
|
+
// EventEntry resume is async via Promise.resolve(fn()).
|
|
142
|
+
await Promise.resolve();
|
|
143
|
+
await Promise.resolve();
|
|
144
|
+
await new Promise((r) => setTimeout(r, Number(a.waitMs ?? 10)));
|
|
145
|
+
inst = el.__vmzInst || inst;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (kind === 'click') {
|
|
149
|
+
const root = island || document.getElementById('app');
|
|
150
|
+
const sel = typeof a.selector === 'string' ? a.selector : 'button';
|
|
151
|
+
const el = root?.querySelector(sel);
|
|
152
|
+
if (!el) {
|
|
153
|
+
fail(`click: no ${sel}`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
el.click();
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (kind === 'flush') {
|
|
160
|
+
if (!inst) {
|
|
161
|
+
fail('flush before resume');
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
await dom.flushPending(inst);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (kind === 'assert') {
|
|
168
|
+
const assertion = String(a.assertion || 'html');
|
|
169
|
+
const expect = a.expect && typeof a.expect === 'object' ? a.expect : {};
|
|
170
|
+
if (assertion === 'html') {
|
|
171
|
+
if (expect.contains != null && !html.includes(String(expect.contains))) {
|
|
172
|
+
fail(`html contains want ${JSON.stringify(expect.contains)}`);
|
|
173
|
+
}
|
|
174
|
+
if (expect.notContains != null && html.includes(String(expect.notContains))) {
|
|
175
|
+
fail(`html notContains ${JSON.stringify(expect.notContains)}`);
|
|
176
|
+
}
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (assertion === 'resumed') {
|
|
180
|
+
const want = expect.value !== false;
|
|
181
|
+
const got = Boolean(island?.__vmzResumed);
|
|
182
|
+
if (got !== want)
|
|
183
|
+
fail(`__vmzResumed want ${want}, got ${got}`);
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
fail(`unknown resume assert ${assertion}`);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
fail(`unknown resume action ${JSON.stringify(kind)}`);
|
|
190
|
+
}
|
|
191
|
+
catch (e) {
|
|
192
|
+
fail(`action ${kind}: ${e instanceof Error ? e.message : String(e)}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
const assertions = Array.isArray(manifest.assertions) ? manifest.assertions : [];
|
|
196
|
+
for (const raw of assertions) {
|
|
197
|
+
const a = raw && typeof raw === 'object' ? raw : {};
|
|
198
|
+
const kind = String(a.kind || '');
|
|
199
|
+
const expect = a.expect && typeof a.expect === 'object' ? a.expect : {};
|
|
200
|
+
if (kind === 'html') {
|
|
201
|
+
if (expect.contains != null && !html.includes(String(expect.contains))) {
|
|
202
|
+
fail(`html contains want ${JSON.stringify(expect.contains)}`);
|
|
203
|
+
}
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (kind === 'resumed') {
|
|
207
|
+
const want = expect.value !== false;
|
|
208
|
+
if (Boolean(island?.__vmzResumed) !== want) {
|
|
209
|
+
fail(`__vmzResumed want ${want}, got ${Boolean(island?.__vmzResumed)}`);
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (kind === 'onMount') {
|
|
214
|
+
const want = Number(expect.hits ?? 0);
|
|
215
|
+
if (onMountHits !== want)
|
|
216
|
+
fail(`onMount hits want ${want}, got ${onMountHits}`);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (kind === 'createOnce') {
|
|
220
|
+
if (createHits !== 1)
|
|
221
|
+
fail(`__vmzCreate want 1, got ${createHits}`);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (kind === 'nodeIdentity') {
|
|
225
|
+
const after = island?.querySelector('button') ?? null;
|
|
226
|
+
if (!buttonBefore || !after || after !== buttonBefore) {
|
|
227
|
+
fail('button node identity changed on resume');
|
|
228
|
+
}
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
if (kind === 'text') {
|
|
232
|
+
const text = island?.querySelector('button')?.textContent ?? island?.textContent ?? '';
|
|
233
|
+
if (expect.contains != null && !text.includes(String(expect.contains))) {
|
|
234
|
+
fail(`text contains want ${JSON.stringify(expect.contains)}, got ${JSON.stringify(text)}`);
|
|
235
|
+
}
|
|
236
|
+
if (Array.isArray(expect.containsAny)) {
|
|
237
|
+
const ok = expect.containsAny.some((s) => text.includes(String(s)));
|
|
238
|
+
if (!ok) {
|
|
239
|
+
fail(`text containsAny want ${JSON.stringify(expect.containsAny)}, got ${JSON.stringify(text)}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (kind === 'graph' || kind === 'plan' || kind === 'deployment' || kind === 'diagnostic') {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
fail(`unknown resume assertion ${JSON.stringify(kind)}`);
|
|
248
|
+
}
|
|
249
|
+
const failed = diagnostics.some((d) => d.severity === 'error');
|
|
250
|
+
return {
|
|
251
|
+
status: failed ? 'failed' : 'passed',
|
|
252
|
+
diagnostics,
|
|
253
|
+
planId: null,
|
|
254
|
+
programId,
|
|
255
|
+
};
|
|
256
|
+
}
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic entry: run manifests without CLI discovery automation.
|
|
3
|
+
*/
|
|
4
|
+
import { type BuildOptions, type CompileResult } from './compile.js';
|
|
5
|
+
import { type LogicResult } from './logic.js';
|
|
6
|
+
import { type TestMode } from './protocol.js';
|
|
7
|
+
export type RunManifestOptions = BuildOptions & {
|
|
8
|
+
outDir?: string;
|
|
9
|
+
modes?: TestMode[];
|
|
10
|
+
/** When true, skip build and use existing outDir. */
|
|
11
|
+
reuseOutDir?: boolean;
|
|
12
|
+
};
|
|
13
|
+
export type ManifestRunResult = {
|
|
14
|
+
testId: string;
|
|
15
|
+
status: string;
|
|
16
|
+
diagnostics: unknown[];
|
|
17
|
+
programId: string | null;
|
|
18
|
+
planId: string | null;
|
|
19
|
+
compile?: CompileResult;
|
|
20
|
+
logic?: LogicResult;
|
|
21
|
+
outDir: string | null;
|
|
22
|
+
};
|
|
23
|
+
/** Run a single manifest object (no discovery). Caller supplies project root for build. */
|
|
24
|
+
export declare function runManifest(manifest: Record<string, unknown>, projectRoot: string, options?: RunManifestOptions): Promise<ManifestRunResult>;
|
|
25
|
+
/** Convenience: build report skeleton from runManifest results. */
|
|
26
|
+
export declare function resultsToReport(project: string, modes: TestMode[], results: ManifestRunResult[], files?: Record<string, string>): {
|
|
27
|
+
schema: string;
|
|
28
|
+
status: string;
|
|
29
|
+
project: string;
|
|
30
|
+
modes: TestMode[];
|
|
31
|
+
generatedAt: string;
|
|
32
|
+
tests: {
|
|
33
|
+
testId: string;
|
|
34
|
+
file: string;
|
|
35
|
+
modes: string[];
|
|
36
|
+
programId: string | null;
|
|
37
|
+
planId: string | null;
|
|
38
|
+
status: string;
|
|
39
|
+
diagnostics: unknown[];
|
|
40
|
+
trace: null;
|
|
41
|
+
snapshots: null;
|
|
42
|
+
coverage: null;
|
|
43
|
+
unknownReasons: string[];
|
|
44
|
+
}[];
|
|
45
|
+
};
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic entry: run manifests without CLI discovery automation.
|
|
3
|
+
*/
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { buildForCompile, runCompileManifest } from './compile.js';
|
|
6
|
+
import { runLogicManifest } from './logic.js';
|
|
7
|
+
import { buildTestReport } from './protocol.js';
|
|
8
|
+
/** Run a single manifest object (no discovery). Caller supplies project root for build. */
|
|
9
|
+
export async function runManifest(manifest, projectRoot, options = {}) {
|
|
10
|
+
const testId = String(manifest.id || 'anonymous');
|
|
11
|
+
const mModes = Array.isArray(manifest.modes) ? manifest.modes.map(String) : [];
|
|
12
|
+
const active = options.modes?.length ? options.modes : ['all'];
|
|
13
|
+
const modeActive = (name) => active.includes('all') || active.includes(name);
|
|
14
|
+
const doCompile = mModes.includes('compile') && modeActive('compile');
|
|
15
|
+
const doLogic = mModes.includes('logic') && modeActive('logic');
|
|
16
|
+
let outDir = options.outDir ?? null;
|
|
17
|
+
if ((doCompile || doLogic) && !options.reuseOutDir) {
|
|
18
|
+
const built = buildForCompile(projectRoot, options.outDir, options);
|
|
19
|
+
outDir = built.outDir;
|
|
20
|
+
if (!built.ok) {
|
|
21
|
+
return {
|
|
22
|
+
testId,
|
|
23
|
+
status: 'error',
|
|
24
|
+
diagnostics: [...built.diagnostics, { severity: 'error', message: built.error }],
|
|
25
|
+
programId: null,
|
|
26
|
+
planId: null,
|
|
27
|
+
outDir,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (!outDir && (doCompile || doLogic)) {
|
|
32
|
+
return {
|
|
33
|
+
testId,
|
|
34
|
+
status: 'error',
|
|
35
|
+
diagnostics: [{ severity: 'error', message: 'outDir unavailable' }],
|
|
36
|
+
programId: null,
|
|
37
|
+
planId: null,
|
|
38
|
+
outDir: null,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const diags = [];
|
|
42
|
+
const statuses = [];
|
|
43
|
+
let programId = null;
|
|
44
|
+
let planId = null;
|
|
45
|
+
let compile;
|
|
46
|
+
let logic;
|
|
47
|
+
if (doCompile && outDir) {
|
|
48
|
+
compile = runCompileManifest(manifest, { outDir });
|
|
49
|
+
statuses.push(compile.status);
|
|
50
|
+
diags.push(...compile.diagnostics);
|
|
51
|
+
programId = compile.programId;
|
|
52
|
+
planId = compile.planId;
|
|
53
|
+
}
|
|
54
|
+
if (doLogic && outDir) {
|
|
55
|
+
logic = await runLogicManifest(manifest, { outDir });
|
|
56
|
+
statuses.push(logic.status);
|
|
57
|
+
diags.push(...logic.diagnostics);
|
|
58
|
+
programId = logic.programId ?? programId;
|
|
59
|
+
planId = logic.planId ?? planId;
|
|
60
|
+
}
|
|
61
|
+
let status = 'skipped';
|
|
62
|
+
if (statuses.includes('error'))
|
|
63
|
+
status = 'error';
|
|
64
|
+
else if (statuses.includes('failed'))
|
|
65
|
+
status = 'failed';
|
|
66
|
+
else if (statuses.length && statuses.every((s) => s === 'passed'))
|
|
67
|
+
status = 'passed';
|
|
68
|
+
else if (!doCompile && !doLogic)
|
|
69
|
+
status = 'skipped';
|
|
70
|
+
return {
|
|
71
|
+
testId,
|
|
72
|
+
status,
|
|
73
|
+
diagnostics: diags,
|
|
74
|
+
programId,
|
|
75
|
+
planId,
|
|
76
|
+
compile,
|
|
77
|
+
logic,
|
|
78
|
+
outDir,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
/** Convenience: build report skeleton from runManifest results. */
|
|
82
|
+
export function resultsToReport(project, modes, results, files = {}) {
|
|
83
|
+
return buildTestReport({
|
|
84
|
+
project: path.relative(process.cwd(), project) || '.',
|
|
85
|
+
modes,
|
|
86
|
+
tests: results.map((r) => ({
|
|
87
|
+
testId: r.testId,
|
|
88
|
+
file: files[r.testId] || '',
|
|
89
|
+
modes: [],
|
|
90
|
+
programId: r.programId,
|
|
91
|
+
planId: r.planId,
|
|
92
|
+
status: r.status,
|
|
93
|
+
diagnostics: r.diagnostics,
|
|
94
|
+
})),
|
|
95
|
+
status: results.some((r) => r.status === 'failed' || r.status === 'error') ? 'failed' : results.length ? 'passed' : 'empty',
|
|
96
|
+
});
|
|
97
|
+
}
|
package/dist/ssr.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR / hydrate / stream host for `vmz test --mode ssr` (T2).
|
|
3
|
+
* Same Direct schedule as production via linkedom + renderToString / renderToStream / hydrate.
|
|
4
|
+
*/
|
|
5
|
+
type Diag = {
|
|
6
|
+
severity: string;
|
|
7
|
+
message: string;
|
|
8
|
+
[k: string]: unknown;
|
|
9
|
+
};
|
|
10
|
+
export type SsrResult = {
|
|
11
|
+
status: 'passed' | 'failed' | 'error';
|
|
12
|
+
diagnostics: Diag[];
|
|
13
|
+
planId: string | null;
|
|
14
|
+
programId: string | null;
|
|
15
|
+
};
|
|
16
|
+
export declare function runSsrManifest(manifest: Record<string, unknown>, ctx: {
|
|
17
|
+
outDir: string;
|
|
18
|
+
}): Promise<SsrResult>;
|
|
19
|
+
export {};
|
package/dist/ssr.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR / hydrate / stream host for `vmz test --mode ssr` (T2).
|
|
3
|
+
* Same Direct schedule as production via linkedom + renderToString / renderToStream / hydrate.
|
|
4
|
+
*/
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
import { resolveChunkArtifacts } from './compile.js';
|
|
8
|
+
import { installHeadlessDocument } from './logic.js';
|
|
9
|
+
export async function runSsrManifest(manifest, ctx) {
|
|
10
|
+
const diagnostics = [];
|
|
11
|
+
const program = manifest.program && typeof manifest.program === 'object' ? manifest.program : {};
|
|
12
|
+
const chunkId = String(program.chunkId || '');
|
|
13
|
+
const programId = chunkId || null;
|
|
14
|
+
const fail = (message, extra = {}) => {
|
|
15
|
+
diagnostics.push({ severity: 'error', message, ...extra });
|
|
16
|
+
};
|
|
17
|
+
if (!chunkId) {
|
|
18
|
+
fail('program.chunkId missing');
|
|
19
|
+
return { status: 'error', diagnostics, planId: null, programId: null };
|
|
20
|
+
}
|
|
21
|
+
const arts = resolveChunkArtifacts(ctx.outDir, chunkId);
|
|
22
|
+
if (!arts.clientPath) {
|
|
23
|
+
fail(`missing ${chunkId}.client.js`);
|
|
24
|
+
return { status: 'failed', diagnostics, planId: null, programId };
|
|
25
|
+
}
|
|
26
|
+
installHeadlessDocument();
|
|
27
|
+
let dom;
|
|
28
|
+
let Component;
|
|
29
|
+
try {
|
|
30
|
+
dom = await import(pathToFileURL(path.join(ctx.outDir, 'vmz-dom.js')).href);
|
|
31
|
+
Component = (await import(pathToFileURL(arts.clientPath).href)).default;
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
fail(`import dist: ${e instanceof Error ? e.message : String(e)}`);
|
|
35
|
+
return { status: 'error', diagnostics, planId: null, programId };
|
|
36
|
+
}
|
|
37
|
+
const components = program.components && typeof program.components === 'object' ? program.components : undefined;
|
|
38
|
+
if (components) {
|
|
39
|
+
const map = {};
|
|
40
|
+
for (const [name, chunk] of Object.entries(components)) {
|
|
41
|
+
const cArts = resolveChunkArtifacts(ctx.outDir, chunk);
|
|
42
|
+
if (!cArts.clientPath) {
|
|
43
|
+
fail(`missing component ${chunk}`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
map[name] = (await import(pathToFileURL(cArts.clientPath).href)).default;
|
|
47
|
+
}
|
|
48
|
+
dom.registerComponents(map);
|
|
49
|
+
}
|
|
50
|
+
let html = '';
|
|
51
|
+
let streamChunks = [];
|
|
52
|
+
let streamAborted = false;
|
|
53
|
+
let streamPullGapsMs = [];
|
|
54
|
+
let lastSsrProps = {};
|
|
55
|
+
let app = document.getElementById('app');
|
|
56
|
+
let inst = null;
|
|
57
|
+
let nodeBefore = null;
|
|
58
|
+
const actions = Array.isArray(manifest.actions) ? manifest.actions : [];
|
|
59
|
+
for (const raw of actions) {
|
|
60
|
+
const a = raw && typeof raw === 'object' ? raw : {};
|
|
61
|
+
const kind = String(a.kind || '');
|
|
62
|
+
try {
|
|
63
|
+
if (kind === 'ssr' || kind === 'renderToString') {
|
|
64
|
+
const props = a.props && typeof a.props === 'object' ? a.props : {};
|
|
65
|
+
lastSsrProps = props;
|
|
66
|
+
html = await dom.renderToString(Component, props);
|
|
67
|
+
streamChunks = [];
|
|
68
|
+
streamAborted = false;
|
|
69
|
+
streamPullGapsMs = [];
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (kind === 'renderToStream' || kind === 'stream') {
|
|
73
|
+
const props = a.props && typeof a.props === 'object' ? a.props : {};
|
|
74
|
+
lastSsrProps = props;
|
|
75
|
+
if (typeof dom.renderToStream !== 'function') {
|
|
76
|
+
fail('renderToStream missing from vmz-dom (rebuild example dist)');
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
streamChunks = [];
|
|
80
|
+
streamAborted = false;
|
|
81
|
+
streamPullGapsMs = [];
|
|
82
|
+
const abortAfter = a.abortAfterChunks != null ? Number(a.abortAfterChunks) : NaN;
|
|
83
|
+
const pullDelayMs = a.pullDelayMs != null ? Number(a.pullDelayMs) : 0;
|
|
84
|
+
const ac = new AbortController();
|
|
85
|
+
let lastPullAt = 0;
|
|
86
|
+
for await (const chunk of dom.renderToStream(Component, props, {
|
|
87
|
+
signal: ac.signal,
|
|
88
|
+
})) {
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
if (lastPullAt > 0)
|
|
91
|
+
streamPullGapsMs.push(now - lastPullAt);
|
|
92
|
+
lastPullAt = now;
|
|
93
|
+
streamChunks.push(String(chunk));
|
|
94
|
+
if (Number.isFinite(abortAfter) && streamChunks.length >= abortAfter) {
|
|
95
|
+
ac.abort();
|
|
96
|
+
streamAborted = true;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
if (pullDelayMs > 0) {
|
|
100
|
+
await new Promise((r) => setTimeout(r, pullDelayMs));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (ac.signal.aborted)
|
|
104
|
+
streamAborted = true;
|
|
105
|
+
html = streamChunks.join('');
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (kind === 'hydrate') {
|
|
109
|
+
const props = a.props && typeof a.props === 'object' ? a.props : {};
|
|
110
|
+
if (!html) {
|
|
111
|
+
html = await dom.renderToString(Component, props);
|
|
112
|
+
}
|
|
113
|
+
document.body.innerHTML = `<div id="app">${html}</div>`;
|
|
114
|
+
app = document.getElementById('app');
|
|
115
|
+
if (!app) {
|
|
116
|
+
fail('hydrate #app missing');
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const sel = typeof a.identitySelector === 'string' ? a.identitySelector : 'button';
|
|
120
|
+
nodeBefore = app.querySelector(sel);
|
|
121
|
+
inst = await dom.hydrate(Component, app, props);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (kind === 'mount') {
|
|
125
|
+
const props = a.props && typeof a.props === 'object' ? a.props : {};
|
|
126
|
+
if (!app)
|
|
127
|
+
app = document.getElementById('app');
|
|
128
|
+
inst = await dom.mount(Component, app, props);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (kind === 'write') {
|
|
132
|
+
if (!inst) {
|
|
133
|
+
fail('write before hydrate/mount');
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
inst[String(a.field || '')] = a.value;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (kind === 'flush') {
|
|
140
|
+
if (!inst) {
|
|
141
|
+
fail('flush before hydrate/mount');
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
await dom.flushPending(inst);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (kind === 'click') {
|
|
148
|
+
if (!app) {
|
|
149
|
+
fail('click before hydrate');
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const sel = typeof a.selector === 'string' ? a.selector : 'button';
|
|
153
|
+
const el = app.querySelector(sel);
|
|
154
|
+
if (!el) {
|
|
155
|
+
fail(`click: no ${sel}`);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
el.click();
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
fail(`unknown ssr action ${JSON.stringify(kind)}`);
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
fail(`action ${kind}: ${e instanceof Error ? e.message : String(e)}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const assertions = Array.isArray(manifest.assertions) ? manifest.assertions : [];
|
|
168
|
+
for (const raw of assertions) {
|
|
169
|
+
const a = raw && typeof raw === 'object' ? raw : {};
|
|
170
|
+
const kind = String(a.kind || '');
|
|
171
|
+
const expect = a.expect && typeof a.expect === 'object' ? a.expect : {};
|
|
172
|
+
if (kind === 'html') {
|
|
173
|
+
if (expect.contains != null && !html.includes(String(expect.contains))) {
|
|
174
|
+
fail(`html contains want ${JSON.stringify(expect.contains)}, got ${JSON.stringify(html)}`);
|
|
175
|
+
}
|
|
176
|
+
if (expect.notContains != null && html.includes(String(expect.notContains))) {
|
|
177
|
+
fail(`html notContains ${JSON.stringify(expect.notContains)}`);
|
|
178
|
+
}
|
|
179
|
+
if (expect.equals != null && html !== String(expect.equals)) {
|
|
180
|
+
fail(`html equals want ${JSON.stringify(expect.equals)}, got ${JSON.stringify(html)}`);
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (kind === 'stream') {
|
|
185
|
+
if (streamChunks.length === 0 && !html && expect.aborted !== true) {
|
|
186
|
+
fail('stream assertion without renderToStream action');
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (expect.chunkCountMin != null && streamChunks.length < Number(expect.chunkCountMin)) {
|
|
190
|
+
fail(`stream.chunkCountMin want >= ${expect.chunkCountMin}, got ${streamChunks.length}`);
|
|
191
|
+
}
|
|
192
|
+
if (expect.chunkCountMax != null && streamChunks.length > Number(expect.chunkCountMax)) {
|
|
193
|
+
fail(`stream.chunkCountMax want <= ${expect.chunkCountMax}, got ${streamChunks.length}`);
|
|
194
|
+
}
|
|
195
|
+
if (expect.aborted === true && !streamAborted) {
|
|
196
|
+
fail('stream.aborted want true, got false');
|
|
197
|
+
}
|
|
198
|
+
if (expect.aborted === false && streamAborted) {
|
|
199
|
+
fail('stream.aborted want false, got true');
|
|
200
|
+
}
|
|
201
|
+
if (expect.pullGapMinMs != null) {
|
|
202
|
+
const minGap = Number(expect.pullGapMinMs);
|
|
203
|
+
const ok = streamPullGapsMs.some((g) => g >= minGap);
|
|
204
|
+
if (!ok) {
|
|
205
|
+
fail(`stream.pullGapMinMs want >= ${minGap} between pulls, gaps=${JSON.stringify(streamPullGapsMs)}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (expect.equalsRenderToString === true) {
|
|
209
|
+
const asString = await dom.renderToString(Component, lastSsrProps);
|
|
210
|
+
if (html !== asString) {
|
|
211
|
+
fail(`stream join !== renderToString\nstream: ${JSON.stringify(html)}\nstring: ${JSON.stringify(asString)}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (expect.firstChunkContains != null) {
|
|
215
|
+
const first = streamChunks[0] || '';
|
|
216
|
+
if (!first.includes(String(expect.firstChunkContains))) {
|
|
217
|
+
fail(`stream.firstChunkContains want ${JSON.stringify(expect.firstChunkContains)}, got ${JSON.stringify(first)}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (kind === 'text') {
|
|
223
|
+
const text = app?.textContent ?? '';
|
|
224
|
+
if (expect.contains != null && !text.includes(String(expect.contains))) {
|
|
225
|
+
fail(`text contains want ${JSON.stringify(expect.contains)}, got ${JSON.stringify(text)}`);
|
|
226
|
+
}
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (kind === 'nodeIdentity') {
|
|
230
|
+
const sel = typeof expect.selector === 'string' ? expect.selector : 'button';
|
|
231
|
+
const after = app?.querySelector(sel) ?? null;
|
|
232
|
+
if (!nodeBefore || !after || after !== nodeBefore) {
|
|
233
|
+
fail(`nodeIdentity failed for ${sel}`);
|
|
234
|
+
}
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (kind === 'graph' || kind === 'plan' || kind === 'view' || kind === 'diagnostic') {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
fail(`unknown ssr assertion ${JSON.stringify(kind)}`);
|
|
241
|
+
}
|
|
242
|
+
const failed = diagnostics.some((d) => d.severity === 'error');
|
|
243
|
+
return {
|
|
244
|
+
status: failed ? 'failed' : 'passed',
|
|
245
|
+
diagnostics,
|
|
246
|
+
planId: null,
|
|
247
|
+
programId,
|
|
248
|
+
};
|
|
249
|
+
}
|