@xmachines/play-router 1.0.0-beta.1

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 (141) hide show
  1. package/.oxfmtrc.json +3 -0
  2. package/.oxlintrc.json +3 -0
  3. package/README.md +436 -0
  4. package/coverage/base.css +224 -0
  5. package/coverage/block-navigation.js +87 -0
  6. package/coverage/build-tree.ts.html +316 -0
  7. package/coverage/connect-router.ts.html +505 -0
  8. package/coverage/coverage-summary.json +15 -0
  9. package/coverage/crawl-machine.ts.html +385 -0
  10. package/coverage/create-browser-history.ts.html +556 -0
  11. package/coverage/create-route-map.ts.html +400 -0
  12. package/coverage/create-router.ts.html +328 -0
  13. package/coverage/extract-route.ts.html +322 -0
  14. package/coverage/extract-routes.ts.html +286 -0
  15. package/coverage/favicon.png +0 -0
  16. package/coverage/index.html +296 -0
  17. package/coverage/index.ts.html +610 -0
  18. package/coverage/prettify.css +1 -0
  19. package/coverage/prettify.js +2 -0
  20. package/coverage/query.ts.html +307 -0
  21. package/coverage/router-bridge-base.ts.html +919 -0
  22. package/coverage/sort-arrow-sprite.png +0 -0
  23. package/coverage/sorter.js +210 -0
  24. package/coverage/types.ts.html +787 -0
  25. package/coverage/validate-routes.ts.html +319 -0
  26. package/dist/build-tree.d.ts +13 -0
  27. package/dist/build-tree.d.ts.map +1 -0
  28. package/dist/build-tree.js +67 -0
  29. package/dist/build-tree.js.map +1 -0
  30. package/dist/connect-router.d.ts +56 -0
  31. package/dist/connect-router.d.ts.map +1 -0
  32. package/dist/connect-router.js +119 -0
  33. package/dist/connect-router.js.map +1 -0
  34. package/dist/crawl-machine.d.ts +74 -0
  35. package/dist/crawl-machine.d.ts.map +1 -0
  36. package/dist/crawl-machine.js +95 -0
  37. package/dist/crawl-machine.js.map +1 -0
  38. package/dist/create-browser-history.d.ts +68 -0
  39. package/dist/create-browser-history.d.ts.map +1 -0
  40. package/dist/create-browser-history.js +94 -0
  41. package/dist/create-browser-history.js.map +1 -0
  42. package/dist/create-route-map.d.ts +46 -0
  43. package/dist/create-route-map.d.ts.map +1 -0
  44. package/dist/create-route-map.js +73 -0
  45. package/dist/create-route-map.js.map +1 -0
  46. package/dist/create-router.d.ts +73 -0
  47. package/dist/create-router.d.ts.map +1 -0
  48. package/dist/create-router.js +63 -0
  49. package/dist/create-router.js.map +1 -0
  50. package/dist/extract-route.d.ts +25 -0
  51. package/dist/extract-route.d.ts.map +1 -0
  52. package/dist/extract-route.js +63 -0
  53. package/dist/extract-route.js.map +1 -0
  54. package/dist/extract-routes.d.ts +41 -0
  55. package/dist/extract-routes.d.ts.map +1 -0
  56. package/dist/extract-routes.js +61 -0
  57. package/dist/extract-routes.js.map +1 -0
  58. package/dist/index.d.ts +56 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/index.js +141 -0
  61. package/dist/index.js.map +1 -0
  62. package/dist/query.d.ts +52 -0
  63. package/dist/query.d.ts.map +1 -0
  64. package/dist/query.js +69 -0
  65. package/dist/query.js.map +1 -0
  66. package/dist/router-bridge-base.d.ts +150 -0
  67. package/dist/router-bridge-base.d.ts.map +1 -0
  68. package/dist/router-bridge-base.js +240 -0
  69. package/dist/router-bridge-base.js.map +1 -0
  70. package/dist/types.d.ts +228 -0
  71. package/dist/types.d.ts.map +1 -0
  72. package/dist/types.js +2 -0
  73. package/dist/types.js.map +1 -0
  74. package/dist/validate-routes.d.ts +39 -0
  75. package/dist/validate-routes.d.ts.map +1 -0
  76. package/dist/validate-routes.js +65 -0
  77. package/dist/validate-routes.js.map +1 -0
  78. package/examples/demo/README.md +127 -0
  79. package/examples/demo/index.html +41 -0
  80. package/examples/demo/package.json +27 -0
  81. package/examples/demo/src/main.ts +28 -0
  82. package/examples/demo/src/router.ts +37 -0
  83. package/examples/demo/src/shell.ts +316 -0
  84. package/examples/demo/test/browser/auth-flow.browser.test.ts +60 -0
  85. package/examples/demo/test/browser/startup.browser.test.ts +37 -0
  86. package/examples/demo/test/library-pattern.test.ts +51 -0
  87. package/examples/demo/tsconfig.json +17 -0
  88. package/examples/demo/vite.config.ts +7 -0
  89. package/examples/demo/vitest.browser.config.ts +20 -0
  90. package/examples/demo/vitest.config.ts +9 -0
  91. package/examples/shared/dist/auth-machine.d.ts +20 -0
  92. package/examples/shared/dist/auth-machine.d.ts.map +1 -0
  93. package/examples/shared/dist/auth-machine.js +212 -0
  94. package/examples/shared/dist/auth-machine.js.map +1 -0
  95. package/examples/shared/dist/catalog.d.ts +85 -0
  96. package/examples/shared/dist/catalog.d.ts.map +1 -0
  97. package/examples/shared/dist/catalog.js +86 -0
  98. package/examples/shared/dist/catalog.js.map +1 -0
  99. package/examples/shared/dist/index.d.ts +4 -0
  100. package/examples/shared/dist/index.d.ts.map +1 -0
  101. package/examples/shared/dist/index.js +3 -0
  102. package/examples/shared/dist/index.js.map +1 -0
  103. package/examples/shared/package.json +37 -0
  104. package/examples/shared/src/auth-machine.ts +234 -0
  105. package/examples/shared/src/catalog.ts +95 -0
  106. package/examples/shared/src/index.css +3 -0
  107. package/examples/shared/src/index.ts +3 -0
  108. package/examples/shared/src/styles/layout.css +413 -0
  109. package/examples/shared/src/styles/reset.css +42 -0
  110. package/examples/shared/src/styles/tokens.css +183 -0
  111. package/examples/shared/tsconfig.json +14 -0
  112. package/examples/shared/tsconfig.tsbuildinfo +1 -0
  113. package/package.json +44 -0
  114. package/src/build-tree.ts +77 -0
  115. package/src/connect-router.ts +142 -0
  116. package/src/crawl-machine.ts +100 -0
  117. package/src/create-browser-history.ts +157 -0
  118. package/src/create-route-map.ts +105 -0
  119. package/src/create-router.ts +87 -0
  120. package/src/extract-route.ts +79 -0
  121. package/src/extract-routes.ts +67 -0
  122. package/src/index.ts +175 -0
  123. package/src/query.ts +74 -0
  124. package/src/router-bridge-base.ts +279 -0
  125. package/src/types.ts +234 -0
  126. package/src/validate-routes.ts +76 -0
  127. package/test/connect-route-map.test.ts +320 -0
  128. package/test/crawl-extract.test.js +473 -0
  129. package/test/create-browser-history.test.ts +123 -0
  130. package/test/create-router.test.ts +23 -0
  131. package/test/extract-routes.test.ts +80 -0
  132. package/test/find-route-by-path-patterns.test.ts +69 -0
  133. package/test/integration.test.js +438 -0
  134. package/test/query.test.ts +56 -0
  135. package/test/router-bridge-base-edge.test.ts +165 -0
  136. package/test/router-bridge-base.test.ts +119 -0
  137. package/test/tree-query.test.js +692 -0
  138. package/test/validation.test.js +158 -0
  139. package/tsconfig.json +14 -0
  140. package/tsconfig.tsbuildinfo +1 -0
  141. package/vitest.config.ts +35 -0
@@ -0,0 +1,473 @@
1
+ import { describe, test } from "vitest";
2
+ import { expect } from "vitest";
3
+ import { createMachine } from "xstate";
4
+ import { crawlMachine } from "../src/crawl-machine.js";
5
+ import { extractRoute } from "../src/extract-route.js";
6
+
7
+ describe("crawlMachine()", () => {
8
+ describe("nested states", () => {
9
+ test("simple machine with 2 states", () => {
10
+ const machine = createMachine({
11
+ initial: "home",
12
+ states: {
13
+ home: {},
14
+ about: {},
15
+ },
16
+ });
17
+
18
+ const visits = crawlMachine(machine);
19
+
20
+ // Should visit root + 2 states = 3 visits
21
+ expect(visits.length).toBe(3);
22
+
23
+ // Root visit
24
+ expect(visits[0].path).toEqual([]);
25
+ expect(visits[0].parent).toBe(null);
26
+
27
+ // Child visits
28
+ expect(visits[1].path).toEqual(["home"]);
29
+ expect(visits[1].parent).toBe(machine.root);
30
+ expect(visits[2].path).toEqual(["about"]);
31
+ expect(visits[2].parent).toBe(machine.root);
32
+ });
33
+
34
+ test("nested machine (parent + child)", () => {
35
+ const machine = createMachine({
36
+ initial: "home",
37
+ states: {
38
+ home: {
39
+ initial: "dashboard",
40
+ states: {
41
+ dashboard: {},
42
+ },
43
+ },
44
+ },
45
+ });
46
+
47
+ const visits = crawlMachine(machine);
48
+
49
+ // Should visit root + home + dashboard = 3 visits
50
+ expect(visits.length).toBe(3);
51
+
52
+ // BFS order: root, home, dashboard
53
+ expect(visits[0].path).toEqual([]);
54
+ expect(visits[1].path).toEqual(["home"]);
55
+ expect(visits[2].path).toEqual(["home", "dashboard"]);
56
+ });
57
+
58
+ test("deeply nested (3 levels)", () => {
59
+ const machine = createMachine({
60
+ initial: "a",
61
+ states: {
62
+ a: {
63
+ initial: "b",
64
+ states: {
65
+ b: {
66
+ initial: "c",
67
+ states: {
68
+ c: {},
69
+ },
70
+ },
71
+ },
72
+ },
73
+ },
74
+ });
75
+
76
+ const visits = crawlMachine(machine);
77
+
78
+ // Should visit root + a + b + c = 4 visits
79
+ expect(visits.length).toBe(4);
80
+
81
+ expect(visits[0].path).toEqual([]);
82
+ expect(visits[1].path).toEqual(["a"]);
83
+ expect(visits[2].path).toEqual(["a", "b"]);
84
+ expect(visits[3].path).toEqual(["a", "b", "c"]);
85
+ });
86
+ });
87
+
88
+ describe("parallel states", () => {
89
+ test("parallel region with 2 children", () => {
90
+ const machine = createMachine({
91
+ type: "parallel",
92
+ states: {
93
+ sidebar: {},
94
+ content: {},
95
+ },
96
+ });
97
+
98
+ const visits = crawlMachine(machine);
99
+
100
+ // Should visit root + sidebar + content = 3 visits
101
+ expect(visits.length).toBe(3);
102
+
103
+ expect(visits[0].path).toEqual([]);
104
+ expect(visits[1].path).toEqual(["sidebar"]);
105
+ expect(visits[2].path).toEqual(["content"]);
106
+ });
107
+
108
+ test("mixed nested + parallel", () => {
109
+ const machine = createMachine({
110
+ initial: "app",
111
+ states: {
112
+ app: {
113
+ type: "parallel",
114
+ states: {
115
+ sidebar: {
116
+ initial: "collapsed",
117
+ states: {
118
+ collapsed: {},
119
+ expanded: {},
120
+ },
121
+ },
122
+ main: {},
123
+ },
124
+ },
125
+ },
126
+ });
127
+
128
+ const visits = crawlMachine(machine);
129
+
130
+ // Should visit: root, app, sidebar, main, collapsed, expanded = 6 visits
131
+ expect(visits.length).toBe(6);
132
+
133
+ // BFS order
134
+ expect(visits[0].path).toEqual([]);
135
+ expect(visits[1].path).toEqual(["app"]);
136
+ expect(visits[2].path).toEqual(["app", "sidebar"]);
137
+ expect(visits[3].path).toEqual(["app", "main"]);
138
+ expect(visits[4].path).toEqual(["app", "sidebar", "collapsed"]);
139
+ expect(visits[5].path).toEqual(["app", "sidebar", "expanded"]);
140
+ });
141
+ });
142
+
143
+ describe("edge cases", () => {
144
+ test("empty machine (root only)", () => {
145
+ const machine = createMachine({
146
+ // No states defined
147
+ });
148
+
149
+ const visits = crawlMachine(machine);
150
+
151
+ // Should visit only root = 1 visit
152
+ expect(visits.length).toBe(1);
153
+ expect(visits[0].path).toEqual([]);
154
+ });
155
+
156
+ test("machine with empty states object", () => {
157
+ const machine = createMachine({
158
+ states: {},
159
+ });
160
+
161
+ const visits = crawlMachine(machine);
162
+
163
+ // Should visit only root = 1 visit
164
+ expect(visits.length).toBe(1);
165
+ expect(visits[0].path).toEqual([]);
166
+ });
167
+ });
168
+ });
169
+
170
+ describe("extractRoute()", () => {
171
+ describe("valid metadata", () => {
172
+ test("string format meta.route", () => {
173
+ const machine = createMachine({
174
+ id: "home",
175
+ meta: {
176
+ route: "/home",
177
+ },
178
+ });
179
+
180
+ const stateMap = new Map([["home", machine.root]]);
181
+ const route = extractRoute(machine.root, ["home"], stateMap);
182
+
183
+ expect(route).not.toBe(null);
184
+ expect(route.routePath).toBe("/home");
185
+ expect(route.stateId).toBe("home");
186
+ expect(route.isAbsolute).toBe(true);
187
+ });
188
+
189
+ test("object format meta.route with path property", () => {
190
+ const machine = createMachine({
191
+ id: "dashboard",
192
+ meta: {
193
+ route: {
194
+ path: "/dashboard",
195
+ title: "Dashboard",
196
+ },
197
+ },
198
+ });
199
+
200
+ const stateMap = new Map([["dashboard", machine.root]]);
201
+ const route = extractRoute(machine.root, ["dashboard"], stateMap);
202
+
203
+ expect(route).not.toBe(null);
204
+ expect(route.routePath).toBe("/dashboard");
205
+ expect(route.stateId).toBe("dashboard");
206
+ expect(route.metadata).toEqual({
207
+ path: "/dashboard",
208
+ title: "Dashboard",
209
+ });
210
+ });
211
+
212
+ test("absolute path (starts with /)", () => {
213
+ const machine = createMachine({
214
+ id: "settings",
215
+ meta: {
216
+ route: "/settings",
217
+ },
218
+ });
219
+
220
+ const stateMap = new Map([["settings", machine.root]]);
221
+ const route = extractRoute(machine.root, ["settings"], stateMap);
222
+
223
+ expect(route.isAbsolute).toBe(true);
224
+ });
225
+
226
+ test("child route with leading /", () => {
227
+ const machine = createMachine({
228
+ id: "overview",
229
+ meta: {
230
+ route: "/overview",
231
+ },
232
+ });
233
+
234
+ const stateMap = new Map([["overview", machine.root]]);
235
+ const route = extractRoute(machine.root, ["overview"], stateMap);
236
+
237
+ // Even child routes require leading / per validation
238
+ expect(route.routePath).toBe("/overview");
239
+ expect(route.isAbsolute).toBe(true);
240
+ });
241
+ });
242
+
243
+ describe("state ID logic", () => {
244
+ test("falls back to path.join('.') when node.id is missing", () => {
245
+ const route = extractRoute(
246
+ {
247
+ meta: {
248
+ route: "/from-path",
249
+ },
250
+ },
251
+ ["parent", "child"],
252
+ new Map(),
253
+ );
254
+
255
+ expect(route.stateId).toBe("parent.child");
256
+ });
257
+
258
+ test("uses node.id when available", () => {
259
+ const machine = createMachine({
260
+ id: "explicit-id",
261
+ meta: {
262
+ route: "/test",
263
+ },
264
+ });
265
+
266
+ const stateMap = new Map([["explicit-id", machine.root]]);
267
+ const route = extractRoute(machine.root, ["some", "path"], stateMap);
268
+
269
+ expect(route.stateId).toBe("explicit-id");
270
+ });
271
+
272
+ test("uses path.join('.') when no explicit id (XState auto-generates id)", () => {
273
+ const machine = createMachine({
274
+ meta: {
275
+ route: "/test",
276
+ },
277
+ });
278
+
279
+ // XState auto-generates '(machine)' id, so include it in stateMap
280
+ const stateMap = new Map([["(machine)", machine.root]]);
281
+ const route = extractRoute(machine.root, ["dashboard", "overview"], stateMap);
282
+
283
+ // extractRoute uses node.id when present (even auto-generated ones)
284
+ expect(route.stateId).toBe("(machine)");
285
+ });
286
+
287
+ test("includes full path in statePath property", () => {
288
+ const machine = createMachine({
289
+ id: "nested",
290
+ meta: {
291
+ route: "/nested",
292
+ },
293
+ });
294
+
295
+ const stateMap = new Map([["nested", machine.root]]);
296
+ const route = extractRoute(machine.root, ["parent", "child", "nested"], stateMap);
297
+
298
+ expect(route.statePath).toEqual(["parent", "child", "nested"]);
299
+ });
300
+
301
+ test("captures pattern when route contains parameters", () => {
302
+ const machine = createMachine({
303
+ id: "profile",
304
+ meta: {
305
+ route: "/profile/:userId",
306
+ },
307
+ });
308
+
309
+ const stateMap = new Map([["profile", machine.root]]);
310
+ const route = extractRoute(machine.root, ["profile"], stateMap);
311
+
312
+ expect(route.pattern).toBe("/profile/:userId");
313
+ });
314
+ });
315
+
316
+ describe("non-routable states", () => {
317
+ test("returns null when no meta", () => {
318
+ const machine = createMachine({
319
+ id: "loading",
320
+ });
321
+
322
+ const stateMap = new Map();
323
+ const route = extractRoute(machine.root, ["loading"], stateMap);
324
+
325
+ expect(route).toBe(null);
326
+ });
327
+
328
+ test("returns null when meta has no route property", () => {
329
+ const machine = createMachine({
330
+ id: "other",
331
+ meta: {
332
+ title: "Other",
333
+ description: "Some other state",
334
+ },
335
+ });
336
+
337
+ const stateMap = new Map();
338
+ const route = extractRoute(machine.root, ["other"], stateMap);
339
+
340
+ expect(route).toBe(null);
341
+ });
342
+
343
+ test("returns null when meta.route is undefined", () => {
344
+ const machine = createMachine({
345
+ id: "undefined-route",
346
+ meta: {
347
+ route: undefined,
348
+ },
349
+ });
350
+
351
+ const stateMap = new Map();
352
+ const route = extractRoute(machine.root, ["undefined-route"], stateMap);
353
+
354
+ expect(route).toBe(null);
355
+ });
356
+ });
357
+
358
+ describe("route: {} config (Stately pattern)", () => {
359
+ test("detects route: {} config with meta.route", () => {
360
+ // Simulate a state node with route: {} config
361
+ const machine = createMachine({
362
+ id: "dashboard",
363
+ initial: "overview",
364
+ states: {
365
+ overview: {
366
+ id: "overview",
367
+ meta: {
368
+ route: "/overview",
369
+ },
370
+ },
371
+ },
372
+ });
373
+
374
+ // Get the overview state node
375
+ const overviewNode = machine.states.overview;
376
+ // Manually add route: {} to simulate Stately pattern
377
+ overviewNode.route = {};
378
+
379
+ const stateMap = new Map([["overview", overviewNode]]);
380
+ const route = extractRoute(overviewNode, ["overview"], stateMap);
381
+
382
+ expect(route).not.toBe(null);
383
+ expect(route.routePath).toBe("/overview");
384
+ expect(route.stateId).toBe("overview");
385
+ expect(route.routable).toBe(true); // Has route: {} config
386
+ });
387
+
388
+ test("route: {} config with meta.route falls back correctly", () => {
389
+ const machine = createMachine({
390
+ id: "settings",
391
+ initial: "profile",
392
+ states: {
393
+ profile: {
394
+ id: "profile",
395
+ meta: {
396
+ route: "/profile",
397
+ },
398
+ },
399
+ },
400
+ });
401
+
402
+ const profileNode = machine.states.profile;
403
+ profileNode.route = {};
404
+
405
+ const stateMap = new Map([["profile", profileNode]]);
406
+ const route = extractRoute(profileNode, ["profile"], stateMap);
407
+
408
+ expect(route.routePath).toBe("/profile");
409
+ expect(route.routable).toBe(true);
410
+ });
411
+
412
+ test("returns null when no meta.route or meta.path", () => {
413
+ const machine = createMachine({
414
+ initial: "invalid",
415
+ states: {
416
+ invalid: {
417
+ id: "invalid",
418
+ meta: {}, // No route metadata
419
+ },
420
+ },
421
+ });
422
+
423
+ const invalidNode = machine.states.invalid;
424
+ const stateMap = new Map([["invalid", invalidNode]]);
425
+
426
+ // Should return null for states without route metadata
427
+ const result = extractRoute(invalidNode, ["invalid"], stateMap);
428
+ expect(result).toBe(null);
429
+ });
430
+
431
+ test("meta.route makes state routable (no route: {} required)", () => {
432
+ const machine = createMachine({
433
+ id: "home",
434
+ meta: {
435
+ route: "/home",
436
+ },
437
+ });
438
+
439
+ const stateMap = new Map([["home", machine.root]]);
440
+ const route = extractRoute(machine.root, ["home"], stateMap);
441
+
442
+ expect(route).not.toBe(null);
443
+ expect(route.routePath).toBe("/home");
444
+ expect(route.routable).toBe(true); // meta.route makes state routable
445
+ });
446
+
447
+ test("meta.route takes precedence over meta.path when route: {} exists", () => {
448
+ const machine = createMachine({
449
+ id: "priority",
450
+ initial: "test",
451
+ states: {
452
+ test: {
453
+ id: "test",
454
+ meta: {
455
+ route: "/new-path",
456
+ path: "/old-path",
457
+ },
458
+ },
459
+ },
460
+ });
461
+
462
+ const testNode = machine.states.test;
463
+ testNode.route = {};
464
+
465
+ const stateMap = new Map([["test", testNode]]);
466
+ const route = extractRoute(testNode, ["test"], stateMap);
467
+
468
+ // meta.route should win (NEW pattern)
469
+ expect(route.routePath).toBe("/new-path");
470
+ expect(route.routable).toBe(true);
471
+ });
472
+ });
473
+ });
@@ -0,0 +1,123 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { createBrowserHistory } from "../src/create-browser-history.js";
3
+
4
+ function createMockWindow() {
5
+ const listeners = new Map<string, Set<() => void>>();
6
+ const location = {
7
+ pathname: "/",
8
+ search: "",
9
+ hash: "",
10
+ };
11
+
12
+ const history = {
13
+ state: null as unknown,
14
+ pushState: vi.fn((state: unknown, _title: string, url?: string | URL | null) => {
15
+ history.state = state;
16
+ if (typeof url === "string") {
17
+ location.pathname = url;
18
+ }
19
+ }),
20
+ replaceState: vi.fn((state: unknown, _title: string, url?: string | URL | null) => {
21
+ history.state = state;
22
+ if (typeof url === "string") {
23
+ location.pathname = url;
24
+ }
25
+ }),
26
+ go: vi.fn(),
27
+ back: vi.fn(),
28
+ forward: vi.fn(),
29
+ };
30
+
31
+ const win = {
32
+ history,
33
+ location,
34
+ addEventListener: vi.fn((event: string, cb: () => void) => {
35
+ if (!listeners.has(event)) {
36
+ listeners.set(event, new Set());
37
+ }
38
+ listeners.get(event)!.add(cb);
39
+ }),
40
+ removeEventListener: vi.fn((event: string, cb: () => void) => {
41
+ listeners.get(event)?.delete(cb);
42
+ }),
43
+ };
44
+
45
+ return {
46
+ win,
47
+ emit(event: string) {
48
+ listeners.get(event)?.forEach((listener) => listener());
49
+ },
50
+ };
51
+ }
52
+
53
+ describe("createBrowserHistory", () => {
54
+ test("notifies subscribers for push/replace/popstate and restores on destroy", () => {
55
+ const { win, emit } = createMockWindow();
56
+ const browserHistory = createBrowserHistory({ window: win as any });
57
+ const listener = vi.fn();
58
+ const unsubscribe = browserHistory.subscribe(listener);
59
+
60
+ browserHistory.push("/dashboard", { from: "push" });
61
+ expect(listener).toHaveBeenLastCalledWith(
62
+ expect.objectContaining({ pathname: "/dashboard", state: { from: "push" } }),
63
+ );
64
+
65
+ browserHistory.replace("/settings", { from: "replace" });
66
+ expect(listener).toHaveBeenLastCalledWith(
67
+ expect.objectContaining({ pathname: "/settings", state: { from: "replace" } }),
68
+ );
69
+
70
+ emit("popstate");
71
+ expect(listener).toHaveBeenCalledTimes(3);
72
+
73
+ unsubscribe();
74
+ browserHistory.push("/ignored");
75
+ expect(listener).toHaveBeenCalledTimes(3);
76
+
77
+ browserHistory.destroy();
78
+ expect(win.removeEventListener).toHaveBeenCalledWith("popstate", expect.any(Function));
79
+ });
80
+
81
+ test("exposes location and navigation helpers", () => {
82
+ const { win } = createMockWindow();
83
+ const browserHistory = createBrowserHistory({ window: win as any });
84
+
85
+ win.location.pathname = "/profile";
86
+ win.location.search = "?tab=security";
87
+ win.location.hash = "#anchor";
88
+ win.history.state = { from: "state" };
89
+
90
+ expect(browserHistory.location).toEqual({
91
+ pathname: "/profile",
92
+ search: "?tab=security",
93
+ hash: "#anchor",
94
+ state: { from: "state" },
95
+ });
96
+
97
+ expect(browserHistory.createHref("/settings")).toBe("/settings");
98
+
99
+ browserHistory.go(-1);
100
+ browserHistory.back();
101
+ browserHistory.forward();
102
+
103
+ expect(win.history.go).toHaveBeenCalledWith(-1);
104
+ expect(win.history.back).toHaveBeenCalledTimes(1);
105
+ expect(win.history.forward).toHaveBeenCalledTimes(1);
106
+
107
+ browserHistory.destroy();
108
+ });
109
+
110
+ test("restores original history methods on destroy", () => {
111
+ const { win } = createMockWindow();
112
+ const browserHistory = createBrowserHistory({ window: win as any });
113
+ const listener = vi.fn();
114
+ browserHistory.subscribe(listener);
115
+
116
+ browserHistory.destroy();
117
+ win.history.pushState({ from: "destroy" }, "", "/after-destroy");
118
+ win.history.replaceState({ from: "destroy" }, "", "/after-destroy-2");
119
+
120
+ expect(listener).not.toHaveBeenCalled();
121
+ expect(win.location.pathname).toBe("/after-destroy-2");
122
+ });
123
+ });
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { createRouter } from "../src/create-router.js";
3
+
4
+ describe("createRouter", () => {
5
+ test("returns history/routeTree and delegates destroy", () => {
6
+ const routeTree = {
7
+ root: { id: "root" },
8
+ byPath: new Map(),
9
+ byStateId: new Map(),
10
+ } as any;
11
+ const history = {
12
+ destroy: vi.fn(),
13
+ } as any;
14
+
15
+ const router = createRouter({ routeTree, history });
16
+
17
+ expect(router.routeTree).toBe(routeTree);
18
+ expect(router.history).toBe(history);
19
+
20
+ router.destroy();
21
+ expect(history.destroy).toHaveBeenCalledTimes(1);
22
+ });
23
+ });
@@ -0,0 +1,80 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import type { RouteInfo } from "../src/types.js";
3
+
4
+ const mocks = vi.hoisted(() => ({
5
+ crawlMachine: vi.fn(),
6
+ extractRoute: vi.fn(),
7
+ detectDuplicateRoutes: vi.fn(),
8
+ buildRouteTree: vi.fn(),
9
+ }));
10
+
11
+ vi.mock("../src/crawl-machine.js", () => ({
12
+ crawlMachine: mocks.crawlMachine,
13
+ }));
14
+
15
+ vi.mock("../src/extract-route.js", () => ({
16
+ extractRoute: mocks.extractRoute,
17
+ }));
18
+
19
+ vi.mock("../src/validate-routes.js", () => ({
20
+ detectDuplicateRoutes: mocks.detectDuplicateRoutes,
21
+ }));
22
+
23
+ vi.mock("../src/build-tree.js", () => ({
24
+ buildRouteTree: mocks.buildRouteTree,
25
+ }));
26
+
27
+ import { extractMachineRoutes } from "../src/extract-routes.js";
28
+
29
+ describe("extractMachineRoutes", () => {
30
+ test("builds state map only for nodes with ids and filters null routes", () => {
31
+ const visitWithId = {
32
+ node: { id: "known", meta: { route: "/known" } },
33
+ path: ["known"],
34
+ parent: null,
35
+ };
36
+ const visitWithoutId = {
37
+ node: { meta: { route: "/anonymous" } },
38
+ path: ["anonymous"],
39
+ parent: null,
40
+ };
41
+
42
+ mocks.crawlMachine.mockReturnValue([visitWithId, visitWithoutId]);
43
+
44
+ const routeInfo: RouteInfo = {
45
+ stateId: "known",
46
+ statePath: ["known"],
47
+ routePath: "/known",
48
+ isAbsolute: true,
49
+ routable: true,
50
+ metadata: { path: "/known" },
51
+ };
52
+
53
+ mocks.extractRoute
54
+ .mockImplementationOnce(
55
+ (_node: unknown, _path: string[], stateMap: Map<string, unknown>) => {
56
+ expect(stateMap.has("known")).toBe(true);
57
+ return routeInfo;
58
+ },
59
+ )
60
+ .mockImplementationOnce(
61
+ (_node: unknown, _path: string[], stateMap: Map<string, unknown>) => {
62
+ expect(stateMap.has("known")).toBe(true);
63
+ return null;
64
+ },
65
+ );
66
+
67
+ const expectedTree = {
68
+ root: { id: "root" },
69
+ byPath: new Map(),
70
+ byStateId: new Map(),
71
+ };
72
+ mocks.buildRouteTree.mockReturnValue(expectedTree);
73
+
74
+ const tree = extractMachineRoutes({} as any);
75
+
76
+ expect(mocks.detectDuplicateRoutes).toHaveBeenCalledWith([routeInfo]);
77
+ expect(mocks.buildRouteTree).toHaveBeenCalledWith([routeInfo]);
78
+ expect(tree).toBe(expectedTree);
79
+ });
80
+ });