@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,145 @@
|
|
|
1
|
+
import { matchesSpec } from './spec-matching';
|
|
2
|
+
const TRAFFIC_FLAGS = ['matched', 'mocked', 'bypassed', 'unhandled'];
|
|
3
|
+
function formatQuery(searchParams) {
|
|
4
|
+
const query = new URLSearchParams(searchParams).toString();
|
|
5
|
+
return query ? `?${query}` : '';
|
|
6
|
+
}
|
|
7
|
+
function formatFlags(entry) {
|
|
8
|
+
const activeFlags = TRAFFIC_FLAGS.filter((flag) => entry[flag]);
|
|
9
|
+
return activeFlags.length > 0 ? activeFlags.join('/') : 'no verdict';
|
|
10
|
+
}
|
|
11
|
+
function formatTrafficLine(entry) {
|
|
12
|
+
const status = entry.status ?? '(no response)';
|
|
13
|
+
return ` ${entry.method} ${entry.path}${formatQuery(entry.searchParams)} → ${status} [${formatFlags(entry)}]`;
|
|
14
|
+
}
|
|
15
|
+
/** Dumps the observed traffic, one line per entry, to embed in a failure message. */
|
|
16
|
+
export function formatTraffic(traffic) {
|
|
17
|
+
if (traffic.length === 0)
|
|
18
|
+
return ' (no traffic observed)';
|
|
19
|
+
return traffic.map(formatTrafficLine).join('\n');
|
|
20
|
+
}
|
|
21
|
+
// Only what identifies the expected request, not the comparison options
|
|
22
|
+
// (`exact`): that's a mode of matchesSpec, not something one "expected to
|
|
23
|
+
// receive".
|
|
24
|
+
function describeSpec(spec) {
|
|
25
|
+
return {
|
|
26
|
+
method: spec.method,
|
|
27
|
+
path: spec.path,
|
|
28
|
+
searchParams: spec.searchParams,
|
|
29
|
+
body: spec.body,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Composes the network matchers' failure message: the matcher's hint, what
|
|
34
|
+
* was expected, a diff against the closest candidate (same method and path,
|
|
35
|
+
* even if it doesn't fully match) if one exists, and the full dump of the
|
|
36
|
+
* observed traffic.
|
|
37
|
+
*/
|
|
38
|
+
export function requestFailureMessage(messageContext) {
|
|
39
|
+
const { utils, matcherName, spec, traffic, isNot } = messageContext;
|
|
40
|
+
const sections = [
|
|
41
|
+
utils.matcherHint(matcherName, undefined, undefined, { isNot }),
|
|
42
|
+
// Under `.not`, what failed is that there WAS a matching request: the
|
|
43
|
+
// hint already says so with the "not.", but the "Expected" line by
|
|
44
|
+
// itself would still read as the positive case if it didn't change
|
|
45
|
+
// along with it.
|
|
46
|
+
isNot
|
|
47
|
+
? `Not expected: ${utils.printExpected(describeSpec(spec))}`
|
|
48
|
+
: `Expected: ${utils.printExpected(describeSpec(spec))}`,
|
|
49
|
+
];
|
|
50
|
+
const candidate = traffic.find((entry) => entry.method === spec.method && entry.path === spec.path);
|
|
51
|
+
if (candidate) {
|
|
52
|
+
const diff = utils.diff({ searchParams: spec.searchParams, body: spec.body }, { searchParams: candidate.searchParams, body: candidate.body });
|
|
53
|
+
if (diff)
|
|
54
|
+
sections.push(diff);
|
|
55
|
+
}
|
|
56
|
+
sections.push(`Observed traffic:\n${formatTraffic(traffic)}`);
|
|
57
|
+
return sections.join('\n\n');
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Composes the `toHaveBeenRequestedTimes` failure message: there's no
|
|
61
|
+
* "closest candidate" to show like in `requestFailureMessage`, but an
|
|
62
|
+
* expected count against the one actually observed, plus the full dump.
|
|
63
|
+
*/
|
|
64
|
+
export function requestCountFailureMessage(messageContext) {
|
|
65
|
+
const { utils, spec, expectedCount, foundCount, traffic, isNot } = messageContext;
|
|
66
|
+
// Under `.not`, `foundCount` is necessarily equal to `expectedCount` (that's
|
|
67
|
+
// what made the negated assertion fail): a bare "found 2" would read as a
|
|
68
|
+
// meaningless echo if it didn't also say that count is exactly the one
|
|
69
|
+
// that wasn't expected.
|
|
70
|
+
const leadLine = isNot
|
|
71
|
+
? `Did not expect exactly ${expectedCount} request(s) to ${utils.printExpected(describeSpec(spec))}, yet found ${foundCount}`
|
|
72
|
+
: `Expected ${expectedCount} request(s) to ${utils.printExpected(describeSpec(spec))}, found ${foundCount}`;
|
|
73
|
+
const sections = [
|
|
74
|
+
utils.matcherHint('toHaveBeenRequestedTimes', undefined, undefined, {
|
|
75
|
+
isNot,
|
|
76
|
+
}),
|
|
77
|
+
leadLine,
|
|
78
|
+
`Observed traffic:\n${formatTraffic(traffic)}`,
|
|
79
|
+
];
|
|
80
|
+
return sections.join('\n\n');
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Composes the `toHaveNoUnhandledRequests` failure message: which requests
|
|
84
|
+
* arrived without a handler (method and path, the minimum needed to locate
|
|
85
|
+
* them in the code) followed by the full traffic dump, in case the missing
|
|
86
|
+
* handler becomes obvious when seen alongside the rest.
|
|
87
|
+
*/
|
|
88
|
+
export function noUnhandledRequestsFailureMessage(messageContext) {
|
|
89
|
+
const { utils, unhandledEntries, traffic, isNot } = messageContext;
|
|
90
|
+
// Under `.not`, what failed is that `unhandledEntries` is EMPTY — listing
|
|
91
|
+
// "Unhandled requests:" followed by nothing is the bug this `isNot`
|
|
92
|
+
// fixes: the negated assertion demanded traffic without a handler and
|
|
93
|
+
// there was none.
|
|
94
|
+
const leadLine = isNot
|
|
95
|
+
? 'Expected to find some request without a handler, but the traffic came in clean'
|
|
96
|
+
: `Unhandled requests:\n${unhandledEntries
|
|
97
|
+
.map((entry) => ` ${entry.method} ${entry.path}`)
|
|
98
|
+
.join('\n')}`;
|
|
99
|
+
const sections = [
|
|
100
|
+
utils.matcherHint('toHaveNoUnhandledRequests', undefined, undefined, {
|
|
101
|
+
isNot,
|
|
102
|
+
}),
|
|
103
|
+
leadLine,
|
|
104
|
+
`Observed traffic:\n${formatTraffic(traffic)}`,
|
|
105
|
+
];
|
|
106
|
+
return sections.join('\n\n');
|
|
107
|
+
}
|
|
108
|
+
function describeExpectedResponse(expected) {
|
|
109
|
+
return expected.body === undefined
|
|
110
|
+
? { status: expected.status }
|
|
111
|
+
: { status: expected.status, body: expected.body };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Composes the `toHaveRespondedWith` failure message: the matcher's hint,
|
|
115
|
+
* the expected response, a diff against the closest intercepted-and-matched
|
|
116
|
+
* candidate (even if it doesn't match on status or body) if one exists, and
|
|
117
|
+
* the full dump of the observed traffic. The candidate requires `matched &&
|
|
118
|
+
* mocked` because a passthrough entry can match the request's spec without
|
|
119
|
+
* ever having been intercepted: showing it as "close" would be misleading.
|
|
120
|
+
*/
|
|
121
|
+
export function respondedWithFailureMessage(messageContext) {
|
|
122
|
+
const { utils, spec, expected, traffic, isNot } = messageContext;
|
|
123
|
+
const leadLine = isNot
|
|
124
|
+
? `Not expected: ${utils.printExpected(describeSpec(spec))} responded with ${utils.printExpected(describeExpectedResponse(expected))}`
|
|
125
|
+
: `Expected: ${utils.printExpected(describeSpec(spec))} responded with ${utils.printExpected(describeExpectedResponse(expected))}`;
|
|
126
|
+
const sections = [
|
|
127
|
+
utils.matcherHint('toHaveRespondedWith', undefined, undefined, { isNot }),
|
|
128
|
+
leadLine,
|
|
129
|
+
];
|
|
130
|
+
const candidate = traffic.find((entry) => entry.matched && entry.mocked && matchesSpec(entry, spec));
|
|
131
|
+
if (candidate) {
|
|
132
|
+
// Same key (`body`) on both sides, and absent on both if `expected`
|
|
133
|
+
// doesn't carry it: with different names (`body` vs `responseBody`) the
|
|
134
|
+
// diff finds no common ground and shows two whole unrelated blocks
|
|
135
|
+
// instead of pointing at the field that actually changed inside the
|
|
136
|
+
// body.
|
|
137
|
+
const diff = utils.diff(describeExpectedResponse(expected), expected.body === undefined
|
|
138
|
+
? { status: candidate.status }
|
|
139
|
+
: { status: candidate.status, body: candidate.responseBody });
|
|
140
|
+
if (diff)
|
|
141
|
+
sections.push(diff);
|
|
142
|
+
}
|
|
143
|
+
sections.push(`Observed traffic:\n${formatTraffic(traffic)}`);
|
|
144
|
+
return sections.join('\n\n');
|
|
145
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a handler is expected to have responded, for `toHaveRespondedWith`.
|
|
3
|
+
* `status` is required; `body` is compared by subset of top-level keys
|
|
4
|
+
* unless `exact: true`, which requires strict equality — same semantics as
|
|
5
|
+
* `RequestSpecOptions.body` in request-descriptors.ts, but over the
|
|
6
|
+
* response instead of the request.
|
|
7
|
+
*/
|
|
8
|
+
export interface ExpectedResponse {
|
|
9
|
+
status: number;
|
|
10
|
+
body?: unknown;
|
|
11
|
+
exact?: boolean;
|
|
12
|
+
}
|
|
13
|
+
interface NetworkMatchers<ReturnType = unknown> {
|
|
14
|
+
/**
|
|
15
|
+
* Checks that the application made a request matching `spec`, by exact
|
|
16
|
+
* `method` and `path` and `searchParams`/`body` by subset. Retries until
|
|
17
|
+
* it finds one or the timeout runs out: a request is an effect that
|
|
18
|
+
* follows an interaction, same as `expect.element`.
|
|
19
|
+
*
|
|
20
|
+
* `.not.toHaveBeenRequested()` does not retry: it waits for the network to
|
|
21
|
+
* settle first, so as not to mistake a request that hasn't arrived yet for
|
|
22
|
+
* one that was never made.
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* import { get } from '@yabbadabbadev/pepito'
|
|
27
|
+
*
|
|
28
|
+
* await fetch('/api/products')
|
|
29
|
+
* await expect(get('/api/products')).toHaveBeenRequested()
|
|
30
|
+
* await expect(get('/api/other')).not.toHaveBeenRequested()
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
toHaveBeenRequested(): Promise<ReturnType>;
|
|
34
|
+
/**
|
|
35
|
+
* Checks that exactly `count` requests matching `spec` were made. Always
|
|
36
|
+
* waits for the network to settle before counting, with or without
|
|
37
|
+
* `.not`: a count taken mid-traffic is as false as an absence taken
|
|
38
|
+
* mid-traffic.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { get } from '@yabbadabbadev/pepito'
|
|
43
|
+
*
|
|
44
|
+
* await Promise.all([fetch('/api/products'), fetch('/api/products')])
|
|
45
|
+
* await expect(get('/api/products')).toHaveBeenRequestedTimes(2)
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
toHaveBeenRequestedTimes(count: number): Promise<ReturnType>;
|
|
49
|
+
/**
|
|
50
|
+
* Checks that one of your own handlers produced the response, not
|
|
51
|
+
* just that the request matched its route: a handler with `passthrough()`
|
|
52
|
+
* satisfies `toHaveBeenRequested` but not this matcher, because the
|
|
53
|
+
* response came from the real network.
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```ts
|
|
57
|
+
* import { post } from '@yabbadabbadev/pepito'
|
|
58
|
+
*
|
|
59
|
+
* await fetch('/api/products', { method: 'POST' })
|
|
60
|
+
* await expect(post('/api/products')).toHaveBeenIntercepted()
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
toHaveBeenIntercepted(): Promise<ReturnType>;
|
|
64
|
+
/**
|
|
65
|
+
* Checks that the request was intercepted (like `toHaveBeenIntercepted`)
|
|
66
|
+
* and that the response has the expected `status` and, if given, a `body`
|
|
67
|
+
* that matches by subset. `toHaveRespondedWith(500)` is the shorthand for
|
|
68
|
+
* `toHaveRespondedWith({ status: 500 })`.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```ts
|
|
72
|
+
* import { get } from '@yabbadabbadev/pepito'
|
|
73
|
+
*
|
|
74
|
+
* await fetch('/api/products')
|
|
75
|
+
* await expect(get('/api/products')).toHaveRespondedWith({
|
|
76
|
+
* status: 200,
|
|
77
|
+
* body: { total: 2 },
|
|
78
|
+
* })
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
toHaveRespondedWith(expected: number | ExpectedResponse): Promise<ReturnType>;
|
|
82
|
+
/**
|
|
83
|
+
* Suite guardrail: checks that no request was left without a handler. It
|
|
84
|
+
* does not describe a specific request, so it hangs off `expect.network()`
|
|
85
|
+
* instead of a `get`/`post`/… descriptor; using it on anything else fails
|
|
86
|
+
* with an instruction, not a data verdict.
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```ts
|
|
90
|
+
* await expect.network().toHaveNoUnhandledRequests()
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
toHaveNoUnhandledRequests(): Promise<ReturnType>;
|
|
94
|
+
}
|
|
95
|
+
declare module '@vitest/expect' {
|
|
96
|
+
interface Assertion<T = any> extends NetworkMatchers<T> {
|
|
97
|
+
}
|
|
98
|
+
interface AsymmetricMatchersContaining extends NetworkMatchers {
|
|
99
|
+
}
|
|
100
|
+
interface ExpectStatic {
|
|
101
|
+
/**
|
|
102
|
+
* Entry point for assertions over the network as a whole, not over a
|
|
103
|
+
* specific request. Today only `toHaveNoUnhandledRequests` consumes it.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* await expect.network().toHaveNoUnhandledRequests()
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
network(): Assertion<unknown>;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ResolvedRequest } from './traffic-registry';
|
|
2
|
+
/**
|
|
3
|
+
* Waits for the network to settle (two consecutive calm observations,
|
|
4
|
+
* separated by a poll interval) and returns a single snapshot of the
|
|
5
|
+
* traffic accumulated up to that point.
|
|
6
|
+
*
|
|
7
|
+
* Use it to assert an ABSENCE or an EXACT COUNT, never for the positive,
|
|
8
|
+
* retrying case (that's `pollTraffic`): it's the basis for the negated
|
|
9
|
+
* branch of `resolveTraffic` and for `toHaveBeenRequestedTimes`, and
|
|
10
|
+
* `network.log()` (network.ts) also uses it, outside this file.
|
|
11
|
+
*
|
|
12
|
+
* Total budget `QUIESCENCE_TIMEOUT_MS`: if the network doesn't settle in
|
|
13
|
+
* time, it throws with a dump of what's pending instead of returning a mute
|
|
14
|
+
* boolean. Full design rationale in the comment block right below the
|
|
15
|
+
* function.
|
|
16
|
+
*/
|
|
17
|
+
export declare function snapshotAfterIdle(): Promise<ResolvedRequest[]>;
|
package/dist/matchers.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { expect } from 'vitest';
|
|
2
|
+
import { noUnhandledRequestsFailureMessage, requestCountFailureMessage, requestFailureMessage, respondedWithFailureMessage, } from './failure-messages';
|
|
3
|
+
import { NETWORK_TARGET } from './network';
|
|
4
|
+
import { matchesBody, matchesSpec } from './spec-matching';
|
|
5
|
+
import { inFlightCount, QUIESCENCE_TIMEOUT_MS, RETRY_INTERVAL_MS, snapshotTraffic, waitForNetworkIdle, } from './traffic-registry';
|
|
6
|
+
// 1s of margin on top of polling every RETRY_INTERVAL_MS: enough for a
|
|
7
|
+
// request fired right after the assert to have time to complete its real
|
|
8
|
+
// trip through the service worker (browser mode, not a same-thread mock).
|
|
9
|
+
const RETRY_TIMEOUT_MS = 1000;
|
|
10
|
+
function sleep(ms) {
|
|
11
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
12
|
+
}
|
|
13
|
+
// The shorthand `toHaveRespondedWith(500)` and the long form
|
|
14
|
+
// `toHaveRespondedWith({ status: 500 })` coexist in the public signature so
|
|
15
|
+
// the common case doesn't force wrapping an object; normalizing here, once,
|
|
16
|
+
// avoids spreading the `typeof` check across the rest of the matcher.
|
|
17
|
+
function normalizeExpectedResponse(expected) {
|
|
18
|
+
return typeof expected === 'number' ? { status: expected } : expected;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Polls `snapshotTraffic()` until an entry matches `spec` and satisfies
|
|
22
|
+
* `isSatisfyingEntry`, or until `RETRY_TIMEOUT_MS` runs out. Returns the
|
|
23
|
+
* last traffic seen even if there was never a positive verdict: the
|
|
24
|
+
* matchers need it to compose the failure message.
|
|
25
|
+
*/
|
|
26
|
+
async function pollTraffic(spec, isSatisfyingEntry) {
|
|
27
|
+
const deadline = Date.now() + RETRY_TIMEOUT_MS;
|
|
28
|
+
for (;;) {
|
|
29
|
+
const traffic = await snapshotTraffic();
|
|
30
|
+
const pass = traffic.some((entry) => matchesSpec(entry, spec) && isSatisfyingEntry(entry));
|
|
31
|
+
if (pass || Date.now() >= deadline)
|
|
32
|
+
return { traffic, pass };
|
|
33
|
+
await sleep(RETRY_INTERVAL_MS);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Waits for the network to settle (two consecutive calm observations,
|
|
38
|
+
* separated by a poll interval) and returns a single snapshot of the
|
|
39
|
+
* traffic accumulated up to that point.
|
|
40
|
+
*
|
|
41
|
+
* Use it to assert an ABSENCE or an EXACT COUNT, never for the positive,
|
|
42
|
+
* retrying case (that's `pollTraffic`): it's the basis for the negated
|
|
43
|
+
* branch of `resolveTraffic` and for `toHaveBeenRequestedTimes`, and
|
|
44
|
+
* `network.log()` (network.ts) also uses it, outside this file.
|
|
45
|
+
*
|
|
46
|
+
* Total budget `QUIESCENCE_TIMEOUT_MS`: if the network doesn't settle in
|
|
47
|
+
* time, it throws with a dump of what's pending instead of returning a mute
|
|
48
|
+
* boolean. Full design rationale in the comment block right below the
|
|
49
|
+
* function.
|
|
50
|
+
*/
|
|
51
|
+
export async function snapshotAfterIdle() {
|
|
52
|
+
const deadline = performance.now() + QUIESCENCE_TIMEOUT_MS;
|
|
53
|
+
await sleep(RETRY_INTERVAL_MS);
|
|
54
|
+
for (;;) {
|
|
55
|
+
// Second argument: if this sub-call runs out its remaining budget and
|
|
56
|
+
// throws, the message has to talk about the TOTAL budget, not the
|
|
57
|
+
// remainder it happened to get (I2 — otherwise the last lap would say
|
|
58
|
+
// "did not settle within 1ms" after having waited almost the entire
|
|
59
|
+
// QUIESCENCE_TIMEOUT_MS).
|
|
60
|
+
await waitForNetworkIdle(Math.max(1, deadline - performance.now()), QUIESCENCE_TIMEOUT_MS);
|
|
61
|
+
await sleep(RETRY_INTERVAL_MS);
|
|
62
|
+
if (inFlightCount() === 0)
|
|
63
|
+
return snapshotTraffic();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Full design and rationale, for whoever reviews this later:
|
|
67
|
+
//
|
|
68
|
+
// Exported (instead of staying private to this file) because network.log()
|
|
69
|
+
// needs the same calm-wait without copying it or calling waitForNetworkIdle
|
|
70
|
+
// raw, which would reopen the blind window this function closes. Any future
|
|
71
|
+
// matcher with the same need (negated forms of toHaveRespondedWith) can call
|
|
72
|
+
// it as-is without needing to export anything else, as long as it lives in
|
|
73
|
+
// this file alongside the rest of the matchers.
|
|
74
|
+
//
|
|
75
|
+
// A single fixed margin before `waitForNetworkIdle` isn't enough: it's a
|
|
76
|
+
// timing assumption measured in this harness (real page-service
|
|
77
|
+
// worker-page round trip of 1 to 6 ms, see
|
|
78
|
+
// docs/knowledge/quiescencia-red-msw.md), not a guarantee. On a loaded CI
|
|
79
|
+
// that round trip can stretch past the margin while the margin's own
|
|
80
|
+
// `setTimeout` keeps its own clock — the calm check would read the registry
|
|
81
|
+
// as empty right when the request is still on its way, precisely in the
|
|
82
|
+
// tests that exist to prove that doesn't happen. That's why a STABILITY
|
|
83
|
+
// CONDITION is required, not a margin: two consecutive observations
|
|
84
|
+
// separated by a poll interval, both calm (`inFlightCount() === 0`). If the
|
|
85
|
+
// second one sees new traffic (arrived during the wait for the first), the
|
|
86
|
+
// calm wait repeats and the check runs again.
|
|
87
|
+
//
|
|
88
|
+
// This does NOT close the blind window: no bounded wait can, that's the
|
|
89
|
+
// preregistered position of docs/knowledge/quiescencia-red-msw.md. It only
|
|
90
|
+
// narrows the practical failure bound from one interval to two — reasoned
|
|
91
|
+
// from how `waitForNetworkIdle` works, not measured: this harness is too
|
|
92
|
+
// fast to reproduce the contended CI that motivates the change. The
|
|
93
|
+
// DETERMINISM tests in matchers-quiescence.test.ts measure something real
|
|
94
|
+
// but different: that the full mechanism (margin + double observation) is
|
|
95
|
+
// needed against a naive check or no wait at all, not that the second
|
|
96
|
+
// observation alone is detectable in this harness (verified by mutation,
|
|
97
|
+
// see docs/knowledge/quiescencia-red-msw.md).
|
|
98
|
+
//
|
|
99
|
+
// The loop needs ONE clock budget for the whole operation, not a fresh one
|
|
100
|
+
// per lap: `waitForNetworkIdle()` with no argument starts its own
|
|
101
|
+
// `QUIESCENCE_TIMEOUT_MS` every time it's called, so a relay of overlapping
|
|
102
|
+
// requests (each one settles the counter just before another starts during
|
|
103
|
+
// the trailing margin) would make the loop never converge or throw — it
|
|
104
|
+
// would die by Vitest's generic timeout, without the dump of what's
|
|
105
|
+
// pending. `deadline` is computed once on entry and each sub-call gets
|
|
106
|
+
// whatever budget is left, never a fresh full one again. If the budget runs
|
|
107
|
+
// out while the counter happens to be at zero, the current sub-call returns
|
|
108
|
+
// right away (nothing to wait for), the check that follows fails if
|
|
109
|
+
// something started meanwhile, and the next sub-call gets ~1 ms: if
|
|
110
|
+
// something is in flight, its own loop detects it once that remainder
|
|
111
|
+
// passes and throws with the dump — bounded to, at most, one extra
|
|
112
|
+
// `RETRY_INTERVAL_MS` on top of the global budget, never unbounded.
|
|
113
|
+
/**
|
|
114
|
+
* Single decision point between retrying and waiting for calm. `.not`
|
|
115
|
+
* needs quiescence: retrying until something shows up, applied to an
|
|
116
|
+
* absence, would give a false positive with any request still in flight
|
|
117
|
+
* (the DETERMINISM test in matchers-quiescence.test.ts catches exactly
|
|
118
|
+
* that). The positive case keeps the retrying poll because there it's the
|
|
119
|
+
* request arriving that matters, not the whole network going quiet.
|
|
120
|
+
*/
|
|
121
|
+
async function resolveTraffic(spec, isSatisfyingEntry, isNot) {
|
|
122
|
+
if (!isNot)
|
|
123
|
+
return pollTraffic(spec, isSatisfyingEntry);
|
|
124
|
+
const traffic = await snapshotAfterIdle();
|
|
125
|
+
return {
|
|
126
|
+
traffic,
|
|
127
|
+
pass: traffic.some((entry) => matchesSpec(entry, spec) && isSatisfyingEntry(entry)),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
expect.extend({
|
|
131
|
+
async toHaveBeenRequested(spec) {
|
|
132
|
+
const { traffic, pass } = await resolveTraffic(spec, () => true, this.isNot);
|
|
133
|
+
return {
|
|
134
|
+
pass,
|
|
135
|
+
message: () => requestFailureMessage({
|
|
136
|
+
utils: this.utils,
|
|
137
|
+
matcherName: 'toHaveBeenRequested',
|
|
138
|
+
spec,
|
|
139
|
+
traffic,
|
|
140
|
+
isNot: this.isNot,
|
|
141
|
+
}),
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
async toHaveBeenIntercepted(spec) {
|
|
145
|
+
// Neither passthrough (matched without mocked) nor the 500 MSW
|
|
146
|
+
// fabricates in error mode (mocked without matched) count as
|
|
147
|
+
// intercepted: the handler itself has to have responded. See
|
|
148
|
+
// docs/knowledge/msw-browser-mode.md.
|
|
149
|
+
const { traffic, pass } = await resolveTraffic(spec, (entry) => entry.matched && entry.mocked, this.isNot);
|
|
150
|
+
return {
|
|
151
|
+
pass,
|
|
152
|
+
message: () => requestFailureMessage({
|
|
153
|
+
utils: this.utils,
|
|
154
|
+
matcherName: 'toHaveBeenIntercepted',
|
|
155
|
+
spec,
|
|
156
|
+
traffic,
|
|
157
|
+
isNot: this.isNot,
|
|
158
|
+
}),
|
|
159
|
+
};
|
|
160
|
+
},
|
|
161
|
+
async toHaveRespondedWith(spec, expected) {
|
|
162
|
+
const expectedResponse = normalizeExpectedResponse(expected);
|
|
163
|
+
// The above (intercepted: neither passthrough nor an MSW-fabricated
|
|
164
|
+
// error, see toHaveBeenIntercepted above), plus this response: exact
|
|
165
|
+
// status plus, if given, a subset of the response body using the same
|
|
166
|
+
// rules matchesSpec applies to the request.
|
|
167
|
+
const isSatisfyingEntry = (entry) => entry.matched &&
|
|
168
|
+
entry.mocked &&
|
|
169
|
+
entry.status === expectedResponse.status &&
|
|
170
|
+
(expectedResponse.body === undefined ||
|
|
171
|
+
matchesBody(entry.responseBody, expectedResponse.body, expectedResponse.exact));
|
|
172
|
+
const { traffic, pass } = await resolveTraffic(spec, isSatisfyingEntry, this.isNot);
|
|
173
|
+
return {
|
|
174
|
+
pass,
|
|
175
|
+
message: () => respondedWithFailureMessage({
|
|
176
|
+
utils: this.utils,
|
|
177
|
+
spec,
|
|
178
|
+
expected: expectedResponse,
|
|
179
|
+
traffic,
|
|
180
|
+
isNot: this.isNot,
|
|
181
|
+
}),
|
|
182
|
+
};
|
|
183
|
+
},
|
|
184
|
+
async toHaveBeenRequestedTimes(spec, count) {
|
|
185
|
+
// Always waits for calm, negated with `.not` or not: an exact count
|
|
186
|
+
// taken mid-traffic is as false as an absence taken mid-traffic (the
|
|
187
|
+
// other DETERMINISM test in the same test file).
|
|
188
|
+
const traffic = await snapshotAfterIdle();
|
|
189
|
+
const matchingEntries = traffic.filter((entry) => matchesSpec(entry, spec));
|
|
190
|
+
const foundCount = matchingEntries.length;
|
|
191
|
+
return {
|
|
192
|
+
pass: foundCount === count,
|
|
193
|
+
message: () => requestCountFailureMessage({
|
|
194
|
+
utils: this.utils,
|
|
195
|
+
spec,
|
|
196
|
+
expectedCount: count,
|
|
197
|
+
foundCount,
|
|
198
|
+
traffic,
|
|
199
|
+
isNot: this.isNot,
|
|
200
|
+
}),
|
|
201
|
+
};
|
|
202
|
+
},
|
|
203
|
+
async toHaveNoUnhandledRequests(received) {
|
|
204
|
+
// This matcher doesn't describe a request: it hangs off
|
|
205
|
+
// `expect.network()`, the only place that produces the
|
|
206
|
+
// `NETWORK_TARGET` marker. Used on anything else (a `get(...)`
|
|
207
|
+
// descriptor, a string) is a usage error, not an assertion failing on
|
|
208
|
+
// data.
|
|
209
|
+
if (received !== NETWORK_TARGET) {
|
|
210
|
+
return {
|
|
211
|
+
pass: false,
|
|
212
|
+
message: () => 'toHaveNoUnhandledRequests is used through expect.network(), not on a request descriptor: expect.network().toHaveNoUnhandledRequests()',
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
const traffic = await snapshotAfterIdle();
|
|
216
|
+
const unhandledEntries = traffic.filter((entry) => entry.unhandled);
|
|
217
|
+
return {
|
|
218
|
+
pass: unhandledEntries.length === 0,
|
|
219
|
+
message: () => noUnhandledRequestsFailureMessage({
|
|
220
|
+
utils: this.utils,
|
|
221
|
+
unhandledEntries,
|
|
222
|
+
traffic,
|
|
223
|
+
isNot: this.isNot,
|
|
224
|
+
}),
|
|
225
|
+
};
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
// Vitest doesn't allow declaring new properties on `ExpectStatic` via
|
|
229
|
+
// `expect.extend`: `expect.extend` only installs matchers inside an
|
|
230
|
+
// `Assertion`. `expect.network()` needs to be a top-level function on
|
|
231
|
+
// `expect` itself, so it's assigned directly; the type shape comes from the
|
|
232
|
+
// `ExpectStatic` augmentation in matcher-types.ts.
|
|
233
|
+
expect.network = () => expect(NETWORK_TARGET);
|
package/dist/mount.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReactElement } from 'react';
|
|
2
|
+
import type { RequestHandler } from 'msw';
|
|
3
|
+
import { type RenderResult } from 'vitest-browser-react';
|
|
4
|
+
/** Options for {@link mount}. Both are optional: `mount(<App />)` alone just mounts. */
|
|
5
|
+
export interface MountOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Same-origin URI starting with `/`, query and hash included (for example
|
|
8
|
+
* `/products?filter=bread#detail`). Applied with `history.pushState`
|
|
9
|
+
* before render so the application's router reads it on mount.
|
|
10
|
+
*/
|
|
11
|
+
path?: string;
|
|
12
|
+
/**
|
|
13
|
+
* MSW handlers of this test's own. Installed with `worker.use()` before
|
|
14
|
+
* render, so they take priority over the suite's for the same route, and
|
|
15
|
+
* `setupNetwork()` undoes them in its `afterEach`.
|
|
16
|
+
*/
|
|
17
|
+
network?: RequestHandler[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Mounts `ui` with `vitest-browser-react`, optionally on a real document
|
|
21
|
+
* route and with test-specific MSW handlers.
|
|
22
|
+
*
|
|
23
|
+
* `path`, if given, has to be a same-origin URI starting with `/`: it's
|
|
24
|
+
* applied with `history.pushState` BEFORE render because the application's
|
|
25
|
+
* `BrowserRouter` reads the URL on mount and only listens to `popstate`
|
|
26
|
+
* afterwards (measured — see
|
|
27
|
+
* `.claude/docs/references/measured-foundations.md`). The URI can carry
|
|
28
|
+
* query and hash: they flow through the router the same as the path.
|
|
29
|
+
* `setupNetwork()` restores the original URL in its `afterEach`, so every
|
|
30
|
+
* test starts from the same route regardless of what the previous one
|
|
31
|
+
* mounted.
|
|
32
|
+
*
|
|
33
|
+
* `network`, if given, is registered with `worker.use()` before render: its
|
|
34
|
+
* handlers take priority over the suite's for this request, with the same
|
|
35
|
+
* resolution rules as MSW.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* import { mount } from '@yabbadabbadev/pepito'
|
|
40
|
+
*
|
|
41
|
+
* const screen = await mount(<App />, { path: '/products?filter=bread' })
|
|
42
|
+
* await expect.element(screen.getByText('filter: bread')).toBeVisible()
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export declare function mount(ui: ReactElement, options?: MountOptions): Promise<RenderResult>;
|
package/dist/mount.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { render } from 'vitest-browser-react';
|
|
2
|
+
import { requireNetworkContext } from './network-singleton';
|
|
3
|
+
// The '/' prefix alone isn't enough: '//evil.com' is protocol-relative and
|
|
4
|
+
// WHATWG also treats '/\' as introducing an authority, so both would pass
|
|
5
|
+
// it and pushState would be the one throwing the raw SecurityError further
|
|
6
|
+
// down.
|
|
7
|
+
function isSameOriginPath(path) {
|
|
8
|
+
if (!path.startsWith('/'))
|
|
9
|
+
return false;
|
|
10
|
+
try {
|
|
11
|
+
return new URL(path, location.origin).origin === location.origin;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Mounts `ui` with `vitest-browser-react`, optionally on a real document
|
|
19
|
+
* route and with test-specific MSW handlers.
|
|
20
|
+
*
|
|
21
|
+
* `path`, if given, has to be a same-origin URI starting with `/`: it's
|
|
22
|
+
* applied with `history.pushState` BEFORE render because the application's
|
|
23
|
+
* `BrowserRouter` reads the URL on mount and only listens to `popstate`
|
|
24
|
+
* afterwards (measured — see
|
|
25
|
+
* `.claude/docs/references/measured-foundations.md`). The URI can carry
|
|
26
|
+
* query and hash: they flow through the router the same as the path.
|
|
27
|
+
* `setupNetwork()` restores the original URL in its `afterEach`, so every
|
|
28
|
+
* test starts from the same route regardless of what the previous one
|
|
29
|
+
* mounted.
|
|
30
|
+
*
|
|
31
|
+
* `network`, if given, is registered with `worker.use()` before render: its
|
|
32
|
+
* handlers take priority over the suite's for this request, with the same
|
|
33
|
+
* resolution rules as MSW.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```tsx
|
|
37
|
+
* import { mount } from '@yabbadabbadev/pepito'
|
|
38
|
+
*
|
|
39
|
+
* const screen = await mount(<App />, { path: '/products?filter=bread' })
|
|
40
|
+
* await expect.element(screen.getByText('filter: bread')).toBeVisible()
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export async function mount(ui, options = {}) {
|
|
44
|
+
const { worker } = requireNetworkContext('mount');
|
|
45
|
+
const { path, network: testHandlers } = options;
|
|
46
|
+
if (path !== undefined && !isSameOriginPath(path)) {
|
|
47
|
+
throw new Error(`pepito: path must be a same-origin URI that starts with '/'; ` +
|
|
48
|
+
`received: ${path}. A different origin is mocked in the MSW ` +
|
|
49
|
+
`handlers, not in the mount.`);
|
|
50
|
+
}
|
|
51
|
+
// Before render: the app's router reads the URL on mount and only
|
|
52
|
+
// listens to popstate afterwards. See
|
|
53
|
+
// docs/knowledge/url-navegacion-browser-mode.md.
|
|
54
|
+
if (path !== undefined) {
|
|
55
|
+
history.pushState({}, '', path);
|
|
56
|
+
}
|
|
57
|
+
if (testHandlers !== undefined && testHandlers.length > 0) {
|
|
58
|
+
worker.use(...testHandlers);
|
|
59
|
+
}
|
|
60
|
+
return render(ui);
|
|
61
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SetupWorker } from 'msw/browser';
|
|
2
|
+
/**
|
|
3
|
+
* Hooks up the traffic registry (traffic-registry.ts) to MSW's events.
|
|
4
|
+
* `setupNetwork()` calls it once per worker, before `worker.start()`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function watchNetwork(events: SetupWorker['events']): void;
|