@bractjs/bractjs 0.1.26 → 0.1.28

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 (98) hide show
  1. package/README.md +283 -58
  2. package/bin/cli.ts +18 -1
  3. package/package.json +1 -1
  4. package/src/__tests__/build-path.test.ts +29 -0
  5. package/src/__tests__/codegen-write.test.ts +67 -0
  6. package/src/__tests__/codegen.test.ts +64 -1
  7. package/src/__tests__/compile-safety.test.ts +4 -0
  8. package/src/__tests__/csp.test.ts +10 -0
  9. package/src/__tests__/define-actions.test.ts +69 -0
  10. package/src/__tests__/env.test.ts +18 -0
  11. package/src/__tests__/fetcher-store.test.ts +67 -0
  12. package/src/__tests__/fixtures/app/root.tsx +7 -2
  13. package/src/__tests__/fixtures/app/routes/boom.tsx +9 -0
  14. package/src/__tests__/fixtures/app/routes/client-only.tsx +16 -0
  15. package/src/__tests__/fixtures/app/routes/counter.tsx +16 -0
  16. package/src/__tests__/fixtures/app/routes/data-only.tsx +16 -0
  17. package/src/__tests__/fixtures/app/routes/intent-demo.tsx +46 -0
  18. package/src/__tests__/fixtures/app/routes/protected-client-only.tsx +15 -0
  19. package/src/__tests__/fixtures/app/routes/search-demo.tsx +39 -0
  20. package/src/__tests__/form-data-helpers.test.ts +43 -0
  21. package/src/__tests__/integration.test.ts +56 -0
  22. package/src/__tests__/loader.test.ts +32 -1
  23. package/src/__tests__/nav-utils.test.ts +46 -0
  24. package/src/__tests__/prerender.test.ts +102 -0
  25. package/src/__tests__/programmatic-api.test.ts +20 -1
  26. package/src/__tests__/revalidation.test.ts +65 -0
  27. package/src/__tests__/route-lint.test.ts +74 -0
  28. package/src/__tests__/route-table.test.ts +33 -0
  29. package/src/__tests__/safe-validate.test.ts +96 -0
  30. package/src/__tests__/scroll-restoration.test.ts +66 -0
  31. package/src/__tests__/search-serializer.test.ts +42 -0
  32. package/src/__tests__/search-validation.test.ts +125 -0
  33. package/src/__tests__/security.test.ts +110 -1
  34. package/src/__tests__/selective-ssr.test.ts +85 -0
  35. package/src/__tests__/spa-mode.test.ts +77 -0
  36. package/src/__tests__/typed-routing.test.ts +239 -0
  37. package/src/build/bundler.ts +33 -0
  38. package/src/build/prerender.ts +88 -0
  39. package/src/build/route-lint.ts +49 -0
  40. package/src/client/ClientRouter.tsx +239 -47
  41. package/src/client/build-path.ts +24 -0
  42. package/src/client/cache.ts +8 -0
  43. package/src/client/components/Await.tsx +9 -2
  44. package/src/client/components/Form.tsx +23 -34
  45. package/src/client/components/Link.tsx +105 -11
  46. package/src/client/components/Outlet.tsx +8 -2
  47. package/src/client/components/ScrollRestoration.tsx +125 -0
  48. package/src/client/entry.tsx +39 -2
  49. package/src/client/fetcher-store.ts +61 -0
  50. package/src/client/form-utils.ts +3 -0
  51. package/src/client/hooks/useActionData.ts +7 -3
  52. package/src/client/hooks/useFetcher.ts +116 -33
  53. package/src/client/hooks/useFetchers.ts +23 -0
  54. package/src/client/hooks/useLoaderData.ts +8 -4
  55. package/src/client/hooks/useLocation.ts +27 -0
  56. package/src/client/hooks/useNavigate.ts +51 -0
  57. package/src/client/hooks/useParams.ts +15 -4
  58. package/src/client/hooks/useRevalidator.ts +26 -0
  59. package/src/client/hooks/useSearch.ts +73 -0
  60. package/src/client/hooks/useSearchParams.ts +21 -6
  61. package/src/client/nav-utils.ts +26 -0
  62. package/src/client/prefetch.ts +110 -15
  63. package/src/client/registry.ts +131 -0
  64. package/src/client/revalidation.ts +25 -0
  65. package/src/client/router.tsx +28 -1
  66. package/src/client/scroll-restoration.ts +48 -0
  67. package/src/client/search-serializer.ts +40 -0
  68. package/src/client/types.ts +6 -0
  69. package/src/codegen/route-codegen.ts +201 -29
  70. package/src/config/load.ts +21 -0
  71. package/src/dev/hmr-client.ts +3 -1
  72. package/src/dev/route-table.ts +27 -0
  73. package/src/dev/server.ts +106 -8
  74. package/src/dev/watcher.ts +25 -3
  75. package/src/index.ts +44 -3
  76. package/src/server/action-handler.ts +12 -3
  77. package/src/server/action-registry.ts +35 -0
  78. package/src/server/csp.ts +10 -1
  79. package/src/server/csrf.ts +26 -0
  80. package/src/server/env.ts +26 -5
  81. package/src/server/layout.ts +31 -1
  82. package/src/server/loader.ts +14 -8
  83. package/src/server/render.ts +18 -3
  84. package/src/server/request-handler.ts +50 -8
  85. package/src/server/search.ts +43 -0
  86. package/src/server/serve.ts +88 -1
  87. package/src/server/spa.ts +62 -0
  88. package/src/server/stream-handler.ts +10 -1
  89. package/src/server/validate.ts +85 -13
  90. package/src/shared/context.ts +5 -0
  91. package/src/shared/define-actions.ts +39 -0
  92. package/src/shared/form-data.ts +34 -0
  93. package/src/shared/route-types.ts +83 -2
  94. package/templates/new-app/app/root.tsx +2 -1
  95. package/templates/new-app/bractjs.config.ts +7 -12
  96. package/types/config.d.ts +21 -0
  97. package/types/index.d.ts +210 -10
  98. package/types/route.d.ts +62 -2
@@ -1,4 +1,4 @@
1
- import { test, expect, describe } from "bun:test";
1
+ import { test, expect, describe, spyOn } from "bun:test";
2
2
  import { safeRun, runLoaders, buildLoaderArgs } from "../server/loader.ts";
3
3
  import { HttpError } from "../shared/errors.ts";
4
4
  import type { LoaderArgs } from "../shared/route-types.ts";
@@ -9,6 +9,7 @@ const stubArgs: LoaderArgs = {
9
9
  request: new Request("http://localhost/"),
10
10
  params: {},
11
11
  context: {},
12
+ search: {},
12
13
  };
13
14
 
14
15
  const emptyModule: RouteModule = {};
@@ -41,6 +42,36 @@ describe("safeRun", () => {
41
42
  const fn = async () => { throw new Response(null, { status: 302, headers: { Location: "/" } }); };
42
43
  await expect(safeRun(fn, stubArgs)).rejects.toBeInstanceOf(Response);
43
44
  });
45
+
46
+ test("includes the `where` location in the error log", async () => {
47
+ const spy = spyOn(console, "error").mockImplementation(() => {});
48
+ try {
49
+ await safeRun(async () => { throw new Error("boom"); }, stubArgs, undefined, "routes/x.tsx");
50
+ const logged = spy.mock.calls.map((c) => String(c[0])).join("\n");
51
+ expect(logged).toContain("loader error in routes/x.tsx");
52
+ } finally {
53
+ spy.mockRestore();
54
+ }
55
+ });
56
+
57
+ test("dev __error carries the routeFile; prod stays generic", async () => {
58
+ const original = Bun.env.NODE_ENV;
59
+ const spy = spyOn(console, "error").mockImplementation(() => {});
60
+ try {
61
+ Bun.env.NODE_ENV = "development";
62
+ const dev = await safeRun(async () => { throw new Error("boom"); }, stubArgs, undefined, "routes/x.tsx");
63
+ expect(dev).toMatchObject({ __error: { routeFile: "routes/x.tsx" } });
64
+
65
+ Bun.env.NODE_ENV = "production";
66
+ const prod = await safeRun(async () => { throw new Error("boom"); }, stubArgs, undefined, "routes/x.tsx") as { __error: Record<string, unknown> };
67
+ expect(prod.__error.routeFile).toBeUndefined();
68
+ expect(prod.__error.message).toBe("Internal Server Error");
69
+ } finally {
70
+ if (original === undefined) delete Bun.env.NODE_ENV;
71
+ else Bun.env.NODE_ENV = original;
72
+ spy.mockRestore();
73
+ }
74
+ });
44
75
  });
45
76
 
46
77
  describe("runLoaders", () => {
@@ -0,0 +1,46 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { parseTo, createLocationKey } from "../client/nav-utils.ts";
3
+
4
+ describe("parseTo", () => {
5
+ test("plain pathname", () => {
6
+ expect(parseTo("/posts")).toEqual({ pathname: "/posts", search: "", hash: "" });
7
+ });
8
+
9
+ test("pathname + search", () => {
10
+ expect(parseTo("/posts?page=2")).toEqual({ pathname: "/posts", search: "?page=2", hash: "" });
11
+ });
12
+
13
+ test("pathname + hash", () => {
14
+ expect(parseTo("/docs#install")).toEqual({ pathname: "/docs", search: "", hash: "#install" });
15
+ });
16
+
17
+ test("pathname + search + hash", () => {
18
+ expect(parseTo("/docs?v=2#install")).toEqual({ pathname: "/docs", search: "?v=2", hash: "#install" });
19
+ });
20
+
21
+ test("hash containing a question mark stays in the hash", () => {
22
+ expect(parseTo("/docs#frag?notsearch")).toEqual({ pathname: "/docs", search: "", hash: "#frag?notsearch" });
23
+ });
24
+
25
+ test("empty string falls back to root", () => {
26
+ expect(parseTo("")).toEqual({ pathname: "/", search: "", hash: "" });
27
+ });
28
+
29
+ test("bare query string keeps root pathname", () => {
30
+ expect(parseTo("?page=2")).toEqual({ pathname: "/", search: "?page=2", hash: "" });
31
+ });
32
+
33
+ test("root with everything", () => {
34
+ expect(parseTo("/?a=1&b=2#top")).toEqual({ pathname: "/", search: "?a=1&b=2", hash: "#top" });
35
+ });
36
+ });
37
+
38
+ describe("createLocationKey", () => {
39
+ test("returns a short non-empty string and varies between calls", () => {
40
+ const a = createLocationKey();
41
+ const b = createLocationKey();
42
+ expect(a.length).toBeGreaterThanOrEqual(6);
43
+ expect(a.length).toBeLessThanOrEqual(10);
44
+ expect(a).not.toBe(b);
45
+ });
46
+ });
@@ -0,0 +1,102 @@
1
+ import { test, expect, describe, beforeAll, afterAll } from "bun:test";
2
+ import { resolve, join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { rm } from "node:fs/promises";
5
+ import { runPrerender, prerenderPaths } from "../build/prerender.ts";
6
+ import { createServer } from "../server/serve.ts";
7
+
8
+ const FIXTURE_APP = resolve(import.meta.dir, "fixtures/app");
9
+ const TMP_BUILD = join(tmpdir(), `bract-prerender-${Date.now()}`);
10
+ const MANIFEST = { clientEntry: "/build/client/client.js", routes: {} };
11
+
12
+ // ── Unit: path mapping + safety ─────────────────────────────────────────────
13
+
14
+ describe("prerenderPaths", () => {
15
+ test("maps / and nested paths to index.html + _data.json", () => {
16
+ expect(prerenderPaths("/")).toEqual({ html: "index.html", data: "_data.json" });
17
+ expect(prerenderPaths("/about")).toEqual({ html: "about/index.html", data: "about/_data.json" });
18
+ expect(prerenderPaths("/blog/intro")).toEqual({ html: "blog/intro/index.html", data: "blog/intro/_data.json" });
19
+ });
20
+
21
+ test("rejects route patterns instead of silently writing junk", () => {
22
+ expect(() => prerenderPaths("/blog/:slug")).toThrow(/PATTERN/);
23
+ expect(() => prerenderPaths("/blog/[slug]")).toThrow(/PATTERN/);
24
+ expect(() => prerenderPaths("relative")).toThrow(/must start/);
25
+ });
26
+
27
+ test("rejects dot segments — these strings become filesystem writes", () => {
28
+ expect(() => prerenderPaths("/../etc/passwd")).toThrow(/dot segments/);
29
+ expect(() => prerenderPaths("/a/./b")).toThrow(/dot segments/);
30
+ });
31
+ });
32
+
33
+ // ── Integration: generate + serve ───────────────────────────────────────────
34
+
35
+ describe("runPrerender + production serving", () => {
36
+ let handle: ReturnType<typeof createServer>;
37
+ const PORT = 3992;
38
+ const BASE = `http://localhost:${PORT}`;
39
+
40
+ beforeAll(async () => {
41
+ const { written } = await runPrerender({
42
+ prerender: ["/", "/counter"],
43
+ appDir: FIXTURE_APP,
44
+ buildDir: TMP_BUILD,
45
+ manifest: MANIFEST,
46
+ });
47
+ expect(written.length).toBe(4); // 2 paths × (index.html + _data.json)
48
+
49
+ // Overwrite one artifact with a sentinel so the serving test below can
50
+ // prove the FILE was served, not a fresh SSR pass.
51
+ await Bun.write(join(TMP_BUILD, "client", "_prerender", "counter", "index.html"), "<!-- PRERENDERED-SENTINEL -->");
52
+
53
+ handle = createServer({
54
+ port: PORT,
55
+ appDir: FIXTURE_APP,
56
+ buildDir: TMP_BUILD,
57
+ manifest: MANIFEST,
58
+ });
59
+ });
60
+
61
+ afterAll(async () => {
62
+ handle.stop();
63
+ await rm(TMP_BUILD, { recursive: true, force: true });
64
+ });
65
+
66
+ test("writes real SSR output at build time", async () => {
67
+ const html = await Bun.file(join(TMP_BUILD, "client", "_prerender", "index.html")).text();
68
+ expect(html).toContain("hello from bractjs"); // loader ran during prerender
69
+ const data = await Bun.file(join(TMP_BUILD, "client", "_prerender", "_data.json")).json() as { route: { message: string } };
70
+ expect(data.route.message).toBe("hello from bractjs");
71
+ });
72
+
73
+ test("clean document GETs are served from the prerendered file", async () => {
74
+ const res = await fetch(`${BASE}/counter`);
75
+ expect(res.status).toBe(200);
76
+ expect(await res.text()).toContain("PRERENDERED-SENTINEL");
77
+ });
78
+
79
+ test("a query string opts back into dynamic SSR", async () => {
80
+ const res = await fetch(`${BASE}/counter?fresh=1`);
81
+ expect(res.status).toBe(200);
82
+ expect(await res.text()).not.toContain("PRERENDERED-SENTINEL");
83
+ });
84
+
85
+ test("clean /_data is served from the prerendered payload; queried /_data stays dynamic", async () => {
86
+ const filed = await fetch(`${BASE}/_data?path=/`);
87
+ expect(filed.headers.get("cache-control")).toContain("must-revalidate");
88
+ const data = (await filed.json()) as { route: { message: string } };
89
+ expect(data.route.message).toBe("hello from bractjs");
90
+
91
+ // With a query the file is skipped — the loader runs (search echoes back).
92
+ const dynamic = await fetch(`${BASE}/_data?path=${encodeURIComponent("/?q=1")}`);
93
+ const dyn = (await dynamic.json()) as { search: Record<string, unknown> };
94
+ expect(dyn.search).toEqual({ q: "1" });
95
+ });
96
+
97
+ test("non-prerendered paths fall through to dynamic SSR", async () => {
98
+ const res = await fetch(`${BASE}/search-demo?page=2`);
99
+ expect(res.status).toBe(200);
100
+ expect(await res.text()).toContain('"search":{"page":2}');
101
+ });
102
+ });
@@ -7,7 +7,7 @@
7
7
  * Behavioral coverage (HTTP response, HMR) lives in integration.test.ts.
8
8
  */
9
9
  import { test, expect, describe } from "bun:test";
10
- import { loadUserConfig, validateUserConfig } from "../config/load.ts";
10
+ import { loadUserConfig, validateUserConfig, defineConfig } from "../config/load.ts";
11
11
  import { runBuild } from "../build/bundler.ts";
12
12
  import { createDevServer } from "../dev/server.ts";
13
13
  import type { BuildConfig } from "../build/bundler.ts";
@@ -60,11 +60,30 @@ describe("validateUserConfig", () => {
60
60
  expect(() => validateUserConfig({ sourcemap: "yes" })).toThrow(/"sourcemap" must be/);
61
61
  });
62
62
 
63
+ test("rejects a string hmrPort but accepts a number", () => {
64
+ expect(() => validateUserConfig({ hmrPort: "3001" })).toThrow(/"hmrPort" must be a finite number/);
65
+ expect(validateUserConfig({ hmrPort: 3005 })).toEqual({ hmrPort: 3005 });
66
+ });
67
+
63
68
  test("ignores unknown keys and undefined values", () => {
64
69
  expect(() => validateUserConfig({ port: undefined, somethingCustom: 1 })).not.toThrow();
65
70
  });
66
71
  });
67
72
 
73
+ // ── defineConfig ──────────────────────────────────────────────────────────
74
+
75
+ describe("defineConfig", () => {
76
+ test("is an identity function (returns the same reference)", () => {
77
+ const cfg = { port: 3000, clientEnv: ["X"] };
78
+ expect(defineConfig(cfg)).toBe(cfg);
79
+ });
80
+
81
+ test("is re-exported from src/index.ts", async () => {
82
+ const mod = await import("../index.ts");
83
+ expect(typeof mod.defineConfig).toBe("function");
84
+ });
85
+ });
86
+
68
87
  // ── runBuild ──────────────────────────────────────────────────────────────
69
88
 
70
89
  test("runBuild is exported from build/bundler", () => {
@@ -0,0 +1,65 @@
1
+ import { test, expect, describe, beforeAll, afterAll } from "bun:test";
2
+ import { resolve } from "node:path";
3
+ import { createServer } from "../server/serve.ts";
4
+ import { registerRevalidator, triggerRevalidation } from "../client/revalidation.ts";
5
+
6
+ // ── Unit: revalidator seam (router ↔ fetcher bridge) ───────────────────────
7
+
8
+ describe("revalidation seam", () => {
9
+ test("triggerRevalidation forwards info to the registered revalidator", async () => {
10
+ const calls: unknown[] = [];
11
+ registerRevalidator(async (info) => { calls.push(info); });
12
+ await triggerRevalidation({ formMethod: "POST", actionStatus: 200 });
13
+ expect(calls).toEqual([{ formMethod: "POST", actionStatus: 200 }]);
14
+ registerRevalidator(null);
15
+ });
16
+
17
+ test("resolves quietly when no router is mounted", async () => {
18
+ registerRevalidator(null);
19
+ await triggerRevalidation({ formMethod: "DELETE" }); // must not throw
20
+ });
21
+ });
22
+
23
+ // ── Integration: the submit → revalidate contract ───────────────────────────
24
+ // ClientRouter.submit POSTs with X-BractJS-Action (JSON action reply), then
25
+ // refetches /_data. This proves the server side of that contract: the mutation
26
+ // changes what the next /_data returns.
27
+
28
+ const PORT = 3995;
29
+ const BASE = `http://localhost:${PORT}`;
30
+ const FIXTURE_APP = resolve(import.meta.dir, "fixtures/app");
31
+
32
+ let handle: ReturnType<typeof createServer>;
33
+
34
+ beforeAll(() => {
35
+ handle = createServer({
36
+ port: PORT,
37
+ appDir: FIXTURE_APP,
38
+ manifest: { clientEntry: "/build/client/client.js", routes: {} },
39
+ });
40
+ });
41
+
42
+ afterAll(() => {
43
+ handle.stop();
44
+ });
45
+
46
+ describe("mutation → revalidation contract", () => {
47
+ test("action mutates, JSON reply carries actionData, /_data reflects the new state", async () => {
48
+ const before = await (await fetch(`${BASE}/_data?path=/counter`)).json() as { route: { count: number } };
49
+
50
+ const post = await fetch(`${BASE}/counter`, {
51
+ method: "POST",
52
+ body: new FormData(),
53
+ headers: { Origin: BASE, "X-BractJS-Action": "1" },
54
+ });
55
+ expect(post.status).toBe(200);
56
+ expect(post.headers.get("content-type")).toContain("json");
57
+ const actionData = (await post.json()) as { ok: boolean; count: number };
58
+ expect(actionData.ok).toBe(true);
59
+ expect(actionData.count).toBe(before.route.count + 1);
60
+
61
+ // The revalidation fetch ClientRouter issues after the action:
62
+ const after = await (await fetch(`${BASE}/_data?path=/counter`)).json() as { route: { count: number } };
63
+ expect(after.route.count).toBe(before.route.count + 1);
64
+ });
65
+ });
@@ -0,0 +1,74 @@
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 searchSchema = {};\n` +
62
+ `export function Fallback() { return null; }\n` +
63
+ `export const ssr = false;\n`;
64
+ expect(lintRouteModuleSource(src, "routes/z.tsx")).toEqual([]);
65
+ });
66
+
67
+ test("unrelated exports are not flagged", () => {
68
+ const w = lintRouteModuleSource(
69
+ `export default () => null;\nexport const helper = 1;\nexport type Foo = string;\n`,
70
+ "routes/h.tsx",
71
+ );
72
+ expect(w).toEqual([]);
73
+ });
74
+ });
@@ -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
+ });
@@ -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
+ });