@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,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Traffic entry as it lives in the registry while the test runs: `body` and
|
|
3
|
+
* `responseBody` are promises because they can only be read once (measured
|
|
4
|
+
* — see `.claude/docs/references/measured-foundations.md`) and are stored
|
|
5
|
+
* without awaiting, so as not to block the `request:start` listener that
|
|
6
|
+
* opens them.
|
|
7
|
+
*/
|
|
8
|
+
export interface ObservedRequest {
|
|
9
|
+
requestId: string;
|
|
10
|
+
method: string;
|
|
11
|
+
origin: string;
|
|
12
|
+
path: string;
|
|
13
|
+
searchParams: Record<string, string>;
|
|
14
|
+
body: Promise<unknown>;
|
|
15
|
+
matched: boolean;
|
|
16
|
+
mocked: boolean;
|
|
17
|
+
bypassed: boolean;
|
|
18
|
+
unhandled: boolean;
|
|
19
|
+
status: number | null;
|
|
20
|
+
responseBody: Promise<unknown>;
|
|
21
|
+
}
|
|
22
|
+
/** Same shape as {@link ObservedRequest} with `body` and `responseBody` already resolved: what the matchers and failure messages see. */
|
|
23
|
+
export interface ResolvedRequest extends Omit<ObservedRequest, 'body' | 'responseBody'> {
|
|
24
|
+
body: unknown;
|
|
25
|
+
responseBody: unknown;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Opens a traffic entry with what can only be read at `request:start`
|
|
29
|
+
* (measured — see `.claude/docs/references/measured-foundations.md`) and
|
|
30
|
+
* marks it in flight.
|
|
31
|
+
* `watchNetwork` (msw-events.ts) is the only caller.
|
|
32
|
+
*/
|
|
33
|
+
export declare function recordRequestStart(requestId: string, startFields: Pick<ObservedRequest, 'method' | 'origin' | 'path' | 'searchParams' | 'body'>): void;
|
|
34
|
+
/**
|
|
35
|
+
* Marks that a handler matched the route. By itself it doesn't imply it
|
|
36
|
+
* responded — a `passthrough()` also emits this event — which is why
|
|
37
|
+
* `toHaveBeenIntercepted` (matchers.ts) additionally requires
|
|
38
|
+
* {@link recordMockedResponse}.
|
|
39
|
+
*/
|
|
40
|
+
export declare function recordMatch(requestId: string): void;
|
|
41
|
+
/** Records the response a handler produced and closes the request (removes it from `pending`). */
|
|
42
|
+
export declare function recordMockedResponse(requestId: string, status: number, responseBody: Promise<unknown>): void;
|
|
43
|
+
/** Records that the request went out to the real network (passthrough or no handler) and closes the request. */
|
|
44
|
+
export declare function recordBypassResponse(requestId: string, status: number): void;
|
|
45
|
+
/** Marks a request as unhandled; this is what the `toHaveNoUnhandledRequests` guardrail reads. */
|
|
46
|
+
export declare function recordUnhandled(requestId: string): void;
|
|
47
|
+
/**
|
|
48
|
+
* Resolves the pending `body`/`responseBody` promises and returns a
|
|
49
|
+
* snapshot of the traffic accumulated so far. Doesn't empty the registry:
|
|
50
|
+
* several calls within the same test see the same history plus whatever is
|
|
51
|
+
* new.
|
|
52
|
+
*/
|
|
53
|
+
export declare function snapshotTraffic(): Promise<ResolvedRequest[]>;
|
|
54
|
+
/** Empties the registry and the in-flight counter; called by `setupNetwork()` in its `afterEach`. */
|
|
55
|
+
export declare function resetTraffic(): void;
|
|
56
|
+
/** Number of requests with no close event yet. See {@link waitForNetworkIdle}. */
|
|
57
|
+
export declare function inFlightCount(): number;
|
|
58
|
+
/** Poll interval for `waitForNetworkIdle`; also used by the retrying matchers. */
|
|
59
|
+
export declare const RETRY_INTERVAL_MS = 25;
|
|
60
|
+
/** Default budget for {@link waitForNetworkIdle} before it throws with a dump of what's pending. */
|
|
61
|
+
export declare const QUIESCENCE_TIMEOUT_MS = 4000;
|
|
62
|
+
/**
|
|
63
|
+
* Waits for the in-flight request counter to reach zero.
|
|
64
|
+
*
|
|
65
|
+
* Polls instead of reacting to an event because the close of the last
|
|
66
|
+
* request can arrive before any waiting listener gets installed. If the
|
|
67
|
+
* timeout runs out, it throws with a dump of what's pending instead of
|
|
68
|
+
* returning a boolean: an aborted request whose handler never finishes
|
|
69
|
+
* would leave the counter stuck forever, and a meter that cannot measure
|
|
70
|
+
* has to go red with diagnostics, not stay silent.
|
|
71
|
+
*
|
|
72
|
+
* A single call isn't enough to know "there's no traffic": a request fired
|
|
73
|
+
* in the same tick as the call hasn't yet crossed the real round trip to
|
|
74
|
+
* the service worker that registers its `request:start`, so this function
|
|
75
|
+
* reads the counter at zero by construction, not by actual absence (see
|
|
76
|
+
* `.claude/docs/references/measured-foundations.md`). Whoever needs to
|
|
77
|
+
* assert an absence or count with precision must go through
|
|
78
|
+
* `snapshotAfterIdle` in `pepito/src/matchers.ts`, which closes that window
|
|
79
|
+
* with a two-observation stability condition; calling this function
|
|
80
|
+
* directly reopens it.
|
|
81
|
+
*
|
|
82
|
+
* @param timeoutMs - The real budget for this call: what governs when it throws.
|
|
83
|
+
* @param reportedTimeoutMs - The number that appears in the error message if it
|
|
84
|
+
* throws; defaults to the same `timeoutMs`. Exists because `snapshotAfterIdle`
|
|
85
|
+
* splits ONE global budget across several sub-calls (each with whatever is
|
|
86
|
+
* left of the total, never a fresh full one) and needs the final message to
|
|
87
|
+
* talk about that total, not the remaining milliseconds the last sub-call
|
|
88
|
+
* got — otherwise the last lap of the loop could report "did not settle
|
|
89
|
+
* within 1ms" after having waited the entire budget.
|
|
90
|
+
*/
|
|
91
|
+
export declare function waitForNetworkIdle(timeoutMs?: number, reportedTimeoutMs?: number): Promise<void>;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
const traffic = new Map();
|
|
2
|
+
// A Set of in-flight requestId, not an integer: a close event with no prior
|
|
3
|
+
// request:start (traffic from before a resetTraffic) is simply ignored,
|
|
4
|
+
// instead of leaving a counter stuck negative without anyone noticing.
|
|
5
|
+
// Measured in docs/knowledge/quiescencia-red-msw.md.
|
|
6
|
+
const pending = new Set();
|
|
7
|
+
/**
|
|
8
|
+
* Opens a traffic entry with what can only be read at `request:start`
|
|
9
|
+
* (measured — see `.claude/docs/references/measured-foundations.md`) and
|
|
10
|
+
* marks it in flight.
|
|
11
|
+
* `watchNetwork` (msw-events.ts) is the only caller.
|
|
12
|
+
*/
|
|
13
|
+
export function recordRequestStart(requestId, startFields) {
|
|
14
|
+
traffic.set(requestId, {
|
|
15
|
+
requestId,
|
|
16
|
+
...startFields,
|
|
17
|
+
matched: false,
|
|
18
|
+
mocked: false,
|
|
19
|
+
bypassed: false,
|
|
20
|
+
unhandled: false,
|
|
21
|
+
status: null,
|
|
22
|
+
responseBody: Promise.resolve(undefined),
|
|
23
|
+
});
|
|
24
|
+
pending.add(requestId);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Marks that a handler matched the route. By itself it doesn't imply it
|
|
28
|
+
* responded — a `passthrough()` also emits this event — which is why
|
|
29
|
+
* `toHaveBeenIntercepted` (matchers.ts) additionally requires
|
|
30
|
+
* {@link recordMockedResponse}.
|
|
31
|
+
*/
|
|
32
|
+
export function recordMatch(requestId) {
|
|
33
|
+
const entry = traffic.get(requestId);
|
|
34
|
+
if (entry)
|
|
35
|
+
entry.matched = true;
|
|
36
|
+
}
|
|
37
|
+
/** Records the response a handler produced and closes the request (removes it from `pending`). */
|
|
38
|
+
export function recordMockedResponse(requestId, status, responseBody) {
|
|
39
|
+
const entry = traffic.get(requestId);
|
|
40
|
+
if (entry) {
|
|
41
|
+
entry.mocked = true;
|
|
42
|
+
entry.status = status;
|
|
43
|
+
entry.responseBody = responseBody;
|
|
44
|
+
}
|
|
45
|
+
pending.delete(requestId);
|
|
46
|
+
}
|
|
47
|
+
/** Records that the request went out to the real network (passthrough or no handler) and closes the request. */
|
|
48
|
+
export function recordBypassResponse(requestId, status) {
|
|
49
|
+
const entry = traffic.get(requestId);
|
|
50
|
+
if (entry) {
|
|
51
|
+
entry.bypassed = true;
|
|
52
|
+
entry.status = status;
|
|
53
|
+
}
|
|
54
|
+
pending.delete(requestId);
|
|
55
|
+
}
|
|
56
|
+
/** Marks a request as unhandled; this is what the `toHaveNoUnhandledRequests` guardrail reads. */
|
|
57
|
+
export function recordUnhandled(requestId) {
|
|
58
|
+
const entry = traffic.get(requestId);
|
|
59
|
+
if (entry)
|
|
60
|
+
entry.unhandled = true;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Resolves the pending `body`/`responseBody` promises and returns a
|
|
64
|
+
* snapshot of the traffic accumulated so far. Doesn't empty the registry:
|
|
65
|
+
* several calls within the same test see the same history plus whatever is
|
|
66
|
+
* new.
|
|
67
|
+
*/
|
|
68
|
+
export async function snapshotTraffic() {
|
|
69
|
+
return Promise.all([...traffic.values()].map(async (entry) => ({
|
|
70
|
+
...entry,
|
|
71
|
+
body: await entry.body,
|
|
72
|
+
responseBody: await entry.responseBody,
|
|
73
|
+
})));
|
|
74
|
+
}
|
|
75
|
+
/** Empties the registry and the in-flight counter; called by `setupNetwork()` in its `afterEach`. */
|
|
76
|
+
export function resetTraffic() {
|
|
77
|
+
traffic.clear();
|
|
78
|
+
pending.clear();
|
|
79
|
+
}
|
|
80
|
+
/** Number of requests with no close event yet. See {@link waitForNetworkIdle}. */
|
|
81
|
+
export function inFlightCount() {
|
|
82
|
+
return pending.size;
|
|
83
|
+
}
|
|
84
|
+
/** Poll interval for `waitForNetworkIdle`; also used by the retrying matchers. */
|
|
85
|
+
export const RETRY_INTERVAL_MS = 25;
|
|
86
|
+
/** Default budget for {@link waitForNetworkIdle} before it throws with a dump of what's pending. */
|
|
87
|
+
export const QUIESCENCE_TIMEOUT_MS = 4000;
|
|
88
|
+
/**
|
|
89
|
+
* Waits for the in-flight request counter to reach zero.
|
|
90
|
+
*
|
|
91
|
+
* Polls instead of reacting to an event because the close of the last
|
|
92
|
+
* request can arrive before any waiting listener gets installed. If the
|
|
93
|
+
* timeout runs out, it throws with a dump of what's pending instead of
|
|
94
|
+
* returning a boolean: an aborted request whose handler never finishes
|
|
95
|
+
* would leave the counter stuck forever, and a meter that cannot measure
|
|
96
|
+
* has to go red with diagnostics, not stay silent.
|
|
97
|
+
*
|
|
98
|
+
* A single call isn't enough to know "there's no traffic": a request fired
|
|
99
|
+
* in the same tick as the call hasn't yet crossed the real round trip to
|
|
100
|
+
* the service worker that registers its `request:start`, so this function
|
|
101
|
+
* reads the counter at zero by construction, not by actual absence (see
|
|
102
|
+
* `.claude/docs/references/measured-foundations.md`). Whoever needs to
|
|
103
|
+
* assert an absence or count with precision must go through
|
|
104
|
+
* `snapshotAfterIdle` in `pepito/src/matchers.ts`, which closes that window
|
|
105
|
+
* with a two-observation stability condition; calling this function
|
|
106
|
+
* directly reopens it.
|
|
107
|
+
*
|
|
108
|
+
* @param timeoutMs - The real budget for this call: what governs when it throws.
|
|
109
|
+
* @param reportedTimeoutMs - The number that appears in the error message if it
|
|
110
|
+
* throws; defaults to the same `timeoutMs`. Exists because `snapshotAfterIdle`
|
|
111
|
+
* splits ONE global budget across several sub-calls (each with whatever is
|
|
112
|
+
* left of the total, never a fresh full one) and needs the final message to
|
|
113
|
+
* talk about that total, not the remaining milliseconds the last sub-call
|
|
114
|
+
* got — otherwise the last lap of the loop could report "did not settle
|
|
115
|
+
* within 1ms" after having waited the entire budget.
|
|
116
|
+
*/
|
|
117
|
+
export async function waitForNetworkIdle(timeoutMs = QUIESCENCE_TIMEOUT_MS, reportedTimeoutMs = timeoutMs) {
|
|
118
|
+
const deadline = Date.now() + timeoutMs;
|
|
119
|
+
while (pending.size > 0) {
|
|
120
|
+
if (Date.now() >= deadline) {
|
|
121
|
+
const dump = [...pending]
|
|
122
|
+
.map((requestId) => {
|
|
123
|
+
const entry = traffic.get(requestId);
|
|
124
|
+
return entry ? `${entry.method} ${entry.path}` : requestId;
|
|
125
|
+
})
|
|
126
|
+
.join('\n ');
|
|
127
|
+
throw new Error(`the network did not settle within ${reportedTimeoutMs}ms; in flight:\n ${dump}`);
|
|
128
|
+
}
|
|
129
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL_MS));
|
|
130
|
+
}
|
|
131
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@yabbadabbadev/pepito",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Network test utilities for Vitest browser mode: application mounting and matchers over the traffic observed by MSW",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/yabbadabbadev/pepito.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/yabbadabbadev/pepito",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/yabbadabbadev/pepito/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"vitest",
|
|
17
|
+
"browser-mode",
|
|
18
|
+
"msw",
|
|
19
|
+
"testing",
|
|
20
|
+
"network",
|
|
21
|
+
"matchers"
|
|
22
|
+
],
|
|
23
|
+
"main": "dist/index.js",
|
|
24
|
+
"types": "dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"CHANGELOG.md"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"test:watch": "vitest",
|
|
41
|
+
"test:verbose": "vitest run --reporter=verbose",
|
|
42
|
+
"coverage": "vitest run --coverage",
|
|
43
|
+
"typecheck": "tsc --noEmit",
|
|
44
|
+
"build": "tsc -p tsconfig.build.json",
|
|
45
|
+
"pack:local": "npm run build && npm pack --pack-destination dist-pack",
|
|
46
|
+
"setup": "playwright install chromium",
|
|
47
|
+
"lint": "eslint --cache . --report-unused-disable-directives --max-warnings 0",
|
|
48
|
+
"lint-fix": "eslint --cache . --fix",
|
|
49
|
+
"format": "prettier --write .",
|
|
50
|
+
"format:check": "prettier --check ."
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"msw": "^2.15.0",
|
|
54
|
+
"react": "^19.0.0",
|
|
55
|
+
"react-dom": "^19.0.0",
|
|
56
|
+
"vitest": "^4.0.0",
|
|
57
|
+
"vitest-browser-react": "^2.0.0"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@eslint/js": "^9.39.5",
|
|
61
|
+
"@types/react": "^19.2.18",
|
|
62
|
+
"@types/react-dom": "^19.2.4",
|
|
63
|
+
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
|
64
|
+
"@typescript-eslint/parser": "^8.67.0",
|
|
65
|
+
"@vitejs/plugin-react": "^6.0.5",
|
|
66
|
+
"@vitest/browser": "^4.1.10",
|
|
67
|
+
"@vitest/browser-playwright": "^4.1.10",
|
|
68
|
+
"@vitest/coverage-v8": "^4.1.10",
|
|
69
|
+
"eslint": "^9.39.5",
|
|
70
|
+
"eslint-config-prettier": "^10.1.8",
|
|
71
|
+
"eslint-plugin-prettier": "^5.5.6",
|
|
72
|
+
"eslint-plugin-react": "^7.37.5",
|
|
73
|
+
"eslint-plugin-react-hooks": "^5.2.0",
|
|
74
|
+
"eslint-plugin-react-refresh": "^0.4.26",
|
|
75
|
+
"eslint-plugin-vitest-globals": "^1.6.1",
|
|
76
|
+
"globals": "^17.9.0",
|
|
77
|
+
"msw": "^2.15.0",
|
|
78
|
+
"playwright": "^1.62.1",
|
|
79
|
+
"prettier": "^3.9.6",
|
|
80
|
+
"react": "^19.2.8",
|
|
81
|
+
"react-dom": "^19.2.8",
|
|
82
|
+
"react-router": "^8.3.0",
|
|
83
|
+
"typescript": "^6.0.3",
|
|
84
|
+
"vitest": "^4.1.10",
|
|
85
|
+
"vitest-browser-react": "^2.2.0"
|
|
86
|
+
},
|
|
87
|
+
"msw": {
|
|
88
|
+
"workerDirectory": [
|
|
89
|
+
"public"
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
}
|