@vmz/test 0.0.3 → 0.1.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/browser-evidence.d.ts +32 -0
- package/dist/browser-evidence.js +51 -0
- package/dist/browser-protocol.d.ts +56 -0
- package/dist/browser-protocol.js +249 -0
- package/dist/browser-serve.d.ts +19 -0
- package/dist/browser-serve.js +130 -0
- package/dist/browser.d.ts +6 -3
- package/dist/browser.js +609 -164
- package/dist/compile.js +84 -8
- package/dist/deployment.js +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/logic.js +14 -3
- package/dist/resume.js +2 -2
- package/dist/ssr.js +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Host evidence stub (U3 thin): failure screenshot + wall-clock step timing.
|
|
3
|
+
* Not a full U3 artifact pack (no network.json / accessible-tree / trace viewer).
|
|
4
|
+
*/
|
|
5
|
+
export type StepTiming = {
|
|
6
|
+
phase: 'action' | 'assertion';
|
|
7
|
+
kind: string;
|
|
8
|
+
ms: number;
|
|
9
|
+
ok: boolean;
|
|
10
|
+
detail?: string;
|
|
11
|
+
};
|
|
12
|
+
export type BrowserTiming = {
|
|
13
|
+
schema: 'vmz.test.browser.timing.v0';
|
|
14
|
+
totalMs: number;
|
|
15
|
+
steps: StepTiming[];
|
|
16
|
+
};
|
|
17
|
+
export type EvidencePaths = {
|
|
18
|
+
dir: string;
|
|
19
|
+
screenshot?: string;
|
|
20
|
+
timing?: string;
|
|
21
|
+
dom?: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function createArtifactsDir(outDir: string, testId: string): string;
|
|
24
|
+
export declare function createTempArtifactsDir(testId: string): string;
|
|
25
|
+
export declare function writeFailureEvidence(page: {
|
|
26
|
+
screenshot?: (opts: {
|
|
27
|
+
path: string;
|
|
28
|
+
fullPage?: boolean;
|
|
29
|
+
}) => Promise<unknown>;
|
|
30
|
+
content?: () => Promise<string>;
|
|
31
|
+
}, artifactsDir: string, timing: BrowserTiming): Promise<EvidencePaths>;
|
|
32
|
+
export declare function writeTimingOnly(artifactsDir: string, timing: BrowserTiming): string;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Host evidence stub (U3 thin): failure screenshot + wall-clock step timing.
|
|
3
|
+
* Not a full U3 artifact pack (no network.json / accessible-tree / trace viewer).
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
export function createArtifactsDir(outDir, testId) {
|
|
9
|
+
const safe = String(testId || 'anonymous').replace(/[^\w.-]+/g, '_');
|
|
10
|
+
const base = path.join(outDir, '_vmz', 'test-artifacts', safe);
|
|
11
|
+
fs.mkdirSync(base, { recursive: true });
|
|
12
|
+
return base;
|
|
13
|
+
}
|
|
14
|
+
export function createTempArtifactsDir(testId) {
|
|
15
|
+
const safe = String(testId || 'anonymous').replace(/[^\w.-]+/g, '_');
|
|
16
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), `vmz-bh-${safe}-`));
|
|
17
|
+
}
|
|
18
|
+
export async function writeFailureEvidence(page, artifactsDir, timing) {
|
|
19
|
+
fs.mkdirSync(artifactsDir, { recursive: true });
|
|
20
|
+
const out = { dir: artifactsDir };
|
|
21
|
+
const timingPath = path.join(artifactsDir, 'timing.json');
|
|
22
|
+
fs.writeFileSync(timingPath, JSON.stringify(timing, null, 2), 'utf8');
|
|
23
|
+
out.timing = timingPath;
|
|
24
|
+
try {
|
|
25
|
+
if (typeof page.screenshot === 'function') {
|
|
26
|
+
const shot = path.join(artifactsDir, 'screenshot.png');
|
|
27
|
+
await page.screenshot({ path: shot, fullPage: true });
|
|
28
|
+
out.screenshot = shot;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* screenshot optional */
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
if (typeof page.content === 'function') {
|
|
36
|
+
const domPath = path.join(artifactsDir, 'dom.html');
|
|
37
|
+
fs.writeFileSync(domPath, await page.content(), 'utf8');
|
|
38
|
+
out.dom = domPath;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
/* optional */
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
export function writeTimingOnly(artifactsDir, timing) {
|
|
47
|
+
fs.mkdirSync(artifactsDir, { recursive: true });
|
|
48
|
+
const timingPath = path.join(artifactsDir, 'timing.json');
|
|
49
|
+
fs.writeFileSync(timingPath, JSON.stringify(timing, null, 2), 'utf8');
|
|
50
|
+
return timingPath;
|
|
51
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Host U0 protocol — Locator / Action / Expectation shapes.
|
|
3
|
+
*
|
|
4
|
+
* Owns VMZ locator semantics. CDP/puppeteer-core is transport only.
|
|
5
|
+
* Legacy `selector` strings lower to `{ kind: 'css' }` with a warning (escape hatch).
|
|
6
|
+
*/
|
|
7
|
+
export declare const BROWSER_LOCATOR_KINDS: readonly ["role", "label", "text", "testId", "css"];
|
|
8
|
+
export type BrowserLocator = {
|
|
9
|
+
kind: 'role';
|
|
10
|
+
role: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
exact?: boolean;
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'label';
|
|
15
|
+
text: string;
|
|
16
|
+
exact?: boolean;
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'text';
|
|
19
|
+
text: string;
|
|
20
|
+
exact?: boolean;
|
|
21
|
+
} | {
|
|
22
|
+
kind: 'testId';
|
|
23
|
+
testId: string;
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'css';
|
|
26
|
+
selector: string;
|
|
27
|
+
};
|
|
28
|
+
export type BrowserActionOptions = {
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
force?: boolean;
|
|
31
|
+
};
|
|
32
|
+
export type LocatorResolveResult = {
|
|
33
|
+
ok: boolean;
|
|
34
|
+
count: number;
|
|
35
|
+
actionable: boolean;
|
|
36
|
+
reason: string;
|
|
37
|
+
index: number;
|
|
38
|
+
tag?: string;
|
|
39
|
+
name?: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Parse locator from a browser action object.
|
|
43
|
+
* Prefers `locator`; falls back to legacy `selector` → css (warning).
|
|
44
|
+
*/
|
|
45
|
+
export declare function parseActionLocator(action: Record<string, unknown>): {
|
|
46
|
+
locator: BrowserLocator | null;
|
|
47
|
+
warnings: string[];
|
|
48
|
+
};
|
|
49
|
+
/** Default click target when neither locator nor selector is set (Direct mount harness). */
|
|
50
|
+
export declare function defaultClickLocator(): BrowserLocator;
|
|
51
|
+
/**
|
|
52
|
+
* In-page locator resolver (serializable for page.evaluate).
|
|
53
|
+
* Returns match metadata; host waits until unique + actionable.
|
|
54
|
+
*/
|
|
55
|
+
export declare function resolveLocatorInPage(locator: BrowserLocator, opts?: BrowserActionOptions): LocatorResolveResult;
|
|
56
|
+
export declare function sleep(ms: number): Promise<void>;
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Host U0 protocol — Locator / Action / Expectation shapes.
|
|
3
|
+
*
|
|
4
|
+
* Owns VMZ locator semantics. CDP/puppeteer-core is transport only.
|
|
5
|
+
* Legacy `selector` strings lower to `{ kind: 'css' }` with a warning (escape hatch).
|
|
6
|
+
*/
|
|
7
|
+
export const BROWSER_LOCATOR_KINDS = Object.freeze(['role', 'label', 'text', 'testId', 'css']);
|
|
8
|
+
/**
|
|
9
|
+
* Parse locator from a browser action object.
|
|
10
|
+
* Prefers `locator`; falls back to legacy `selector` → css (warning).
|
|
11
|
+
*/
|
|
12
|
+
export function parseActionLocator(action) {
|
|
13
|
+
const warnings = [];
|
|
14
|
+
const loc = action && typeof action.locator === 'object' && action.locator
|
|
15
|
+
? action.locator
|
|
16
|
+
: null;
|
|
17
|
+
if (loc) {
|
|
18
|
+
const kind = String(loc.kind || '');
|
|
19
|
+
if (kind === 'role') {
|
|
20
|
+
const role = String(loc.role || '').trim();
|
|
21
|
+
if (!role)
|
|
22
|
+
return { locator: null, warnings: ['locator.role required'] };
|
|
23
|
+
return {
|
|
24
|
+
locator: {
|
|
25
|
+
kind: 'role',
|
|
26
|
+
role,
|
|
27
|
+
name: loc.name != null ? String(loc.name) : undefined,
|
|
28
|
+
exact: loc.exact === true,
|
|
29
|
+
},
|
|
30
|
+
warnings,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (kind === 'label') {
|
|
34
|
+
const text = String(loc.text || '').trim();
|
|
35
|
+
if (!text)
|
|
36
|
+
return { locator: null, warnings: ['locator.text required for label'] };
|
|
37
|
+
return { locator: { kind: 'label', text, exact: loc.exact === true }, warnings };
|
|
38
|
+
}
|
|
39
|
+
if (kind === 'text') {
|
|
40
|
+
const text = String(loc.text || '').trim();
|
|
41
|
+
if (!text)
|
|
42
|
+
return { locator: null, warnings: ['locator.text required'] };
|
|
43
|
+
return { locator: { kind: 'text', text, exact: loc.exact === true }, warnings };
|
|
44
|
+
}
|
|
45
|
+
if (kind === 'testId') {
|
|
46
|
+
const testId = String(loc.testId || loc.value || '').trim();
|
|
47
|
+
if (!testId)
|
|
48
|
+
return { locator: null, warnings: ['locator.testId required'] };
|
|
49
|
+
return { locator: { kind: 'testId', testId }, warnings };
|
|
50
|
+
}
|
|
51
|
+
if (kind === 'css') {
|
|
52
|
+
const selector = String(loc.selector || '').trim();
|
|
53
|
+
if (!selector)
|
|
54
|
+
return { locator: null, warnings: ['locator.selector required for css'] };
|
|
55
|
+
warnings.push('locator.kind=css is an escape hatch; prefer role/label/text/testId');
|
|
56
|
+
return { locator: { kind: 'css', selector }, warnings };
|
|
57
|
+
}
|
|
58
|
+
return { locator: null, warnings: [`unknown locator.kind ${JSON.stringify(kind)}`] };
|
|
59
|
+
}
|
|
60
|
+
if (typeof action.selector === 'string' && action.selector.trim()) {
|
|
61
|
+
warnings.push('action.selector is legacy css escape hatch; prefer action.locator');
|
|
62
|
+
return { locator: { kind: 'css', selector: action.selector.trim() }, warnings };
|
|
63
|
+
}
|
|
64
|
+
return { locator: null, warnings };
|
|
65
|
+
}
|
|
66
|
+
/** Default click target when neither locator nor selector is set (Direct mount harness). */
|
|
67
|
+
export function defaultClickLocator() {
|
|
68
|
+
return { kind: 'role', role: 'button' };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* In-page locator resolver (serializable for page.evaluate).
|
|
72
|
+
* Returns match metadata; host waits until unique + actionable.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveLocatorInPage(locator, opts = {}) {
|
|
75
|
+
const root = document.getElementById('app') || document.body;
|
|
76
|
+
if (!root)
|
|
77
|
+
return { ok: false, count: 0, actionable: false, reason: '#app missing', index: -1 };
|
|
78
|
+
const normalize = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
79
|
+
const nameOf = (el) => {
|
|
80
|
+
const labelled = el.getAttribute('aria-label');
|
|
81
|
+
if (labelled)
|
|
82
|
+
return normalize(labelled);
|
|
83
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
84
|
+
const id = el.id;
|
|
85
|
+
if (id) {
|
|
86
|
+
const lab = root.querySelector(`label[for="${CSS.escape(id)}"]`);
|
|
87
|
+
if (lab)
|
|
88
|
+
return normalize(lab.textContent || '');
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return normalize(el.innerText || el.textContent || '');
|
|
92
|
+
};
|
|
93
|
+
const isVisible = (el) => {
|
|
94
|
+
if (!(el instanceof Element))
|
|
95
|
+
return false;
|
|
96
|
+
const st = window.getComputedStyle(el);
|
|
97
|
+
if (st.display === 'none' || st.visibility === 'hidden' || Number(st.opacity) === 0)
|
|
98
|
+
return false;
|
|
99
|
+
const r = el.getBoundingClientRect();
|
|
100
|
+
return r.width > 0 && r.height > 0;
|
|
101
|
+
};
|
|
102
|
+
const isEnabled = (el) => {
|
|
103
|
+
if (el instanceof HTMLButtonElement ||
|
|
104
|
+
el instanceof HTMLInputElement ||
|
|
105
|
+
el instanceof HTMLSelectElement ||
|
|
106
|
+
el instanceof HTMLTextAreaElement) {
|
|
107
|
+
if (el.disabled)
|
|
108
|
+
return false;
|
|
109
|
+
if ((el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) && el.readOnly)
|
|
110
|
+
return false;
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
return el.getAttribute('aria-disabled') !== 'true';
|
|
114
|
+
};
|
|
115
|
+
let found = [];
|
|
116
|
+
if (locator.kind === 'css') {
|
|
117
|
+
try {
|
|
118
|
+
found = [...root.querySelectorAll(String(locator.selector || ''))];
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
count: 0,
|
|
124
|
+
actionable: false,
|
|
125
|
+
reason: `bad css: ${e instanceof Error ? e.message : e}`,
|
|
126
|
+
index: -1,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else if (locator.kind === 'testId') {
|
|
131
|
+
const id = String(locator.testId || '');
|
|
132
|
+
found = [...root.querySelectorAll(`[data-testid="${CSS.escape(id)}"]`)];
|
|
133
|
+
}
|
|
134
|
+
else if (locator.kind === 'role') {
|
|
135
|
+
const role = String(locator.role || '');
|
|
136
|
+
let pool = [];
|
|
137
|
+
if (role === 'button') {
|
|
138
|
+
pool = [...root.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]')];
|
|
139
|
+
}
|
|
140
|
+
else if (role === 'textbox') {
|
|
141
|
+
pool = [
|
|
142
|
+
...root.querySelectorAll('input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]), textarea, [role="textbox"]'),
|
|
143
|
+
];
|
|
144
|
+
}
|
|
145
|
+
else if (role === 'link') {
|
|
146
|
+
pool = [...root.querySelectorAll('a[href], [role="link"]')];
|
|
147
|
+
}
|
|
148
|
+
else if (role === 'checkbox') {
|
|
149
|
+
pool = [...root.querySelectorAll('input[type="checkbox"], [role="checkbox"]')];
|
|
150
|
+
}
|
|
151
|
+
else if (role === 'combobox') {
|
|
152
|
+
pool = [...root.querySelectorAll('select, [role="combobox"]')];
|
|
153
|
+
}
|
|
154
|
+
else if (role === 'listbox') {
|
|
155
|
+
pool = [...root.querySelectorAll('select, [role="listbox"]')];
|
|
156
|
+
}
|
|
157
|
+
else if (role === 'option') {
|
|
158
|
+
pool = [...root.querySelectorAll('option, [role="option"], [data-vmz-option]')];
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
pool = [...root.querySelectorAll(`[role="${CSS.escape(role)}"]`)];
|
|
162
|
+
}
|
|
163
|
+
if (locator.name != null && String(locator.name).length) {
|
|
164
|
+
const want = normalize(locator.name);
|
|
165
|
+
found = pool.filter((el) => {
|
|
166
|
+
const n = nameOf(el);
|
|
167
|
+
const optVal = el.getAttribute('data-vmz-option') || (el instanceof HTMLOptionElement ? el.value : '');
|
|
168
|
+
return locator.exact
|
|
169
|
+
? n === want || optVal === want
|
|
170
|
+
: n.includes(want) || String(optVal).includes(want);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
found = pool;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
else if (locator.kind === 'label') {
|
|
178
|
+
const want = normalize(locator.text);
|
|
179
|
+
const labels = [...root.querySelectorAll('label')].filter((lab) => {
|
|
180
|
+
const t = normalize(lab.textContent || '');
|
|
181
|
+
return locator.exact ? t === want : t.includes(want);
|
|
182
|
+
});
|
|
183
|
+
found = labels
|
|
184
|
+
.map((lab) => {
|
|
185
|
+
if (lab instanceof HTMLLabelElement && lab.control)
|
|
186
|
+
return lab.control;
|
|
187
|
+
const htmlFor = lab.getAttribute('for');
|
|
188
|
+
if (htmlFor)
|
|
189
|
+
return root.querySelector(`#${CSS.escape(htmlFor)}`);
|
|
190
|
+
return lab.querySelector('input, textarea, select');
|
|
191
|
+
})
|
|
192
|
+
.filter((el) => Boolean(el));
|
|
193
|
+
}
|
|
194
|
+
else if (locator.kind === 'text') {
|
|
195
|
+
const want = normalize(locator.text);
|
|
196
|
+
const all = [...root.querySelectorAll('button, a, label, p, span, li, td, th, h1, h2, h3, h4, h5, h6, [role]')];
|
|
197
|
+
found = all.filter((el) => {
|
|
198
|
+
const t = normalize(el.innerText || el.textContent || '');
|
|
199
|
+
if (!t)
|
|
200
|
+
return false;
|
|
201
|
+
return locator.exact ? t === want : t.includes(want);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
count: 0,
|
|
208
|
+
actionable: false,
|
|
209
|
+
reason: `unknown locator.kind ${locator.kind}`,
|
|
210
|
+
index: -1,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const visible = found.filter(isVisible);
|
|
214
|
+
const actionable = opts.force ? visible : visible.filter(isEnabled);
|
|
215
|
+
if (actionable.length === 1) {
|
|
216
|
+
for (const el of root.querySelectorAll('[data-vmz-bh-target]'))
|
|
217
|
+
el.removeAttribute('data-vmz-bh-target');
|
|
218
|
+
const target = actionable[0];
|
|
219
|
+
target.setAttribute('data-vmz-bh-target', '1');
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
count: 1,
|
|
223
|
+
actionable: true,
|
|
224
|
+
reason: '',
|
|
225
|
+
index: 0,
|
|
226
|
+
tag: target.tagName.toLowerCase(),
|
|
227
|
+
name: nameOf(target),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
if (actionable.length === 0) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
count: found.length,
|
|
234
|
+
actionable: false,
|
|
235
|
+
reason: found.length === 0 ? 'no matches' : visible.length === 0 ? 'matches not visible' : 'matches not actionable',
|
|
236
|
+
index: -1,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
count: actionable.length,
|
|
242
|
+
actionable: false,
|
|
243
|
+
reason: `ambiguous: ${actionable.length} actionable matches`,
|
|
244
|
+
index: -1,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
export function sleep(ms) {
|
|
248
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
249
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Host U2 — real VMZ serve-host lifecycle + RouteId → path resolution.
|
|
3
|
+
*/
|
|
4
|
+
export type ServeHostHandle = {
|
|
5
|
+
port: number;
|
|
6
|
+
origin: string;
|
|
7
|
+
kill: () => void;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Resolve author RouteId / pathPattern to a pathname for page.goto.
|
|
11
|
+
* Prefers explicit path; then cdn-policy-manifest; then route-realization.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveRoutePath(outDir: string, opts: {
|
|
14
|
+
routeId?: string;
|
|
15
|
+
path?: string;
|
|
16
|
+
params?: Record<string, string>;
|
|
17
|
+
}): string;
|
|
18
|
+
export declare function startServeHost(outDir: string): Promise<ServeHostHandle>;
|
|
19
|
+
export declare function isServeHostManifest(manifest: Record<string, unknown>): boolean;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Host U2 — real VMZ serve-host lifecycle + RouteId → path resolution.
|
|
3
|
+
*/
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
function freePort() {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const s = net.createServer();
|
|
11
|
+
s.listen(0, '127.0.0.1', () => {
|
|
12
|
+
const addr = s.address();
|
|
13
|
+
if (!addr || typeof addr === 'string') {
|
|
14
|
+
s.close();
|
|
15
|
+
reject(new Error('freePort: no address'));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const port = addr.port;
|
|
19
|
+
s.close((err) => (err ? reject(err) : resolve(port)));
|
|
20
|
+
});
|
|
21
|
+
s.on('error', reject);
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Resolve author RouteId / pathPattern to a pathname for page.goto.
|
|
26
|
+
* Prefers explicit path; then cdn-policy-manifest; then route-realization.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveRoutePath(outDir, opts) {
|
|
29
|
+
if (typeof opts.path === 'string' && opts.path.trim()) {
|
|
30
|
+
return applyParams(opts.path.trim(), opts.params);
|
|
31
|
+
}
|
|
32
|
+
const routeId = String(opts.routeId || '').trim();
|
|
33
|
+
if (!routeId)
|
|
34
|
+
throw new Error('open/navigate: routeId or path required');
|
|
35
|
+
const cdnPath = path.join(outDir, '_vmz', 'cdn-policy-manifest.json');
|
|
36
|
+
if (fs.existsSync(cdnPath)) {
|
|
37
|
+
try {
|
|
38
|
+
const doc = JSON.parse(fs.readFileSync(cdnPath, 'utf8'));
|
|
39
|
+
const entries = Array.isArray(doc.entries) ? doc.entries : [];
|
|
40
|
+
const hit = entries.find((e) => e.routeId === routeId && (!e.localeId || e.localeId === 'en-us') && e.path) ||
|
|
41
|
+
entries.find((e) => e.routeId === routeId && e.path);
|
|
42
|
+
if (hit?.path)
|
|
43
|
+
return applyParams(String(hit.path), opts.params);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
/* fall through */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const rrPath = path.join(outDir, '_vmz', 'route-realization.json');
|
|
50
|
+
if (fs.existsSync(rrPath)) {
|
|
51
|
+
try {
|
|
52
|
+
const doc = JSON.parse(fs.readFileSync(rrPath, 'utf8'));
|
|
53
|
+
const routes = Array.isArray(doc.routes) ? doc.routes : [];
|
|
54
|
+
const hit = routes.find((r) => r.routeId === routeId) ||
|
|
55
|
+
routes.find((r) => r.routeId === `pages/${routeId}`) ||
|
|
56
|
+
routes.find((r) => String(r.routeId || '').endsWith(`/${routeId}`));
|
|
57
|
+
if (hit?.pathPattern)
|
|
58
|
+
return applyParams(String(hit.pathPattern), opts.params);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* fall through */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`open/navigate: cannot resolve RouteId ${JSON.stringify(routeId)} (no path / cdn / realization)`);
|
|
65
|
+
}
|
|
66
|
+
function applyParams(pattern, params) {
|
|
67
|
+
if (!params)
|
|
68
|
+
return pattern;
|
|
69
|
+
let out = pattern;
|
|
70
|
+
for (const [k, v] of Object.entries(params)) {
|
|
71
|
+
out = out.replace(new RegExp(`\\[${k}\\]`, 'g'), encodeURIComponent(String(v)));
|
|
72
|
+
out = out.replace(new RegExp(`:${k}\\b`, 'g'), encodeURIComponent(String(v)));
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
export async function startServeHost(outDir) {
|
|
77
|
+
const hostJs = path.join(outDir, 'vmz-serve-host.mjs');
|
|
78
|
+
if (!fs.existsSync(hostJs)) {
|
|
79
|
+
throw new Error(`serve host: missing ${hostJs} (run vmz build for application dist)`);
|
|
80
|
+
}
|
|
81
|
+
const port = await freePort();
|
|
82
|
+
const child = spawn(process.execPath, [hostJs], {
|
|
83
|
+
cwd: outDir,
|
|
84
|
+
env: { ...process.env, VMZ_DIST: outDir, VMZ_HOST: '127.0.0.1', VMZ_PORT: String(port) },
|
|
85
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
86
|
+
});
|
|
87
|
+
const kill = () => {
|
|
88
|
+
try {
|
|
89
|
+
child.kill('SIGTERM');
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* ignore */
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
await new Promise((resolve, reject) => {
|
|
96
|
+
const t = setTimeout(() => {
|
|
97
|
+
kill();
|
|
98
|
+
reject(new Error(`serve host start timeout :${port}`));
|
|
99
|
+
}, 12000);
|
|
100
|
+
const onData = (buf) => {
|
|
101
|
+
if (String(buf).includes('vmz serve http://')) {
|
|
102
|
+
clearTimeout(t);
|
|
103
|
+
child.stdout?.off('data', onData);
|
|
104
|
+
resolve();
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
child.stdout?.on('data', onData);
|
|
108
|
+
child.stderr?.on('data', () => {
|
|
109
|
+
/* absorb */
|
|
110
|
+
});
|
|
111
|
+
child.on('exit', (code) => {
|
|
112
|
+
clearTimeout(t);
|
|
113
|
+
reject(new Error(`serve host exited early ${code}`));
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
return { port, origin: `http://127.0.0.1:${port}`, kill };
|
|
117
|
+
}
|
|
118
|
+
export function isServeHostManifest(manifest) {
|
|
119
|
+
const host = manifest.host && typeof manifest.host === 'object' ? manifest.host : null;
|
|
120
|
+
if (host && (host.kind === 'serve' || host.mode === 'serve'))
|
|
121
|
+
return true;
|
|
122
|
+
const program = manifest.program && typeof manifest.program === 'object' ? manifest.program : null;
|
|
123
|
+
if (program && (program.kind === 'application' || program.host === 'serve'))
|
|
124
|
+
return true;
|
|
125
|
+
const actions = Array.isArray(manifest.actions) ? manifest.actions : [];
|
|
126
|
+
return actions.some((raw) => {
|
|
127
|
+
const a = raw && typeof raw === 'object' ? raw : {};
|
|
128
|
+
return a.kind === 'open' || a.kind === 'navigate';
|
|
129
|
+
});
|
|
130
|
+
}
|
package/dist/browser.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser Host for `vmz test --mode browser` (
|
|
2
|
+
* Browser Host for `vmz test --mode browser` (U0–U2 thin).
|
|
3
3
|
*
|
|
4
4
|
* Real Chromium/Chrome via CDP. Transport may use puppeteer-core as a CDP
|
|
5
5
|
* client — that is NOT the Playwright/Puppeteer *test model*. Manifest actions
|
|
6
|
-
* and assertions remain the VMZ Browser Host protocol
|
|
7
|
-
* production (`__vmzCreate` in a real document).
|
|
6
|
+
* and assertions remain the VMZ Browser Host protocol.
|
|
8
7
|
*
|
|
8
|
+
* U0: Locator / Action / Expectation dispatcher (browser-protocol.ts).
|
|
9
|
+
* U1: role/label/text/testId; click/fill/press/select; actionability + auto-wait.
|
|
10
|
+
* U2: real serve-host + RouteId open/navigate; console/request fail gate;
|
|
11
|
+
* wall-clock timing + failure screenshot/DOM (not full U3 artifact pack).
|
|
9
12
|
*/
|
|
10
13
|
type Diag = {
|
|
11
14
|
severity: string;
|