@bractjs/bractjs 0.1.27 → 0.1.29
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/bin/cli.ts +18 -1
- package/package.json +3 -2
- package/src/__tests__/codegen-write.test.ts +67 -0
- package/src/__tests__/codegen.test.ts +29 -2
- package/src/__tests__/compile-safety.test.ts +4 -0
- package/src/__tests__/csp.test.ts +10 -0
- package/src/__tests__/define-actions.test.ts +69 -0
- package/src/__tests__/env.test.ts +18 -0
- package/src/__tests__/fetcher-store.test.ts +67 -0
- package/src/__tests__/fixtures/app/root.tsx +7 -2
- package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
- package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
- package/src/__tests__/fixtures/app/routes/features-demo.tsx +28 -0
- package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
- package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
- package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
- package/src/__tests__/form-data-helpers.test.ts +43 -0
- package/src/__tests__/headers.test.ts +111 -0
- package/src/__tests__/integration.test.ts +90 -0
- package/src/__tests__/layout-registry.test.ts +7 -3
- package/src/__tests__/loader.test.ts +32 -1
- package/src/__tests__/matcher.test.ts +29 -0
- package/src/__tests__/module-registry.test.ts +2 -3
- package/src/__tests__/nav-utils.test.ts +46 -0
- package/src/__tests__/prerender.test.ts +102 -0
- package/src/__tests__/programmatic-api.test.ts +20 -1
- package/src/__tests__/revalidation.test.ts +65 -0
- package/src/__tests__/route-lint.test.ts +79 -0
- package/src/__tests__/route-middleware.test.ts +84 -0
- package/src/__tests__/route-table.test.ts +33 -0
- package/src/__tests__/safe-validate.test.ts +96 -0
- package/src/__tests__/scanner.test.ts +46 -1
- package/src/__tests__/scroll-restoration.test.ts +66 -0
- package/src/__tests__/search-serializer.test.ts +42 -0
- package/src/__tests__/search-validation.test.ts +125 -0
- package/src/__tests__/security-fixes.test.ts +201 -0
- package/src/__tests__/security.test.ts +110 -1
- package/src/__tests__/selective-ssr.test.ts +85 -0
- package/src/__tests__/spa-mode.test.ts +77 -0
- package/src/__tests__/typed-routing.test.ts +51 -1
- package/src/__tests__/use-matches.test.ts +54 -0
- package/src/build/bundler.ts +33 -0
- package/src/build/prerender.ts +88 -0
- package/src/build/route-lint.ts +49 -0
- package/src/client/ClientRouter.tsx +339 -47
- package/src/client/cache.ts +8 -0
- package/src/client/components/Await.tsx +9 -2
- package/src/client/components/Form.tsx +23 -34
- package/src/client/components/Link.tsx +80 -9
- package/src/client/components/Outlet.tsx +8 -2
- package/src/client/components/ScrollRestoration.tsx +125 -0
- package/src/client/entry.tsx +39 -2
- package/src/client/fetcher-store.ts +61 -0
- package/src/client/form-utils.ts +3 -0
- package/src/client/hooks/useActionData.ts +7 -3
- package/src/client/hooks/useFetcher.ts +116 -33
- package/src/client/hooks/useFetchers.ts +23 -0
- package/src/client/hooks/useLoaderData.ts +8 -4
- package/src/client/hooks/useLocation.ts +27 -0
- package/src/client/hooks/useMatches.ts +32 -0
- package/src/client/hooks/useNavigate.ts +11 -6
- package/src/client/hooks/useRevalidator.ts +26 -0
- package/src/client/hooks/useSearch.ts +73 -0
- package/src/client/hooks/useSearchParams.ts +7 -2
- package/src/client/nav-utils.ts +26 -0
- package/src/client/prefetch.ts +110 -15
- package/src/client/registry.ts +24 -0
- package/src/client/revalidation.ts +25 -0
- package/src/client/router.tsx +34 -1
- package/src/client/rpc.ts +11 -1
- package/src/client/scroll-restoration.ts +48 -0
- package/src/client/search-serializer.ts +40 -0
- package/src/client/types.ts +6 -0
- package/src/codegen/module-registry.ts +13 -21
- package/src/codegen/route-codegen.ts +148 -10
- package/src/config/load.ts +22 -0
- package/src/dev/hmr-client.ts +3 -1
- package/src/dev/route-table.ts +27 -0
- package/src/dev/server.ts +106 -8
- package/src/dev/watcher.ts +25 -3
- package/src/index.ts +38 -6
- package/src/server/action-handler.ts +3 -13
- package/src/server/action-registry.ts +35 -0
- package/src/server/adapter.ts +16 -0
- package/src/server/api-route.ts +47 -0
- package/src/server/csp.ts +19 -4
- package/src/server/csrf.ts +36 -3
- package/src/server/env.ts +26 -5
- package/src/server/headers.ts +49 -0
- package/src/server/layout.ts +43 -20
- package/src/server/loader.ts +14 -8
- package/src/server/matcher.ts +29 -2
- package/src/server/matches.ts +50 -0
- package/src/server/middleware.ts +66 -0
- package/src/server/proto-guard.ts +56 -0
- package/src/server/render.ts +51 -18
- package/src/server/request-handler.ts +111 -29
- package/src/server/scanner.ts +45 -3
- package/src/server/search.ts +47 -0
- package/src/server/serve.ts +116 -4
- package/src/server/session.ts +12 -1
- package/src/server/spa.ts +62 -0
- package/src/server/stream-handler.ts +10 -1
- package/src/server/validate.ts +89 -14
- package/src/shared/context.ts +7 -0
- package/src/shared/define-actions.ts +39 -0
- package/src/shared/form-data.ts +34 -0
- package/src/shared/route-types.ts +191 -2
- package/templates/new-app/app/root.tsx +2 -1
- package/templates/new-app/bractjs.config.ts +7 -12
- package/types/config.d.ts +24 -0
- package/types/index.d.ts +182 -9
- package/types/route.d.ts +138 -3
- package/LICENSE +0 -21
- package/README.md +0 -1125
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { lintRouteModuleSource } from "../build/route-lint.ts";
|
|
3
|
+
|
|
4
|
+
describe("lintRouteModuleSource — empty routes", () => {
|
|
5
|
+
test("warns when a route has no meaningful export", () => {
|
|
6
|
+
const w = lintRouteModuleSource(`export const meta = () => [];\n`, "routes/empty.tsx");
|
|
7
|
+
expect(w.some((m) => /renders an empty page/.test(m))).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("loader-only is fine (data route)", () => {
|
|
11
|
+
const w = lintRouteModuleSource(`export function loader() { return {}; }\n`, "routes/data.tsx");
|
|
12
|
+
expect(w).toEqual([]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("beforeLoad-only is fine (redirect/guard route)", () => {
|
|
16
|
+
const w = lintRouteModuleSource(`export function beforeLoad() {}\n`, "routes/guard.tsx");
|
|
17
|
+
expect(w).toEqual([]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("named default is fine", () => {
|
|
21
|
+
const w = lintRouteModuleSource(`export default function Page() { return null; }\n`, "routes/p.tsx");
|
|
22
|
+
expect(w).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("anonymous arrow default is recognized as a component", () => {
|
|
26
|
+
const w = lintRouteModuleSource(`export default () => null;\n`, "routes/anon.tsx");
|
|
27
|
+
expect(w).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("anonymous function default is recognized", () => {
|
|
31
|
+
const w = lintRouteModuleSource(`export default function () { return null; }\n`, "routes/anon2.tsx");
|
|
32
|
+
expect(w).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("lintRouteModuleSource — miscased exports", () => {
|
|
37
|
+
test('"Loader" is flagged as a near-miss of "loader"', () => {
|
|
38
|
+
const w = lintRouteModuleSource(
|
|
39
|
+
`export default () => null;\nexport function Loader() { return {}; }\n`,
|
|
40
|
+
"routes/x.tsx",
|
|
41
|
+
);
|
|
42
|
+
expect(w.some((m) => /"Loader" looks like "loader"/.test(m))).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('"fallback" → "Fallback", "beforeload" → "beforeLoad"', () => {
|
|
46
|
+
const w = lintRouteModuleSource(
|
|
47
|
+
`export default () => null;\n` +
|
|
48
|
+
`export const fallback = () => null;\n` +
|
|
49
|
+
`export function beforeload() {}\n`,
|
|
50
|
+
"routes/y.tsx",
|
|
51
|
+
);
|
|
52
|
+
expect(w.some((m) => /"fallback" looks like "Fallback"/.test(m))).toBe(true);
|
|
53
|
+
expect(w.some((m) => /"beforeload" looks like "beforeLoad"/.test(m))).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("exact canonical names produce no near-miss warnings", () => {
|
|
57
|
+
const src =
|
|
58
|
+
`export default () => null;\n` +
|
|
59
|
+
`export function loader() { return {}; }\n` +
|
|
60
|
+
`export function action() { return {}; }\n` +
|
|
61
|
+
`export const clientLoader = () => ({});\n` +
|
|
62
|
+
`export const clientAction = () => ({});\n` +
|
|
63
|
+
`export function headers() { return {}; }\n` +
|
|
64
|
+
`export const middleware = [];\n` +
|
|
65
|
+
`export const handle = {};\n` +
|
|
66
|
+
`export const searchSchema = {};\n` +
|
|
67
|
+
`export function Fallback() { return null; }\n` +
|
|
68
|
+
`export const ssr = false;\n`;
|
|
69
|
+
expect(lintRouteModuleSource(src, "routes/z.tsx")).toEqual([]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("unrelated exports are not flagged", () => {
|
|
73
|
+
const w = lintRouteModuleSource(
|
|
74
|
+
`export default () => null;\nexport const helper = 1;\nexport type Foo = string;\n`,
|
|
75
|
+
"routes/h.tsx",
|
|
76
|
+
);
|
|
77
|
+
expect(w).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
runRouteMiddleware,
|
|
4
|
+
collectRouteMiddleware,
|
|
5
|
+
type RouteMiddleware,
|
|
6
|
+
} from "../server/middleware.ts";
|
|
7
|
+
import type { MiddlewareContext } from "../server/middleware.ts";
|
|
8
|
+
|
|
9
|
+
function makeCtx(): MiddlewareContext {
|
|
10
|
+
return { request: new Request("http://localhost/"), params: {}, context: {} };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const ok = async () => new Response("ok", { status: 200 });
|
|
14
|
+
|
|
15
|
+
describe("collectRouteMiddleware", () => {
|
|
16
|
+
test("orders root → layouts → route and flattens arrays", () => {
|
|
17
|
+
const order: string[] = [];
|
|
18
|
+
const mk = (label: string): RouteMiddleware => async (_c, n) => { order.push(label); return n(); };
|
|
19
|
+
const chain = {
|
|
20
|
+
root: { middleware: mk("root") },
|
|
21
|
+
layouts: [{ middleware: [mk("l0a"), mk("l0b")] }, { middleware: mk("l1") }],
|
|
22
|
+
route: { middleware: mk("route") },
|
|
23
|
+
};
|
|
24
|
+
const fns = collectRouteMiddleware(chain);
|
|
25
|
+
expect(fns).toHaveLength(5);
|
|
26
|
+
return runRouteMiddleware(fns, makeCtx(), ok).then(() => {
|
|
27
|
+
expect(order).toEqual(["root", "l0a", "l0b", "l1", "route"]);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("ignores modules without middleware and non-function entries", () => {
|
|
32
|
+
const chain = {
|
|
33
|
+
root: {},
|
|
34
|
+
layouts: [{ middleware: undefined }, { middleware: ["nope" as unknown] }],
|
|
35
|
+
route: { middleware: (async (_c: MiddlewareContext, n: () => Promise<Response>) => n()) },
|
|
36
|
+
};
|
|
37
|
+
expect(collectRouteMiddleware(chain)).toHaveLength(1);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("runRouteMiddleware", () => {
|
|
42
|
+
test("empty list calls handler directly", async () => {
|
|
43
|
+
const res = await runRouteMiddleware([], makeCtx(), ok);
|
|
44
|
+
expect(res.status).toBe(200);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("a middleware can short-circuit by not calling next()", async () => {
|
|
48
|
+
let handlerRan = false;
|
|
49
|
+
const gate: RouteMiddleware = async () => new Response("denied", { status: 403 });
|
|
50
|
+
const res = await runRouteMiddleware([gate], makeCtx(), async () => {
|
|
51
|
+
handlerRan = true;
|
|
52
|
+
return ok();
|
|
53
|
+
});
|
|
54
|
+
expect(res.status).toBe(403);
|
|
55
|
+
expect(handlerRan).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("shares a mutable context across the chain and into the handler", async () => {
|
|
59
|
+
const ctx = makeCtx();
|
|
60
|
+
const setUser: RouteMiddleware = async (c, n) => { c.context.user = "alice"; return n(); };
|
|
61
|
+
let seenInHandler: unknown;
|
|
62
|
+
await runRouteMiddleware([setUser], ctx, async () => {
|
|
63
|
+
seenInHandler = ctx.context.user;
|
|
64
|
+
return ok();
|
|
65
|
+
});
|
|
66
|
+
expect(seenInHandler).toBe("alice");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("rejects if a middleware calls next() twice", async () => {
|
|
70
|
+
const bad: RouteMiddleware = async (_c, n) => { await n(); return n(); };
|
|
71
|
+
await expect(runRouteMiddleware([bad], makeCtx(), ok)).rejects.toThrow(/more than once/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("outer middleware can post-process the inner Response", async () => {
|
|
75
|
+
const stamp: RouteMiddleware = async (_c, n) => {
|
|
76
|
+
const res = await n();
|
|
77
|
+
const out = new Response(res.body, res);
|
|
78
|
+
out.headers.set("X-Stamped", "1");
|
|
79
|
+
return out;
|
|
80
|
+
};
|
|
81
|
+
const res = await runRouteMiddleware([stamp], makeCtx(), ok);
|
|
82
|
+
expect(res.headers.get("X-Stamped")).toBe("1");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { formatRouteTable } from "../dev/route-table.ts";
|
|
3
|
+
|
|
4
|
+
describe("formatRouteTable", () => {
|
|
5
|
+
test("empty → a clear no-routes line", () => {
|
|
6
|
+
expect(formatRouteTable([])).toBe("[bractjs] no routes found under routes/");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("lists routes sorted by pattern with loader/action markers", () => {
|
|
10
|
+
const out = formatRouteTable([
|
|
11
|
+
{ pattern: "/blog/:id", file: "routes/blog/[id].tsx", hasLoader: true, hasAction: true },
|
|
12
|
+
{ pattern: "/", file: "routes/_index.tsx", hasLoader: true, hasAction: false },
|
|
13
|
+
]);
|
|
14
|
+
// Header reports the count.
|
|
15
|
+
expect(out).toContain("[bractjs] 2 routes:");
|
|
16
|
+
// Sorted: "/" before "/blog/:id".
|
|
17
|
+
expect(out.indexOf("routes/_index.tsx")).toBeLessThan(out.indexOf("routes/blog/[id].tsx"));
|
|
18
|
+
// Markers present.
|
|
19
|
+
expect(out).toContain("loader");
|
|
20
|
+
expect(out).toContain("action");
|
|
21
|
+
// The index route shows loader but not action.
|
|
22
|
+
const indexLine = out.split("\n").find((l) => l.includes("_index"))!;
|
|
23
|
+
expect(indexLine).toContain("loader");
|
|
24
|
+
expect(indexLine).not.toContain("action");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("singular wording for one route", () => {
|
|
28
|
+
const out = formatRouteTable([
|
|
29
|
+
{ pattern: "/", file: "routes/_index.tsx", hasLoader: false, hasAction: false },
|
|
30
|
+
]);
|
|
31
|
+
expect(out).toContain("1 route:");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
safeValidate,
|
|
4
|
+
isValidationResponse,
|
|
5
|
+
readValidationError,
|
|
6
|
+
type Schema,
|
|
7
|
+
} from "../server/validate.ts";
|
|
8
|
+
|
|
9
|
+
// A Zod/Valibot-style safeParse schema: requires non-empty `title`.
|
|
10
|
+
const TitleSchema: Schema<{ title: string }> = {
|
|
11
|
+
safeParse(input: unknown) {
|
|
12
|
+
const raw = (input as { title?: unknown })?.title;
|
|
13
|
+
const title = typeof raw === "string" ? raw.trim() : "";
|
|
14
|
+
if (!title) {
|
|
15
|
+
return { success: false, error: { issues: [{ path: ["title"], message: "Title is required." }] } };
|
|
16
|
+
}
|
|
17
|
+
return { success: true, data: { title } };
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// A `.parse()`-only schema that throws.
|
|
22
|
+
const ThrowingSchema: Schema<{ n: number }> = {
|
|
23
|
+
parse(input: unknown) {
|
|
24
|
+
const n = Number((input as { n?: unknown })?.n);
|
|
25
|
+
if (!Number.isFinite(n)) throw new Error("n must be a number");
|
|
26
|
+
return { n };
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe("safeValidate", () => {
|
|
31
|
+
test("ok path returns parsed data", async () => {
|
|
32
|
+
const r = await safeValidate(TitleSchema, { title: " hi " });
|
|
33
|
+
expect(r).toEqual({ ok: true, data: { title: "hi" } });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("safeParse failure → fieldErrors + firstError", async () => {
|
|
37
|
+
const r = await safeValidate(TitleSchema, { title: "" });
|
|
38
|
+
expect(r.ok).toBe(false);
|
|
39
|
+
if (!r.ok) {
|
|
40
|
+
expect(r.fieldErrors).toEqual({ title: ["Title is required."] });
|
|
41
|
+
expect(r.firstError).toBe("Title is required.");
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("parse-throw failure → _ field + message", async () => {
|
|
46
|
+
const r = await safeValidate(ThrowingSchema, { n: "abc" });
|
|
47
|
+
expect(r.ok).toBe(false);
|
|
48
|
+
if (!r.ok) {
|
|
49
|
+
expect(r.fieldErrors._).toEqual(["n must be a number"]);
|
|
50
|
+
expect(r.firstError).toBe("n must be a number");
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("works with FormData input", async () => {
|
|
55
|
+
const fd = new FormData();
|
|
56
|
+
fd.set("title", "from form");
|
|
57
|
+
const r = await safeValidate(TitleSchema, fd);
|
|
58
|
+
expect(r).toEqual({ ok: true, data: { title: "from form" } });
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("isValidationResponse", () => {
|
|
63
|
+
test("true for the Response thrown by validation", async () => {
|
|
64
|
+
// safeValidate swallows it, so trigger the throw via the underlying runSchema path:
|
|
65
|
+
let thrown: unknown;
|
|
66
|
+
try {
|
|
67
|
+
const { validate } = await import("../server/validate.ts");
|
|
68
|
+
await validate(TitleSchema, { title: "" });
|
|
69
|
+
} catch (e) {
|
|
70
|
+
thrown = e;
|
|
71
|
+
}
|
|
72
|
+
expect(isValidationResponse(thrown)).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("false for a plain 400 Response without the validation statusText", () => {
|
|
76
|
+
expect(isValidationResponse(new Response(null, { status: 400 }))).toBe(false);
|
|
77
|
+
expect(isValidationResponse(new Error("nope"))).toBe(false);
|
|
78
|
+
expect(isValidationResponse(null)).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("readValidationError", () => {
|
|
83
|
+
test("parses { errors } body", async () => {
|
|
84
|
+
const res = Response.json({ errors: { email: ["Invalid"] } }, { status: 400 });
|
|
85
|
+
const { fieldErrors, firstError } = await readValidationError(res);
|
|
86
|
+
expect(fieldErrors).toEqual({ email: ["Invalid"] });
|
|
87
|
+
expect(firstError).toBe("Invalid");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("falls back gracefully on non-JSON body", async () => {
|
|
91
|
+
const res = new Response("not json", { status: 400 });
|
|
92
|
+
const { fieldErrors, firstError } = await readValidationError(res);
|
|
93
|
+
expect(fieldErrors).toEqual({});
|
|
94
|
+
expect(firstError).toBe("Please check your input.");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { test, expect, describe } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
filePathToPattern,
|
|
4
|
+
pathToSegments,
|
|
5
|
+
layoutDirsFromFilePath,
|
|
6
|
+
isRouteGroupSegment,
|
|
7
|
+
} from "../server/scanner.ts";
|
|
3
8
|
|
|
4
9
|
describe("filePathToPattern", () => {
|
|
5
10
|
test("_index maps to empty pattern (root index)", () => {
|
|
@@ -25,6 +30,42 @@ describe("filePathToPattern", () => {
|
|
|
25
30
|
test("strips .ts extension too", () => {
|
|
26
31
|
expect(filePathToPattern("routes/api/data.ts")).toBe("api/data");
|
|
27
32
|
});
|
|
33
|
+
|
|
34
|
+
test("route group folder adds no URL segment", () => {
|
|
35
|
+
expect(filePathToPattern("routes/(marketing)/about.tsx")).toBe("about");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("nested route group strips only the group segment", () => {
|
|
39
|
+
expect(filePathToPattern("routes/(marketing)/blog/[id].tsx")).toBe("blog/[id]");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("group wrapping the index → root pattern", () => {
|
|
43
|
+
expect(filePathToPattern("routes/(marketing)/_index.tsx")).toBe("");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("[[id]] optional kept in pattern string", () => {
|
|
47
|
+
expect(filePathToPattern("routes/users/[[id]].tsx")).toBe("users/[[id]]");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("route groups", () => {
|
|
52
|
+
test("isRouteGroupSegment detects (group) but not () or plain", () => {
|
|
53
|
+
expect(isRouteGroupSegment("(marketing)")).toBe(true);
|
|
54
|
+
expect(isRouteGroupSegment("()")).toBe(false);
|
|
55
|
+
expect(isRouteGroupSegment("about")).toBe(false);
|
|
56
|
+
expect(isRouteGroupSegment("[id]")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("layoutDirsFromFilePath includes group folders", () => {
|
|
60
|
+
expect(layoutDirsFromFilePath("routes/(marketing)/blog/[id].tsx")).toEqual([
|
|
61
|
+
"(marketing)",
|
|
62
|
+
"(marketing)/blog",
|
|
63
|
+
]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("layoutDirsFromFilePath for a top-level route → empty", () => {
|
|
67
|
+
expect(layoutDirsFromFilePath("routes/about.tsx")).toEqual([]);
|
|
68
|
+
});
|
|
28
69
|
});
|
|
29
70
|
|
|
30
71
|
describe("pathToSegments", () => {
|
|
@@ -44,6 +85,10 @@ describe("pathToSegments", () => {
|
|
|
44
85
|
expect(pathToSegments("docs/[...slug]")).toEqual(["docs", { catchAll: "slug" }]);
|
|
45
86
|
});
|
|
46
87
|
|
|
88
|
+
test("[[id]] → optional segment", () => {
|
|
89
|
+
expect(pathToSegments("users/[[id]]")).toEqual(["users", { optional: "id" }]);
|
|
90
|
+
});
|
|
91
|
+
|
|
47
92
|
test("nested static path", () => {
|
|
48
93
|
expect(pathToSegments("a/b/c")).toEqual(["a", "b", "c"]);
|
|
49
94
|
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
savePosition,
|
|
4
|
+
serializePositions,
|
|
5
|
+
deserializePositions,
|
|
6
|
+
MAX_SCROLL_ENTRIES,
|
|
7
|
+
} from "../client/scroll-restoration.ts";
|
|
8
|
+
|
|
9
|
+
describe("savePosition", () => {
|
|
10
|
+
test("stores and overwrites a key", () => {
|
|
11
|
+
const map = new Map<string, number>();
|
|
12
|
+
savePosition(map, "a", 100);
|
|
13
|
+
savePosition(map, "a", 250);
|
|
14
|
+
expect(map.get("a")).toBe(250);
|
|
15
|
+
expect(map.size).toBe(1);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("evicts the oldest entries past the cap", () => {
|
|
19
|
+
const map = new Map<string, number>();
|
|
20
|
+
for (let i = 0; i < 5; i++) savePosition(map, `k${i}`, i, 3);
|
|
21
|
+
expect(map.size).toBe(3);
|
|
22
|
+
expect(map.has("k0")).toBe(false);
|
|
23
|
+
expect(map.has("k1")).toBe(false);
|
|
24
|
+
expect(map.get("k4")).toBe(4);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("re-saving refreshes LRU order", () => {
|
|
28
|
+
const map = new Map<string, number>();
|
|
29
|
+
savePosition(map, "a", 1, 2);
|
|
30
|
+
savePosition(map, "b", 2, 2);
|
|
31
|
+
savePosition(map, "a", 10, 2); // refresh "a" → "b" is now oldest
|
|
32
|
+
savePosition(map, "c", 3, 2);
|
|
33
|
+
expect(map.has("b")).toBe(false);
|
|
34
|
+
expect(map.get("a")).toBe(10);
|
|
35
|
+
expect(map.get("c")).toBe(3);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("default cap is applied", () => {
|
|
39
|
+
const map = new Map<string, number>();
|
|
40
|
+
for (let i = 0; i < MAX_SCROLL_ENTRIES + 10; i++) savePosition(map, `k${i}`, i);
|
|
41
|
+
expect(map.size).toBe(MAX_SCROLL_ENTRIES);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("serialize/deserialize", () => {
|
|
46
|
+
test("roundtrips entries", () => {
|
|
47
|
+
const map = new Map<string, number>([["a", 0], ["b", 1234.5]]);
|
|
48
|
+
const restored = deserializePositions(serializePositions(map));
|
|
49
|
+
expect(restored.get("a")).toBe(0);
|
|
50
|
+
expect(restored.get("b")).toBe(1234.5);
|
|
51
|
+
expect(restored.size).toBe(2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("null/malformed/foreign payloads yield an empty map", () => {
|
|
55
|
+
expect(deserializePositions(null).size).toBe(0);
|
|
56
|
+
expect(deserializePositions("not json{").size).toBe(0);
|
|
57
|
+
expect(deserializePositions('"a string"').size).toBe(0);
|
|
58
|
+
expect(deserializePositions("[1,2,3]").size).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("non-numeric values are dropped", () => {
|
|
62
|
+
const restored = deserializePositions('{"a": 10, "b": "nope", "c": null, "d": 1e999}');
|
|
63
|
+
expect(restored.get("a")).toBe(10);
|
|
64
|
+
expect(restored.size).toBe(1); // "d" is Infinity after JSON.parse → dropped
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { serializeSearch, withSearch } from "../client/search-serializer.ts";
|
|
3
|
+
|
|
4
|
+
describe("serializeSearch", () => {
|
|
5
|
+
test("primitives stringify", () => {
|
|
6
|
+
expect(serializeSearch({ page: 2, q: "hi", on: true })).toBe("?page=2&q=hi&on=true");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("undefined/null drop the key", () => {
|
|
10
|
+
expect(serializeSearch({ a: 1, b: undefined, c: null })).toBe("?a=1");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("arrays become repeated keys (inverse of searchParamsToObject)", () => {
|
|
14
|
+
expect(serializeSearch({ tag: ["a", "b"] })).toBe("?tag=a&tag=b");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("nested objects JSON-stringify", () => {
|
|
18
|
+
expect(serializeSearch({ f: { x: 1 } })).toBe("?f=" + encodeURIComponent('{"x":1}'));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("empty object → empty string", () => {
|
|
22
|
+
expect(serializeSearch({})).toBe("");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("values are URL-encoded", () => {
|
|
26
|
+
expect(serializeSearch({ q: "a b&c" })).toBe("?q=a+b%26c");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("withSearch", () => {
|
|
31
|
+
test("appends search to a bare path", () => {
|
|
32
|
+
expect(withSearch("/posts", { page: 2 })).toBe("/posts?page=2");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("replaces an existing query, preserves the hash", () => {
|
|
36
|
+
expect(withSearch("/posts?old=1#top", { page: 2 })).toBe("/posts?page=2#top");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("no search → path untouched", () => {
|
|
40
|
+
expect(withSearch("/posts?old=1")).toBe("/posts?old=1");
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { test, expect, describe, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { searchParamsToObject, validateSearch } from "../server/search.ts";
|
|
4
|
+
import { createServer } from "../server/serve.ts";
|
|
5
|
+
|
|
6
|
+
// ── Unit: searchParamsToObject ──────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
describe("searchParamsToObject", () => {
|
|
9
|
+
test("single values stay strings", () => {
|
|
10
|
+
expect(searchParamsToObject(new URLSearchParams("a=1&b=x"))).toEqual({ a: "1", b: "x" });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("repeated keys collapse into arrays", () => {
|
|
14
|
+
expect(searchParamsToObject(new URLSearchParams("tag=a&tag=b&tag=c"))).toEqual({
|
|
15
|
+
tag: ["a", "b", "c"],
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("empty params → empty object", () => {
|
|
20
|
+
expect(searchParamsToObject(new URLSearchParams(""))).toEqual({});
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// ── Unit: validateSearch ────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
const coercingSchema = {
|
|
27
|
+
safeParse(input: unknown) {
|
|
28
|
+
const obj = input as Record<string, unknown>;
|
|
29
|
+
const n = Number(obj.page ?? 1);
|
|
30
|
+
if (!Number.isInteger(n)) {
|
|
31
|
+
return { success: false, error: { issues: [{ path: ["page"], message: "not an int" }] } };
|
|
32
|
+
}
|
|
33
|
+
return { success: true, data: { page: n } };
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe("validateSearch", () => {
|
|
38
|
+
test("no schema → raw string record (back-compat)", async () => {
|
|
39
|
+
const url = new URL("http://x.test/posts?page=2&tag=a&tag=b");
|
|
40
|
+
expect(await validateSearch(undefined, url)).toEqual({ page: "2", tag: ["a", "b"] });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("schema output replaces raw strings (coercion)", async () => {
|
|
44
|
+
const url = new URL("http://x.test/posts?page=7");
|
|
45
|
+
expect(await validateSearch(coercingSchema, url)).toEqual({ page: 7 });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("schema failure throws a 400 Response with field errors", async () => {
|
|
49
|
+
const url = new URL("http://x.test/posts?page=abc");
|
|
50
|
+
try {
|
|
51
|
+
await validateSearch(coercingSchema, url);
|
|
52
|
+
expect.unreachable("validateSearch should have thrown");
|
|
53
|
+
} catch (err) {
|
|
54
|
+
expect(err).toBeInstanceOf(Response);
|
|
55
|
+
const res = err as Response;
|
|
56
|
+
expect(res.status).toBe(400);
|
|
57
|
+
const body = (await res.json()) as { errors: Record<string, string[]> };
|
|
58
|
+
expect(body.errors.page).toEqual(["not an int"]);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── Integration: live server with a searchSchema route ─────────────────────
|
|
64
|
+
|
|
65
|
+
const PORT = 3996;
|
|
66
|
+
const BASE = `http://localhost:${PORT}`;
|
|
67
|
+
const FIXTURE_APP = resolve(import.meta.dir, "fixtures/app");
|
|
68
|
+
|
|
69
|
+
let handle: ReturnType<typeof createServer>;
|
|
70
|
+
|
|
71
|
+
beforeAll(() => {
|
|
72
|
+
handle = createServer({
|
|
73
|
+
port: PORT,
|
|
74
|
+
appDir: FIXTURE_APP,
|
|
75
|
+
manifest: { clientEntry: "/build/client/client.js", routes: {} },
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterAll(() => {
|
|
80
|
+
handle.stop();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("searchSchema end-to-end", () => {
|
|
84
|
+
test("/_data returns the validated+coerced search object and loaders receive it", async () => {
|
|
85
|
+
const res = await fetch(`${BASE}/_data?path=${encodeURIComponent("/search-demo?page=3&tag=x")}`);
|
|
86
|
+
expect(res.status).toBe(200);
|
|
87
|
+
const data = (await res.json()) as {
|
|
88
|
+
search: Record<string, unknown>;
|
|
89
|
+
route: { receivedSearch: Record<string, unknown> };
|
|
90
|
+
};
|
|
91
|
+
expect(data.search).toEqual({ page: 3, tag: ["x"] });
|
|
92
|
+
expect(data.route.receivedSearch).toEqual({ page: 3, tag: ["x"] });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("/_data applies schema defaults when params are absent", async () => {
|
|
96
|
+
const res = await fetch(`${BASE}/_data?path=${encodeURIComponent("/search-demo")}`);
|
|
97
|
+
const data = (await res.json()) as { search: Record<string, unknown> };
|
|
98
|
+
expect(data.search).toEqual({ page: 1 });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("/_data with invalid search → 400 with field errors, loader never runs", async () => {
|
|
102
|
+
const res = await fetch(`${BASE}/_data?path=${encodeURIComponent("/search-demo?page=abc")}`);
|
|
103
|
+
expect(res.status).toBe(400);
|
|
104
|
+
const body = (await res.json()) as { errors: Record<string, string[]> };
|
|
105
|
+
expect(body.errors.page).toBeDefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("document GET with invalid search → 400", async () => {
|
|
109
|
+
const res = await fetch(`${BASE}/search-demo?page=abc`);
|
|
110
|
+
expect(res.status).toBe(400);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("document GET hydrates the validated search into __BRACTJS_DATA__", async () => {
|
|
114
|
+
const res = await fetch(`${BASE}/search-demo?page=5`);
|
|
115
|
+
expect(res.status).toBe(200);
|
|
116
|
+
const html = await res.text();
|
|
117
|
+
expect(html).toContain('"search":{"page":5}');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("routes without a schema still get the raw string record", async () => {
|
|
121
|
+
const res = await fetch(`${BASE}/_data?path=${encodeURIComponent("/?q=hello")}`);
|
|
122
|
+
const data = (await res.json()) as { search: Record<string, unknown> };
|
|
123
|
+
expect(data.search).toEqual({ q: "hello" });
|
|
124
|
+
});
|
|
125
|
+
});
|