diff --git a/core/loader.test.ts b/core/loader.test.ts index 38c81cbf..c5ffa7cc 100644 --- a/core/loader.test.ts +++ b/core/loader.test.ts @@ -3208,3 +3208,141 @@ Deno.test( assertEquals(foundCand!.mount, "/cand"); }, ); + +// --------------------------------------------------------------------------- +// Coverage: wrappedApp.use + wrappedMw for all 3 registration paths +// --------------------------------------------------------------------------- + +// Gap: ns.default with length===1 that calls app.use → covers wrappedApp.use +// and wrappedMw (the inner function that stamps ctx.state.module) +Deno.test("registerFromNamespace: ns.default register fn calling app.use stamps ctx.state.module", async () => { + const capturedMws: Array<(...args: unknown[]) => unknown> = []; + const mockApp: App = { + use: (mw) => capturedMws.push(mw as (...args: unknown[]) => unknown), + }; + + const ns = { + default: (a: App) => { + // This call hits wrappedApp.use + a.use((_req, _ctx, next) => next() as Response); + }, + } as Record; + + const ok = registerFromNamespace("usemod", ns, mockApp); + assert(ok, "expected registration to succeed"); + assert(capturedMws.length >= 1, "expected at least one middleware captured"); + + // Invoke the first captured middleware — this hits wrappedMw + const mw = capturedMws[0]; + const ctx = { state: {} } as { state: Record }; + const result = await (mw( + new Request("http://localhost/"), + ctx, + () => "DONE", + ) as Promise); + assertEquals(result, "DONE"); + assertEquals(ctx.state.module, "usemod"); + + // Also invoke with ctx that has no .state (covers the if (!ctx.state) branch) + const ctxNoState = {} as unknown as { state: Record }; + await (mw( + new Request("http://localhost/"), + ctxNoState, + () => "DONE_NS", + ) as Promise); + assertEquals(ctxNoState.state.module, "usemod"); +}); + +// Gap: ns.register that calls app.use → covers wrappedApp.use + wrappedMw +Deno.test("registerFromNamespace: named register fn calling app.use stamps ctx.state.module", async () => { + const capturedMws: Array<(...args: unknown[]) => unknown> = []; + const mockApp: App = { + use: (mw) => capturedMws.push(mw as (...args: unknown[]) => unknown), + }; + + const ns = { + register: (a: App) => { + a.use((_req, _ctx, next) => next() as Response); + }, + } as Record; + + const ok = registerFromNamespace("namedregmod", ns, mockApp); + assert(ok, "expected registration to succeed"); + assert(capturedMws.length >= 1, "expected at least one middleware captured"); + + const mw = capturedMws[0]; + const ctx = { state: {} } as { state: Record }; + const result = await (mw( + new Request("http://localhost/"), + ctx, + () => "DONE2", + ) as Promise); + assertEquals(result, "DONE2"); + assertEquals(ctx.state.module, "namedregmod"); + + // No .state (covers if (!ctx.state) branch) + const ctxNoState = {} as unknown as { state: Record }; + await (mw( + new Request("http://localhost/"), + ctxNoState, + () => "DONE2_NS", + ) as Promise); + assertEquals(ctxNoState.state.module, "namedregmod"); +}); + +// Gap: ns.default.register (object) that calls app.use → covers wrappedApp.use + wrappedMw +Deno.test("registerFromNamespace: default.register obj calling app.use stamps ctx.state.module", async () => { + const capturedMws: Array<(...args: unknown[]) => unknown> = []; + const mockApp: App = { + use: (mw) => capturedMws.push(mw as (...args: unknown[]) => unknown), + }; + + const ns = { + default: { + register: (a: App) => { + a.use((_req, _ctx, next) => next() as Response); + }, + }, + } as Record; + + const ok = registerFromNamespace("objregmod", ns, mockApp); + assert(ok, "expected registration to succeed"); + assert(capturedMws.length >= 1, "expected at least one middleware captured"); + + const mw = capturedMws[0]; + const ctx = { state: {} } as { state: Record }; + const result = await (mw( + new Request("http://localhost/"), + ctx, + () => "DONE3", + ) as Promise); + assertEquals(result, "DONE3"); + assertEquals(ctx.state.module, "objregmod"); + + // No .state (covers if (!ctx.state) branch) + const ctxNoState = {} as unknown as { state: Record }; + await (mw( + new Request("http://localhost/"), + ctxNoState, + () => "DONE3_NS", + ) as Promise); + assertEquals(ctxNoState.state.module, "objregmod"); +}); + +// --------------------------------------------------------------------------- +// Coverage: core/mod.ts Fastro class instance-member initializer +// --------------------------------------------------------------------------- +Deno.test("core/mod.ts Fastro class can be instantiated and exposes server methods", async () => { + const { default: Fastro } = await import("./mod.ts"); + const f = new Fastro(); + // Verify the class members are assigned (covers instance_members_initializer) + assert(typeof f.get === "function", "expected get to be a function"); + assert(typeof f.post === "function", "expected post to be a function"); + assert(typeof f.put === "function", "expected put to be a function"); + assert(typeof f.delete === "function", "expected delete to be a function"); + assert(typeof f.patch === "function", "expected patch to be a function"); + assert(typeof f.head === "function", "expected head to be a function"); + assert(typeof f.options === "function", "expected options to be a function"); + assert(typeof f.use === "function", "expected use to be a function"); + assert(typeof f.serve === "function", "expected serve to be a function"); +}); diff --git a/core/loader.ts b/core/loader.ts index c2d1c3c6..44cbd5eb 100644 --- a/core/loader.ts +++ b/core/loader.ts @@ -288,7 +288,17 @@ export function registerFromNamespace( (ns.default as (app: App) => void).length === 1 ) { try { - (ns.default as (app: App) => void)(makeRegistrar(app) as unknown as App); + const wrappedApp = makeRegistrar(app) as unknown as App; + const originalUse = wrappedApp.use; + wrappedApp.use = (mw: Middleware) => { + const wrappedMw: Middleware = (req, ctx, next) => { + if (!ctx.state) ctx.state = {}; + ctx.state.module = name; + return mw(req, ctx, next); + }; + return originalUse.call(wrappedApp, wrappedMw); + }; + (ns.default as (app: App) => void)(wrappedApp); // Determine the recorded mount in a clearer, test-friendly way let recordedMount: string; if (typeof ns.mountPath === "string") { @@ -319,7 +329,17 @@ export function registerFromNamespace( // Support named `register` export: `export function register(app) {}` if (typeof ns.register === "function") { try { - (ns.register as (app: App) => void)(makeRegistrar(app) as unknown as App); + const wrappedApp = makeRegistrar(app) as unknown as App; + const originalUse = wrappedApp.use; + wrappedApp.use = (mw: Middleware) => { + const wrappedMw: Middleware = (req, ctx, next) => { + if (!ctx.state) ctx.state = {}; + ctx.state.module = name; + return mw(req, ctx, next); + }; + return originalUse.call(wrappedApp, wrappedMw); + }; + (ns.register as (app: App) => void)(wrappedApp); { let recordedMount: string; if (typeof ns.mountPath === "string") { @@ -351,8 +371,18 @@ export function registerFromNamespace( "function" ) { try { + const wrappedApp = makeRegistrar(app) as unknown as App; + const originalUse = wrappedApp.use; + wrappedApp.use = (mw: Middleware) => { + const wrappedMw: Middleware = (req, ctx, next) => { + if (!ctx.state) ctx.state = {}; + ctx.state.module = name; + return mw(req, ctx, next); + }; + return originalUse.call(wrappedApp, wrappedMw); + }; (ns.default as { register: (app: App) => void }).register( - makeRegistrar(app) as unknown as App, + wrappedApp, ); { let recordedMount: string; diff --git a/deno.json b/deno.json index c5c898c4..c3e56a1e 100644 --- a/deno.json +++ b/deno.json @@ -18,8 +18,24 @@ "imports": { "@std/assert": "jsr:@std/assert@^1.0.19", "@std/testing": "jsr:@std/testing@^1.0.17", + "@types/react": "npm:@types/react@^19.0.8", + "@types/react-dom": "npm:@types/react-dom@^19.0.3", "react": "npm:react@^19.2.4", "react-dom": "npm:react-dom@^19.2.4", + "react-dom/server": "npm:react-dom@^19.2.4/server", + "react/jsx-runtime": "npm:react@^19.2.4/jsx-runtime", "tailwindcss": "npm:tailwindcss@^4.2.1" + }, + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react", + "jsxImportSourceTypes": "@types/react", + "lib": [ + "deno.ns", + "deno.unstable", + "dom", + "dom.iterable", + "dom.asynciterable" + ] } } diff --git a/manifest.ts b/manifest.ts index f261a8c1..2220c92b 100644 --- a/manifest.ts +++ b/manifest.ts @@ -1,18 +1,2 @@ -// Project manifest — export modules available for auto-registration. -// Keep entries in this file in sync with the `modules/` folder. The -// loader will import this manifest to auto-register middleware. - export * as index from "./modules/index/mod.ts"; export * as user from "./modules/user/mod.ts"; - -// Uncomment or add exports as modules are created: -// export * as dashboard from "./modules/dashboard/mod.ts"; -// export * as git from "./modules/git/mod.ts"; -// export * as media from "./modules/media/mod.ts"; -// export * as password from "./modules/password/mod.ts"; -// export * as posts from "./modules/posts/mod.ts"; -// export * as profile from "./modules/profile/mod.ts"; -// export * as signin from "./modules/signin/mod.ts"; -// export * as signup from "./modules/signup/mod.ts"; -// export * as ssr from "./modules/ssr/mod.ts"; -// export * as user from "./modules/user/mod.ts"; diff --git a/middlewares/render/render.test.ts b/middlewares/render/render.test.ts index 6ab324be..710d8fcc 100644 --- a/middlewares/render/render.test.ts +++ b/middlewares/render/render.test.ts @@ -391,3 +391,67 @@ Deno.test("/hmr path upgrades websocket and registers client (mocked)", () => { } _resetWatcherForTests(); }); + +Deno.test("renderToString infers module from URL path when no opts.module or state.module", () => { + Deno.env.set("ENV", "production"); + const mw = createRenderMiddleware(); + + // Case 1: /login -> module should be "login" + const ctx1: Context = { + params: {}, + query: {}, + remoteAddr: { transport: "tcp" }, + url: new URL("http://localhost/login"), + }; + mw(new Request("http://localhost/login"), ctx1, () => new Response("next")); + const html1 = ctx1.renderToString!( + React.createElement("div", null, "x"), + { includeHead: true }, + ); + assertStringIncludes(html1, "/js/login/client.js"); + + // Case 2: / -> module should fall back to "index" + const ctx2: Context = { + params: {}, + query: {}, + remoteAddr: { transport: "tcp" }, + url: new URL("http://localhost/"), + }; + mw(new Request("http://localhost/"), ctx2, () => new Response("next")); + const html2 = ctx2.renderToString!( + React.createElement("div", null, "x"), + { includeHead: true }, + ); + assertStringIncludes(html2, "/js/index/client.js"); + + // Case 3: /index -> module should be "index" + const ctx3: Context = { + params: {}, + query: {}, + remoteAddr: { transport: "tcp" }, + url: new URL("http://localhost/index"), + }; + mw(new Request("http://localhost/index"), ctx3, () => new Response("next")); + const html3 = ctx3.renderToString!( + React.createElement("div", null, "x"), + { includeHead: true }, + ); + assertStringIncludes(html3, "/js/index/client.js"); + + // Case 4: URL with no meaningful path segments (e.g. slashes only) -> "index" + const ctx4: Context = { + params: {}, + query: {}, + remoteAddr: { transport: "tcp" }, + url: new URL("http://localhost//"), + }; + mw(new Request("http://localhost//"), ctx4, () => new Response("next")); + const html4 = ctx4.renderToString!( + React.createElement("div", null, "x"), + { includeHead: true }, + ); + assertStringIncludes(html4, "/js/index/client.js"); + + _resetWatcherForTests(); + Deno.env.delete("ENV"); +}); diff --git a/middlewares/render/render.ts b/middlewares/render/render.ts index 204ebd25..16a87238 100644 --- a/middlewares/render/render.ts +++ b/middlewares/render/render.ts @@ -213,11 +213,7 @@ export function startComponentsWatcher( if (shouldImmediate) { // Run the callback synchronously; tests may mock Deno.stat/_watchTickForTests // to avoid background async operations when calling with `startInterval:false`. - try { - __fastro_watcher_cb(); - } catch (_) { - void 0; - } + __fastro_watcher_cb(); } } catch (_e) { /* ignore initialization errors */ @@ -286,16 +282,26 @@ const createRenderToString = (_context: Context) => { // Prefer explicit option, otherwise fall back to the module name stored // on the context by the loader (autoRegisterModules) or other middleware. - const resolvedModule = moduleFromOpts ?? ((_context && _context.state && + let resolvedModule = moduleFromOpts ?? ((_context && _context.state && typeof _context.state.module === "string") ? _context.state.module : undefined); + if (!resolvedModule) { + const pathname = _context.url.pathname; + if (pathname === "/" || pathname === "" || pathname === "/index") { + resolvedModule = "index"; + } else { + // Find the first segment that is not empty + const parts = pathname.split("/").filter(Boolean); + resolvedModule = parts[0] || "index"; + } + } + const isProd = Deno.env.get("ENV") === "production"; const timestamp = !isProd ? `?t=${Date.now()}` : ""; - const clientScript = resolvedModule - ? `` - : ""; + const clientScript = + ``; const hmrScript = !isProd ? rawHMRscript : ""; // Avoid inserting extraneous newlines between tags and the rendered diff --git a/middlewares/render/render_extra.test.ts b/middlewares/render/render_extra.test.ts index 281dfcb8..c2608656 100644 --- a/middlewares/render/render_extra.test.ts +++ b/middlewares/render/render_extra.test.ts @@ -22,7 +22,8 @@ Deno.test({ }); Deno.test({ - name: "render_extra: DA:185 - writeTextFileSync catch & DA:191 - second statSync throws", + name: + "render_extra: DA:185 - writeTextFileSync catch & DA:191 - second statSync throws", sanitizeOps: false, sanitizeResources: false, fn: () => { @@ -30,7 +31,7 @@ Deno.test({ let statCount = 0; const originalStatSync = Deno.statSync; const originalWriteTextFileSync = Deno.writeTextFileSync; - + Deno.statSync = (p) => { if (p === "./.build_done" || p.toString().endsWith(".build_done")) { statCount++; @@ -39,7 +40,7 @@ Deno.test({ } return originalStatSync(p); }; - + Deno.writeTextFileSync = (p, d) => { if (p === "./.build_done" || p.toString().endsWith(".build_done")) { throw new Error("force fail write"); @@ -68,12 +69,12 @@ Deno.test({ _resetWatcherForTests(); let statCount = 0; const originalStatSync = Deno.statSync; - + Deno.statSync = (p) => { if (p === "./.build_done" || p.toString().endsWith(".build_done")) { statCount++; if (statCount === 2) { - return { mtime: { getTime: () => 0 } } as unknown as Deno.FileInfo; + return { mtime: { getTime: () => 0 } } as unknown as Deno.FileInfo; } } return originalStatSync(p); @@ -139,7 +140,8 @@ Deno.test({ }); Deno.test({ - name: "render_extra: DA:222-224 - startComponentsWatcher initialization throws", + name: + "render_extra: DA:222-224 - startComponentsWatcher initialization throws", sanitizeOps: false, sanitizeResources: false, fn: () => { @@ -169,13 +171,28 @@ Deno.test({ _resetWatcherForTests(); const mw = createRenderMiddleware(); const req1 = { url: "" } as Request; - mw(req1, {} as import("../../core/types.ts").Context, () => new Response() as unknown as Response); - + mw( + req1, + {} as import("../../core/types.ts").Context, + () => new Response() as unknown as Response, + ); + const req2 = {} as Request; - mw(req2, {} as import("../../core/types.ts").Context, () => new Response() as unknown as Response); - - const req3 = { url: "invalid cat /home/dev/project/fw/fastro/middlewares/render/render_extra.test.ts" } as Request; - mw(req3, {} as import("../../core/types.ts").Context, () => new Response() as unknown as Response); + mw( + req2, + {} as import("../../core/types.ts").Context, + () => new Response() as unknown as Response, + ); + + const req3 = { + url: + "invalid cat /home/dev/project/fw/fastro/middlewares/render/render_extra.test.ts", + } as Request; + mw( + req3, + {} as import("../../core/types.ts").Context, + () => new Response() as unknown as Response, + ); }, }); @@ -203,11 +220,19 @@ Deno.test({ }; const mw = createRenderMiddleware(); - const req = new Request("http://localhost/hmr", { headers: { upgrade: "websocket", connection: "Upgrade" } }); - mw(req, {} as import("../../core/types.ts").Context, () => new Response() as unknown as Response); + const req = new Request("http://localhost/hmr", { + headers: { upgrade: "websocket", connection: "Upgrade" }, + }); + mw( + req, + {} as import("../../core/types.ts").Context, + () => new Response() as unknown as Response, + ); _setLastMtimeForTests(0); - await _watchTickForTestsWithStat(() => Promise.resolve({ mtime: new Date(Date.now() + 1000) } as Deno.FileInfo)); + await _watchTickForTestsWithStat(() => + Promise.resolve({ mtime: new Date(Date.now() + 1000) } as Deno.FileInfo) + ); if (mockSocket && mockSocket.onopen) { mockSocket.readyState = WebSocket.OPEN; @@ -230,6 +255,7 @@ Deno.test({ const originalSetInterval = globalThis.setInterval; let heartbeatCb: (() => void) | undefined; + // @ts-ignore: mock setInterval globalThis.setInterval = (cb: string | ((...args: unknown[]) => void)) => { if (typeof cb === "function") heartbeatCb = cb as () => void; return 999 as unknown as number; @@ -254,8 +280,14 @@ Deno.test({ }; const mw = createRenderMiddleware(); - const req = new Request("http://localhost/hmr", { headers: { upgrade: "websocket", connection: "Upgrade" } }); - mw(req, {} as import("../../core/types.ts").Context, () => new Response() as unknown as Response); + const req = new Request("http://localhost/hmr", { + headers: { upgrade: "websocket", connection: "Upgrade" }, + }); + mw( + req, + {} as import("../../core/types.ts").Context, + () => new Response() as unknown as Response, + ); if (mockSocket && mockSocket.onopen) { mockSocket.onopen(); @@ -263,7 +295,9 @@ Deno.test({ if (heartbeatCb) { heartbeatCb(); - mockSocket.send = () => { throw new Error("Force catch"); }; + mockSocket.send = () => { + throw new Error("Force catch"); + }; heartbeatCb(); } @@ -321,14 +355,19 @@ Deno.test({ const mw = createRenderMiddleware(); const req = { url: "invalid-url!" } as Request; - mw(req, {} as import("../../core/types.ts").Context, () => new Response() as unknown as Response); + mw( + req, + {} as import("../../core/types.ts").Context, + () => new Response() as unknown as Response, + ); Deno.env.get = originalGet; }, }); Deno.test({ - name: "render_extra: renderToString branches on includeHead and doctype false", + name: + "render_extra: renderToString branches on includeHead and doctype false", sanitizeOps: false, sanitizeResources: false, fn: () => { @@ -336,15 +375,25 @@ Deno.test({ const el = React.createElement("div", null, "Hello"); const ctx = {} as import("../../core/types.ts").Context; const mw = createRenderMiddleware(); - mw(new Request("http://localhost/"), ctx, () => new Response() as unknown as Response); + mw( + new Request("http://localhost/"), + ctx, + () => new Response() as unknown as Response, + ); - const rts = (ctx as unknown as { renderToString: (...args: unknown[]) => string }).renderToString; + const rts = + (ctx as unknown as { renderToString: (...args: unknown[]) => string }) + .renderToString; if (rts) { const html1 = rts(el, { includeHead: false, includeDoctype: true }); - if (!html1.startsWith("")) throw new Error("Missing doctype"); + if (!html1.startsWith("")) { + throw new Error("Missing doctype"); + } const html2 = rts(el, { includeHead: false, includeDoctype: false }); - if (html2.startsWith("")) throw new Error("Unexpected doctype"); + if (html2.startsWith("")) { + throw new Error("Unexpected doctype"); + } } }, }); @@ -360,7 +409,9 @@ Deno.test({ if (k === "ENV") return "test"; return originalGet.call(Deno.env, k); }; - await _watchTickForTestsWithStat(() => Promise.reject(new Error("Test catch branch"))); + await _watchTickForTestsWithStat(() => + Promise.reject(new Error("Test catch branch")) + ); Deno.env.get = originalGet; }, }); @@ -374,13 +425,18 @@ Deno.test({ _setLastMtimeForTests(0); const clients = _getHmrClientsForTests(); clients.clear(); - await _watchTickForTestsWithStat(() => Promise.resolve({ mtime: new Date(Date.now() + 1000) } as Deno.FileInfo)); - await _watchTickForTestsWithStat(() => Promise.resolve({ mtime: new Date(Date.now() + 2000) } as Deno.FileInfo)); + await _watchTickForTestsWithStat(() => + Promise.resolve({ mtime: new Date(Date.now() + 1000) } as Deno.FileInfo) + ); + await _watchTickForTestsWithStat(() => + Promise.resolve({ mtime: new Date(Date.now() + 2000) } as Deno.FileInfo) + ); }, }); Deno.test({ - name: "render_extra: startComponentsWatcher ignores errors if statSync fails on read but fallback works", + name: + "render_extra: startComponentsWatcher ignores errors if statSync fails on read but fallback works", sanitizeOps: false, sanitizeResources: false, fn: () => { @@ -403,6 +459,7 @@ Deno.test({ const originalSetInterval = globalThis.setInterval; let intervalCb: () => void = () => {}; + // @ts-ignore: mock setInterval globalThis.setInterval = (cb: string | ((...args: unknown[]) => void)) => { if (typeof cb === "function") intervalCb = cb as () => void; return 123 as unknown as number; @@ -425,10 +482,18 @@ Deno.test({ sanitizeResources: false, fn: () => { _resetWatcherForTests(); - const ctx = { state: { module: "test-module" } } as unknown as import("../../core/types.ts").Context; + const ctx = { + state: { module: "test-module" }, + } as unknown as import("../../core/types.ts").Context; const mw = createRenderMiddleware(); - mw(new Request("http://localhost/"), ctx, () => new Response() as unknown as Response); - const rts = (ctx as unknown as { renderToString: (...args: unknown[]) => string }).renderToString; + mw( + new Request("http://localhost/"), + ctx, + () => new Response() as unknown as Response, + ); + const rts = + (ctx as unknown as { renderToString: (...args: unknown[]) => string }) + .renderToString; if (rts) { const el = React.createElement("div", null, "Hello"); const html = rts(el, { includeHead: true });