playwright-intelligence-engine 2.0.0 → 2.0.2

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.
Files changed (48) hide show
  1. package/README.md +229 -229
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/core/version.d.ts +3 -3
  4. package/dist/index.js +81 -24
  5. package/package.json +76 -76
  6. package/src/authoring/author.ts +81 -81
  7. package/src/authoring/generation-contract.ts +283 -283
  8. package/src/authoring/hybrid-author.ts +115 -115
  9. package/src/authoring/index.ts +7 -7
  10. package/src/authoring/llm-author.ts +282 -282
  11. package/src/authoring/naming/name-plan.ts +28 -28
  12. package/src/authoring/naming/name-planner.ts +339 -339
  13. package/src/authoring/naming/naming-linter.ts +292 -292
  14. package/src/cli/index.ts +1238 -1172
  15. package/src/collectors/git-diff-collector.ts +190 -190
  16. package/src/core/version.ts +25 -25
  17. package/src/diagnostics/diagnostic-report.ts +204 -204
  18. package/src/diagnostics/failure-classifier.ts +701 -701
  19. package/src/evaluation/repository-runner.ts +197 -197
  20. package/src/gate/quality.ts +101 -101
  21. package/src/generator/architecture-generator.ts +582 -582
  22. package/src/generator/assertion-quality-gate.ts +88 -88
  23. package/src/generator/candidate-writer.ts +288 -288
  24. package/src/generator/test-generator.ts +52 -52
  25. package/src/index.ts +107 -107
  26. package/src/intelligence/evidence-bundle.ts +300 -300
  27. package/src/intelligence/generation-agent.ts +561 -561
  28. package/src/intelligence/generation-verifier.ts +1115 -1115
  29. package/src/intelligence/locator-disambiguation-engine.ts +331 -331
  30. package/src/intelligence/project-intelligence.ts +140 -140
  31. package/src/intelligence/repository-profile.ts +796 -796
  32. package/src/intelligence/trace-proposal-workflow.ts +318 -318
  33. package/src/intelligence/workflow-planner.ts +966 -966
  34. package/src/mcp/server.ts +970 -970
  35. package/src/perception/app-model.ts +32 -32
  36. package/src/perception/crawler.ts +263 -263
  37. package/src/perception/index.ts +3 -3
  38. package/src/repair/assertion-hasher.ts +386 -386
  39. package/src/repair/diff-allowlist.ts +612 -612
  40. package/src/repair/intent-validator.ts +761 -761
  41. package/src/repair/repair-planner.ts +467 -467
  42. package/src/repair/statement-patcher.ts +544 -544
  43. package/src/reproduce/interactive-workflow-runner.ts +828 -828
  44. package/src/sandbox/execution-runner.ts +408 -408
  45. package/src/selector/abstention-policy.ts +175 -175
  46. package/src/selector/candidate-extractor.ts +343 -343
  47. package/src/selector/repair-candidate-ranker.ts +92 -92
  48. package/src/selector/selector-scorer.ts +344 -344
@@ -1,701 +1,701 @@
1
- import { DiagnosticContext, FailureCategory, NetworkEvent } from '../core/types/diagnostic-context.js';
2
- import { CandidateExtractor } from '../selector/candidate-extractor.js';
3
-
4
- export interface CausalEvidenceNode {
5
- type: 'action' | 'network' | 'console' | 'dom';
6
- id: string;
7
- timestamp: number;
8
- description: string;
9
- isCausallyRelevant: boolean;
10
- relevanceReason: string;
11
- frameId?: string | undefined;
12
- }
13
-
14
- export type CausalEdgeRelation =
15
- | 'TEMPORALLY_PRECEDES'
16
- | 'SAME_FRAME'
17
- | 'INITIATED_BY'
18
- | 'SUPPORTS'
19
- | 'CONTRADICTS';
20
-
21
- export interface CausalEvidenceEdge {
22
- fromNodeId: string;
23
- toNodeId: string;
24
- relation: CausalEdgeRelation;
25
- description?: string;
26
- }
27
-
28
- export interface CausalEvidenceGraph {
29
- nodes: CausalEvidenceNode[];
30
- edges: CausalEvidenceEdge[];
31
- actionNode?: CausalEvidenceNode | undefined;
32
- domNode?: CausalEvidenceNode | undefined;
33
- hasCausallyRelevantServerError: boolean;
34
- hasCausallyRelevantAuthError: boolean;
35
- offendingNetworkEvent?: NetworkEvent | undefined;
36
- }
37
-
38
- export type RuntimeSymptom =
39
- | 'TIMEOUT'
40
- | 'ELEMENT_NOT_FOUND'
41
- | 'ASSERTION_FAILED'
42
- | 'ACTION_INTERCEPTED'
43
- | 'TARGET_CLOSED'
44
- | 'NETWORK_ERROR'
45
- | 'PAGE_CRASH'
46
- | 'UNKNOWN_SYMPTOM';
47
-
48
- export type CausalRootCause =
49
- | 'LOCATOR_DRIFT'
50
- | 'DYNAMIC_CONTENT_SLOW'
51
- | 'NETWORK_SYNCHRONIZATION_FAILURE'
52
- | 'OBSTRUCTED_ELEMENT'
53
- | 'DISABLED_ELEMENT'
54
- | 'BACKEND_500'
55
- | 'AUTH_EXPIRED'
56
- | 'BUSINESS_LOGIC_DEFECT'
57
- | 'AMBIGUOUS_MATCH'
58
- | 'UNKNOWN_ROOT_CAUSE';
59
-
60
- export interface ClassificationResult {
61
- category: FailureCategory;
62
- confidence: number;
63
- reason: string;
64
- shouldAbstain: boolean;
65
- abstentionReason?: string | undefined;
66
- supportingEvidence: string[];
67
- causalGraph?: CausalEvidenceGraph | undefined;
68
- runtimeSymptom?: RuntimeSymptom | undefined;
69
- causalRootCause?: CausalRootCause | undefined;
70
- }
71
-
72
- export class FailureClassifier {
73
- private static readonly TELEMETRY_PATTERNS = [
74
- /analytics/i,
75
- /telemetry/i,
76
- /beacon/i,
77
- /collect/i,
78
- /metrics/i,
79
- /google-analytics/i,
80
- /doubleclick/i,
81
- /sentry/i,
82
- /datadog/i,
83
- /favicon\.ico/i,
84
- /hotjar/i,
85
- /segment\.io/i,
86
- ];
87
-
88
- /**
89
- * Builds an explicit Causal Evidence Graph to correlate network events with failed actions,
90
- * distinguishing primary business requests from background analytics and telemetry noise.
91
- */
92
- public static buildCausalEvidenceGraph(context: DiagnosticContext): CausalEvidenceGraph {
93
- const nodes: CausalEvidenceNode[] = [];
94
- const edges: CausalEvidenceEdge[] = [];
95
- let hasCausallyRelevantServerError = false;
96
- let hasCausallyRelevantAuthError = false;
97
- let offendingNetworkEvent: NetworkEvent | undefined;
98
-
99
- // Action node
100
- const actionNodeId = context.failedAction.callId || 'failed-action-0';
101
- const actionTimestamp = context.failedAction.timestamp ?? Date.now();
102
- const actionNode: CausalEvidenceNode = {
103
- type: 'action',
104
- id: actionNodeId,
105
- timestamp: actionTimestamp,
106
- description: `Action: ${context.failedAction.action} (${context.failedAction.selector ?? 'no selector'})`,
107
- isCausallyRelevant: true,
108
- relevanceReason: 'Primary failed action under diagnosis',
109
- frameId: context.failedAction.frameId,
110
- };
111
-
112
- // DOM Snapshot node
113
- let domNode: CausalEvidenceNode | undefined;
114
- const hasDom = Boolean(
115
- context.snapshots?.action || context.snapshots?.before || context.snapshots?.after
116
- );
117
- if (hasDom) {
118
- domNode = {
119
- type: 'dom',
120
- id: 'dom-snapshot',
121
- timestamp: actionTimestamp,
122
- description: 'DOM Snapshot available at failure point',
123
- isCausallyRelevant: true,
124
- relevanceReason: 'Captures target page state at action time',
125
- };
126
- edges.push({
127
- fromNodeId: 'dom-snapshot',
128
- toNodeId: actionNodeId,
129
- relation: 'SUPPORTS',
130
- description: 'DOM snapshot provides ground truth for element availability',
131
- });
132
- }
133
-
134
- for (const net of context.networkEvents) {
135
- const isTelemetry = FailureClassifier.TELEMETRY_PATTERNS.some((p) => p.test(net.url));
136
- const isServerError = net.status >= 500 && net.status <= 599;
137
- const isAuthError = net.status === 401 || net.status === 403;
138
- const isClientApiError = (net.status === 400 || net.status === 404 || net.status === 429) && !isTelemetry;
139
-
140
- let isCausallyRelevant = false;
141
- let relevanceReason = '';
142
-
143
- if (isTelemetry) {
144
- isCausallyRelevant = false;
145
- relevanceReason = 'Ignored: background telemetry/analytics or non-critical resource';
146
- } else if (isServerError || isAuthError || isClientApiError) {
147
- // Business endpoint returning 5xx, 401/403, or client API failure 400/404/429
148
- isCausallyRelevant = true;
149
- relevanceReason = `Causally relevant: HTTP ${net.status} on application endpoint ${net.method} ${net.url}`;
150
-
151
- if (isServerError || isClientApiError) {
152
- hasCausallyRelevantServerError = true;
153
- offendingNetworkEvent = net;
154
- }
155
- if (isAuthError) {
156
- hasCausallyRelevantAuthError = true;
157
- offendingNetworkEvent = net;
158
- }
159
- } else {
160
- isCausallyRelevant = false;
161
- relevanceReason = `Normal response (HTTP ${net.status})`;
162
- }
163
-
164
- const netNodeId = net.requestId ?? net.url;
165
- nodes.push({
166
- type: 'network',
167
- id: netNodeId,
168
- timestamp: net.timestamp,
169
- description: `${net.method} ${net.url} -> ${net.status}`,
170
- isCausallyRelevant,
171
- relevanceReason,
172
- frameId: net.frameId,
173
- });
174
-
175
- // Construct causal edges
176
- if (typeof net.timestamp === 'number' && typeof context.failedAction.timestamp === 'number') {
177
- if (net.timestamp <= context.failedAction.timestamp) {
178
- edges.push({
179
- fromNodeId: netNodeId,
180
- toNodeId: actionNodeId,
181
- relation: 'TEMPORALLY_PRECEDES',
182
- description: `Network event completed ${context.failedAction.timestamp - net.timestamp}ms before action`,
183
- });
184
- } else if (net.timestamp > context.failedAction.timestamp && net.timestamp <= context.failedAction.timestamp + 2000) {
185
- edges.push({
186
- fromNodeId: actionNodeId,
187
- toNodeId: netNodeId,
188
- relation: 'INITIATED_BY',
189
- description: `Action initiated network request to ${net.url} (${net.timestamp - context.failedAction.timestamp}ms after action start)`,
190
- });
191
- }
192
- }
193
-
194
- if (
195
- context.failedAction.frameId &&
196
- net.frameId &&
197
- context.failedAction.frameId === net.frameId
198
- ) {
199
- edges.push({
200
- fromNodeId: netNodeId,
201
- toNodeId: actionNodeId,
202
- relation: 'SAME_FRAME',
203
- description: `Network request originated from the same frame (${net.frameId})`,
204
- });
205
- }
206
-
207
- if (isServerError || isAuthError) {
208
- edges.push({
209
- fromNodeId: netNodeId,
210
- toNodeId: actionNodeId,
211
- relation: 'SUPPORTS',
212
- description: `HTTP ${net.status} directly explains failure of action`,
213
- });
214
- edges.push({
215
- fromNodeId: netNodeId,
216
- toNodeId: 'hypothesis:locator-drift',
217
- relation: 'CONTRADICTS',
218
- description: `HTTP ${net.status} on application endpoint contradicts UI locator drift as root cause`,
219
- });
220
- }
221
- }
222
-
223
- return {
224
- nodes,
225
- edges,
226
- actionNode,
227
- domNode,
228
- hasCausallyRelevantServerError,
229
- hasCausallyRelevantAuthError,
230
- offendingNetworkEvent,
231
- };
232
- }
233
-
234
- public static extractRuntimeSymptom(context: DiagnosticContext): RuntimeSymptom {
235
- const errorMsg = (context.failedAction.errorMessage ?? '').toLowerCase();
236
- const action = (context.failedAction.action ?? '').toLowerCase();
237
-
238
- if (errorMsg.includes('timeout') || errorMsg.includes('timed out') || errorMsg.includes('waiting for')) {
239
- return 'TIMEOUT';
240
- }
241
- if (
242
- errorMsg.includes('not found') ||
243
- errorMsg.includes('could not find') ||
244
- errorMsg.includes('no element found') ||
245
- errorMsg.includes('failed to resolve')
246
- ) {
247
- return 'ELEMENT_NOT_FOUND';
248
- }
249
- if (
250
- errorMsg.includes('expect(') ||
251
- errorMsg.includes('expected') ||
252
- errorMsg.includes('received') ||
253
- action.startsWith('expect')
254
- ) {
255
- return 'ASSERTION_FAILED';
256
- }
257
- if (
258
- errorMsg.includes('intercept') ||
259
- errorMsg.includes('overlay') ||
260
- errorMsg.includes('subtree intercepts')
261
- ) {
262
- return 'ACTION_INTERCEPTED';
263
- }
264
- if (
265
- errorMsg.includes('target closed') ||
266
- errorMsg.includes('page closed') ||
267
- errorMsg.includes('crash')
268
- ) {
269
- return 'TARGET_CLOSED';
270
- }
271
- if (errorMsg.includes('network') || errorMsg.includes('err_')) {
272
- return 'NETWORK_ERROR';
273
- }
274
- return 'UNKNOWN_SYMPTOM';
275
- }
276
-
277
- public static mapCategoryToRootCause(category: FailureCategory): CausalRootCause {
278
- switch (category) {
279
- case 'LOCATOR_DRIFT':
280
- return 'LOCATOR_DRIFT';
281
- case 'HYDRATION_OR_TIMING':
282
- return 'DYNAMIC_CONTENT_SLOW';
283
- case 'OBSTRUCTED_ELEMENT':
284
- return 'OBSTRUCTED_ELEMENT';
285
- case 'DISABLED_ELEMENT':
286
- return 'DISABLED_ELEMENT';
287
- case 'BACKEND_500':
288
- return 'BACKEND_500';
289
- case 'AUTH_EXPIRED':
290
- return 'AUTH_EXPIRED';
291
- case 'AMBIGUOUS_MATCH':
292
- return 'AMBIGUOUS_MATCH';
293
- case 'ASSERTION_FAILURE':
294
- return 'BUSINESS_LOGIC_DEFECT';
295
- default:
296
- return 'UNKNOWN_ROOT_CAUSE';
297
- }
298
- }
299
-
300
- /**
301
- * Evaluates the DiagnosticContext using causal evidence correlation to categorize
302
- * the failure and strictly enforce negative control abstentions.
303
- */
304
- public static classify(
305
- context: DiagnosticContext,
306
- options?: { provenUniqueScopedReplacement?: boolean }
307
- ): ClassificationResult {
308
- const result = FailureClassifier.classifyInternal(context, options);
309
- result.runtimeSymptom = FailureClassifier.extractRuntimeSymptom(context);
310
- result.causalRootCause = FailureClassifier.mapCategoryToRootCause(result.category);
311
- return result;
312
- }
313
-
314
- private static classifyInternal(
315
- context: DiagnosticContext,
316
- options?: { provenUniqueScopedReplacement?: boolean }
317
- ): ClassificationResult {
318
- const errorMsg = (context.failedAction.errorMessage ?? '').toLowerCase();
319
- const action = (context.failedAction.action ?? '').toLowerCase();
320
- const actionLogs = (context.failedAction.logs ?? []).join('\n').toLowerCase();
321
- // Playwright may embed the browser launch command in an error. That command
322
- // contains tokens such as "lens-overlay" and "popup-blocking" which can
323
- // look like actionability evidence. Remove that operational noise before
324
- // applying root-cause patterns; retain the actual Call log after shutdown.
325
- const diagnosticText = `${errorMsg}\n${actionLogs}`.replace(
326
- /<launching>[\s\S]*?<gracefully close start>/gi,
327
- '',
328
- );
329
- const supportingEvidence: string[] = [];
330
-
331
- // An assertion can legitimately observe an authentication response (for
332
- // example, an invalid-login test expecting an error banner). Explicit
333
- // assertion mismatch evidence is stronger than the surrounding HTTP
334
- // status and must be classified before auth/backend heuristics.
335
- const explicitAssertionMismatch =
336
- /unexpected value|(?:^|\s)(?:expected|received):|expect\(received\)|to have (?:text|value|attribute|class|count)/i.test(diagnosticText) &&
337
- (action.includes('expect') || action.includes('assert') || !context.failedAction.selector);
338
- if (explicitAssertionMismatch) {
339
- supportingEvidence.push(`Assertion mismatch detected: ${context.failedAction.errorMessage}`);
340
- return {
341
- category: 'ASSERTION_FAILURE',
342
- confidence: 0.95,
343
- reason: 'Test assertion failed against verified element state or business logic outcome',
344
- shouldAbstain: true,
345
- abstentionReason: 'Engine strictly prohibits modifying test assertions to force a passing test run.',
346
- supportingEvidence,
347
- causalGraph: FailureClassifier.buildCausalEvidenceGraph(context),
348
- };
349
- }
350
-
351
- // Step 1: Build causal evidence graph
352
- const causalGraph = FailureClassifier.buildCausalEvidenceGraph(context);
353
-
354
- // Step 2: Check for Network Offline / DNS resolution failures
355
- const hasNetworkOffline = context.networkEvents.some(
356
- (req) => /err_internet_disconnected|enotfound|err_name_not_resolved|dns_probe_finished_nxdomain|eai_again|eai_fail/i.test(req.errorText ?? '')
357
- );
358
- if (
359
- hasNetworkOffline ||
360
- /err_internet_disconnected|enotfound|err_name_not_resolved|dns_probe_finished_nxdomain|eai_again|eai_fail/i.test(errorMsg)
361
- ) {
362
- supportingEvidence.push('Network transport offline or DNS lookup failure detected');
363
- return {
364
- category: 'NETWORK_OFFLINE',
365
- confidence: 0.99,
366
- reason: 'Network connectivity offline or DNS resolution failed',
367
- shouldAbstain: true,
368
- abstentionReason: 'Negative Control Triggered: Network offline. Test failure is caused by connectivity loss.',
369
- supportingEvidence,
370
- causalGraph,
371
- };
372
- }
373
-
374
- // Step 3: Environment/service availability failures. These are distinct
375
- // from an offline host: the machine has a network stack, but the target
376
- // application/service is unavailable or the connection was interrupted.
377
- const hasEnvironmentFailure =
378
- /(?:net::)?err_connection_refused|econnrefused|connection refused|(?:net::)?err_connection_reset|econnreset|(?:net::)?err_connection_closed|ehostunreach|connection reset by peer/i.test(diagnosticText) ||
379
- context.networkEvents.some((request) => /(?:err_connection_refused|econnrefused|err_connection_reset|econnreset|ehostunreach)/i.test(request.errorText ?? ''));
380
- if (hasEnvironmentFailure) {
381
- supportingEvidence.push('Target application or dependent service refused or reset the connection');
382
- return {
383
- category: 'ENVIRONMENT_FAILURE',
384
- confidence: 0.99,
385
- reason: 'The target application or a required service was unavailable during the test',
386
- shouldAbstain: true,
387
- abstentionReason: 'Environment failure detected. UI locator repair cannot restore an unavailable service.',
388
- supportingEvidence,
389
- causalGraph,
390
- };
391
- }
392
-
393
- // Step 4: Causal Server Errors (Negative Control: MUST ABSTAIN)
394
- // Only triggers on causally relevant application endpoints, ignoring background analytics 500s
395
- if (causalGraph.hasCausallyRelevantServerError && causalGraph.offendingNetworkEvent) {
396
- const offending = causalGraph.offendingNetworkEvent;
397
- supportingEvidence.push(
398
- `HTTP ${offending.status} (${offending.statusText ?? 'Server Error'}) on ${offending.method} ${offending.url}`
399
- );
400
-
401
- return {
402
- category: 'BACKEND_500',
403
- confidence: 0.99,
404
- reason: `Backend server returned HTTP ${offending.status} during or immediately preceding test failure`,
405
- shouldAbstain: true,
406
- abstentionReason: `Negative Control Triggered: Test failed due to backend server failure (${offending.status} on ${offending.url}). Modifying UI locators is prohibited.`,
407
- supportingEvidence,
408
- causalGraph,
409
- };
410
- }
411
-
412
- // Step 5: Causal Auth Expiration / 401 / 403 (Negative Control: MUST ABSTAIN)
413
- if (causalGraph.hasCausallyRelevantAuthError && causalGraph.offendingNetworkEvent) {
414
- const offending = causalGraph.offendingNetworkEvent;
415
- supportingEvidence.push(`HTTP ${offending.status} on authenticated application endpoint: ${offending.url}`);
416
- return {
417
- category: 'AUTH_EXPIRED',
418
- confidence: 0.98,
419
- reason: `Authentication expired or unauthorized access detected (HTTP ${offending.status})`,
420
- shouldAbstain: true,
421
- abstentionReason: `Negative Control Triggered: Authentication session expired or unauthorized (HTTP ${offending.status}). Locator healing must abstain.`,
422
- supportingEvidence,
423
- causalGraph,
424
- };
425
- }
426
-
427
- // Step 6: Assertion Failure (e.g. expect(received).toBe(expected)) (Negative Control: MUST ABSTAIN)
428
- // Negative control: engine strictly prohibits modifying test assertions to force a passing test run.
429
- const isValueAssertionAction =
430
- /tohavetext|tocontaintext|tohavevalue|tohaveattribute|tohaveclass|tohavecount|tohavetitle|tohaveurl|tobechecked|tobeenabled|tobedisabled/i.test(action) ||
431
- /tohavetext|tocontaintext|tohavevalue|tohaveattribute|tohaveclass|tohavecount/i.test(errorMsg);
432
-
433
- const hasUnexpectedValueInLog =
434
- (context.failedAction.logs ?? []).some((l) => /unexpected value/i.test(l)) ||
435
- /unexpected value/i.test(errorMsg);
436
-
437
- const hasValueMismatch =
438
- errorMsg.includes('received:') ||
439
- errorMsg.includes('expected:') ||
440
- hasUnexpectedValueInLog;
441
-
442
- const isAssertionMethod =
443
- action === 'expect' ||
444
- action.startsWith('expect ') ||
445
- action.includes('expect.') ||
446
- action.includes('assert') ||
447
- isValueAssertionAction;
448
-
449
- const hasAssertionErrorPattern =
450
- errorMsg.includes('expect(received)') ||
451
- errorMsg.includes('to have attribute') ||
452
- errorMsg.includes('to have text') ||
453
- errorMsg.includes('to have value') ||
454
- errorMsg.includes('to have class') ||
455
- errorMsg.includes('to have count') ||
456
- (errorMsg.includes('to be visible') && !context.failedAction.selector);
457
-
458
- if (
459
- hasValueMismatch ||
460
- isValueAssertionAction ||
461
- hasAssertionErrorPattern ||
462
- (isAssertionMethod && !context.failedAction.selector) ||
463
- (action.includes('expect') && (errorMsg.includes('expect(') || errorMsg.includes('received:')))
464
- ) {
465
- supportingEvidence.push(`Assertion failed: ${context.failedAction.errorMessage}`);
466
- return {
467
- category: 'ASSERTION_FAILURE',
468
- confidence: 0.95,
469
- reason: 'Test assertion failed against verified element state or business logic outcome',
470
- shouldAbstain: true,
471
- abstentionReason: 'Engine strictly prohibits modifying test assertions to force a passing test run.',
472
- supportingEvidence,
473
- causalGraph,
474
- };
475
- }
476
-
477
- // Step 7: Actionability and browser-context failures. These categories
478
- // are intentionally abstaining: they explain the failure without claiming
479
- // that changing the locator would make the application correct.
480
- const isAssertionAction = action.includes('expect') || action.includes('assert');
481
- if (!isAssertionAction && /intercepts pointer events|another element would receive|element is obscured|element is covered|overlay.*blocking|blocking.*overlay|subtree intercepts pointer events/i.test(diagnosticText)) {
482
- supportingEvidence.push('A different element or overlay intercepted the intended pointer action');
483
- return {
484
- category: 'OBSTRUCTED_ELEMENT',
485
- confidence: 0.96,
486
- reason: 'The target action was obstructed by another element or overlay',
487
- shouldAbstain: true,
488
- abstentionReason: 'Actionability failure detected. Changing the locator could mask an obstructed application state.',
489
- supportingEvidence,
490
- causalGraph,
491
- };
492
- }
493
-
494
- if (!isAssertionAction && /element is not enabled|element is disabled|locator is disabled|button is disabled|aria-disabled["'= ]+true/i.test(diagnosticText)) {
495
- supportingEvidence.push('The target element was present but disabled by the application');
496
- return {
497
- category: 'DISABLED_ELEMENT',
498
- confidence: 0.97,
499
- reason: 'The target element was disabled and could not perform the requested action',
500
- shouldAbstain: true,
501
- abstentionReason: 'Disabled-control failure detected. Locator repair cannot satisfy the application prerequisite.',
502
- supportingEvidence,
503
- causalGraph,
504
- };
505
- }
506
-
507
- const isFrameScopedSelector = /contentFrame|frameLocator|\.frame\(/i.test(context.failedAction.selector ?? '');
508
- const isFrameUnavailableError = /frame was detached|frame is detached|frame not found|target frame.*not found|frame.*navigated/i.test(diagnosticText);
509
-
510
- if (!isAssertionAction && isFrameUnavailableError) {
511
- supportingEvidence.push('The frame context was detached, navigated away, or destroyed');
512
- return {
513
- category: 'FRAME_OR_IFRAME',
514
- confidence: 0.94,
515
- reason: 'The target frame was detached or unavailable',
516
- shouldAbstain: true,
517
- abstentionReason: 'Frame-context failure detected: frame was detached or destroyed.',
518
- supportingEvidence,
519
- causalGraph,
520
- };
521
- }
522
-
523
- const hasExtractableCandidates = Boolean(
524
- (context as any).domRoot &&
525
- (() => {
526
- try {
527
- return CandidateExtractor.extractCandidates((context as any).domRoot, context.failedAction).length > 0;
528
- } catch {
529
- return false;
530
- }
531
- })()
532
- );
533
-
534
- if (!isAssertionAction && isFrameScopedSelector && context.evidenceCompleteness.domAvailable && hasExtractableCandidates) {
535
- // Frame-scoped selector where evidence is complete and child candidates are available:
536
- // candidate extraction is frame-aware (searches page.frames()), so this can be safely routed to LOCATOR_DRIFT repair.
537
- supportingEvidence.push('The failed action was scoped to a frame, but evidence is complete and candidates can be extracted from child frames');
538
- } else if (!isAssertionAction && /frame.*(?:not found|detached|unavailable)|iframe.*(?:not found|detached|unavailable)|different frame|content frame|contentFrame|frame locator|frameLocator/i.test(diagnosticText)) {
539
- supportingEvidence.push('The failed action was associated with a missing, detached, or mismatched frame context');
540
- return {
541
- category: 'FRAME_OR_IFRAME',
542
- confidence: 0.94,
543
- reason: 'The target element was not available in the expected frame or iframe context',
544
- shouldAbstain: true,
545
- abstentionReason: 'Frame-context failure detected. A locator-only repair is unsafe without confirming the frame boundary.',
546
- supportingEvidence,
547
- causalGraph,
548
- };
549
- }
550
-
551
- if (!isAssertionAction && /hydration|hydrated|networkidle|waiting for load state|delayed content|element is not stable/i.test(diagnosticText)) {
552
- supportingEvidence.push('Failure evidence indicates delayed application readiness or hydration');
553
- return {
554
- category: 'HYDRATION_OR_TIMING',
555
- confidence: 0.86,
556
- reason: 'The target page or component was not ready when the action executed',
557
- shouldAbstain: true,
558
- abstentionReason: 'Readiness/timing failure detected. Locator modification will not reliably solve delayed application state.',
559
- supportingEvidence,
560
- causalGraph,
561
- };
562
- }
563
-
564
- // Step 8: Ambiguous Match / Strict Mode Violation
565
- if (
566
- errorMsg.includes('strict mode violation') ||
567
- /resolved to \d+ elements/i.test(errorMsg)
568
- ) {
569
- const countMatch = errorMsg.match(/resolved to (\d+) elements/i);
570
- const matchCount = countMatch && countMatch[1] ? parseInt(countMatch[1], 10) : 0;
571
- const rawSel = (context.failedAction.selector ?? '').trim().toLowerCase();
572
- const isAnonymousTag = /^(div|span|p|tr|td|li|ul|ol|table|section|article|header|footer|main|nav)(\.[a-z0-9_-]+)*$/i.test(rawSel);
573
- const isHighAmbiguity = matchCount >= 20 || (isAnonymousTag && matchCount >= 2);
574
-
575
- // Unresolvable ambiguous match: anonymous tag or >= 20 identical matches without semantic anchors
576
- if (isHighAmbiguity) {
577
- supportingEvidence.push(
578
- `Unresolvable ambiguous match: locator '${context.failedAction.selector}' resolved to ${matchCount} anonymous elements without semantic anchors`
579
- );
580
- return {
581
- category: 'INCOMPLETE_EVIDENCE',
582
- confidence: 0.95,
583
- reason: `Locator '${context.failedAction.selector}' resolved to ${matchCount} anonymous elements without semantic anchors`,
584
- shouldAbstain: true,
585
- abstentionReason:
586
- 'Insufficient semantic evidence; engine must safely abstain rather than generate arbitrary nth(x) selectors.',
587
- supportingEvidence,
588
- causalGraph,
589
- };
590
- }
591
-
592
- supportingEvidence.push(`Strict mode violation detected: ${context.failedAction.errorMessage}`);
593
- const isScopedUnique = options?.provenUniqueScopedReplacement === true;
594
- return {
595
- category: 'AMBIGUOUS_MATCH',
596
- confidence: 0.92,
597
- reason: 'Locator resolved to multiple matching elements in the DOM (strict mode violation)',
598
- shouldAbstain: !isScopedUnique,
599
- abstentionReason: isScopedUnique
600
- ? undefined
601
- : 'Strict mode multi-match detected; engine abstains by default unless live DOM investigation proves unique scoped replacement.',
602
- supportingEvidence,
603
- causalGraph,
604
- };
605
- }
606
-
607
- // Step 9: Timing Race / Detached Node
608
- if (
609
- errorMsg.includes('element is not attached to the dom') ||
610
- errorMsg.includes('navigation interrupted') ||
611
- errorMsg.includes('execution context was destroyed') ||
612
- errorMsg.includes('target closed') ||
613
- errorMsg.includes('browser has been closed')
614
- ) {
615
- supportingEvidence.push(`DOM lifecycle race: ${context.failedAction.errorMessage}`);
616
- return {
617
- category: 'TIMING_RACE',
618
- confidence: 0.88,
619
- reason: 'Element detached or page navigated mid-action (asynchronous timing race condition)',
620
- shouldAbstain: true,
621
- abstentionReason: 'Timing race condition detected. Locator modification will not solve DOM detachment.',
622
- supportingEvidence,
623
- causalGraph,
624
- };
625
- }
626
-
627
- // A post-action assertion timeout is not sufficient evidence of locator
628
- // drift. The application may still be hydrating or waiting for a state
629
- // transition, and rewriting the assertion target could mask that defect.
630
- // Keep explicit action timeouts repairable below, but fail closed here.
631
- const isAssertionTimeout =
632
- isAssertionMethod &&
633
- /timeout|timed out|element\(s\) not found|waiting for/i.test(diagnosticText) &&
634
- Boolean(context.failedAction.selector);
635
- if (isAssertionTimeout) {
636
- supportingEvidence.push('Assertion timed out while waiting for a post-action UI state; trace does not prove locator drift');
637
- return {
638
- category: 'HYDRATION_OR_TIMING',
639
- confidence: 0.72,
640
- reason: 'Post-action assertion timed out before the expected UI state became observable',
641
- shouldAbstain: true,
642
- abstentionReason: 'Assertion readiness is unresolved. Locator repair is prohibited without evidence that the target identity changed.',
643
- supportingEvidence,
644
- causalGraph,
645
- };
646
- }
647
-
648
- // Step 10: Locator Drift (Timeout waiting for locator / locator not found)
649
- if (
650
- (errorMsg.includes('timeout') || errorMsg.includes('element(s) not found') || errorMsg.includes('not found')) &&
651
- (errorMsg.includes('waiting for locator') ||
652
- errorMsg.includes('waiting for getby') ||
653
- errorMsg.includes('waiting for selector') ||
654
- errorMsg.includes('tobevisible') ||
655
- errorMsg.includes('to be visible') ||
656
- errorMsg.includes('expect failed') ||
657
- Boolean(context.failedAction.selector))
658
- ) {
659
- // Check if DOM evidence is missing when trying to diagnose locator drift
660
- // CRITICAL FIX (R-015): Check all snapshot channels before concluding DOM evidence is missing
661
- const hasSnapshots = Boolean(
662
- (context.snapshots?.action && context.snapshots.action.trim().length > 0) ||
663
- (context.snapshots?.before && context.snapshots.before.trim().length > 0) ||
664
- (context.snapshots?.after && context.snapshots.after.trim().length > 0) ||
665
- context.evidenceCompleteness?.domAvailable
666
- );
667
- if (context.evidenceCompleteness?.domAvailable === false && !hasSnapshots) {
668
- return {
669
- category: 'INCOMPLETE_EVIDENCE',
670
- confidence: 0.95,
671
- reason: `Target locator '${context.failedAction.selector}' timed out, but DOM snapshots are missing or incomplete in trace`,
672
- shouldAbstain: true,
673
- abstentionReason: 'Insufficient DOM evidence to diagnose or safely extract replacement candidates.',
674
- supportingEvidence: ['DOM snapshot missing from trace archive'],
675
- causalGraph,
676
- };
677
- }
678
-
679
- supportingEvidence.push(`Locator timeout for selector '${context.failedAction.selector ?? 'unknown'}'`);
680
- return {
681
- category: 'LOCATOR_DRIFT',
682
- confidence: 0.93,
683
- reason: `Target locator '${context.failedAction.selector}' timed out waiting for matching DOM element`,
684
- shouldAbstain: false, // Legitimate candidate for candidate extraction & repair
685
- supportingEvidence,
686
- causalGraph,
687
- };
688
- }
689
-
690
- // Default Unknown
691
- return {
692
- category: 'UNKNOWN_ERROR',
693
- confidence: 0.5,
694
- reason: `Unclassified failure: ${context.failedAction.errorMessage || 'No error message available'}`,
695
- shouldAbstain: true,
696
- abstentionReason: 'Insufficient diagnostic evidence to safely classify failure root cause.',
697
- supportingEvidence,
698
- causalGraph,
699
- };
700
- }
701
- }
1
+ import { DiagnosticContext, FailureCategory, NetworkEvent } from '../core/types/diagnostic-context.js';
2
+ import { CandidateExtractor } from '../selector/candidate-extractor.js';
3
+
4
+ export interface CausalEvidenceNode {
5
+ type: 'action' | 'network' | 'console' | 'dom';
6
+ id: string;
7
+ timestamp: number;
8
+ description: string;
9
+ isCausallyRelevant: boolean;
10
+ relevanceReason: string;
11
+ frameId?: string | undefined;
12
+ }
13
+
14
+ export type CausalEdgeRelation =
15
+ | 'TEMPORALLY_PRECEDES'
16
+ | 'SAME_FRAME'
17
+ | 'INITIATED_BY'
18
+ | 'SUPPORTS'
19
+ | 'CONTRADICTS';
20
+
21
+ export interface CausalEvidenceEdge {
22
+ fromNodeId: string;
23
+ toNodeId: string;
24
+ relation: CausalEdgeRelation;
25
+ description?: string;
26
+ }
27
+
28
+ export interface CausalEvidenceGraph {
29
+ nodes: CausalEvidenceNode[];
30
+ edges: CausalEvidenceEdge[];
31
+ actionNode?: CausalEvidenceNode | undefined;
32
+ domNode?: CausalEvidenceNode | undefined;
33
+ hasCausallyRelevantServerError: boolean;
34
+ hasCausallyRelevantAuthError: boolean;
35
+ offendingNetworkEvent?: NetworkEvent | undefined;
36
+ }
37
+
38
+ export type RuntimeSymptom =
39
+ | 'TIMEOUT'
40
+ | 'ELEMENT_NOT_FOUND'
41
+ | 'ASSERTION_FAILED'
42
+ | 'ACTION_INTERCEPTED'
43
+ | 'TARGET_CLOSED'
44
+ | 'NETWORK_ERROR'
45
+ | 'PAGE_CRASH'
46
+ | 'UNKNOWN_SYMPTOM';
47
+
48
+ export type CausalRootCause =
49
+ | 'LOCATOR_DRIFT'
50
+ | 'DYNAMIC_CONTENT_SLOW'
51
+ | 'NETWORK_SYNCHRONIZATION_FAILURE'
52
+ | 'OBSTRUCTED_ELEMENT'
53
+ | 'DISABLED_ELEMENT'
54
+ | 'BACKEND_500'
55
+ | 'AUTH_EXPIRED'
56
+ | 'BUSINESS_LOGIC_DEFECT'
57
+ | 'AMBIGUOUS_MATCH'
58
+ | 'UNKNOWN_ROOT_CAUSE';
59
+
60
+ export interface ClassificationResult {
61
+ category: FailureCategory;
62
+ confidence: number;
63
+ reason: string;
64
+ shouldAbstain: boolean;
65
+ abstentionReason?: string | undefined;
66
+ supportingEvidence: string[];
67
+ causalGraph?: CausalEvidenceGraph | undefined;
68
+ runtimeSymptom?: RuntimeSymptom | undefined;
69
+ causalRootCause?: CausalRootCause | undefined;
70
+ }
71
+
72
+ export class FailureClassifier {
73
+ private static readonly TELEMETRY_PATTERNS = [
74
+ /analytics/i,
75
+ /telemetry/i,
76
+ /beacon/i,
77
+ /collect/i,
78
+ /metrics/i,
79
+ /google-analytics/i,
80
+ /doubleclick/i,
81
+ /sentry/i,
82
+ /datadog/i,
83
+ /favicon\.ico/i,
84
+ /hotjar/i,
85
+ /segment\.io/i,
86
+ ];
87
+
88
+ /**
89
+ * Builds an explicit Causal Evidence Graph to correlate network events with failed actions,
90
+ * distinguishing primary business requests from background analytics and telemetry noise.
91
+ */
92
+ public static buildCausalEvidenceGraph(context: DiagnosticContext): CausalEvidenceGraph {
93
+ const nodes: CausalEvidenceNode[] = [];
94
+ const edges: CausalEvidenceEdge[] = [];
95
+ let hasCausallyRelevantServerError = false;
96
+ let hasCausallyRelevantAuthError = false;
97
+ let offendingNetworkEvent: NetworkEvent | undefined;
98
+
99
+ // Action node
100
+ const actionNodeId = context.failedAction.callId || 'failed-action-0';
101
+ const actionTimestamp = context.failedAction.timestamp ?? Date.now();
102
+ const actionNode: CausalEvidenceNode = {
103
+ type: 'action',
104
+ id: actionNodeId,
105
+ timestamp: actionTimestamp,
106
+ description: `Action: ${context.failedAction.action} (${context.failedAction.selector ?? 'no selector'})`,
107
+ isCausallyRelevant: true,
108
+ relevanceReason: 'Primary failed action under diagnosis',
109
+ frameId: context.failedAction.frameId,
110
+ };
111
+
112
+ // DOM Snapshot node
113
+ let domNode: CausalEvidenceNode | undefined;
114
+ const hasDom = Boolean(
115
+ context.snapshots?.action || context.snapshots?.before || context.snapshots?.after
116
+ );
117
+ if (hasDom) {
118
+ domNode = {
119
+ type: 'dom',
120
+ id: 'dom-snapshot',
121
+ timestamp: actionTimestamp,
122
+ description: 'DOM Snapshot available at failure point',
123
+ isCausallyRelevant: true,
124
+ relevanceReason: 'Captures target page state at action time',
125
+ };
126
+ edges.push({
127
+ fromNodeId: 'dom-snapshot',
128
+ toNodeId: actionNodeId,
129
+ relation: 'SUPPORTS',
130
+ description: 'DOM snapshot provides ground truth for element availability',
131
+ });
132
+ }
133
+
134
+ for (const net of context.networkEvents) {
135
+ const isTelemetry = FailureClassifier.TELEMETRY_PATTERNS.some((p) => p.test(net.url));
136
+ const isServerError = net.status >= 500 && net.status <= 599;
137
+ const isAuthError = net.status === 401 || net.status === 403;
138
+ const isClientApiError = (net.status === 400 || net.status === 404 || net.status === 429) && !isTelemetry;
139
+
140
+ let isCausallyRelevant = false;
141
+ let relevanceReason = '';
142
+
143
+ if (isTelemetry) {
144
+ isCausallyRelevant = false;
145
+ relevanceReason = 'Ignored: background telemetry/analytics or non-critical resource';
146
+ } else if (isServerError || isAuthError || isClientApiError) {
147
+ // Business endpoint returning 5xx, 401/403, or client API failure 400/404/429
148
+ isCausallyRelevant = true;
149
+ relevanceReason = `Causally relevant: HTTP ${net.status} on application endpoint ${net.method} ${net.url}`;
150
+
151
+ if (isServerError || isClientApiError) {
152
+ hasCausallyRelevantServerError = true;
153
+ offendingNetworkEvent = net;
154
+ }
155
+ if (isAuthError) {
156
+ hasCausallyRelevantAuthError = true;
157
+ offendingNetworkEvent = net;
158
+ }
159
+ } else {
160
+ isCausallyRelevant = false;
161
+ relevanceReason = `Normal response (HTTP ${net.status})`;
162
+ }
163
+
164
+ const netNodeId = net.requestId ?? net.url;
165
+ nodes.push({
166
+ type: 'network',
167
+ id: netNodeId,
168
+ timestamp: net.timestamp,
169
+ description: `${net.method} ${net.url} -> ${net.status}`,
170
+ isCausallyRelevant,
171
+ relevanceReason,
172
+ frameId: net.frameId,
173
+ });
174
+
175
+ // Construct causal edges
176
+ if (typeof net.timestamp === 'number' && typeof context.failedAction.timestamp === 'number') {
177
+ if (net.timestamp <= context.failedAction.timestamp) {
178
+ edges.push({
179
+ fromNodeId: netNodeId,
180
+ toNodeId: actionNodeId,
181
+ relation: 'TEMPORALLY_PRECEDES',
182
+ description: `Network event completed ${context.failedAction.timestamp - net.timestamp}ms before action`,
183
+ });
184
+ } else if (net.timestamp > context.failedAction.timestamp && net.timestamp <= context.failedAction.timestamp + 2000) {
185
+ edges.push({
186
+ fromNodeId: actionNodeId,
187
+ toNodeId: netNodeId,
188
+ relation: 'INITIATED_BY',
189
+ description: `Action initiated network request to ${net.url} (${net.timestamp - context.failedAction.timestamp}ms after action start)`,
190
+ });
191
+ }
192
+ }
193
+
194
+ if (
195
+ context.failedAction.frameId &&
196
+ net.frameId &&
197
+ context.failedAction.frameId === net.frameId
198
+ ) {
199
+ edges.push({
200
+ fromNodeId: netNodeId,
201
+ toNodeId: actionNodeId,
202
+ relation: 'SAME_FRAME',
203
+ description: `Network request originated from the same frame (${net.frameId})`,
204
+ });
205
+ }
206
+
207
+ if (isServerError || isAuthError) {
208
+ edges.push({
209
+ fromNodeId: netNodeId,
210
+ toNodeId: actionNodeId,
211
+ relation: 'SUPPORTS',
212
+ description: `HTTP ${net.status} directly explains failure of action`,
213
+ });
214
+ edges.push({
215
+ fromNodeId: netNodeId,
216
+ toNodeId: 'hypothesis:locator-drift',
217
+ relation: 'CONTRADICTS',
218
+ description: `HTTP ${net.status} on application endpoint contradicts UI locator drift as root cause`,
219
+ });
220
+ }
221
+ }
222
+
223
+ return {
224
+ nodes,
225
+ edges,
226
+ actionNode,
227
+ domNode,
228
+ hasCausallyRelevantServerError,
229
+ hasCausallyRelevantAuthError,
230
+ offendingNetworkEvent,
231
+ };
232
+ }
233
+
234
+ public static extractRuntimeSymptom(context: DiagnosticContext): RuntimeSymptom {
235
+ const errorMsg = (context.failedAction.errorMessage ?? '').toLowerCase();
236
+ const action = (context.failedAction.action ?? '').toLowerCase();
237
+
238
+ if (errorMsg.includes('timeout') || errorMsg.includes('timed out') || errorMsg.includes('waiting for')) {
239
+ return 'TIMEOUT';
240
+ }
241
+ if (
242
+ errorMsg.includes('not found') ||
243
+ errorMsg.includes('could not find') ||
244
+ errorMsg.includes('no element found') ||
245
+ errorMsg.includes('failed to resolve')
246
+ ) {
247
+ return 'ELEMENT_NOT_FOUND';
248
+ }
249
+ if (
250
+ errorMsg.includes('expect(') ||
251
+ errorMsg.includes('expected') ||
252
+ errorMsg.includes('received') ||
253
+ action.startsWith('expect')
254
+ ) {
255
+ return 'ASSERTION_FAILED';
256
+ }
257
+ if (
258
+ errorMsg.includes('intercept') ||
259
+ errorMsg.includes('overlay') ||
260
+ errorMsg.includes('subtree intercepts')
261
+ ) {
262
+ return 'ACTION_INTERCEPTED';
263
+ }
264
+ if (
265
+ errorMsg.includes('target closed') ||
266
+ errorMsg.includes('page closed') ||
267
+ errorMsg.includes('crash')
268
+ ) {
269
+ return 'TARGET_CLOSED';
270
+ }
271
+ if (errorMsg.includes('network') || errorMsg.includes('err_')) {
272
+ return 'NETWORK_ERROR';
273
+ }
274
+ return 'UNKNOWN_SYMPTOM';
275
+ }
276
+
277
+ public static mapCategoryToRootCause(category: FailureCategory): CausalRootCause {
278
+ switch (category) {
279
+ case 'LOCATOR_DRIFT':
280
+ return 'LOCATOR_DRIFT';
281
+ case 'HYDRATION_OR_TIMING':
282
+ return 'DYNAMIC_CONTENT_SLOW';
283
+ case 'OBSTRUCTED_ELEMENT':
284
+ return 'OBSTRUCTED_ELEMENT';
285
+ case 'DISABLED_ELEMENT':
286
+ return 'DISABLED_ELEMENT';
287
+ case 'BACKEND_500':
288
+ return 'BACKEND_500';
289
+ case 'AUTH_EXPIRED':
290
+ return 'AUTH_EXPIRED';
291
+ case 'AMBIGUOUS_MATCH':
292
+ return 'AMBIGUOUS_MATCH';
293
+ case 'ASSERTION_FAILURE':
294
+ return 'BUSINESS_LOGIC_DEFECT';
295
+ default:
296
+ return 'UNKNOWN_ROOT_CAUSE';
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Evaluates the DiagnosticContext using causal evidence correlation to categorize
302
+ * the failure and strictly enforce negative control abstentions.
303
+ */
304
+ public static classify(
305
+ context: DiagnosticContext,
306
+ options?: { provenUniqueScopedReplacement?: boolean }
307
+ ): ClassificationResult {
308
+ const result = FailureClassifier.classifyInternal(context, options);
309
+ result.runtimeSymptom = FailureClassifier.extractRuntimeSymptom(context);
310
+ result.causalRootCause = FailureClassifier.mapCategoryToRootCause(result.category);
311
+ return result;
312
+ }
313
+
314
+ private static classifyInternal(
315
+ context: DiagnosticContext,
316
+ options?: { provenUniqueScopedReplacement?: boolean }
317
+ ): ClassificationResult {
318
+ const errorMsg = (context.failedAction.errorMessage ?? '').toLowerCase();
319
+ const action = (context.failedAction.action ?? '').toLowerCase();
320
+ const actionLogs = (context.failedAction.logs ?? []).join('\n').toLowerCase();
321
+ // Playwright may embed the browser launch command in an error. That command
322
+ // contains tokens such as "lens-overlay" and "popup-blocking" which can
323
+ // look like actionability evidence. Remove that operational noise before
324
+ // applying root-cause patterns; retain the actual Call log after shutdown.
325
+ const diagnosticText = `${errorMsg}\n${actionLogs}`.replace(
326
+ /<launching>[\s\S]*?<gracefully close start>/gi,
327
+ '',
328
+ );
329
+ const supportingEvidence: string[] = [];
330
+
331
+ // An assertion can legitimately observe an authentication response (for
332
+ // example, an invalid-login test expecting an error banner). Explicit
333
+ // assertion mismatch evidence is stronger than the surrounding HTTP
334
+ // status and must be classified before auth/backend heuristics.
335
+ const explicitAssertionMismatch =
336
+ /unexpected value|(?:^|\s)(?:expected|received):|expect\(received\)|to have (?:text|value|attribute|class|count)/i.test(diagnosticText) &&
337
+ (action.includes('expect') || action.includes('assert') || !context.failedAction.selector);
338
+ if (explicitAssertionMismatch) {
339
+ supportingEvidence.push(`Assertion mismatch detected: ${context.failedAction.errorMessage}`);
340
+ return {
341
+ category: 'ASSERTION_FAILURE',
342
+ confidence: 0.95,
343
+ reason: 'Test assertion failed against verified element state or business logic outcome',
344
+ shouldAbstain: true,
345
+ abstentionReason: 'Engine strictly prohibits modifying test assertions to force a passing test run.',
346
+ supportingEvidence,
347
+ causalGraph: FailureClassifier.buildCausalEvidenceGraph(context),
348
+ };
349
+ }
350
+
351
+ // Step 1: Build causal evidence graph
352
+ const causalGraph = FailureClassifier.buildCausalEvidenceGraph(context);
353
+
354
+ // Step 2: Check for Network Offline / DNS resolution failures
355
+ const hasNetworkOffline = context.networkEvents.some(
356
+ (req) => /err_internet_disconnected|enotfound|err_name_not_resolved|dns_probe_finished_nxdomain|eai_again|eai_fail/i.test(req.errorText ?? '')
357
+ );
358
+ if (
359
+ hasNetworkOffline ||
360
+ /err_internet_disconnected|enotfound|err_name_not_resolved|dns_probe_finished_nxdomain|eai_again|eai_fail/i.test(errorMsg)
361
+ ) {
362
+ supportingEvidence.push('Network transport offline or DNS lookup failure detected');
363
+ return {
364
+ category: 'NETWORK_OFFLINE',
365
+ confidence: 0.99,
366
+ reason: 'Network connectivity offline or DNS resolution failed',
367
+ shouldAbstain: true,
368
+ abstentionReason: 'Negative Control Triggered: Network offline. Test failure is caused by connectivity loss.',
369
+ supportingEvidence,
370
+ causalGraph,
371
+ };
372
+ }
373
+
374
+ // Step 3: Environment/service availability failures. These are distinct
375
+ // from an offline host: the machine has a network stack, but the target
376
+ // application/service is unavailable or the connection was interrupted.
377
+ const hasEnvironmentFailure =
378
+ /(?:net::)?err_connection_refused|econnrefused|connection refused|(?:net::)?err_connection_reset|econnreset|(?:net::)?err_connection_closed|ehostunreach|connection reset by peer/i.test(diagnosticText) ||
379
+ context.networkEvents.some((request) => /(?:err_connection_refused|econnrefused|err_connection_reset|econnreset|ehostunreach)/i.test(request.errorText ?? ''));
380
+ if (hasEnvironmentFailure) {
381
+ supportingEvidence.push('Target application or dependent service refused or reset the connection');
382
+ return {
383
+ category: 'ENVIRONMENT_FAILURE',
384
+ confidence: 0.99,
385
+ reason: 'The target application or a required service was unavailable during the test',
386
+ shouldAbstain: true,
387
+ abstentionReason: 'Environment failure detected. UI locator repair cannot restore an unavailable service.',
388
+ supportingEvidence,
389
+ causalGraph,
390
+ };
391
+ }
392
+
393
+ // Step 4: Causal Server Errors (Negative Control: MUST ABSTAIN)
394
+ // Only triggers on causally relevant application endpoints, ignoring background analytics 500s
395
+ if (causalGraph.hasCausallyRelevantServerError && causalGraph.offendingNetworkEvent) {
396
+ const offending = causalGraph.offendingNetworkEvent;
397
+ supportingEvidence.push(
398
+ `HTTP ${offending.status} (${offending.statusText ?? 'Server Error'}) on ${offending.method} ${offending.url}`
399
+ );
400
+
401
+ return {
402
+ category: 'BACKEND_500',
403
+ confidence: 0.99,
404
+ reason: `Backend server returned HTTP ${offending.status} during or immediately preceding test failure`,
405
+ shouldAbstain: true,
406
+ abstentionReason: `Negative Control Triggered: Test failed due to backend server failure (${offending.status} on ${offending.url}). Modifying UI locators is prohibited.`,
407
+ supportingEvidence,
408
+ causalGraph,
409
+ };
410
+ }
411
+
412
+ // Step 5: Causal Auth Expiration / 401 / 403 (Negative Control: MUST ABSTAIN)
413
+ if (causalGraph.hasCausallyRelevantAuthError && causalGraph.offendingNetworkEvent) {
414
+ const offending = causalGraph.offendingNetworkEvent;
415
+ supportingEvidence.push(`HTTP ${offending.status} on authenticated application endpoint: ${offending.url}`);
416
+ return {
417
+ category: 'AUTH_EXPIRED',
418
+ confidence: 0.98,
419
+ reason: `Authentication expired or unauthorized access detected (HTTP ${offending.status})`,
420
+ shouldAbstain: true,
421
+ abstentionReason: `Negative Control Triggered: Authentication session expired or unauthorized (HTTP ${offending.status}). Locator healing must abstain.`,
422
+ supportingEvidence,
423
+ causalGraph,
424
+ };
425
+ }
426
+
427
+ // Step 6: Assertion Failure (e.g. expect(received).toBe(expected)) (Negative Control: MUST ABSTAIN)
428
+ // Negative control: engine strictly prohibits modifying test assertions to force a passing test run.
429
+ const isValueAssertionAction =
430
+ /tohavetext|tocontaintext|tohavevalue|tohaveattribute|tohaveclass|tohavecount|tohavetitle|tohaveurl|tobechecked|tobeenabled|tobedisabled/i.test(action) ||
431
+ /tohavetext|tocontaintext|tohavevalue|tohaveattribute|tohaveclass|tohavecount/i.test(errorMsg);
432
+
433
+ const hasUnexpectedValueInLog =
434
+ (context.failedAction.logs ?? []).some((l) => /unexpected value/i.test(l)) ||
435
+ /unexpected value/i.test(errorMsg);
436
+
437
+ const hasValueMismatch =
438
+ errorMsg.includes('received:') ||
439
+ errorMsg.includes('expected:') ||
440
+ hasUnexpectedValueInLog;
441
+
442
+ const isAssertionMethod =
443
+ action === 'expect' ||
444
+ action.startsWith('expect ') ||
445
+ action.includes('expect.') ||
446
+ action.includes('assert') ||
447
+ isValueAssertionAction;
448
+
449
+ const hasAssertionErrorPattern =
450
+ errorMsg.includes('expect(received)') ||
451
+ errorMsg.includes('to have attribute') ||
452
+ errorMsg.includes('to have text') ||
453
+ errorMsg.includes('to have value') ||
454
+ errorMsg.includes('to have class') ||
455
+ errorMsg.includes('to have count') ||
456
+ (errorMsg.includes('to be visible') && !context.failedAction.selector);
457
+
458
+ if (
459
+ hasValueMismatch ||
460
+ isValueAssertionAction ||
461
+ hasAssertionErrorPattern ||
462
+ (isAssertionMethod && !context.failedAction.selector) ||
463
+ (action.includes('expect') && (errorMsg.includes('expect(') || errorMsg.includes('received:')))
464
+ ) {
465
+ supportingEvidence.push(`Assertion failed: ${context.failedAction.errorMessage}`);
466
+ return {
467
+ category: 'ASSERTION_FAILURE',
468
+ confidence: 0.95,
469
+ reason: 'Test assertion failed against verified element state or business logic outcome',
470
+ shouldAbstain: true,
471
+ abstentionReason: 'Engine strictly prohibits modifying test assertions to force a passing test run.',
472
+ supportingEvidence,
473
+ causalGraph,
474
+ };
475
+ }
476
+
477
+ // Step 7: Actionability and browser-context failures. These categories
478
+ // are intentionally abstaining: they explain the failure without claiming
479
+ // that changing the locator would make the application correct.
480
+ const isAssertionAction = action.includes('expect') || action.includes('assert');
481
+ if (!isAssertionAction && /intercepts pointer events|another element would receive|element is obscured|element is covered|overlay.*blocking|blocking.*overlay|subtree intercepts pointer events/i.test(diagnosticText)) {
482
+ supportingEvidence.push('A different element or overlay intercepted the intended pointer action');
483
+ return {
484
+ category: 'OBSTRUCTED_ELEMENT',
485
+ confidence: 0.96,
486
+ reason: 'The target action was obstructed by another element or overlay',
487
+ shouldAbstain: true,
488
+ abstentionReason: 'Actionability failure detected. Changing the locator could mask an obstructed application state.',
489
+ supportingEvidence,
490
+ causalGraph,
491
+ };
492
+ }
493
+
494
+ if (!isAssertionAction && /element is not enabled|element is disabled|locator is disabled|button is disabled|aria-disabled["'= ]+true/i.test(diagnosticText)) {
495
+ supportingEvidence.push('The target element was present but disabled by the application');
496
+ return {
497
+ category: 'DISABLED_ELEMENT',
498
+ confidence: 0.97,
499
+ reason: 'The target element was disabled and could not perform the requested action',
500
+ shouldAbstain: true,
501
+ abstentionReason: 'Disabled-control failure detected. Locator repair cannot satisfy the application prerequisite.',
502
+ supportingEvidence,
503
+ causalGraph,
504
+ };
505
+ }
506
+
507
+ const isFrameScopedSelector = /contentFrame|frameLocator|\.frame\(/i.test(context.failedAction.selector ?? '');
508
+ const isFrameUnavailableError = /frame was detached|frame is detached|frame not found|target frame.*not found|frame.*navigated/i.test(diagnosticText);
509
+
510
+ if (!isAssertionAction && isFrameUnavailableError) {
511
+ supportingEvidence.push('The frame context was detached, navigated away, or destroyed');
512
+ return {
513
+ category: 'FRAME_OR_IFRAME',
514
+ confidence: 0.94,
515
+ reason: 'The target frame was detached or unavailable',
516
+ shouldAbstain: true,
517
+ abstentionReason: 'Frame-context failure detected: frame was detached or destroyed.',
518
+ supportingEvidence,
519
+ causalGraph,
520
+ };
521
+ }
522
+
523
+ const hasExtractableCandidates = Boolean(
524
+ (context as any).domRoot &&
525
+ (() => {
526
+ try {
527
+ return CandidateExtractor.extractCandidates((context as any).domRoot, context.failedAction).length > 0;
528
+ } catch {
529
+ return false;
530
+ }
531
+ })()
532
+ );
533
+
534
+ if (!isAssertionAction && isFrameScopedSelector && context.evidenceCompleteness.domAvailable && hasExtractableCandidates) {
535
+ // Frame-scoped selector where evidence is complete and child candidates are available:
536
+ // candidate extraction is frame-aware (searches page.frames()), so this can be safely routed to LOCATOR_DRIFT repair.
537
+ supportingEvidence.push('The failed action was scoped to a frame, but evidence is complete and candidates can be extracted from child frames');
538
+ } else if (!isAssertionAction && /frame.*(?:not found|detached|unavailable)|iframe.*(?:not found|detached|unavailable)|different frame|content frame|contentFrame|frame locator|frameLocator/i.test(diagnosticText)) {
539
+ supportingEvidence.push('The failed action was associated with a missing, detached, or mismatched frame context');
540
+ return {
541
+ category: 'FRAME_OR_IFRAME',
542
+ confidence: 0.94,
543
+ reason: 'The target element was not available in the expected frame or iframe context',
544
+ shouldAbstain: true,
545
+ abstentionReason: 'Frame-context failure detected. A locator-only repair is unsafe without confirming the frame boundary.',
546
+ supportingEvidence,
547
+ causalGraph,
548
+ };
549
+ }
550
+
551
+ if (!isAssertionAction && /hydration|hydrated|networkidle|waiting for load state|delayed content|element is not stable/i.test(diagnosticText)) {
552
+ supportingEvidence.push('Failure evidence indicates delayed application readiness or hydration');
553
+ return {
554
+ category: 'HYDRATION_OR_TIMING',
555
+ confidence: 0.86,
556
+ reason: 'The target page or component was not ready when the action executed',
557
+ shouldAbstain: true,
558
+ abstentionReason: 'Readiness/timing failure detected. Locator modification will not reliably solve delayed application state.',
559
+ supportingEvidence,
560
+ causalGraph,
561
+ };
562
+ }
563
+
564
+ // Step 8: Ambiguous Match / Strict Mode Violation
565
+ if (
566
+ errorMsg.includes('strict mode violation') ||
567
+ /resolved to \d+ elements/i.test(errorMsg)
568
+ ) {
569
+ const countMatch = errorMsg.match(/resolved to (\d+) elements/i);
570
+ const matchCount = countMatch && countMatch[1] ? parseInt(countMatch[1], 10) : 0;
571
+ const rawSel = (context.failedAction.selector ?? '').trim().toLowerCase();
572
+ const isAnonymousTag = /^(div|span|p|tr|td|li|ul|ol|table|section|article|header|footer|main|nav)(\.[a-z0-9_-]+)*$/i.test(rawSel);
573
+ const isHighAmbiguity = matchCount >= 20 || (isAnonymousTag && matchCount >= 2);
574
+
575
+ // Unresolvable ambiguous match: anonymous tag or >= 20 identical matches without semantic anchors
576
+ if (isHighAmbiguity) {
577
+ supportingEvidence.push(
578
+ `Unresolvable ambiguous match: locator '${context.failedAction.selector}' resolved to ${matchCount} anonymous elements without semantic anchors`
579
+ );
580
+ return {
581
+ category: 'INCOMPLETE_EVIDENCE',
582
+ confidence: 0.95,
583
+ reason: `Locator '${context.failedAction.selector}' resolved to ${matchCount} anonymous elements without semantic anchors`,
584
+ shouldAbstain: true,
585
+ abstentionReason:
586
+ 'Insufficient semantic evidence; engine must safely abstain rather than generate arbitrary nth(x) selectors.',
587
+ supportingEvidence,
588
+ causalGraph,
589
+ };
590
+ }
591
+
592
+ supportingEvidence.push(`Strict mode violation detected: ${context.failedAction.errorMessage}`);
593
+ const isScopedUnique = options?.provenUniqueScopedReplacement === true;
594
+ return {
595
+ category: 'AMBIGUOUS_MATCH',
596
+ confidence: 0.92,
597
+ reason: 'Locator resolved to multiple matching elements in the DOM (strict mode violation)',
598
+ shouldAbstain: !isScopedUnique,
599
+ abstentionReason: isScopedUnique
600
+ ? undefined
601
+ : 'Strict mode multi-match detected; engine abstains by default unless live DOM investigation proves unique scoped replacement.',
602
+ supportingEvidence,
603
+ causalGraph,
604
+ };
605
+ }
606
+
607
+ // Step 9: Timing Race / Detached Node
608
+ if (
609
+ errorMsg.includes('element is not attached to the dom') ||
610
+ errorMsg.includes('navigation interrupted') ||
611
+ errorMsg.includes('execution context was destroyed') ||
612
+ errorMsg.includes('target closed') ||
613
+ errorMsg.includes('browser has been closed')
614
+ ) {
615
+ supportingEvidence.push(`DOM lifecycle race: ${context.failedAction.errorMessage}`);
616
+ return {
617
+ category: 'TIMING_RACE',
618
+ confidence: 0.88,
619
+ reason: 'Element detached or page navigated mid-action (asynchronous timing race condition)',
620
+ shouldAbstain: true,
621
+ abstentionReason: 'Timing race condition detected. Locator modification will not solve DOM detachment.',
622
+ supportingEvidence,
623
+ causalGraph,
624
+ };
625
+ }
626
+
627
+ // A post-action assertion timeout is not sufficient evidence of locator
628
+ // drift. The application may still be hydrating or waiting for a state
629
+ // transition, and rewriting the assertion target could mask that defect.
630
+ // Keep explicit action timeouts repairable below, but fail closed here.
631
+ const isAssertionTimeout =
632
+ isAssertionMethod &&
633
+ /timeout|timed out|element\(s\) not found|waiting for/i.test(diagnosticText) &&
634
+ Boolean(context.failedAction.selector);
635
+ if (isAssertionTimeout) {
636
+ supportingEvidence.push('Assertion timed out while waiting for a post-action UI state; trace does not prove locator drift');
637
+ return {
638
+ category: 'HYDRATION_OR_TIMING',
639
+ confidence: 0.72,
640
+ reason: 'Post-action assertion timed out before the expected UI state became observable',
641
+ shouldAbstain: true,
642
+ abstentionReason: 'Assertion readiness is unresolved. Locator repair is prohibited without evidence that the target identity changed.',
643
+ supportingEvidence,
644
+ causalGraph,
645
+ };
646
+ }
647
+
648
+ // Step 10: Locator Drift (Timeout waiting for locator / locator not found)
649
+ if (
650
+ (errorMsg.includes('timeout') || errorMsg.includes('element(s) not found') || errorMsg.includes('not found')) &&
651
+ (errorMsg.includes('waiting for locator') ||
652
+ errorMsg.includes('waiting for getby') ||
653
+ errorMsg.includes('waiting for selector') ||
654
+ errorMsg.includes('tobevisible') ||
655
+ errorMsg.includes('to be visible') ||
656
+ errorMsg.includes('expect failed') ||
657
+ Boolean(context.failedAction.selector))
658
+ ) {
659
+ // Check if DOM evidence is missing when trying to diagnose locator drift
660
+ // CRITICAL FIX (R-015): Check all snapshot channels before concluding DOM evidence is missing
661
+ const hasSnapshots = Boolean(
662
+ (context.snapshots?.action && context.snapshots.action.trim().length > 0) ||
663
+ (context.snapshots?.before && context.snapshots.before.trim().length > 0) ||
664
+ (context.snapshots?.after && context.snapshots.after.trim().length > 0) ||
665
+ context.evidenceCompleteness?.domAvailable
666
+ );
667
+ if (context.evidenceCompleteness?.domAvailable === false && !hasSnapshots) {
668
+ return {
669
+ category: 'INCOMPLETE_EVIDENCE',
670
+ confidence: 0.95,
671
+ reason: `Target locator '${context.failedAction.selector}' timed out, but DOM snapshots are missing or incomplete in trace`,
672
+ shouldAbstain: true,
673
+ abstentionReason: 'Insufficient DOM evidence to diagnose or safely extract replacement candidates.',
674
+ supportingEvidence: ['DOM snapshot missing from trace archive'],
675
+ causalGraph,
676
+ };
677
+ }
678
+
679
+ supportingEvidence.push(`Locator timeout for selector '${context.failedAction.selector ?? 'unknown'}'`);
680
+ return {
681
+ category: 'LOCATOR_DRIFT',
682
+ confidence: 0.93,
683
+ reason: `Target locator '${context.failedAction.selector}' timed out waiting for matching DOM element`,
684
+ shouldAbstain: false, // Legitimate candidate for candidate extraction & repair
685
+ supportingEvidence,
686
+ causalGraph,
687
+ };
688
+ }
689
+
690
+ // Default Unknown
691
+ return {
692
+ category: 'UNKNOWN_ERROR',
693
+ confidence: 0.5,
694
+ reason: `Unclassified failure: ${context.failedAction.errorMessage || 'No error message available'}`,
695
+ shouldAbstain: true,
696
+ abstentionReason: 'Insufficient diagnostic evidence to safely classify failure root cause.',
697
+ supportingEvidence,
698
+ causalGraph,
699
+ };
700
+ }
701
+ }