@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.
@@ -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
+ }