@r0hitsharma/http-client-msw 0.12.0-rohit-fork-ci.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 +251 -0
- package/dist/base-url.d.ts +46 -0
- package/dist/base-url.js +65 -0
- package/dist/browser.d.ts +50 -0
- package/dist/browser.js +59 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +20 -0
- package/dist/mock-api.d.ts +75 -0
- package/dist/mock-api.js +35 -0
- package/dist/mock-delay.d.ts +35 -0
- package/dist/mock-delay.js +50 -0
- package/dist/mock-store.d.ts +76 -0
- package/dist/mock-store.js +114 -0
- package/dist/node.d.ts +41 -0
- package/dist/node.js +41 -0
- package/dist/seeded-rng.d.ts +27 -0
- package/dist/seeded-rng.js +52 -0
- package/dist/setup.d.ts +54 -0
- package/dist/setup.js +26 -0
- package/dist/worker-options.d.ts +55 -0
- package/dist/worker-options.js +49 -0
- package/package.json +59 -0
- package/src/base-url.test.ts +177 -0
- package/src/base-url.ts +80 -0
- package/src/browser.ts +88 -0
- package/src/index.ts +53 -0
- package/src/mock-api.test.ts +148 -0
- package/src/mock-api.ts +96 -0
- package/src/mock-api.types.test.ts +119 -0
- package/src/mock-delay.test.ts +51 -0
- package/src/mock-delay.ts +68 -0
- package/src/mock-store.test.ts +263 -0
- package/src/mock-store.ts +180 -0
- package/src/node.ts +61 -0
- package/src/seeded-rng.test.ts +106 -0
- package/src/seeded-rng.ts +77 -0
- package/src/setup.test.ts +137 -0
- package/src/setup.ts +71 -0
- package/src/test-fixtures.ts +113 -0
- package/src/worker-options.test.ts +128 -0
- package/src/worker-options.ts +108 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { http, HttpResponse } from 'msw';
|
|
2
|
+
import { setupServer } from 'msw/node';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
isAbsoluteUrl,
|
|
7
|
+
type MockOriginMatching,
|
|
8
|
+
normalizeApiBaseUrl,
|
|
9
|
+
resolveHandlerBase,
|
|
10
|
+
resolveWorkerScriptUrl,
|
|
11
|
+
} from './base-url.js';
|
|
12
|
+
|
|
13
|
+
describe('normalizeApiBaseUrl', () => {
|
|
14
|
+
it('is empty when no base is given', () => {
|
|
15
|
+
expect(normalizeApiBaseUrl()).toBe('');
|
|
16
|
+
expect(normalizeApiBaseUrl('')).toBe('');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('strips trailing slashes so a prefixed path never doubles up', () => {
|
|
20
|
+
expect(normalizeApiBaseUrl('/api')).toBe('/api');
|
|
21
|
+
expect(normalizeApiBaseUrl('/api/')).toBe('/api');
|
|
22
|
+
expect(normalizeApiBaseUrl('/api//')).toBe('/api');
|
|
23
|
+
expect(normalizeApiBaseUrl('/')).toBe('');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('keeps an absolute base intact', () => {
|
|
27
|
+
expect(normalizeApiBaseUrl('https://api.test/v1/')).toBe(
|
|
28
|
+
'https://api.test/v1',
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('isAbsoluteUrl', () => {
|
|
34
|
+
it('distinguishes an origin-relative base from an absolute one', () => {
|
|
35
|
+
expect(isAbsoluteUrl('http://localhost:3000/api')).toBe(true);
|
|
36
|
+
expect(isAbsoluteUrl('https://api.test')).toBe(true);
|
|
37
|
+
expect(isAbsoluteUrl('/api')).toBe(false);
|
|
38
|
+
expect(isAbsoluteUrl('')).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('does not count a protocol-relative base, which carries no scheme', () => {
|
|
42
|
+
expect(isAbsoluteUrl('//api.test/v1')).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('resolveHandlerBase', () => {
|
|
47
|
+
it('wildcards the origin of a relative base by default', () => {
|
|
48
|
+
expect(resolveHandlerBase('/api', 'any')).toBe('*/api');
|
|
49
|
+
expect(resolveHandlerBase('/api/', 'any')).toBe('*/api');
|
|
50
|
+
expect(resolveHandlerBase(undefined, 'any')).toBe('*');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps a relative base relative when matching exactly', () => {
|
|
54
|
+
expect(resolveHandlerBase('/api', 'exact')).toBe('/api');
|
|
55
|
+
expect(resolveHandlerBase(undefined, 'exact')).toBe('');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('leaves an absolute base alone, since it already pins the origin', () => {
|
|
59
|
+
expect(resolveHandlerBase('https://api.test/v1/', 'any')).toBe(
|
|
60
|
+
'https://api.test/v1',
|
|
61
|
+
);
|
|
62
|
+
expect(resolveHandlerBase('https://api.test/v1', 'exact')).toBe(
|
|
63
|
+
'https://api.test/v1',
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('wildcards a protocol-relative base under either setting', () => {
|
|
68
|
+
expect(resolveHandlerBase('//api.test/v1/', 'any')).toBe('*//api.test/v1');
|
|
69
|
+
expect(resolveHandlerBase('//api.test/v1', 'exact')).toBe('*//api.test/v1');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The contract that actually matters is whether msw matches, not what string
|
|
75
|
+
* comes back — a resolved base can read perfectly and still match nothing, at
|
|
76
|
+
* which point the request escapes to the real network instead of failing.
|
|
77
|
+
*
|
|
78
|
+
* So these drive the resolved base through `setupServer`. The trailing
|
|
79
|
+
* catch-all is what makes them match tests: msw resolves handlers in
|
|
80
|
+
* declaration order, so a request the resolved base does not match falls
|
|
81
|
+
* through to the sentinel rather than reaching the network, and the assertion
|
|
82
|
+
* is which of the two answered.
|
|
83
|
+
*/
|
|
84
|
+
const answeredBy = async (
|
|
85
|
+
baseUrl: string | undefined,
|
|
86
|
+
origin: MockOriginMatching,
|
|
87
|
+
url: string,
|
|
88
|
+
): Promise<'base' | 'sentinel'> => {
|
|
89
|
+
const server = setupServer(
|
|
90
|
+
http.get(`${resolveHandlerBase(baseUrl, origin)}/things`, () =>
|
|
91
|
+
HttpResponse.json('base'),
|
|
92
|
+
),
|
|
93
|
+
http.all(/.*/, () => HttpResponse.json('sentinel')),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
server.listen({ onUnhandledRequest: 'error' });
|
|
97
|
+
try {
|
|
98
|
+
return (await (await fetch(url)).json()) as 'base' | 'sentinel';
|
|
99
|
+
} finally {
|
|
100
|
+
server.close();
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
describe('resolveHandlerBase — matching under msw', () => {
|
|
105
|
+
it('matches a protocol-relative base on either scheme', async () => {
|
|
106
|
+
await expect(
|
|
107
|
+
answeredBy('//api.test/v1', 'any', 'http://api.test/v1/things'),
|
|
108
|
+
).resolves.toBe('base');
|
|
109
|
+
await expect(
|
|
110
|
+
answeredBy('//api.test/v1', 'any', 'https://api.test/v1/things'),
|
|
111
|
+
).resolves.toBe('base');
|
|
112
|
+
await expect(
|
|
113
|
+
answeredBy('//api.test/v1', 'exact', 'https://api.test/v1/things'),
|
|
114
|
+
).resolves.toBe('base');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('still pins the host of a protocol-relative base', async () => {
|
|
118
|
+
await expect(
|
|
119
|
+
answeredBy('//api.test/v1', 'any', 'https://elsewhere.test/v1/things'),
|
|
120
|
+
).resolves.toBe('sentinel');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('matches an absolute base on its own scheme and host only', async () => {
|
|
124
|
+
await expect(
|
|
125
|
+
answeredBy('https://api.test/v1/', 'any', 'https://api.test/v1/things'),
|
|
126
|
+
).resolves.toBe('base');
|
|
127
|
+
await expect(
|
|
128
|
+
answeredBy('https://api.test/v1', 'any', 'http://api.test/v1/things'),
|
|
129
|
+
).resolves.toBe('sentinel');
|
|
130
|
+
await expect(
|
|
131
|
+
answeredBy('https://api.test/v1', 'any', 'https://other.test/v1/things'),
|
|
132
|
+
).resolves.toBe('sentinel');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('matches any origin when no base is given', async () => {
|
|
136
|
+
await expect(
|
|
137
|
+
answeredBy(undefined, 'any', 'http://localhost/things'),
|
|
138
|
+
).resolves.toBe('base');
|
|
139
|
+
await expect(
|
|
140
|
+
answeredBy(undefined, 'any', 'https://api.test/things'),
|
|
141
|
+
).resolves.toBe('base');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('matches any origin for a relative base by default', async () => {
|
|
145
|
+
await expect(
|
|
146
|
+
answeredBy('/api', 'any', 'http://localhost/api/things'),
|
|
147
|
+
).resolves.toBe('base');
|
|
148
|
+
await expect(
|
|
149
|
+
answeredBy('/api', 'any', 'https://api.test/api/things'),
|
|
150
|
+
).resolves.toBe('base');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('matches nothing in node for a relative base under `exact`', async () => {
|
|
154
|
+
// The documented cost of opting out: node request URLs are always
|
|
155
|
+
// absolute, so the relative pattern never matches and a node test needs an
|
|
156
|
+
// absolute `baseUrl` of its own.
|
|
157
|
+
await expect(
|
|
158
|
+
answeredBy('/api', 'exact', 'http://localhost/api/things'),
|
|
159
|
+
).resolves.toBe('sentinel');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('resolveWorkerScriptUrl', () => {
|
|
164
|
+
it('serves from the root by default', () => {
|
|
165
|
+
expect(resolveWorkerScriptUrl()).toBe('/mockServiceWorker.js');
|
|
166
|
+
expect(resolveWorkerScriptUrl('')).toBe('/mockServiceWorker.js');
|
|
167
|
+
expect(resolveWorkerScriptUrl('/')).toBe('/mockServiceWorker.js');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('follows the app base path for a subpath deployment', () => {
|
|
171
|
+
expect(resolveWorkerScriptUrl('/app')).toBe('/app/mockServiceWorker.js');
|
|
172
|
+
expect(resolveWorkerScriptUrl('/app/')).toBe('/app/mockServiceWorker.js');
|
|
173
|
+
expect(resolveWorkerScriptUrl('/nested/app/')).toBe(
|
|
174
|
+
'/nested/app/mockServiceWorker.js',
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
});
|
package/src/base-url.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two different base URLs live in this package and they normalize to opposite
|
|
3
|
+
* shapes, so both live here rather than being inlined at their call sites:
|
|
4
|
+
*
|
|
5
|
+
* - the **API** base (`createMockApi({ baseUrl })`) is prepended to OpenAPI path
|
|
6
|
+
* templates, which already start with `/`, so it must not end in one;
|
|
7
|
+
* - the **app** base (`setupMockWorker(mocks, { baseUrl })`) is the public base
|
|
8
|
+
* path a subpath deployment is served from, and the worker script filename is
|
|
9
|
+
* appended to it, so it must end in one.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** How an origin-relative API base is matched. See {@link resolveHandlerBase}. */
|
|
13
|
+
export type MockOriginMatching = 'any' | 'exact';
|
|
14
|
+
|
|
15
|
+
/** A scheme plus authority — `https://api.test`. Pins both scheme and host. */
|
|
16
|
+
const ABSOLUTE_URL = /^[a-z][a-z\d+.-]*:\/\//i;
|
|
17
|
+
|
|
18
|
+
/** A protocol-relative `//api.test` — pins the host, leaves the scheme open. */
|
|
19
|
+
const PROTOCOL_RELATIVE_URL = /^\/\//;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether the base carries its own scheme. A protocol-relative `//host` does
|
|
23
|
+
* not, and so is *not* absolute here — see {@link resolveHandlerBase}, which
|
|
24
|
+
* has to prefix one for msw to match it at all.
|
|
25
|
+
*/
|
|
26
|
+
export function isAbsoluteUrl(url: string): boolean {
|
|
27
|
+
return ABSOLUTE_URL.test(url);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Strips trailing slashes so `${base}${'/things'}` never doubles up. */
|
|
31
|
+
export function normalizeApiBaseUrl(baseUrl?: string): string {
|
|
32
|
+
if (!baseUrl) return '';
|
|
33
|
+
|
|
34
|
+
return baseUrl.replace(/\/+$/, '');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The prefix every handler path is built on.
|
|
39
|
+
*
|
|
40
|
+
* msw resolves a relative handler path against `document.baseURI` in the browser
|
|
41
|
+
* and leaves it relative in node, where request URLs are always absolute — so a
|
|
42
|
+
* relative path silently matches nothing under `setupServer`. Prefixing an
|
|
43
|
+
* origin-relative base with msw's `*` wildcard makes one handler array match in
|
|
44
|
+
* both, which is what lets the same mocks serve dev, vitest, and Playwright.
|
|
45
|
+
*
|
|
46
|
+
* `'exact'` opts out and keeps the path relative — same-origin matching only,
|
|
47
|
+
* and node tests then need an absolute `baseUrl`. An absolute base already pins
|
|
48
|
+
* the origin, so the setting does not apply to one.
|
|
49
|
+
*
|
|
50
|
+
* A protocol-relative `//host` is prefixed under *both* settings. It pins the
|
|
51
|
+
* host but not the scheme, and msw matches a bare `//host` pattern against
|
|
52
|
+
* neither `http:` nor `https:` — the request escapes to the real network. The
|
|
53
|
+
* wildcard stands in for the scheme only, so the host stays pinned.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveHandlerBase(
|
|
56
|
+
baseUrl: string | undefined,
|
|
57
|
+
origin: MockOriginMatching,
|
|
58
|
+
): string {
|
|
59
|
+
const normalized = normalizeApiBaseUrl(baseUrl);
|
|
60
|
+
|
|
61
|
+
if (isAbsoluteUrl(normalized)) return normalized;
|
|
62
|
+
|
|
63
|
+
const pinsHost = PROTOCOL_RELATIVE_URL.test(normalized);
|
|
64
|
+
|
|
65
|
+
if (origin === 'exact' && !pinsHost) return normalized;
|
|
66
|
+
|
|
67
|
+
return `*${normalized}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The URL msw's service worker script is served from. It follows the app's
|
|
72
|
+
* public base path — `import.meta.env.BASE_URL` under Vite — because a bundler
|
|
73
|
+
* copies `public/mockServiceWorker.js` to `${base}mockServiceWorker.js`, and the
|
|
74
|
+
* worker's scope is limited to the directory it is served from.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveWorkerScriptUrl(baseUrl?: string): string {
|
|
77
|
+
const base = baseUrl && baseUrl.length > 0 ? baseUrl : '/';
|
|
78
|
+
|
|
79
|
+
return `${base.endsWith('/') ? base : `${base}/`}mockServiceWorker.js`;
|
|
80
|
+
}
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser entry: `@r0hitsharma/http-client-msw/browser`.
|
|
3
|
+
*
|
|
4
|
+
* Kept behind its own subpath so `msw/browser` — and the service-worker
|
|
5
|
+
* machinery it pulls in — never reaches a node test's module graph, and so an
|
|
6
|
+
* app bundle that imports only this entry does not drag in `msw/node`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { setupWorker, type SetupWorker } from 'msw/browser';
|
|
10
|
+
|
|
11
|
+
import type { MockSetup } from './setup.js';
|
|
12
|
+
import {
|
|
13
|
+
buildWorkerStartOptions,
|
|
14
|
+
createIdempotentStart,
|
|
15
|
+
type MockWorkerOptions,
|
|
16
|
+
} from './worker-options.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Re-exported here rather than from the package root: their types reference
|
|
20
|
+
* `msw/browser`, which does not resolve under a node-only condition set.
|
|
21
|
+
*/
|
|
22
|
+
export {
|
|
23
|
+
buildWorkerStartOptions,
|
|
24
|
+
createIdempotentStart,
|
|
25
|
+
type IdempotentStart,
|
|
26
|
+
type MockWorkerOptions,
|
|
27
|
+
} from './worker-options.js';
|
|
28
|
+
|
|
29
|
+
export type MockWorker = {
|
|
30
|
+
/** The underlying msw worker, for `use()` and lifecycle events. */
|
|
31
|
+
worker: SetupWorker;
|
|
32
|
+
/** Registers and activates the worker. Idempotent; safe to await anywhere. */
|
|
33
|
+
start: () => Promise<void>;
|
|
34
|
+
/** Stops interception. A later `start()` re-registers the worker. */
|
|
35
|
+
stop: () => void;
|
|
36
|
+
/** Runs the setup's state resets, then drops runtime handler overrides. */
|
|
37
|
+
reset: () => void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Serves a {@link MockSetup} from a service worker.
|
|
42
|
+
*
|
|
43
|
+
* Call `start()` and await it **before** rendering, so no component can fire a
|
|
44
|
+
* request the worker is not yet intercepting:
|
|
45
|
+
*
|
|
46
|
+
* ```ts
|
|
47
|
+
* // src/main.tsx
|
|
48
|
+
* if (import.meta.env.VITE_API_MOCKS === '1') {
|
|
49
|
+
* const { setupMockWorker } = await import(
|
|
50
|
+
* '@r0hitsharma/http-client-msw/browser'
|
|
51
|
+
* );
|
|
52
|
+
* const { mocks } = await import('./mocks');
|
|
53
|
+
*
|
|
54
|
+
* await setupMockWorker(mocks, { baseUrl: import.meta.env.BASE_URL }).start();
|
|
55
|
+
* }
|
|
56
|
+
*
|
|
57
|
+
* createRoot(document.getElementById('root')!).render(<App />);
|
|
58
|
+
* ```
|
|
59
|
+
*
|
|
60
|
+
* The dynamic imports are what keep msw and the fixtures out of a production
|
|
61
|
+
* bundle: with a statically analysable `import.meta.env` flag, the whole branch
|
|
62
|
+
* is dead code a bundler drops.
|
|
63
|
+
*/
|
|
64
|
+
export function setupMockWorker(
|
|
65
|
+
mocks: MockSetup,
|
|
66
|
+
options: MockWorkerOptions = {},
|
|
67
|
+
): MockWorker {
|
|
68
|
+
const worker = setupWorker(...mocks.handlers);
|
|
69
|
+
const startOptions = buildWorkerStartOptions(options);
|
|
70
|
+
const { start, invalidate } = createIdempotentStart(async () => {
|
|
71
|
+
await worker.start(startOptions);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
worker,
|
|
76
|
+
start,
|
|
77
|
+
stop: () => {
|
|
78
|
+
worker.stop();
|
|
79
|
+
// A stopped worker intercepts nothing, so the next `start()` has to
|
|
80
|
+
// re-register rather than resolve from the memo.
|
|
81
|
+
invalidate();
|
|
82
|
+
},
|
|
83
|
+
reset: () => {
|
|
84
|
+
mocks.resetState();
|
|
85
|
+
worker.resetHandlers();
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed msw mocks keyed off a generated OpenAPI `paths` type — the same type
|
|
3
|
+
* that drives `createApiClient` and `createQueryApi`, so handlers, params, and
|
|
4
|
+
* fixture bodies are checked against one contract.
|
|
5
|
+
*
|
|
6
|
+
* Environment wiring lives behind subpath entries so neither environment's msw
|
|
7
|
+
* import ends up in the other's bundle:
|
|
8
|
+
*
|
|
9
|
+
* - `@r0hitsharma/http-client-msw/browser` — `setupMockWorker`
|
|
10
|
+
* - `@r0hitsharma/http-client-msw/node` — `setupMockServer`
|
|
11
|
+
*
|
|
12
|
+
* Nothing here references `msw/browser` or `msw/node`, so this entry stays
|
|
13
|
+
* resolvable from either environment.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export {
|
|
17
|
+
createMockApi,
|
|
18
|
+
type MockApi,
|
|
19
|
+
type MockApiOptions,
|
|
20
|
+
type MockHandler,
|
|
21
|
+
type MockOriginMatching,
|
|
22
|
+
type MockPathsFor,
|
|
23
|
+
type MockRequestBodyFor,
|
|
24
|
+
type MockRequestHandler,
|
|
25
|
+
type MockResponseBodyFor,
|
|
26
|
+
type MockResponseResolver,
|
|
27
|
+
type MockResponseResolverInfo,
|
|
28
|
+
} from './mock-api.js';
|
|
29
|
+
export {
|
|
30
|
+
isAbsoluteUrl,
|
|
31
|
+
normalizeApiBaseUrl,
|
|
32
|
+
resolveHandlerBase,
|
|
33
|
+
resolveWorkerScriptUrl,
|
|
34
|
+
} from './base-url.js';
|
|
35
|
+
export {
|
|
36
|
+
isTestEnvironment,
|
|
37
|
+
mockDelay,
|
|
38
|
+
type MockDelayInput,
|
|
39
|
+
resolveMockDelay,
|
|
40
|
+
} from './mock-delay.js';
|
|
41
|
+
export {
|
|
42
|
+
createMockStore,
|
|
43
|
+
type MockStore,
|
|
44
|
+
type MockStoreOptions,
|
|
45
|
+
} from './mock-store.js';
|
|
46
|
+
export { createSeededRng, type SeededRng } from './seeded-rng.js';
|
|
47
|
+
export {
|
|
48
|
+
type MockResetCallback,
|
|
49
|
+
type MockSetup,
|
|
50
|
+
type MockSetupHandler,
|
|
51
|
+
type MockSetupOptions,
|
|
52
|
+
setupMocks,
|
|
53
|
+
} from './setup.js';
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { setupServer } from 'msw/node';
|
|
2
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createMockApi } from './mock-api.js';
|
|
5
|
+
import { type Thing, type TestPaths, seedThings } from './test-fixtures.js';
|
|
6
|
+
|
|
7
|
+
const mock = createMockApi<TestPaths>();
|
|
8
|
+
|
|
9
|
+
const server = setupServer();
|
|
10
|
+
|
|
11
|
+
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
|
12
|
+
afterEach(() => server.resetHandlers());
|
|
13
|
+
afterAll(() => server.close());
|
|
14
|
+
|
|
15
|
+
describe('createMockApi', () => {
|
|
16
|
+
it('responds with the operation 2xx body', async () => {
|
|
17
|
+
server.use(
|
|
18
|
+
mock.get('/things', ({ response }) => response(200).json(seedThings())),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
const response = await fetch('http://localhost/things');
|
|
22
|
+
|
|
23
|
+
expect(response.status).toBe(200);
|
|
24
|
+
expect((await response.json()) as Thing[]).toEqual(seedThings());
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('parses the OpenAPI path template into typed path params', async () => {
|
|
28
|
+
server.use(
|
|
29
|
+
mock.get('/things/{id}', ({ params, response }) =>
|
|
30
|
+
response(200).json({
|
|
31
|
+
id: params.id,
|
|
32
|
+
name: `thing-${params.id}`,
|
|
33
|
+
size: 3,
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const response = await fetch('http://localhost/things/t7');
|
|
39
|
+
|
|
40
|
+
expect(await response.json()).toEqual({
|
|
41
|
+
id: 't7',
|
|
42
|
+
name: 'thing-t7',
|
|
43
|
+
size: 3,
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('exposes query params through the typed query helper', async () => {
|
|
48
|
+
server.use(
|
|
49
|
+
mock.get('/things', ({ query, response }) => {
|
|
50
|
+
const limit = Number(query.get('limit') ?? '0');
|
|
51
|
+
|
|
52
|
+
return response(200).json(seedThings().slice(0, limit));
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const response = await fetch('http://localhost/things?limit=1');
|
|
57
|
+
|
|
58
|
+
expect(await response.json()).toEqual([seedThings()[0]]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('reads a typed request body off a mutating operation', async () => {
|
|
62
|
+
server.use(
|
|
63
|
+
mock.post('/things', async ({ request, response }) => {
|
|
64
|
+
const body = await request.json();
|
|
65
|
+
|
|
66
|
+
return response(201).json({ id: 'new', ...body });
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const response = await fetch('http://localhost/things', {
|
|
71
|
+
method: 'POST',
|
|
72
|
+
headers: { 'Content-Type': 'application/json' },
|
|
73
|
+
body: JSON.stringify({ name: 'Fresh', size: 9 }),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(response.status).toBe(201);
|
|
77
|
+
expect(await response.json()).toEqual({
|
|
78
|
+
id: 'new',
|
|
79
|
+
name: 'Fresh',
|
|
80
|
+
size: 9,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('answers with a declared error status and body', async () => {
|
|
85
|
+
server.use(
|
|
86
|
+
mock.get('/things/{id}', ({ response }) =>
|
|
87
|
+
response(404).json({ message: 'no such thing' }),
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const response = await fetch('http://localhost/things/missing');
|
|
92
|
+
|
|
93
|
+
expect(response.status).toBe(404);
|
|
94
|
+
expect(await response.json()).toEqual({ message: 'no such thing' });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('answers a no-content operation with an empty body', async () => {
|
|
98
|
+
server.use(
|
|
99
|
+
mock.delete('/things/{id}', ({ response }) => response(204).empty()),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const response = await fetch('http://localhost/things/t1', {
|
|
103
|
+
method: 'DELETE',
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
expect(response.status).toBe(204);
|
|
107
|
+
expect(await response.text()).toBe('');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('prefixes handler paths with a relative baseUrl on any origin', async () => {
|
|
111
|
+
const prefixed = createMockApi<TestPaths>({ baseUrl: '/api' });
|
|
112
|
+
server.use(
|
|
113
|
+
prefixed.get('/things', ({ response }) =>
|
|
114
|
+
response(200).json(seedThings()),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
for (const origin of ['http://localhost', 'https://elsewhere.test']) {
|
|
119
|
+
const response = await fetch(`${origin}/api/things`);
|
|
120
|
+
|
|
121
|
+
expect(response.status).toBe(200);
|
|
122
|
+
expect(await response.json()).toEqual(seedThings());
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('tolerates a trailing slash on baseUrl', async () => {
|
|
127
|
+
const prefixed = createMockApi<TestPaths>({ baseUrl: '/api/' });
|
|
128
|
+
server.use(
|
|
129
|
+
prefixed.get('/things', ({ response }) => response(200).json([])),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const response = await fetch('http://localhost/api/things');
|
|
133
|
+
|
|
134
|
+
expect(response.status).toBe(200);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('pins the origin when baseUrl is absolute', async () => {
|
|
138
|
+
const prefixed = createMockApi<TestPaths>({
|
|
139
|
+
baseUrl: 'http://localhost/api',
|
|
140
|
+
});
|
|
141
|
+
server.use(
|
|
142
|
+
prefixed.get('/things', ({ response }) => response(200).json([])),
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect((await fetch('http://localhost/api/things')).status).toBe(200);
|
|
146
|
+
await expect(fetch('https://elsewhere.test/api/things')).rejects.toThrow();
|
|
147
|
+
});
|
|
148
|
+
});
|
package/src/mock-api.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { createOpenApiHttp, type OpenApiHttpHandlers } from 'openapi-msw';
|
|
2
|
+
|
|
3
|
+
import { type MockOriginMatching, resolveHandlerBase } from './base-url.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The msw handler types this package's surface is expressed in. They are
|
|
7
|
+
* re-exported so a consumer writing a helper around a resolver types against
|
|
8
|
+
* the copy of msw this package resolves, rather than importing msw types in one
|
|
9
|
+
* file and ours in another.
|
|
10
|
+
*/
|
|
11
|
+
export type {
|
|
12
|
+
HttpHandler as MockHandler,
|
|
13
|
+
RequestHandler as MockRequestHandler,
|
|
14
|
+
} from 'msw';
|
|
15
|
+
export type { MockOriginMatching } from './base-url.js';
|
|
16
|
+
export type {
|
|
17
|
+
ResponseResolver as MockResponseResolver,
|
|
18
|
+
ResponseResolverInfo as MockResponseResolverInfo,
|
|
19
|
+
} from 'openapi-msw';
|
|
20
|
+
export type {
|
|
21
|
+
PathsFor as MockPathsFor,
|
|
22
|
+
RequestBodyFor as MockRequestBodyFor,
|
|
23
|
+
ResponseBodyFor as MockResponseBodyFor,
|
|
24
|
+
} from 'openapi-msw';
|
|
25
|
+
|
|
26
|
+
export type MockApiOptions = {
|
|
27
|
+
/**
|
|
28
|
+
* Prepended to every handler path, for an API mounted under a prefix. Given
|
|
29
|
+
* `'/api'`, a handler declared on `/things/{id}` matches `/api/things/:id`.
|
|
30
|
+
* Pass the same value the app passes to `createApiClient`.
|
|
31
|
+
*
|
|
32
|
+
* Either an origin-relative path with a leading slash (`'/api'`) or an absolute
|
|
33
|
+
* URL (`'https://api.test/v1'`); a trailing slash is tolerated. It is
|
|
34
|
+
* concatenated with the OpenAPI path, so anything else — a query string, a
|
|
35
|
+
* fragment, a missing leading slash — yields a pattern that matches nothing,
|
|
36
|
+
* which surfaces as an unhandled request rather than an error.
|
|
37
|
+
*/
|
|
38
|
+
baseUrl?: string;
|
|
39
|
+
/**
|
|
40
|
+
* How an origin-relative `baseUrl` is matched.
|
|
41
|
+
*
|
|
42
|
+
* - `'any'` (default) prefixes handler paths with msw's `*` origin wildcard, so
|
|
43
|
+
* one handler array matches both the relative request a browser app makes
|
|
44
|
+
* and the absolute URL a node test has to issue. That is what makes the same
|
|
45
|
+
* mocks reusable across dev, vitest, and Playwright.
|
|
46
|
+
* - `'exact'` leaves paths relative: same-origin matching only, and a node
|
|
47
|
+
* test then needs an absolute `baseUrl` of its own.
|
|
48
|
+
*
|
|
49
|
+
* An absolute `baseUrl` already pins the origin, so this does not apply to
|
|
50
|
+
* one.
|
|
51
|
+
*/
|
|
52
|
+
origin?: MockOriginMatching;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A typed handler factory per HTTP method, plus `untyped` — msw's own `http`
|
|
57
|
+
* object, for the rare route that is not in the OpenAPI document at all (an
|
|
58
|
+
* auth callback on another host, say).
|
|
59
|
+
*/
|
|
60
|
+
export type MockApi<TPaths extends object> = OpenApiHttpHandlers<TPaths>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Creates typed msw request-handler factories bound to a generated OpenAPI
|
|
64
|
+
* `paths` type.
|
|
65
|
+
*
|
|
66
|
+
* The generated `TPaths` is the only endpoint definition: which methods exist on
|
|
67
|
+
* which paths, what path and query params they take, and what body each status
|
|
68
|
+
* may return are all read off it. A handler for a path the API does not have, or
|
|
69
|
+
* one that answers with a body the operation does not declare, fails to compile
|
|
70
|
+
* — which is the whole point of the layer, since a fixture that silently drifts
|
|
71
|
+
* from the contract makes every test that depends on it a false pass.
|
|
72
|
+
*
|
|
73
|
+
* `TPaths` must be passed explicitly; there is no value argument to infer it
|
|
74
|
+
* from — which is why the constraint is `object` rather than the `{}` that
|
|
75
|
+
* openapi-msw itself accepts. Every primitive but `null` and `undefined`
|
|
76
|
+
* satisfies `{}`, so a mistyped type argument compiled into a factory offering
|
|
77
|
+
* no paths at all.
|
|
78
|
+
*
|
|
79
|
+
* ```ts
|
|
80
|
+
* const mock = createMockApi<paths>({ baseUrl: '/api' });
|
|
81
|
+
*
|
|
82
|
+
* const handlers = [
|
|
83
|
+
* mock.get('/things/{id}', ({ params, response }) =>
|
|
84
|
+
* response(200).json({ id: params.id, name: 'Thing' }),
|
|
85
|
+
* ),
|
|
86
|
+
* mock.get('/things', ({ response }) => response(500).json({ message: 'nope' })),
|
|
87
|
+
* ];
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
export function createMockApi<TPaths extends object>(
|
|
91
|
+
options: MockApiOptions = {},
|
|
92
|
+
): MockApi<TPaths> {
|
|
93
|
+
return createOpenApiHttp<TPaths>({
|
|
94
|
+
baseUrl: resolveHandlerBase(options.baseUrl, options.origin ?? 'any'),
|
|
95
|
+
});
|
|
96
|
+
}
|