@mizchi/playwright-faults 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/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/faults.d.ts +98 -0
- package/dist/faults.d.ts.map +1 -0
- package/dist/faults.js +165 -0
- package/dist/faults.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle-faults.d.ts +75 -0
- package/dist/lifecycle-faults.d.ts.map +1 -0
- package/dist/lifecycle-faults.js +187 -0
- package/dist/lifecycle-faults.js.map +1 -0
- package/dist/random.d.ts +12 -0
- package/dist/random.d.ts.map +1 -0
- package/dist/random.js +20 -0
- package/dist/random.js.map +1 -0
- package/dist/runtime-faults.d.ts +71 -0
- package/dist/runtime-faults.d.ts.map +1 -0
- package/dist/runtime-faults.js +231 -0
- package/dist/runtime-faults.js.map +1 -0
- package/dist/types.d.ts +191 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/package.json +61 -0
- package/src/faults.test.ts +127 -0
- package/src/faults.ts +225 -0
- package/src/index.ts +48 -0
- package/src/lifecycle-faults.test.ts +252 -0
- package/src/lifecycle-faults.ts +252 -0
- package/src/random.ts +26 -0
- package/src/runtime-faults.test.ts +282 -0
- package/src/runtime-faults.ts +255 -0
- package/src/types.ts +196 -0
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for @mizchi/playwright-faults. Three layers of fault
|
|
3
|
+
* injection share a few types (UrlMatcher, FaultStats shapes); each layer
|
|
4
|
+
* has its own discriminated-union `Action` type.
|
|
5
|
+
*
|
|
6
|
+
* 1. Network — `FaultRule` / `Fault` (Playwright `route()` interception)
|
|
7
|
+
* 2. Page lifecycle — `LifecycleFault` / `LifecycleAction`
|
|
8
|
+
* (Playwright `Page` / `BrowserContext` / CDP at named stages)
|
|
9
|
+
* 3. JS runtime — `RuntimeFault` / `RuntimeAction`
|
|
10
|
+
* (Playwright `addInitScript` per page nav)
|
|
11
|
+
*/
|
|
12
|
+
/** Anything that can match a URL. String inputs are compiled with `new RegExp`. */
|
|
13
|
+
export type UrlMatcher = string | RegExp;
|
|
14
|
+
/**
|
|
15
|
+
* Minimal RNG contract used by the runtime-fault compiler. Caller passes
|
|
16
|
+
* any object with `next(): number` returning [0, 1). Caller-provided so
|
|
17
|
+
* playwright-faults stays seed-agnostic.
|
|
18
|
+
*/
|
|
19
|
+
export interface Rng {
|
|
20
|
+
next(): number;
|
|
21
|
+
}
|
|
22
|
+
/** What to do when a FaultRule matches a request. */
|
|
23
|
+
export type Fault = {
|
|
24
|
+
kind: "abort";
|
|
25
|
+
errorCode?: string;
|
|
26
|
+
} | {
|
|
27
|
+
kind: "status";
|
|
28
|
+
status: number;
|
|
29
|
+
body?: string;
|
|
30
|
+
contentType?: string;
|
|
31
|
+
} | {
|
|
32
|
+
kind: "delay";
|
|
33
|
+
ms: number;
|
|
34
|
+
};
|
|
35
|
+
export interface FaultRule {
|
|
36
|
+
/** Optional human-readable name used in stats. */
|
|
37
|
+
name?: string;
|
|
38
|
+
/** URL matcher — a regex literal or a regex string. */
|
|
39
|
+
urlPattern: UrlMatcher;
|
|
40
|
+
/** HTTP methods to match (case-insensitive). Empty = all methods. */
|
|
41
|
+
methods?: string[];
|
|
42
|
+
/** Action taken on a match. */
|
|
43
|
+
fault: Fault;
|
|
44
|
+
/** 0..1, default 1.0. Uses the caller-provided RNG. */
|
|
45
|
+
probability?: number;
|
|
46
|
+
}
|
|
47
|
+
/** Per-rule stats for fault injection, emitted on the final report. */
|
|
48
|
+
export interface FaultInjectionStats {
|
|
49
|
+
rule: string;
|
|
50
|
+
matched: number;
|
|
51
|
+
injected: number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* When during a page's lifecycle a `LifecycleFault` fires.
|
|
55
|
+
*
|
|
56
|
+
* - `beforeNavigation`: before `page.goto` — for CDP-level conditions that need to
|
|
57
|
+
* apply during the load itself (CPU throttle, virtual time).
|
|
58
|
+
* - `afterLoad`: right after navigation completes, before any chaos actions or
|
|
59
|
+
* `afterLoad` invariants run — for in-page mutations (storage clears, tamper).
|
|
60
|
+
* - `beforeActions`: after `afterLoad` invariants pass, before the first chaos
|
|
61
|
+
* action — for one-shot evictions that should not affect invariants but should
|
|
62
|
+
* precede user simulation (Service Worker cache eviction).
|
|
63
|
+
* - `betweenActions`: after every chaos action — for sustained pressure faults
|
|
64
|
+
* that need re-application across the action loop.
|
|
65
|
+
*/
|
|
66
|
+
export type LifecycleStage = "beforeNavigation" | "afterLoad" | "beforeActions" | "betweenActions";
|
|
67
|
+
/** Where a `clear-storage` / `tamper-storage` action targets. */
|
|
68
|
+
export type StorageScope = "localStorage" | "sessionStorage" | "cookies" | "indexedDB";
|
|
69
|
+
/**
|
|
70
|
+
* What a lifecycle fault does when it fires.
|
|
71
|
+
*
|
|
72
|
+
* Distinct from network-side `Fault` (which is request-scoped). These are
|
|
73
|
+
* page-scoped client-side perturbations applied via the Playwright Page /
|
|
74
|
+
* BrowserContext / CDP session.
|
|
75
|
+
*/
|
|
76
|
+
export type LifecycleAction =
|
|
77
|
+
/**
|
|
78
|
+
* Apply CPU throttling via CDP `Emulation.setCPUThrottlingRate`.
|
|
79
|
+
* `rate` is a multiplier ≥ 1 (1 = no throttle, 4 = ~4× slower).
|
|
80
|
+
*/
|
|
81
|
+
{
|
|
82
|
+
kind: "cpu-throttle";
|
|
83
|
+
rate: number;
|
|
84
|
+
}
|
|
85
|
+
/** Wipe one or more storage scopes. */
|
|
86
|
+
| {
|
|
87
|
+
kind: "clear-storage";
|
|
88
|
+
scopes: StorageScope[];
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Drop entries from the Service Worker `caches` API. When `cacheNames` is
|
|
92
|
+
* omitted, every cache is dropped.
|
|
93
|
+
*/
|
|
94
|
+
| {
|
|
95
|
+
kind: "evict-cache";
|
|
96
|
+
cacheNames?: string[];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Set a single key/value in `localStorage` or `sessionStorage`. Useful for
|
|
100
|
+
* forcing a logged-in app into "stale auth token" state and similar
|
|
101
|
+
* targeted-corruption scenarios.
|
|
102
|
+
*/
|
|
103
|
+
| {
|
|
104
|
+
kind: "tamper-storage";
|
|
105
|
+
scope: "localStorage" | "sessionStorage";
|
|
106
|
+
key: string;
|
|
107
|
+
value: string;
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* Page-level fault injected at a specific lifecycle stage. Network-level faults
|
|
111
|
+
* stay on `FaultRule` (URL-matched, applied via Playwright `route()`).
|
|
112
|
+
*/
|
|
113
|
+
export interface LifecycleFault {
|
|
114
|
+
/** Optional human-readable name used in stats. Auto-derived when omitted. */
|
|
115
|
+
name?: string;
|
|
116
|
+
/** When during the page lifecycle this fault fires. */
|
|
117
|
+
when: LifecycleStage;
|
|
118
|
+
/**
|
|
119
|
+
* Restrict to URLs matching this matcher. Omit to apply on every page. For
|
|
120
|
+
* `beforeNavigation` faults the about-to-be-navigated URL is matched.
|
|
121
|
+
*/
|
|
122
|
+
urlPattern?: UrlMatcher;
|
|
123
|
+
/** 0..1, default 1.0. Uses the caller-provided RNG. */
|
|
124
|
+
probability?: number;
|
|
125
|
+
/** What to do when the fault fires. */
|
|
126
|
+
action: LifecycleAction;
|
|
127
|
+
}
|
|
128
|
+
/** Per-fault stats emitted on the final report. */
|
|
129
|
+
export interface LifecycleFaultStats {
|
|
130
|
+
/** `name` from the `LifecycleFault`, or an auto-derived label. */
|
|
131
|
+
name: string;
|
|
132
|
+
/** Pages whose URL matched (regardless of probability). */
|
|
133
|
+
matched: number;
|
|
134
|
+
/** Pages where the fault actually fired (after the probability roll). */
|
|
135
|
+
fired: number;
|
|
136
|
+
/** Pages where the fault threw while firing. */
|
|
137
|
+
errored: number;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* What a runtime fault does when it fires. Each kind is a persistent
|
|
141
|
+
* monkey-patch installed in every page via `addInitScript`.
|
|
142
|
+
*/
|
|
143
|
+
export type RuntimeAction =
|
|
144
|
+
/**
|
|
145
|
+
* Reject `window.fetch` calls before any network round-trip. Different
|
|
146
|
+
* from a network `Fault` of kind `"abort"`: `flaky-fetch` rejects the
|
|
147
|
+
* Promise client-side with a TypeError, simulating "Failed to fetch" /
|
|
148
|
+
* Service Worker reject / DNS failure.
|
|
149
|
+
*/
|
|
150
|
+
{
|
|
151
|
+
kind: "flaky-fetch";
|
|
152
|
+
rejectionMessage?: string;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Skew `Date.now()` / `performance.now()` (and the no-arg `Date`
|
|
156
|
+
* constructor) forward by `skewMs`. Useful for forcing token-expiry,
|
|
157
|
+
* cache-bust, and "clock drift" code paths.
|
|
158
|
+
*/
|
|
159
|
+
| {
|
|
160
|
+
kind: "clock-skew";
|
|
161
|
+
skewMs: number;
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* Page-level JS-runtime fault. Installed via `addInitScript` on every page
|
|
165
|
+
* navigation. Distinct from `FaultRule` (request-scoped) and
|
|
166
|
+
* `LifecycleFault` (one-shot at named stages of a page visit).
|
|
167
|
+
*/
|
|
168
|
+
export interface RuntimeFault {
|
|
169
|
+
/** Optional human-readable name used in stats. Auto-derived when omitted. */
|
|
170
|
+
name?: string;
|
|
171
|
+
/**
|
|
172
|
+
* Restrict to pages whose URL matches this matcher. Omitted = applies on
|
|
173
|
+
* every page. The check happens inside the page (against `location.href`),
|
|
174
|
+
* so the matcher must be JSON-serializable (string regex or RegExp literal).
|
|
175
|
+
*/
|
|
176
|
+
urlPattern?: UrlMatcher;
|
|
177
|
+
/** 0..1, default 1.0. Rolled per call against an in-page seeded RNG. */
|
|
178
|
+
probability?: number;
|
|
179
|
+
/** What to do when the fault fires. */
|
|
180
|
+
action: RuntimeAction;
|
|
181
|
+
}
|
|
182
|
+
/** Per-fault stats for runtime fault injection, emitted on the final report. */
|
|
183
|
+
export interface RuntimeFaultStats {
|
|
184
|
+
/** `name` from the `RuntimeFault`, or an auto-derived label. */
|
|
185
|
+
rule: string;
|
|
186
|
+
/** Times the fault was tested (URL matched, probability about to roll). */
|
|
187
|
+
matched: number;
|
|
188
|
+
/** Times the fault actually fired. */
|
|
189
|
+
fired: number;
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for @mizchi/playwright-faults. Three layers of fault
|
|
3
|
+
* injection share a few types (UrlMatcher, FaultStats shapes); each layer
|
|
4
|
+
* has its own discriminated-union `Action` type.
|
|
5
|
+
*
|
|
6
|
+
* 1. Network — `FaultRule` / `Fault` (Playwright `route()` interception)
|
|
7
|
+
* 2. Page lifecycle — `LifecycleFault` / `LifecycleAction`
|
|
8
|
+
* (Playwright `Page` / `BrowserContext` / CDP at named stages)
|
|
9
|
+
* 3. JS runtime — `RuntimeFault` / `RuntimeAction`
|
|
10
|
+
* (Playwright `addInitScript` per page nav)
|
|
11
|
+
*/
|
|
12
|
+
export {};
|
|
13
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG"}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mizchi/playwright-faults",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Network / page-lifecycle / JS-runtime fault injection primitives for Playwright. Extracted from chaosbringer.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "mizchi",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/mizchi/chaosbringer.git",
|
|
10
|
+
"directory": "packages/playwright-faults"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/mizchi/chaosbringer/tree/main/packages/playwright-faults#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/mizchi/chaosbringer/issues"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc",
|
|
33
|
+
"prepare": "tsc",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"lint:nul": "node ../chaosbringer/scripts/check-no-nul.mjs src"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"src"
|
|
40
|
+
],
|
|
41
|
+
"keywords": [
|
|
42
|
+
"playwright",
|
|
43
|
+
"fault-injection",
|
|
44
|
+
"chaos-engineering",
|
|
45
|
+
"testing",
|
|
46
|
+
"monkey-patch"
|
|
47
|
+
],
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"playwright": "^1.40.0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"playwright": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"playwright": "^1.52.0",
|
|
58
|
+
"typescript": "^5.8.3",
|
|
59
|
+
"vitest": "^3.0.7"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { faults } from "./faults.js";
|
|
3
|
+
|
|
4
|
+
describe("faults helpers", () => {
|
|
5
|
+
it("faults.status produces a status fault rule", () => {
|
|
6
|
+
const rule = faults.status(500, { urlPattern: /\/api\// });
|
|
7
|
+
expect(rule.fault).toEqual({ kind: "status", status: 500 });
|
|
8
|
+
expect(rule.urlPattern).toBeInstanceOf(RegExp);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("faults.status forwards body + contentType when provided", () => {
|
|
12
|
+
const rule = faults.status(503, {
|
|
13
|
+
urlPattern: "/api",
|
|
14
|
+
body: "down",
|
|
15
|
+
contentType: "text/plain",
|
|
16
|
+
});
|
|
17
|
+
expect(rule.fault).toEqual({
|
|
18
|
+
kind: "status",
|
|
19
|
+
status: 503,
|
|
20
|
+
body: "down",
|
|
21
|
+
contentType: "text/plain",
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("faults.abort defaults errorCode to nothing (crawler supplies default)", () => {
|
|
26
|
+
const rule = faults.abort({ urlPattern: /tracking/ });
|
|
27
|
+
expect(rule.fault).toEqual({ kind: "abort" });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("faults.abort passes errorCode through", () => {
|
|
31
|
+
const rule = faults.abort({ urlPattern: "t", errorCode: "internetdisconnected" });
|
|
32
|
+
expect(rule.fault).toEqual({ kind: "abort", errorCode: "internetdisconnected" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("faults.delay wraps ms", () => {
|
|
36
|
+
const rule = faults.delay(2000, { urlPattern: "/slow" });
|
|
37
|
+
expect(rule.fault).toEqual({ kind: "delay", ms: 2000 });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("forwards common options (name, methods, probability)", () => {
|
|
41
|
+
const rule = faults.status(500, {
|
|
42
|
+
urlPattern: "/api",
|
|
43
|
+
name: "api-500",
|
|
44
|
+
methods: ["POST"],
|
|
45
|
+
probability: 0.5,
|
|
46
|
+
});
|
|
47
|
+
expect(rule.name).toBe("api-500");
|
|
48
|
+
expect(rule.methods).toEqual(["POST"]);
|
|
49
|
+
expect(rule.probability).toBe(0.5);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("omits common options when not set (no undefined pollution)", () => {
|
|
53
|
+
const rule = faults.status(500, { urlPattern: "/api" });
|
|
54
|
+
expect(rule).not.toHaveProperty("name");
|
|
55
|
+
expect(rule).not.toHaveProperty("methods");
|
|
56
|
+
expect(rule).not.toHaveProperty("probability");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("lifecycle fault helpers", () => {
|
|
61
|
+
it("faults.cpu wraps CPU throttle rate with a sensible default stage", () => {
|
|
62
|
+
const lf = faults.cpu(4);
|
|
63
|
+
expect(lf.action).toEqual({ kind: "cpu-throttle", rate: 4 });
|
|
64
|
+
expect(lf.when).toBe("beforeNavigation");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("faults.cpu forwards override stage / urlPattern / probability / name", () => {
|
|
68
|
+
const lf = faults.cpu(2, {
|
|
69
|
+
when: "afterLoad",
|
|
70
|
+
urlPattern: /\/heavy\//,
|
|
71
|
+
probability: 0.25,
|
|
72
|
+
name: "cpu-2x-on-heavy",
|
|
73
|
+
});
|
|
74
|
+
expect(lf).toMatchObject({
|
|
75
|
+
when: "afterLoad",
|
|
76
|
+
probability: 0.25,
|
|
77
|
+
name: "cpu-2x-on-heavy",
|
|
78
|
+
action: { kind: "cpu-throttle", rate: 2 },
|
|
79
|
+
});
|
|
80
|
+
expect(lf.urlPattern).toBeInstanceOf(RegExp);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("faults.cpu rejects rates < 1", () => {
|
|
84
|
+
expect(() => faults.cpu(0.5)).toThrow(/rate/i);
|
|
85
|
+
expect(() => faults.cpu(0)).toThrow(/rate/i);
|
|
86
|
+
expect(() => faults.cpu(-1)).toThrow(/rate/i);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("faults.clearStorage requires at least one scope and defaults stage to afterLoad", () => {
|
|
90
|
+
const lf = faults.clearStorage({ scopes: ["localStorage", "cookies"] });
|
|
91
|
+
expect(lf.action).toEqual({ kind: "clear-storage", scopes: ["localStorage", "cookies"] });
|
|
92
|
+
expect(lf.when).toBe("afterLoad");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("faults.clearStorage rejects empty scope list", () => {
|
|
96
|
+
expect(() => faults.clearStorage({ scopes: [] })).toThrow(/scope/i);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("faults.evictCache defaults to all caches and beforeActions stage", () => {
|
|
100
|
+
const lf = faults.evictCache();
|
|
101
|
+
expect(lf.action).toEqual({ kind: "evict-cache" });
|
|
102
|
+
expect(lf.when).toBe("beforeActions");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("faults.evictCache passes specific cacheNames through", () => {
|
|
106
|
+
const lf = faults.evictCache({ cacheNames: ["v1", "static"] });
|
|
107
|
+
expect(lf.action).toEqual({ kind: "evict-cache", cacheNames: ["v1", "static"] });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("faults.tamperStorage produces a tamper action and defaults stage to afterLoad", () => {
|
|
111
|
+
const lf = faults.tamperStorage({ scope: "localStorage", key: "auth", value: "" });
|
|
112
|
+
expect(lf.action).toEqual({
|
|
113
|
+
kind: "tamper-storage",
|
|
114
|
+
scope: "localStorage",
|
|
115
|
+
key: "auth",
|
|
116
|
+
value: "",
|
|
117
|
+
});
|
|
118
|
+
expect(lf.when).toBe("afterLoad");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("lifecycle helpers omit name / urlPattern / probability when not provided", () => {
|
|
122
|
+
const lf = faults.cpu(4);
|
|
123
|
+
expect(lf).not.toHaveProperty("name");
|
|
124
|
+
expect(lf).not.toHaveProperty("urlPattern");
|
|
125
|
+
expect(lf).not.toHaveProperty("probability");
|
|
126
|
+
});
|
|
127
|
+
});
|
package/src/faults.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small helper functions that build FaultRule objects without the
|
|
3
|
+
* discriminated-union ceremony. Exported from the package root.
|
|
4
|
+
*
|
|
5
|
+
* import { faults } from "chaosbringer";
|
|
6
|
+
* const rules = [
|
|
7
|
+
* faults.status(500, { urlPattern: /\/api\// }),
|
|
8
|
+
* faults.abort({ urlPattern: /tracking/ }),
|
|
9
|
+
* faults.delay(2000, { urlPattern: /\/api\// }),
|
|
10
|
+
* ];
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
FaultRule,
|
|
15
|
+
LifecycleFault,
|
|
16
|
+
LifecycleStage,
|
|
17
|
+
RuntimeFault,
|
|
18
|
+
StorageScope,
|
|
19
|
+
UrlMatcher,
|
|
20
|
+
} from "./types.js";
|
|
21
|
+
|
|
22
|
+
export interface FaultHelperOptions {
|
|
23
|
+
urlPattern: UrlMatcher;
|
|
24
|
+
methods?: string[];
|
|
25
|
+
probability?: number;
|
|
26
|
+
name?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function applyCommon(rule: FaultRule, opts: FaultHelperOptions): FaultRule {
|
|
30
|
+
rule.urlPattern = opts.urlPattern;
|
|
31
|
+
if (opts.methods !== undefined) rule.methods = opts.methods;
|
|
32
|
+
if (opts.probability !== undefined) rule.probability = opts.probability;
|
|
33
|
+
if (opts.name !== undefined) rule.name = opts.name;
|
|
34
|
+
return rule;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Common options accepted by every lifecycle fault helper. */
|
|
38
|
+
export interface LifecycleHelperOptions {
|
|
39
|
+
/** Override the helper's default lifecycle stage. */
|
|
40
|
+
when?: LifecycleStage;
|
|
41
|
+
/** Restrict the fault to URLs matching this matcher. */
|
|
42
|
+
urlPattern?: UrlMatcher;
|
|
43
|
+
/** 0..1, default 1.0. Uses the crawler's seeded RNG. */
|
|
44
|
+
probability?: number;
|
|
45
|
+
/** Override the auto-derived stats name. */
|
|
46
|
+
name?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function applyLifecycleCommon(
|
|
50
|
+
fault: LifecycleFault,
|
|
51
|
+
opts: LifecycleHelperOptions | undefined,
|
|
52
|
+
defaultStage: LifecycleStage,
|
|
53
|
+
): LifecycleFault {
|
|
54
|
+
fault.when = opts?.when ?? defaultStage;
|
|
55
|
+
if (opts?.urlPattern !== undefined) fault.urlPattern = opts.urlPattern;
|
|
56
|
+
if (opts?.probability !== undefined) fault.probability = opts.probability;
|
|
57
|
+
if (opts?.name !== undefined) fault.name = opts.name;
|
|
58
|
+
return fault;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const faults = {
|
|
62
|
+
/** Respond with `status` (and optional body / content-type). */
|
|
63
|
+
status(
|
|
64
|
+
status: number,
|
|
65
|
+
opts: FaultHelperOptions & { body?: string; contentType?: string }
|
|
66
|
+
): FaultRule {
|
|
67
|
+
const rule: FaultRule = {
|
|
68
|
+
urlPattern: opts.urlPattern,
|
|
69
|
+
fault: {
|
|
70
|
+
kind: "status",
|
|
71
|
+
status,
|
|
72
|
+
...(opts.body !== undefined ? { body: opts.body } : {}),
|
|
73
|
+
...(opts.contentType !== undefined ? { contentType: opts.contentType } : {}),
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
return applyCommon(rule, opts);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/** Abort the request (e.g. to simulate a blocked third-party or transport failure). */
|
|
80
|
+
abort(opts: FaultHelperOptions & { errorCode?: string }): FaultRule {
|
|
81
|
+
const rule: FaultRule = {
|
|
82
|
+
urlPattern: opts.urlPattern,
|
|
83
|
+
fault: {
|
|
84
|
+
kind: "abort",
|
|
85
|
+
...(opts.errorCode !== undefined ? { errorCode: opts.errorCode } : {}),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
return applyCommon(rule, opts);
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
/** Wait `ms` milliseconds, then continue the request unchanged. */
|
|
92
|
+
delay(ms: number, opts: FaultHelperOptions): FaultRule {
|
|
93
|
+
const rule: FaultRule = {
|
|
94
|
+
urlPattern: opts.urlPattern,
|
|
95
|
+
fault: { kind: "delay", ms },
|
|
96
|
+
};
|
|
97
|
+
return applyCommon(rule, opts);
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Apply CDP CPU throttling. `rate` is a multiplier ≥ 1 (1 = no throttle,
|
|
102
|
+
* 4 = ~4× slower). Default stage is `beforeNavigation` so the load itself
|
|
103
|
+
* is slowed.
|
|
104
|
+
*/
|
|
105
|
+
cpu(rate: number, opts?: LifecycleHelperOptions): LifecycleFault {
|
|
106
|
+
if (!Number.isFinite(rate) || rate < 1) {
|
|
107
|
+
throw new Error(`faults.cpu: rate must be a finite number >= 1 (got ${rate})`);
|
|
108
|
+
}
|
|
109
|
+
const fault: LifecycleFault = {
|
|
110
|
+
when: "beforeNavigation",
|
|
111
|
+
action: { kind: "cpu-throttle", rate },
|
|
112
|
+
};
|
|
113
|
+
return applyLifecycleCommon(fault, opts, "beforeNavigation");
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Wipe the listed storage scopes. Cookies are cleared at the BrowserContext
|
|
118
|
+
* level; `localStorage` / `sessionStorage` / `indexedDB` are cleared in-page.
|
|
119
|
+
* Default stage is `afterLoad` so the page exists when the wipe runs.
|
|
120
|
+
*/
|
|
121
|
+
clearStorage(
|
|
122
|
+
opts: LifecycleHelperOptions & { scopes: StorageScope[] },
|
|
123
|
+
): LifecycleFault {
|
|
124
|
+
if (!opts.scopes || opts.scopes.length === 0) {
|
|
125
|
+
throw new Error("faults.clearStorage: at least one scope is required");
|
|
126
|
+
}
|
|
127
|
+
const fault: LifecycleFault = {
|
|
128
|
+
when: "afterLoad",
|
|
129
|
+
action: { kind: "clear-storage", scopes: [...opts.scopes] },
|
|
130
|
+
};
|
|
131
|
+
return applyLifecycleCommon(fault, opts, "afterLoad");
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Drop entries from the Service Worker `caches` API. With no `cacheNames`,
|
|
136
|
+
* every cache is dropped. Default stage is `beforeActions` so the wipe
|
|
137
|
+
* happens after invariants but before chaos clicks.
|
|
138
|
+
*/
|
|
139
|
+
evictCache(
|
|
140
|
+
opts?: LifecycleHelperOptions & { cacheNames?: string[] },
|
|
141
|
+
): LifecycleFault {
|
|
142
|
+
const fault: LifecycleFault = {
|
|
143
|
+
when: "beforeActions",
|
|
144
|
+
action:
|
|
145
|
+
opts?.cacheNames !== undefined
|
|
146
|
+
? { kind: "evict-cache", cacheNames: [...opts.cacheNames] }
|
|
147
|
+
: { kind: "evict-cache" },
|
|
148
|
+
};
|
|
149
|
+
return applyLifecycleCommon(fault, opts, "beforeActions");
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Set a single key/value in `localStorage` or `sessionStorage`. Empty value
|
|
154
|
+
* mimics a logged-out / token-cleared state without dropping unrelated keys.
|
|
155
|
+
*/
|
|
156
|
+
tamperStorage(
|
|
157
|
+
opts: LifecycleHelperOptions & {
|
|
158
|
+
scope: "localStorage" | "sessionStorage";
|
|
159
|
+
key: string;
|
|
160
|
+
value: string;
|
|
161
|
+
},
|
|
162
|
+
): LifecycleFault {
|
|
163
|
+
const fault: LifecycleFault = {
|
|
164
|
+
when: "afterLoad",
|
|
165
|
+
action: {
|
|
166
|
+
kind: "tamper-storage",
|
|
167
|
+
scope: opts.scope,
|
|
168
|
+
key: opts.key,
|
|
169
|
+
value: opts.value,
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
return applyLifecycleCommon(fault, opts, "afterLoad");
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Reject `window.fetch` calls before any network round-trip. Different
|
|
177
|
+
* from `faults.abort()` (request-scoped, applied via Playwright `route`):
|
|
178
|
+
* `flakyFetch` rejects the Promise client-side with a TypeError, so any
|
|
179
|
+
* `try/catch` and Service-Worker fallbacks downstream of `fetch` engage
|
|
180
|
+
* just like a real "Failed to fetch" event.
|
|
181
|
+
*/
|
|
182
|
+
flakyFetch(opts?: RuntimeHelperOptions & { rejectionMessage?: string }): RuntimeFault {
|
|
183
|
+
const fault: RuntimeFault = {
|
|
184
|
+
action: {
|
|
185
|
+
kind: "flaky-fetch",
|
|
186
|
+
...(opts?.rejectionMessage !== undefined
|
|
187
|
+
? { rejectionMessage: opts.rejectionMessage }
|
|
188
|
+
: {}),
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
return applyRuntimeCommon(fault, opts);
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Skew `Date.now()`, `performance.now()`, and the no-arg `Date`
|
|
196
|
+
* constructor forward by `skewMs`. Use to surface token-expiry,
|
|
197
|
+
* cache-bust, and "clock drift" code paths without waiting real time.
|
|
198
|
+
*/
|
|
199
|
+
clockSkew(skewMs: number, opts?: RuntimeHelperOptions): RuntimeFault {
|
|
200
|
+
if (!Number.isFinite(skewMs) || !Number.isInteger(skewMs)) {
|
|
201
|
+
throw new Error(`faults.clockSkew: skewMs must be a finite integer (got ${skewMs})`);
|
|
202
|
+
}
|
|
203
|
+
const fault: RuntimeFault = { action: { kind: "clock-skew", skewMs } };
|
|
204
|
+
return applyRuntimeCommon(fault, opts);
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
export interface RuntimeHelperOptions {
|
|
209
|
+
/** Restrict the fault to pages whose URL matches this matcher. */
|
|
210
|
+
urlPattern?: UrlMatcher;
|
|
211
|
+
/** 0..1, default 1.0. Rolled per call against the in-page seeded RNG. */
|
|
212
|
+
probability?: number;
|
|
213
|
+
/** Override the auto-derived stats name. */
|
|
214
|
+
name?: string;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function applyRuntimeCommon(
|
|
218
|
+
fault: RuntimeFault,
|
|
219
|
+
opts: RuntimeHelperOptions | undefined,
|
|
220
|
+
): RuntimeFault {
|
|
221
|
+
if (opts?.urlPattern !== undefined) fault.urlPattern = opts.urlPattern;
|
|
222
|
+
if (opts?.probability !== undefined) fault.probability = opts.probability;
|
|
223
|
+
if (opts?.name !== undefined) fault.name = opts.name;
|
|
224
|
+
return fault;
|
|
225
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Types
|
|
2
|
+
export type {
|
|
3
|
+
Fault,
|
|
4
|
+
FaultInjectionStats,
|
|
5
|
+
FaultRule,
|
|
6
|
+
LifecycleAction,
|
|
7
|
+
LifecycleFault,
|
|
8
|
+
LifecycleFaultStats,
|
|
9
|
+
LifecycleStage,
|
|
10
|
+
Rng,
|
|
11
|
+
RuntimeAction,
|
|
12
|
+
RuntimeFault,
|
|
13
|
+
RuntimeFaultStats,
|
|
14
|
+
StorageScope,
|
|
15
|
+
UrlMatcher,
|
|
16
|
+
} from "./types.js";
|
|
17
|
+
|
|
18
|
+
// Network-level (FaultRule helpers + builders)
|
|
19
|
+
export {
|
|
20
|
+
faults,
|
|
21
|
+
type FaultHelperOptions,
|
|
22
|
+
type LifecycleHelperOptions,
|
|
23
|
+
type RuntimeHelperOptions,
|
|
24
|
+
} from "./faults.js";
|
|
25
|
+
|
|
26
|
+
// Runtime-level (addInitScript-based monkey patches)
|
|
27
|
+
export {
|
|
28
|
+
buildRuntimeFaultsScript,
|
|
29
|
+
compileRuntimeFaults,
|
|
30
|
+
mergeRuntimeStats,
|
|
31
|
+
runtimeFaultName,
|
|
32
|
+
runtimeMatchesUrl,
|
|
33
|
+
type CompiledRuntimeFault,
|
|
34
|
+
} from "./runtime-faults.js";
|
|
35
|
+
|
|
36
|
+
// Page lifecycle (Playwright Page / BrowserContext at named stages)
|
|
37
|
+
export {
|
|
38
|
+
compileLifecycleFaults,
|
|
39
|
+
executeLifecycleAction,
|
|
40
|
+
lifecycleFaultName,
|
|
41
|
+
lifecycleFaultsAtStage,
|
|
42
|
+
lifecycleMatchesUrl,
|
|
43
|
+
lifecycleStatsFrom,
|
|
44
|
+
PlaywrightLifecycleExecutor,
|
|
45
|
+
shouldFireProbability,
|
|
46
|
+
type CompiledLifecycleFault,
|
|
47
|
+
type LifecycleActionExecutor,
|
|
48
|
+
} from "./lifecycle-faults.js";
|