@sigil-dev/grimoire 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.grimoire/_routes.dom.js +4 -0
- package/.grimoire/_routes.hydrate.js +4 -0
- package/.grimoire/_routes.ts +4 -0
- package/.grimoire/tsconfig.generated.json +11 -0
- package/.grimoire/types/ambient.d.ts +6 -0
- package/.grimoire/types/api/hello/$types.d.ts +29 -0
- package/README.md +1 -0
- package/index.ts +22 -0
- package/package.json +36 -0
- package/public/__grimoire__/client.js +86 -0
- package/public/__grimoire__/hydrate.js +101 -0
- package/src/client-router.ts +77 -0
- package/src/client.ts +4 -0
- package/src/context.ts +10 -0
- package/src/cookie-utils.ts +66 -0
- package/src/enhance.ts +97 -0
- package/src/error.ts +52 -0
- package/src/fail.ts +41 -0
- package/src/head.ts +27 -0
- package/src/headers.ts +114 -0
- package/src/hooks.ts +93 -0
- package/src/hydrate.ts +22 -0
- package/src/manifest-gen.ts +26 -0
- package/src/plugins.ts +25 -0
- package/src/redirect.ts +35 -0
- package/src/renderer.ts +142 -0
- package/src/router.ts +94 -0
- package/src/scanner.ts +97 -0
- package/src/scope.ts +22 -0
- package/src/server.ts +318 -0
- package/src/ssrPlugin.ts +26 -0
- package/src/sync.ts +18 -0
- package/src/transform-routes.ts +90 -0
- package/src/typegen.ts +263 -0
- package/src/types.ts +85 -0
- package/src/vite-plugin.ts +72 -0
- package/test/context.test.ts +52 -0
- package/test/fail.test.ts +46 -0
- package/test/headers.test.ts +96 -0
- package/test/hydration.test.ts +119 -0
- package/test/middleware.test.ts +217 -0
- package/test/preload.ts +5 -0
- package/test/redirect-error.test.ts +112 -0
- package/test/rendering.test.ts +172 -0
- package/test/routing.test.ts +45 -0
- package/test/scanning.test.ts +55 -0
- package/test/scope.test.ts +164 -0
- package/test/server.test.ts +30 -0
- package/test/streaming.test.ts +132 -0
- package/test/transform-routes.test.ts +84 -0
- package/test/typegen.test.ts +652 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Window } from "happy-dom";
|
|
2
|
+
|
|
3
|
+
// set up DOM globals before tests
|
|
4
|
+
const window = new Window();
|
|
5
|
+
const document = window.document;
|
|
6
|
+
|
|
7
|
+
// @ts-expect-error
|
|
8
|
+
globalThis.document = document;
|
|
9
|
+
// @ts-expect-error
|
|
10
|
+
globalThis.window = window;
|
|
11
|
+
// @ts-expect-error
|
|
12
|
+
globalThis.HTMLElement = window.HTMLElement;
|
|
13
|
+
// @ts-expect-error
|
|
14
|
+
globalThis.Event = window.Event;
|
|
15
|
+
// @ts-expect-error
|
|
16
|
+
globalThis.MouseEvent = window.MouseEvent;
|
|
17
|
+
// @ts-expect-error
|
|
18
|
+
globalThis.SyntaxError = SyntaxError;
|
|
19
|
+
|
|
20
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
21
|
+
|
|
22
|
+
// mock routes - simulates what #grimoire-routes generates
|
|
23
|
+
const mockRoutes: Record<string, (props: any) => any> = {
|
|
24
|
+
"/spells/:id": (props: any) => {
|
|
25
|
+
return { params: props.params };
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// simulate hydrate logic (same as hydrate.ts but without the import)
|
|
30
|
+
function runHydrate(routes: Record<string, (props: any) => any>) {
|
|
31
|
+
const stateEl = document.getElementById("__grimoire_state__");
|
|
32
|
+
if (stateEl) {
|
|
33
|
+
const state = JSON.parse(stateEl.textContent!);
|
|
34
|
+
const Page = routes[state.pattern];
|
|
35
|
+
if (Page) {
|
|
36
|
+
Page({ ...state.data, params: state.params });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// simulate initRouter (same logic as client-router.ts)
|
|
42
|
+
let clickHandlerAttached = false;
|
|
43
|
+
function initRouter(routes: Record<string, (props: any) => any>) {
|
|
44
|
+
document.addEventListener("click", () => {});
|
|
45
|
+
clickHandlerAttached = true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("hydration", () => {
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
clickHandlerAttached = false;
|
|
51
|
+
document.body.innerHTML = `
|
|
52
|
+
<div id="app">
|
|
53
|
+
<div id="grimoire-page">
|
|
54
|
+
<div class="spell-detail">
|
|
55
|
+
<h1>Fireball</h1>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
<script id="__grimoire_state__" type="application/json">
|
|
60
|
+
${JSON.stringify({ params: { id: "fireball" }, data: {}, pattern: "/spells/:id" })}
|
|
61
|
+
</script>
|
|
62
|
+
`;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// 1. hydrate claims existing DOM, doesn't create new nodes
|
|
66
|
+
test("hydrate claims existing h1 instead of creating new one", () => {
|
|
67
|
+
const before = document.getElementById("app")!.innerHTML;
|
|
68
|
+
runHydrate(mockRoutes);
|
|
69
|
+
// DOM unchanged - hydrate only reads state, doesn't write
|
|
70
|
+
expect(document.getElementById("app")!.innerHTML).toBe(before);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// 2. router attaches after hydration
|
|
74
|
+
test("initRouter attaches click handler", () => {
|
|
75
|
+
initRouter(mockRoutes);
|
|
76
|
+
expect(clickHandlerAttached).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// 3. state script is readable
|
|
80
|
+
test("grimoire state is parseable", () => {
|
|
81
|
+
const el = document.getElementById("__grimoire_state__");
|
|
82
|
+
const state = JSON.parse(el!.textContent!);
|
|
83
|
+
expect(state.pattern).toBe("/spells/:id");
|
|
84
|
+
expect(state.params).toEqual({ id: "fireball" });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// 4. hydrate doesn't touch DOM structure
|
|
88
|
+
test("hydrate preserves DOM structure", () => {
|
|
89
|
+
const before = document.body.innerHTML;
|
|
90
|
+
runHydrate(mockRoutes);
|
|
91
|
+
expect(document.body.innerHTML).toBe(before);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// 5. hydrate with missing pattern is safe
|
|
95
|
+
test("hydrate with unknown pattern does not throw", () => {
|
|
96
|
+
const el = document.getElementById("__grimoire_state__");
|
|
97
|
+
const state = JSON.parse(el!.textContent!);
|
|
98
|
+
state.pattern = "/nonexistent";
|
|
99
|
+
el!.textContent = JSON.stringify(state);
|
|
100
|
+
|
|
101
|
+
expect(() => runHydrate(mockRoutes)).not.toThrow();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// 6. hydrate passes data and params correctly
|
|
105
|
+
test("hydrate passes data and params to page component", () => {
|
|
106
|
+
let receivedProps: any = null;
|
|
107
|
+
const testRoutes: Record<string, (props: any) => any> = {
|
|
108
|
+
"/spells/:id": (props: any) => {
|
|
109
|
+
receivedProps = props;
|
|
110
|
+
return null;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
runHydrate(testRoutes);
|
|
115
|
+
expect(receivedProps).toEqual({
|
|
116
|
+
params: { id: "fireball" },
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { sequence, createHooks } from "../src/hooks";
|
|
3
|
+
import { createCookies } from "../src/cookie-utils";
|
|
4
|
+
import type { Handle, RequestEvent } from "../src/hooks";
|
|
5
|
+
|
|
6
|
+
function fakeEvent(overrides?: Partial<RequestEvent>): RequestEvent {
|
|
7
|
+
return {
|
|
8
|
+
request: new Request("http://localhost/"),
|
|
9
|
+
url: new URL("http://localhost/"),
|
|
10
|
+
params: {},
|
|
11
|
+
locals: {},
|
|
12
|
+
cookies: {
|
|
13
|
+
get: () => undefined,
|
|
14
|
+
set: () => {},
|
|
15
|
+
delete: () => {},
|
|
16
|
+
},
|
|
17
|
+
setHeaders: () => {},
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("sequence()", () => {
|
|
23
|
+
test("chains two handlers in order", async () => {
|
|
24
|
+
const order: string[] = [];
|
|
25
|
+
|
|
26
|
+
const a: Handle = async ({ event, resolve }) => {
|
|
27
|
+
order.push("a-before");
|
|
28
|
+
const res = await resolve(event);
|
|
29
|
+
order.push("a-after");
|
|
30
|
+
return res;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const b: Handle = async ({ event, resolve }) => {
|
|
34
|
+
order.push("b-before");
|
|
35
|
+
const res = await resolve(event);
|
|
36
|
+
order.push("b-after");
|
|
37
|
+
return res;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const handler = sequence(a, b);
|
|
41
|
+
const res = await handler({
|
|
42
|
+
event: fakeEvent(),
|
|
43
|
+
resolve: async (evt) => new Response("OK"),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(order).toEqual(["a-before", "b-before", "b-after", "a-after"]);
|
|
47
|
+
expect(await res.text()).toBe("OK");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("can short-circuit", async () => {
|
|
51
|
+
let reached = false;
|
|
52
|
+
|
|
53
|
+
const a: Handle = async () => {
|
|
54
|
+
return new Response("Blocked", { status: 403 });
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const b: Handle = async ({ resolve, event }) => {
|
|
58
|
+
reached = true;
|
|
59
|
+
return resolve(event);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const handler = sequence(a, b);
|
|
63
|
+
const res = await handler({
|
|
64
|
+
event: fakeEvent(),
|
|
65
|
+
resolve: async () => new Response("OK"),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(reached).toBe(false);
|
|
69
|
+
expect(res.status).toBe(403);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("can modify event.locals", async () => {
|
|
73
|
+
const a: Handle = async ({ event, resolve }) => {
|
|
74
|
+
event.locals.user = "alice";
|
|
75
|
+
return resolve(event);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const b: Handle = async ({ event, resolve }) => {
|
|
79
|
+
event.locals.greet = `hello ${event.locals.user}`;
|
|
80
|
+
return resolve(event);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const handler = sequence(a, b);
|
|
84
|
+
const event = fakeEvent();
|
|
85
|
+
await handler({
|
|
86
|
+
event,
|
|
87
|
+
resolve: async (evt) => {
|
|
88
|
+
return new Response(JSON.stringify(evt.locals));
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(event.locals.user).toBe("alice");
|
|
93
|
+
expect(event.locals.greet).toBe("hello alice");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("can modify response", async () => {
|
|
97
|
+
const a: Handle = async ({ event, resolve }) => {
|
|
98
|
+
const res = await resolve(event);
|
|
99
|
+
const headers = new Headers(res.headers);
|
|
100
|
+
headers.set("X-First", "1");
|
|
101
|
+
return new Response(res.body, { status: res.status, headers });
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const b: Handle = async ({ event, resolve }) => {
|
|
105
|
+
const res = await resolve(event);
|
|
106
|
+
const headers = new Headers(res.headers);
|
|
107
|
+
headers.set("X-Second", "2");
|
|
108
|
+
return new Response(res.body, { status: res.status, headers });
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const handler = sequence(a, b);
|
|
112
|
+
const res = await handler({
|
|
113
|
+
event: fakeEvent(),
|
|
114
|
+
resolve: async () => new Response("OK"),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(res.headers.get("X-First")).toBe("1");
|
|
118
|
+
expect(res.headers.get("X-Second")).toBe("2");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("single handler works", async () => {
|
|
122
|
+
const handler: Handle = async ({ event, resolve }) => {
|
|
123
|
+
return resolve(event);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const res = await sequence(handler)({
|
|
127
|
+
event: fakeEvent(),
|
|
128
|
+
resolve: async () => new Response("Single"),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(await res.text()).toBe("Single");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("three handlers chain correctly", async () => {
|
|
135
|
+
const order: string[] = [];
|
|
136
|
+
|
|
137
|
+
const a: Handle = async ({ event, resolve }) => {
|
|
138
|
+
order.push("a");
|
|
139
|
+
return resolve(event);
|
|
140
|
+
};
|
|
141
|
+
const b: Handle = async ({ event, resolve }) => {
|
|
142
|
+
order.push("b");
|
|
143
|
+
return resolve(event);
|
|
144
|
+
};
|
|
145
|
+
const c: Handle = async ({ event, resolve }) => {
|
|
146
|
+
order.push("c");
|
|
147
|
+
return resolve(event);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
await sequence(a, b, c)({
|
|
151
|
+
event: fakeEvent(),
|
|
152
|
+
resolve: async () => new Response("OK"),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(order).toEqual(["a", "b", "c"]);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("Cookies", () => {
|
|
160
|
+
function makeEvent(cookieHeader = ""): RequestEvent {
|
|
161
|
+
return fakeEvent({
|
|
162
|
+
cookies: createCookies(cookieHeader),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
test("cookies.get reads from header", async () => {
|
|
167
|
+
let value: string | undefined;
|
|
168
|
+
const handler: Handle = async ({ event, resolve }) => {
|
|
169
|
+
value = event.cookies.get("session");
|
|
170
|
+
return resolve(event);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
await handler({
|
|
174
|
+
event: makeEvent("session=abc123; theme=dark"),
|
|
175
|
+
resolve: async () => new Response("OK"),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expect(value).toBe("abc123");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("cookies.set adds Set-Cookie header", async () => {
|
|
182
|
+
const event = makeEvent();
|
|
183
|
+
const handler: Handle = async ({ event: evt, resolve }) => {
|
|
184
|
+
evt.cookies.set("token", "xyz", { httpOnly: true, path: "/" });
|
|
185
|
+
return resolve(evt);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
await handler({
|
|
189
|
+
event,
|
|
190
|
+
resolve: async () => new Response("OK"),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(event.cookies.get("token")).toBe("xyz");
|
|
194
|
+
const cookieUtils = event.cookies as ReturnType<typeof createCookies>;
|
|
195
|
+
expect(cookieUtils.toHeaders().length).toBe(1);
|
|
196
|
+
expect(cookieUtils.toHeaders()[0]).toContain("token=xyz");
|
|
197
|
+
expect(cookieUtils.toHeaders()[0]).toContain("HttpOnly");
|
|
198
|
+
expect(cookieUtils.toHeaders()[0]).toContain("Path=/");
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe("createHooks()", () => {
|
|
203
|
+
test("returns handle and init", async () => {
|
|
204
|
+
const handle: Handle = async ({ resolve, event }) => resolve(event);
|
|
205
|
+
const init = () => {};
|
|
206
|
+
const hooks = createHooks(handle, init);
|
|
207
|
+
expect(hooks.handle).toBe(handle);
|
|
208
|
+
expect(hooks.init).toBe(init);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("returns handle without init", () => {
|
|
212
|
+
const handle: Handle = async ({ resolve, event }) => resolve(event);
|
|
213
|
+
const hooks = createHooks(handle);
|
|
214
|
+
expect(hooks.handle).toBe(handle);
|
|
215
|
+
expect(hooks.init).toBeUndefined();
|
|
216
|
+
});
|
|
217
|
+
});
|
package/test/preload.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { redirect, isRedirectResult } from "../src/redirect";
|
|
3
|
+
import { error, isErrorResult } from "../src/error";
|
|
4
|
+
|
|
5
|
+
describe("redirect()", () => {
|
|
6
|
+
test("throws a RedirectResult", () => {
|
|
7
|
+
try {
|
|
8
|
+
redirect(302, "/login");
|
|
9
|
+
expect(true).toBe(false); // should not reach
|
|
10
|
+
} catch (e) {
|
|
11
|
+
expect(isRedirectResult(e)).toBe(true);
|
|
12
|
+
expect(e.status).toBe(302);
|
|
13
|
+
expect(e.location).toBe("/login");
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("isRedirectResult returns false for plain objects", () => {
|
|
18
|
+
expect(isRedirectResult({ status: 302, location: "/login" })).toBe(false);
|
|
19
|
+
expect(isRedirectResult(null)).toBe(false);
|
|
20
|
+
expect(isRedirectResult(undefined)).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("works with different status codes", () => {
|
|
24
|
+
try {
|
|
25
|
+
redirect(301, "/old");
|
|
26
|
+
} catch (e) {
|
|
27
|
+
expect(e.status).toBe(301);
|
|
28
|
+
expect(e.location).toBe("/old");
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
redirect(303, "/other");
|
|
32
|
+
} catch (e) {
|
|
33
|
+
expect(e.status).toBe(303);
|
|
34
|
+
expect(e.location).toBe("/other");
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
redirect(307, "/temp");
|
|
38
|
+
} catch (e) {
|
|
39
|
+
expect(e.status).toBe(307);
|
|
40
|
+
expect(e.location).toBe("/temp");
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("error()", () => {
|
|
46
|
+
test("throws an ErrorResult with status and message", () => {
|
|
47
|
+
try {
|
|
48
|
+
error(404, "Not found");
|
|
49
|
+
expect(true).toBe(false);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
expect(isErrorResult(e)).toBe(true);
|
|
52
|
+
expect(e.status).toBe(404);
|
|
53
|
+
expect(e.message).toBe("Not found");
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("isErrorResult returns false for plain objects", () => {
|
|
58
|
+
expect(isErrorResult({ status: 404, message: "Not found" })).toBe(false);
|
|
59
|
+
expect(isErrorResult(null)).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("works with different status codes", () => {
|
|
63
|
+
try {
|
|
64
|
+
error(401, "Unauthorized");
|
|
65
|
+
} catch (e) {
|
|
66
|
+
expect(e.status).toBe(401);
|
|
67
|
+
expect(e.message).toBe("Unauthorized");
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
error(500, "Internal Server Error");
|
|
71
|
+
} catch (e) {
|
|
72
|
+
expect(e.status).toBe(500);
|
|
73
|
+
expect(e.message).toBe("Internal Server Error");
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("redirect and error together", () => {
|
|
79
|
+
test("can be caught in sequence", async () => {
|
|
80
|
+
let caught = 0;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
redirect(302, "/login");
|
|
84
|
+
} catch (e) {
|
|
85
|
+
if (isRedirectResult(e)) caught++;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
error(404, "Not found");
|
|
90
|
+
} catch (e) {
|
|
91
|
+
if (isErrorResult(e)) caught++;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
expect(caught).toBe(2);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("redirect and error are distinguishable", () => {
|
|
98
|
+
try {
|
|
99
|
+
redirect(302, "/login");
|
|
100
|
+
} catch (e) {
|
|
101
|
+
expect(isRedirectResult(e)).toBe(true);
|
|
102
|
+
expect(isErrorResult(e)).toBe(false);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
error(404, "Not found");
|
|
107
|
+
} catch (e) {
|
|
108
|
+
expect(isErrorResult(e)).toBe(true);
|
|
109
|
+
expect(isRedirectResult(e)).toBe(false);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, rm, writeFile } from "fs/promises";
|
|
3
|
+
import { tmpdir as osTmpDir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { renderRoute } from "../src/renderer";
|
|
6
|
+
import { matchRoute } from "../src/router";
|
|
7
|
+
import { scanRoutes } from "../src/scanner";
|
|
8
|
+
|
|
9
|
+
/** Read a streaming Response body into a single string */
|
|
10
|
+
async function readStream(res: Response): Promise<string> {
|
|
11
|
+
const reader = res.body!.getReader();
|
|
12
|
+
const parts: string[] = [];
|
|
13
|
+
while (true) {
|
|
14
|
+
const { done, value } = await reader.read();
|
|
15
|
+
if (done) break;
|
|
16
|
+
// Bun ReadableStream chunks can be string, Uint8Array, or other
|
|
17
|
+
parts.push(String(value));
|
|
18
|
+
}
|
|
19
|
+
return parts.join("");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let tmpDir: string;
|
|
23
|
+
|
|
24
|
+
beforeAll(async () => {
|
|
25
|
+
tmpDir = join(osTmpDir(), `grimoire-render-${Date.now()}`);
|
|
26
|
+
await mkdir(join(tmpDir, "blog"), { recursive: true });
|
|
27
|
+
|
|
28
|
+
// simple static page
|
|
29
|
+
await writeFile(
|
|
30
|
+
join(tmpDir, "index.tsx"),
|
|
31
|
+
`
|
|
32
|
+
export default function Home() {
|
|
33
|
+
return <h1>Hello Grimoire</h1>;
|
|
34
|
+
}
|
|
35
|
+
`,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// page with load function
|
|
39
|
+
await writeFile(
|
|
40
|
+
join(tmpDir, "blog", "+page.server.ts"),
|
|
41
|
+
`
|
|
42
|
+
export async function load({ params }) {
|
|
43
|
+
return { title: 'My Blog' };
|
|
44
|
+
}
|
|
45
|
+
`,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
await writeFile(
|
|
49
|
+
join(tmpDir, "blog", "+page.tsx"),
|
|
50
|
+
`
|
|
51
|
+
export default function Blog({ title }) {
|
|
52
|
+
return <h1>{title}</h1>;
|
|
53
|
+
}
|
|
54
|
+
`,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// layout
|
|
58
|
+
await writeFile(
|
|
59
|
+
join(tmpDir, "+layout.tsx"),
|
|
60
|
+
`
|
|
61
|
+
export default function Layout({ children }) {
|
|
62
|
+
return <div class="app" innerHTML={children}></div>;
|
|
63
|
+
}
|
|
64
|
+
`,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(async () => {
|
|
69
|
+
await rm(tmpDir, { recursive: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("renderRoute", () => {
|
|
73
|
+
test("renders static page", async () => {
|
|
74
|
+
const tree = await scanRoutes(tmpDir);
|
|
75
|
+
const matched = matchRoute(tree, new URL("http://localhost/"));
|
|
76
|
+
const res = await renderRoute(matched!, new Request("http://localhost/"));
|
|
77
|
+
const html = await readStream(res);
|
|
78
|
+
expect(html).toContain("<h1>Hello Grimoire</h1>");
|
|
79
|
+
expect(html).toContain("<!DOCTYPE html>");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("renders page with load data", async () => {
|
|
83
|
+
const tree = await scanRoutes(tmpDir);
|
|
84
|
+
const matched = matchRoute(tree, new URL("http://localhost/blog"));
|
|
85
|
+
console.log("matched.route.type:", matched?.route.type);
|
|
86
|
+
console.log("matched.pageServer:", matched?.pageServer?.filePath);
|
|
87
|
+
const res = await renderRoute(
|
|
88
|
+
matched!,
|
|
89
|
+
new Request("http://localhost/blog"),
|
|
90
|
+
);
|
|
91
|
+
const html = await readStream(res);
|
|
92
|
+
expect(html).toContain("<!--g-->My Blog<!--/g-->");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("wraps page in layout", async () => {
|
|
96
|
+
const tree = await scanRoutes(tmpDir);
|
|
97
|
+
const matched = matchRoute(tree, new URL("http://localhost/"));
|
|
98
|
+
const res = await renderRoute(matched!, new Request("http://localhost/"));
|
|
99
|
+
const html = await readStream(res);
|
|
100
|
+
expect(html).toContain('class="app"');
|
|
101
|
+
expect(html).toContain("<h1>Hello Grimoire</h1>");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("returns 404 shape for unmatched route", async () => {
|
|
105
|
+
const tree = await scanRoutes(tmpDir);
|
|
106
|
+
const matched = matchRoute(tree, new URL("http://localhost/nonexistent"));
|
|
107
|
+
expect(matched).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("injects grimoire state script", async () => {
|
|
111
|
+
const tree = await scanRoutes(tmpDir);
|
|
112
|
+
const matched = matchRoute(tree, new URL("http://localhost/"));
|
|
113
|
+
const res = await renderRoute(matched!, new Request("http://localhost/"));
|
|
114
|
+
const html = await readStream(res);
|
|
115
|
+
expect(html).toContain("__grimoire_state__");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("component output is not escaped", async () => {
|
|
119
|
+
await writeFile(
|
|
120
|
+
join(tmpDir, "inner.tsx"),
|
|
121
|
+
`
|
|
122
|
+
export default function Inner() {
|
|
123
|
+
return <strong>bold</strong>;
|
|
124
|
+
}
|
|
125
|
+
`,
|
|
126
|
+
);
|
|
127
|
+
await writeFile(
|
|
128
|
+
join(tmpDir, "outer.tsx"),
|
|
129
|
+
`
|
|
130
|
+
import Inner from './inner';
|
|
131
|
+
export default function Outer() {
|
|
132
|
+
return <div><Inner /></div>;
|
|
133
|
+
}
|
|
134
|
+
`,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const tree = await scanRoutes(tmpDir);
|
|
138
|
+
const matched = matchRoute(tree, new URL("http://localhost/outer"));
|
|
139
|
+
const res = await renderRoute(
|
|
140
|
+
matched!,
|
|
141
|
+
new Request("http://localhost/outer"),
|
|
142
|
+
);
|
|
143
|
+
const html = await readStream(res);
|
|
144
|
+
|
|
145
|
+
// should contain actual HTML tags, not escaped versions
|
|
146
|
+
expect(html).toContain("<strong>bold</strong>");
|
|
147
|
+
expect(html).not.toContain("<strong>");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("user data is escaped", async () => {
|
|
151
|
+
await writeFile(
|
|
152
|
+
join(tmpDir, "xss.tsx"),
|
|
153
|
+
`
|
|
154
|
+
export default function XSS() {
|
|
155
|
+
const userInput = '<script>alert("xss")</script>';
|
|
156
|
+
return <div>{userInput}</div>;
|
|
157
|
+
}
|
|
158
|
+
`,
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const tree = await scanRoutes(tmpDir);
|
|
162
|
+
const matched = matchRoute(tree, new URL("http://localhost/xss"));
|
|
163
|
+
const res = await renderRoute(
|
|
164
|
+
matched!,
|
|
165
|
+
new Request("http://localhost/xss"),
|
|
166
|
+
);
|
|
167
|
+
const html = await readStream(res);
|
|
168
|
+
|
|
169
|
+
expect(html).toContain("<script>");
|
|
170
|
+
expect(html).not.toContain("<script>alert");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { matchRoute } from "../src/router";
|
|
3
|
+
import type { RouteFile, RouteTree } from "../src/scanner";
|
|
4
|
+
|
|
5
|
+
const makeRoute = (path: string, type: RouteFile["type"]): RouteFile => ({
|
|
6
|
+
path,
|
|
7
|
+
filePath: `/app/routes${path}`,
|
|
8
|
+
type,
|
|
9
|
+
paramNames: path
|
|
10
|
+
.split("/")
|
|
11
|
+
.filter((p) => p.startsWith(":"))
|
|
12
|
+
.map((p) => p.slice(1)),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const tree: RouteTree = {
|
|
16
|
+
routes: [
|
|
17
|
+
makeRoute("/", "page"),
|
|
18
|
+
makeRoute("/about", "simple"),
|
|
19
|
+
makeRoute("/blog", "page"),
|
|
20
|
+
makeRoute("/blog/:slug", "page"),
|
|
21
|
+
makeRoute("/blog/:slug", "pageServer"),
|
|
22
|
+
],
|
|
23
|
+
layouts: [makeRoute("/", "layout"), makeRoute("/blog", "layout")],
|
|
24
|
+
servers: [makeRoute("/api/todos", "server")],
|
|
25
|
+
errors: [],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe("matchRoute", () => {
|
|
29
|
+
test("matches static route", () => {
|
|
30
|
+
const match = matchRoute(tree, new URL("http://localhost/about"));
|
|
31
|
+
expect(match?.route.path).toBe("/about");
|
|
32
|
+
expect(match?.params).toEqual({});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("matches dynamic route and extracts params", () => {
|
|
36
|
+
const match = matchRoute(tree, new URL("http://localhost/blog/hello"));
|
|
37
|
+
expect(match?.route.path).toBe("/blog/:slug");
|
|
38
|
+
expect(match?.params).toEqual({ slug: "hello" });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("returns null for unknown route", () => {
|
|
42
|
+
const match = matchRoute(tree, new URL("http://localhost/unknown"));
|
|
43
|
+
expect(match).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|