@noego/wood 0.4.1 → 0.4.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.
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 +188 -34
  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 +188 -35
  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,5 +1,9 @@
1
- import { TraceCapture, TraceLevel } from '../tracing/trace_hub.cjs';
1
+ import { TraceLevel } from '../tracing/trace_hub.cjs';
2
+ import { TraceRecorder } from '../trace-testing/trace_recorder.cjs';
2
3
  import 'rxjs';
4
+ import '@noego/trace/testing';
5
+ import '../tracing/trace_provider.cjs';
6
+ import '@noego/ioc';
3
7
 
4
8
  type MaybePromise<T> = T | Promise<T>;
5
9
  type BrowserName = 'chromium' | 'firefox' | 'webkit';
@@ -153,7 +157,7 @@ type MountedWoodComponent = MountedWoodSurface;
153
157
  type MountedWoodTree = MountedWoodSurface;
154
158
  interface WoodBrowserHarness {
155
159
  page: any;
156
- trace?: TraceCapture;
160
+ traceRecorder?: TraceRecorder;
157
161
  mountRoute(routeKey: string, options?: MountRouteOptions): Promise<MountedWoodRoute>;
158
162
  mountComponent(component: string, options?: MountComponentOptions): Promise<MountedWoodComponent>;
159
163
  mountTree(tree: MountTreeOptions): Promise<MountedWoodTree>;
@@ -1,5 +1,9 @@
1
- import { TraceCapture, TraceLevel } from '../tracing/trace_hub.js';
1
+ import { TraceLevel } from '../tracing/trace_hub.js';
2
+ import { TraceRecorder } from '../trace-testing/trace_recorder.js';
2
3
  import 'rxjs';
4
+ import '@noego/trace/testing';
5
+ import '../tracing/trace_provider.js';
6
+ import '@noego/ioc';
3
7
 
4
8
  type MaybePromise<T> = T | Promise<T>;
5
9
  type BrowserName = 'chromium' | 'firefox' | 'webkit';
@@ -153,7 +157,7 @@ type MountedWoodComponent = MountedWoodSurface;
153
157
  type MountedWoodTree = MountedWoodSurface;
154
158
  interface WoodBrowserHarness {
155
159
  page: any;
156
- trace?: TraceCapture;
160
+ traceRecorder?: TraceRecorder;
157
161
  mountRoute(routeKey: string, options?: MountRouteOptions): Promise<MountedWoodRoute>;
158
162
  mountComponent(component: string, options?: MountComponentOptions): Promise<MountedWoodComponent>;
159
163
  mountTree(tree: MountTreeOptions): Promise<MountedWoodTree>;
@@ -3,13 +3,17 @@ import { createRequire } from "node:module";
3
3
  import path from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
5
  import {
6
- createTraceCapture,
7
6
  dispatchRendererTracePayload
8
7
  } from "../tracing/index.js";
8
+ import { TraceRecorder } from "../trace-testing/index.js";
9
9
  const DEFAULT_VIEWPORT = {
10
10
  width: 1280,
11
11
  height: 800
12
12
  };
13
+ const DEFAULT_EXPECT_TIMEOUT_MS = 1e4;
14
+ const DEFAULT_RESOURCE_CLOSE_TIMEOUT_MS = 5e3;
15
+ const WORKER_RUNTIME_IDLE_CLOSE_MS = 5e3;
16
+ const browserWorkerRuntimes = /* @__PURE__ */ new Map();
13
17
  function normalizeRoot(rootDir) {
14
18
  return path.resolve(rootDir ?? process.cwd());
15
19
  }
@@ -32,6 +36,18 @@ function mergeBridge(base, override) {
32
36
  record: override?.record ?? base?.record
33
37
  };
34
38
  }
39
+ async function waitForBoundedClose(promise, timeoutMs = DEFAULT_RESOURCE_CLOSE_TIMEOUT_MS) {
40
+ let timeout;
41
+ await Promise.race([
42
+ promise.catch(() => void 0),
43
+ new Promise((resolve) => {
44
+ timeout = setTimeout(resolve, timeoutMs);
45
+ })
46
+ ]);
47
+ if (timeout) {
48
+ clearTimeout(timeout);
49
+ }
50
+ }
35
51
  function channelToManifestEntry(channel, handler) {
36
52
  const pathSegments = channel.split(".").filter(Boolean);
37
53
  const action = pathSegments.length > 0 ? pathSegments[pathSegments.length - 1] : channel;
@@ -163,12 +179,13 @@ async function loadViews(viewsPath) {
163
179
  const parser = await import("../parser/views_parser.js");
164
180
  return parser.parseViewsFile(viewsPath);
165
181
  }
166
- async function createViteServer(rootDir) {
182
+ async function createViteServer(rootDir, cacheDir) {
167
183
  const vite = await loadVite();
168
184
  const svelte = await loadSveltePlugin(rootDir);
169
185
  const plugins = svelte ? [svelte()] : [];
170
186
  const server = await vite.createServer({
171
187
  root: rootDir,
188
+ cacheDir,
172
189
  logLevel: process.env.WOOD_BROWSER_TEST_DEBUG ? "info" : "silent",
173
190
  plugins,
174
191
  resolve: {
@@ -179,6 +196,92 @@ async function createViteServer(rootDir) {
179
196
  await server.listen();
180
197
  return server;
181
198
  }
199
+ function workerRuntimeKey(input) {
200
+ return [
201
+ input.rootDir,
202
+ input.browserName,
203
+ input.headless ? "headless" : "headed"
204
+ ].join("::");
205
+ }
206
+ async function createBrowserWorkerRuntime(input) {
207
+ const cacheRoot = fs.mkdtempSync(path.join(input.rootDir, ".wood-browser-vite-"));
208
+ let browser2 = null;
209
+ try {
210
+ const playwrightBrowser = await loadPlaywright(input.browserName);
211
+ browser2 = await playwrightBrowser.launch({ headless: input.headless });
212
+ const server = await createViteServer(input.rootDir, path.join(cacheRoot, ".vite"));
213
+ return {
214
+ browser: browser2,
215
+ server,
216
+ cacheDir: cacheRoot,
217
+ rootDir: input.rootDir,
218
+ key: input.key,
219
+ activeHarnesses: 0,
220
+ closing: false,
221
+ idleCloseTimer: null
222
+ };
223
+ } catch (error) {
224
+ await browser2?.close().catch(() => void 0);
225
+ fs.rmSync(cacheRoot, { recursive: true, force: true });
226
+ throw error;
227
+ }
228
+ }
229
+ async function acquireBrowserWorkerRuntime(input) {
230
+ const key = workerRuntimeKey(input);
231
+ let entry = browserWorkerRuntimes.get(key);
232
+ if (!entry) {
233
+ entry = {
234
+ promise: createBrowserWorkerRuntime({ ...input, key })
235
+ };
236
+ browserWorkerRuntimes.set(key, entry);
237
+ }
238
+ const runtime = await entry.promise;
239
+ if (runtime.closing) {
240
+ browserWorkerRuntimes.delete(key);
241
+ return await acquireBrowserWorkerRuntime(input);
242
+ }
243
+ if (runtime.idleCloseTimer) {
244
+ clearTimeout(runtime.idleCloseTimer);
245
+ runtime.idleCloseTimer = null;
246
+ }
247
+ runtime.activeHarnesses += 1;
248
+ return runtime;
249
+ }
250
+ async function closeBrowserWorkerRuntime(runtime) {
251
+ if (runtime.closing) {
252
+ return;
253
+ }
254
+ runtime.closing = true;
255
+ try {
256
+ await Promise.all([
257
+ waitForBoundedClose(runtime.server.close()),
258
+ waitForBoundedClose(runtime.browser.close())
259
+ ]);
260
+ } finally {
261
+ fs.rmSync(runtime.cacheDir, { recursive: true, force: true });
262
+ const entry = browserWorkerRuntimes.get(runtime.key);
263
+ const current = entry ? await entry.promise.catch(() => null) : null;
264
+ if (current === runtime) {
265
+ browserWorkerRuntimes.delete(runtime.key);
266
+ }
267
+ }
268
+ }
269
+ function scheduleBrowserWorkerRuntimeClose(runtime) {
270
+ if (runtime.closing || runtime.activeHarnesses > 0 || runtime.idleCloseTimer) {
271
+ return;
272
+ }
273
+ runtime.idleCloseTimer = setTimeout(() => {
274
+ runtime.idleCloseTimer = null;
275
+ if (runtime.activeHarnesses === 0) {
276
+ void closeBrowserWorkerRuntime(runtime);
277
+ }
278
+ }, WORKER_RUNTIME_IDLE_CLOSE_MS);
279
+ runtime.idleCloseTimer.unref?.();
280
+ }
281
+ function releaseBrowserWorkerRuntime(runtime) {
282
+ runtime.activeHarnesses = Math.max(0, runtime.activeHarnesses - 1);
283
+ scheduleBrowserWorkerRuntimeClose(runtime);
284
+ }
182
285
  function serverBaseUrl(server) {
183
286
  return server.resolvedUrls?.local?.[0] ?? "http://127.0.0.1:5173";
184
287
  }
@@ -385,8 +488,8 @@ async function installBridge(page, bridgeState, manifest, bridgeInit) {
385
488
  window.__WOOD_BROWSER_BRIDGE_MANIFEST__ = entries;
386
489
  }, manifest);
387
490
  }
388
- async function installTraceBinding(page, trace, context) {
389
- if (!trace) {
491
+ async function installTraceBinding(page, traceRecorder, context) {
492
+ if (!traceRecorder) {
390
493
  return;
391
494
  }
392
495
  await page.exposeBinding("__woodBrowserTrace", async (_source, payload) => {
@@ -469,20 +572,35 @@ async function elementRect(page, selector) {
469
572
  }).catch(() => null);
470
573
  }
471
574
  function createExpect(page, outputDir) {
575
+ async function waitForVisible(selector) {
576
+ const locator = page.locator(selector).first();
577
+ try {
578
+ await locator.waitFor({ state: "attached", timeout: DEFAULT_EXPECT_TIMEOUT_MS });
579
+ } catch {
580
+ throw new Error(`Expected "${selector}" to exist.`);
581
+ }
582
+ try {
583
+ await locator.waitFor({ state: "visible", timeout: DEFAULT_EXPECT_TIMEOUT_MS });
584
+ } catch {
585
+ throw new Error(`Expected "${selector}" to be visible.`);
586
+ }
587
+ }
472
588
  return {
473
589
  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.`);
590
+ await waitForVisible(selector);
478
591
  },
479
592
  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.`);
593
+ try {
594
+ await page.locator(selector).first().waitFor({
595
+ state: "hidden",
596
+ timeout: DEFAULT_EXPECT_TIMEOUT_MS
597
+ });
598
+ } catch {
599
+ throw new Error(`Expected "${selector}" to be hidden.`);
600
+ }
484
601
  },
485
602
  async insideViewport(selector, options = {}) {
603
+ await waitForVisible(selector);
486
604
  const margin = options.margin ?? 0;
487
605
  const rect = await elementRect(page, selector);
488
606
  if (!rect) throw new Error(`Expected "${selector}" to exist.`);
@@ -493,6 +611,7 @@ function createExpect(page, outputDir) {
493
611
  }
494
612
  },
495
613
  async notClipped(selector) {
614
+ await waitForVisible(selector);
496
615
  const result = await page.locator(selector).first().evaluate((element) => {
497
616
  const rect = element.getBoundingClientRect();
498
617
  let parent = element.parentElement;
@@ -514,6 +633,8 @@ function createExpect(page, outputDir) {
514
633
  }
515
634
  },
516
635
  async notOverlapping(firstSelector, secondSelector) {
636
+ await waitForVisible(firstSelector);
637
+ await waitForVisible(secondSelector);
517
638
  const first = await elementRect(page, firstSelector);
518
639
  const second = await elementRect(page, secondSelector);
519
640
  if (!first || !second) throw new Error(`Expected both "${firstSelector}" and "${secondSelector}" to exist.`);
@@ -523,6 +644,8 @@ function createExpect(page, outputDir) {
523
644
  }
524
645
  },
525
646
  async zIndexAbove(topSelector, lowerSelector) {
647
+ await waitForVisible(topSelector);
648
+ await waitForVisible(lowerSelector);
526
649
  const values = await page.evaluate((payload) => {
527
650
  const { topSelector: top, lowerSelector: lower } = payload;
528
651
  const topElement = document.querySelector(top);
@@ -596,7 +719,7 @@ async function openMountPage(input) {
596
719
  await page.setViewportSize({ width: viewport.width, height: viewport.height });
597
720
  const entryPath = writeEntry(input.runtime.tempDir, input.name, input.entrySource);
598
721
  const htmlPath = writeHtml(input.runtime.tempDir, input.name, entryPath, input.css);
599
- await installTraceBinding(input.runtime.page, input.trace, input.traceContext ?? {});
722
+ await installTraceBinding(input.runtime.page, input.traceRecorder, input.traceContext ?? {});
600
723
  await installBridge(page, input.bridge.state, input.bridge.manifest, input.bridge.bridgeInit);
601
724
  const mountedBridge = createMountedBridge(page, input.bridge.state);
602
725
  const context = {
@@ -629,23 +752,47 @@ async function openMountPage(input) {
629
752
  async function createHarness(config = {}) {
630
753
  const rootDir = normalizeRoot(config.rootDir);
631
754
  const browserName = config.browser?.browserName ?? "chromium";
632
- const playwrightBrowser = await loadPlaywright(browserName);
633
- const launchedBrowser = await playwrightBrowser.launch({
634
- headless: config.browser?.headless ?? true
755
+ const headless = config.browser?.headless ?? true;
756
+ const workerRuntime = await acquireBrowserWorkerRuntime({
757
+ rootDir,
758
+ browserName,
759
+ headless
635
760
  });
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
761
+ let released = false;
762
+ let destroyed = false;
763
+ let tempDir = null;
764
+ let context = null;
765
+ let page = null;
766
+ let traceRecorder;
767
+ function releaseRuntimeOnce() {
768
+ if (released) {
769
+ return;
642
770
  }
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;
771
+ released = true;
772
+ releaseBrowserWorkerRuntime(workerRuntime);
773
+ }
774
+ try {
775
+ tempDir = fs.mkdtempSync(path.join(rootDir, ".wood-browser-harness-"));
776
+ context = await workerRuntime.browser.newContext({
777
+ viewport: {
778
+ width: config.viewport?.width ?? DEFAULT_VIEWPORT.width,
779
+ height: config.viewport?.height ?? DEFAULT_VIEWPORT.height,
780
+ deviceScaleFactor: config.viewport?.deviceScaleFactor
781
+ }
782
+ });
783
+ page = await context.newPage();
784
+ traceRecorder = config.trace?.enabled === true ? new TraceRecorder() : void 0;
785
+ traceRecorder?.enable({ maxEvents: config.trace?.maxEvents });
786
+ } catch (error) {
787
+ if (page) await waitForBoundedClose(page.close());
788
+ if (context) await waitForBoundedClose(context.close());
789
+ if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
790
+ releaseRuntimeOnce();
791
+ throw error;
792
+ }
646
793
  const runtime = {
647
- browser: launchedBrowser,
648
- server,
794
+ context,
795
+ server: workerRuntime.server,
649
796
  page,
650
797
  tempDir,
651
798
  rootDir
@@ -657,7 +804,7 @@ async function createHarness(config = {}) {
657
804
  });
658
805
  return {
659
806
  page,
660
- ...trace ? { trace } : {},
807
+ ...traceRecorder ? { traceRecorder } : {},
661
808
  async mountRoute(routeKey, options = {}) {
662
809
  const bridge = createBrowserBridgeFixtureWithTrace(mergeBridge(config.bridge, options.bridge), config.trace);
663
810
  const route = {
@@ -690,7 +837,7 @@ async function createHarness(config = {}) {
690
837
  css,
691
838
  viewport: options.viewport ?? config.viewport,
692
839
  bridge,
693
- trace,
840
+ traceRecorder,
694
841
  traceContext: {
695
842
  windowId: config.trace?.windowId ?? routeKey.split(".")[0],
696
843
  routeKey,
@@ -727,7 +874,7 @@ async function createHarness(config = {}) {
727
874
  css,
728
875
  viewport: options.viewport ?? config.viewport,
729
876
  bridge,
730
- trace,
877
+ traceRecorder,
731
878
  traceContext: {
732
879
  windowId: config.trace?.windowId ?? "wood-browser-harness",
733
880
  harnessId: config.trace?.harnessId
@@ -760,7 +907,7 @@ async function createHarness(config = {}) {
760
907
  css,
761
908
  viewport: tree.viewport ?? config.viewport,
762
909
  bridge,
763
- trace,
910
+ traceRecorder,
764
911
  traceContext: {
765
912
  windowId: config.trace?.windowId ?? tree.page?.key?.split(".")[0] ?? "wood-browser-harness",
766
913
  routeKey: tree.page?.key,
@@ -777,11 +924,17 @@ async function createHarness(config = {}) {
777
924
  });
778
925
  },
779
926
  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();
927
+ if (destroyed) {
928
+ return;
929
+ }
930
+ destroyed = true;
931
+ await Promise.all([
932
+ waitForBoundedClose(page.close()),
933
+ waitForBoundedClose(context.close())
934
+ ]);
935
+ traceRecorder?.disable();
784
936
  fs.rmSync(tempDir, { recursive: true, force: true });
937
+ releaseRuntimeOnce();
785
938
  }
786
939
  };
787
940
  }