@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.
- package/.oxfmtrc.json +3 -0
- package/.oxlintrc.json +3 -0
- package/README.md +436 -0
- package/coverage/base.css +224 -0
- package/coverage/block-navigation.js +87 -0
- package/coverage/build-tree.ts.html +316 -0
- package/coverage/connect-router.ts.html +505 -0
- package/coverage/coverage-summary.json +15 -0
- package/coverage/crawl-machine.ts.html +385 -0
- package/coverage/create-browser-history.ts.html +556 -0
- package/coverage/create-route-map.ts.html +400 -0
- package/coverage/create-router.ts.html +328 -0
- package/coverage/extract-route.ts.html +322 -0
- package/coverage/extract-routes.ts.html +286 -0
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +296 -0
- package/coverage/index.ts.html +610 -0
- package/coverage/prettify.css +1 -0
- package/coverage/prettify.js +2 -0
- package/coverage/query.ts.html +307 -0
- package/coverage/router-bridge-base.ts.html +919 -0
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +210 -0
- package/coverage/types.ts.html +787 -0
- package/coverage/validate-routes.ts.html +319 -0
- package/dist/build-tree.d.ts +13 -0
- package/dist/build-tree.d.ts.map +1 -0
- package/dist/build-tree.js +67 -0
- package/dist/build-tree.js.map +1 -0
- package/dist/connect-router.d.ts +56 -0
- package/dist/connect-router.d.ts.map +1 -0
- package/dist/connect-router.js +119 -0
- package/dist/connect-router.js.map +1 -0
- package/dist/crawl-machine.d.ts +74 -0
- package/dist/crawl-machine.d.ts.map +1 -0
- package/dist/crawl-machine.js +95 -0
- package/dist/crawl-machine.js.map +1 -0
- package/dist/create-browser-history.d.ts +68 -0
- package/dist/create-browser-history.d.ts.map +1 -0
- package/dist/create-browser-history.js +94 -0
- package/dist/create-browser-history.js.map +1 -0
- package/dist/create-route-map.d.ts +46 -0
- package/dist/create-route-map.d.ts.map +1 -0
- package/dist/create-route-map.js +73 -0
- package/dist/create-route-map.js.map +1 -0
- package/dist/create-router.d.ts +73 -0
- package/dist/create-router.d.ts.map +1 -0
- package/dist/create-router.js +63 -0
- package/dist/create-router.js.map +1 -0
- package/dist/extract-route.d.ts +25 -0
- package/dist/extract-route.d.ts.map +1 -0
- package/dist/extract-route.js +63 -0
- package/dist/extract-route.js.map +1 -0
- package/dist/extract-routes.d.ts +41 -0
- package/dist/extract-routes.d.ts.map +1 -0
- package/dist/extract-routes.js +61 -0
- package/dist/extract-routes.js.map +1 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +141 -0
- package/dist/index.js.map +1 -0
- package/dist/query.d.ts +52 -0
- package/dist/query.d.ts.map +1 -0
- package/dist/query.js +69 -0
- package/dist/query.js.map +1 -0
- package/dist/router-bridge-base.d.ts +150 -0
- package/dist/router-bridge-base.d.ts.map +1 -0
- package/dist/router-bridge-base.js +240 -0
- package/dist/router-bridge-base.js.map +1 -0
- package/dist/types.d.ts +228 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validate-routes.d.ts +39 -0
- package/dist/validate-routes.d.ts.map +1 -0
- package/dist/validate-routes.js +65 -0
- package/dist/validate-routes.js.map +1 -0
- package/examples/demo/README.md +127 -0
- package/examples/demo/index.html +41 -0
- package/examples/demo/package.json +27 -0
- package/examples/demo/src/main.ts +28 -0
- package/examples/demo/src/router.ts +37 -0
- package/examples/demo/src/shell.ts +316 -0
- package/examples/demo/test/browser/auth-flow.browser.test.ts +60 -0
- package/examples/demo/test/browser/startup.browser.test.ts +37 -0
- package/examples/demo/test/library-pattern.test.ts +51 -0
- package/examples/demo/tsconfig.json +17 -0
- package/examples/demo/vite.config.ts +7 -0
- package/examples/demo/vitest.browser.config.ts +20 -0
- package/examples/demo/vitest.config.ts +9 -0
- package/examples/shared/dist/auth-machine.d.ts +20 -0
- package/examples/shared/dist/auth-machine.d.ts.map +1 -0
- package/examples/shared/dist/auth-machine.js +212 -0
- package/examples/shared/dist/auth-machine.js.map +1 -0
- package/examples/shared/dist/catalog.d.ts +85 -0
- package/examples/shared/dist/catalog.d.ts.map +1 -0
- package/examples/shared/dist/catalog.js +86 -0
- package/examples/shared/dist/catalog.js.map +1 -0
- package/examples/shared/dist/index.d.ts +4 -0
- package/examples/shared/dist/index.d.ts.map +1 -0
- package/examples/shared/dist/index.js +3 -0
- package/examples/shared/dist/index.js.map +1 -0
- package/examples/shared/package.json +37 -0
- package/examples/shared/src/auth-machine.ts +234 -0
- package/examples/shared/src/catalog.ts +95 -0
- package/examples/shared/src/index.css +3 -0
- package/examples/shared/src/index.ts +3 -0
- package/examples/shared/src/styles/layout.css +413 -0
- package/examples/shared/src/styles/reset.css +42 -0
- package/examples/shared/src/styles/tokens.css +183 -0
- package/examples/shared/tsconfig.json +14 -0
- package/examples/shared/tsconfig.tsbuildinfo +1 -0
- package/package.json +44 -0
- package/src/build-tree.ts +77 -0
- package/src/connect-router.ts +142 -0
- package/src/crawl-machine.ts +100 -0
- package/src/create-browser-history.ts +157 -0
- package/src/create-route-map.ts +105 -0
- package/src/create-router.ts +87 -0
- package/src/extract-route.ts +79 -0
- package/src/extract-routes.ts +67 -0
- package/src/index.ts +175 -0
- package/src/query.ts +74 -0
- package/src/router-bridge-base.ts +279 -0
- package/src/types.ts +234 -0
- package/src/validate-routes.ts +76 -0
- package/test/connect-route-map.test.ts +320 -0
- package/test/crawl-extract.test.js +473 -0
- package/test/create-browser-history.test.ts +123 -0
- package/test/create-router.test.ts +23 -0
- package/test/extract-routes.test.ts +80 -0
- package/test/find-route-by-path-patterns.test.ts +69 -0
- package/test/integration.test.js +438 -0
- package/test/query.test.ts +56 -0
- package/test/router-bridge-base-edge.test.ts +165 -0
- package/test/router-bridge-base.test.ts +119 -0
- package/test/tree-query.test.js +692 -0
- package/test/validation.test.js +158 -0
- package/tsconfig.json +14 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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
|
+
});
|