@yabbadabbadev/pepito 0.1.0
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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +512 -0
- package/dist/failure-messages.d.ts +59 -0
- package/dist/failure-messages.js +145 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/matcher-types.d.ts +113 -0
- package/dist/matcher-types.js +1 -0
- package/dist/matchers.d.ts +17 -0
- package/dist/matchers.js +233 -0
- package/dist/mount.d.ts +45 -0
- package/dist/mount.js +61 -0
- package/dist/msw-events.d.ts +6 -0
- package/dist/msw-events.js +44 -0
- package/dist/network-singleton.d.ts +10 -0
- package/dist/network-singleton.js +16 -0
- package/dist/network.d.ts +63 -0
- package/dist/network.js +76 -0
- package/dist/request-descriptors.d.ts +79 -0
- package/dist/request-descriptors.js +49 -0
- package/dist/setup-network.d.ts +33 -0
- package/dist/setup-network.js +57 -0
- package/dist/spec-matching.d.ts +18 -0
- package/dist/spec-matching.js +66 -0
- package/dist/storage-cleanup.d.ts +34 -0
- package/dist/storage-cleanup.js +44 -0
- package/dist/traffic-registry.d.ts +91 -0
- package/dist/traffic-registry.js +131 -0
- package/package.json +92 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { recordBypassResponse, recordMatch, recordMockedResponse, recordRequestStart, recordUnhandled, } from './traffic-registry';
|
|
2
|
+
// The only file in the package that touches `worker.events` (spec risk 1:
|
|
3
|
+
// the life-cycle events API is mid-migration — see
|
|
4
|
+
// docs/knowledge/msw-browser-mode.md). If MSW changes that API, it gets
|
|
5
|
+
// fixed here without touching the registry or the matchers.
|
|
6
|
+
//
|
|
7
|
+
// Only five events: `request:end` and `unhandledException` add nothing to
|
|
8
|
+
// the registry (measured, see docs/knowledge/quiescencia-red-msw.md), so
|
|
9
|
+
// they aren't listened to.
|
|
10
|
+
/**
|
|
11
|
+
* Hooks up the traffic registry (traffic-registry.ts) to MSW's events.
|
|
12
|
+
* `setupNetwork()` calls it once per worker, before `worker.start()`.
|
|
13
|
+
*/
|
|
14
|
+
export function watchNetwork(events) {
|
|
15
|
+
events.on('request:start', ({ request, requestId }) => {
|
|
16
|
+
const url = new URL(request.url);
|
|
17
|
+
// Clone without giving up control: as soon as the handler starts
|
|
18
|
+
// reading the stream, request.clone() throws (gotcha 1,
|
|
19
|
+
// docs/knowledge/msw-browser-mode.md).
|
|
20
|
+
const clone = request.clone();
|
|
21
|
+
recordRequestStart(requestId, {
|
|
22
|
+
method: request.method,
|
|
23
|
+
origin: url.origin,
|
|
24
|
+
path: url.pathname,
|
|
25
|
+
searchParams: Object.fromEntries(url.searchParams),
|
|
26
|
+
body: clone.json().catch(() => undefined),
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
events.on('request:match', ({ requestId }) => {
|
|
30
|
+
recordMatch(requestId);
|
|
31
|
+
});
|
|
32
|
+
events.on('request:unhandled', ({ requestId }) => {
|
|
33
|
+
recordUnhandled(requestId);
|
|
34
|
+
});
|
|
35
|
+
events.on('response:mocked', ({ response, requestId }) => {
|
|
36
|
+
recordMockedResponse(requestId, response.status, response
|
|
37
|
+
.clone()
|
|
38
|
+
.json()
|
|
39
|
+
.catch(() => undefined));
|
|
40
|
+
});
|
|
41
|
+
events.on('response:bypass', ({ response, requestId }) => {
|
|
42
|
+
recordBypassResponse(requestId, response.status);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SetupWorker } from 'msw/browser';
|
|
2
|
+
/** What `mount` needs from `setupNetwork()`: the worker to register test handlers on and the original `href` to restore between tests. */
|
|
3
|
+
export interface NetworkContext {
|
|
4
|
+
worker: SetupWorker;
|
|
5
|
+
initialHref: string;
|
|
6
|
+
}
|
|
7
|
+
/** Publishes `setupNetwork()`'s context for `mount` to read; one call per test file. */
|
|
8
|
+
export declare function registerNetworkContext(nextContext: NetworkContext): void;
|
|
9
|
+
/** Reads the published context or throws with a fix instruction if `setupNetwork()` wasn't called before `caller`. */
|
|
10
|
+
export declare function requireNetworkContext(caller: string): NetworkContext;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Module per test file in browser mode (docs/knowledge/aislamiento-tests.md):
|
|
2
|
+
// this variable doesn't leak between files, so a file that never calls
|
|
3
|
+
// setupNetwork always sees `undefined` here, with no need for an explicit
|
|
4
|
+
// reset.
|
|
5
|
+
let context;
|
|
6
|
+
/** Publishes `setupNetwork()`'s context for `mount` to read; one call per test file. */
|
|
7
|
+
export function registerNetworkContext(nextContext) {
|
|
8
|
+
context = nextContext;
|
|
9
|
+
}
|
|
10
|
+
/** Reads the published context or throws with a fix instruction if `setupNetwork()` wasn't called before `caller`. */
|
|
11
|
+
export function requireNetworkContext(caller) {
|
|
12
|
+
if (!context) {
|
|
13
|
+
throw new Error(`pepito: setupNetwork(handlers) has not been initialized. Call it in your setup file (setupFiles in vitest.config) before using ${caller}.`);
|
|
14
|
+
}
|
|
15
|
+
return context;
|
|
16
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opaque marker that `expect.network()` hangs off. It doesn't identify a
|
|
3
|
+
* request but the overall traffic observed by the registry: a symbol keeps
|
|
4
|
+
* any real value from the application (a string, a spec object) from
|
|
5
|
+
* accidentally slipping in where only this marker makes sense —
|
|
6
|
+
* `toHaveNoUnhandledRequests` requires it with `===` before looking at the
|
|
7
|
+
* traffic.
|
|
8
|
+
*/
|
|
9
|
+
export declare const NETWORK_TARGET: unique symbol;
|
|
10
|
+
/**
|
|
11
|
+
* Diagnostic utilities over the observed traffic, for inspecting it outside
|
|
12
|
+
* an assertion (for example, while debugging a failing test).
|
|
13
|
+
*/
|
|
14
|
+
export declare const network: {
|
|
15
|
+
/**
|
|
16
|
+
* Waits for the network to settle and dumps the observed traffic via
|
|
17
|
+
* `console.log`, in the same format that appears in the network matchers'
|
|
18
|
+
* failure messages.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* await fetch('/api/products')
|
|
23
|
+
* await network.log()
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
log(): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Waits for the network to settle, with the same double-observation
|
|
29
|
+
* mechanism the network matchers use (`snapshotAfterIdle` in
|
|
30
|
+
* matchers.ts) — it doesn't duplicate it, it only discards the snapshot it
|
|
31
|
+
* produces. Guarantees that every request seen up to the moment of the
|
|
32
|
+
* call has closed (with `response:mocked`, `response:bypass` or the 500
|
|
33
|
+
* MSW fabricates when a handler throws). It does not guarantee the
|
|
34
|
+
* absence of future traffic: the same practical blind window remains as
|
|
35
|
+
* for the rest of the mechanism — a request fired in the same tick as the
|
|
36
|
+
* call, before crossing the real round trip to the service worker (1–6 ms
|
|
37
|
+
* measured; see `.claude/docs/references/measured-foundations.md`), may
|
|
38
|
+
* not be in the registry yet when `network.idle()` resolves.
|
|
39
|
+
*
|
|
40
|
+
* Meant for visual regression: capturing right after mounting, with a
|
|
41
|
+
* slow network in the mix, produces a STABLE but wrong baseline — the
|
|
42
|
+
* native stabilizer of `toMatchScreenshot` doesn't catch it because
|
|
43
|
+
* "Loading…" is also a capture that stops changing between frames
|
|
44
|
+
* (measured: 0 failures across 17 local runs + 3 in CI waiting for calm
|
|
45
|
+
* this way — see `.claude/docs/references/measured-foundations.md`).
|
|
46
|
+
* Before this, the only public way to wait for calm on its own was to
|
|
47
|
+
* divert `expect.network().toHaveNoUnhandledRequests()` from its actual
|
|
48
|
+
* purpose (detecting traffic without a handler). It works just as well as a
|
|
49
|
+
* generic "wait for the network to settle", without asserting anything.
|
|
50
|
+
*
|
|
51
|
+
* @example
|
|
52
|
+
* ```ts
|
|
53
|
+
* const screen = await mount(<App />)
|
|
54
|
+
* await network.idle()
|
|
55
|
+
* await expect.element(screen.getByRole('main')).toMatchScreenshot('catalog')
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* @throws If the network doesn't settle within the `QUIESCENCE_TIMEOUT_MS`
|
|
59
|
+
* budget, with the dump of the requests that were still in flight — the
|
|
60
|
+
* same error `waitForNetworkIdle` throws.
|
|
61
|
+
*/
|
|
62
|
+
idle(): Promise<void>;
|
|
63
|
+
};
|
package/dist/network.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { formatTraffic } from './failure-messages';
|
|
2
|
+
// Cycle with matchers.ts (which imports NETWORK_TARGET from this file,
|
|
3
|
+
// below): safe because both uses live inside function bodies that run
|
|
4
|
+
// deferred, never during module evaluation — `import`/`function` hoisting
|
|
5
|
+
// resolves them at call time. Changing either side to a module-level
|
|
6
|
+
// const-arrow would break the cycle, with one of the two sides seeing
|
|
7
|
+
// `undefined` on load.
|
|
8
|
+
import { snapshotAfterIdle } from './matchers';
|
|
9
|
+
/**
|
|
10
|
+
* Opaque marker that `expect.network()` hangs off. It doesn't identify a
|
|
11
|
+
* request but the overall traffic observed by the registry: a symbol keeps
|
|
12
|
+
* any real value from the application (a string, a spec object) from
|
|
13
|
+
* accidentally slipping in where only this marker makes sense —
|
|
14
|
+
* `toHaveNoUnhandledRequests` requires it with `===` before looking at the
|
|
15
|
+
* traffic.
|
|
16
|
+
*/
|
|
17
|
+
export const NETWORK_TARGET = Symbol('pepito:network-target');
|
|
18
|
+
/**
|
|
19
|
+
* Diagnostic utilities over the observed traffic, for inspecting it outside
|
|
20
|
+
* an assertion (for example, while debugging a failing test).
|
|
21
|
+
*/
|
|
22
|
+
export const network = {
|
|
23
|
+
/**
|
|
24
|
+
* Waits for the network to settle and dumps the observed traffic via
|
|
25
|
+
* `console.log`, in the same format that appears in the network matchers'
|
|
26
|
+
* failure messages.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* await fetch('/api/products')
|
|
31
|
+
* await network.log()
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
async log() {
|
|
35
|
+
const traffic = await snapshotAfterIdle();
|
|
36
|
+
console.log(formatTraffic(traffic));
|
|
37
|
+
},
|
|
38
|
+
/**
|
|
39
|
+
* Waits for the network to settle, with the same double-observation
|
|
40
|
+
* mechanism the network matchers use (`snapshotAfterIdle` in
|
|
41
|
+
* matchers.ts) — it doesn't duplicate it, it only discards the snapshot it
|
|
42
|
+
* produces. Guarantees that every request seen up to the moment of the
|
|
43
|
+
* call has closed (with `response:mocked`, `response:bypass` or the 500
|
|
44
|
+
* MSW fabricates when a handler throws). It does not guarantee the
|
|
45
|
+
* absence of future traffic: the same practical blind window remains as
|
|
46
|
+
* for the rest of the mechanism — a request fired in the same tick as the
|
|
47
|
+
* call, before crossing the real round trip to the service worker (1–6 ms
|
|
48
|
+
* measured; see `.claude/docs/references/measured-foundations.md`), may
|
|
49
|
+
* not be in the registry yet when `network.idle()` resolves.
|
|
50
|
+
*
|
|
51
|
+
* Meant for visual regression: capturing right after mounting, with a
|
|
52
|
+
* slow network in the mix, produces a STABLE but wrong baseline — the
|
|
53
|
+
* native stabilizer of `toMatchScreenshot` doesn't catch it because
|
|
54
|
+
* "Loading…" is also a capture that stops changing between frames
|
|
55
|
+
* (measured: 0 failures across 17 local runs + 3 in CI waiting for calm
|
|
56
|
+
* this way — see `.claude/docs/references/measured-foundations.md`).
|
|
57
|
+
* Before this, the only public way to wait for calm on its own was to
|
|
58
|
+
* divert `expect.network().toHaveNoUnhandledRequests()` from its actual
|
|
59
|
+
* purpose (detecting traffic without a handler). It works just as well as a
|
|
60
|
+
* generic "wait for the network to settle", without asserting anything.
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* ```ts
|
|
64
|
+
* const screen = await mount(<App />)
|
|
65
|
+
* await network.idle()
|
|
66
|
+
* await expect.element(screen.getByRole('main')).toMatchScreenshot('catalog')
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* @throws If the network doesn't settle within the `QUIESCENCE_TIMEOUT_MS`
|
|
70
|
+
* budget, with the dump of the requests that were still in flight — the
|
|
71
|
+
* same error `waitForNetworkIdle` throws.
|
|
72
|
+
*/
|
|
73
|
+
async idle() {
|
|
74
|
+
await snapshotAfterIdle();
|
|
75
|
+
},
|
|
76
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the expected request is compared against the observed one.
|
|
3
|
+
* `searchParams` and `body` match by subset of top-level keys with deep
|
|
4
|
+
* equality per key — `{ body: { productName: 'Milk' } }` matches even if
|
|
5
|
+
* the real request also carries `id` or `createdAt` — unless `exact: true`,
|
|
6
|
+
* which requires strict equality of the whole object.
|
|
7
|
+
*
|
|
8
|
+
* A repeated key in the real query (`?tag=a&tag=b`) collapses to a single
|
|
9
|
+
* value — the last one — before comparing, because the registry builds
|
|
10
|
+
* `searchParams` with `Object.fromEntries`: if your application relies on a
|
|
11
|
+
* key appearing more than once, this comparison won't detect it.
|
|
12
|
+
*/
|
|
13
|
+
export interface RequestSpecOptions {
|
|
14
|
+
searchParams?: Record<string, string>;
|
|
15
|
+
body?: unknown;
|
|
16
|
+
exact?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Describes the request a network matcher looks for in the observed
|
|
20
|
+
* traffic. Built with `request()` or one of its shortcuts (`get`, `post`,
|
|
21
|
+
* `put`, `patch`, `del`, `query`); there's no need to build it by hand.
|
|
22
|
+
*
|
|
23
|
+
* Matching ignores `origin`: `get('/api/x')` matches both a same-origin
|
|
24
|
+
* request to `/api/x` and one to `https://other.host/api/x`, if some
|
|
25
|
+
* handler responds there. The traffic registry does keep the `origin` of
|
|
26
|
+
* each request — not used yet, but it leaves room for an assertion by host
|
|
27
|
+
* the day two hosts share the same path.
|
|
28
|
+
*/
|
|
29
|
+
export interface RequestSpec extends RequestSpecOptions {
|
|
30
|
+
method: string;
|
|
31
|
+
path: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Escape hatch for any HTTP method, including ones MSW 2.15 doesn't yet
|
|
35
|
+
* expose as its own helper (`QUERY`). The shortcuts (`get`, `post`, …) are
|
|
36
|
+
* `request(fixedMethod, ...)`; for any other method, or to leave the method
|
|
37
|
+
* explicit in the test itself, use this directly.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* import { request } from '@yabbadabbadev/pepito'
|
|
42
|
+
*
|
|
43
|
+
* await expect(request('QUERY', '/api/products')).toHaveBeenRequested()
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export declare function request(method: string, path: string, options?: RequestSpecOptions): RequestSpec;
|
|
47
|
+
/**
|
|
48
|
+
* Describes an expected `GET` request.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* import { get } from '@yabbadabbadev/pepito'
|
|
53
|
+
*
|
|
54
|
+
* await expect(get('/api/products', { searchParams: { filter: 'bread' } })).toHaveBeenRequested()
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export declare const get: (path: string, options?: RequestSpecOptions) => RequestSpec;
|
|
58
|
+
/** Describes an expected `POST` request; same shape as {@link get}. */
|
|
59
|
+
export declare const post: typeof get;
|
|
60
|
+
/** Describes an expected `PUT` request; same shape as {@link get}. */
|
|
61
|
+
export declare const put: typeof get;
|
|
62
|
+
/** Describes an expected `PATCH` request; same shape as {@link get}. */
|
|
63
|
+
export declare const patch: typeof get;
|
|
64
|
+
/**
|
|
65
|
+
* Describes an expected `DELETE` request; same shape as {@link get}.
|
|
66
|
+
* Named `del` because `delete` is a reserved word.
|
|
67
|
+
*/
|
|
68
|
+
export declare const del: typeof get;
|
|
69
|
+
/**
|
|
70
|
+
* Describes an expected `QUERY` request — the method this package's spec
|
|
71
|
+
* introduces, between `GET` and `POST`. The relevant option is usually
|
|
72
|
+
* `searchParams`, not `body`: it's called `searchParams` and not `query` on
|
|
73
|
+
* purpose, because `query` is already the name of this HTTP method in the
|
|
74
|
+
* same API. MSW 2.15 doesn't expose `query` as a handler helper yet — it's
|
|
75
|
+
* declared with `http.all` and filtered by method — but pepito's traffic
|
|
76
|
+
* registry observes it just the same, because it reads `request.method` as
|
|
77
|
+
* a plain string.
|
|
78
|
+
*/
|
|
79
|
+
export declare const query: typeof get;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape hatch for any HTTP method, including ones MSW 2.15 doesn't yet
|
|
3
|
+
* expose as its own helper (`QUERY`). The shortcuts (`get`, `post`, …) are
|
|
4
|
+
* `request(fixedMethod, ...)`; for any other method, or to leave the method
|
|
5
|
+
* explicit in the test itself, use this directly.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { request } from '@yabbadabbadev/pepito'
|
|
10
|
+
*
|
|
11
|
+
* await expect(request('QUERY', '/api/products')).toHaveBeenRequested()
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
export function request(method, path, options) {
|
|
15
|
+
return { method, path, ...options };
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Describes an expected `GET` request.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* import { get } from '@yabbadabbadev/pepito'
|
|
23
|
+
*
|
|
24
|
+
* await expect(get('/api/products', { searchParams: { filter: 'bread' } })).toHaveBeenRequested()
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export const get = (path, options) => request('GET', path, options);
|
|
28
|
+
/** Describes an expected `POST` request; same shape as {@link get}. */
|
|
29
|
+
export const post = (path, options) => request('POST', path, options);
|
|
30
|
+
/** Describes an expected `PUT` request; same shape as {@link get}. */
|
|
31
|
+
export const put = (path, options) => request('PUT', path, options);
|
|
32
|
+
/** Describes an expected `PATCH` request; same shape as {@link get}. */
|
|
33
|
+
export const patch = (path, options) => request('PATCH', path, options);
|
|
34
|
+
/**
|
|
35
|
+
* Describes an expected `DELETE` request; same shape as {@link get}.
|
|
36
|
+
* Named `del` because `delete` is a reserved word.
|
|
37
|
+
*/
|
|
38
|
+
export const del = (path, options) => request('DELETE', path, options);
|
|
39
|
+
/**
|
|
40
|
+
* Describes an expected `QUERY` request — the method this package's spec
|
|
41
|
+
* introduces, between `GET` and `POST`. The relevant option is usually
|
|
42
|
+
* `searchParams`, not `body`: it's called `searchParams` and not `query` on
|
|
43
|
+
* purpose, because `query` is already the name of this HTTP method in the
|
|
44
|
+
* same API. MSW 2.15 doesn't expose `query` as a handler helper yet — it's
|
|
45
|
+
* declared with `http.all` and filtered by method — but pepito's traffic
|
|
46
|
+
* registry observes it just the same, because it reads `request.method` as
|
|
47
|
+
* a plain string.
|
|
48
|
+
*/
|
|
49
|
+
export const query = (path, options) => request('QUERY', path, options);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RequestHandler } from 'msw';
|
|
2
|
+
import type { SetupWorker, StartOptions } from 'msw/browser';
|
|
3
|
+
/**
|
|
4
|
+
* Starts the MSW worker and hooks up the traffic registry, leaving the
|
|
5
|
+
* between-test cleanup installed in `afterEach`: registry, hot handlers,
|
|
6
|
+
* document URL and origin storage all return to their pre-test state
|
|
7
|
+
* (measured — see `.claude/docs/references/measured-foundations.md`).
|
|
8
|
+
*
|
|
9
|
+
* Called once per test file, typically from a `setupFiles` entry in
|
|
10
|
+
* `vitest.config`, never from inside a test.
|
|
11
|
+
*
|
|
12
|
+
* Known cleanup limit: a cookie set with an explicit `path` other than `/`
|
|
13
|
+
* (or with `domain`) can't be enumerated from `document.cookie`, so it
|
|
14
|
+
* survives between tests — see `clearOriginStorage` in storage-cleanup.ts.
|
|
15
|
+
* A cookie without an explicit `path` does get cleared, including one set
|
|
16
|
+
* while `mount` was simulating being on a nested route (measured — see
|
|
17
|
+
* `.claude/docs/references/measured-foundations.md`).
|
|
18
|
+
*
|
|
19
|
+
* @param handlers - Initial MSW handlers, the same ones `setupWorker` would take.
|
|
20
|
+
* @param startOptions - Passed through to `worker.start()` as-is; no wrapper of its own.
|
|
21
|
+
* @returns The MSW `SetupWorker`, so individual tests can call `worker.use()`.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* import { http, HttpResponse } from 'msw'
|
|
26
|
+
* import { setupNetwork } from '@yabbadabbadev/pepito'
|
|
27
|
+
*
|
|
28
|
+
* setupNetwork([
|
|
29
|
+
* http.get('/api/products', () => HttpResponse.json([])),
|
|
30
|
+
* ])
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export declare function setupNetwork(handlers: RequestHandler[], startOptions?: StartOptions): SetupWorker;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { setupWorker } from 'msw/browser';
|
|
2
|
+
import { afterEach, beforeAll } from 'vitest';
|
|
3
|
+
import { watchNetwork } from './msw-events';
|
|
4
|
+
import { registerNetworkContext, requireNetworkContext, } from './network-singleton';
|
|
5
|
+
import { clearOriginStorage } from './storage-cleanup';
|
|
6
|
+
import { resetTraffic } from './traffic-registry';
|
|
7
|
+
/**
|
|
8
|
+
* Starts the MSW worker and hooks up the traffic registry, leaving the
|
|
9
|
+
* between-test cleanup installed in `afterEach`: registry, hot handlers,
|
|
10
|
+
* document URL and origin storage all return to their pre-test state
|
|
11
|
+
* (measured — see `.claude/docs/references/measured-foundations.md`).
|
|
12
|
+
*
|
|
13
|
+
* Called once per test file, typically from a `setupFiles` entry in
|
|
14
|
+
* `vitest.config`, never from inside a test.
|
|
15
|
+
*
|
|
16
|
+
* Known cleanup limit: a cookie set with an explicit `path` other than `/`
|
|
17
|
+
* (or with `domain`) can't be enumerated from `document.cookie`, so it
|
|
18
|
+
* survives between tests — see `clearOriginStorage` in storage-cleanup.ts.
|
|
19
|
+
* A cookie without an explicit `path` does get cleared, including one set
|
|
20
|
+
* while `mount` was simulating being on a nested route (measured — see
|
|
21
|
+
* `.claude/docs/references/measured-foundations.md`).
|
|
22
|
+
*
|
|
23
|
+
* @param handlers - Initial MSW handlers, the same ones `setupWorker` would take.
|
|
24
|
+
* @param startOptions - Passed through to `worker.start()` as-is; no wrapper of its own.
|
|
25
|
+
* @returns The MSW `SetupWorker`, so individual tests can call `worker.use()`.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* import { http, HttpResponse } from 'msw'
|
|
30
|
+
* import { setupNetwork } from '@yabbadabbadev/pepito'
|
|
31
|
+
*
|
|
32
|
+
* setupNetwork([
|
|
33
|
+
* http.get('/api/products', () => HttpResponse.json([])),
|
|
34
|
+
* ])
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function setupNetwork(handlers, startOptions) {
|
|
38
|
+
const worker = setupWorker(...handlers);
|
|
39
|
+
// The registry is hooked up BEFORE start(): start is the earliest point
|
|
40
|
+
// that can generate traffic.
|
|
41
|
+
watchNetwork(worker.events);
|
|
42
|
+
registerNetworkContext({ worker, initialHref: location.href });
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
await worker.start({ quiet: true, ...startOptions });
|
|
45
|
+
});
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
resetTraffic();
|
|
48
|
+
worker.resetHandlers();
|
|
49
|
+
// clearOriginStorage() goes BEFORE restoring the URL, not after:
|
|
50
|
+
// defensive ordering per RFC 6265, with no observable effect measured
|
|
51
|
+
// in this harness — see the TSDoc on clearOriginStorage in
|
|
52
|
+
// storage-cleanup.ts.
|
|
53
|
+
clearOriginStorage();
|
|
54
|
+
history.replaceState({}, '', requireNetworkContext('setupNetwork').initialHref);
|
|
55
|
+
});
|
|
56
|
+
return worker;
|
|
57
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { RequestSpec } from './request-descriptors';
|
|
2
|
+
import type { ResolvedRequest } from './traffic-registry';
|
|
3
|
+
/**
|
|
4
|
+
* Compares an observed value against an expected one by subset of
|
|
5
|
+
* top-level keys with deep equality per key (or direct deep equality if
|
|
6
|
+
* `expected` isn't a plain object), unless `exact: true`, which requires
|
|
7
|
+
* strict equality of the whole object. Used both by `matchesSpec` over the
|
|
8
|
+
* request's `body` and by `toHaveRespondedWith` over the `responseBody`:
|
|
9
|
+
* same subset semantics, two different places it's applied.
|
|
10
|
+
*/
|
|
11
|
+
export declare function matchesBody(observed: unknown, expected: unknown, exact: boolean | undefined): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Compares an observed request against a `RequestSpec`: method and path by
|
|
14
|
+
* strict equality; `body` and `searchParams` by subset of top-level keys
|
|
15
|
+
* with deep equality per key, unless `{ exact: true }`, which requires
|
|
16
|
+
* strict equality of the whole object.
|
|
17
|
+
*/
|
|
18
|
+
export declare function matchesSpec(observed: ResolvedRequest, spec: RequestSpec): boolean;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
function deepEqual(a, b) {
|
|
2
|
+
if (a === b)
|
|
3
|
+
return true;
|
|
4
|
+
if (typeof a !== 'object' ||
|
|
5
|
+
typeof b !== 'object' ||
|
|
6
|
+
a === null ||
|
|
7
|
+
b === null) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
if (Array.isArray(a) !== Array.isArray(b))
|
|
11
|
+
return false;
|
|
12
|
+
const keysA = Object.keys(a);
|
|
13
|
+
const keysB = Object.keys(b);
|
|
14
|
+
if (keysA.length !== keysB.length)
|
|
15
|
+
return false;
|
|
16
|
+
return keysA.every((key) => deepEqual(a[key], b[key]));
|
|
17
|
+
}
|
|
18
|
+
// Excludes null (typeof null === 'object') and arrays: both match by direct
|
|
19
|
+
// deep equality, not by subset of keys.
|
|
20
|
+
function isPlainObject(candidate) {
|
|
21
|
+
return (typeof candidate === 'object' &&
|
|
22
|
+
candidate !== null &&
|
|
23
|
+
!Array.isArray(candidate));
|
|
24
|
+
}
|
|
25
|
+
function matchesSubset(observed, expected, exact) {
|
|
26
|
+
if (exact)
|
|
27
|
+
return deepEqual(observed, expected);
|
|
28
|
+
return Object.keys(expected).every((key) => deepEqual(observed[key], expected[key]));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Compares an observed value against an expected one by subset of
|
|
32
|
+
* top-level keys with deep equality per key (or direct deep equality if
|
|
33
|
+
* `expected` isn't a plain object), unless `exact: true`, which requires
|
|
34
|
+
* strict equality of the whole object. Used both by `matchesSpec` over the
|
|
35
|
+
* request's `body` and by `toHaveRespondedWith` over the `responseBody`:
|
|
36
|
+
* same subset semantics, two different places it's applied.
|
|
37
|
+
*/
|
|
38
|
+
export function matchesBody(observed, expected, exact) {
|
|
39
|
+
if (!isPlainObject(expected))
|
|
40
|
+
return deepEqual(observed, expected);
|
|
41
|
+
return isPlainObject(observed) && matchesSubset(observed, expected, exact);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Compares an observed request against a `RequestSpec`: method and path by
|
|
45
|
+
* strict equality; `body` and `searchParams` by subset of top-level keys
|
|
46
|
+
* with deep equality per key, unless `{ exact: true }`, which requires
|
|
47
|
+
* strict equality of the whole object.
|
|
48
|
+
*/
|
|
49
|
+
export function matchesSpec(observed, spec) {
|
|
50
|
+
if (observed.method !== spec.method || observed.path !== spec.path) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (spec.searchParams !== undefined &&
|
|
54
|
+
// observed.searchParams comes from Object.fromEntries(url.searchParams):
|
|
55
|
+
// with a repeated key (`?tag=a&tag=b`) only the last value survives. The
|
|
56
|
+
// subset comparison checks that single value without being able to
|
|
57
|
+
// detect the loss.
|
|
58
|
+
!matchesSubset(observed.searchParams, spec.searchParams, spec.exact)) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
if (spec.body !== undefined &&
|
|
62
|
+
!matchesBody(observed.body, spec.body, spec.exact)) {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clears the origin storage that browser mode leaks between test files that
|
|
3
|
+
* land on the same worker: `localStorage`, `sessionStorage` and cookies
|
|
4
|
+
* belong to the origin, not the document, so they survive per-file
|
|
5
|
+
* isolation (measured — see
|
|
6
|
+
* `.claude/docs/references/measured-foundations.md`).
|
|
7
|
+
*
|
|
8
|
+
* Each cookie is expired twice per name: once with `path=/` (the one most
|
|
9
|
+
* application code sets) and once with no `path` attribute, in case one was
|
|
10
|
+
* set without it. `setupNetwork()` calls this function BEFORE restoring the
|
|
11
|
+
* URL in its `afterEach`, as a defensive ordering: per RFC 6265, the
|
|
12
|
+
* default `path` of a cookie without the attribute is, in theory, computed
|
|
13
|
+
* from the active URL both when it's set and when it's deleted, so clearing
|
|
14
|
+
* before restoring the URL would be the correct order IF that computation
|
|
15
|
+
* followed the routes `setupNetwork()` simulates with `history.pushState`.
|
|
16
|
+
*
|
|
17
|
+
* Measured that it doesn't, in this harness (Chromium via Playwright,
|
|
18
|
+
* `vitest@4.1.10`): the order has no observable effect today — full
|
|
19
|
+
* evidence in `.claude/docs/references/measured-foundations.md`. The order
|
|
20
|
+
* is kept anyway, at no cost, in case some future runner or browser does
|
|
21
|
+
* follow the simulated URL.
|
|
22
|
+
*
|
|
23
|
+
* Known and actually verified limit: a cookie set with an explicit `path`
|
|
24
|
+
* or with `domain` can't even be enumerated from `document.cookie` — it
|
|
25
|
+
* stays alive after this call.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* afterEach(() => {
|
|
30
|
+
* clearOriginStorage()
|
|
31
|
+
* })
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export declare function clearOriginStorage(): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clears the origin storage that browser mode leaks between test files that
|
|
3
|
+
* land on the same worker: `localStorage`, `sessionStorage` and cookies
|
|
4
|
+
* belong to the origin, not the document, so they survive per-file
|
|
5
|
+
* isolation (measured — see
|
|
6
|
+
* `.claude/docs/references/measured-foundations.md`).
|
|
7
|
+
*
|
|
8
|
+
* Each cookie is expired twice per name: once with `path=/` (the one most
|
|
9
|
+
* application code sets) and once with no `path` attribute, in case one was
|
|
10
|
+
* set without it. `setupNetwork()` calls this function BEFORE restoring the
|
|
11
|
+
* URL in its `afterEach`, as a defensive ordering: per RFC 6265, the
|
|
12
|
+
* default `path` of a cookie without the attribute is, in theory, computed
|
|
13
|
+
* from the active URL both when it's set and when it's deleted, so clearing
|
|
14
|
+
* before restoring the URL would be the correct order IF that computation
|
|
15
|
+
* followed the routes `setupNetwork()` simulates with `history.pushState`.
|
|
16
|
+
*
|
|
17
|
+
* Measured that it doesn't, in this harness (Chromium via Playwright,
|
|
18
|
+
* `vitest@4.1.10`): the order has no observable effect today — full
|
|
19
|
+
* evidence in `.claude/docs/references/measured-foundations.md`. The order
|
|
20
|
+
* is kept anyway, at no cost, in case some future runner or browser does
|
|
21
|
+
* follow the simulated URL.
|
|
22
|
+
*
|
|
23
|
+
* Known and actually verified limit: a cookie set with an explicit `path`
|
|
24
|
+
* or with `domain` can't even be enumerated from `document.cookie` — it
|
|
25
|
+
* stays alive after this call.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```ts
|
|
29
|
+
* afterEach(() => {
|
|
30
|
+
* clearOriginStorage()
|
|
31
|
+
* })
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function clearOriginStorage() {
|
|
35
|
+
localStorage.clear();
|
|
36
|
+
sessionStorage.clear();
|
|
37
|
+
for (const cookie of document.cookie.split(';')) {
|
|
38
|
+
const name = cookie.split('=')[0]?.trim();
|
|
39
|
+
if (!name)
|
|
40
|
+
continue;
|
|
41
|
+
document.cookie = `${name}=;expires=${new Date(0).toUTCString()};path=/`;
|
|
42
|
+
document.cookie = `${name}=;expires=${new Date(0).toUTCString()}`;
|
|
43
|
+
}
|
|
44
|
+
}
|