@vijaypjavvadi/bdd2pw 3.8.1 → 3.10.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 +171 -0
- package/README.md +396 -332
- package/dist/cli.js +30 -0
- package/dist/cli.js.map +1 -1
- package/dist/index.js +29 -1
- package/dist/index.js.map +1 -1
- package/dist/llm/index.d.ts +1 -0
- package/dist/llm/index.js +4 -1
- package/dist/llm/index.js.map +1 -1
- package/dist/llm/prompt.js +66 -66
- package/dist/llm/telemetry.d.ts +150 -0
- package/dist/llm/telemetry.js +230 -0
- package/dist/llm/telemetry.js.map +1 -0
- package/dist/reports/healStats.d.ts +132 -0
- package/dist/reports/healStats.js +303 -0
- package/dist/reports/healStats.js.map +1 -0
- package/dist/types.d.ts +11 -0
- package/package.json +1 -1
- package/templates/heal.ts.tmpl +452 -452
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* v3.9.0 — LLM telemetry collector.
|
|
4
|
+
*
|
|
5
|
+
* Subscribes to the existing `LLMLogEvent` stream that AnthropicLLMClient
|
|
6
|
+
* emits during `generateBinding` / `generateBatchBindings`, accumulates
|
|
7
|
+
* per-call stats, and exposes an aggregated summary suitable for writing
|
|
8
|
+
* to `<repo>/artefacts/llm-stats.json`.
|
|
9
|
+
*
|
|
10
|
+
* Why this exists: v3.5.0 introduced per-scenario batching which can cut
|
|
11
|
+
* Anthropic spend by ~75% on unmatched-heavy scaffolds. Until this
|
|
12
|
+
* release, the savings were invisible — operators had no per-scaffold
|
|
13
|
+
* record of cache hit rate, batch size distribution, token counts, or
|
|
14
|
+
* estimated cost. The sidecar makes the ROI measurable per run.
|
|
15
|
+
*
|
|
16
|
+
* Design constraints:
|
|
17
|
+
* - Zero overhead when disabled. The collector is constructed
|
|
18
|
+
* conditionally; the log-event fan-out is a single `if` check at
|
|
19
|
+
* the bdd2pw orchestrator layer.
|
|
20
|
+
* - Per-call detail PLUS aggregates. Operators want both "this
|
|
21
|
+
* scaffold cost $0.07" and "the batch of 5 unmatched steps in
|
|
22
|
+
* scenario X took 2.3s and consumed 6700 input tokens".
|
|
23
|
+
* - Cost is an ESTIMATE. Provider prices change; the sidecar
|
|
24
|
+
* records the per-million rates used so a reviewer can re-derive
|
|
25
|
+
* the number against current pricing.
|
|
26
|
+
* - Backwards compatible: no change to LLMClient interface, no
|
|
27
|
+
* change to existing log consumers. The collector is a regular
|
|
28
|
+
* `log: (e) => {...}` subscriber.
|
|
29
|
+
*/
|
|
30
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
31
|
+
exports.LLMTelemetry = exports.DEFAULT_PRICING = void 0;
|
|
32
|
+
/**
|
|
33
|
+
* Per-million-token pricing snapshot used to estimate cost. Prices
|
|
34
|
+
* change; the sidecar records what we used so a reviewer can re-derive.
|
|
35
|
+
*
|
|
36
|
+
* Update when you know providers raised / lowered prices, OR pass an
|
|
37
|
+
* override via `LLMTelemetry`'s constructor.
|
|
38
|
+
*/
|
|
39
|
+
exports.DEFAULT_PRICING = {
|
|
40
|
+
// Anthropic Claude Sonnet 4.6 — Q4 2025 published pricing.
|
|
41
|
+
"claude-sonnet-4-6": { inputUsdPerMillion: 3, outputUsdPerMillion: 15 },
|
|
42
|
+
// Older Sonnet generation (still used by some clients) — same pricing.
|
|
43
|
+
"claude-3-7-sonnet-latest": {
|
|
44
|
+
inputUsdPerMillion: 3,
|
|
45
|
+
outputUsdPerMillion: 15,
|
|
46
|
+
},
|
|
47
|
+
// Anthropic Opus — when used.
|
|
48
|
+
"claude-opus-4-6": { inputUsdPerMillion: 15, outputUsdPerMillion: 75 },
|
|
49
|
+
// Anthropic Haiku — when used.
|
|
50
|
+
"claude-haiku-4-5-20251001": {
|
|
51
|
+
inputUsdPerMillion: 0.8,
|
|
52
|
+
outputUsdPerMillion: 4,
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Telemetry collector. One instance per scaffold call. Subscribe via
|
|
57
|
+
* `.handleEvent` (typed to match the existing `LLMLogEvent` shape);
|
|
58
|
+
* read aggregates via `.toSummary()`; write to disk via
|
|
59
|
+
* `.writeSidecar(path)`.
|
|
60
|
+
*/
|
|
61
|
+
class LLMTelemetry {
|
|
62
|
+
constructor(scaffoldId, version,
|
|
63
|
+
/** Override DEFAULT_PRICING when known per model. */
|
|
64
|
+
pricingOverride) {
|
|
65
|
+
this.scaffoldId = scaffoldId;
|
|
66
|
+
this.version = version;
|
|
67
|
+
this.pricingOverride = pricingOverride;
|
|
68
|
+
this.callsCounter = 0;
|
|
69
|
+
/**
|
|
70
|
+
* cache_miss events fire BEFORE provider_call_start in
|
|
71
|
+
* AnthropicLLMClient.generateBatchBindings (cache lookup is step 1,
|
|
72
|
+
* provider call is step 4). We accumulate misses here and snapshot
|
|
73
|
+
* the counter into the in-flight record when the call starts, so
|
|
74
|
+
* batchSize reports correctly regardless of event ordering.
|
|
75
|
+
*/
|
|
76
|
+
this.pendingMisses = 0;
|
|
77
|
+
/**
|
|
78
|
+
* The call we're currently accumulating into. provider_call_start
|
|
79
|
+
* opens a record (capturing pendingMisses as batchSize); provider_call_done
|
|
80
|
+
* fills in latency + tokens and appends to completedCalls.
|
|
81
|
+
*/
|
|
82
|
+
this.inFlight = null;
|
|
83
|
+
this.completedCalls = [];
|
|
84
|
+
this.cacheHits = 0;
|
|
85
|
+
this.cacheMisses = 0;
|
|
86
|
+
this.bindingsGenerated = 0;
|
|
87
|
+
this.callsAttempted = 0;
|
|
88
|
+
this.modelName = "unknown";
|
|
89
|
+
/**
|
|
90
|
+
* Subscribe this method to the AnthropicLLMClient log callback:
|
|
91
|
+
*
|
|
92
|
+
* new AnthropicLLMClient({ ..., log: telemetry.handleEvent }, ...)
|
|
93
|
+
*
|
|
94
|
+
* Or compose with the existing pino logger by fanning out:
|
|
95
|
+
*
|
|
96
|
+
* log: (e) => { pinoLog(e); telemetry.handleEvent(e); }
|
|
97
|
+
*
|
|
98
|
+
* Multiple events arrive per call. The collector reads the shape
|
|
99
|
+
* to advance its internal state machine.
|
|
100
|
+
*/
|
|
101
|
+
this.handleEvent = (event) => {
|
|
102
|
+
switch (event.kind) {
|
|
103
|
+
case "cache_hit":
|
|
104
|
+
this.cacheHits += 1;
|
|
105
|
+
this.bindingsGenerated += 1;
|
|
106
|
+
break;
|
|
107
|
+
case "cache_miss":
|
|
108
|
+
this.cacheMisses += 1;
|
|
109
|
+
// Real flow: cache_miss fires BEFORE provider_call_start
|
|
110
|
+
// (cache lookup is step 1, provider call is step 4). Buffer
|
|
111
|
+
// the misses in `pendingMisses` and snapshot them into the
|
|
112
|
+
// record at start time. Also support the case where misses
|
|
113
|
+
// fire AFTER start (older code paths / future refactors) by
|
|
114
|
+
// adding to inFlight.batchSize too.
|
|
115
|
+
if (this.inFlight)
|
|
116
|
+
this.inFlight.batchSize += 1;
|
|
117
|
+
else
|
|
118
|
+
this.pendingMisses += 1;
|
|
119
|
+
break;
|
|
120
|
+
case "provider_call_start":
|
|
121
|
+
this.callsAttempted += 1;
|
|
122
|
+
this.inFlight = {
|
|
123
|
+
model: event.model,
|
|
124
|
+
latencyMs: 0,
|
|
125
|
+
inputTokens: 0,
|
|
126
|
+
outputTokens: 0,
|
|
127
|
+
batchSize: this.pendingMisses,
|
|
128
|
+
startTime: Date.now(),
|
|
129
|
+
};
|
|
130
|
+
// Consume the buffered misses so a subsequent call doesn't
|
|
131
|
+
// double-count them.
|
|
132
|
+
this.pendingMisses = 0;
|
|
133
|
+
// The pricing snapshot tracks whichever model we see first.
|
|
134
|
+
// Most scaffolds use one model start-to-finish.
|
|
135
|
+
this.modelName = event.model;
|
|
136
|
+
const pricing = (this.pricingOverride && this.pricingOverride[event.model]) ??
|
|
137
|
+
exports.DEFAULT_PRICING[event.model];
|
|
138
|
+
if (pricing)
|
|
139
|
+
this.modelPricing = pricing;
|
|
140
|
+
break;
|
|
141
|
+
case "provider_call_done":
|
|
142
|
+
if (this.inFlight) {
|
|
143
|
+
this.inFlight.latencyMs = event.latencyMs;
|
|
144
|
+
this.inFlight.inputTokens = event.inputTokens;
|
|
145
|
+
this.inFlight.outputTokens = event.outputTokens;
|
|
146
|
+
// batchSize may have been incremented by cache_miss events
|
|
147
|
+
// between start and done. If somehow 0, default to 1 (single-
|
|
148
|
+
// step generateBinding case where the orchestrator goes
|
|
149
|
+
// through the per-step path that doesn't emit cache_miss in
|
|
150
|
+
// the start→done window).
|
|
151
|
+
if (this.inFlight.batchSize === 0)
|
|
152
|
+
this.inFlight.batchSize = 1;
|
|
153
|
+
this.callsCounter += 1;
|
|
154
|
+
this.completedCalls.push({
|
|
155
|
+
index: this.callsCounter,
|
|
156
|
+
model: this.inFlight.model,
|
|
157
|
+
inputTokens: this.inFlight.inputTokens,
|
|
158
|
+
outputTokens: this.inFlight.outputTokens,
|
|
159
|
+
latencyMs: this.inFlight.latencyMs,
|
|
160
|
+
fromCache: false,
|
|
161
|
+
batchSize: this.inFlight.batchSize,
|
|
162
|
+
});
|
|
163
|
+
this.inFlight = null;
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
case "binding_parsed":
|
|
167
|
+
this.bindingsGenerated += 1;
|
|
168
|
+
break;
|
|
169
|
+
// error events don't affect aggregates; they're already
|
|
170
|
+
// captured by the surrounding scaffold log.
|
|
171
|
+
default:
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
toSummary() {
|
|
177
|
+
const latencies = this.completedCalls
|
|
178
|
+
.map((c) => c.latencyMs)
|
|
179
|
+
.sort((a, b) => a - b);
|
|
180
|
+
const inputTokens = this.completedCalls.reduce((acc, c) => acc + c.inputTokens, 0);
|
|
181
|
+
const outputTokens = this.completedCalls.reduce((acc, c) => acc + c.outputTokens, 0);
|
|
182
|
+
const pricing = this.modelPricing ?? {
|
|
183
|
+
inputUsdPerMillion: 0,
|
|
184
|
+
outputUsdPerMillion: 0,
|
|
185
|
+
};
|
|
186
|
+
const estimatedCostUsd = (inputTokens / 1000000) * pricing.inputUsdPerMillion +
|
|
187
|
+
(outputTokens / 1000000) * pricing.outputUsdPerMillion;
|
|
188
|
+
const totalCacheChecks = this.cacheHits + this.cacheMisses;
|
|
189
|
+
return {
|
|
190
|
+
version: this.version,
|
|
191
|
+
scaffoldId: this.scaffoldId,
|
|
192
|
+
generatedAt: new Date().toISOString(),
|
|
193
|
+
totals: {
|
|
194
|
+
callsAttempted: this.callsAttempted,
|
|
195
|
+
callsSuccessful: this.callsCounter,
|
|
196
|
+
bindingsGenerated: this.bindingsGenerated,
|
|
197
|
+
cacheHits: this.cacheHits,
|
|
198
|
+
cacheMisses: this.cacheMisses,
|
|
199
|
+
cacheHitRate: totalCacheChecks === 0 ? 0 : this.cacheHits / totalCacheChecks,
|
|
200
|
+
inputTokens,
|
|
201
|
+
outputTokens,
|
|
202
|
+
estimatedCostUsd: Number(estimatedCostUsd.toFixed(6)),
|
|
203
|
+
},
|
|
204
|
+
calls: this.completedCalls,
|
|
205
|
+
latencyMs: {
|
|
206
|
+
p50: percentile(latencies, 0.5),
|
|
207
|
+
p95: percentile(latencies, 0.95),
|
|
208
|
+
min: latencies.length ? latencies[0] : 0,
|
|
209
|
+
max: latencies.length ? latencies[latencies.length - 1] : 0,
|
|
210
|
+
},
|
|
211
|
+
pricing: {
|
|
212
|
+
model: this.modelName,
|
|
213
|
+
inputUsdPerMillion: pricing.inputUsdPerMillion,
|
|
214
|
+
outputUsdPerMillion: pricing.outputUsdPerMillion,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
exports.LLMTelemetry = LLMTelemetry;
|
|
220
|
+
/**
|
|
221
|
+
* Index-based percentile. Returns 0 for an empty array. Sorted input.
|
|
222
|
+
* Picks the ceiling index so p95 of [10, 20, 30, 40, 50] is 50, not 40.
|
|
223
|
+
*/
|
|
224
|
+
function percentile(sortedAsc, p) {
|
|
225
|
+
if (sortedAsc.length === 0)
|
|
226
|
+
return 0;
|
|
227
|
+
const idx = Math.ceil(sortedAsc.length * p) - 1;
|
|
228
|
+
return sortedAsc[Math.max(0, Math.min(idx, sortedAsc.length - 1))];
|
|
229
|
+
}
|
|
230
|
+
//# sourceMappingURL=telemetry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"telemetry.js","sourceRoot":"","sources":["../../src/llm/telemetry.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v3.10.0 — heal-events.jsonl → heal-stats.json analyzer.
|
|
3
|
+
*
|
|
4
|
+
* Same flavor as v3.9.0's `LLMTelemetry` sidecar but for the
|
|
5
|
+
* self-healing pipeline. Where v3.9.0 measures the LLM batching ROI
|
|
6
|
+
* per scaffold, v3.10.0 measures the self-healing ROI per TEST RUN.
|
|
7
|
+
*
|
|
8
|
+
* Input: `<repo>/artefacts/heal-events.jsonl` — one JSON event per
|
|
9
|
+
* line, written by the runtime `healOrThrow` helper from
|
|
10
|
+
* `templates/heal.ts.tmpl`. Event kinds:
|
|
11
|
+
* - `register` — locator constructed
|
|
12
|
+
* - `heal_attempt` — action failed; asking the heal
|
|
13
|
+
* service for a candidate
|
|
14
|
+
* - `healed` — candidate worked; subsequent
|
|
15
|
+
* calls use it transparently
|
|
16
|
+
* - `heal_unavailable` — no candidate / candidate failed
|
|
17
|
+
*
|
|
18
|
+
* Output: `<repo>/artefacts/heal-stats.json` — stable shape:
|
|
19
|
+
*
|
|
20
|
+
* {
|
|
21
|
+
* "version": "3.10.0",
|
|
22
|
+
* "generatedAt": "2026-...",
|
|
23
|
+
* "source": "<events file>",
|
|
24
|
+
* "totals": {
|
|
25
|
+
* "registrations": 47,
|
|
26
|
+
* "healAttempts": 6,
|
|
27
|
+
* "healed": 4,
|
|
28
|
+
* "healUnavailable": 2,
|
|
29
|
+
* "healRate": 0.6667, // healed / healAttempts
|
|
30
|
+
* "uniqueFields": 23,
|
|
31
|
+
* "uniquePages": 3
|
|
32
|
+
* },
|
|
33
|
+
* "topFailingFields": [
|
|
34
|
+
* { "page": "LoginPage", "name": "submitButton", "attempts": 3, "healed": 2 },
|
|
35
|
+
* ...
|
|
36
|
+
* ],
|
|
37
|
+
* "topErrors": [
|
|
38
|
+
* { "error": "Timeout 30000ms exceeded", "count": 4 },
|
|
39
|
+
* ...
|
|
40
|
+
* ],
|
|
41
|
+
* "topCandidates": [
|
|
42
|
+
* { "selector": "[data-testid='submit']", "promotions": 2,
|
|
43
|
+
* "averageConfidence": 0.87 },
|
|
44
|
+
* ...
|
|
45
|
+
* ],
|
|
46
|
+
* "retryLatencyMs": { "p50": 1240, "p95": 3010, "min": 880, "max": 3010 },
|
|
47
|
+
* "perScenario": [
|
|
48
|
+
* { "scenario": "User can log in", "attempts": 1, "healed": 1 },
|
|
49
|
+
* ...
|
|
50
|
+
* ]
|
|
51
|
+
* }
|
|
52
|
+
*
|
|
53
|
+
* Why a separate command (not part of scaffold): scaffold GENERATES the
|
|
54
|
+
* test repo; the heal events are produced when the user RUNS the
|
|
55
|
+
* generated tests. So this analyzer is a post-test command, run from
|
|
56
|
+
* CI after `npx playwright test`. Same pattern as `bdd2pw propose-rules`
|
|
57
|
+
* which runs after the LLM has populated `candidate-rules.jsonl`.
|
|
58
|
+
*/
|
|
59
|
+
export interface HealStatsOptions {
|
|
60
|
+
/** Path to heal-events.jsonl OR a repo dir containing it. */
|
|
61
|
+
inputPath: string;
|
|
62
|
+
/** Override output path. Default: <input dir>/heal-stats.json. */
|
|
63
|
+
outputPath?: string;
|
|
64
|
+
/** How many top entries to keep in each ranked list. Default 10. */
|
|
65
|
+
topN?: number;
|
|
66
|
+
}
|
|
67
|
+
export interface HealStatsResult {
|
|
68
|
+
outputPath: string;
|
|
69
|
+
totalEvents: number;
|
|
70
|
+
}
|
|
71
|
+
/** One event line in heal-events.jsonl. Shape tracks `templates/heal.ts.tmpl`. */
|
|
72
|
+
interface HealEvent {
|
|
73
|
+
ts: string;
|
|
74
|
+
event: "register" | "heal_attempt" | "healed" | "heal_unavailable";
|
|
75
|
+
page: string;
|
|
76
|
+
name: string;
|
|
77
|
+
scenario_name?: string | null;
|
|
78
|
+
method?: string;
|
|
79
|
+
original?: string | null;
|
|
80
|
+
healed?: string | null;
|
|
81
|
+
confidence?: number | null;
|
|
82
|
+
error?: string | null;
|
|
83
|
+
}
|
|
84
|
+
export interface HealStats {
|
|
85
|
+
version: string;
|
|
86
|
+
generatedAt: string;
|
|
87
|
+
source: string;
|
|
88
|
+
totals: {
|
|
89
|
+
registrations: number;
|
|
90
|
+
healAttempts: number;
|
|
91
|
+
healed: number;
|
|
92
|
+
healUnavailable: number;
|
|
93
|
+
healRate: number;
|
|
94
|
+
uniqueFields: number;
|
|
95
|
+
uniquePages: number;
|
|
96
|
+
};
|
|
97
|
+
topFailingFields: {
|
|
98
|
+
page: string;
|
|
99
|
+
name: string;
|
|
100
|
+
attempts: number;
|
|
101
|
+
healed: number;
|
|
102
|
+
}[];
|
|
103
|
+
topErrors: {
|
|
104
|
+
error: string;
|
|
105
|
+
count: number;
|
|
106
|
+
}[];
|
|
107
|
+
topCandidates: {
|
|
108
|
+
selector: string;
|
|
109
|
+
promotions: number;
|
|
110
|
+
averageConfidence: number | null;
|
|
111
|
+
}[];
|
|
112
|
+
retryLatencyMs: {
|
|
113
|
+
p50: number;
|
|
114
|
+
p95: number;
|
|
115
|
+
min: number;
|
|
116
|
+
max: number;
|
|
117
|
+
};
|
|
118
|
+
perScenario: {
|
|
119
|
+
scenario: string;
|
|
120
|
+
attempts: number;
|
|
121
|
+
healed: number;
|
|
122
|
+
}[];
|
|
123
|
+
}
|
|
124
|
+
/** Public entry point. */
|
|
125
|
+
export declare function analyseHealStats(opts: HealStatsOptions): Promise<HealStatsResult>;
|
|
126
|
+
/**
|
|
127
|
+
* Compute a HealStats summary from an in-memory event list. Pure
|
|
128
|
+
* function — no I/O. Used both by the public CLI entry and the unit
|
|
129
|
+
* tests.
|
|
130
|
+
*/
|
|
131
|
+
export declare function aggregate(events: HealEvent[], source: string, topN: number): HealStats;
|
|
132
|
+
export {};
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* v3.10.0 — heal-events.jsonl → heal-stats.json analyzer.
|
|
4
|
+
*
|
|
5
|
+
* Same flavor as v3.9.0's `LLMTelemetry` sidecar but for the
|
|
6
|
+
* self-healing pipeline. Where v3.9.0 measures the LLM batching ROI
|
|
7
|
+
* per scaffold, v3.10.0 measures the self-healing ROI per TEST RUN.
|
|
8
|
+
*
|
|
9
|
+
* Input: `<repo>/artefacts/heal-events.jsonl` — one JSON event per
|
|
10
|
+
* line, written by the runtime `healOrThrow` helper from
|
|
11
|
+
* `templates/heal.ts.tmpl`. Event kinds:
|
|
12
|
+
* - `register` — locator constructed
|
|
13
|
+
* - `heal_attempt` — action failed; asking the heal
|
|
14
|
+
* service for a candidate
|
|
15
|
+
* - `healed` — candidate worked; subsequent
|
|
16
|
+
* calls use it transparently
|
|
17
|
+
* - `heal_unavailable` — no candidate / candidate failed
|
|
18
|
+
*
|
|
19
|
+
* Output: `<repo>/artefacts/heal-stats.json` — stable shape:
|
|
20
|
+
*
|
|
21
|
+
* {
|
|
22
|
+
* "version": "3.10.0",
|
|
23
|
+
* "generatedAt": "2026-...",
|
|
24
|
+
* "source": "<events file>",
|
|
25
|
+
* "totals": {
|
|
26
|
+
* "registrations": 47,
|
|
27
|
+
* "healAttempts": 6,
|
|
28
|
+
* "healed": 4,
|
|
29
|
+
* "healUnavailable": 2,
|
|
30
|
+
* "healRate": 0.6667, // healed / healAttempts
|
|
31
|
+
* "uniqueFields": 23,
|
|
32
|
+
* "uniquePages": 3
|
|
33
|
+
* },
|
|
34
|
+
* "topFailingFields": [
|
|
35
|
+
* { "page": "LoginPage", "name": "submitButton", "attempts": 3, "healed": 2 },
|
|
36
|
+
* ...
|
|
37
|
+
* ],
|
|
38
|
+
* "topErrors": [
|
|
39
|
+
* { "error": "Timeout 30000ms exceeded", "count": 4 },
|
|
40
|
+
* ...
|
|
41
|
+
* ],
|
|
42
|
+
* "topCandidates": [
|
|
43
|
+
* { "selector": "[data-testid='submit']", "promotions": 2,
|
|
44
|
+
* "averageConfidence": 0.87 },
|
|
45
|
+
* ...
|
|
46
|
+
* ],
|
|
47
|
+
* "retryLatencyMs": { "p50": 1240, "p95": 3010, "min": 880, "max": 3010 },
|
|
48
|
+
* "perScenario": [
|
|
49
|
+
* { "scenario": "User can log in", "attempts": 1, "healed": 1 },
|
|
50
|
+
* ...
|
|
51
|
+
* ]
|
|
52
|
+
* }
|
|
53
|
+
*
|
|
54
|
+
* Why a separate command (not part of scaffold): scaffold GENERATES the
|
|
55
|
+
* test repo; the heal events are produced when the user RUNS the
|
|
56
|
+
* generated tests. So this analyzer is a post-test command, run from
|
|
57
|
+
* CI after `npx playwright test`. Same pattern as `bdd2pw propose-rules`
|
|
58
|
+
* which runs after the LLM has populated `candidate-rules.jsonl`.
|
|
59
|
+
*/
|
|
60
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
61
|
+
if (k2 === undefined) k2 = k;
|
|
62
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
63
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
64
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
65
|
+
}
|
|
66
|
+
Object.defineProperty(o, k2, desc);
|
|
67
|
+
}) : (function(o, m, k, k2) {
|
|
68
|
+
if (k2 === undefined) k2 = k;
|
|
69
|
+
o[k2] = m[k];
|
|
70
|
+
}));
|
|
71
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
72
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
73
|
+
}) : function(o, v) {
|
|
74
|
+
o["default"] = v;
|
|
75
|
+
});
|
|
76
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
77
|
+
var ownKeys = function(o) {
|
|
78
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
79
|
+
var ar = [];
|
|
80
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
81
|
+
return ar;
|
|
82
|
+
};
|
|
83
|
+
return ownKeys(o);
|
|
84
|
+
};
|
|
85
|
+
return function (mod) {
|
|
86
|
+
if (mod && mod.__esModule) return mod;
|
|
87
|
+
var result = {};
|
|
88
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
89
|
+
__setModuleDefault(result, mod);
|
|
90
|
+
return result;
|
|
91
|
+
};
|
|
92
|
+
})();
|
|
93
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
94
|
+
exports.analyseHealStats = analyseHealStats;
|
|
95
|
+
exports.aggregate = aggregate;
|
|
96
|
+
const fs = __importStar(require("fs-extra"));
|
|
97
|
+
const path = __importStar(require("path"));
|
|
98
|
+
const PACKAGE_VERSION = "3.10.0";
|
|
99
|
+
/** Public entry point. */
|
|
100
|
+
async function analyseHealStats(opts) {
|
|
101
|
+
const jsonlPath = await resolveJsonlPath(opts.inputPath);
|
|
102
|
+
const events = await readEvents(jsonlPath);
|
|
103
|
+
const summary = aggregate(events, jsonlPath, opts.topN ?? 10);
|
|
104
|
+
const outputPath = opts.outputPath ?? path.join(path.dirname(jsonlPath), "heal-stats.json");
|
|
105
|
+
await fs.ensureDir(path.dirname(outputPath));
|
|
106
|
+
await fs.writeFile(outputPath, JSON.stringify(summary, null, 2), "utf8");
|
|
107
|
+
return { outputPath, totalEvents: events.length };
|
|
108
|
+
}
|
|
109
|
+
/* ─── pure aggregation (testable without fs) ──────────────────────── */
|
|
110
|
+
/**
|
|
111
|
+
* Compute a HealStats summary from an in-memory event list. Pure
|
|
112
|
+
* function — no I/O. Used both by the public CLI entry and the unit
|
|
113
|
+
* tests.
|
|
114
|
+
*/
|
|
115
|
+
function aggregate(events, source, topN) {
|
|
116
|
+
const fieldKey = (e) => `${e.page}::${e.name}`;
|
|
117
|
+
const fieldAttempts = new Map();
|
|
118
|
+
const errorCounts = new Map();
|
|
119
|
+
const candidateStats = new Map();
|
|
120
|
+
const scenarioStats = new Map();
|
|
121
|
+
const uniquePages = new Set();
|
|
122
|
+
const uniqueFields = new Set();
|
|
123
|
+
const retryLatencies = [];
|
|
124
|
+
/**
|
|
125
|
+
* Match heal_attempt events with their matching `healed` /
|
|
126
|
+
* `heal_unavailable` outcome by (page, name, method). The keying
|
|
127
|
+
* assumes a heal attempt resolves before another one fires for the
|
|
128
|
+
* same field+method combo, which holds in practice — actions run
|
|
129
|
+
* serially per test.
|
|
130
|
+
*/
|
|
131
|
+
const openAttempts = new Map();
|
|
132
|
+
const attemptKey = (e) => `${e.page}::${e.name}::${e.method ?? ""}`;
|
|
133
|
+
let registrations = 0;
|
|
134
|
+
let healAttempts = 0;
|
|
135
|
+
let healed = 0;
|
|
136
|
+
let healUnavailable = 0;
|
|
137
|
+
for (const e of events) {
|
|
138
|
+
uniquePages.add(e.page);
|
|
139
|
+
uniqueFields.add(fieldKey(e));
|
|
140
|
+
if (e.event === "register") {
|
|
141
|
+
registrations += 1;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (e.event === "heal_attempt") {
|
|
145
|
+
healAttempts += 1;
|
|
146
|
+
const field = fieldAttempts.get(fieldKey(e)) ?? {
|
|
147
|
+
page: e.page,
|
|
148
|
+
name: e.name,
|
|
149
|
+
attempts: 0,
|
|
150
|
+
healed: 0,
|
|
151
|
+
};
|
|
152
|
+
field.attempts += 1;
|
|
153
|
+
fieldAttempts.set(fieldKey(e), field);
|
|
154
|
+
if (e.error) {
|
|
155
|
+
const msg = truncateError(e.error);
|
|
156
|
+
errorCounts.set(msg, (errorCounts.get(msg) ?? 0) + 1);
|
|
157
|
+
}
|
|
158
|
+
const scenario = e.scenario_name ?? "(no scenario context)";
|
|
159
|
+
const ss = scenarioStats.get(scenario) ?? { attempts: 0, healed: 0 };
|
|
160
|
+
ss.attempts += 1;
|
|
161
|
+
scenarioStats.set(scenario, ss);
|
|
162
|
+
openAttempts.set(attemptKey(e), Date.parse(e.ts));
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (e.event === "healed") {
|
|
166
|
+
healed += 1;
|
|
167
|
+
const field = fieldAttempts.get(fieldKey(e)) ?? {
|
|
168
|
+
page: e.page,
|
|
169
|
+
name: e.name,
|
|
170
|
+
attempts: 0,
|
|
171
|
+
healed: 0,
|
|
172
|
+
};
|
|
173
|
+
field.healed += 1;
|
|
174
|
+
fieldAttempts.set(fieldKey(e), field);
|
|
175
|
+
if (e.healed) {
|
|
176
|
+
const cs = candidateStats.get(e.healed) ?? {
|
|
177
|
+
promotions: 0,
|
|
178
|
+
confidenceSum: 0,
|
|
179
|
+
confidenceCount: 0,
|
|
180
|
+
};
|
|
181
|
+
cs.promotions += 1;
|
|
182
|
+
if (typeof e.confidence === "number") {
|
|
183
|
+
cs.confidenceSum += e.confidence;
|
|
184
|
+
cs.confidenceCount += 1;
|
|
185
|
+
}
|
|
186
|
+
candidateStats.set(e.healed, cs);
|
|
187
|
+
}
|
|
188
|
+
const scenario = e.scenario_name ?? "(no scenario context)";
|
|
189
|
+
const ss = scenarioStats.get(scenario) ?? { attempts: 0, healed: 0 };
|
|
190
|
+
ss.healed += 1;
|
|
191
|
+
scenarioStats.set(scenario, ss);
|
|
192
|
+
const startTs = openAttempts.get(attemptKey(e));
|
|
193
|
+
if (startTs !== undefined) {
|
|
194
|
+
retryLatencies.push(Date.parse(e.ts) - startTs);
|
|
195
|
+
openAttempts.delete(attemptKey(e));
|
|
196
|
+
}
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (e.event === "heal_unavailable") {
|
|
200
|
+
healUnavailable += 1;
|
|
201
|
+
openAttempts.delete(attemptKey(e));
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const sortedFailingFields = Array.from(fieldAttempts.values())
|
|
206
|
+
.sort((a, b) => b.attempts - a.attempts)
|
|
207
|
+
.slice(0, topN);
|
|
208
|
+
const sortedErrors = Array.from(errorCounts.entries())
|
|
209
|
+
.map(([error, count]) => ({ error, count }))
|
|
210
|
+
.sort((a, b) => b.count - a.count)
|
|
211
|
+
.slice(0, topN);
|
|
212
|
+
const sortedCandidates = Array.from(candidateStats.entries())
|
|
213
|
+
.map(([selector, s]) => ({
|
|
214
|
+
selector,
|
|
215
|
+
promotions: s.promotions,
|
|
216
|
+
averageConfidence: s.confidenceCount > 0 ? s.confidenceSum / s.confidenceCount : null,
|
|
217
|
+
}))
|
|
218
|
+
.sort((a, b) => b.promotions - a.promotions)
|
|
219
|
+
.slice(0, topN);
|
|
220
|
+
const sortedScenarios = Array.from(scenarioStats.entries())
|
|
221
|
+
.map(([scenario, s]) => ({ scenario, ...s }))
|
|
222
|
+
.sort((a, b) => b.attempts - a.attempts)
|
|
223
|
+
.slice(0, topN);
|
|
224
|
+
const latenciesSorted = [...retryLatencies].sort((a, b) => a - b);
|
|
225
|
+
return {
|
|
226
|
+
version: PACKAGE_VERSION,
|
|
227
|
+
generatedAt: new Date().toISOString(),
|
|
228
|
+
source,
|
|
229
|
+
totals: {
|
|
230
|
+
registrations,
|
|
231
|
+
healAttempts,
|
|
232
|
+
healed,
|
|
233
|
+
healUnavailable,
|
|
234
|
+
healRate: healAttempts === 0 ? 0 : healed / healAttempts,
|
|
235
|
+
uniqueFields: uniqueFields.size,
|
|
236
|
+
uniquePages: uniquePages.size,
|
|
237
|
+
},
|
|
238
|
+
topFailingFields: sortedFailingFields,
|
|
239
|
+
topErrors: sortedErrors,
|
|
240
|
+
topCandidates: sortedCandidates,
|
|
241
|
+
retryLatencyMs: {
|
|
242
|
+
p50: percentile(latenciesSorted, 0.5),
|
|
243
|
+
p95: percentile(latenciesSorted, 0.95),
|
|
244
|
+
min: latenciesSorted.length ? latenciesSorted[0] : 0,
|
|
245
|
+
max: latenciesSorted.length
|
|
246
|
+
? latenciesSorted[latenciesSorted.length - 1]
|
|
247
|
+
: 0,
|
|
248
|
+
},
|
|
249
|
+
perScenario: sortedScenarios,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
function percentile(sortedAsc, p) {
|
|
253
|
+
if (sortedAsc.length === 0)
|
|
254
|
+
return 0;
|
|
255
|
+
const idx = Math.ceil(sortedAsc.length * p) - 1;
|
|
256
|
+
return sortedAsc[Math.max(0, Math.min(idx, sortedAsc.length - 1))];
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Normalise an error message so different timestamps / IDs collapse to
|
|
260
|
+
* the same bucket: lowercase, strip leading "Error: " / "TimeoutError",
|
|
261
|
+
* collapse runs of digits.
|
|
262
|
+
*/
|
|
263
|
+
function truncateError(s) {
|
|
264
|
+
return s
|
|
265
|
+
.toLowerCase()
|
|
266
|
+
.replace(/^(?:error|timeouterror|protocolerror):\s*/i, "")
|
|
267
|
+
.replace(/\b\d{3,}\b/g, "<n>")
|
|
268
|
+
.slice(0, 120);
|
|
269
|
+
}
|
|
270
|
+
/* ─── filesystem glue ─────────────────────────────────────────────── */
|
|
271
|
+
async function resolveJsonlPath(inputPath) {
|
|
272
|
+
const stat = await fs.stat(inputPath).catch(() => undefined);
|
|
273
|
+
if (stat?.isFile())
|
|
274
|
+
return inputPath;
|
|
275
|
+
if (stat?.isDirectory()) {
|
|
276
|
+
const direct = path.join(inputPath, "heal-events.jsonl");
|
|
277
|
+
if (await fs.pathExists(direct))
|
|
278
|
+
return direct;
|
|
279
|
+
const nested = path.join(inputPath, "artefacts", "heal-events.jsonl");
|
|
280
|
+
if (await fs.pathExists(nested))
|
|
281
|
+
return nested;
|
|
282
|
+
return nested; // analyser will handle 'no events' gracefully
|
|
283
|
+
}
|
|
284
|
+
return path.join(inputPath, "artefacts", "heal-events.jsonl");
|
|
285
|
+
}
|
|
286
|
+
async function readEvents(jsonlPath) {
|
|
287
|
+
if (!(await fs.pathExists(jsonlPath)))
|
|
288
|
+
return [];
|
|
289
|
+
const raw = await fs.readFile(jsonlPath, "utf8");
|
|
290
|
+
const out = [];
|
|
291
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
292
|
+
if (!line.trim())
|
|
293
|
+
continue;
|
|
294
|
+
try {
|
|
295
|
+
out.push(JSON.parse(line));
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
/* skip malformed line */
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
//# sourceMappingURL=healStats.js.map
|