@noego/wood 0.1.2 → 0.2.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/README.md +56 -0
- package/bin/axe.js +3 -1
- package/bin/wood.js +7 -3
- package/dist/controller/controller_resolver.cjs +6 -2
- package/dist/controller/controller_resolver.cjs.map +1 -1
- package/dist/controller/controller_resolver.js +6 -2
- package/dist/controller/controller_resolver.js.map +1 -1
- package/dist/loader/loader_runner.cjs +5 -1
- package/dist/loader/loader_runner.cjs.map +1 -1
- package/dist/loader/loader_runner.js +5 -1
- package/dist/loader/loader_runner.js.map +1 -1
- package/dist/middleware/middleware_resolver.cjs +5 -1
- package/dist/middleware/middleware_resolver.cjs.map +1 -1
- package/dist/middleware/middleware_resolver.js +5 -1
- package/dist/middleware/middleware_resolver.js.map +1 -1
- package/dist/testing/browser.cjs +789 -0
- package/dist/testing/browser.cjs.map +1 -0
- package/dist/testing/browser.d.cts +183 -0
- package/dist/testing/browser.d.ts +183 -0
- package/dist/testing/browser.js +754 -0
- package/dist/testing/browser.js.map +1 -0
- package/dist/testing/index.cjs +5 -0
- package/dist/testing/index.cjs.map +1 -1
- package/dist/testing/index.d.cts +1 -0
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +6 -0
- package/dist/testing/index.js.map +1 -1
- package/docs/browser-testing.md +212 -0
- package/package.json +7 -1
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
const DEFAULT_VIEWPORT = {
|
|
6
|
+
width: 1280,
|
|
7
|
+
height: 800
|
|
8
|
+
};
|
|
9
|
+
function normalizeRoot(rootDir) {
|
|
10
|
+
return path.resolve(rootDir ?? process.cwd());
|
|
11
|
+
}
|
|
12
|
+
function resolveFromRoot(rootDir, filePath) {
|
|
13
|
+
return path.isAbsolute(filePath) ? filePath : path.resolve(rootDir, filePath);
|
|
14
|
+
}
|
|
15
|
+
function viteImport(filePath) {
|
|
16
|
+
return `/@fs/${filePath.replace(/\\/g, "/").replace(/^\/+/, "")}`;
|
|
17
|
+
}
|
|
18
|
+
function mergeBridge(base, override) {
|
|
19
|
+
return {
|
|
20
|
+
operations: {
|
|
21
|
+
...base?.operations ?? {},
|
|
22
|
+
...override?.operations ?? {}
|
|
23
|
+
},
|
|
24
|
+
events: {
|
|
25
|
+
...base?.events ?? {},
|
|
26
|
+
...override?.events ?? {}
|
|
27
|
+
},
|
|
28
|
+
record: override?.record ?? base?.record
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
function channelToManifestEntry(channel, handler) {
|
|
32
|
+
const pathSegments = channel.split(".").filter(Boolean);
|
|
33
|
+
const action = pathSegments.length > 0 ? pathSegments[pathSegments.length - 1] : channel;
|
|
34
|
+
const controller = pathSegments.length > 1 ? pathSegments[0] : channel;
|
|
35
|
+
return {
|
|
36
|
+
channel,
|
|
37
|
+
controller,
|
|
38
|
+
action,
|
|
39
|
+
path: pathSegments,
|
|
40
|
+
hasInput: typeof handler === "function"
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function createBridgeState(input) {
|
|
44
|
+
const initialEvents = Object.entries(input?.events ?? {}).flatMap(([channel, payloadOrList]) => {
|
|
45
|
+
const payloads = Array.isArray(payloadOrList) ? payloadOrList : [payloadOrList];
|
|
46
|
+
return payloads.map((payload) => ({
|
|
47
|
+
channel,
|
|
48
|
+
payload,
|
|
49
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
50
|
+
}));
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
operations: new Map(Object.entries(input?.operations ?? {})),
|
|
54
|
+
initialEvents,
|
|
55
|
+
calls: [],
|
|
56
|
+
emittedEvents: [],
|
|
57
|
+
record: input?.record ?? true
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function createBrowserBridgeFixture(input) {
|
|
61
|
+
const state = createBridgeState(input);
|
|
62
|
+
const manifest = Array.from(state.operations.entries()).map(([channel, handler]) => channelToManifestEntry(channel, handler));
|
|
63
|
+
return {
|
|
64
|
+
state,
|
|
65
|
+
manifest,
|
|
66
|
+
bridgeInit: buildBridgeInitScript(manifest)
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function buildBridgeInitScript(manifest) {
|
|
70
|
+
return `
|
|
71
|
+
(() => {
|
|
72
|
+
const manifest = ${JSON.stringify(manifest)};
|
|
73
|
+
const listeners = new Map();
|
|
74
|
+
const bridge = {
|
|
75
|
+
__rpcManifest: manifest,
|
|
76
|
+
__woodConfig: async () => ({ rendererTraceEnabled: false }),
|
|
77
|
+
__log: () => {},
|
|
78
|
+
__trace: () => {},
|
|
79
|
+
__window: {
|
|
80
|
+
current: async () => ({ ok: true, windowId: 'wood-browser-harness', defaultRoute: window.__WOOD_BROWSER_DEFAULT_ROUTE__ }),
|
|
81
|
+
open: async () => ({}),
|
|
82
|
+
close: async () => ({}),
|
|
83
|
+
focus: async () => ({}),
|
|
84
|
+
minimize: async () => ({}),
|
|
85
|
+
maximize: async () => ({}),
|
|
86
|
+
},
|
|
87
|
+
on(channel, callback) {
|
|
88
|
+
if (!listeners.has(channel)) listeners.set(channel, new Set());
|
|
89
|
+
listeners.get(channel).add(callback);
|
|
90
|
+
return () => listeners.get(channel)?.delete(callback);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
for (const entry of manifest) {
|
|
95
|
+
let current = bridge;
|
|
96
|
+
for (let i = 0; i < entry.path.length - 1; i += 1) {
|
|
97
|
+
const segment = entry.path[i];
|
|
98
|
+
current[segment] ??= {};
|
|
99
|
+
current = current[segment];
|
|
100
|
+
}
|
|
101
|
+
const leaf = entry.path[entry.path.length - 1];
|
|
102
|
+
current[leaf] = async (data) => window.__woodBrowserInvoke(entry.channel, data);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
window.__woodBrowserEmit = (channel, payload) => {
|
|
106
|
+
const handlers = listeners.get(channel);
|
|
107
|
+
if (!handlers) return;
|
|
108
|
+
for (const handler of handlers) handler(payload);
|
|
109
|
+
};
|
|
110
|
+
window.__NOEGO_WOOD_BRIDGE__ = bridge;
|
|
111
|
+
window.wood = bridge;
|
|
112
|
+
globalThis.__NOEGO_WOOD_BRIDGE__ = bridge;
|
|
113
|
+
globalThis.wood = bridge;
|
|
114
|
+
})();
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
async function loadPlaywright(browserName) {
|
|
118
|
+
const playwright = await import("playwright");
|
|
119
|
+
return playwright[browserName];
|
|
120
|
+
}
|
|
121
|
+
async function loadVite() {
|
|
122
|
+
return await import("vite");
|
|
123
|
+
}
|
|
124
|
+
async function loadSveltePlugin(rootDir) {
|
|
125
|
+
try {
|
|
126
|
+
const rootPluginPath = path.join(
|
|
127
|
+
rootDir,
|
|
128
|
+
"node_modules",
|
|
129
|
+
"@sveltejs",
|
|
130
|
+
"vite-plugin-svelte",
|
|
131
|
+
"src",
|
|
132
|
+
"index.js"
|
|
133
|
+
);
|
|
134
|
+
if (fs.existsSync(rootPluginPath)) {
|
|
135
|
+
const mod2 = await import(pathToFileURL(rootPluginPath).href);
|
|
136
|
+
return mod2.svelte;
|
|
137
|
+
}
|
|
138
|
+
const rootRequire = createRequire(path.join(rootDir, "package.json"));
|
|
139
|
+
const mod = await import(pathToFileURL(rootRequire.resolve("@sveltejs/vite-plugin-svelte")).href);
|
|
140
|
+
return mod.svelte;
|
|
141
|
+
} catch {
|
|
142
|
+
try {
|
|
143
|
+
const mod = await import("@sveltejs/vite-plugin-svelte");
|
|
144
|
+
return mod.svelte;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function loadViews(viewsPath) {
|
|
151
|
+
const parser = await import("../parser/views_parser.js");
|
|
152
|
+
return parser.parseViewsFile(viewsPath);
|
|
153
|
+
}
|
|
154
|
+
async function createViteServer(rootDir) {
|
|
155
|
+
const vite = await loadVite();
|
|
156
|
+
const svelte = await loadSveltePlugin(rootDir);
|
|
157
|
+
const plugins = svelte ? [svelte()] : [];
|
|
158
|
+
const server = await vite.createServer({
|
|
159
|
+
root: rootDir,
|
|
160
|
+
logLevel: process.env.WOOD_BROWSER_TEST_DEBUG ? "info" : "silent",
|
|
161
|
+
plugins,
|
|
162
|
+
resolve: {
|
|
163
|
+
dedupe: ["svelte"]
|
|
164
|
+
},
|
|
165
|
+
server: { port: 0 }
|
|
166
|
+
});
|
|
167
|
+
await server.listen();
|
|
168
|
+
return server;
|
|
169
|
+
}
|
|
170
|
+
function serverBaseUrl(server) {
|
|
171
|
+
return server.resolvedUrls?.local?.[0] ?? "http://127.0.0.1:5173";
|
|
172
|
+
}
|
|
173
|
+
function assertFileExists(filePath, label) {
|
|
174
|
+
if (!fs.existsSync(filePath)) {
|
|
175
|
+
throw new Error(`${label} does not exist: ${filePath}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function writeEntry(tempDir, name, source) {
|
|
179
|
+
fs.mkdirSync(tempDir, { recursive: true });
|
|
180
|
+
const entryPath = path.join(tempDir, `${name}.ts`);
|
|
181
|
+
fs.writeFileSync(entryPath, source);
|
|
182
|
+
return entryPath;
|
|
183
|
+
}
|
|
184
|
+
function writeHtml(tempDir, name, entryPath, cssFiles) {
|
|
185
|
+
const styles = cssFiles.map((cssPath) => `<link rel="stylesheet" href="${viteImport(cssPath)}" />`).join("\n");
|
|
186
|
+
const htmlPath = path.join(tempDir, `${name}.html`);
|
|
187
|
+
fs.writeFileSync(htmlPath, `<!doctype html>
|
|
188
|
+
<html>
|
|
189
|
+
<head>
|
|
190
|
+
<meta charset="utf-8" />
|
|
191
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
192
|
+
${styles}
|
|
193
|
+
</head>
|
|
194
|
+
<body>
|
|
195
|
+
<div id="app"></div>
|
|
196
|
+
<script type="module" src="${viteImport(entryPath)}"></script>
|
|
197
|
+
</body>
|
|
198
|
+
</html>
|
|
199
|
+
`);
|
|
200
|
+
return htmlPath;
|
|
201
|
+
}
|
|
202
|
+
function viewKey(view) {
|
|
203
|
+
return `${view.windowName}.${view.pageName}`;
|
|
204
|
+
}
|
|
205
|
+
function findView(views, routeKey) {
|
|
206
|
+
const found = views.find((view) => viewKey(view) === routeKey);
|
|
207
|
+
if (!found) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Unknown Wood route "${routeKey}". Available routes: ${views.map(viewKey).join(", ")}`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return found;
|
|
213
|
+
}
|
|
214
|
+
function resolveComponentPath(rootDir, componentDir, filePath) {
|
|
215
|
+
if (path.isAbsolute(filePath)) return filePath;
|
|
216
|
+
const base = componentDir ? resolveFromRoot(rootDir, componentDir) : rootDir;
|
|
217
|
+
const componentDirName = componentDir ? path.basename(componentDir) : "";
|
|
218
|
+
if (componentDirName && filePath.replace(/\\/g, "/").startsWith(`${componentDirName}/`)) {
|
|
219
|
+
return path.resolve(base, filePath.slice(componentDirName.length + 1));
|
|
220
|
+
}
|
|
221
|
+
return path.resolve(base, filePath);
|
|
222
|
+
}
|
|
223
|
+
function buildRouteEntrySource(input) {
|
|
224
|
+
const { view, rootDir, componentDir, routeKey, route } = input;
|
|
225
|
+
const viewPath = resolveComponentPath(rootDir, componentDir, view.viewPath);
|
|
226
|
+
const layoutPaths = view.layouts.map((layout) => resolveComponentPath(rootDir, componentDir, layout));
|
|
227
|
+
const controllerPath = view.controller ? resolveComponentPath(rootDir, componentDir, view.controller) : null;
|
|
228
|
+
const layoutControllerPaths = view.layoutControllers?.map(
|
|
229
|
+
(controller) => controller ? resolveComponentPath(rootDir, componentDir, controller) : null
|
|
230
|
+
) ?? [];
|
|
231
|
+
const imports = [
|
|
232
|
+
`import { mount } from 'svelte';`,
|
|
233
|
+
`import NavigationShell from '@noego/wood/navigation-shell';`,
|
|
234
|
+
`import { getContainer } from '@noego/wood/client';`,
|
|
235
|
+
`import { getNavigation } from '@noego/wood/navigation';`,
|
|
236
|
+
`import ViewComponent from '${viteImport(viewPath)}';`,
|
|
237
|
+
...layoutPaths.map((layoutPath, index) => `import Layout${index} from '${viteImport(layoutPath)}';`),
|
|
238
|
+
...controllerPath ? [`import PageController from '${viteImport(controllerPath)}';`] : [],
|
|
239
|
+
...layoutControllerPaths.map(
|
|
240
|
+
(controllerPath2, index) => controllerPath2 ? `import LayoutController${index} from '${viteImport(controllerPath2)}';` : ""
|
|
241
|
+
).filter(Boolean)
|
|
242
|
+
];
|
|
243
|
+
const layoutControllerClasses = layoutControllerPaths.map(
|
|
244
|
+
(controllerPath2, index) => controllerPath2 ? `LayoutController${index}` : "undefined"
|
|
245
|
+
);
|
|
246
|
+
return `${imports.join("\n")}
|
|
247
|
+
|
|
248
|
+
const entry = {
|
|
249
|
+
key: ${JSON.stringify(routeKey)},
|
|
250
|
+
windowName: ${JSON.stringify(view.windowName)},
|
|
251
|
+
pageName: ${JSON.stringify(view.pageName)},
|
|
252
|
+
viewPath: ${JSON.stringify(view.viewPath)},
|
|
253
|
+
layoutPaths: ${JSON.stringify(view.layouts)},
|
|
254
|
+
layoutControllers: ${JSON.stringify(view.layoutControllers ?? [])},
|
|
255
|
+
controller: ${JSON.stringify(view.controller)},
|
|
256
|
+
layoutControllerClasses: [${layoutControllerClasses.join(", ")}],
|
|
257
|
+
controllerClass: ${controllerPath ? "PageController" : "undefined"},
|
|
258
|
+
view: ViewComponent,
|
|
259
|
+
layouts: [${layoutPaths.map((_, index) => `Layout${index}`).join(", ")}],
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
function createScopedContainer() {
|
|
263
|
+
const root = getContainer();
|
|
264
|
+
const scope = root.extend();
|
|
265
|
+
return scope;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function resolveController(ControllerClass) {
|
|
269
|
+
if (!ControllerClass) return undefined;
|
|
270
|
+
const scope = createScopedContainer();
|
|
271
|
+
scope.registerClass?.(ControllerClass);
|
|
272
|
+
return await scope.get(ControllerClass);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function createViewController(nextEntry) {
|
|
276
|
+
return await resolveController(nextEntry.controllerClass);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function createLayoutControllers(nextEntry) {
|
|
280
|
+
const controllers = [];
|
|
281
|
+
for (const ControllerClass of nextEntry.layoutControllerClasses) {
|
|
282
|
+
controllers.push(await resolveController(ControllerClass));
|
|
283
|
+
}
|
|
284
|
+
return controllers;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function getViewManifestEntry(nextKey) {
|
|
288
|
+
return nextKey === entry.key ? entry : null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const navigation = getNavigation();
|
|
292
|
+
navigation.replace(entry.key, ${JSON.stringify(route.params)}, ${JSON.stringify(route.query)});
|
|
293
|
+
|
|
294
|
+
const controller = await createViewController(entry);
|
|
295
|
+
const layoutControllers = await createLayoutControllers(entry);
|
|
296
|
+
|
|
297
|
+
window.__WOOD_BROWSER_APP__ = mount(NavigationShell, {
|
|
298
|
+
target: document.getElementById('app'),
|
|
299
|
+
props: {
|
|
300
|
+
manifest: { [entry.key]: entry },
|
|
301
|
+
initialEntry: entry,
|
|
302
|
+
initialController: controller,
|
|
303
|
+
initialLayoutControllers: layoutControllers,
|
|
304
|
+
createViewControllerFn: createViewController,
|
|
305
|
+
createLayoutControllersFn: createLayoutControllers,
|
|
306
|
+
getViewManifestEntryFn: getViewManifestEntry,
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
`;
|
|
310
|
+
}
|
|
311
|
+
function buildGeneratedAppEntrySource(input) {
|
|
312
|
+
return `import { app } from '${viteImport(input.generatedApp)}';
|
|
313
|
+
window.__WOOD_BROWSER_DEFAULT_ROUTE__ = ${JSON.stringify(input.routeKey)};
|
|
314
|
+
window.__WOOD_BROWSER_APP__ = await app(document.getElementById('app'));
|
|
315
|
+
`;
|
|
316
|
+
}
|
|
317
|
+
function buildComponentEntrySource(input) {
|
|
318
|
+
return `import { mount } from 'svelte';
|
|
319
|
+
import Component from '${viteImport(input.componentPath)}';
|
|
320
|
+
window.__WOOD_BROWSER_APP__ = mount(Component, {
|
|
321
|
+
target: document.getElementById('app'),
|
|
322
|
+
props: ${JSON.stringify(input.props)},
|
|
323
|
+
});
|
|
324
|
+
`;
|
|
325
|
+
}
|
|
326
|
+
function buildTreeEntrySource(input) {
|
|
327
|
+
const imports = [
|
|
328
|
+
`import { mount } from 'svelte';`,
|
|
329
|
+
`import WoodRecursiveRender from '@noego/wood/recursive-render';`,
|
|
330
|
+
`import ViewComponent from '${viteImport(input.view.component)}';`,
|
|
331
|
+
...input.layouts.map((layout, index) => `import Layout${index} from '${viteImport(layout.component)}';`)
|
|
332
|
+
];
|
|
333
|
+
const layoutControllers = input.layouts.map((layout) => ({
|
|
334
|
+
data: layout.props?.data,
|
|
335
|
+
input: layout.props?.input,
|
|
336
|
+
events: layout.props?.events
|
|
337
|
+
}));
|
|
338
|
+
const viewController = {
|
|
339
|
+
data: input.view.props?.data,
|
|
340
|
+
input: input.view.props?.input,
|
|
341
|
+
events: input.view.props?.events
|
|
342
|
+
};
|
|
343
|
+
return `${imports.join("\n")}
|
|
344
|
+
window.__WOOD_BROWSER_APP__ = mount(WoodRecursiveRender, {
|
|
345
|
+
target: document.getElementById('app'),
|
|
346
|
+
props: {
|
|
347
|
+
layouts: [${input.layouts.map((_, index) => `Layout${index}`).join(", ")}],
|
|
348
|
+
layoutControllers: ${JSON.stringify(layoutControllers)},
|
|
349
|
+
view: ViewComponent,
|
|
350
|
+
controller: ${JSON.stringify(viewController)},
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
`;
|
|
354
|
+
}
|
|
355
|
+
async function installBridge(page, bridgeState, manifest, bridgeInit) {
|
|
356
|
+
await page.exposeBinding("__woodBrowserInvoke", async (_source, channel, input) => {
|
|
357
|
+
const call = {
|
|
358
|
+
channel,
|
|
359
|
+
input,
|
|
360
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
361
|
+
};
|
|
362
|
+
if (bridgeState.record) {
|
|
363
|
+
bridgeState.calls.push(call);
|
|
364
|
+
}
|
|
365
|
+
const handler = bridgeState.operations.get(channel);
|
|
366
|
+
if (typeof handler === "function") {
|
|
367
|
+
return await handler(input, { channel, calls: bridgeState.calls });
|
|
368
|
+
}
|
|
369
|
+
return handler;
|
|
370
|
+
});
|
|
371
|
+
await page.addInitScript(bridgeInit);
|
|
372
|
+
await page.addInitScript((entries) => {
|
|
373
|
+
window.__WOOD_BROWSER_BRIDGE_MANIFEST__ = entries;
|
|
374
|
+
}, manifest);
|
|
375
|
+
}
|
|
376
|
+
function createMountedBridge(page, state) {
|
|
377
|
+
return {
|
|
378
|
+
async emit(channel, payload) {
|
|
379
|
+
state.emittedEvents.push({
|
|
380
|
+
channel,
|
|
381
|
+
payload,
|
|
382
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
383
|
+
});
|
|
384
|
+
await page.evaluate(({ eventChannel, eventPayload }) => {
|
|
385
|
+
window.__woodBrowserEmit?.(eventChannel, eventPayload);
|
|
386
|
+
}, { eventChannel: channel, eventPayload: payload });
|
|
387
|
+
},
|
|
388
|
+
calls(channel) {
|
|
389
|
+
return channel ? state.calls.filter((call) => call.channel === channel) : [...state.calls];
|
|
390
|
+
},
|
|
391
|
+
events(channel) {
|
|
392
|
+
return channel ? state.emittedEvents.filter((event) => event.channel === channel) : [...state.emittedEvents];
|
|
393
|
+
},
|
|
394
|
+
setOperation(channel, handler) {
|
|
395
|
+
state.operations.set(channel, handler);
|
|
396
|
+
},
|
|
397
|
+
clearCalls() {
|
|
398
|
+
state.calls.length = 0;
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function createMountedNavigation(page) {
|
|
403
|
+
return {
|
|
404
|
+
async current() {
|
|
405
|
+
return await page.evaluate(async () => {
|
|
406
|
+
const specifier = "@noego/wood/navigation";
|
|
407
|
+
const mod = await import(
|
|
408
|
+
/* @vite-ignore */
|
|
409
|
+
specifier
|
|
410
|
+
);
|
|
411
|
+
return mod.getNavigation().getCurrent();
|
|
412
|
+
});
|
|
413
|
+
},
|
|
414
|
+
async go(routePage, params = {}, query = {}) {
|
|
415
|
+
await page.evaluate(async (payload) => {
|
|
416
|
+
const { routePage: nextPage, params: nextParams, query: nextQuery } = payload;
|
|
417
|
+
const specifier = "@noego/wood/navigation";
|
|
418
|
+
const mod = await import(
|
|
419
|
+
/* @vite-ignore */
|
|
420
|
+
specifier
|
|
421
|
+
);
|
|
422
|
+
mod.getNavigation().go(nextPage, nextParams, nextQuery);
|
|
423
|
+
}, { routePage, params, query });
|
|
424
|
+
},
|
|
425
|
+
async replace(routePage, params = {}, query = {}) {
|
|
426
|
+
await page.evaluate(async (payload) => {
|
|
427
|
+
const { routePage: nextPage, params: nextParams, query: nextQuery } = payload;
|
|
428
|
+
const specifier = "@noego/wood/navigation";
|
|
429
|
+
const mod = await import(
|
|
430
|
+
/* @vite-ignore */
|
|
431
|
+
specifier
|
|
432
|
+
);
|
|
433
|
+
mod.getNavigation().replace(nextPage, nextParams, nextQuery);
|
|
434
|
+
}, { routePage, params, query });
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
async function elementRect(page, selector) {
|
|
439
|
+
return await page.locator(selector).evaluate((element) => {
|
|
440
|
+
const rect = element.getBoundingClientRect();
|
|
441
|
+
return {
|
|
442
|
+
top: rect.top,
|
|
443
|
+
left: rect.left,
|
|
444
|
+
right: rect.right,
|
|
445
|
+
bottom: rect.bottom,
|
|
446
|
+
width: rect.width,
|
|
447
|
+
height: rect.height
|
|
448
|
+
};
|
|
449
|
+
}).catch(() => null);
|
|
450
|
+
}
|
|
451
|
+
function createExpect(page, outputDir) {
|
|
452
|
+
return {
|
|
453
|
+
async visible(selector) {
|
|
454
|
+
const count = await page.locator(selector).count();
|
|
455
|
+
if (count < 1) throw new Error(`Expected "${selector}" to exist.`);
|
|
456
|
+
const visible = await page.locator(selector).first().isVisible();
|
|
457
|
+
if (!visible) throw new Error(`Expected "${selector}" to be visible.`);
|
|
458
|
+
},
|
|
459
|
+
async hidden(selector) {
|
|
460
|
+
const count = await page.locator(selector).count();
|
|
461
|
+
if (count < 1) return;
|
|
462
|
+
const visible = await page.locator(selector).first().isVisible();
|
|
463
|
+
if (visible) throw new Error(`Expected "${selector}" to be hidden.`);
|
|
464
|
+
},
|
|
465
|
+
async insideViewport(selector, options = {}) {
|
|
466
|
+
const margin = options.margin ?? 0;
|
|
467
|
+
const rect = await elementRect(page, selector);
|
|
468
|
+
if (!rect) throw new Error(`Expected "${selector}" to exist.`);
|
|
469
|
+
const viewport = page.viewportSize();
|
|
470
|
+
if (!viewport) throw new Error("Cannot assert viewport bounds without a viewport.");
|
|
471
|
+
if (rect.left < margin || rect.top < margin || rect.right > viewport.width - margin || rect.bottom > viewport.height - margin) {
|
|
472
|
+
throw new Error(`Expected "${selector}" to be inside viewport. Rect: ${JSON.stringify(rect)}, viewport: ${JSON.stringify(viewport)}`);
|
|
473
|
+
}
|
|
474
|
+
},
|
|
475
|
+
async notClipped(selector) {
|
|
476
|
+
const result = await page.locator(selector).first().evaluate((element) => {
|
|
477
|
+
const rect = element.getBoundingClientRect();
|
|
478
|
+
let parent = element.parentElement;
|
|
479
|
+
while (parent) {
|
|
480
|
+
const style = window.getComputedStyle(parent);
|
|
481
|
+
const clips = /(auto|hidden|scroll|clip)/.test(`${style.overflow}${style.overflowX}${style.overflowY}`);
|
|
482
|
+
if (clips) {
|
|
483
|
+
const parentRect = parent.getBoundingClientRect();
|
|
484
|
+
if (rect.left < parentRect.left || rect.top < parentRect.top || rect.right > parentRect.right || rect.bottom > parentRect.bottom) {
|
|
485
|
+
return { ok: false, parent: parent.tagName, rect, parentRect };
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
parent = parent.parentElement;
|
|
489
|
+
}
|
|
490
|
+
return { ok: true };
|
|
491
|
+
});
|
|
492
|
+
if (!result.ok) {
|
|
493
|
+
throw new Error(`Expected "${selector}" not to be clipped. ${JSON.stringify(result)}`);
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
async notOverlapping(firstSelector, secondSelector) {
|
|
497
|
+
const first = await elementRect(page, firstSelector);
|
|
498
|
+
const second = await elementRect(page, secondSelector);
|
|
499
|
+
if (!first || !second) throw new Error(`Expected both "${firstSelector}" and "${secondSelector}" to exist.`);
|
|
500
|
+
const overlaps = first.left < second.right && first.right > second.left && first.top < second.bottom && first.bottom > second.top;
|
|
501
|
+
if (overlaps) {
|
|
502
|
+
throw new Error(`Expected "${firstSelector}" not to overlap "${secondSelector}".`);
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
async zIndexAbove(topSelector, lowerSelector) {
|
|
506
|
+
const values = await page.evaluate((payload) => {
|
|
507
|
+
const { topSelector: top, lowerSelector: lower } = payload;
|
|
508
|
+
const topElement = document.querySelector(top);
|
|
509
|
+
const lowerElement = document.querySelector(lower);
|
|
510
|
+
if (!topElement || !lowerElement) return null;
|
|
511
|
+
return {
|
|
512
|
+
top: Number(window.getComputedStyle(topElement).zIndex) || 0,
|
|
513
|
+
lower: Number(window.getComputedStyle(lowerElement).zIndex) || 0
|
|
514
|
+
};
|
|
515
|
+
}, { topSelector, lowerSelector });
|
|
516
|
+
if (!values) throw new Error(`Expected both "${topSelector}" and "${lowerSelector}" to exist.`);
|
|
517
|
+
if (values.top <= values.lower) {
|
|
518
|
+
throw new Error(`Expected "${topSelector}" z-index (${values.top}) to be above "${lowerSelector}" (${values.lower}).`);
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
async stableAfterAnimation(selector = "body", options = {}) {
|
|
522
|
+
const frameCount = options.frameCount ?? 3;
|
|
523
|
+
const timeoutMs = options.timeoutMs ?? 2e3;
|
|
524
|
+
const startedAt = Date.now();
|
|
525
|
+
let previous = "";
|
|
526
|
+
let stableFrames = 0;
|
|
527
|
+
while (Date.now() - startedAt < timeoutMs) {
|
|
528
|
+
const current = JSON.stringify(await elementRect(page, selector));
|
|
529
|
+
if (current === previous) {
|
|
530
|
+
stableFrames += 1;
|
|
531
|
+
if (stableFrames >= frameCount) return;
|
|
532
|
+
} else {
|
|
533
|
+
stableFrames = 0;
|
|
534
|
+
previous = current;
|
|
535
|
+
}
|
|
536
|
+
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve(void 0))));
|
|
537
|
+
}
|
|
538
|
+
throw new Error(`Expected "${selector}" to become stable within ${timeoutMs}ms.`);
|
|
539
|
+
},
|
|
540
|
+
async screenshot(name, options = {}) {
|
|
541
|
+
const outputPath = path.join(outputDir, `${name}.png`);
|
|
542
|
+
await fs.promises.mkdir(outputDir, { recursive: true });
|
|
543
|
+
const masks = options.mask?.map((selector) => page.locator(selector)) ?? [];
|
|
544
|
+
await page.screenshot({
|
|
545
|
+
path: outputPath,
|
|
546
|
+
fullPage: options.fullPage ?? true,
|
|
547
|
+
mask: masks
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
function createSurface(input) {
|
|
553
|
+
return {
|
|
554
|
+
page: input.page,
|
|
555
|
+
bridge: input.bridge,
|
|
556
|
+
navigation: createMountedNavigation(input.page),
|
|
557
|
+
expect: createExpect(input.page, input.outputDir),
|
|
558
|
+
locator(selector) {
|
|
559
|
+
return input.page.locator(selector);
|
|
560
|
+
},
|
|
561
|
+
async click(selector) {
|
|
562
|
+
await input.page.locator(selector).click();
|
|
563
|
+
},
|
|
564
|
+
async fill(selector, value) {
|
|
565
|
+
await input.page.locator(selector).fill(value);
|
|
566
|
+
},
|
|
567
|
+
async evaluate(fn) {
|
|
568
|
+
return await input.page.evaluate(fn);
|
|
569
|
+
},
|
|
570
|
+
destroy: input.destroy
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
async function openMountPage(input) {
|
|
574
|
+
const page = input.runtime.page;
|
|
575
|
+
const viewport = input.viewport ?? DEFAULT_VIEWPORT;
|
|
576
|
+
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
577
|
+
const entryPath = writeEntry(input.runtime.tempDir, input.name, input.entrySource);
|
|
578
|
+
const htmlPath = writeHtml(input.runtime.tempDir, input.name, entryPath, input.css);
|
|
579
|
+
await installBridge(page, input.bridge.state, input.bridge.manifest, input.bridge.bridgeInit);
|
|
580
|
+
const mountedBridge = createMountedBridge(page, input.bridge.state);
|
|
581
|
+
const context = {
|
|
582
|
+
page,
|
|
583
|
+
bridge: mountedBridge,
|
|
584
|
+
...input.context
|
|
585
|
+
};
|
|
586
|
+
await input.hooks?.beforeMount?.(context);
|
|
587
|
+
await page.goto(`${serverBaseUrl(input.runtime.server)}${viteImport(htmlPath)}`, {
|
|
588
|
+
waitUntil: "domcontentloaded"
|
|
589
|
+
});
|
|
590
|
+
for (const event of input.bridge.state.initialEvents) {
|
|
591
|
+
await mountedBridge.emit(event.channel, event.payload);
|
|
592
|
+
}
|
|
593
|
+
await input.hooks?.afterMount?.(context);
|
|
594
|
+
return createSurface({
|
|
595
|
+
page,
|
|
596
|
+
bridge: mountedBridge,
|
|
597
|
+
outputDir: path.join(input.runtime.rootDir, "test-results", "wood-browser"),
|
|
598
|
+
destroy: async () => {
|
|
599
|
+
await page.evaluate(() => {
|
|
600
|
+
const app = window.__WOOD_BROWSER_APP__;
|
|
601
|
+
if (app && typeof app === "object" && typeof app.$destroy === "function") {
|
|
602
|
+
app.$destroy();
|
|
603
|
+
}
|
|
604
|
+
}).catch(() => void 0);
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
async function createHarness(config = {}) {
|
|
609
|
+
const rootDir = normalizeRoot(config.rootDir);
|
|
610
|
+
const browserName = config.browser?.browserName ?? "chromium";
|
|
611
|
+
const playwrightBrowser = await loadPlaywright(browserName);
|
|
612
|
+
const launchedBrowser = await playwrightBrowser.launch({
|
|
613
|
+
headless: config.browser?.headless ?? true
|
|
614
|
+
});
|
|
615
|
+
const server = await createViteServer(rootDir);
|
|
616
|
+
const page = await launchedBrowser.newPage({
|
|
617
|
+
viewport: {
|
|
618
|
+
width: config.viewport?.width ?? DEFAULT_VIEWPORT.width,
|
|
619
|
+
height: config.viewport?.height ?? DEFAULT_VIEWPORT.height,
|
|
620
|
+
deviceScaleFactor: config.viewport?.deviceScaleFactor
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
const tempDir = fs.mkdtempSync(path.join(rootDir, ".wood-browser-harness-"));
|
|
624
|
+
const runtime = {
|
|
625
|
+
browser: launchedBrowser,
|
|
626
|
+
server,
|
|
627
|
+
page,
|
|
628
|
+
tempDir,
|
|
629
|
+
rootDir
|
|
630
|
+
};
|
|
631
|
+
const css = (config.css ?? []).map((cssPath) => {
|
|
632
|
+
const resolved = resolveFromRoot(rootDir, cssPath);
|
|
633
|
+
assertFileExists(resolved, "CSS file");
|
|
634
|
+
return resolved;
|
|
635
|
+
});
|
|
636
|
+
return {
|
|
637
|
+
page,
|
|
638
|
+
async mountRoute(routeKey, options = {}) {
|
|
639
|
+
const bridge = createBrowserBridgeFixture(mergeBridge(config.bridge, options.bridge));
|
|
640
|
+
const route = {
|
|
641
|
+
params: options.route?.params ?? {},
|
|
642
|
+
query: options.route?.query ?? {}
|
|
643
|
+
};
|
|
644
|
+
let entrySource;
|
|
645
|
+
if (config.viewsConfig) {
|
|
646
|
+
const viewsPath = resolveFromRoot(rootDir, config.viewsConfig);
|
|
647
|
+
const views = await loadViews(viewsPath);
|
|
648
|
+
const view = findView(views, routeKey);
|
|
649
|
+
entrySource = buildRouteEntrySource({
|
|
650
|
+
routeKey,
|
|
651
|
+
view,
|
|
652
|
+
rootDir,
|
|
653
|
+
componentDir: config.componentDir,
|
|
654
|
+
route
|
|
655
|
+
});
|
|
656
|
+
} else if (config.generatedApp) {
|
|
657
|
+
const generatedApp = resolveFromRoot(rootDir, config.generatedApp);
|
|
658
|
+
assertFileExists(generatedApp, "Generated app");
|
|
659
|
+
entrySource = buildGeneratedAppEntrySource({ generatedApp, routeKey });
|
|
660
|
+
} else {
|
|
661
|
+
throw new Error("mountRoute requires either viewsConfig or generatedApp in browser.createHarness().");
|
|
662
|
+
}
|
|
663
|
+
const mounted = await openMountPage({
|
|
664
|
+
runtime,
|
|
665
|
+
entrySource,
|
|
666
|
+
name: `route-${routeKey.replace(/[^a-zA-Z0-9_-]/g, "-")}`,
|
|
667
|
+
css,
|
|
668
|
+
viewport: options.viewport ?? config.viewport,
|
|
669
|
+
bridge,
|
|
670
|
+
context: {
|
|
671
|
+
route: {
|
|
672
|
+
key: routeKey,
|
|
673
|
+
params: route.params,
|
|
674
|
+
query: route.query
|
|
675
|
+
}
|
|
676
|
+
},
|
|
677
|
+
hooks: {
|
|
678
|
+
beforeMount: options.page?.beforeMount ?? config.page?.beforeMount,
|
|
679
|
+
afterMount: options.page?.afterMount ?? config.page?.afterMount
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
return {
|
|
683
|
+
...mounted,
|
|
684
|
+
routeKey
|
|
685
|
+
};
|
|
686
|
+
},
|
|
687
|
+
async mountComponent(component, options = {}) {
|
|
688
|
+
const componentPath = resolveFromRoot(rootDir, component);
|
|
689
|
+
assertFileExists(componentPath, "Component");
|
|
690
|
+
const bridge = createBrowserBridgeFixture(mergeBridge(config.bridge, options.bridge));
|
|
691
|
+
return await openMountPage({
|
|
692
|
+
runtime,
|
|
693
|
+
entrySource: buildComponentEntrySource({
|
|
694
|
+
componentPath,
|
|
695
|
+
props: options.props ?? {}
|
|
696
|
+
}),
|
|
697
|
+
name: `component-${path.basename(componentPath).replace(/[^a-zA-Z0-9_-]/g, "-")}`,
|
|
698
|
+
css,
|
|
699
|
+
viewport: options.viewport ?? config.viewport,
|
|
700
|
+
bridge,
|
|
701
|
+
context: {},
|
|
702
|
+
hooks: {
|
|
703
|
+
beforeMount: options.page?.beforeMount ?? config.page?.beforeMount,
|
|
704
|
+
afterMount: options.page?.afterMount ?? config.page?.afterMount
|
|
705
|
+
}
|
|
706
|
+
});
|
|
707
|
+
},
|
|
708
|
+
async mountTree(tree) {
|
|
709
|
+
const bridge = createBrowserBridgeFixture(mergeBridge(config.bridge, tree.bridge));
|
|
710
|
+
const layouts = (tree.layouts ?? []).map((layout) => ({
|
|
711
|
+
component: resolveFromRoot(rootDir, layout.component),
|
|
712
|
+
props: layout.props
|
|
713
|
+
}));
|
|
714
|
+
for (const layout of layouts) {
|
|
715
|
+
assertFileExists(layout.component, "Layout component");
|
|
716
|
+
}
|
|
717
|
+
const view = {
|
|
718
|
+
component: resolveFromRoot(rootDir, tree.view.component),
|
|
719
|
+
props: tree.view.props
|
|
720
|
+
};
|
|
721
|
+
assertFileExists(view.component, "View component");
|
|
722
|
+
return await openMountPage({
|
|
723
|
+
runtime,
|
|
724
|
+
entrySource: buildTreeEntrySource({ layouts, view }),
|
|
725
|
+
name: `tree-${path.basename(view.component).replace(/[^a-zA-Z0-9_-]/g, "-")}`,
|
|
726
|
+
css,
|
|
727
|
+
viewport: tree.viewport ?? config.viewport,
|
|
728
|
+
bridge,
|
|
729
|
+
context: tree.page?.key ? {
|
|
730
|
+
route: {
|
|
731
|
+
key: tree.page.key,
|
|
732
|
+
params: tree.page.params ?? {},
|
|
733
|
+
query: tree.page.query ?? {}
|
|
734
|
+
}
|
|
735
|
+
} : {},
|
|
736
|
+
hooks: config.page
|
|
737
|
+
});
|
|
738
|
+
},
|
|
739
|
+
async destroy() {
|
|
740
|
+
await page.close().catch(() => void 0);
|
|
741
|
+
await server.close().catch(() => void 0);
|
|
742
|
+
await launchedBrowser.close().catch(() => void 0);
|
|
743
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
const browser = {
|
|
748
|
+
createHarness
|
|
749
|
+
};
|
|
750
|
+
export {
|
|
751
|
+
browser,
|
|
752
|
+
createBrowserBridgeFixture
|
|
753
|
+
};
|
|
754
|
+
//# sourceMappingURL=browser.js.map
|