@typed/app 1.0.0-beta.1
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/README.md +166 -0
- package/dist/HttpApiVirtualModulePlugin.d.ts +26 -0
- package/dist/HttpApiVirtualModulePlugin.d.ts.map +1 -0
- package/dist/HttpApiVirtualModulePlugin.js +301 -0
- package/dist/RouterVirtualModulePlugin.d.ts +23 -0
- package/dist/RouterVirtualModulePlugin.d.ts.map +1 -0
- package/dist/RouterVirtualModulePlugin.js +176 -0
- package/dist/createTypeInfoApiSessionForApp.d.ts +29 -0
- package/dist/createTypeInfoApiSessionForApp.d.ts.map +1 -0
- package/dist/createTypeInfoApiSessionForApp.js +46 -0
- package/dist/httpapi/defineApiHandler.d.ts +70 -0
- package/dist/httpapi/defineApiHandler.d.ts.map +1 -0
- package/dist/httpapi/defineApiHandler.js +23 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/internal/appConfigTypes.d.ts +11 -0
- package/dist/internal/appConfigTypes.d.ts.map +1 -0
- package/dist/internal/appConfigTypes.js +1 -0
- package/dist/internal/appLayerTypes.d.ts +24 -0
- package/dist/internal/appLayerTypes.d.ts.map +1 -0
- package/dist/internal/appLayerTypes.js +28 -0
- package/dist/internal/buildRouteDescriptors.d.ts +48 -0
- package/dist/internal/buildRouteDescriptors.d.ts.map +1 -0
- package/dist/internal/buildRouteDescriptors.js +371 -0
- package/dist/internal/emitHttpApiSource.d.ts +18 -0
- package/dist/internal/emitHttpApiSource.d.ts.map +1 -0
- package/dist/internal/emitHttpApiSource.js +404 -0
- package/dist/internal/emitRouterHelpers.d.ts +17 -0
- package/dist/internal/emitRouterHelpers.d.ts.map +1 -0
- package/dist/internal/emitRouterHelpers.js +74 -0
- package/dist/internal/emitRouterSource.d.ts +8 -0
- package/dist/internal/emitRouterSource.d.ts.map +1 -0
- package/dist/internal/emitRouterSource.js +139 -0
- package/dist/internal/extractHttpApiLiterals.d.ts +17 -0
- package/dist/internal/extractHttpApiLiterals.d.ts.map +1 -0
- package/dist/internal/extractHttpApiLiterals.js +45 -0
- package/dist/internal/httpapiDescriptorTree.d.ts +75 -0
- package/dist/internal/httpapiDescriptorTree.d.ts.map +1 -0
- package/dist/internal/httpapiDescriptorTree.js +182 -0
- package/dist/internal/httpapiEndpointContract.d.ts +32 -0
- package/dist/internal/httpapiEndpointContract.d.ts.map +1 -0
- package/dist/internal/httpapiEndpointContract.js +79 -0
- package/dist/internal/httpapiFileRoles.d.ts +67 -0
- package/dist/internal/httpapiFileRoles.d.ts.map +1 -0
- package/dist/internal/httpapiFileRoles.js +145 -0
- package/dist/internal/httpapiId.d.ts +30 -0
- package/dist/internal/httpapiId.d.ts.map +1 -0
- package/dist/internal/httpapiId.js +57 -0
- package/dist/internal/httpapiOpenApiConfig.d.ts +87 -0
- package/dist/internal/httpapiOpenApiConfig.d.ts.map +1 -0
- package/dist/internal/httpapiOpenApiConfig.js +144 -0
- package/dist/internal/httpapiSort.d.ts +16 -0
- package/dist/internal/httpapiSort.d.ts.map +1 -0
- package/dist/internal/httpapiSort.js +29 -0
- package/dist/internal/path.d.ts +16 -0
- package/dist/internal/path.d.ts.map +1 -0
- package/dist/internal/path.js +38 -0
- package/dist/internal/resolveConfig.d.ts +8 -0
- package/dist/internal/resolveConfig.d.ts.map +1 -0
- package/dist/internal/resolveConfig.js +13 -0
- package/dist/internal/routeIdentifiers.d.ts +18 -0
- package/dist/internal/routeIdentifiers.d.ts.map +1 -0
- package/dist/internal/routeIdentifiers.js +90 -0
- package/dist/internal/routeTypeNode.d.ts +45 -0
- package/dist/internal/routeTypeNode.d.ts.map +1 -0
- package/dist/internal/routeTypeNode.js +93 -0
- package/dist/internal/routerDescriptorTree.d.ts +110 -0
- package/dist/internal/routerDescriptorTree.d.ts.map +1 -0
- package/dist/internal/routerDescriptorTree.js +230 -0
- package/dist/internal/typeTargetBootstrap.d.ts +2 -0
- package/dist/internal/typeTargetBootstrap.d.ts.map +1 -0
- package/dist/internal/typeTargetBootstrap.js +23 -0
- package/dist/internal/typeTargetBootstrapHttpApi.d.ts +2 -0
- package/dist/internal/typeTargetBootstrapHttpApi.d.ts.map +1 -0
- package/dist/internal/typeTargetBootstrapHttpApi.js +21 -0
- package/dist/internal/typeTargetSpecs.d.ts +15 -0
- package/dist/internal/typeTargetSpecs.d.ts.map +1 -0
- package/dist/internal/typeTargetSpecs.js +32 -0
- package/dist/internal/validation.d.ts +12 -0
- package/dist/internal/validation.d.ts.map +1 -0
- package/dist/internal/validation.js +32 -0
- package/package.json +45 -0
- package/src/HttpApiVirtualModulePlugin.test.ts +1062 -0
- package/src/HttpApiVirtualModulePlugin.ts +376 -0
- package/src/RouterVirtualModulePlugin.test.ts +1254 -0
- package/src/RouterVirtualModulePlugin.ts +242 -0
- package/src/createTypeInfoApiSessionForApp.ts +57 -0
- package/src/defineApiHandler.test.ts +100 -0
- package/src/httpapi/defineApiHandler.ts +141 -0
- package/src/httpapiDescriptorTree.test.ts +124 -0
- package/src/httpapiEndpointContract.test.ts +160 -0
- package/src/httpapiFileRoles.test.ts +105 -0
- package/src/index.ts +40 -0
- package/src/internal/appConfigTypes.ts +12 -0
- package/src/internal/appLayerTypes.ts +79 -0
- package/src/internal/buildRouteDescriptors.ts +489 -0
- package/src/internal/emitHttpApiSource.ts +563 -0
- package/src/internal/emitRouterHelpers.ts +89 -0
- package/src/internal/emitRouterSource.ts +191 -0
- package/src/internal/extractHttpApiLiterals.ts +67 -0
- package/src/internal/httpapiDescriptorTree.ts +283 -0
- package/src/internal/httpapiEndpointContract.ts +110 -0
- package/src/internal/httpapiFileRoles.ts +204 -0
- package/src/internal/httpapiId.ts +78 -0
- package/src/internal/httpapiOpenApiConfig.ts +228 -0
- package/src/internal/httpapiSort.ts +39 -0
- package/src/internal/path.ts +46 -0
- package/src/internal/resolveConfig.ts +15 -0
- package/src/internal/routeIdentifiers.ts +93 -0
- package/src/internal/routeTypeNode.ts +120 -0
- package/src/internal/routerDescriptorTree.ts +366 -0
- package/src/internal/typeTargetBootstrap.ts +24 -0
- package/src/internal/typeTargetBootstrapHttpApi.ts +22 -0
- package/src/internal/typeTargetSpecs.ts +35 -0
- package/src/internal/validation.ts +46 -0
|
@@ -0,0 +1,1254 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import ts from "typescript";
|
|
6
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
createTypeInfoApiSession,
|
|
9
|
+
PluginManager,
|
|
10
|
+
resolveTypeTargetsFromSpecs,
|
|
11
|
+
} from "@typed/virtual-modules";
|
|
12
|
+
import type { VirtualModuleBuildError } from "@typed/virtual-modules";
|
|
13
|
+
import {
|
|
14
|
+
createRouterVirtualModulePlugin,
|
|
15
|
+
parseRouterVirtualModuleId,
|
|
16
|
+
resolveRouterTargetDirectory,
|
|
17
|
+
ROUTER_TYPE_TARGET_SPECS,
|
|
18
|
+
} from "./index.js";
|
|
19
|
+
const tempDirs: string[] = [];
|
|
20
|
+
|
|
21
|
+
const createTempDir = (): string => {
|
|
22
|
+
const base = join(process.cwd(), "tmp-router-test");
|
|
23
|
+
try {
|
|
24
|
+
mkdirSync(base, { recursive: true });
|
|
25
|
+
} catch {
|
|
26
|
+
// ignore
|
|
27
|
+
}
|
|
28
|
+
const dir = mkdtempSync(join(base, "run-"));
|
|
29
|
+
tempDirs.push(dir);
|
|
30
|
+
return dir;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Declarative filesystem fixture. Keys are paths relative to root (forward slashes).
|
|
35
|
+
* Creates root, writes each file (creating parent dirs), and returns importer + all paths.
|
|
36
|
+
* If "src/entry.ts" is omitted, it is added with "export {};".
|
|
37
|
+
*/
|
|
38
|
+
type FixtureSpec = Record<string, string>;
|
|
39
|
+
|
|
40
|
+
function createFixture(spec: FixtureSpec): {
|
|
41
|
+
root: string;
|
|
42
|
+
importer: string;
|
|
43
|
+
paths: string[];
|
|
44
|
+
} {
|
|
45
|
+
const root = createTempDir();
|
|
46
|
+
const normalized: FixtureSpec = { ...spec };
|
|
47
|
+
if (!("src/entry.ts" in normalized)) {
|
|
48
|
+
normalized["src/entry.ts"] = "export {};";
|
|
49
|
+
}
|
|
50
|
+
const sortedKeys = Object.keys(normalized).sort();
|
|
51
|
+
const paths: string[] = [];
|
|
52
|
+
for (const rel of sortedKeys) {
|
|
53
|
+
const abs = join(root, rel);
|
|
54
|
+
mkdirSync(join(abs, ".."), { recursive: true });
|
|
55
|
+
writeFileSync(abs, normalized[rel], "utf8");
|
|
56
|
+
paths.push(abs);
|
|
57
|
+
}
|
|
58
|
+
const importer = join(root, "src/entry.ts");
|
|
59
|
+
return { root, importer, paths };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Valid guard export: function returning Effect<Option<*>> so guard validation passes. */
|
|
63
|
+
const validGuardExport =
|
|
64
|
+
'import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; export const guard = (): Effect.Effect<Option.Option<unknown>> => Effect.succeed(Option.none());';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build router virtual module source from a declarative fixture and optional program file list.
|
|
68
|
+
* If programFiles is omitted, uses fixture.paths (importer + all written files).
|
|
69
|
+
* Returns string on success or VirtualModuleBuildError on validation failure.
|
|
70
|
+
*/
|
|
71
|
+
function buildRouterFromFixture(spec: FixtureSpec, programFiles?: string[]) {
|
|
72
|
+
const fixture = createFixture(spec);
|
|
73
|
+
const plugin = createRouterVirtualModulePlugin();
|
|
74
|
+
const files = programFiles ?? fixture.paths;
|
|
75
|
+
const programFilesWithBootstrap =
|
|
76
|
+
existsSync(BOOTSTRAP_FILE) && !files.includes(BOOTSTRAP_FILE)
|
|
77
|
+
? [...files, BOOTSTRAP_FILE]
|
|
78
|
+
: files;
|
|
79
|
+
const program = makeProgram(
|
|
80
|
+
programFilesWithBootstrap,
|
|
81
|
+
programFilesWithBootstrap.includes(BOOTSTRAP_FILE) ? APP_ROOT : fixture.root,
|
|
82
|
+
);
|
|
83
|
+
const session = createTypeInfoApiSession({
|
|
84
|
+
ts,
|
|
85
|
+
program,
|
|
86
|
+
typeTargetSpecs: ROUTER_TYPE_TARGET_SPECS,
|
|
87
|
+
});
|
|
88
|
+
return plugin.build("router:./routes", fixture.importer, session.api);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const APP_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
92
|
+
const NM = join(APP_ROOT, "node_modules");
|
|
93
|
+
/** Bootstrap file in the real project to ensure type target resolution finds canonical targets. */
|
|
94
|
+
const BOOTSTRAP_FILE = resolve(APP_ROOT, "src", "internal", "typeTargetBootstrap.ts");
|
|
95
|
+
|
|
96
|
+
const MODULE_FALLBACKS: Record<string, string> = {
|
|
97
|
+
"@typed/router": join(NM, "@typed", "router", "src", "index.ts"),
|
|
98
|
+
"@typed/fx": join(NM, "@typed", "fx", "src", "index.ts"),
|
|
99
|
+
"@typed/fx/Fx": join(NM, "@typed", "fx", "src", "Fx", "index.ts"),
|
|
100
|
+
"@typed/fx/RefSubject": join(NM, "@typed", "fx", "src", "RefSubject", "index.ts"),
|
|
101
|
+
effect: join(NM, "effect", "dist", "index.d.ts"),
|
|
102
|
+
"effect/Effect": join(NM, "effect", "dist", "Effect.d.ts"),
|
|
103
|
+
"effect/Stream": join(NM, "effect", "dist", "Stream.d.ts"),
|
|
104
|
+
"effect/Layer": join(NM, "effect", "dist", "Layer.d.ts"),
|
|
105
|
+
"effect/ServiceMap": join(NM, "effect", "dist", "ServiceMap.d.ts"),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
function makeProgram(rootFiles: readonly string[], fixtureRoot?: string): ts.Program {
|
|
109
|
+
const projectRoot = fixtureRoot ?? (rootFiles.length > 0 ? dirname(dirname(rootFiles[0])) : APP_ROOT);
|
|
110
|
+
const options: ts.CompilerOptions = {
|
|
111
|
+
strict: true,
|
|
112
|
+
target: ts.ScriptTarget.ESNext,
|
|
113
|
+
module: ts.ModuleKind.ESNext,
|
|
114
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
115
|
+
skipLibCheck: true,
|
|
116
|
+
noEmit: true,
|
|
117
|
+
};
|
|
118
|
+
const defaultHost = ts.createCompilerHost(options);
|
|
119
|
+
const moduleResolutionHost: ts.ModuleResolutionHost = {
|
|
120
|
+
getCurrentDirectory: () => projectRoot,
|
|
121
|
+
fileExists: defaultHost.fileExists?.bind(defaultHost),
|
|
122
|
+
readFile: defaultHost.readFile?.bind(defaultHost),
|
|
123
|
+
useCaseSensitiveFileNames: () => defaultHost.useCaseSensitiveFileNames?.() ?? true,
|
|
124
|
+
};
|
|
125
|
+
const customHost: ts.CompilerHost = {
|
|
126
|
+
...defaultHost,
|
|
127
|
+
getCurrentDirectory: () => projectRoot,
|
|
128
|
+
resolveModuleNames: (
|
|
129
|
+
moduleNames: string[],
|
|
130
|
+
containingFile: string,
|
|
131
|
+
_reusedNames: string[] | undefined,
|
|
132
|
+
_redirectedReference: ts.ResolvedProjectReference | undefined,
|
|
133
|
+
opts: ts.CompilerOptions,
|
|
134
|
+
): (ts.ResolvedModule | undefined)[] =>
|
|
135
|
+
moduleNames.map((moduleName) => {
|
|
136
|
+
const resolved = ts.resolveModuleName(
|
|
137
|
+
moduleName,
|
|
138
|
+
containingFile,
|
|
139
|
+
opts,
|
|
140
|
+
moduleResolutionHost,
|
|
141
|
+
);
|
|
142
|
+
if (resolved.resolvedModule) return resolved.resolvedModule;
|
|
143
|
+
const fallback = MODULE_FALLBACKS[moduleName];
|
|
144
|
+
if (fallback && defaultHost.fileExists?.(fallback)) {
|
|
145
|
+
return {
|
|
146
|
+
resolvedFileName: fallback,
|
|
147
|
+
extension: fallback.endsWith(".ts") ? ts.Extension.Ts : ts.Extension.Js,
|
|
148
|
+
isExternalLibraryImport: false,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}),
|
|
153
|
+
};
|
|
154
|
+
return ts.createProgram(rootFiles, options, customHost);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Route export using @typed/router so type is Route.Any. Path e.g. "/", "/home", "/users/:id". */
|
|
158
|
+
function routeExportForPath(path: string): string {
|
|
159
|
+
const segments = path
|
|
160
|
+
.replace(/^\/|\/$/g, "")
|
|
161
|
+
.split("/")
|
|
162
|
+
.filter(Boolean);
|
|
163
|
+
if (segments.length === 0) {
|
|
164
|
+
return 'import * as Route from "@typed/router";\nexport const route = Route.Slash;';
|
|
165
|
+
}
|
|
166
|
+
const parts = segments.map((s) =>
|
|
167
|
+
s.startsWith(":")
|
|
168
|
+
? `Route.Param(${JSON.stringify(s.slice(1))})`
|
|
169
|
+
: `Route.Parse(${JSON.stringify(s)})`,
|
|
170
|
+
);
|
|
171
|
+
const expr = parts.length === 1 ? parts[0] : `Route.Join(${parts.join(", ")})`;
|
|
172
|
+
return `import * as Route from "@typed/router";\nexport const route = ${expr};`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Shorthand for a route file with route + handler (plain value). */
|
|
176
|
+
function route(path: string, body: string): string {
|
|
177
|
+
return `${routeExportForPath(path)}\n${body}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
afterEach(() => {
|
|
181
|
+
while (tempDirs.length > 0) {
|
|
182
|
+
const dir = tempDirs.pop();
|
|
183
|
+
if (dir) {
|
|
184
|
+
rmSync(dir, { recursive: true, force: true });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("resolveTypeTargetsFromSpecs with ROUTER_TYPE_TARGET_SPECS", () => {
|
|
190
|
+
describe("explicit type target resolution", () => {
|
|
191
|
+
it("resolves all ROUTER_TYPE_TARGET_SPECS when bootstrap in program", () => {
|
|
192
|
+
const fixture = createFixture({
|
|
193
|
+
"src/routes/home.ts": route("/", "export const handler = 1;"),
|
|
194
|
+
});
|
|
195
|
+
const files =
|
|
196
|
+
existsSync(BOOTSTRAP_FILE) && !fixture.paths.includes(BOOTSTRAP_FILE)
|
|
197
|
+
? [...fixture.paths, BOOTSTRAP_FILE]
|
|
198
|
+
: fixture.paths;
|
|
199
|
+
const program = makeProgram(files, files.includes(BOOTSTRAP_FILE) ? APP_ROOT : fixture.root);
|
|
200
|
+
const targets = resolveTypeTargetsFromSpecs(program, ts, ROUTER_TYPE_TARGET_SPECS);
|
|
201
|
+
const targetIds = targets.map((t) => t.id).sort();
|
|
202
|
+
expect(targetIds).toMatchInlineSnapshot(`
|
|
203
|
+
[
|
|
204
|
+
"Cause",
|
|
205
|
+
"Effect",
|
|
206
|
+
"Fx",
|
|
207
|
+
"Layer",
|
|
208
|
+
"Option",
|
|
209
|
+
"RefSubject",
|
|
210
|
+
"Route",
|
|
211
|
+
"ServiceMap",
|
|
212
|
+
"Stream",
|
|
213
|
+
]
|
|
214
|
+
`);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("explicit assignability against @typed/router and effect/*", () => {
|
|
219
|
+
it("route and handler exports have expected assignability (Route, Fx/Effect)", () => {
|
|
220
|
+
const fixture = createFixture({
|
|
221
|
+
"src/routes/home.ts": `import * as Fx from "@typed/fx/Fx"; ${routeExportForPath("/")} export const handler: Fx.Fx<number> = Fx.succeed(1);`,
|
|
222
|
+
});
|
|
223
|
+
const files =
|
|
224
|
+
existsSync(BOOTSTRAP_FILE) && !fixture.paths.includes(BOOTSTRAP_FILE)
|
|
225
|
+
? [...fixture.paths, BOOTSTRAP_FILE]
|
|
226
|
+
: fixture.paths;
|
|
227
|
+
const program = makeProgram(files, files.includes(BOOTSTRAP_FILE) ? APP_ROOT : fixture.root);
|
|
228
|
+
const session = createTypeInfoApiSession({
|
|
229
|
+
ts,
|
|
230
|
+
program,
|
|
231
|
+
typeTargetSpecs: ROUTER_TYPE_TARGET_SPECS,
|
|
232
|
+
});
|
|
233
|
+
const result = session.api.file("src/routes/home.ts", { baseDir: fixture.root });
|
|
234
|
+
expect(result.ok).toBe(true);
|
|
235
|
+
if (!result.ok) return;
|
|
236
|
+
const { api } = session;
|
|
237
|
+
const routeExport = result.snapshot.exports.find((e) => e.name === "route");
|
|
238
|
+
const handlerExport = result.snapshot.exports.find((e) => e.name === "handler");
|
|
239
|
+
expect(routeExport).toBeDefined();
|
|
240
|
+
expect(handlerExport).toBeDefined();
|
|
241
|
+
expect(api.isAssignableTo(routeExport!.type, "Route")).toBe(true);
|
|
242
|
+
expect(api.isAssignableTo(routeExport!.type, "Fx")).toBe(false);
|
|
243
|
+
expect(api.isAssignableTo(handlerExport!.type, "Fx")).toBe(true);
|
|
244
|
+
expect(api.isAssignableTo(handlerExport!.type, "Route")).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("Effect-valued handler has returnTypeAssignableTo.Effect when returning Effect", () => {
|
|
248
|
+
const fixture = createFixture({
|
|
249
|
+
"src/routes/effect.ts": `import * as Effect from "effect/Effect"; ${routeExportForPath("/")} export const handler: Effect.Effect<number> = Effect.succeed(1);`,
|
|
250
|
+
});
|
|
251
|
+
const files =
|
|
252
|
+
existsSync(BOOTSTRAP_FILE) && !fixture.paths.includes(BOOTSTRAP_FILE)
|
|
253
|
+
? [...fixture.paths, BOOTSTRAP_FILE]
|
|
254
|
+
: fixture.paths;
|
|
255
|
+
const program = makeProgram(files, files.includes(BOOTSTRAP_FILE) ? APP_ROOT : fixture.root);
|
|
256
|
+
const session = createTypeInfoApiSession({
|
|
257
|
+
ts,
|
|
258
|
+
program,
|
|
259
|
+
typeTargetSpecs: ROUTER_TYPE_TARGET_SPECS,
|
|
260
|
+
});
|
|
261
|
+
const result = session.api.file("src/routes/effect.ts", { baseDir: fixture.root });
|
|
262
|
+
expect(result.ok).toBe(true);
|
|
263
|
+
if (!result.ok) return;
|
|
264
|
+
const handlerExport = result.snapshot.exports.find((e) => e.name === "handler");
|
|
265
|
+
expect(handlerExport).toBeDefined();
|
|
266
|
+
expect(session.api.isAssignableTo(handlerExport!.type, "Effect")).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
// Snapshot test matrix and naming: .docs/workflows/20250221-1200-router-snapshot-test-design/00-router-snapshot-test-design.md
|
|
272
|
+
describe("RouterVirtualModulePlugin", () => {
|
|
273
|
+
it("parses router id with ./ prefix", () => {
|
|
274
|
+
const parsed = parseRouterVirtualModuleId("router:./routes");
|
|
275
|
+
expect(parsed).toEqual({ ok: true, relativeDirectory: "./routes" });
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("rejects ids that do not use the router prefix", () => {
|
|
279
|
+
const parsed = parseRouterVirtualModuleId("virtual:./routes");
|
|
280
|
+
expect(parsed.ok).toBe(false);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("accepts router:routes without ./ prefix (normalized to ./routes)", () => {
|
|
284
|
+
const parsed = parseRouterVirtualModuleId("router:routes");
|
|
285
|
+
expect(parsed).toEqual({ ok: true, relativeDirectory: "./routes" });
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("resolves target directory from importer", () => {
|
|
289
|
+
const { importer } = createFixture({ "src/routes/index.ts": "export {};" });
|
|
290
|
+
|
|
291
|
+
const resolved = resolveRouterTargetDirectory("router:./routes", importer);
|
|
292
|
+
expect(resolved.ok).toBe(true);
|
|
293
|
+
if (!resolved.ok) return;
|
|
294
|
+
expect(resolved.targetDirectory.endsWith("/src/routes")).toBe(true);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("shouldResolve returns true when target directory exists with .ts files", () => {
|
|
298
|
+
const { importer } = createFixture({ "src/routes/index.ts": "export {};" });
|
|
299
|
+
|
|
300
|
+
const plugin = createRouterVirtualModulePlugin();
|
|
301
|
+
expect(plugin.shouldResolve("router:./routes", importer)).toBe(true);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("shouldResolve returns false when directory has no .ts files (AC-9)", () => {
|
|
305
|
+
const { importer } = createFixture({ "src/routes/readme.txt": "no ts files" });
|
|
306
|
+
|
|
307
|
+
const plugin = createRouterVirtualModulePlugin();
|
|
308
|
+
expect(plugin.shouldResolve("router:./routes", importer)).toBe(false);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("shouldResolve returns false when target directory is missing", () => {
|
|
312
|
+
const { importer } = createFixture({});
|
|
313
|
+
|
|
314
|
+
const plugin = createRouterVirtualModulePlugin();
|
|
315
|
+
expect(plugin.shouldResolve("router:./routes", importer)).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("build returns deterministic scaffold source", () => {
|
|
319
|
+
const result = buildRouterFromFixture({
|
|
320
|
+
"src/routes/users.ts": route("/", "export const handler = 1;"),
|
|
321
|
+
"src/routes/helper.ts": 'export const helper = "ok";',
|
|
322
|
+
});
|
|
323
|
+
expect(typeof result).toBe("string");
|
|
324
|
+
const source = result as string;
|
|
325
|
+
expect(source).toMatchInlineSnapshot(`
|
|
326
|
+
"import * as Router from "@typed/router";
|
|
327
|
+
import * as Fx from "@typed/fx/Fx";
|
|
328
|
+
import { constant } from "effect/Function";
|
|
329
|
+
import * as Users from "./routes/users.js";
|
|
330
|
+
|
|
331
|
+
const router = Router.match(Users.route, constant(Fx.succeed(Users.handler)));
|
|
332
|
+
export default router;
|
|
333
|
+
"
|
|
334
|
+
`);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("build throws when entrypoint export exists without route export", () => {
|
|
338
|
+
const result = buildRouterFromFixture({
|
|
339
|
+
"src/routes/invalid.ts": 'export const handler = "oops";',
|
|
340
|
+
});
|
|
341
|
+
expect(result).toMatchObject({ errors: expect.any(Array) });
|
|
342
|
+
expect((result as VirtualModuleBuildError).errors[0].code).toBe("RVM-ROUTE-001");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("build throws RVM-DEPS-001 when directory _dependencies has no default export", () => {
|
|
346
|
+
const result = buildRouterFromFixture({
|
|
347
|
+
"src/routes/_dependencies.ts": "export const deps = [];",
|
|
348
|
+
"src/routes/home.ts": route("/", "export const handler = 1;"),
|
|
349
|
+
});
|
|
350
|
+
expect(result).toMatchObject({ errors: expect.any(Array) });
|
|
351
|
+
expect((result as VirtualModuleBuildError).errors[0].code).toBe("RVM-DEPS-001");
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("build throws RVM-DEPS-001 when directory _dependencies default type is unclassified", () => {
|
|
355
|
+
const result = buildRouterFromFixture({
|
|
356
|
+
"src/routes/_dependencies.ts": "export default { foo: 1 };",
|
|
357
|
+
"src/routes/home.ts": route("/", "export const handler = 1;"),
|
|
358
|
+
});
|
|
359
|
+
expect(result).toMatchObject({ errors: expect.any(Array) });
|
|
360
|
+
expect((result as VirtualModuleBuildError).errors[0].code).toBe("RVM-DEPS-001");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("composes sibling and directory companions in ancestor->leaf order (TS-4)", () => {
|
|
364
|
+
const source = buildRouterFromFixture({
|
|
365
|
+
"src/routes/_dependencies.ts": "const deps: Array<unknown> = []; export default deps;",
|
|
366
|
+
"src/routes/users/profile.ts": route("/", "export const handler = 1;"),
|
|
367
|
+
"src/routes/users/profile.dependencies.ts": "export const dependencies = [];",
|
|
368
|
+
});
|
|
369
|
+
expect(typeof source).toBe("string");
|
|
370
|
+
expect(source).toMatchInlineSnapshot(`
|
|
371
|
+
"import * as Router from "@typed/router";
|
|
372
|
+
import * as Fx from "@typed/fx/Fx";
|
|
373
|
+
import { constant } from "effect/Function";
|
|
374
|
+
import * as UsersProfile from "./routes/users/profile.js";
|
|
375
|
+
import * as Dependencies from "./routes/_dependencies.js";
|
|
376
|
+
import * as UsersProfiledependencies from "./routes/users/profile.dependencies.js";
|
|
377
|
+
|
|
378
|
+
const router = Router.match(UsersProfile.route, { handler: constant(Fx.succeed(UsersProfile.handler)), dependencies: UsersProfiledependencies.dependencies }).provide(Router.normalizeDependencyInput(Dependencies.default));
|
|
379
|
+
export default router;
|
|
380
|
+
"
|
|
381
|
+
`);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("golden: directory dependencies and layout", () => {
|
|
385
|
+
const source = buildRouterFromFixture({
|
|
386
|
+
"src/routes/_dependencies.ts":
|
|
387
|
+
"import * as Layer from 'effect/Layer'; export default Layer.empty;",
|
|
388
|
+
"src/routes/api/_layout.ts": "export const layout = (x: unknown) => x;",
|
|
389
|
+
"src/routes/api/item.ts": route("/", "export const handler = 1;"),
|
|
390
|
+
"src/routes/api/item.catch.ts": "export const catchFn = () => null;",
|
|
391
|
+
});
|
|
392
|
+
expect(typeof source).toBe("string");
|
|
393
|
+
expect(source).toMatchInlineSnapshot(`
|
|
394
|
+
"import * as Router from "@typed/router";
|
|
395
|
+
import * as Fx from "@typed/fx/Fx";
|
|
396
|
+
import { constant } from "effect/Function";
|
|
397
|
+
import * as Effect from "effect/Effect";
|
|
398
|
+
import * as Cause from "effect/Cause";
|
|
399
|
+
import * as Result from "effect/Result";
|
|
400
|
+
import * as ApiItem from "./routes/api/item.js";
|
|
401
|
+
import * as Dependencies from "./routes/_dependencies.js";
|
|
402
|
+
import * as ApiLayout from "./routes/api/_layout.js";
|
|
403
|
+
import * as ApiItemcatch from "./routes/api/item.catch.js";
|
|
404
|
+
|
|
405
|
+
const router = Router.match(ApiItem.route, { handler: constant(Fx.succeed(ApiItem.handler)), catch: (causeRef) => Fx.flatMap(causeRef, (cause) => Result.match(Cause.findFail(cause), { onFailure: (c) => Fx.fromEffect(Effect.failCause(c)), onSuccess: ({ error: e }) => Fx.succeed(ApiItemcatch.catchFn(e)) })) }).layout(ApiLayout.layout).provide(Dependencies.default);
|
|
406
|
+
export default router;
|
|
407
|
+
"
|
|
408
|
+
`);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("golden: sibling dependencies and layout", () => {
|
|
412
|
+
const source = buildRouterFromFixture({
|
|
413
|
+
"src/routes/page.ts": route("/", "export const handler = 1;"),
|
|
414
|
+
"src/routes/page.dependencies.ts": "export const dependencies = [];",
|
|
415
|
+
"src/routes/page.layout.ts": "export const layout = (x: unknown) => x;",
|
|
416
|
+
});
|
|
417
|
+
expect(source).toMatchInlineSnapshot(`
|
|
418
|
+
"import * as Router from "@typed/router";
|
|
419
|
+
import * as Fx from "@typed/fx/Fx";
|
|
420
|
+
import { constant } from "effect/Function";
|
|
421
|
+
import * as Page from "./routes/page.js";
|
|
422
|
+
import * as Pagedependencies from "./routes/page.dependencies.js";
|
|
423
|
+
import * as Pagelayout from "./routes/page.layout.js";
|
|
424
|
+
|
|
425
|
+
const router = Router.match(Page.route, { handler: constant(Fx.succeed(Page.handler)), dependencies: Pagedependencies.dependencies, layout: Pagelayout.layout });
|
|
426
|
+
export default router;
|
|
427
|
+
"
|
|
428
|
+
`);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("golden: sibling and directory companions", () => {
|
|
432
|
+
const source = buildRouterFromFixture({
|
|
433
|
+
"src/routes/_dependencies.ts":
|
|
434
|
+
"import * as Layer from 'effect/Layer'; export default Layer.empty;",
|
|
435
|
+
"src/routes/users/profile.ts": route("/", "export const handler = 1;"),
|
|
436
|
+
"src/routes/users/profile.dependencies.ts": "export const dependencies = [];",
|
|
437
|
+
});
|
|
438
|
+
expect(source).toMatchInlineSnapshot(`
|
|
439
|
+
"import * as Router from "@typed/router";
|
|
440
|
+
import * as Fx from "@typed/fx/Fx";
|
|
441
|
+
import { constant } from "effect/Function";
|
|
442
|
+
import * as UsersProfile from "./routes/users/profile.js";
|
|
443
|
+
import * as Dependencies from "./routes/_dependencies.js";
|
|
444
|
+
import * as UsersProfiledependencies from "./routes/users/profile.dependencies.js";
|
|
445
|
+
|
|
446
|
+
const router = Router.match(UsersProfile.route, { handler: constant(Fx.succeed(UsersProfile.handler)), dependencies: UsersProfiledependencies.dependencies }).provide(Dependencies.default);
|
|
447
|
+
export default router;
|
|
448
|
+
"
|
|
449
|
+
`);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it("golden: multiple ancestors dependencies", () => {
|
|
453
|
+
const source = buildRouterFromFixture({
|
|
454
|
+
"src/routes/_dependencies.ts":
|
|
455
|
+
"import * as Layer from 'effect/Layer'; export default Layer.empty;",
|
|
456
|
+
"src/routes/api/_dependencies.ts":
|
|
457
|
+
"import * as Layer from 'effect/Layer'; export default Layer.empty;",
|
|
458
|
+
"src/routes/api/item.ts": route("/", "export const handler = 1;"),
|
|
459
|
+
});
|
|
460
|
+
expect(source).toMatchInlineSnapshot(`
|
|
461
|
+
"import * as Router from "@typed/router";
|
|
462
|
+
import * as Fx from "@typed/fx/Fx";
|
|
463
|
+
import { constant } from "effect/Function";
|
|
464
|
+
import * as ApiItem from "./routes/api/item.js";
|
|
465
|
+
import * as ApiDependencies from "./routes/api/_dependencies.js";
|
|
466
|
+
import * as Dependencies from "./routes/_dependencies.js";
|
|
467
|
+
|
|
468
|
+
const router = Router.match(ApiItem.route, constant(Fx.succeed(ApiItem.handler))).provide(ApiDependencies.default).provide(Dependencies.default);
|
|
469
|
+
export default router;
|
|
470
|
+
"
|
|
471
|
+
`);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("golden: sibling and directory layout", () => {
|
|
475
|
+
const source = buildRouterFromFixture({
|
|
476
|
+
"src/routes/api/_layout.ts": "export const layout = (x: unknown) => x;",
|
|
477
|
+
"src/routes/api/item.ts": route("/", "export const handler = 1;"),
|
|
478
|
+
"src/routes/api/item.layout.ts": "export const layout = (x: unknown) => x;",
|
|
479
|
+
});
|
|
480
|
+
expect(source).toMatchInlineSnapshot(`
|
|
481
|
+
"import * as Router from "@typed/router";
|
|
482
|
+
import * as Fx from "@typed/fx/Fx";
|
|
483
|
+
import { constant } from "effect/Function";
|
|
484
|
+
import * as ApiItem from "./routes/api/item.js";
|
|
485
|
+
import * as ApiLayout from "./routes/api/_layout.js";
|
|
486
|
+
import * as ApiItemlayout from "./routes/api/item.layout.js";
|
|
487
|
+
|
|
488
|
+
const router = Router.match(ApiItem.route, { handler: constant(Fx.succeed(ApiItem.handler)), layout: ApiItemlayout.layout }).layout(ApiLayout.layout);
|
|
489
|
+
export default router;
|
|
490
|
+
"
|
|
491
|
+
`);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it("build throws when multiple entrypoints are exported", () => {
|
|
495
|
+
const result = buildRouterFromFixture({
|
|
496
|
+
"src/routes/invalid.ts": `${routeExportForPath("/")}\nexport const handler = "a";\nexport const template = "b";`,
|
|
497
|
+
});
|
|
498
|
+
expect(result).toMatchObject({ errors: expect.any(Array) });
|
|
499
|
+
expect((result as VirtualModuleBuildError).errors[0].code).toBe("RVM-ENTRY-002");
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it("build throws when route export is not structurally compatible with Route", () => {
|
|
503
|
+
const result = buildRouterFromFixture({
|
|
504
|
+
"src/routes/bad.ts": "export const route = { foo: 1 }; export const handler = 1;",
|
|
505
|
+
});
|
|
506
|
+
expect(result).toMatchObject({ errors: expect.any(Array) });
|
|
507
|
+
expect((result as VirtualModuleBuildError).errors[0].code).toBe("RVM-ROUTE-002");
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("build throws when route has no entrypoint", () => {
|
|
511
|
+
const result = buildRouterFromFixture({
|
|
512
|
+
"src/routes/noroute.ts": routeExportForPath("/"),
|
|
513
|
+
});
|
|
514
|
+
expect(result).toMatchObject({ errors: expect.any(Array) });
|
|
515
|
+
expect((result as VirtualModuleBuildError).errors[0].code).toBe("RVM-ENTRY-001");
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("build throws when there are no valid route leaves", () => {
|
|
519
|
+
const result = buildRouterFromFixture({
|
|
520
|
+
"src/routes/helper.ts": "export const helper = 1;",
|
|
521
|
+
});
|
|
522
|
+
expect(result).toMatchObject({ errors: expect.any(Array) });
|
|
523
|
+
expect((result as VirtualModuleBuildError).errors[0].code).toBe("RVM-LEAF-001");
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it("build returns RVM-ID-001 when virtual module id is invalid (e.g. empty relative path)", () => {
|
|
527
|
+
const fixture = createFixture({
|
|
528
|
+
"src/routes/home.ts": route("/", "export const handler = 1;"),
|
|
529
|
+
});
|
|
530
|
+
const plugin = createRouterVirtualModulePlugin();
|
|
531
|
+
const program = makeProgram(fixture.paths);
|
|
532
|
+
const session = createTypeInfoApiSession({ ts, program });
|
|
533
|
+
const result = plugin.build("router:", fixture.importer, session.api);
|
|
534
|
+
expect(result).toMatchObject({ errors: expect.any(Array) });
|
|
535
|
+
expect((result as VirtualModuleBuildError).errors[0].code).toBe("RVM-ID-001");
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("build returns RVM-DISC-001 when target directory does not exist", () => {
|
|
539
|
+
const result = buildRouterFromFixture({});
|
|
540
|
+
expect(result).toMatchObject({ errors: expect.any(Array) });
|
|
541
|
+
expect((result as VirtualModuleBuildError).errors[0].code).toBe("RVM-DISC-001");
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
it("invalid guard (non-function) produces RVM-GUARD-001", () => {
|
|
545
|
+
const result = buildRouterFromFixture({
|
|
546
|
+
"src/routes/users.ts": route("/", "export const handler = 1;"),
|
|
547
|
+
"src/routes/users.guard.ts": "export const guard = true;",
|
|
548
|
+
});
|
|
549
|
+
expect(result).toMatchObject({ errors: expect.any(Array) });
|
|
550
|
+
expect((result as VirtualModuleBuildError).errors[0].code).toBe("RVM-GUARD-001");
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it("invalid guard (wrong return type) produces RVM-GUARD-001", () => {
|
|
554
|
+
const result = buildRouterFromFixture({
|
|
555
|
+
"src/routes/users.ts": route("/", "export const handler = 1;"),
|
|
556
|
+
"src/routes/users.guard.ts": "export const guard = () => true;",
|
|
557
|
+
});
|
|
558
|
+
expect(result).toMatchObject({ errors: expect.any(Array) });
|
|
559
|
+
expect((result as VirtualModuleBuildError).errors[0].code).toBe("RVM-GUARD-001");
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it("valid guard (Effect<Option<*>>) is accepted and emitted", () => {
|
|
563
|
+
const result = buildRouterFromFixture({
|
|
564
|
+
"src/routes/users.ts": route("/", "export const handler = 1;"),
|
|
565
|
+
"src/routes/users.guard.ts": validGuardExport,
|
|
566
|
+
});
|
|
567
|
+
if (typeof result !== "string") {
|
|
568
|
+
expect(result).toMatchObject({ errors: expect.any(Array) });
|
|
569
|
+
expect((result as VirtualModuleBuildError).errors[0].code).toBe("RVM-GUARD-001");
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
expect(result).toContain("guard:");
|
|
573
|
+
expect(result).toContain("UsersGuard.guard");
|
|
574
|
+
expect(result).not.toContain("??");
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("guard default export is accepted and emitted", () => {
|
|
578
|
+
const defaultGuardExport =
|
|
579
|
+
'import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; export default function guard(): Effect.Effect<Option.Option<unknown>> { return Effect.succeed(Option.none()); }';
|
|
580
|
+
const result = buildRouterFromFixture({
|
|
581
|
+
"src/routes/users.ts": route("/", "export const handler = 1;"),
|
|
582
|
+
"src/routes/users.guard.ts": defaultGuardExport,
|
|
583
|
+
});
|
|
584
|
+
if (typeof result !== "string") {
|
|
585
|
+
expect(result).toMatchObject({ errors: expect.any(Array) });
|
|
586
|
+
expect((result as VirtualModuleBuildError).errors[0].code).toBe("RVM-GUARD-001");
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
expect(result).toContain("guard:");
|
|
590
|
+
expect(result).toContain("UsersGuard.default");
|
|
591
|
+
expect(result).not.toContain("??");
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("classifies plain entrypoint and sets needsLift (TS-5, AC-11)", () => {
|
|
595
|
+
const source = buildRouterFromFixture({
|
|
596
|
+
"src/routes/home.ts": route("/", "export const handler = 42;"),
|
|
597
|
+
});
|
|
598
|
+
expect(source).toMatchInlineSnapshot(`
|
|
599
|
+
"import * as Router from "@typed/router";
|
|
600
|
+
import * as Fx from "@typed/fx/Fx";
|
|
601
|
+
import { constant } from "effect/Function";
|
|
602
|
+
import * as Home from "./routes/home.js";
|
|
603
|
+
|
|
604
|
+
const router = Router.match(Home.route, constant(Fx.succeed(Home.handler)));
|
|
605
|
+
export default router;
|
|
606
|
+
"
|
|
607
|
+
`);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("effect-valued handler is lifted with Fx.fromEffect (T-07, TS-5)", () => {
|
|
611
|
+
const source = buildRouterFromFixture({
|
|
612
|
+
"src/routes/effect.ts": `import * as Effect from "effect/Effect"; ${routeExportForPath("/")} export const handler: Effect.Effect<number> = Effect.succeed(1);`,
|
|
613
|
+
});
|
|
614
|
+
expect(source).toMatchInlineSnapshot(`
|
|
615
|
+
"import * as Router from "@typed/router";
|
|
616
|
+
import * as Fx from "@typed/fx/Fx";
|
|
617
|
+
import { constant } from "effect/Function";
|
|
618
|
+
import * as MEffect from "./routes/effect.js";
|
|
619
|
+
|
|
620
|
+
const router = Router.match(MEffect.route, constant(Fx.fromEffect(MEffect.handler)));
|
|
621
|
+
export default router;
|
|
622
|
+
"
|
|
623
|
+
`);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("fx-valued handler is passed through (constant(ref)) since already Fx (T-07, TS-5)", () => {
|
|
627
|
+
const source = buildRouterFromFixture({
|
|
628
|
+
"src/routes/fx.ts": `import * as Fx from "@typed/fx/Fx"; ${routeExportForPath("/")} export const handler: Fx.Fx<number> = Fx.succeed(1);`,
|
|
629
|
+
});
|
|
630
|
+
expect(source).toMatchInlineSnapshot(`
|
|
631
|
+
"import * as Router from "@typed/router";
|
|
632
|
+
import * as Fx from "@typed/fx/Fx";
|
|
633
|
+
import { constant } from "effect/Function";
|
|
634
|
+
import * as MFx from "./routes/fx.js";
|
|
635
|
+
|
|
636
|
+
const router = Router.match(MFx.route, constant(MFx.handler));
|
|
637
|
+
export default router;
|
|
638
|
+
"
|
|
639
|
+
`);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("stream-valued handler is classified as stream (fromStream) (T-07, TS-5)", () => {
|
|
643
|
+
const source = buildRouterFromFixture({
|
|
644
|
+
"src/routes/stream.ts": `import * as Stream from "effect/Stream"; ${routeExportForPath("/")} export const handler = Stream.succeed(1);`,
|
|
645
|
+
});
|
|
646
|
+
expect(source).toMatchInlineSnapshot(`
|
|
647
|
+
"import * as Router from "@typed/router";
|
|
648
|
+
import * as Fx from "@typed/fx/Fx";
|
|
649
|
+
import { constant } from "effect/Function";
|
|
650
|
+
import * as MStream from "./routes/stream.js";
|
|
651
|
+
|
|
652
|
+
const router = Router.match(MStream.route, constant(Fx.fromStream(MStream.handler)));
|
|
653
|
+
export default router;
|
|
654
|
+
"
|
|
655
|
+
`);
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it("plain function handler: emits (params) => Fx.succeed(M.handler(params))", () => {
|
|
659
|
+
const source = buildRouterFromFixture({
|
|
660
|
+
"src/routes/page.ts": route("/", "export const handler = (p: unknown) => 1;"),
|
|
661
|
+
});
|
|
662
|
+
expect(source).toMatchInlineSnapshot(`
|
|
663
|
+
"import * as Router from "@typed/router";
|
|
664
|
+
import * as Fx from "@typed/fx/Fx";
|
|
665
|
+
import { constant } from "effect/Function";
|
|
666
|
+
import * as Page from "./routes/page.js";
|
|
667
|
+
|
|
668
|
+
const router = Router.match(Page.route, (params) => Fx.map(params, Page.handler));
|
|
669
|
+
export default router;
|
|
670
|
+
"
|
|
671
|
+
`);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
it("effect-like function handler: passes through when return type is Fx", () => {
|
|
675
|
+
const result = buildRouterFromFixture({
|
|
676
|
+
"src/routes/async.ts": `import * as Fx from "@typed/fx/Fx"; ${routeExportForPath("/")} export const handler = (_p: unknown): Fx.Fx<number> => Fx.succeed(1);`,
|
|
677
|
+
});
|
|
678
|
+
expect(typeof result).toBe("string");
|
|
679
|
+
const source = result as string;
|
|
680
|
+
expect(source).toMatchInlineSnapshot(`
|
|
681
|
+
"import * as Router from "@typed/router";
|
|
682
|
+
import * as Fx from "@typed/fx/Fx";
|
|
683
|
+
import { constant } from "effect/Function";
|
|
684
|
+
import * as Async from "./routes/async.js";
|
|
685
|
+
|
|
686
|
+
const router = Router.match(Async.route, Async.handler);
|
|
687
|
+
export default router;
|
|
688
|
+
"
|
|
689
|
+
`);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it("handler matrix: plain value emits constant(Fx.succeed(M.handler))", () => {
|
|
693
|
+
const source = buildRouterFromFixture({
|
|
694
|
+
"src/routes/v.ts": route("/", "export const handler = 1;"),
|
|
695
|
+
});
|
|
696
|
+
expect(source).toMatchInlineSnapshot(`
|
|
697
|
+
"import * as Router from "@typed/router";
|
|
698
|
+
import * as Fx from "@typed/fx/Fx";
|
|
699
|
+
import { constant } from "effect/Function";
|
|
700
|
+
import * as V from "./routes/v.js";
|
|
701
|
+
|
|
702
|
+
const router = Router.match(V.route, constant(Fx.succeed(V.handler)));
|
|
703
|
+
export default router;
|
|
704
|
+
"
|
|
705
|
+
`);
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it("handler matrix: plain function emits (params) => Fx.succeed(M.handler(params))", () => {
|
|
709
|
+
const source = buildRouterFromFixture({
|
|
710
|
+
"src/routes/f.ts": route("/", "export const handler = (p: unknown) => 1;"),
|
|
711
|
+
});
|
|
712
|
+
expect(source).toMatchInlineSnapshot(`
|
|
713
|
+
"import * as Router from "@typed/router";
|
|
714
|
+
import * as Fx from "@typed/fx/Fx";
|
|
715
|
+
import { constant } from "effect/Function";
|
|
716
|
+
import * as F from "./routes/f.js";
|
|
717
|
+
|
|
718
|
+
const router = Router.match(F.route, (params) => Fx.map(params, F.handler));
|
|
719
|
+
export default router;
|
|
720
|
+
"
|
|
721
|
+
`);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("handler matrix: Effect value uses fromEffect when type resolves as Effect", () => {
|
|
725
|
+
const result = buildRouterFromFixture({
|
|
726
|
+
"src/routes/e.ts": `import * as Effect from "effect/Effect"; ${routeExportForPath("/")} export const handler: Effect.Effect<number> = Effect.succeed(1);`,
|
|
727
|
+
});
|
|
728
|
+
expect(typeof result).toBe("string");
|
|
729
|
+
expect(result as string).toMatchInlineSnapshot(`
|
|
730
|
+
"import * as Router from "@typed/router";
|
|
731
|
+
import * as Fx from "@typed/fx/Fx";
|
|
732
|
+
import { constant } from "effect/Function";
|
|
733
|
+
import * as E from "./routes/e.js";
|
|
734
|
+
|
|
735
|
+
const router = Router.match(E.route, constant(Fx.fromEffect(E.handler)));
|
|
736
|
+
export default router;
|
|
737
|
+
"
|
|
738
|
+
`);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("handler matrix: Effect function uses fromEffect when return type resolves as Effect", () => {
|
|
742
|
+
const result = buildRouterFromFixture({
|
|
743
|
+
"src/routes/ef.ts": `import * as Effect from "effect/Effect"; ${routeExportForPath("/")} export const handler = (_p: unknown): Effect.Effect<number> => Effect.succeed(1);`,
|
|
744
|
+
});
|
|
745
|
+
expect(typeof result).toBe("string");
|
|
746
|
+
expect(result as string).toMatchInlineSnapshot(`
|
|
747
|
+
"import * as Router from "@typed/router";
|
|
748
|
+
import * as Fx from "@typed/fx/Fx";
|
|
749
|
+
import { constant } from "effect/Function";
|
|
750
|
+
import * as Ef from "./routes/ef.js";
|
|
751
|
+
|
|
752
|
+
const router = Router.match(Ef.route, (params) => Fx.mapEffect(params, Ef.handler));
|
|
753
|
+
export default router;
|
|
754
|
+
"
|
|
755
|
+
`);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("handler matrix: Stream value uses fromStream when type resolves as Stream", () => {
|
|
759
|
+
const result = buildRouterFromFixture({
|
|
760
|
+
"src/routes/s.ts": `import * as Stream from "effect/Stream"; ${routeExportForPath("/")} export const handler = Stream.succeed(1);`,
|
|
761
|
+
});
|
|
762
|
+
expect(typeof result).toBe("string");
|
|
763
|
+
expect(result as string).toMatchInlineSnapshot(`
|
|
764
|
+
"import * as Router from "@typed/router";
|
|
765
|
+
import * as Fx from "@typed/fx/Fx";
|
|
766
|
+
import { constant } from "effect/Function";
|
|
767
|
+
import * as S from "./routes/s.js";
|
|
768
|
+
|
|
769
|
+
const router = Router.match(S.route, constant(Fx.fromStream(S.handler)));
|
|
770
|
+
export default router;
|
|
771
|
+
"
|
|
772
|
+
`);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
it("handler matrix: Stream function uses fromStream when return type resolves as Stream", () => {
|
|
776
|
+
const result = buildRouterFromFixture({
|
|
777
|
+
"src/routes/sf.ts": `import * as Stream from "effect/Stream"; ${routeExportForPath("/")} export const handler = (_p: unknown) => Stream.succeed(1);`,
|
|
778
|
+
});
|
|
779
|
+
expect(typeof result).toBe("string");
|
|
780
|
+
expect(result as string).toMatchInlineSnapshot(`
|
|
781
|
+
"import * as Router from "@typed/router";
|
|
782
|
+
import * as Fx from "@typed/fx/Fx";
|
|
783
|
+
import { constant } from "effect/Function";
|
|
784
|
+
import * as Sf from "./routes/sf.js";
|
|
785
|
+
|
|
786
|
+
const router = Router.match(Sf.route, (params) => Fx.switchMap(params, (p) => Fx.fromStream(Sf.handler(p))));
|
|
787
|
+
export default router;
|
|
788
|
+
"
|
|
789
|
+
`);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it("wrong typeTargetSpecs module path yields no assignableTo and RVM-KIND-001 (structural compatibility required)", () => {
|
|
793
|
+
const wrongSpecs = [
|
|
794
|
+
{ id: "Fx", module: "nonexistent/fx", exportName: "Fx" } as const,
|
|
795
|
+
{ id: "Route", module: "nonexistent/router", exportName: "Route" } as const,
|
|
796
|
+
];
|
|
797
|
+
const fixture = createFixture({
|
|
798
|
+
"src/routes/fx.ts": `import * as Fx from "@typed/fx/Fx"; ${routeExportForPath("/")} export const handler: Fx.Fx<number> = Fx.succeed(1);`,
|
|
799
|
+
});
|
|
800
|
+
const program = makeProgram(fixture.paths);
|
|
801
|
+
const session = createTypeInfoApiSession({
|
|
802
|
+
ts,
|
|
803
|
+
program,
|
|
804
|
+
typeTargetSpecs: wrongSpecs,
|
|
805
|
+
failWhenNoTargetsResolved: false,
|
|
806
|
+
});
|
|
807
|
+
const result = session.api.file("./src/routes/fx.ts", { baseDir: fixture.root });
|
|
808
|
+
expect(result.ok).toBe(true);
|
|
809
|
+
if (!result.ok) return;
|
|
810
|
+
const handlerExport = result.snapshot.exports.find((e) => e.name === "handler");
|
|
811
|
+
expect(handlerExport).toBeDefined();
|
|
812
|
+
expect(handlerExport!.assignableTo?.Fx).toBeUndefined();
|
|
813
|
+
const plugin = createRouterVirtualModulePlugin();
|
|
814
|
+
const buildResult = plugin.build("router:./routes", fixture.importer, session.api);
|
|
815
|
+
expect(buildResult).toMatchObject({ errors: expect.any(Array) });
|
|
816
|
+
const codes = (buildResult as VirtualModuleBuildError).errors.map((e) => e.code);
|
|
817
|
+
expect(
|
|
818
|
+
codes.some((c) => c === "RVM-KIND-001" || c === "RVM-ROUTE-002"),
|
|
819
|
+
).toBe(true);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it("handler matrix: Fx value pass-through when type resolves as Fx", () => {
|
|
823
|
+
const result = buildRouterFromFixture({
|
|
824
|
+
"src/routes/x.ts": `import * as Fx from "@typed/fx/Fx"; ${routeExportForPath("/")} export const handler: Fx.Fx<number> = Fx.succeed(1);`,
|
|
825
|
+
});
|
|
826
|
+
expect(typeof result).toBe("string");
|
|
827
|
+
expect(result as string).toMatchInlineSnapshot(`
|
|
828
|
+
"import * as Router from "@typed/router";
|
|
829
|
+
import * as Fx from "@typed/fx/Fx";
|
|
830
|
+
import { constant } from "effect/Function";
|
|
831
|
+
import * as X from "./routes/x.js";
|
|
832
|
+
|
|
833
|
+
const router = Router.match(X.route, constant(X.handler));
|
|
834
|
+
export default router;
|
|
835
|
+
"
|
|
836
|
+
`);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it("handler matrix: Fx function pass-through when return type resolves as Fx", () => {
|
|
840
|
+
const result = buildRouterFromFixture({
|
|
841
|
+
"src/routes/xf.ts": `import * as Fx from "@typed/fx/Fx"; ${routeExportForPath("/")} export const handler = (_p: unknown): Fx.Fx<number> => Fx.succeed(1);`,
|
|
842
|
+
});
|
|
843
|
+
expect(typeof result).toBe("string");
|
|
844
|
+
expect(result as string).toMatchInlineSnapshot(`
|
|
845
|
+
"import * as Router from "@typed/router";
|
|
846
|
+
import * as Fx from "@typed/fx/Fx";
|
|
847
|
+
import { constant } from "effect/Function";
|
|
848
|
+
import * as Xf from "./routes/xf.js";
|
|
849
|
+
|
|
850
|
+
const router = Router.match(Xf.route, Xf.handler);
|
|
851
|
+
export default router;
|
|
852
|
+
"
|
|
853
|
+
`);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it("build returns source string", () => {
|
|
857
|
+
const fixture = createFixture({
|
|
858
|
+
"src/routes/home.ts": route("/", "export const handler = 1;"),
|
|
859
|
+
});
|
|
860
|
+
const plugin = createRouterVirtualModulePlugin();
|
|
861
|
+
const program = makeProgram(
|
|
862
|
+
existsSync(BOOTSTRAP_FILE) ? [...fixture.paths, BOOTSTRAP_FILE] : fixture.paths,
|
|
863
|
+
APP_ROOT,
|
|
864
|
+
);
|
|
865
|
+
const session = createTypeInfoApiSession({
|
|
866
|
+
ts,
|
|
867
|
+
program,
|
|
868
|
+
typeTargetSpecs: ROUTER_TYPE_TARGET_SPECS,
|
|
869
|
+
});
|
|
870
|
+
const result = plugin.build("router:./routes", fixture.importer, session.api);
|
|
871
|
+
expect(typeof result).toBe("string");
|
|
872
|
+
expect((result as string).length).toBeGreaterThan(0);
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
it("unchanged inputs produce identical output (T-08, TS-6)", () => {
|
|
876
|
+
const fixture = createFixture({
|
|
877
|
+
"src/routes/home.ts": route("/", "export const handler = 1;"),
|
|
878
|
+
});
|
|
879
|
+
const plugin = createRouterVirtualModulePlugin();
|
|
880
|
+
const program = makeProgram(
|
|
881
|
+
existsSync(BOOTSTRAP_FILE) ? [...fixture.paths, BOOTSTRAP_FILE] : fixture.paths,
|
|
882
|
+
APP_ROOT,
|
|
883
|
+
);
|
|
884
|
+
const session1 = createTypeInfoApiSession({
|
|
885
|
+
ts,
|
|
886
|
+
program,
|
|
887
|
+
typeTargetSpecs: ROUTER_TYPE_TARGET_SPECS,
|
|
888
|
+
});
|
|
889
|
+
const session2 = createTypeInfoApiSession({
|
|
890
|
+
ts,
|
|
891
|
+
program,
|
|
892
|
+
typeTargetSpecs: ROUTER_TYPE_TARGET_SPECS,
|
|
893
|
+
});
|
|
894
|
+
const source1 = plugin.build("router:./routes", fixture.importer, session1.api);
|
|
895
|
+
const source2 = plugin.build("router:./routes", fixture.importer, session2.api);
|
|
896
|
+
expect(typeof source1).toBe(typeof source2);
|
|
897
|
+
if (typeof source1 === "string") {
|
|
898
|
+
expect(source1).toBe(source2);
|
|
899
|
+
} else {
|
|
900
|
+
expect(source1).toStrictEqual(source2);
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
it("emits readonly descriptor metadata with as const (T-08, TS-7)", () => {
|
|
905
|
+
const source = buildRouterFromFixture({
|
|
906
|
+
"src/routes/home.ts": route("/", "export const handler = 1;"),
|
|
907
|
+
});
|
|
908
|
+
expect(source).toMatchInlineSnapshot(`
|
|
909
|
+
"import * as Router from "@typed/router";
|
|
910
|
+
import * as Fx from "@typed/fx/Fx";
|
|
911
|
+
import { constant } from "effect/Function";
|
|
912
|
+
import * as Home from "./routes/home.js";
|
|
913
|
+
|
|
914
|
+
const router = Router.match(Home.route, constant(Fx.succeed(Home.handler)));
|
|
915
|
+
export default router;
|
|
916
|
+
"
|
|
917
|
+
`);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it("golden: single route with handler only", () => {
|
|
921
|
+
const source = buildRouterFromFixture({
|
|
922
|
+
"src/routes/home.ts": route("/", "export const handler = 1;"),
|
|
923
|
+
});
|
|
924
|
+
expect(source).toMatchInlineSnapshot(`
|
|
925
|
+
"import * as Router from "@typed/router";
|
|
926
|
+
import * as Fx from "@typed/fx/Fx";
|
|
927
|
+
import { constant } from "effect/Function";
|
|
928
|
+
import * as Home from "./routes/home.js";
|
|
929
|
+
|
|
930
|
+
const router = Router.match(Home.route, constant(Fx.succeed(Home.handler)));
|
|
931
|
+
export default router;
|
|
932
|
+
"
|
|
933
|
+
`);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it("golden: plain function handler", () => {
|
|
937
|
+
const source = buildRouterFromFixture({
|
|
938
|
+
"src/routes/page.ts": route("/", "export const handler = (p: unknown) => 1;"),
|
|
939
|
+
});
|
|
940
|
+
expect(source).toMatchInlineSnapshot(`
|
|
941
|
+
"import * as Router from "@typed/router";
|
|
942
|
+
import * as Fx from "@typed/fx/Fx";
|
|
943
|
+
import { constant } from "effect/Function";
|
|
944
|
+
import * as Page from "./routes/page.js";
|
|
945
|
+
|
|
946
|
+
const router = Router.match(Page.route, (params) => Fx.map(params, Page.handler));
|
|
947
|
+
export default router;
|
|
948
|
+
"
|
|
949
|
+
`);
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
it("golden: fx handler pass-through", () => {
|
|
953
|
+
const result = buildRouterFromFixture({
|
|
954
|
+
"src/routes/fx.ts": `import * as Fx from "@typed/fx/Fx"; ${routeExportForPath("/")} export const handler: Fx.Fx<number> = Fx.succeed(1);`,
|
|
955
|
+
});
|
|
956
|
+
expect(typeof result).toBe("string");
|
|
957
|
+
expect(result as string).toMatchInlineSnapshot(`
|
|
958
|
+
"import * as Router from "@typed/router";
|
|
959
|
+
import * as Fx from "@typed/fx/Fx";
|
|
960
|
+
import { constant } from "effect/Function";
|
|
961
|
+
import * as MFx from "./routes/fx.js";
|
|
962
|
+
|
|
963
|
+
const router = Router.match(MFx.route, constant(MFx.handler));
|
|
964
|
+
export default router;
|
|
965
|
+
"
|
|
966
|
+
`);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it("golden: effect handler pass-through", () => {
|
|
970
|
+
const result = buildRouterFromFixture({
|
|
971
|
+
"src/routes/effect.ts": `import * as Effect from "effect/Effect"; ${routeExportForPath("/")} export const handler: Effect.Effect<number> = Effect.succeed(1);`,
|
|
972
|
+
});
|
|
973
|
+
expect(typeof result).toBe("string");
|
|
974
|
+
expect(result as string).toMatchInlineSnapshot(`
|
|
975
|
+
"import * as Router from "@typed/router";
|
|
976
|
+
import * as Fx from "@typed/fx/Fx";
|
|
977
|
+
import { constant } from "effect/Function";
|
|
978
|
+
import * as MEffect from "./routes/effect.js";
|
|
979
|
+
|
|
980
|
+
const router = Router.match(MEffect.route, constant(Fx.fromEffect(MEffect.handler)));
|
|
981
|
+
export default router;
|
|
982
|
+
"
|
|
983
|
+
`);
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
it("golden: stream handler pass-through", () => {
|
|
987
|
+
const source = buildRouterFromFixture({
|
|
988
|
+
"src/routes/stream.ts": `import * as Stream from "effect/Stream"; ${routeExportForPath("/")} export const handler = Stream.succeed(1);`,
|
|
989
|
+
});
|
|
990
|
+
expect(source).toMatchInlineSnapshot(`
|
|
991
|
+
"import * as Router from "@typed/router";
|
|
992
|
+
import * as Fx from "@typed/fx/Fx";
|
|
993
|
+
import { constant } from "effect/Function";
|
|
994
|
+
import * as MStream from "./routes/stream.js";
|
|
995
|
+
|
|
996
|
+
const router = Router.match(MStream.route, constant(Fx.fromStream(MStream.handler)));
|
|
997
|
+
export default router;
|
|
998
|
+
"
|
|
999
|
+
`);
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("golden: template entrypoint", () => {
|
|
1003
|
+
const source = buildRouterFromFixture({
|
|
1004
|
+
"src/routes/template.ts": route("/", 'export const template = "<div/>";'),
|
|
1005
|
+
});
|
|
1006
|
+
expect(source).toMatchInlineSnapshot(`
|
|
1007
|
+
"import * as Router from "@typed/router";
|
|
1008
|
+
import * as Fx from "@typed/fx/Fx";
|
|
1009
|
+
import { constant } from "effect/Function";
|
|
1010
|
+
import * as Template from "./routes/template.js";
|
|
1011
|
+
|
|
1012
|
+
const router = Router.match(Template.route, constant(Fx.succeed(Template.template)));
|
|
1013
|
+
export default router;
|
|
1014
|
+
"
|
|
1015
|
+
`);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
it("golden: multiple routes at same level", () => {
|
|
1019
|
+
const source = buildRouterFromFixture({
|
|
1020
|
+
"src/routes/home.ts": route("/home", "export const handler = 1;"),
|
|
1021
|
+
"src/routes/about.ts": route("/about", "export const handler = 1;"),
|
|
1022
|
+
"src/routes/contact.ts": route("/contact", "export const handler = 1;"),
|
|
1023
|
+
});
|
|
1024
|
+
expect(source).toMatchInlineSnapshot(`
|
|
1025
|
+
"import * as Router from "@typed/router";
|
|
1026
|
+
import * as Fx from "@typed/fx/Fx";
|
|
1027
|
+
import { constant } from "effect/Function";
|
|
1028
|
+
import * as About from "./routes/about.js";
|
|
1029
|
+
import * as Contact from "./routes/contact.js";
|
|
1030
|
+
import * as Home from "./routes/home.js";
|
|
1031
|
+
|
|
1032
|
+
const router = Router.merge(
|
|
1033
|
+
Router.match(About.route, constant(Fx.succeed(About.handler))),
|
|
1034
|
+
Router.match(Contact.route, constant(Fx.succeed(Contact.handler))),
|
|
1035
|
+
Router.match(Home.route, constant(Fx.succeed(Home.handler)))
|
|
1036
|
+
);
|
|
1037
|
+
export default router;
|
|
1038
|
+
"
|
|
1039
|
+
`);
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
it("golden: nested routes", () => {
|
|
1043
|
+
const source = buildRouterFromFixture({
|
|
1044
|
+
"src/routes/users/index.ts": route("/users", "export const handler = 1;"),
|
|
1045
|
+
"src/routes/users/profile.ts": route("/users/profile", "export const handler = 1;"),
|
|
1046
|
+
"src/routes/users/[id].ts": route("/users/:id", "export const handler = 1;"),
|
|
1047
|
+
});
|
|
1048
|
+
expect(source).toMatchInlineSnapshot(`
|
|
1049
|
+
"import * as Router from "@typed/router";
|
|
1050
|
+
import * as Fx from "@typed/fx/Fx";
|
|
1051
|
+
import { constant } from "effect/Function";
|
|
1052
|
+
import * as UsersId from "./routes/users/[id].js";
|
|
1053
|
+
import * as UsersIndex from "./routes/users/index.js";
|
|
1054
|
+
import * as UsersProfile from "./routes/users/profile.js";
|
|
1055
|
+
|
|
1056
|
+
const router = Router.merge(
|
|
1057
|
+
Router.match(UsersId.route, constant(Fx.succeed(UsersId.handler))),
|
|
1058
|
+
Router.match(UsersIndex.route, constant(Fx.succeed(UsersIndex.handler))),
|
|
1059
|
+
Router.match(UsersProfile.route, constant(Fx.succeed(UsersProfile.handler)))
|
|
1060
|
+
);
|
|
1061
|
+
export default router;
|
|
1062
|
+
"
|
|
1063
|
+
`);
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
it("golden: nested Router.merge when multiple dir levels have multiple siblings", () => {
|
|
1067
|
+
const source = buildRouterFromFixture({
|
|
1068
|
+
"src/routes/page.ts": route("/", "export const handler = 1;"),
|
|
1069
|
+
"src/routes/about.ts": route("/about", "export const handler = 1;"),
|
|
1070
|
+
"src/routes/docs/index.ts": route("/docs", "export const handler = 1;"),
|
|
1071
|
+
"src/routes/docs/guide.ts": route("/docs/guide", "export const handler = 1;"),
|
|
1072
|
+
"src/routes/api/status.ts": route("/api/status", "export const handler = 1;"),
|
|
1073
|
+
"src/routes/api/users/index.ts": route("/api/users", "export const handler = 1;"),
|
|
1074
|
+
"src/routes/api/users/[id].ts": route("/api/users/:id", "export const handler = 1;"),
|
|
1075
|
+
});
|
|
1076
|
+
const s = source as string;
|
|
1077
|
+
expect(s).toContain("import * as Fx from");
|
|
1078
|
+
expect(s).not.toMatch(/\(Router\.merge\(/);
|
|
1079
|
+
const mergeCount = (s.match(/Router\.merge/g) ?? []).length;
|
|
1080
|
+
expect(mergeCount).toBeGreaterThanOrEqual(4);
|
|
1081
|
+
expect(s).toContain("Router.merge(");
|
|
1082
|
+
expect(s).toContain("Router.match(Page.");
|
|
1083
|
+
expect(s).toContain("Router.match(About.");
|
|
1084
|
+
expect(s).toContain("DocsIndex");
|
|
1085
|
+
expect(s).toContain("DocsGuide");
|
|
1086
|
+
expect(s).toContain("ApiStatus");
|
|
1087
|
+
expect(s).toContain("ApiUsersIndex");
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
it("golden: index route", () => {
|
|
1091
|
+
const source = buildRouterFromFixture({
|
|
1092
|
+
"src/routes/index.ts": route("/", "export const handler = 1;"),
|
|
1093
|
+
});
|
|
1094
|
+
expect(source).toMatchInlineSnapshot(`
|
|
1095
|
+
"import * as Router from "@typed/router";
|
|
1096
|
+
import * as Fx from "@typed/fx/Fx";
|
|
1097
|
+
import { constant } from "effect/Function";
|
|
1098
|
+
import * as Index from "./routes/index.js";
|
|
1099
|
+
|
|
1100
|
+
const router = Router.match(Index.route, constant(Fx.succeed(Index.handler)));
|
|
1101
|
+
export default router;
|
|
1102
|
+
"
|
|
1103
|
+
`);
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
it("golden: default entrypoint", () => {
|
|
1107
|
+
const source = buildRouterFromFixture({
|
|
1108
|
+
"src/routes/default.ts": `${routeExportForPath("/")} export default 1;`,
|
|
1109
|
+
});
|
|
1110
|
+
expect(source).toMatchInlineSnapshot(`
|
|
1111
|
+
"import * as Router from "@typed/router";
|
|
1112
|
+
import * as Fx from "@typed/fx/Fx";
|
|
1113
|
+
import { constant } from "effect/Function";
|
|
1114
|
+
import * as Default from "./routes/default.js";
|
|
1115
|
+
|
|
1116
|
+
const router = Router.match(Default.route, constant(Fx.succeed(Default.default)));
|
|
1117
|
+
export default router;
|
|
1118
|
+
"
|
|
1119
|
+
`);
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
it("golden: provide and layout order leaf to ancestor (chain: closest first)", () => {
|
|
1123
|
+
const source = buildRouterFromFixture({
|
|
1124
|
+
"src/routes/_dependencies.ts":
|
|
1125
|
+
"import * as Layer from 'effect/Layer'; export default Layer.empty;",
|
|
1126
|
+
"src/routes/api/_dependencies.ts":
|
|
1127
|
+
"import * as Layer from 'effect/Layer'; export default Layer.empty;",
|
|
1128
|
+
"src/routes/api/_layout.ts": "export const layout = (x: unknown) => x;",
|
|
1129
|
+
"src/routes/api/items/_layout.ts": "export const layout = (x: unknown) => x;",
|
|
1130
|
+
"src/routes/api/items/x.ts": route("/", "export const handler = 1;"),
|
|
1131
|
+
});
|
|
1132
|
+
expect(source).toMatchInlineSnapshot(`
|
|
1133
|
+
"import * as Router from "@typed/router";
|
|
1134
|
+
import * as Fx from "@typed/fx/Fx";
|
|
1135
|
+
import { constant } from "effect/Function";
|
|
1136
|
+
import * as ApiItemsX from "./routes/api/items/x.js";
|
|
1137
|
+
import * as ApiDependencies from "./routes/api/_dependencies.js";
|
|
1138
|
+
import * as Dependencies from "./routes/_dependencies.js";
|
|
1139
|
+
import * as ApiItemsLayout from "./routes/api/items/_layout.js";
|
|
1140
|
+
import * as ApiLayout from "./routes/api/_layout.js";
|
|
1141
|
+
|
|
1142
|
+
const router = Router.match(ApiItemsX.route, constant(Fx.succeed(ApiItemsX.handler))).layout(ApiItemsLayout.layout).layout(ApiLayout.layout).provide(ApiDependencies.default).provide(Dependencies.default);
|
|
1143
|
+
export default router;
|
|
1144
|
+
"
|
|
1145
|
+
`);
|
|
1146
|
+
});
|
|
1147
|
+
|
|
1148
|
+
it("allows routes with identical route type in different files (file-scoped identity, no RVM-AMBIGUOUS-001) (TS-9)", () => {
|
|
1149
|
+
const source = buildRouterFromFixture({
|
|
1150
|
+
"src/routes/shared.ts": routeExportForPath("/"),
|
|
1151
|
+
"src/routes/a.ts": `import { route } from "./shared"; export { route }; export const handler = 1;`,
|
|
1152
|
+
"src/routes/b.ts": `import { route } from "./shared"; export { route }; export const handler = 2;`,
|
|
1153
|
+
});
|
|
1154
|
+
expect(source).toMatchInlineSnapshot(`
|
|
1155
|
+
"import * as Router from "@typed/router";
|
|
1156
|
+
import * as Fx from "@typed/fx/Fx";
|
|
1157
|
+
import { constant } from "effect/Function";
|
|
1158
|
+
import * as A from "./routes/a.js";
|
|
1159
|
+
import * as B from "./routes/b.js";
|
|
1160
|
+
|
|
1161
|
+
const router = Router.merge(
|
|
1162
|
+
Router.match(A.route, constant(Fx.succeed(A.handler))),
|
|
1163
|
+
Router.match(B.route, constant(Fx.succeed(B.handler)))
|
|
1164
|
+
);
|
|
1165
|
+
export default router;
|
|
1166
|
+
"
|
|
1167
|
+
`);
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
it("multiple routes from shared route type are ordered by file path (T-10, TS-9)", () => {
|
|
1171
|
+
const source = buildRouterFromFixture({
|
|
1172
|
+
"src/routes/shared.ts": routeExportForPath("/"),
|
|
1173
|
+
"src/routes/a.ts": `import { route } from "./shared"; export { route }; export const handler = 1;`,
|
|
1174
|
+
"src/routes/b.ts": `import { route } from "./shared"; export { route }; export const handler = 2;`,
|
|
1175
|
+
});
|
|
1176
|
+
expect(source).toMatchInlineSnapshot(`
|
|
1177
|
+
"import * as Router from "@typed/router";
|
|
1178
|
+
import * as Fx from "@typed/fx/Fx";
|
|
1179
|
+
import { constant } from "effect/Function";
|
|
1180
|
+
import * as A from "./routes/a.js";
|
|
1181
|
+
import * as B from "./routes/b.js";
|
|
1182
|
+
|
|
1183
|
+
const router = Router.merge(
|
|
1184
|
+
Router.match(A.route, constant(Fx.succeed(A.handler))),
|
|
1185
|
+
Router.match(B.route, constant(Fx.succeed(B.handler)))
|
|
1186
|
+
);
|
|
1187
|
+
export default router;
|
|
1188
|
+
"
|
|
1189
|
+
`);
|
|
1190
|
+
});
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
describe("RouterVirtualModulePlugin integration", () => {
|
|
1194
|
+
it("resolves through PluginManager when target exists with valid routes (SG-C1)", () => {
|
|
1195
|
+
const fixture = createFixture({
|
|
1196
|
+
"src/routes/home.ts": route("/", "export const handler = 1;"),
|
|
1197
|
+
});
|
|
1198
|
+
const program = makeProgram(
|
|
1199
|
+
existsSync(BOOTSTRAP_FILE) ? [...fixture.paths, BOOTSTRAP_FILE] : fixture.paths,
|
|
1200
|
+
APP_ROOT,
|
|
1201
|
+
);
|
|
1202
|
+
const sessionFactory = () =>
|
|
1203
|
+
createTypeInfoApiSession({
|
|
1204
|
+
ts,
|
|
1205
|
+
program,
|
|
1206
|
+
typeTargetSpecs: ROUTER_TYPE_TARGET_SPECS,
|
|
1207
|
+
});
|
|
1208
|
+
const manager = new PluginManager([createRouterVirtualModulePlugin()]);
|
|
1209
|
+
|
|
1210
|
+
const resolved = manager.resolveModule({
|
|
1211
|
+
id: "router:./routes",
|
|
1212
|
+
importer: fixture.importer,
|
|
1213
|
+
createTypeInfoApiSession: sessionFactory,
|
|
1214
|
+
});
|
|
1215
|
+
|
|
1216
|
+
expect(resolved.status).toBe("resolved");
|
|
1217
|
+
if (resolved.status !== "resolved") return;
|
|
1218
|
+
expect(resolved.pluginName).toBe("router-virtual-module");
|
|
1219
|
+
expect(resolved.sourceText).toContain("Router.match");
|
|
1220
|
+
expect(resolved.sourceText).toContain("export default ");
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
it("returns unresolved through PluginManager when id does not match (T-09)", () => {
|
|
1224
|
+
const { importer } = createFixture({});
|
|
1225
|
+
const manager = new PluginManager([createRouterVirtualModulePlugin()]);
|
|
1226
|
+
const resolved = manager.resolveModule({ id: "other:something", importer });
|
|
1227
|
+
expect(resolved.status).toBe("unresolved");
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
it("returns unresolved through PluginManager when target directory has no .ts files (T-09)", () => {
|
|
1231
|
+
const { importer } = createFixture({ "src/routes/readme.txt": "no ts" });
|
|
1232
|
+
const manager = new PluginManager([createRouterVirtualModulePlugin()]);
|
|
1233
|
+
const resolved = manager.resolveModule({ id: "router:./routes", importer });
|
|
1234
|
+
expect(resolved.status).toBe("unresolved");
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
it("returns error when build throws (invalid routes)", () => {
|
|
1238
|
+
const fixture = createFixture({ "src/routes/bad.ts": "export const handler = 1;" });
|
|
1239
|
+
const program = makeProgram(fixture.paths);
|
|
1240
|
+
const sessionFactory = () => createTypeInfoApiSession({ ts, program });
|
|
1241
|
+
const manager = new PluginManager([createRouterVirtualModulePlugin()]);
|
|
1242
|
+
|
|
1243
|
+
const resolved = manager.resolveModule({
|
|
1244
|
+
id: "router:./routes",
|
|
1245
|
+
importer: fixture.importer,
|
|
1246
|
+
createTypeInfoApiSession: sessionFactory,
|
|
1247
|
+
});
|
|
1248
|
+
|
|
1249
|
+
expect(resolved.status).toBe("error");
|
|
1250
|
+
if (resolved.status !== "error") return;
|
|
1251
|
+
expect(resolved.diagnostic.code).toBe("RVM-ROUTE-001");
|
|
1252
|
+
expect(resolved.diagnostic.message).toContain('missing "route" export');
|
|
1253
|
+
});
|
|
1254
|
+
});
|