@ereo/testing 0.1.6

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/dist/index.js ADDED
@@ -0,0 +1,658 @@
1
+ // @bun
2
+ var __require = import.meta.require;
3
+
4
+ // src/context.ts
5
+ function createTestContext(options = {}) {
6
+ const store = new Map(Object.entries(options.store || {}));
7
+ const env = { ...options.env };
8
+ const responseHeaders = new Headers(options.responseHeaders);
9
+ const url = typeof options.url === "string" ? new URL(options.url) : options.url || new URL("http://localhost:3000/");
10
+ let cacheOptions;
11
+ const cacheTags = new Set(options.cacheTags || []);
12
+ const cacheOperations = [];
13
+ const cache = {
14
+ set(opts) {
15
+ cacheOptions = opts;
16
+ if (opts.tags) {
17
+ opts.tags.forEach((tag) => cacheTags.add(tag));
18
+ }
19
+ cacheOperations.push({
20
+ type: "set",
21
+ options: { ...opts },
22
+ timestamp: Date.now()
23
+ });
24
+ },
25
+ get() {
26
+ cacheOperations.push({
27
+ type: "get",
28
+ timestamp: Date.now()
29
+ });
30
+ return cacheOptions;
31
+ },
32
+ getTags() {
33
+ return Array.from(cacheTags);
34
+ }
35
+ };
36
+ const context = {
37
+ cache,
38
+ get(key) {
39
+ return store.get(key);
40
+ },
41
+ set(key, value) {
42
+ store.set(key, value);
43
+ },
44
+ responseHeaders,
45
+ url,
46
+ env,
47
+ getStore() {
48
+ return Object.fromEntries(store);
49
+ },
50
+ getCacheOperations() {
51
+ return [...cacheOperations];
52
+ },
53
+ reset() {
54
+ store.clear();
55
+ Object.entries(options.store || {}).forEach(([k, v]) => store.set(k, v));
56
+ cacheOptions = undefined;
57
+ cacheTags.clear();
58
+ (options.cacheTags || []).forEach((tag) => cacheTags.add(tag));
59
+ cacheOperations.length = 0;
60
+ responseHeaders.forEach((_, key) => responseHeaders.delete(key));
61
+ Object.entries(options.responseHeaders || {}).forEach(([k, v]) => responseHeaders.set(k, v));
62
+ }
63
+ };
64
+ return context;
65
+ }
66
+ function createContextFactory(baseOptions = {}) {
67
+ return (overrides = {}) => createTestContext({
68
+ ...baseOptions,
69
+ ...overrides,
70
+ store: { ...baseOptions.store, ...overrides.store },
71
+ env: { ...baseOptions.env, ...overrides.env },
72
+ responseHeaders: {
73
+ ...baseOptions.responseHeaders,
74
+ ...overrides.responseHeaders
75
+ },
76
+ cacheTags: [
77
+ ...baseOptions.cacheTags || [],
78
+ ...overrides.cacheTags || []
79
+ ]
80
+ });
81
+ }
82
+ // src/request.ts
83
+ function createMockRequest(url, options) {
84
+ if (typeof url === "string") {
85
+ options = { ...options, url };
86
+ } else if (url) {
87
+ options = url;
88
+ } else {
89
+ options = options || {};
90
+ }
91
+ return createMockRequestImpl(options);
92
+ }
93
+ function createMockRequestImpl(options = {}) {
94
+ const {
95
+ method = "GET",
96
+ url = "/",
97
+ headers = {},
98
+ body,
99
+ searchParams,
100
+ formData,
101
+ cookies
102
+ } = options;
103
+ let requestUrl = url.startsWith("http") ? url : `http://localhost:3000${url}`;
104
+ if (searchParams) {
105
+ const urlObj = new URL(requestUrl);
106
+ for (const [key, value] of Object.entries(searchParams)) {
107
+ if (Array.isArray(value)) {
108
+ value.forEach((v) => urlObj.searchParams.append(key, v));
109
+ } else {
110
+ urlObj.searchParams.set(key, value);
111
+ }
112
+ }
113
+ requestUrl = urlObj.toString();
114
+ }
115
+ const requestHeaders = new Headers(headers);
116
+ if (cookies) {
117
+ const cookieString = Object.entries(cookies).map(([name, value]) => `${name}=${value}`).join("; ");
118
+ requestHeaders.set("Cookie", cookieString);
119
+ }
120
+ let requestBody = null;
121
+ if (formData) {
122
+ const fd = new FormData;
123
+ for (const [key, value] of Object.entries(formData)) {
124
+ fd.append(key, value);
125
+ }
126
+ requestBody = fd;
127
+ } else if (body) {
128
+ if (typeof body === "object" && !(body instanceof Blob) && !(body instanceof FormData)) {
129
+ requestBody = JSON.stringify(body);
130
+ if (!requestHeaders.has("Content-Type")) {
131
+ requestHeaders.set("Content-Type", "application/json");
132
+ }
133
+ } else {
134
+ requestBody = body;
135
+ }
136
+ }
137
+ return new Request(requestUrl, {
138
+ method,
139
+ headers: requestHeaders,
140
+ body: requestBody
141
+ });
142
+ }
143
+ function createFormRequest(url, data) {
144
+ const formData = new URLSearchParams;
145
+ for (const [key, value] of Object.entries(data)) {
146
+ if (typeof value === "string") {
147
+ formData.append(key, value);
148
+ }
149
+ }
150
+ return new Request(url.startsWith("http") ? url : `http://localhost:3000${url}`, {
151
+ method: "POST",
152
+ headers: {
153
+ "Content-Type": "application/x-www-form-urlencoded"
154
+ },
155
+ body: formData.toString()
156
+ });
157
+ }
158
+ function createMockFormData(data) {
159
+ const formData = new FormData;
160
+ for (const [key, value] of Object.entries(data)) {
161
+ formData.append(key, value);
162
+ }
163
+ return formData;
164
+ }
165
+ function createMockHeaders(data) {
166
+ return new Headers(data);
167
+ }
168
+ async function parseJsonResponse(response) {
169
+ const text = await response.text();
170
+ try {
171
+ return JSON.parse(text);
172
+ } catch {
173
+ throw new Error(`Failed to parse JSON response: ${text.slice(0, 100)}`);
174
+ }
175
+ }
176
+ async function parseTextResponse(response) {
177
+ return response.text();
178
+ }
179
+
180
+ // src/loader.ts
181
+ async function testLoader(loader, options = {}) {
182
+ const request = createMockRequest(options.request);
183
+ const context = createTestContext(options.context);
184
+ const params = options.params || {};
185
+ const startTime = performance.now();
186
+ const data = await loader({ request, params, context });
187
+ const duration = performance.now() - startTime;
188
+ return {
189
+ data,
190
+ context,
191
+ request,
192
+ duration
193
+ };
194
+ }
195
+ function createLoaderTester(loader, baseOptions = {}) {
196
+ return async (overrides = {}) => {
197
+ return testLoader(loader, {
198
+ ...baseOptions,
199
+ ...overrides,
200
+ params: { ...baseOptions.params, ...overrides.params },
201
+ request: { ...baseOptions.request, ...overrides.request },
202
+ context: {
203
+ ...baseOptions.context,
204
+ ...overrides.context,
205
+ store: { ...baseOptions.context?.store, ...overrides.context?.store },
206
+ env: { ...baseOptions.context?.env, ...overrides.context?.env }
207
+ }
208
+ });
209
+ };
210
+ }
211
+ // src/action.ts
212
+ async function testAction(action, options = {}) {
213
+ const requestOptions = {
214
+ method: "POST",
215
+ ...options.request
216
+ };
217
+ if (options.formData) {
218
+ requestOptions.formData = options.formData;
219
+ } else if (options.body) {
220
+ requestOptions.body = options.body;
221
+ }
222
+ const request = createMockRequest(requestOptions);
223
+ const context = createTestContext(options.context);
224
+ const params = options.params || {};
225
+ const startTime = performance.now();
226
+ const result = await action({ request, params, context });
227
+ const duration = performance.now() - startTime;
228
+ let data;
229
+ let response = null;
230
+ let isRedirect = false;
231
+ let redirectTo = null;
232
+ if (result instanceof Response) {
233
+ response = result;
234
+ isRedirect = result.status >= 300 && result.status < 400;
235
+ redirectTo = result.headers.get("Location");
236
+ try {
237
+ const cloned = result.clone();
238
+ data = await parseJsonResponse(cloned);
239
+ } catch {
240
+ data = undefined;
241
+ }
242
+ } else {
243
+ data = result;
244
+ }
245
+ return {
246
+ data,
247
+ response,
248
+ context,
249
+ request,
250
+ duration,
251
+ isRedirect,
252
+ redirectTo
253
+ };
254
+ }
255
+ function createActionTester(action, baseOptions = {}) {
256
+ return async (overrides = {}) => {
257
+ return testAction(action, {
258
+ ...baseOptions,
259
+ ...overrides,
260
+ params: { ...baseOptions.params, ...overrides.params },
261
+ request: { ...baseOptions.request, ...overrides.request },
262
+ context: {
263
+ ...baseOptions.context,
264
+ ...overrides.context,
265
+ store: { ...baseOptions.context?.store, ...overrides.context?.store },
266
+ env: { ...baseOptions.context?.env, ...overrides.context?.env }
267
+ },
268
+ formData: overrides.formData || baseOptions.formData,
269
+ body: overrides.body || baseOptions.body
270
+ });
271
+ };
272
+ }
273
+ // src/middleware.ts
274
+ async function testMiddleware(middleware, options = {}) {
275
+ const request = createMockRequest(options.request);
276
+ const context = createTestContext(options.context);
277
+ let nextCalled = false;
278
+ let nextCallCount = 0;
279
+ const next = options.next || (async () => {
280
+ nextCalled = true;
281
+ nextCallCount++;
282
+ return options.nextResponse || new Response("OK", { status: 200 });
283
+ });
284
+ const wrappedNext = async () => {
285
+ nextCalled = true;
286
+ nextCallCount++;
287
+ return next();
288
+ };
289
+ const startTime = performance.now();
290
+ const response = await middleware(request, context, wrappedNext);
291
+ const duration = performance.now() - startTime;
292
+ return {
293
+ response,
294
+ context,
295
+ request,
296
+ nextCalled,
297
+ nextCallCount,
298
+ duration
299
+ };
300
+ }
301
+ function createMiddlewareTester(middleware, baseOptions = {}) {
302
+ return async (overrides = {}) => {
303
+ return testMiddleware(middleware, {
304
+ ...baseOptions,
305
+ ...overrides,
306
+ request: { ...baseOptions.request, ...overrides.request },
307
+ context: {
308
+ ...baseOptions.context,
309
+ ...overrides.context,
310
+ store: { ...baseOptions.context?.store, ...overrides.context?.store },
311
+ env: { ...baseOptions.context?.env, ...overrides.context?.env }
312
+ }
313
+ });
314
+ };
315
+ }
316
+ // src/render.ts
317
+ async function renderRoute(module, options = {}) {
318
+ const request = createMockRequest(options.request);
319
+ const context = createTestContext(options.context);
320
+ const params = options.params || {};
321
+ let loaderData;
322
+ if (options.loaderData !== undefined) {
323
+ loaderData = options.loaderData;
324
+ } else if (module.loader) {
325
+ loaderData = await module.loader({ request, params, context });
326
+ } else {
327
+ loaderData = undefined;
328
+ }
329
+ const Component = module.default;
330
+ if (!Component) {
331
+ throw new Error("Route module has no default export");
332
+ }
333
+ const props = {
334
+ loaderData,
335
+ params,
336
+ children: options.children
337
+ };
338
+ const element = {
339
+ type: Component,
340
+ props,
341
+ key: null
342
+ };
343
+ return {
344
+ element,
345
+ loaderData,
346
+ context,
347
+ request,
348
+ props
349
+ };
350
+ }
351
+ function createRouteRenderer(module, baseOptions = {}) {
352
+ return async (overrides = {}) => {
353
+ return renderRoute(module, {
354
+ ...baseOptions,
355
+ ...overrides,
356
+ params: { ...baseOptions.params, ...overrides.params },
357
+ request: { ...baseOptions.request, ...overrides.request },
358
+ context: {
359
+ ...baseOptions.context,
360
+ ...overrides.context,
361
+ store: { ...baseOptions.context?.store, ...overrides.context?.store },
362
+ env: { ...baseOptions.context?.env, ...overrides.context?.env }
363
+ }
364
+ });
365
+ };
366
+ }
367
+ // src/assertions.ts
368
+ function assertRedirect(response, expectedLocation, options = {}) {
369
+ if (!response) {
370
+ throw new Error(options.message || "Expected a response but got null");
371
+ }
372
+ const status = response.status;
373
+ const expectedStatus = options.status || 302;
374
+ if (status < 300 || status >= 400) {
375
+ throw new Error(options.message || `Expected redirect status (3xx) but got ${status}`);
376
+ }
377
+ if (options.status && status !== expectedStatus) {
378
+ throw new Error(options.message || `Expected redirect status ${expectedStatus} but got ${status}`);
379
+ }
380
+ const location = response.headers.get("Location");
381
+ if (expectedLocation) {
382
+ if (!location) {
383
+ throw new Error(options.message || "Expected Location header but it was not set");
384
+ }
385
+ if (location !== expectedLocation) {
386
+ throw new Error(options.message || `Expected redirect to "${expectedLocation}" but got "${location}"`);
387
+ }
388
+ }
389
+ }
390
+ async function assertJson(responseOrData, expected, options = {}) {
391
+ let data;
392
+ if (responseOrData instanceof Response) {
393
+ try {
394
+ data = await responseOrData.clone().json();
395
+ } catch {
396
+ throw new Error(options.message || "Failed to parse response body as JSON");
397
+ }
398
+ } else {
399
+ data = responseOrData;
400
+ }
401
+ for (const [key, value] of Object.entries(expected)) {
402
+ const actual = data[key];
403
+ if (JSON.stringify(actual) !== JSON.stringify(value)) {
404
+ throw new Error(options.message || `Expected "${key}" to be ${JSON.stringify(value)} but got ${JSON.stringify(actual)}`);
405
+ }
406
+ }
407
+ }
408
+ function assertStatus(response, expected, options = {}) {
409
+ if (!response) {
410
+ throw new Error(options.message || "Expected a response but got null");
411
+ }
412
+ const expectedStatuses = Array.isArray(expected) ? expected : [expected];
413
+ if (!expectedStatuses.includes(response.status)) {
414
+ throw new Error(options.message || `Expected status ${expectedStatuses.join(" or ")} but got ${response.status}`);
415
+ }
416
+ }
417
+ function assertHeaders(response, expected, options = {}) {
418
+ if (!response) {
419
+ throw new Error(options.message || "Expected a response but got null");
420
+ }
421
+ for (const [name, expectedValue] of Object.entries(expected)) {
422
+ const actual = response.headers.get(name);
423
+ if (actual === null) {
424
+ throw new Error(options.message || `Expected header "${name}" to be set but it was not`);
425
+ }
426
+ if (expectedValue instanceof RegExp) {
427
+ if (!expectedValue.test(actual)) {
428
+ throw new Error(options.message || `Expected header "${name}" to match ${expectedValue} but got "${actual}"`);
429
+ }
430
+ } else {
431
+ if (actual !== expectedValue) {
432
+ throw new Error(options.message || `Expected header "${name}" to be "${expectedValue}" but got "${actual}"`);
433
+ }
434
+ }
435
+ }
436
+ }
437
+ function assertCookies(response, expected, options = {}) {
438
+ if (!response) {
439
+ throw new Error(options.message || "Expected a response but got null");
440
+ }
441
+ const setCookieHeaders = response.headers.getSetCookie?.() || [];
442
+ const cookies = new Map;
443
+ for (const header of setCookieHeaders) {
444
+ const [nameValue] = header.split(";");
445
+ const eqIndex = nameValue.indexOf("=");
446
+ if (eqIndex > 0) {
447
+ const name = nameValue.slice(0, eqIndex).trim();
448
+ cookies.set(name, header);
449
+ }
450
+ }
451
+ for (const [name, expectations] of Object.entries(expected)) {
452
+ const cookieHeader = cookies.get(name);
453
+ if (expectations.exists === false) {
454
+ if (cookieHeader) {
455
+ throw new Error(options.message || `Expected cookie "${name}" not to be set but it was`);
456
+ }
457
+ continue;
458
+ }
459
+ if (expectations.exists !== false && !cookieHeader) {
460
+ throw new Error(options.message || `Expected cookie "${name}" to be set but it was not`);
461
+ }
462
+ if (!cookieHeader)
463
+ continue;
464
+ const headerLower = cookieHeader.toLowerCase();
465
+ if (expectations.value) {
466
+ const [nameValue] = cookieHeader.split(";");
467
+ const value = nameValue.split("=").slice(1).join("=");
468
+ if (expectations.value instanceof RegExp) {
469
+ if (!expectations.value.test(value)) {
470
+ throw new Error(options.message || `Expected cookie "${name}" value to match ${expectations.value} but got "${value}"`);
471
+ }
472
+ } else {
473
+ if (value !== expectations.value) {
474
+ throw new Error(options.message || `Expected cookie "${name}" value to be "${expectations.value}" but got "${value}"`);
475
+ }
476
+ }
477
+ }
478
+ if (expectations.httpOnly !== undefined) {
479
+ const hasHttpOnly = headerLower.includes("httponly");
480
+ if (expectations.httpOnly !== hasHttpOnly) {
481
+ throw new Error(options.message || `Expected cookie "${name}" HttpOnly to be ${expectations.httpOnly}`);
482
+ }
483
+ }
484
+ if (expectations.secure !== undefined) {
485
+ const hasSecure = headerLower.includes("secure");
486
+ if (expectations.secure !== hasSecure) {
487
+ throw new Error(options.message || `Expected cookie "${name}" Secure to be ${expectations.secure}`);
488
+ }
489
+ }
490
+ if (expectations.sameSite) {
491
+ const hasSameSite = headerLower.includes(`samesite=${expectations.sameSite.toLowerCase()}`);
492
+ if (!hasSameSite) {
493
+ throw new Error(options.message || `Expected cookie "${name}" SameSite to be ${expectations.sameSite}`);
494
+ }
495
+ }
496
+ if (expectations.path) {
497
+ const hasPath = headerLower.includes(`path=${expectations.path.toLowerCase()}`);
498
+ if (!hasPath) {
499
+ throw new Error(options.message || `Expected cookie "${name}" Path to be ${expectations.path}`);
500
+ }
501
+ }
502
+ }
503
+ }
504
+ // src/server.ts
505
+ async function createTestServer(options = {}) {
506
+ const port = options.port || await getAvailablePort();
507
+ const url = `http://localhost:${port}`;
508
+ const { createApp } = await import("@ereo/core");
509
+ const { initFileRouter } = await import("@ereo/router");
510
+ const { createServer } = await import("@ereo/server");
511
+ const app = createApp({
512
+ config: {
513
+ ...options.config,
514
+ server: {
515
+ port,
516
+ hostname: "localhost",
517
+ development: true,
518
+ ...options.config?.server
519
+ }
520
+ }
521
+ });
522
+ const router = await initFileRouter({
523
+ routesDir: options.routesDir || options.config?.routesDir || "app/routes",
524
+ watch: false
525
+ });
526
+ await router.loadAllModules();
527
+ const server = createServer({
528
+ port,
529
+ hostname: "localhost",
530
+ development: true,
531
+ logging: false
532
+ });
533
+ server.setApp(app);
534
+ server.setRouter(router);
535
+ await server.start();
536
+ const makeFetch = async (path, init) => {
537
+ const fullUrl = path.startsWith("http") ? path : `${url}${path}`;
538
+ return fetch(fullUrl, init);
539
+ };
540
+ const makeBodyRequest = async (method, path, body, init) => {
541
+ const headers = new Headers(init?.headers);
542
+ let requestBody;
543
+ if (body !== undefined) {
544
+ if (body instanceof FormData) {
545
+ requestBody = body;
546
+ } else {
547
+ requestBody = JSON.stringify(body);
548
+ if (!headers.has("Content-Type")) {
549
+ headers.set("Content-Type", "application/json");
550
+ }
551
+ }
552
+ }
553
+ return makeFetch(path, {
554
+ ...init,
555
+ method,
556
+ headers,
557
+ body: requestBody
558
+ });
559
+ };
560
+ const testServer = {
561
+ url,
562
+ port,
563
+ fetch: makeFetch,
564
+ get: (path, init) => makeFetch(path, { ...init, method: "GET" }),
565
+ post: (path, body, init) => makeBodyRequest("POST", path, body, init),
566
+ put: (path, body, init) => makeBodyRequest("PUT", path, body, init),
567
+ delete: (path, init) => makeFetch(path, { ...init, method: "DELETE" }),
568
+ patch: (path, body, init) => makeBodyRequest("PATCH", path, body, init),
569
+ submitForm: async (path, formData, init) => {
570
+ const fd = new FormData;
571
+ for (const [key, value] of Object.entries(formData)) {
572
+ fd.append(key, value);
573
+ }
574
+ return makeFetch(path, {
575
+ ...init,
576
+ method: "POST",
577
+ body: fd
578
+ });
579
+ },
580
+ stop: async () => {
581
+ server.stop();
582
+ }
583
+ };
584
+ return testServer;
585
+ }
586
+ async function getAvailablePort() {
587
+ const server = Bun.serve({
588
+ port: 0,
589
+ fetch() {
590
+ return new Response("");
591
+ }
592
+ });
593
+ const port = server.port ?? 0;
594
+ server.stop();
595
+ return port;
596
+ }
597
+ // src/snapshot.ts
598
+ function prepareForSnapshot(data, options = {}) {
599
+ if (data === null || data === undefined) {
600
+ return data;
601
+ }
602
+ if (typeof data !== "object") {
603
+ return data;
604
+ }
605
+ if (Array.isArray(data)) {
606
+ return data.map((item) => prepareForSnapshot(item, options));
607
+ }
608
+ const obj = data;
609
+ const result = {};
610
+ for (const [key, value] of Object.entries(obj)) {
611
+ if (options.exclude?.includes(key)) {
612
+ continue;
613
+ }
614
+ if (options.include && !options.include.includes(key)) {
615
+ continue;
616
+ }
617
+ if (options.replacers && key in options.replacers) {
618
+ result[key] = options.replacers[key];
619
+ continue;
620
+ }
621
+ result[key] = prepareForSnapshot(value, options);
622
+ }
623
+ return result;
624
+ }
625
+ async function snapshotLoader(loader, testOptions = {}, snapshotOptions = {}) {
626
+ const result = await testLoader(loader, testOptions);
627
+ return prepareForSnapshot(result.data, snapshotOptions);
628
+ }
629
+ async function snapshotAction(action, testOptions = {}, snapshotOptions = {}) {
630
+ const result = await testAction(action, testOptions);
631
+ return prepareForSnapshot(result.data, snapshotOptions);
632
+ }
633
+ export {
634
+ testMiddleware,
635
+ testLoader,
636
+ testAction,
637
+ snapshotLoader,
638
+ snapshotAction,
639
+ renderRoute,
640
+ parseTextResponse,
641
+ parseJsonResponse,
642
+ createTestServer,
643
+ createTestContext,
644
+ createRouteRenderer,
645
+ createMockRequest,
646
+ createMockHeaders,
647
+ createMockFormData,
648
+ createMiddlewareTester,
649
+ createLoaderTester,
650
+ createFormRequest,
651
+ createContextFactory,
652
+ createActionTester,
653
+ assertStatus,
654
+ assertRedirect,
655
+ assertJson,
656
+ assertHeaders,
657
+ assertCookies
658
+ };