Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions core/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

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<string, unknown> };
const result = await (mw(
new Request("http://localhost/"),
ctx,
() => "DONE",
) as Promise<unknown>);
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<string, unknown> };
await (mw(
new Request("http://localhost/"),
ctxNoState,
() => "DONE_NS",
) as Promise<unknown>);
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<string, unknown>;

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<string, unknown> };
const result = await (mw(
new Request("http://localhost/"),
ctx,
() => "DONE2",
) as Promise<unknown>);
assertEquals(result, "DONE2");
assertEquals(ctx.state.module, "namedregmod");

// No .state (covers if (!ctx.state) branch)
const ctxNoState = {} as unknown as { state: Record<string, unknown> };
await (mw(
new Request("http://localhost/"),
ctxNoState,
() => "DONE2_NS",
) as Promise<unknown>);
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<string, unknown>;

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<string, unknown> };
const result = await (mw(
new Request("http://localhost/"),
ctx,
() => "DONE3",
) as Promise<unknown>);
assertEquals(result, "DONE3");
assertEquals(ctx.state.module, "objregmod");

// No .state (covers if (!ctx.state) branch)
const ctxNoState = {} as unknown as { state: Record<string, unknown> };
await (mw(
new Request("http://localhost/"),
ctxNoState,
() => "DONE3_NS",
) as Promise<unknown>);
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");
});
36 changes: 33 additions & 3 deletions core/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 16 additions & 0 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
}
16 changes: 0 additions & 16 deletions manifest.ts
Original file line number Diff line number Diff line change
@@ -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";
64 changes: 64 additions & 0 deletions middlewares/render/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
24 changes: 15 additions & 9 deletions middlewares/render/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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
? `<script src="/js/${resolvedModule}/client.js${timestamp}" defer></script>`
: "";
const clientScript =
`<script src="/js/${resolvedModule}/client.js${timestamp}" defer></script>`;
const hmrScript = !isProd ? rawHMRscript : "";

// Avoid inserting extraneous newlines between tags and the rendered
Expand Down
Loading
Loading