@gaganref/confect-test 0.0.0-stage → 10.3.1-fork.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.
@@ -0,0 +1,422 @@
1
+ import { App, Ref } from "@confect/core";
2
+ import type { DatabaseSchema, DataModel } from "@confect/server";
3
+ import { RegisteredConvexFunction } from "@confect/server";
4
+ import type {
5
+ TestConvexForDataModel,
6
+ TestConvexForDataModelAndIdentity,
7
+ } from "convex-test";
8
+ import { convexTest } from "convex-test";
9
+ import type {
10
+ GenericDataModel,
11
+ GenericMutationCtx,
12
+ GenericSchema,
13
+ SchemaDefinition,
14
+ UserIdentity,
15
+ } from "convex/server";
16
+ import type { Value } from "convex/values";
17
+ import * as Context from "effect/Context";
18
+ import * as Effect from "effect/Effect";
19
+ import * as Layer from "effect/Layer";
20
+ import * as Schema from "effect/Schema";
21
+ import * as MountedHttp from "./internal/MountedHttp";
22
+
23
+ /**
24
+ * Register a component's Convex schema and modules at its installation path.
25
+ */
26
+ export type RegisterComponent =
27
+ TestConvexForDataModelAndIdentity<GenericDataModel>["registerComponent"];
28
+
29
+ /**
30
+ * An installed component path and the HTTP prefix exposed by its host.
31
+ */
32
+ export type HttpMount = {
33
+ readonly componentPath: string;
34
+ readonly httpPrefix: string;
35
+ };
36
+
37
+ export type LayerOptions = {
38
+ /**
39
+ * Register component kits synchronously before the test service is available.
40
+ */
41
+ readonly register?: (registerComponent: RegisterComponent) => void;
42
+ /**
43
+ * Explicit HTTP mounts. The test layer does not evaluate convex.config.ts.
44
+ */
45
+ readonly httpMounts?: ReadonlyArray<HttpMount>;
46
+ };
47
+
48
+ export type TestConfectWithoutIdentity<
49
+ ConfectSchema extends DatabaseSchema.AnyWithProps,
50
+ > = {
51
+ query: <QueryRef extends Ref.AnyQuery>(
52
+ queryRef: QueryRef,
53
+ ...args: Ref.OptionalArgs<QueryRef>
54
+ ) => Effect.Effect<
55
+ Ref.Returns<QueryRef>,
56
+ Ref.Error<QueryRef> | Schema.SchemaError
57
+ >;
58
+ mutation: <MutationRef extends Ref.AnyMutation>(
59
+ mutationRef: MutationRef,
60
+ ...args: Ref.OptionalArgs<MutationRef>
61
+ ) => Effect.Effect<
62
+ Ref.Returns<MutationRef>,
63
+ Ref.Error<MutationRef> | Schema.SchemaError
64
+ >;
65
+ action: <ActionRef extends Ref.AnyAction>(
66
+ actionRef: ActionRef,
67
+ ...args: Ref.OptionalArgs<ActionRef>
68
+ ) => Effect.Effect<
69
+ Ref.Returns<ActionRef>,
70
+ Ref.Error<ActionRef> | Schema.SchemaError
71
+ >;
72
+ run: {
73
+ <E>(
74
+ handler: Effect.Effect<
75
+ void,
76
+ E,
77
+ RegisteredConvexFunction.MutationServices<ConfectSchema>
78
+ >,
79
+ ): Effect.Effect<void>;
80
+ <A, B extends Value, E>(
81
+ handler: Effect.Effect<
82
+ A,
83
+ E,
84
+ RegisteredConvexFunction.MutationServices<ConfectSchema>
85
+ >,
86
+ returns: Schema.Codec<A, B>,
87
+ ): Effect.Effect<A, Schema.SchemaError>;
88
+ };
89
+ fetch: (
90
+ pathQueryFragment: string,
91
+ init?: RequestInit,
92
+ ) => Effect.Effect<Response>;
93
+ finishInProgressScheduledFunctions: () => Effect.Effect<void>;
94
+ finishAllScheduledFunctions: (
95
+ advanceTimers: () => void,
96
+ ) => Effect.Effect<void>;
97
+ };
98
+
99
+ export type TestConfect<ConfectSchema extends DatabaseSchema.AnyWithProps> = {
100
+ registerComponent: (
101
+ componentPath: string,
102
+ schema: SchemaDefinition<GenericSchema, boolean>,
103
+ modules: Record<string, () => Promise<any>>,
104
+ ) => Effect.Effect<void>;
105
+ withIdentity: (
106
+ userIdentity: Partial<UserIdentity>,
107
+ ) => TestConfectWithoutIdentity<ConfectSchema>;
108
+ } & TestConfectWithoutIdentity<ConfectSchema>;
109
+
110
+ export const TestConfect = <
111
+ ConfectSchema extends DatabaseSchema.AnyWithProps,
112
+ >() => Context.Service<TestConfect<ConfectSchema>>("@confect/test/TestConfect");
113
+
114
+ class TestConfectImplWithoutIdentity<
115
+ ConfectSchema extends DatabaseSchema.AnyWithProps,
116
+ > implements TestConfectWithoutIdentity<ConfectSchema> {
117
+ constructor(
118
+ private confectSchema: ConfectSchema,
119
+ private testConvex: TestConvexForDataModel<
120
+ DataModel.ToConvex<DataModel.FromSchema<ConfectSchema>>
121
+ >,
122
+ private mountedHttp: ReturnType<typeof MountedHttp.make>,
123
+ ) {}
124
+
125
+ readonly query = <QueryRef extends Ref.AnyQuery>(
126
+ queryRef: QueryRef,
127
+ ...args: Ref.OptionalArgs<QueryRef>
128
+ ): Effect.Effect<
129
+ Ref.Returns<QueryRef>,
130
+ Ref.Error<QueryRef> | Schema.SchemaError
131
+ > =>
132
+ Ref.runWithCodec(
133
+ queryRef,
134
+ (args[0] ?? {}) as Ref.Args<QueryRef>,
135
+ (functionReference, encodedArgs) =>
136
+ this.testConvex.query(functionReference as any, encodedArgs),
137
+ );
138
+
139
+ readonly mutation = <MutationRef extends Ref.AnyMutation>(
140
+ mutationRef: MutationRef,
141
+ ...args: Ref.OptionalArgs<MutationRef>
142
+ ): Effect.Effect<
143
+ Ref.Returns<MutationRef>,
144
+ Ref.Error<MutationRef> | Schema.SchemaError
145
+ > =>
146
+ Ref.runWithCodec(
147
+ mutationRef,
148
+ (args[0] ?? {}) as Ref.Args<MutationRef>,
149
+ (functionReference, encodedArgs) =>
150
+ this.testConvex.mutation(functionReference as any, encodedArgs),
151
+ );
152
+
153
+ readonly action = <ActionRef extends Ref.AnyAction>(
154
+ actionRef: ActionRef,
155
+ ...args: Ref.OptionalArgs<ActionRef>
156
+ ): Effect.Effect<
157
+ Ref.Returns<ActionRef>,
158
+ Ref.Error<ActionRef> | Schema.SchemaError
159
+ > =>
160
+ Ref.runWithCodec(
161
+ actionRef,
162
+ (args[0] ?? {}) as Ref.Args<ActionRef>,
163
+ (functionReference, encodedArgs) =>
164
+ this.testConvex.action(functionReference as any, encodedArgs),
165
+ );
166
+
167
+ readonly run: TestConfectWithoutIdentity<ConfectSchema>["run"] = (<
168
+ A,
169
+ B extends Value,
170
+ E,
171
+ >(
172
+ handler: Effect.Effect<
173
+ A,
174
+ E,
175
+ RegisteredConvexFunction.MutationServices<ConfectSchema>
176
+ >,
177
+ returns?: Schema.Codec<A, B>,
178
+ ): Effect.Effect<void> | Effect.Effect<A, Schema.SchemaError> => {
179
+ const makeMutationLayer = (
180
+ mutationCtx: GenericMutationCtx<
181
+ DataModel.ToConvex<DataModel.FromSchema<ConfectSchema>>
182
+ >,
183
+ ): Layer.Layer<RegisteredConvexFunction.MutationServices<ConfectSchema>> =>
184
+ RegisteredConvexFunction.mutationLayer(
185
+ this.confectSchema,
186
+ mutationCtx,
187
+ ) as Layer.Layer<
188
+ RegisteredConvexFunction.MutationServices<ConfectSchema>
189
+ >;
190
+
191
+ return returns === undefined
192
+ ? Effect.promise(() =>
193
+ this.testConvex.run((mutationCtx) =>
194
+ Effect.runPromise(
195
+ handler.pipe(
196
+ Effect.asVoid,
197
+ Effect.provide(makeMutationLayer(mutationCtx)),
198
+ ),
199
+ ),
200
+ ),
201
+ )
202
+ : Effect.promise(() =>
203
+ this.testConvex.run((mutationCtx) =>
204
+ Effect.runPromise(
205
+ handler.pipe(
206
+ Effect.andThen(Schema.encodeEffect(returns)),
207
+ Effect.provide(makeMutationLayer(mutationCtx)),
208
+ ),
209
+ ),
210
+ ),
211
+ ).pipe(Effect.andThen(Schema.decodeEffect(returns)));
212
+ }) as TestConfectWithoutIdentity<ConfectSchema>["run"];
213
+
214
+ readonly fetch = <PathQueryFragment extends string>(
215
+ pathQueryFragment: PathQueryFragment,
216
+ init?: RequestInit,
217
+ ) =>
218
+ Effect.promise(() =>
219
+ this.mountedHttp.fetch(this.testConvex, pathQueryFragment, init),
220
+ );
221
+
222
+ readonly finishInProgressScheduledFunctions = () =>
223
+ Effect.promise(() => this.testConvex.finishInProgressScheduledFunctions());
224
+
225
+ readonly finishAllScheduledFunctions = (advanceTimers: () => void) =>
226
+ Effect.promise(() =>
227
+ this.testConvex.finishAllScheduledFunctions(advanceTimers),
228
+ );
229
+ }
230
+
231
+ class TestConfectImpl<
232
+ ConfectSchema extends DatabaseSchema.AnyWithProps,
233
+ > implements TestConfect<ConfectSchema> {
234
+ private readonly testConfectImplWithoutIdentity: TestConfectImplWithoutIdentity<ConfectSchema>;
235
+
236
+ constructor(
237
+ private confectSchema: ConfectSchema,
238
+ private testConvex: TestConvexForDataModelAndIdentity<
239
+ DataModel.ToConvex<DataModel.FromSchema<ConfectSchema>>
240
+ >,
241
+ private mountedHttp: ReturnType<typeof MountedHttp.make>,
242
+ ) {
243
+ this.testConfectImplWithoutIdentity = new TestConfectImplWithoutIdentity(
244
+ confectSchema,
245
+ testConvex,
246
+ mountedHttp,
247
+ );
248
+ }
249
+
250
+ readonly withIdentity = (userIdentity: Partial<UserIdentity>) =>
251
+ new TestConfectImplWithoutIdentity(
252
+ this.confectSchema,
253
+ this.testConvex.withIdentity(userIdentity),
254
+ this.mountedHttp,
255
+ );
256
+
257
+ readonly registerComponent: TestConfect<ConfectSchema>["registerComponent"] =
258
+ Effect.fn("TestConfect.registerComponent")(
259
+ (componentPath, schema, modules) =>
260
+ Effect.sync(() =>
261
+ this.mountedHttp.register(componentPath, schema, modules),
262
+ ),
263
+ );
264
+
265
+ readonly query = <QueryRef extends Ref.AnyQuery>(
266
+ queryRef: QueryRef,
267
+ ...args: Ref.OptionalArgs<QueryRef>
268
+ ) => this.testConfectImplWithoutIdentity.query(queryRef, ...args);
269
+
270
+ readonly mutation = <MutationRef extends Ref.AnyMutation>(
271
+ mutationRef: MutationRef,
272
+ ...args: Ref.OptionalArgs<MutationRef>
273
+ ) => this.testConfectImplWithoutIdentity.mutation(mutationRef, ...args);
274
+
275
+ readonly action = <ActionRef extends Ref.AnyAction>(
276
+ actionRef: ActionRef,
277
+ ...args: Ref.OptionalArgs<ActionRef>
278
+ ) => this.testConfectImplWithoutIdentity.action(actionRef, ...args);
279
+
280
+ readonly run: TestConfect<ConfectSchema>["run"] = ((
281
+ handler: any,
282
+ returns?: any,
283
+ ) =>
284
+ this.testConfectImplWithoutIdentity.run(
285
+ handler,
286
+ returns,
287
+ )) as TestConfect<ConfectSchema>["run"];
288
+
289
+ readonly fetch = <PathQueryFragment extends string>(
290
+ pathQueryFragment: PathQueryFragment,
291
+ init?: RequestInit,
292
+ ) => this.testConfectImplWithoutIdentity.fetch(pathQueryFragment, init);
293
+
294
+ readonly finishInProgressScheduledFunctions = () =>
295
+ this.testConfectImplWithoutIdentity.finishInProgressScheduledFunctions();
296
+
297
+ readonly finishAllScheduledFunctions = (advanceTimers: () => void) =>
298
+ this.testConfectImplWithoutIdentity.finishAllScheduledFunctions(
299
+ advanceTimers,
300
+ );
301
+ }
302
+
303
+ /**
304
+ * Codegen's test-only schema and module registry.
305
+ */
306
+ export type Registry<DatabaseSchema_ extends DatabaseSchema.AnyWithProps> = {
307
+ readonly schema: DatabaseSchema_;
308
+ readonly convexSchema: SchemaDefinition<GenericSchema, true>;
309
+ readonly modules: Record<string, () => Promise<any>>;
310
+ readonly components: Readonly<
311
+ Record<
312
+ string,
313
+ {
314
+ readonly schema: SchemaDefinition<GenericSchema, boolean>;
315
+ readonly modules: Record<string, () => Promise<any>>;
316
+ }
317
+ >
318
+ >;
319
+ };
320
+
321
+ /**
322
+ * The declared app's generated, test-only representation.
323
+ */
324
+ export type DeclaredApp<DatabaseSchema_ extends DatabaseSchema.AnyWithProps> =
325
+ Registry<DatabaseSchema_> & {
326
+ readonly httpMounts: ReadonlyArray<HttpMount>;
327
+ };
328
+
329
+ /**
330
+ * Build a fresh test backend from a declaration and its generated
331
+ * `confect/_generated/testing` registry, without restating component mounts.
332
+ */
333
+ export function layer<DatabaseSchema_ extends DatabaseSchema.AnyWithProps>(
334
+ app: DeclaredApp<DatabaseSchema_>,
335
+ ): Layer.Layer<TestConfect<DatabaseSchema_>>;
336
+ export function layer<
337
+ A extends App.Any,
338
+ DatabaseSchema_ extends DatabaseSchema.AnyWithProps,
339
+ >(
340
+ app: A,
341
+ registry: Registry<DatabaseSchema_> & {
342
+ readonly components: Readonly<
343
+ Record<
344
+ keyof NoInfer<A>["mounts"],
345
+ Registry<DatabaseSchema_>["components"][string]
346
+ >
347
+ >;
348
+ },
349
+ ): Layer.Layer<TestConfect<DatabaseSchema_>>;
350
+ export function layer<DatabaseSchema_ extends DatabaseSchema.AnyWithProps>(
351
+ databaseSchema: DatabaseSchema_,
352
+ convexSchemaDefinition: SchemaDefinition<GenericSchema, true>,
353
+ modules: Record<string, () => Promise<any>>,
354
+ options?: LayerOptions,
355
+ ): Layer.Layer<TestConfect<DatabaseSchema_>>;
356
+ export function layer<DatabaseSchema_ extends DatabaseSchema.AnyWithProps>(
357
+ appOrSchema: App.Any | DeclaredApp<DatabaseSchema_> | DatabaseSchema_,
358
+ registryOrSchema?:
359
+ | Registry<DatabaseSchema_>
360
+ | SchemaDefinition<GenericSchema, true>,
361
+ modules?: Record<string, () => Promise<any>>,
362
+ options: LayerOptions = {},
363
+ ): Layer.Layer<TestConfect<DatabaseSchema_>> {
364
+ if (App.isApp(appOrSchema)) {
365
+ const registry = registryOrSchema as Registry<DatabaseSchema_>;
366
+ const mounts = App.mounts(appOrSchema);
367
+ return schemaLayer(
368
+ registry.schema,
369
+ registry.convexSchema,
370
+ registry.modules,
371
+ {
372
+ httpMounts: mounts.flatMap(({ name, httpPrefix }) =>
373
+ httpPrefix === undefined ? [] : [{ componentPath: name, httpPrefix }],
374
+ ),
375
+ register: (registerComponent) => {
376
+ for (const [name, component] of Object.entries(registry.components)) {
377
+ if (
378
+ mounts.some(
379
+ (mount) =>
380
+ name === mount.name || name.startsWith(`${mount.name}/`),
381
+ )
382
+ ) {
383
+ registerComponent(name, component.schema, component.modules);
384
+ }
385
+ }
386
+ },
387
+ },
388
+ );
389
+ }
390
+ if ("httpMounts" in appOrSchema) {
391
+ const app = appOrSchema as DeclaredApp<DatabaseSchema_>;
392
+ return schemaLayer(app.schema, app.convexSchema, app.modules, {
393
+ httpMounts: app.httpMounts,
394
+ register: (registerComponent) => {
395
+ for (const [name, component] of Object.entries(app.components)) {
396
+ registerComponent(name, component.schema, component.modules);
397
+ }
398
+ },
399
+ });
400
+ }
401
+ return schemaLayer(
402
+ appOrSchema as DatabaseSchema_,
403
+ registryOrSchema as SchemaDefinition<GenericSchema, true>,
404
+ modules!,
405
+ options,
406
+ );
407
+ }
408
+
409
+ const schemaLayer = <DatabaseSchema_ extends DatabaseSchema.AnyWithProps>(
410
+ databaseSchema: DatabaseSchema_,
411
+ convexSchemaDefinition: SchemaDefinition<GenericSchema, true>,
412
+ modules: Record<string, () => Promise<any>>,
413
+ options: LayerOptions = {},
414
+ ): Layer.Layer<TestConfect<DatabaseSchema_>> =>
415
+ Layer.sync(TestConfect<DatabaseSchema_>(), () => {
416
+ const testConvex = convexTest(convexSchemaDefinition, modules);
417
+ const mountedHttp = MountedHttp.make(options.httpMounts ?? [], (...args) =>
418
+ testConvex.registerComponent(...args),
419
+ );
420
+ options.register?.(mountedHttp.register);
421
+ return new TestConfectImpl(databaseSchema, testConvex as any, mountedHttp);
422
+ });
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * as TestConfect from "./TestConfect";
@@ -0,0 +1,266 @@
1
+ /* oxlint-disable effecttsgo/async-function -- This convex-test adapter uses Promise callbacks and temporarily overrides the real process environment consumed by HTTP module construction. */
2
+ import type { TestConvexForDataModel } from "convex-test";
3
+ import type {
4
+ GenericDataModel,
5
+ HttpRouter,
6
+ FunctionReference,
7
+ RoutableMethod,
8
+ PublicHttpAction,
9
+ } from "convex/server";
10
+ import { actionGeneric } from "convex/server";
11
+ import * as Result from "effect/Result";
12
+ import * as Schema from "effect/Schema";
13
+ import type { HttpMount, RegisterComponent } from "../TestConfect";
14
+
15
+ declare const process: { env: Record<string, string | undefined> };
16
+
17
+ class InvalidHttpMount extends Schema.TaggedError<InvalidHttpMount>()(
18
+ "InvalidHttpMount",
19
+ {
20
+ componentPath: Schema.String,
21
+ httpPrefix: Schema.String,
22
+ message: Schema.String,
23
+ },
24
+ ) {}
25
+
26
+ class ComponentHttpError extends Schema.TaggedError<ComponentHttpError>()(
27
+ "ComponentHttpError",
28
+ { componentPath: Schema.String, message: Schema.String },
29
+ ) {}
30
+
31
+ class InvalidFetchPath extends Schema.TaggedError<InvalidFetchPath>()(
32
+ "InvalidFetchPath",
33
+ { path: Schema.String, message: Schema.String },
34
+ ) {}
35
+
36
+ type NormalizedMount = {
37
+ readonly componentPath: string;
38
+ readonly prefix: string;
39
+ };
40
+
41
+ const normalizeMounts = (mounts: ReadonlyArray<HttpMount>) => {
42
+ const normalized: Array<NormalizedMount> = [];
43
+ for (const mount of mounts) {
44
+ if (!mount.httpPrefix.startsWith("/") || /[?#]/.test(mount.httpPrefix)) {
45
+ return Result.fail(
46
+ new InvalidHttpMount({
47
+ ...mount,
48
+ message:
49
+ "httpPrefix must be an absolute path without a query or fragment.",
50
+ }),
51
+ );
52
+ }
53
+ const prefix = mount.httpPrefix.replace(/\/+$/, "");
54
+ if (normalized.some((previous) => previous.prefix === prefix)) {
55
+ return Result.fail(
56
+ new InvalidHttpMount({
57
+ ...mount,
58
+ message: "Each httpPrefix must select one component installation.",
59
+ }),
60
+ );
61
+ }
62
+ normalized.push({ componentPath: mount.componentPath, prefix });
63
+ }
64
+ return Result.succeed(
65
+ normalized.sort((a, b) => b.prefix.length - a.prefix.length),
66
+ );
67
+ };
68
+
69
+ type HttpRequest = {
70
+ siteUrl: string;
71
+ url: string;
72
+ pathname: string;
73
+ method: string;
74
+ headers: Array<[string, string]>;
75
+ body: ArrayBuffer | null;
76
+ };
77
+
78
+ type HttpResponse = {
79
+ status: number;
80
+ statusText: string;
81
+ headers: Array<[string, string]>;
82
+ body: ArrayBuffer;
83
+ };
84
+
85
+ // Convex omits this runtime entry point from its published declarations.
86
+ const isInvokable = (
87
+ action: PublicHttpAction,
88
+ ): action is PublicHttpAction & {
89
+ invokeHttpAction(request: Request): Promise<Response>;
90
+ } =>
91
+ "invokeHttpAction" in action && typeof action.invokeHttpAction === "function";
92
+
93
+ const bridgeModule = "__confect_test_http";
94
+ const defaultSiteUrl = "https://some.convex.site";
95
+
96
+ export const make = (
97
+ mounts: ReadonlyArray<HttpMount>,
98
+ registerComponent: RegisterComponent,
99
+ ) => {
100
+ const normalized = normalizeMounts(mounts);
101
+ if (Result.isFailure(normalized)) throw normalized.failure;
102
+ const registered = new Set<string>();
103
+ let tail = Promise.resolve();
104
+
105
+ const register: RegisterComponent = (componentPath, schema, modules) => {
106
+ if (
107
+ !normalized.success.some((mount) => mount.componentPath === componentPath)
108
+ ) {
109
+ registerComponent(componentPath, schema, modules);
110
+ return;
111
+ }
112
+ const generated = Object.keys(modules).find((key) =>
113
+ key.includes("_generated"),
114
+ );
115
+ const root = generated?.split("_generated", 2)[0];
116
+ const moduleLoaders = Object.fromEntries(
117
+ Object.entries(modules).map(([path, load]) => [
118
+ path.replace(/\.[^.]+$/, ""),
119
+ load,
120
+ ]),
121
+ );
122
+ const loadHttp =
123
+ root === undefined ? undefined : moduleLoaders[`${root}http`];
124
+ if (root === undefined || loadHttp === undefined) {
125
+ throw new ComponentHttpError({
126
+ componentPath,
127
+ message:
128
+ "Mounted components need an HTTP module and Convex _generated modules.",
129
+ });
130
+ }
131
+ const bridgePath = `${root}${bridgeModule}.ts`;
132
+ if (`${root}${bridgeModule}` in moduleLoaders) {
133
+ throw new ComponentHttpError({
134
+ componentPath,
135
+ message: `${bridgeModule} is reserved for mounted HTTP tests.`,
136
+ });
137
+ }
138
+ const routers = new Map<string, Promise<HttpRouter>>();
139
+ const invoke = actionGeneric({
140
+ handler: async (_ctx, args: HttpRequest): Promise<HttpResponse> => {
141
+ const previousSiteUrl = process.env.CONVEX_SITE_URL;
142
+ try {
143
+ process.env.CONVEX_SITE_URL = args.siteUrl;
144
+ let router = routers.get(args.siteUrl);
145
+ if (router === undefined) {
146
+ router = loadHttp().then(
147
+ (module: { default: HttpRouter }) => module.default,
148
+ );
149
+ routers.set(args.siteUrl, router);
150
+ }
151
+ const route = (await router).lookup(
152
+ args.pathname,
153
+ args.method as RoutableMethod,
154
+ );
155
+ let response: Response;
156
+ if (route === null) {
157
+ response = new Response(
158
+ `No HttpAction routed for ${args.pathname}`,
159
+ { status: 404 },
160
+ );
161
+ } else {
162
+ const action = route[0];
163
+ if (!isInvokable(action)) {
164
+ throw new ComponentHttpError({
165
+ componentPath,
166
+ message:
167
+ "This Convex version does not expose invokeHttpAction for HTTP tests.",
168
+ });
169
+ }
170
+ // Convex selects on the relative path and passes the full URL to the handler.
171
+ response = await action.invokeHttpAction(
172
+ new Request(args.url, {
173
+ method: args.method,
174
+ headers: args.headers,
175
+ ...(args.body === null ? {} : { body: args.body }),
176
+ }),
177
+ );
178
+ }
179
+ return {
180
+ status: response.status,
181
+ statusText: response.statusText,
182
+ headers: [...response.headers],
183
+ body: await response.arrayBuffer(),
184
+ };
185
+ } finally {
186
+ if (previousSiteUrl === undefined) delete process.env.CONVEX_SITE_URL;
187
+ else process.env.CONVEX_SITE_URL = previousSiteUrl;
188
+ }
189
+ },
190
+ });
191
+ registerComponent(componentPath, schema, {
192
+ ...modules,
193
+ [bridgePath]: () => Promise.resolve({ invoke }),
194
+ });
195
+ registered.add(componentPath);
196
+ };
197
+
198
+ const fetch = <DataModel extends GenericDataModel>(
199
+ accessor: TestConvexForDataModel<DataModel>,
200
+ path: string,
201
+ init?: RequestInit,
202
+ ): Promise<Response> => {
203
+ if (normalized.success.length === 0) return accessor.fetch(path, init);
204
+ const run = async () => {
205
+ if (!path.startsWith("/")) {
206
+ throw new InvalidFetchPath({
207
+ path,
208
+ message: 'Path given to fetch must start with a "/".',
209
+ });
210
+ }
211
+ const pathname = new URL(`${defaultSiteUrl}${path}`).pathname;
212
+ const mount = normalized.success.find(
213
+ ({ prefix }) =>
214
+ pathname === prefix || pathname.startsWith(`${prefix}/`),
215
+ );
216
+ if (mount === undefined) return accessor.fetch(path, init);
217
+ if (!registered.has(mount.componentPath)) {
218
+ throw new ComponentHttpError({
219
+ componentPath: mount.componentPath,
220
+ message: "Register the mounted component before calling fetch.",
221
+ });
222
+ }
223
+ const appSiteUrl = (
224
+ process.env.CONVEX_SITE_URL || defaultSiteUrl
225
+ ).replace(/\/$/, "");
226
+ const request = new Request(new URL(`${appSiteUrl}${path}`), init);
227
+ // convex-test encodes exact component paths in test-only function handles.
228
+ const reference =
229
+ `function://${mount.componentPath};${bridgeModule}:invoke` as unknown as FunctionReference<
230
+ "action",
231
+ "public",
232
+ HttpRequest,
233
+ HttpResponse
234
+ >;
235
+ const response = await accessor.action(reference, {
236
+ siteUrl: `${appSiteUrl}${mount.prefix}`,
237
+ url: request.url,
238
+ pathname: pathname.slice(mount.prefix.length) || "/",
239
+ method: request.method,
240
+ headers: [...request.headers],
241
+ body:
242
+ request.method === "GET" || request.method === "HEAD"
243
+ ? null
244
+ : await request.arrayBuffer(),
245
+ });
246
+ return new Response(
247
+ request.method === "HEAD" || [204, 205, 304].includes(response.status)
248
+ ? null
249
+ : response.body,
250
+ {
251
+ status: response.status,
252
+ statusText: response.statusText,
253
+ headers: response.headers,
254
+ },
255
+ );
256
+ };
257
+ const result = tail.then(run);
258
+ tail = result.then(
259
+ () => undefined,
260
+ () => undefined,
261
+ );
262
+ return result;
263
+ };
264
+
265
+ return { register, fetch };
266
+ };