@noego/wood 0.4.1 → 0.4.3

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.
Files changed (81) hide show
  1. package/README.md +100 -2
  2. package/dist/client/global_polyfill.d.cts +1 -2
  3. package/dist/client/global_polyfill.d.ts +1 -2
  4. package/dist/client/index.cjs.map +1 -1
  5. package/dist/client/index.d.cts +1 -1
  6. package/dist/client/index.d.ts +1 -1
  7. package/dist/client/index.js.map +1 -1
  8. package/dist/index.cjs +0 -4
  9. package/dist/index.cjs.map +1 -1
  10. package/dist/index.d.cts +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.js +0 -4
  13. package/dist/index.js.map +1 -1
  14. package/dist/testing/browser.cjs +253 -36
  15. package/dist/testing/browser.cjs.map +1 -1
  16. package/dist/testing/browser.d.cts +6 -2
  17. package/dist/testing/browser.d.ts +6 -2
  18. package/dist/testing/browser.js +253 -37
  19. package/dist/testing/browser.js.map +1 -1
  20. package/dist/testing/create_controller_harness.cjs +3 -1
  21. package/dist/testing/create_controller_harness.cjs.map +1 -1
  22. package/dist/testing/create_controller_harness.d.cts +4 -2
  23. package/dist/testing/create_controller_harness.d.ts +4 -2
  24. package/dist/testing/create_controller_harness.js +3 -1
  25. package/dist/testing/create_controller_harness.js.map +1 -1
  26. package/dist/testing/index.cjs +3 -3
  27. package/dist/testing/index.cjs.map +1 -1
  28. package/dist/testing/index.d.cts +5 -3
  29. package/dist/testing/index.d.ts +5 -3
  30. package/dist/testing/index.js +2 -2
  31. package/dist/testing/index.js.map +1 -1
  32. package/dist/trace-testing/index.cjs +3 -3
  33. package/dist/trace-testing/index.cjs.map +1 -1
  34. package/dist/trace-testing/index.d.cts +2 -3
  35. package/dist/trace-testing/index.d.ts +2 -3
  36. package/dist/trace-testing/index.js +3 -3
  37. package/dist/trace-testing/index.js.map +1 -1
  38. package/dist/trace-testing/trace_assertions.cjs +2 -46
  39. package/dist/trace-testing/trace_assertions.cjs.map +1 -1
  40. package/dist/trace-testing/trace_assertions.d.cts +1 -15
  41. package/dist/trace-testing/trace_assertions.d.ts +1 -15
  42. package/dist/trace-testing/trace_assertions.js +3 -45
  43. package/dist/trace-testing/trace_assertions.js.map +1 -1
  44. package/dist/trace-testing/trace_query.cjs +4 -115
  45. package/dist/trace-testing/trace_query.cjs.map +1 -1
  46. package/dist/trace-testing/trace_query.d.cts +1 -24
  47. package/dist/trace-testing/trace_query.d.ts +1 -24
  48. package/dist/trace-testing/trace_query.js +5 -112
  49. package/dist/trace-testing/trace_query.js.map +1 -1
  50. package/dist/trace-testing/trace_recorder.cjs +50 -0
  51. package/dist/trace-testing/trace_recorder.cjs.map +1 -0
  52. package/dist/trace-testing/trace_recorder.d.cts +12 -0
  53. package/dist/trace-testing/trace_recorder.d.ts +12 -0
  54. package/dist/trace-testing/trace_recorder.js +32 -0
  55. package/dist/trace-testing/trace_recorder.js.map +1 -0
  56. package/dist/tracing/index.cjs +0 -4
  57. package/dist/tracing/index.cjs.map +1 -1
  58. package/dist/tracing/index.d.cts +1 -1
  59. package/dist/tracing/index.d.ts +1 -1
  60. package/dist/tracing/index.js +1 -5
  61. package/dist/tracing/index.js.map +1 -1
  62. package/dist/tracing/trace_hub.cjs +0 -84
  63. package/dist/tracing/trace_hub.cjs.map +1 -1
  64. package/dist/tracing/trace_hub.d.cts +1 -16
  65. package/dist/tracing/trace_hub.d.ts +1 -16
  66. package/dist/tracing/trace_hub.js +0 -72
  67. package/dist/tracing/trace_hub.js.map +1 -1
  68. package/dist/tracing/trace_provider.cjs +0 -6
  69. package/dist/tracing/trace_provider.cjs.map +1 -1
  70. package/dist/tracing/trace_provider.d.cts +1 -3
  71. package/dist/tracing/trace_provider.d.ts +1 -3
  72. package/dist/tracing/trace_provider.js +0 -7
  73. package/dist/tracing/trace_provider.js.map +1 -1
  74. package/docs/browser-testing.md +84 -0
  75. package/package.json +2 -1
  76. package/dist/trace-testing/trace_scenario.cjs +0 -61
  77. package/dist/trace-testing/trace_scenario.cjs.map +0 -1
  78. package/dist/trace-testing/trace_scenario.d.cts +0 -27
  79. package/dist/trace-testing/trace_scenario.d.ts +0 -27
  80. package/dist/trace-testing/trace_scenario.js +0 -37
  81. package/dist/trace-testing/trace_scenario.js.map +0 -1
@@ -1,15 +1,31 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
2
3
  import { createRequire } from "node:module";
3
4
  import path from "node:path";
4
5
  import { pathToFileURL } from "node:url";
5
6
  import {
6
- createTraceCapture,
7
7
  dispatchRendererTracePayload
8
8
  } from "../tracing/index.js";
9
+ import { TraceRecorder } from "../trace-testing/index.js";
9
10
  const DEFAULT_VIEWPORT = {
10
11
  width: 1280,
11
12
  height: 800
12
13
  };
14
+ const DEFAULT_EXPECT_TIMEOUT_MS = 1e4;
15
+ const DEFAULT_RESOURCE_CLOSE_TIMEOUT_MS = 5e3;
16
+ const WORKER_RUNTIME_IDLE_CLOSE_MS = 5e3;
17
+ const browserWorkerRuntimes = /* @__PURE__ */ new Map();
18
+ const browserWorkerCacheDirs = /* @__PURE__ */ new Set();
19
+ process.on("exit", () => {
20
+ for (const cacheDir of browserWorkerCacheDirs) {
21
+ try {
22
+ fs.rmSync(cacheDir, { recursive: true, force: true });
23
+ } catch {
24
+ }
25
+ }
26
+ browserWorkerCacheDirs.clear();
27
+ browserWorkerRuntimes.clear();
28
+ });
13
29
  function normalizeRoot(rootDir) {
14
30
  return path.resolve(rootDir ?? process.cwd());
15
31
  }
@@ -32,6 +48,18 @@ function mergeBridge(base, override) {
32
48
  record: override?.record ?? base?.record
33
49
  };
34
50
  }
51
+ async function waitForBoundedClose(promise, timeoutMs = DEFAULT_RESOURCE_CLOSE_TIMEOUT_MS) {
52
+ let timeout;
53
+ await Promise.race([
54
+ promise.catch(() => void 0),
55
+ new Promise((resolve) => {
56
+ timeout = setTimeout(resolve, timeoutMs);
57
+ })
58
+ ]);
59
+ if (timeout) {
60
+ clearTimeout(timeout);
61
+ }
62
+ }
35
63
  function channelToManifestEntry(channel, handler) {
36
64
  const pathSegments = channel.split(".").filter(Boolean);
37
65
  const action = pathSegments.length > 0 ? pathSegments[pathSegments.length - 1] : channel;
@@ -133,6 +161,51 @@ async function loadPlaywright(browserName) {
133
161
  async function loadVite() {
134
162
  return await import("vite");
135
163
  }
164
+ function createPackageRequire(rootDir) {
165
+ return createRequire(path.join(rootDir, "package.json"));
166
+ }
167
+ function resolvePackageModule(rootDir, source) {
168
+ const rootRequire = createPackageRequire(rootDir);
169
+ const processRequire = createRequire(path.join(process.cwd(), "package.json"));
170
+ try {
171
+ return rootRequire.resolve(source);
172
+ } catch {
173
+ try {
174
+ return processRequire.resolve(source);
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+ }
180
+ function resolvePackageDir(rootDir, packageName) {
181
+ const packageJson = resolvePackageModule(rootDir, `${packageName}/package.json`);
182
+ return packageJson ? path.dirname(packageJson) : null;
183
+ }
184
+ function createWoodBrowserResolver(rootDir) {
185
+ const sveltePackageDir = resolvePackageDir(rootDir, "svelte");
186
+ const svelteClientEntry = sveltePackageDir ? path.join(sveltePackageDir, "src", "index-client.js") : null;
187
+ const svelteLegacyClientEntry = sveltePackageDir ? path.join(sveltePackageDir, "src", "legacy", "legacy-client.js") : null;
188
+ const svelteReactivityClientEntry = sveltePackageDir ? path.join(sveltePackageDir, "src", "reactivity", "index-client.js") : null;
189
+ return {
190
+ name: "wood-browser-resolver",
191
+ enforce: "pre",
192
+ resolveId(source) {
193
+ if (source === "svelte") {
194
+ return svelteClientEntry;
195
+ }
196
+ if (source === "svelte/legacy") {
197
+ return svelteLegacyClientEntry;
198
+ }
199
+ if (source === "svelte/reactivity") {
200
+ return svelteReactivityClientEntry;
201
+ }
202
+ if (source.startsWith("svelte/")) {
203
+ return resolvePackageModule(rootDir, source);
204
+ }
205
+ return null;
206
+ }
207
+ };
208
+ }
136
209
  async function loadSveltePlugin(rootDir) {
137
210
  try {
138
211
  const rootPluginPath = path.join(
@@ -163,22 +236,115 @@ async function loadViews(viewsPath) {
163
236
  const parser = await import("../parser/views_parser.js");
164
237
  return parser.parseViewsFile(viewsPath);
165
238
  }
166
- async function createViteServer(rootDir) {
239
+ async function createViteServer(rootDir, cacheDir) {
167
240
  const vite = await loadVite();
168
241
  const svelte = await loadSveltePlugin(rootDir);
169
- const plugins = svelte ? [svelte()] : [];
242
+ const plugins = [
243
+ createWoodBrowserResolver(rootDir),
244
+ ...svelte ? [svelte()] : []
245
+ ];
170
246
  const server = await vite.createServer({
171
247
  root: rootDir,
248
+ cacheDir,
172
249
  logLevel: process.env.WOOD_BROWSER_TEST_DEBUG ? "info" : "silent",
173
250
  plugins,
174
251
  resolve: {
175
252
  dedupe: ["svelte"]
176
253
  },
177
- server: { port: 0 }
254
+ server: { port: 0, fs: { allow: [rootDir, os.tmpdir()] } }
178
255
  });
179
256
  await server.listen();
180
257
  return server;
181
258
  }
259
+ function workerRuntimeKey(input) {
260
+ return [
261
+ input.rootDir,
262
+ input.browserName,
263
+ input.headless ? "headless" : "headed"
264
+ ].join("::");
265
+ }
266
+ async function createBrowserWorkerRuntime(input) {
267
+ const cacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), "wood-browser-vite-"));
268
+ browserWorkerCacheDirs.add(cacheRoot);
269
+ let browser2 = null;
270
+ try {
271
+ const playwrightBrowser = await loadPlaywright(input.browserName);
272
+ browser2 = await playwrightBrowser.launch({ headless: input.headless });
273
+ const server = await createViteServer(input.rootDir, path.join(cacheRoot, ".vite"));
274
+ return {
275
+ browser: browser2,
276
+ server,
277
+ cacheDir: cacheRoot,
278
+ rootDir: input.rootDir,
279
+ key: input.key,
280
+ activeHarnesses: 0,
281
+ closing: false,
282
+ idleCloseTimer: null
283
+ };
284
+ } catch (error) {
285
+ await browser2?.close().catch(() => void 0);
286
+ fs.rmSync(cacheRoot, { recursive: true, force: true });
287
+ browserWorkerCacheDirs.delete(cacheRoot);
288
+ throw error;
289
+ }
290
+ }
291
+ async function acquireBrowserWorkerRuntime(input) {
292
+ const key = workerRuntimeKey(input);
293
+ let entry = browserWorkerRuntimes.get(key);
294
+ if (!entry) {
295
+ entry = {
296
+ promise: createBrowserWorkerRuntime({ ...input, key })
297
+ };
298
+ browserWorkerRuntimes.set(key, entry);
299
+ }
300
+ const runtime = await entry.promise;
301
+ if (runtime.closing) {
302
+ browserWorkerRuntimes.delete(key);
303
+ return await acquireBrowserWorkerRuntime(input);
304
+ }
305
+ if (runtime.idleCloseTimer) {
306
+ clearTimeout(runtime.idleCloseTimer);
307
+ runtime.idleCloseTimer = null;
308
+ }
309
+ runtime.activeHarnesses += 1;
310
+ return runtime;
311
+ }
312
+ async function closeBrowserWorkerRuntime(runtime) {
313
+ if (runtime.closing) {
314
+ return;
315
+ }
316
+ runtime.closing = true;
317
+ try {
318
+ await Promise.all([
319
+ waitForBoundedClose(runtime.server.close()),
320
+ waitForBoundedClose(runtime.browser.close())
321
+ ]);
322
+ } finally {
323
+ fs.rmSync(runtime.cacheDir, { recursive: true, force: true });
324
+ browserWorkerCacheDirs.delete(runtime.cacheDir);
325
+ const entry = browserWorkerRuntimes.get(runtime.key);
326
+ const current = entry ? await entry.promise.catch(() => null) : null;
327
+ if (current === runtime) {
328
+ browserWorkerRuntimes.delete(runtime.key);
329
+ }
330
+ }
331
+ }
332
+ function scheduleBrowserWorkerRuntimeClose(runtime) {
333
+ if (runtime.closing || runtime.activeHarnesses > 0 || runtime.idleCloseTimer) {
334
+ return;
335
+ }
336
+ runtime.idleCloseTimer = setTimeout(() => {
337
+ runtime.idleCloseTimer = null;
338
+ if (runtime.activeHarnesses === 0) {
339
+ void closeBrowserWorkerRuntime(runtime);
340
+ }
341
+ }, WORKER_RUNTIME_IDLE_CLOSE_MS);
342
+ runtime.idleCloseTimer.unref?.();
343
+ }
344
+ function releaseBrowserWorkerRuntime(runtime) {
345
+ runtime.activeHarnesses = Math.max(0, runtime.activeHarnesses - 1);
346
+ scheduleBrowserWorkerRuntimeClose(runtime);
347
+ }
182
348
  function serverBaseUrl(server) {
183
349
  return server.resolvedUrls?.local?.[0] ?? "http://127.0.0.1:5173";
184
350
  }
@@ -385,8 +551,8 @@ async function installBridge(page, bridgeState, manifest, bridgeInit) {
385
551
  window.__WOOD_BROWSER_BRIDGE_MANIFEST__ = entries;
386
552
  }, manifest);
387
553
  }
388
- async function installTraceBinding(page, trace, context) {
389
- if (!trace) {
554
+ async function installTraceBinding(page, traceRecorder, context) {
555
+ if (!traceRecorder) {
390
556
  return;
391
557
  }
392
558
  await page.exposeBinding("__woodBrowserTrace", async (_source, payload) => {
@@ -469,20 +635,35 @@ async function elementRect(page, selector) {
469
635
  }).catch(() => null);
470
636
  }
471
637
  function createExpect(page, outputDir) {
638
+ async function waitForVisible(selector) {
639
+ const locator = page.locator(selector).first();
640
+ try {
641
+ await locator.waitFor({ state: "attached", timeout: DEFAULT_EXPECT_TIMEOUT_MS });
642
+ } catch {
643
+ throw new Error(`Expected "${selector}" to exist.`);
644
+ }
645
+ try {
646
+ await locator.waitFor({ state: "visible", timeout: DEFAULT_EXPECT_TIMEOUT_MS });
647
+ } catch {
648
+ throw new Error(`Expected "${selector}" to be visible.`);
649
+ }
650
+ }
472
651
  return {
473
652
  async visible(selector) {
474
- const count = await page.locator(selector).count();
475
- if (count < 1) throw new Error(`Expected "${selector}" to exist.`);
476
- const visible = await page.locator(selector).first().isVisible();
477
- if (!visible) throw new Error(`Expected "${selector}" to be visible.`);
653
+ await waitForVisible(selector);
478
654
  },
479
655
  async hidden(selector) {
480
- const count = await page.locator(selector).count();
481
- if (count < 1) return;
482
- const visible = await page.locator(selector).first().isVisible();
483
- if (visible) throw new Error(`Expected "${selector}" to be hidden.`);
656
+ try {
657
+ await page.locator(selector).first().waitFor({
658
+ state: "hidden",
659
+ timeout: DEFAULT_EXPECT_TIMEOUT_MS
660
+ });
661
+ } catch {
662
+ throw new Error(`Expected "${selector}" to be hidden.`);
663
+ }
484
664
  },
485
665
  async insideViewport(selector, options = {}) {
666
+ await waitForVisible(selector);
486
667
  const margin = options.margin ?? 0;
487
668
  const rect = await elementRect(page, selector);
488
669
  if (!rect) throw new Error(`Expected "${selector}" to exist.`);
@@ -493,6 +674,7 @@ function createExpect(page, outputDir) {
493
674
  }
494
675
  },
495
676
  async notClipped(selector) {
677
+ await waitForVisible(selector);
496
678
  const result = await page.locator(selector).first().evaluate((element) => {
497
679
  const rect = element.getBoundingClientRect();
498
680
  let parent = element.parentElement;
@@ -514,6 +696,8 @@ function createExpect(page, outputDir) {
514
696
  }
515
697
  },
516
698
  async notOverlapping(firstSelector, secondSelector) {
699
+ await waitForVisible(firstSelector);
700
+ await waitForVisible(secondSelector);
517
701
  const first = await elementRect(page, firstSelector);
518
702
  const second = await elementRect(page, secondSelector);
519
703
  if (!first || !second) throw new Error(`Expected both "${firstSelector}" and "${secondSelector}" to exist.`);
@@ -523,6 +707,8 @@ function createExpect(page, outputDir) {
523
707
  }
524
708
  },
525
709
  async zIndexAbove(topSelector, lowerSelector) {
710
+ await waitForVisible(topSelector);
711
+ await waitForVisible(lowerSelector);
526
712
  const values = await page.evaluate((payload) => {
527
713
  const { topSelector: top, lowerSelector: lower } = payload;
528
714
  const topElement = document.querySelector(top);
@@ -596,7 +782,7 @@ async function openMountPage(input) {
596
782
  await page.setViewportSize({ width: viewport.width, height: viewport.height });
597
783
  const entryPath = writeEntry(input.runtime.tempDir, input.name, input.entrySource);
598
784
  const htmlPath = writeHtml(input.runtime.tempDir, input.name, entryPath, input.css);
599
- await installTraceBinding(input.runtime.page, input.trace, input.traceContext ?? {});
785
+ await installTraceBinding(input.runtime.page, input.traceRecorder, input.traceContext ?? {});
600
786
  await installBridge(page, input.bridge.state, input.bridge.manifest, input.bridge.bridgeInit);
601
787
  const mountedBridge = createMountedBridge(page, input.bridge.state);
602
788
  const context = {
@@ -629,23 +815,47 @@ async function openMountPage(input) {
629
815
  async function createHarness(config = {}) {
630
816
  const rootDir = normalizeRoot(config.rootDir);
631
817
  const browserName = config.browser?.browserName ?? "chromium";
632
- const playwrightBrowser = await loadPlaywright(browserName);
633
- const launchedBrowser = await playwrightBrowser.launch({
634
- headless: config.browser?.headless ?? true
818
+ const headless = config.browser?.headless ?? true;
819
+ const workerRuntime = await acquireBrowserWorkerRuntime({
820
+ rootDir,
821
+ browserName,
822
+ headless
635
823
  });
636
- const server = await createViteServer(rootDir);
637
- const page = await launchedBrowser.newPage({
638
- viewport: {
639
- width: config.viewport?.width ?? DEFAULT_VIEWPORT.width,
640
- height: config.viewport?.height ?? DEFAULT_VIEWPORT.height,
641
- deviceScaleFactor: config.viewport?.deviceScaleFactor
824
+ let released = false;
825
+ let destroyed = false;
826
+ let tempDir = null;
827
+ let context = null;
828
+ let page = null;
829
+ let traceRecorder;
830
+ function releaseRuntimeOnce() {
831
+ if (released) {
832
+ return;
642
833
  }
643
- });
644
- const tempDir = fs.mkdtempSync(path.join(rootDir, ".wood-browser-harness-"));
645
- const trace = config.trace?.enabled === true ? createTraceCapture({ maxEvents: config.trace.maxEvents }) : void 0;
834
+ released = true;
835
+ releaseBrowserWorkerRuntime(workerRuntime);
836
+ }
837
+ try {
838
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "wood-browser-harness-"));
839
+ context = await workerRuntime.browser.newContext({
840
+ viewport: {
841
+ width: config.viewport?.width ?? DEFAULT_VIEWPORT.width,
842
+ height: config.viewport?.height ?? DEFAULT_VIEWPORT.height,
843
+ deviceScaleFactor: config.viewport?.deviceScaleFactor
844
+ }
845
+ });
846
+ page = await context.newPage();
847
+ traceRecorder = config.trace?.enabled === true ? new TraceRecorder() : void 0;
848
+ traceRecorder?.enable({ maxEvents: config.trace?.maxEvents });
849
+ } catch (error) {
850
+ if (page) await waitForBoundedClose(page.close());
851
+ if (context) await waitForBoundedClose(context.close());
852
+ if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
853
+ releaseRuntimeOnce();
854
+ throw error;
855
+ }
646
856
  const runtime = {
647
- browser: launchedBrowser,
648
- server,
857
+ context,
858
+ server: workerRuntime.server,
649
859
  page,
650
860
  tempDir,
651
861
  rootDir
@@ -657,7 +867,7 @@ async function createHarness(config = {}) {
657
867
  });
658
868
  return {
659
869
  page,
660
- ...trace ? { trace } : {},
870
+ ...traceRecorder ? { traceRecorder } : {},
661
871
  async mountRoute(routeKey, options = {}) {
662
872
  const bridge = createBrowserBridgeFixtureWithTrace(mergeBridge(config.bridge, options.bridge), config.trace);
663
873
  const route = {
@@ -690,7 +900,7 @@ async function createHarness(config = {}) {
690
900
  css,
691
901
  viewport: options.viewport ?? config.viewport,
692
902
  bridge,
693
- trace,
903
+ traceRecorder,
694
904
  traceContext: {
695
905
  windowId: config.trace?.windowId ?? routeKey.split(".")[0],
696
906
  routeKey,
@@ -727,7 +937,7 @@ async function createHarness(config = {}) {
727
937
  css,
728
938
  viewport: options.viewport ?? config.viewport,
729
939
  bridge,
730
- trace,
940
+ traceRecorder,
731
941
  traceContext: {
732
942
  windowId: config.trace?.windowId ?? "wood-browser-harness",
733
943
  harnessId: config.trace?.harnessId
@@ -760,7 +970,7 @@ async function createHarness(config = {}) {
760
970
  css,
761
971
  viewport: tree.viewport ?? config.viewport,
762
972
  bridge,
763
- trace,
973
+ traceRecorder,
764
974
  traceContext: {
765
975
  windowId: config.trace?.windowId ?? tree.page?.key?.split(".")[0] ?? "wood-browser-harness",
766
976
  routeKey: tree.page?.key,
@@ -777,11 +987,17 @@ async function createHarness(config = {}) {
777
987
  });
778
988
  },
779
989
  async destroy() {
780
- await page.close().catch(() => void 0);
781
- await server.close().catch(() => void 0);
782
- await launchedBrowser.close().catch(() => void 0);
783
- trace?.stop();
990
+ if (destroyed) {
991
+ return;
992
+ }
993
+ destroyed = true;
994
+ await Promise.all([
995
+ waitForBoundedClose(page.close()),
996
+ waitForBoundedClose(context.close())
997
+ ]);
998
+ traceRecorder?.disable();
784
999
  fs.rmSync(tempDir, { recursive: true, force: true });
1000
+ releaseRuntimeOnce();
785
1001
  }
786
1002
  };
787
1003
  }