@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.
@@ -0,0 +1,252 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createRng } from "./random.js";
3
+ import {
4
+ compileLifecycleFaults,
5
+ executeLifecycleAction,
6
+ lifecycleFaultName,
7
+ lifecycleFaultsAtStage,
8
+ lifecycleMatchesUrl,
9
+ lifecycleStatsFrom,
10
+ shouldFireProbability,
11
+ type LifecycleActionExecutor,
12
+ } from "./lifecycle-faults.js";
13
+ import type { LifecycleFault } from "./types.js";
14
+
15
+ describe("lifecycleFaultName", () => {
16
+ it("uses fault.name when set", () => {
17
+ const f: LifecycleFault = {
18
+ name: "explicit",
19
+ when: "afterLoad",
20
+ action: { kind: "clear-storage", scopes: ["localStorage"] },
21
+ };
22
+ expect(lifecycleFaultName(f)).toBe("explicit");
23
+ });
24
+
25
+ it("derives a label per action kind", () => {
26
+ expect(
27
+ lifecycleFaultName({
28
+ when: "beforeNavigation",
29
+ action: { kind: "cpu-throttle", rate: 4 },
30
+ }),
31
+ ).toBe("cpu-throttle:4x");
32
+
33
+ expect(
34
+ lifecycleFaultName({
35
+ when: "afterLoad",
36
+ action: { kind: "clear-storage", scopes: ["localStorage", "cookies"] },
37
+ }),
38
+ ).toBe("clear-storage:localStorage+cookies");
39
+
40
+ expect(
41
+ lifecycleFaultName({
42
+ when: "beforeActions",
43
+ action: { kind: "evict-cache" },
44
+ }),
45
+ ).toBe("evict-cache");
46
+
47
+ expect(
48
+ lifecycleFaultName({
49
+ when: "beforeActions",
50
+ action: { kind: "evict-cache", cacheNames: ["v1"] },
51
+ }),
52
+ ).toBe("evict-cache:v1");
53
+
54
+ expect(
55
+ lifecycleFaultName({
56
+ when: "afterLoad",
57
+ action: { kind: "tamper-storage", scope: "localStorage", key: "auth", value: "" },
58
+ }),
59
+ ).toBe("tamper-storage:localStorage.auth");
60
+ });
61
+ });
62
+
63
+ describe("compileLifecycleFaults", () => {
64
+ it("returns [] for undefined / empty", () => {
65
+ expect(compileLifecycleFaults(undefined)).toEqual([]);
66
+ expect(compileLifecycleFaults([])).toEqual([]);
67
+ });
68
+
69
+ it("compiles regex patterns from string and RegExp matchers", () => {
70
+ const c = compileLifecycleFaults([
71
+ {
72
+ when: "afterLoad",
73
+ urlPattern: "/dashboard",
74
+ action: { kind: "clear-storage", scopes: ["localStorage"] },
75
+ },
76
+ {
77
+ when: "afterLoad",
78
+ urlPattern: /\/api\//,
79
+ action: { kind: "clear-storage", scopes: ["localStorage"] },
80
+ },
81
+ ]);
82
+ expect(c[0]!.pattern).toBeInstanceOf(RegExp);
83
+ expect(c[0]!.pattern!.test("/dashboard/home")).toBe(true);
84
+ expect(c[1]!.pattern).toBeInstanceOf(RegExp);
85
+ expect(c[1]!.pattern!.test("/api/x")).toBe(true);
86
+ });
87
+
88
+ it("leaves pattern null when urlPattern is omitted", () => {
89
+ const c = compileLifecycleFaults([
90
+ {
91
+ when: "afterLoad",
92
+ action: { kind: "clear-storage", scopes: ["localStorage"] },
93
+ },
94
+ ]);
95
+ expect(c[0]!.pattern).toBeNull();
96
+ });
97
+
98
+ it("derives the name from the action when not provided", () => {
99
+ const c = compileLifecycleFaults([
100
+ { when: "beforeNavigation", action: { kind: "cpu-throttle", rate: 8 } },
101
+ ]);
102
+ expect(c[0]!.name).toBe("cpu-throttle:8x");
103
+ });
104
+ });
105
+
106
+ describe("lifecycleMatchesUrl", () => {
107
+ it("matches every URL when pattern is null", () => {
108
+ expect(lifecycleMatchesUrl({ pattern: null }, "anything")).toBe(true);
109
+ });
110
+
111
+ it("delegates to the compiled regex when present", () => {
112
+ expect(lifecycleMatchesUrl({ pattern: /^\/api\// }, "/api/x")).toBe(true);
113
+ expect(lifecycleMatchesUrl({ pattern: /^\/api\// }, "/static/x")).toBe(false);
114
+ });
115
+ });
116
+
117
+ describe("shouldFireProbability", () => {
118
+ it("treats undefined / >=1 as always-fire and never consumes the RNG", () => {
119
+ let calls = 0;
120
+ const rng = {
121
+ next() {
122
+ calls++;
123
+ return 0.5;
124
+ },
125
+ };
126
+ expect(shouldFireProbability(undefined, rng)).toBe(true);
127
+ expect(shouldFireProbability(1, rng)).toBe(true);
128
+ expect(shouldFireProbability(2, rng)).toBe(true);
129
+ expect(calls).toBe(0);
130
+ });
131
+
132
+ it("treats <=0 as never-fire and never consumes the RNG", () => {
133
+ let calls = 0;
134
+ const rng = {
135
+ next() {
136
+ calls++;
137
+ return 0.5;
138
+ },
139
+ };
140
+ expect(shouldFireProbability(0, rng)).toBe(false);
141
+ expect(shouldFireProbability(-1, rng)).toBe(false);
142
+ expect(calls).toBe(0);
143
+ });
144
+
145
+ it("samples one number from the RNG when prob is in (0, 1)", () => {
146
+ // mulberry32 seed 1 → first next() ≈ 0.62707
147
+ const rng1 = createRng(1);
148
+ expect(shouldFireProbability(0.7, rng1)).toBe(true); // 0.627 < 0.7
149
+ const rng2 = createRng(1);
150
+ expect(shouldFireProbability(0.5, rng2)).toBe(false); // 0.627 >= 0.5
151
+ });
152
+
153
+ it("consumes exactly one RNG draw when prob is in (0, 1)", () => {
154
+ let calls = 0;
155
+ const rng = {
156
+ next() {
157
+ calls++;
158
+ return 0.5;
159
+ },
160
+ };
161
+ shouldFireProbability(0.6, rng);
162
+ expect(calls).toBe(1);
163
+ });
164
+ });
165
+
166
+ describe("lifecycleFaultsAtStage", () => {
167
+ it("filters by `when`", () => {
168
+ const compiled = compileLifecycleFaults([
169
+ { when: "beforeNavigation", action: { kind: "cpu-throttle", rate: 2 } },
170
+ {
171
+ when: "afterLoad",
172
+ action: { kind: "clear-storage", scopes: ["localStorage"] },
173
+ },
174
+ { when: "beforeActions", action: { kind: "evict-cache" } },
175
+ ]);
176
+ expect(lifecycleFaultsAtStage(compiled, "afterLoad").map((c) => c.name)).toEqual([
177
+ "clear-storage:localStorage",
178
+ ]);
179
+ expect(lifecycleFaultsAtStage(compiled, "betweenActions")).toEqual([]);
180
+ });
181
+ });
182
+
183
+ describe("lifecycleStatsFrom", () => {
184
+ it("projects matched / fired / errored counters", () => {
185
+ const compiled = compileLifecycleFaults([
186
+ { when: "beforeNavigation", action: { kind: "cpu-throttle", rate: 2 } },
187
+ ]);
188
+ compiled[0]!.matched = 5;
189
+ compiled[0]!.fired = 3;
190
+ compiled[0]!.errored = 1;
191
+ expect(lifecycleStatsFrom(compiled)).toEqual([
192
+ { name: "cpu-throttle:2x", matched: 5, fired: 3, errored: 1 },
193
+ ]);
194
+ });
195
+ });
196
+
197
+ describe("executeLifecycleAction", () => {
198
+ function makeFakeExecutor() {
199
+ const calls: Array<{ method: string; args: unknown[] }> = [];
200
+ const exec: LifecycleActionExecutor = {
201
+ async cpuThrottle(rate) {
202
+ calls.push({ method: "cpuThrottle", args: [rate] });
203
+ },
204
+ async clearStorage(scopes) {
205
+ calls.push({ method: "clearStorage", args: [Array.from(scopes)] });
206
+ },
207
+ async evictCache(cacheNames) {
208
+ calls.push({ method: "evictCache", args: [cacheNames ? Array.from(cacheNames) : undefined] });
209
+ },
210
+ async tamperStorage(scope, key, value) {
211
+ calls.push({ method: "tamperStorage", args: [scope, key, value] });
212
+ },
213
+ };
214
+ return { exec, calls };
215
+ }
216
+
217
+ it("dispatches cpu-throttle to cpuThrottle", async () => {
218
+ const { exec, calls } = makeFakeExecutor();
219
+ await executeLifecycleAction({ kind: "cpu-throttle", rate: 4 }, exec);
220
+ expect(calls).toEqual([{ method: "cpuThrottle", args: [4] }]);
221
+ });
222
+
223
+ it("dispatches clear-storage to clearStorage", async () => {
224
+ const { exec, calls } = makeFakeExecutor();
225
+ await executeLifecycleAction(
226
+ { kind: "clear-storage", scopes: ["localStorage", "cookies"] },
227
+ exec,
228
+ );
229
+ expect(calls).toEqual([{ method: "clearStorage", args: [["localStorage", "cookies"]] }]);
230
+ });
231
+
232
+ it("dispatches evict-cache to evictCache (with and without names)", async () => {
233
+ const { exec, calls } = makeFakeExecutor();
234
+ await executeLifecycleAction({ kind: "evict-cache" }, exec);
235
+ await executeLifecycleAction({ kind: "evict-cache", cacheNames: ["v1"] }, exec);
236
+ expect(calls).toEqual([
237
+ { method: "evictCache", args: [undefined] },
238
+ { method: "evictCache", args: [["v1"]] },
239
+ ]);
240
+ });
241
+
242
+ it("dispatches tamper-storage to tamperStorage", async () => {
243
+ const { exec, calls } = makeFakeExecutor();
244
+ await executeLifecycleAction(
245
+ { kind: "tamper-storage", scope: "sessionStorage", key: "auth", value: "expired" },
246
+ exec,
247
+ );
248
+ expect(calls).toEqual([
249
+ { method: "tamperStorage", args: ["sessionStorage", "auth", "expired"] },
250
+ ]);
251
+ });
252
+ });
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Page-lifecycle fault injection.
3
+ *
4
+ * Distinct from network-side `FaultRule` (request-scoped, applied via
5
+ * Playwright `route()`). These are page-scoped client-side perturbations
6
+ * applied at well-defined stages of a page visit — CPU throttle, storage
7
+ * wipe, Service Worker cache eviction, key/value tampering. The crawler
8
+ * decides when to call into the executor; the executor decides how to
9
+ * realise each `LifecycleAction` against the browser.
10
+ *
11
+ * Pure helpers (compile / match / probability roll / name derivation) live
12
+ * here so the routing logic is unit-testable without a real browser. The
13
+ * Playwright-backed executor lives at the bottom of this file.
14
+ */
15
+
16
+ import type { BrowserContext, CDPSession, Page } from "playwright";
17
+ import type {
18
+ LifecycleAction,
19
+ LifecycleFault,
20
+ LifecycleFaultStats,
21
+ LifecycleStage,
22
+ UrlMatcher,
23
+ } from "./types.js";
24
+
25
+ /** Compiled form: regex pre-compiled, name pre-derived. */
26
+ export interface CompiledLifecycleFault {
27
+ fault: LifecycleFault;
28
+ /** null when `fault.urlPattern` was omitted (matches every URL). */
29
+ pattern: RegExp | null;
30
+ name: string;
31
+ matched: number;
32
+ fired: number;
33
+ errored: number;
34
+ }
35
+
36
+ /** Auto-derive a stats label when the user didn't set `fault.name`. */
37
+ export function lifecycleFaultName(fault: LifecycleFault): string {
38
+ if (fault.name) return fault.name;
39
+ const a = fault.action;
40
+ switch (a.kind) {
41
+ case "cpu-throttle":
42
+ return `cpu-throttle:${a.rate}x`;
43
+ case "clear-storage":
44
+ return `clear-storage:${a.scopes.join("+")}`;
45
+ case "evict-cache":
46
+ return a.cacheNames && a.cacheNames.length > 0
47
+ ? `evict-cache:${a.cacheNames.join("+")}`
48
+ : "evict-cache";
49
+ case "tamper-storage":
50
+ return `tamper-storage:${a.scope}.${a.key}`;
51
+ }
52
+ }
53
+
54
+ function compilePattern(matcher: UrlMatcher | undefined): RegExp | null {
55
+ if (matcher === undefined) return null;
56
+ return matcher instanceof RegExp ? matcher : new RegExp(matcher);
57
+ }
58
+
59
+ export function compileLifecycleFaults(
60
+ faults: LifecycleFault[] | undefined,
61
+ ): CompiledLifecycleFault[] {
62
+ if (!faults || faults.length === 0) return [];
63
+ return faults.map((fault) => ({
64
+ fault,
65
+ pattern: compilePattern(fault.urlPattern),
66
+ name: lifecycleFaultName(fault),
67
+ matched: 0,
68
+ fired: 0,
69
+ errored: 0,
70
+ }));
71
+ }
72
+
73
+ /** True when `compiled.pattern` matches `url` (or no pattern was set). */
74
+ export function lifecycleMatchesUrl(
75
+ compiled: Pick<CompiledLifecycleFault, "pattern">,
76
+ url: string,
77
+ ): boolean {
78
+ return compiled.pattern === null || compiled.pattern.test(url);
79
+ }
80
+
81
+ /**
82
+ * Roll the seeded RNG against `probability`. Returns true when the fault
83
+ * should fire. `prob >= 1` (or undefined) always fires; `prob <= 0` never
84
+ * fires; anything in between samples one number from `rng`.
85
+ *
86
+ * RNG consumption is deliberately conditional on `prob < 1` so that adding
87
+ * a probability-1 fault to a config doesn't shift the seed sequence for
88
+ * existing chaos action selection.
89
+ */
90
+ export function shouldFireProbability(
91
+ prob: number | undefined,
92
+ rng: { next(): number },
93
+ ): boolean {
94
+ if (prob === undefined || prob >= 1) return true;
95
+ if (prob <= 0) return false;
96
+ return rng.next() < prob;
97
+ }
98
+
99
+ /** Pick the compiled faults that target a given lifecycle stage. */
100
+ export function lifecycleFaultsAtStage(
101
+ compiled: readonly CompiledLifecycleFault[],
102
+ stage: LifecycleStage,
103
+ ): CompiledLifecycleFault[] {
104
+ return compiled.filter((c) => c.fault.when === stage);
105
+ }
106
+
107
+ export function lifecycleStatsFrom(
108
+ compiled: readonly CompiledLifecycleFault[],
109
+ ): LifecycleFaultStats[] {
110
+ return compiled.map((c) => ({
111
+ name: c.name,
112
+ matched: c.matched,
113
+ fired: c.fired,
114
+ errored: c.errored,
115
+ }));
116
+ }
117
+
118
+ /**
119
+ * Browser-side primitives needed to realise each `LifecycleAction`. One
120
+ * method per action kind so tests can fake exactly what they exercise
121
+ * without standing up Playwright.
122
+ */
123
+ export interface LifecycleActionExecutor {
124
+ cpuThrottle(rate: number): Promise<void>;
125
+ clearStorage(scopes: readonly ("localStorage" | "sessionStorage" | "cookies" | "indexedDB")[]): Promise<void>;
126
+ evictCache(cacheNames?: readonly string[]): Promise<void>;
127
+ tamperStorage(scope: "localStorage" | "sessionStorage", key: string, value: string): Promise<void>;
128
+ }
129
+
130
+ /** Dispatch a single `LifecycleAction` to the right executor method. */
131
+ export async function executeLifecycleAction(
132
+ action: LifecycleAction,
133
+ executor: LifecycleActionExecutor,
134
+ ): Promise<void> {
135
+ switch (action.kind) {
136
+ case "cpu-throttle":
137
+ await executor.cpuThrottle(action.rate);
138
+ return;
139
+ case "clear-storage":
140
+ await executor.clearStorage(action.scopes);
141
+ return;
142
+ case "evict-cache":
143
+ await executor.evictCache(action.cacheNames);
144
+ return;
145
+ case "tamper-storage":
146
+ await executor.tamperStorage(action.scope, action.key, action.value);
147
+ return;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Real executor backed by Playwright. CPU throttle requires a CDP session;
153
+ * we attach lazily and reuse across calls on the same page.
154
+ */
155
+ export class PlaywrightLifecycleExecutor implements LifecycleActionExecutor {
156
+ private cdp: Promise<CDPSession> | null = null;
157
+
158
+ constructor(
159
+ private readonly page: Page,
160
+ private readonly context: BrowserContext,
161
+ ) {}
162
+
163
+ private getCdp(): Promise<CDPSession> {
164
+ if (this.cdp === null) {
165
+ this.cdp = this.context.newCDPSession(this.page);
166
+ }
167
+ return this.cdp;
168
+ }
169
+
170
+ async cpuThrottle(rate: number): Promise<void> {
171
+ const client = await this.getCdp();
172
+ await client.send("Emulation.setCPUThrottlingRate", { rate });
173
+ }
174
+
175
+ async clearStorage(
176
+ scopes: readonly ("localStorage" | "sessionStorage" | "cookies" | "indexedDB")[],
177
+ ): Promise<void> {
178
+ const inPage = scopes.filter((s) => s !== "cookies");
179
+ if (inPage.length > 0) {
180
+ // page.evaluate runs in the page context; we pass the scope set so the
181
+ // browser side decides which storages to wipe without us injecting JS.
182
+ await this.page.evaluate(async (scopeList: readonly string[]) => {
183
+ const s = new Set(scopeList);
184
+ if (s.has("localStorage")) {
185
+ try {
186
+ window.localStorage.clear();
187
+ } catch {
188
+ /* SecurityError on opaque origins */
189
+ }
190
+ }
191
+ if (s.has("sessionStorage")) {
192
+ try {
193
+ window.sessionStorage.clear();
194
+ } catch {
195
+ /* SecurityError on opaque origins */
196
+ }
197
+ }
198
+ if (s.has("indexedDB") && "indexedDB" in window) {
199
+ // databases() is not in every browser; guard.
200
+ // @ts-ignore - older lib.dom typings omit databases()
201
+ const dbs: Array<{ name?: string }> = (await indexedDB.databases?.()) ?? [];
202
+ await Promise.all(
203
+ dbs
204
+ .map((d) => d.name)
205
+ .filter((n): n is string => typeof n === "string")
206
+ .map(
207
+ (name) =>
208
+ new Promise<void>((resolve) => {
209
+ const req = indexedDB.deleteDatabase(name);
210
+ req.onsuccess = () => resolve();
211
+ req.onerror = () => resolve();
212
+ req.onblocked = () => resolve();
213
+ }),
214
+ ),
215
+ );
216
+ }
217
+ }, inPage);
218
+ }
219
+ if (scopes.includes("cookies")) {
220
+ // Context-level: drops every cookie across every page in the context.
221
+ await this.context.clearCookies();
222
+ }
223
+ }
224
+
225
+ async evictCache(cacheNames?: readonly string[]): Promise<void> {
226
+ await this.page.evaluate(async (names: readonly string[] | undefined) => {
227
+ if (!("caches" in self)) return;
228
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
229
+ const c = (self as any).caches as CacheStorage;
230
+ const all = await c.keys();
231
+ const target = names && names.length > 0 ? all.filter((k) => names.includes(k)) : all;
232
+ await Promise.all(target.map((k) => c.delete(k)));
233
+ }, cacheNames);
234
+ }
235
+
236
+ async tamperStorage(
237
+ scope: "localStorage" | "sessionStorage",
238
+ key: string,
239
+ value: string,
240
+ ): Promise<void> {
241
+ await this.page.evaluate(
242
+ ({ scope, key, value }: { scope: "localStorage" | "sessionStorage"; key: string; value: string }) => {
243
+ try {
244
+ (scope === "localStorage" ? window.localStorage : window.sessionStorage).setItem(key, value);
245
+ } catch {
246
+ /* SecurityError on opaque origins */
247
+ }
248
+ },
249
+ { scope, key, value },
250
+ );
251
+ }
252
+ }
package/src/random.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Tiny seeded RNG used by the package's own unit tests so they don't have
3
+ * to depend on chaosbringer. Not part of the public surface — only re-export
4
+ * from `index.ts` if a downstream legitimately needs the same generator
5
+ * function (most won't; they'll bring their own RNG).
6
+ */
7
+
8
+ import type { Rng } from "./types.js";
9
+
10
+ export interface SeededRng extends Rng {
11
+ readonly seed: number;
12
+ }
13
+
14
+ export function createRng(seed: number): SeededRng {
15
+ let state = seed >>> 0;
16
+ return {
17
+ seed,
18
+ next(): number {
19
+ state = (state + 0x6d2b79f5) >>> 0;
20
+ let t = state;
21
+ t = Math.imul(t ^ (t >>> 15), t | 1);
22
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
23
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
24
+ },
25
+ };
26
+ }