@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,282 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createRng } from "./random.js";
3
+ import {
4
+ buildRuntimeFaultsScript,
5
+ compileRuntimeFaults,
6
+ mergeRuntimeStats,
7
+ runtimeFaultName,
8
+ runtimeMatchesUrl,
9
+ shouldFireProbability,
10
+ } from "./runtime-faults.js";
11
+ import type { RuntimeFault } from "./types.js";
12
+
13
+ describe("runtimeFaultName", () => {
14
+ it("uses an explicit name when set", () => {
15
+ expect(
16
+ runtimeFaultName({ name: "custom", action: { kind: "flaky-fetch" } }),
17
+ ).toBe("custom");
18
+ });
19
+
20
+ it("auto-derives a name for flaky-fetch", () => {
21
+ expect(runtimeFaultName({ action: { kind: "flaky-fetch" } })).toBe("flaky-fetch");
22
+ });
23
+
24
+ it("auto-derives a name for clock-skew including the skew amount", () => {
25
+ expect(
26
+ runtimeFaultName({ action: { kind: "clock-skew", skewMs: 60_000 } }),
27
+ ).toBe("clock-skew:60000ms");
28
+ });
29
+ });
30
+
31
+ describe("compileRuntimeFaults", () => {
32
+ it("returns empty array for empty input", () => {
33
+ expect(compileRuntimeFaults(undefined)).toEqual([]);
34
+ expect(compileRuntimeFaults([])).toEqual([]);
35
+ });
36
+
37
+ it("compiles string urlPattern to RegExp", () => {
38
+ const compiled = compileRuntimeFaults([
39
+ { urlPattern: "api", action: { kind: "flaky-fetch" } },
40
+ ]);
41
+ expect(compiled[0]!.pattern).toBeInstanceOf(RegExp);
42
+ expect(compiled[0]!.pattern!.test("http://x/api/users")).toBe(true);
43
+ });
44
+
45
+ it("preserves RegExp urlPattern verbatim", () => {
46
+ const re = /\/api\//i;
47
+ const compiled = compileRuntimeFaults([{ urlPattern: re, action: { kind: "flaky-fetch" } }]);
48
+ expect(compiled[0]!.pattern).toBe(re);
49
+ });
50
+
51
+ it("leaves pattern null when urlPattern is omitted", () => {
52
+ const compiled = compileRuntimeFaults([{ action: { kind: "flaky-fetch" } }]);
53
+ expect(compiled[0]!.pattern).toBeNull();
54
+ });
55
+
56
+ it("seeds counters at zero", () => {
57
+ const compiled = compileRuntimeFaults([{ action: { kind: "flaky-fetch" } }]);
58
+ expect(compiled[0]!.matched).toBe(0);
59
+ expect(compiled[0]!.fired).toBe(0);
60
+ });
61
+ });
62
+
63
+ describe("runtimeMatchesUrl", () => {
64
+ it("matches everything when pattern is null", () => {
65
+ expect(runtimeMatchesUrl({ pattern: null }, "http://x/")).toBe(true);
66
+ });
67
+
68
+ it("matches via the regex", () => {
69
+ expect(runtimeMatchesUrl({ pattern: /\/api\// }, "http://x/api/users")).toBe(true);
70
+ expect(runtimeMatchesUrl({ pattern: /\/api\// }, "http://x/static/foo")).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe("shouldFireProbability", () => {
75
+ it("always fires when probability is undefined or >= 1", () => {
76
+ const rng = createRng(1);
77
+ expect(shouldFireProbability(undefined, rng)).toBe(true);
78
+ expect(shouldFireProbability(1, rng)).toBe(true);
79
+ expect(shouldFireProbability(2, rng)).toBe(true);
80
+ });
81
+
82
+ it("never fires when probability is 0 or negative", () => {
83
+ const rng = createRng(1);
84
+ expect(shouldFireProbability(0, rng)).toBe(false);
85
+ expect(shouldFireProbability(-1, rng)).toBe(false);
86
+ });
87
+
88
+ it("rolls deterministically against the RNG", () => {
89
+ const a = createRng(42);
90
+ const b = createRng(42);
91
+ const aFires: boolean[] = [];
92
+ const bFires: boolean[] = [];
93
+ for (let i = 0; i < 20; i++) {
94
+ aFires.push(shouldFireProbability(0.5, a));
95
+ bFires.push(shouldFireProbability(0.5, b));
96
+ }
97
+ expect(aFires).toEqual(bFires);
98
+ });
99
+ });
100
+
101
+ describe("buildRuntimeFaultsScript", () => {
102
+ it("returns a string starting with an IIFE", () => {
103
+ const s = buildRuntimeFaultsScript(
104
+ [{ action: { kind: "flaky-fetch" } }],
105
+ 42,
106
+ );
107
+ expect(s.startsWith("(() => {")).toBe(true);
108
+ expect(s).toContain("__chaosbringerRuntimeFaultsInstalled");
109
+ expect(s).toContain("__chaosbringerRuntimeStats");
110
+ });
111
+
112
+ it("guards against re-installation", () => {
113
+ const s = buildRuntimeFaultsScript([{ action: { kind: "flaky-fetch" } }], 42);
114
+ expect(s).toContain("if (window.__chaosbringerRuntimeFaultsInstalled) return");
115
+ });
116
+
117
+ it("inlines the seed verbatim", () => {
118
+ const s = buildRuntimeFaultsScript([{ action: { kind: "flaky-fetch" } }], 12345);
119
+ expect(s).toContain("12345");
120
+ });
121
+
122
+ it("serializes RegExp urlPattern as { source, flags }", () => {
123
+ const s = buildRuntimeFaultsScript(
124
+ [{ urlPattern: /\/api\//i, action: { kind: "flaky-fetch" } }],
125
+ 1,
126
+ );
127
+ expect(s).toContain('"source":"\\\\/api\\\\/"');
128
+ expect(s).toContain('"flags":"i"');
129
+ });
130
+
131
+ it("serializes string urlPattern with empty flags", () => {
132
+ const s = buildRuntimeFaultsScript(
133
+ [{ urlPattern: "/api/", action: { kind: "flaky-fetch" } }],
134
+ 1,
135
+ );
136
+ expect(s).toContain('"source":"/api/"');
137
+ expect(s).toContain('"flags":""');
138
+ });
139
+
140
+ it("includes the fetch patch only when a flaky-fetch fault is present", () => {
141
+ const withFetch = buildRuntimeFaultsScript(
142
+ [{ action: { kind: "flaky-fetch" } }],
143
+ 1,
144
+ );
145
+ expect(withFetch).toContain("flaky-fetch");
146
+ expect(withFetch).toContain("Promise.reject");
147
+ });
148
+
149
+ it("includes the clock-skew patch only when a clock-skew fault is present", () => {
150
+ const skewed = buildRuntimeFaultsScript(
151
+ [{ action: { kind: "clock-skew", skewMs: 5000 } }],
152
+ 1,
153
+ );
154
+ expect(skewed).toContain("clock-skew");
155
+ expect(skewed).toContain("performance.now");
156
+ expect(skewed).toContain("Date.now");
157
+ });
158
+
159
+ it("uses the fault's auto-derived name as a display label", () => {
160
+ const s = buildRuntimeFaultsScript(
161
+ [{ action: { kind: "flaky-fetch" } }, { action: { kind: "clock-skew", skewMs: 1000 } }],
162
+ 1,
163
+ );
164
+ expect(s).toContain('"name":"flaky-fetch"');
165
+ expect(s).toContain('"name":"clock-skew:1000ms"');
166
+ });
167
+
168
+ it("assigns sequential id keys so duplicate names don't collide in stats", () => {
169
+ const s = buildRuntimeFaultsScript(
170
+ [{ action: { kind: "flaky-fetch" } }, { action: { kind: "flaky-fetch" } }],
171
+ 1,
172
+ );
173
+ expect(s).toContain('"id":0');
174
+ expect(s).toContain('"id":1');
175
+ // The stats accessor uses String(f.id), not f.name.
176
+ expect(s).toContain("stats[String(f.id)]");
177
+ });
178
+
179
+ it("does not 32-bit-truncate large skewMs values", () => {
180
+ // 30 days = 2,592,000,000 ms — exceeds int32 max. The script must
181
+ // accumulate via Number(...), not `| 0`.
182
+ const s = buildRuntimeFaultsScript(
183
+ [{ action: { kind: "clock-skew", skewMs: 2_592_000_000 } }],
184
+ 1,
185
+ );
186
+ expect(s).not.toContain("skewMs | 0");
187
+ expect(s).toContain("Number(f.action.skewMs)");
188
+ });
189
+
190
+ it("respects an explicit fault name", () => {
191
+ const s = buildRuntimeFaultsScript(
192
+ [{ name: "spy", action: { kind: "flaky-fetch" } }],
193
+ 1,
194
+ );
195
+ expect(s).toContain('"name":"spy"');
196
+ });
197
+
198
+ it("is a no-op when no faults are configured (still emits the IIFE shell)", () => {
199
+ const s = buildRuntimeFaultsScript([], 1);
200
+ expect(s.startsWith("(() => {")).toBe(true);
201
+ expect(s).toContain("__chaosbringerRuntimeFaultsInstalled");
202
+ });
203
+
204
+ it("accepts negative seed by coercing into an unsigned 32-bit", () => {
205
+ const s = buildRuntimeFaultsScript([{ action: { kind: "flaky-fetch" } }], -1);
206
+ // -1 coerced to uint32 is 4294967295.
207
+ expect(s).toContain("4294967295");
208
+ });
209
+ });
210
+
211
+ describe("mergeRuntimeStats", () => {
212
+ it("accumulates per-page counters by index into the compiled fault counters", () => {
213
+ const compiled = compileRuntimeFaults([
214
+ { name: "f1", action: { kind: "flaky-fetch" } },
215
+ { name: "c1", action: { kind: "clock-skew", skewMs: 100 } },
216
+ ]);
217
+ mergeRuntimeStats(compiled, { "0": { matched: 3, fired: 2 } });
218
+ expect(compiled[0]!.matched).toBe(3);
219
+ expect(compiled[0]!.fired).toBe(2);
220
+ expect(compiled[1]!.matched).toBe(0);
221
+ expect(compiled[1]!.fired).toBe(0);
222
+ mergeRuntimeStats(compiled, { "0": { matched: 1, fired: 1 } });
223
+ expect(compiled[0]!.matched).toBe(4);
224
+ expect(compiled[0]!.fired).toBe(3);
225
+ });
226
+
227
+ it("does not collapse counters when two faults share a name", () => {
228
+ // Both default to name="flaky-fetch"; index keys keep them apart.
229
+ const compiled = compileRuntimeFaults([
230
+ { action: { kind: "flaky-fetch" }, urlPattern: /\/api\/a/ },
231
+ { action: { kind: "flaky-fetch" }, urlPattern: /\/api\/b/ },
232
+ ]);
233
+ mergeRuntimeStats(compiled, {
234
+ "0": { matched: 5, fired: 5 },
235
+ "1": { matched: 1, fired: 0 },
236
+ });
237
+ expect(compiled[0]!.matched).toBe(5);
238
+ expect(compiled[0]!.fired).toBe(5);
239
+ expect(compiled[1]!.matched).toBe(1);
240
+ expect(compiled[1]!.fired).toBe(0);
241
+ });
242
+
243
+ it("ignores stats keys that don't correspond to a compiled fault", () => {
244
+ const compiled = compileRuntimeFaults([{ name: "known", action: { kind: "flaky-fetch" } }]);
245
+ mergeRuntimeStats(compiled, { "99": { matched: 99, fired: 99 } });
246
+ expect(compiled[0]!.matched).toBe(0);
247
+ });
248
+
249
+ it("returns a stats array shaped like RuntimeFaultStats", () => {
250
+ const compiled = compileRuntimeFaults([{ name: "f", action: { kind: "flaky-fetch" } }]);
251
+ const out = mergeRuntimeStats(compiled, { "0": { matched: 5, fired: 3 } });
252
+ expect(out).toEqual([{ rule: "f", matched: 5, fired: 3 }]);
253
+ });
254
+
255
+ it("falls back to a name-keyed snapshot for the first matching fault", () => {
256
+ // Backwards-compat path: when reading legacy snapshots that keyed
257
+ // by name, the merge applies the count to the first compiled fault
258
+ // with that name and skips the rest.
259
+ const compiled = compileRuntimeFaults([
260
+ { name: "shared", action: { kind: "flaky-fetch" } },
261
+ { name: "shared", action: { kind: "flaky-fetch" } },
262
+ ]);
263
+ mergeRuntimeStats(compiled, { shared: { matched: 4, fired: 2 } });
264
+ expect(compiled[0]!.matched).toBe(4);
265
+ expect(compiled[0]!.fired).toBe(2);
266
+ expect(compiled[1]!.matched).toBe(0);
267
+ expect(compiled[1]!.fired).toBe(0);
268
+ });
269
+ });
270
+
271
+ describe("faults.flakyFetch / faults.clockSkew round-trip via compile", () => {
272
+ it("compiles a programmatic fault config without errors", () => {
273
+ const faults: RuntimeFault[] = [
274
+ { action: { kind: "flaky-fetch", rejectionMessage: "oops" }, probability: 0.5 },
275
+ { action: { kind: "clock-skew", skewMs: 30_000 } },
276
+ ];
277
+ const compiled = compileRuntimeFaults(faults);
278
+ expect(compiled).toHaveLength(2);
279
+ expect(compiled[0]!.name).toBe("flaky-fetch");
280
+ expect(compiled[1]!.name).toBe("clock-skew:30000ms");
281
+ });
282
+ });
@@ -0,0 +1,255 @@
1
+ /**
2
+ * JS-runtime fault injection.
3
+ *
4
+ * Distinct from `FaultRule` (request-scoped, applied via Playwright `route()`)
5
+ * and `LifecycleFault` (one-shot at named stages of a page visit). These are
6
+ * persistent monkey-patches injected into every page via `addInitScript`,
7
+ * subverting in-page JS APIs (fetch / Date / storage / addEventListener) so
8
+ * the app sees client-side failures that no network mock would expose.
9
+ *
10
+ * Examples:
11
+ * - `flaky-fetch`: `window.fetch` rejects with a TypeError before any
12
+ * network round-trip — simulates "Failed to fetch" / DNS down / Service
13
+ * Worker reject. Different from `faults.status(500)`, which still
14
+ * resolves the promise.
15
+ * - `clock-skew`: `Date.now` / `performance.now` are shifted forward by N
16
+ * ms — exposes token-expiry / cache-bust bugs on long sessions.
17
+ *
18
+ * Pure helpers (`buildRuntimeFaultsScript`, `runtimeFaultName`,
19
+ * `compileRuntimeFaults`) generate / serialize the init script and roll
20
+ * probability — unit-testable without a browser. Stats are reported by the
21
+ * in-page script via a known `window.__chaosbringerRuntimeStats` global; the
22
+ * crawler reads it after each page visit.
23
+ */
24
+
25
+ import type { Rng, RuntimeFault, RuntimeFaultStats, UrlMatcher } from "./types.js";
26
+
27
+ /** Compiled form: regex pre-compiled, name pre-derived. */
28
+ export interface CompiledRuntimeFault {
29
+ fault: RuntimeFault;
30
+ /** null when `fault.urlPattern` was omitted. */
31
+ pattern: RegExp | null;
32
+ name: string;
33
+ matched: number;
34
+ fired: number;
35
+ }
36
+
37
+ /** Auto-derive a stats label when the user didn't set `fault.name`. */
38
+ export function runtimeFaultName(fault: RuntimeFault): string {
39
+ if (fault.name) return fault.name;
40
+ const a = fault.action;
41
+ switch (a.kind) {
42
+ case "flaky-fetch":
43
+ return "flaky-fetch";
44
+ case "clock-skew":
45
+ return `clock-skew:${a.skewMs}ms`;
46
+ }
47
+ }
48
+
49
+ function compilePattern(matcher: UrlMatcher | undefined): RegExp | null {
50
+ if (matcher === undefined) return null;
51
+ return matcher instanceof RegExp ? matcher : new RegExp(matcher);
52
+ }
53
+
54
+ export function compileRuntimeFaults(
55
+ faults: RuntimeFault[] | undefined,
56
+ ): CompiledRuntimeFault[] {
57
+ if (!faults || faults.length === 0) return [];
58
+ return faults.map((fault) => ({
59
+ fault,
60
+ pattern: compilePattern(fault.urlPattern),
61
+ name: runtimeFaultName(fault),
62
+ matched: 0,
63
+ fired: 0,
64
+ }));
65
+ }
66
+
67
+ /** True when `compiled.pattern` matches `url` (or no pattern was set). */
68
+ export function runtimeMatchesUrl(
69
+ compiled: Pick<CompiledRuntimeFault, "pattern">,
70
+ url: string,
71
+ ): boolean {
72
+ return compiled.pattern === null || compiled.pattern.test(url);
73
+ }
74
+
75
+ /**
76
+ * Decide whether a probabilistic fault fires this time. Mirrors the
77
+ * lifecycle / network helpers so all three layers share a deterministic
78
+ * roll behaviour given the same RNG.
79
+ */
80
+ export function shouldFireProbability(probability: number | undefined, rng: Rng): boolean {
81
+ const p = probability ?? 1;
82
+ if (p >= 1) return true;
83
+ if (p <= 0) return false;
84
+ return rng.next() < p;
85
+ }
86
+
87
+ /**
88
+ * Serialize a UrlMatcher into a structure the in-page script can rebuild
89
+ * without `eval`. Strings stay strings; RegExp becomes `{ source, flags }`.
90
+ */
91
+ function serializeMatcher(m: UrlMatcher | undefined): { source: string; flags: string } | null {
92
+ if (m === undefined) return null;
93
+ if (m instanceof RegExp) return { source: m.source, flags: m.flags };
94
+ return { source: m, flags: "" };
95
+ }
96
+
97
+ /**
98
+ * Build the init script body. Self-contained IIFE — no closure over the
99
+ * caller's scope, no external imports — because Playwright serializes init
100
+ * scripts as plain text and runs them in a fresh frame on every navigation.
101
+ *
102
+ * `seed` lets each page roll deterministic probabilities. Pass the
103
+ * crawler's seed so a `(seed, runtimeFaults)` pair always produces the same
104
+ * pattern of injections.
105
+ */
106
+ export function buildRuntimeFaultsScript(
107
+ faults: ReadonlyArray<RuntimeFault>,
108
+ seed: number,
109
+ ): string {
110
+ // Stats keys are indices, not names — two faults can legitimately share
111
+ // a name (`flaky-fetch` x2 with different urlPatterns) and we mustn't
112
+ // collapse their counters.
113
+ const serialized = faults.map((f, i) => ({
114
+ id: i,
115
+ name: runtimeFaultName(f),
116
+ pattern: serializeMatcher(f.urlPattern),
117
+ probability: typeof f.probability === "number" ? f.probability : 1,
118
+ action: f.action,
119
+ }));
120
+
121
+ // Body of the init script. Indented for readability; whitespace is fine
122
+ // because Playwright won't minify it.
123
+ return `(() => {
124
+ if (typeof window === "undefined") return;
125
+ if (window.__chaosbringerRuntimeFaultsInstalled) return;
126
+ window.__chaosbringerRuntimeFaultsInstalled = true;
127
+ window.__chaosbringerRuntimeStats = {};
128
+
129
+ // Park-Miller LCG — small, deterministic, good enough for fault rolls.
130
+ let __rng = ${seed >>> 0} || 1;
131
+ const __nextRoll = () => {
132
+ __rng = ((__rng * 16807) % 2147483647) | 0;
133
+ if (__rng <= 0) __rng += 2147483647;
134
+ return (__rng - 1) / 2147483646;
135
+ };
136
+
137
+ const faults = ${JSON.stringify(serialized)};
138
+ const stats = window.__chaosbringerRuntimeStats;
139
+ for (const f of faults) stats[String(f.id)] = { matched: 0, fired: 0 };
140
+
141
+ const matchUrl = (pattern, url) => {
142
+ if (!pattern) return true;
143
+ try {
144
+ return new RegExp(pattern.source, pattern.flags).test(url);
145
+ } catch {
146
+ return false;
147
+ }
148
+ };
149
+
150
+ const roll = (f) => {
151
+ const slot = stats[String(f.id)];
152
+ slot.matched++;
153
+ if (f.probability >= 1) {
154
+ slot.fired++;
155
+ return true;
156
+ }
157
+ if (f.probability <= 0) return false;
158
+ const fired = __nextRoll() < f.probability;
159
+ if (fired) slot.fired++;
160
+ return fired;
161
+ };
162
+
163
+ // --- flaky-fetch ---
164
+ const fetchFaults = faults.filter((f) => f.action.kind === "flaky-fetch");
165
+ if (fetchFaults.length > 0 && typeof window.fetch === "function") {
166
+ const realFetch = window.fetch.bind(window);
167
+ window.fetch = function chaosFetch(input, init) {
168
+ const url =
169
+ typeof input === "string" ? input :
170
+ input instanceof URL ? input.toString() :
171
+ (input && typeof input.url === "string") ? input.url :
172
+ "";
173
+ for (const f of fetchFaults) {
174
+ if (matchUrl(f.pattern, url) && roll(f)) {
175
+ const msg = f.action.rejectionMessage || "chaosbringer: simulated fetch failure";
176
+ return Promise.reject(new TypeError(msg));
177
+ }
178
+ }
179
+ return realFetch(input, init);
180
+ };
181
+ }
182
+
183
+ // --- clock-skew ---
184
+ const skewFaults = faults.filter((f) => f.action.kind === "clock-skew");
185
+ if (skewFaults.length > 0) {
186
+ // Use a plain accumulator. Multi-day skews (e.g. 30 days ~ 2.6e9 ms)
187
+ // exceed int32 max, so any '| 0' truncation here would flip them negative.
188
+ let totalSkew = 0;
189
+ for (const f of skewFaults) {
190
+ if (matchUrl(f.pattern, location.href) && roll(f)) {
191
+ totalSkew += Number(f.action.skewMs);
192
+ }
193
+ }
194
+ if (totalSkew !== 0) {
195
+ const realDateNow = Date.now.bind(Date);
196
+ Date.now = () => realDateNow() + totalSkew;
197
+ const realPerfNow = performance.now.bind(performance);
198
+ performance.now = () => realPerfNow() + totalSkew;
199
+ // Patch the Date constructor so \`new Date()\` (no args) also skews.
200
+ const RealDate = Date;
201
+ const SkewedDate = function (...args) {
202
+ if (args.length === 0) return new RealDate(realDateNow() + totalSkew);
203
+ // @ts-ignore
204
+ return new RealDate(...args);
205
+ };
206
+ SkewedDate.now = Date.now;
207
+ SkewedDate.UTC = RealDate.UTC;
208
+ SkewedDate.parse = RealDate.parse;
209
+ SkewedDate.prototype = RealDate.prototype;
210
+ // @ts-ignore
211
+ window.Date = SkewedDate;
212
+ }
213
+ }
214
+ })();`;
215
+ }
216
+
217
+ /**
218
+ * Read the in-page stats counter and merge into the compiled-fault counters
219
+ * (`matched` and `fired`). Returns the merged stats; the compiled-fault
220
+ * objects are mutated in place so the next page picks up where this one
221
+ * left off.
222
+ *
223
+ * Stats keys are array indices so two faults with the same name don't
224
+ * collide their counters. Both legacy name-keyed snapshots and the
225
+ * current index-keyed shape are accepted: name-keyed entries are applied
226
+ * to the first compiled fault with that name (the safe, non-collapsing
227
+ * fallback used when reading older traces).
228
+ */
229
+ export function mergeRuntimeStats(
230
+ compiled: CompiledRuntimeFault[],
231
+ pageStats: Record<string, { matched: number; fired: number }>,
232
+ ): RuntimeFaultStats[] {
233
+ for (let i = 0; i < compiled.length; i++) {
234
+ const c = compiled[i]!;
235
+ // Index-keyed (current shape).
236
+ const ps = pageStats[String(i)];
237
+ if (ps) {
238
+ c.matched += ps.matched;
239
+ c.fired += ps.fired;
240
+ continue;
241
+ }
242
+ // Backwards-compat: name-keyed (one slot per distinct name only;
243
+ // applied to the first compiled fault that wears that name).
244
+ const byName = pageStats[c.name];
245
+ if (byName && !compiled.slice(0, i).some((c2) => c2.name === c.name)) {
246
+ c.matched += byName.matched;
247
+ c.fired += byName.fired;
248
+ }
249
+ }
250
+ return compiled.map((c) => ({
251
+ rule: c.name,
252
+ matched: c.matched,
253
+ fired: c.fired,
254
+ }));
255
+ }