qa-engineer 0.9.2 → 0.11.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.
Files changed (147) hide show
  1. package/COMPATIBILITY.md +26 -12
  2. package/README.md +182 -84
  3. package/package.json +11 -20
  4. package/packages/engine/bin/qa-engine.mjs +560 -0
  5. package/packages/engine/lib/analysis/branding.mjs +187 -0
  6. package/packages/engine/lib/analysis/context.mjs +294 -0
  7. package/packages/engine/lib/analysis/contracts.mjs +149 -0
  8. package/packages/engine/lib/analysis/diff-guard.mjs +425 -0
  9. package/packages/engine/lib/analysis/discovery.mjs +220 -0
  10. package/packages/engine/lib/analysis/evidence.mjs +116 -0
  11. package/packages/engine/lib/analysis/har.mjs +124 -0
  12. package/packages/engine/lib/analysis/junit.mjs +126 -0
  13. package/packages/engine/lib/analysis/network.mjs +237 -0
  14. package/packages/engine/lib/analysis/redaction.mjs +127 -0
  15. package/packages/engine/lib/analysis/report-html.mjs +76 -0
  16. package/packages/engine/lib/analysis/taxonomy.mjs +109 -0
  17. package/packages/engine/lib/analysis/xml.mjs +153 -0
  18. package/packages/engine/lib/analysis/zip.mjs +107 -0
  19. package/packages/engine/lib/artifacts/manager.mjs +453 -0
  20. package/packages/engine/lib/artifacts/mime.mjs +109 -0
  21. package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
  22. package/packages/engine/lib/diagnostics/engine.mjs +165 -0
  23. package/packages/engine/lib/diagnostics/internal-contracts.mjs +50 -0
  24. package/packages/engine/lib/diagnostics/prioritization.mjs +99 -0
  25. package/packages/engine/lib/diagnostics/repair.mjs +73 -0
  26. package/packages/engine/lib/diagnostics/root-cause.mjs +92 -0
  27. package/packages/engine/lib/diagnostics/timeline.mjs +101 -0
  28. package/packages/engine/lib/frameworks/junit-frameworks.mjs +48 -0
  29. package/packages/engine/lib/frameworks/playwright.mjs +158 -0
  30. package/packages/engine/lib/report/components/charts.mjs +424 -0
  31. package/packages/engine/lib/report/components/evidence.mjs +207 -0
  32. package/packages/engine/lib/report/components/findings.mjs +258 -0
  33. package/packages/engine/lib/report/components/nav.mjs +99 -0
  34. package/packages/engine/lib/report/components/primitives.mjs +246 -0
  35. package/packages/engine/lib/report/components/runtime.mjs +246 -0
  36. package/packages/engine/lib/report/components/timeline.mjs +65 -0
  37. package/packages/engine/lib/report/core/model.mjs +270 -0
  38. package/packages/engine/lib/report/core/normalize.mjs +226 -0
  39. package/packages/engine/lib/report/core/sections.mjs +978 -0
  40. package/packages/engine/lib/report/export/bundle.mjs +293 -0
  41. package/packages/engine/lib/report/export/html.mjs +183 -0
  42. package/packages/engine/lib/report/export/machine.mjs +290 -0
  43. package/packages/engine/lib/report/export/markdown.mjs +323 -0
  44. package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
  45. package/packages/engine/lib/report/theme/css.mjs +529 -0
  46. package/packages/engine/lib/report/theme/tokens.mjs +137 -0
  47. package/packages/engine/lib/report/version.mjs +78 -0
  48. package/packages/engine/package.json +14 -0
  49. package/packages/installer/lib/agents/targets.mjs +90 -0
  50. package/packages/installer/lib/agents/user-level.mjs +80 -0
  51. package/packages/installer/lib/cli/flags.mjs +20 -2
  52. package/packages/installer/lib/commands/doctor.mjs +13 -20
  53. package/packages/installer/lib/commands/install.mjs +160 -91
  54. package/packages/installer/lib/commands/repair.mjs +10 -6
  55. package/packages/installer/lib/commands/self-test.mjs +4 -3
  56. package/packages/installer/lib/commands/uninstall.mjs +14 -8
  57. package/packages/installer/lib/commands/update.mjs +9 -4
  58. package/packages/installer/lib/commands/verify.mjs +13 -12
  59. package/packages/installer/lib/constants.mjs +13 -0
  60. package/packages/installer/lib/core/bundle.mjs +69 -92
  61. package/packages/installer/lib/core/conflict.mjs +5 -4
  62. package/packages/installer/lib/core/fs-safe.mjs +146 -6
  63. package/packages/installer/lib/core/integrity.mjs +59 -0
  64. package/packages/installer/lib/core/lockfile.mjs +19 -3
  65. package/packages/installer/lib/core/manifest.mjs +48 -57
  66. package/packages/installer/lib/core/plan.mjs +213 -0
  67. package/packages/installer/lib/core/qa-home.mjs +145 -0
  68. package/packages/installer/lib/core/scope.mjs +274 -0
  69. package/packages/installer/lib/core/validate-install.mjs +49 -31
  70. package/packages/installer/package.json +1 -1
  71. package/packages/installer/schemas/qa-lock.schema.json +119 -21
  72. package/shared/tooling/qa-tool.mjs +161 -0
  73. package/skills/qa-api/SKILL.md +5 -5
  74. package/skills/qa-api/references/deterministic-tooling.md +34 -32
  75. package/skills/qa-api/references/evidence-and-reporting.md +5 -5
  76. package/skills/qa-api/scripts/qa-tool.mjs +162 -0
  77. package/skills/qa-audit/SKILL.md +5 -5
  78. package/skills/qa-audit/references/deterministic-tooling.md +34 -32
  79. package/skills/qa-audit/references/evidence-and-reporting.md +5 -5
  80. package/skills/qa-audit/scripts/qa-tool.mjs +162 -0
  81. package/skills/qa-debug/SKILL.md +6 -6
  82. package/skills/qa-debug/references/deterministic-tooling.md +34 -32
  83. package/skills/qa-debug/references/diagnostic-engine.md +3 -3
  84. package/skills/qa-debug/references/evidence-and-reporting.md +5 -5
  85. package/skills/qa-debug/scripts/qa-tool.mjs +162 -0
  86. package/skills/qa-explore/SKILL.md +31 -15
  87. package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
  88. package/skills/qa-explore/references/api-replay.md +40 -1
  89. package/skills/qa-explore/references/deterministic-tooling.md +34 -32
  90. package/skills/qa-explore/references/evidence-and-reporting.md +5 -5
  91. package/skills/qa-explore/references/report-pipeline.md +266 -96
  92. package/skills/qa-explore/scripts/qa-tool.mjs +162 -0
  93. package/skills/qa-fix/SKILL.md +4 -4
  94. package/skills/qa-fix/references/deterministic-tooling.md +34 -32
  95. package/skills/qa-fix/references/diagnostic-engine.md +3 -3
  96. package/skills/qa-fix/references/evidence-and-reporting.md +5 -5
  97. package/skills/qa-fix/scripts/qa-tool.mjs +162 -0
  98. package/skills/qa-flaky/SKILL.md +4 -4
  99. package/skills/qa-flaky/references/deterministic-tooling.md +34 -32
  100. package/skills/qa-flaky/references/evidence-and-reporting.md +5 -5
  101. package/skills/qa-flaky/scripts/qa-tool.mjs +162 -0
  102. package/skills/qa-generate/references/evidence-and-reporting.md +5 -5
  103. package/skills/qa-init/SKILL.md +4 -4
  104. package/skills/qa-init/references/deterministic-tooling.md +34 -32
  105. package/skills/qa-init/references/evidence-and-reporting.md +5 -5
  106. package/skills/qa-init/scripts/qa-tool.mjs +162 -0
  107. package/skills/qa-report/SKILL.md +7 -7
  108. package/skills/qa-report/references/deterministic-tooling.md +34 -32
  109. package/skills/qa-report/references/diagnostic-engine.md +3 -3
  110. package/skills/qa-report/references/evidence-and-reporting.md +5 -5
  111. package/skills/qa-report/scripts/qa-tool.mjs +162 -0
  112. package/skills/qa-review/references/evidence-and-reporting.md +5 -5
  113. package/skills/qa-run/SKILL.md +6 -6
  114. package/skills/qa-run/references/deterministic-tooling.md +34 -32
  115. package/skills/qa-run/references/evidence-and-reporting.md +5 -5
  116. package/skills/qa-run/scripts/qa-tool.mjs +162 -0
  117. package/shared/analysis/lib/qa_analysis/__init__.py +0 -11
  118. package/shared/analysis/lib/qa_analysis/branding.py +0 -175
  119. package/shared/analysis/lib/qa_analysis/cli.py +0 -144
  120. package/shared/analysis/lib/qa_analysis/context.py +0 -233
  121. package/shared/analysis/lib/qa_analysis/contracts.py +0 -158
  122. package/shared/analysis/lib/qa_analysis/diff_guard.py +0 -327
  123. package/shared/analysis/lib/qa_analysis/discovery.py +0 -113
  124. package/shared/analysis/lib/qa_analysis/evidence.py +0 -128
  125. package/shared/analysis/lib/qa_analysis/har.py +0 -87
  126. package/shared/analysis/lib/qa_analysis/junit.py +0 -104
  127. package/shared/analysis/lib/qa_analysis/redaction.py +0 -107
  128. package/shared/analysis/lib/qa_analysis/report_html.py +0 -781
  129. package/shared/analysis/lib/qa_analysis/taxonomy.py +0 -98
  130. package/shared/diagnostics/lib/qa_diagnostics/__init__.py +0 -13
  131. package/shared/diagnostics/lib/qa_diagnostics/cli.py +0 -124
  132. package/shared/diagnostics/lib/qa_diagnostics/engine.py +0 -143
  133. package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +0 -75
  134. package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +0 -101
  135. package/shared/diagnostics/lib/qa_diagnostics/repair.py +0 -72
  136. package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +0 -89
  137. package/shared/diagnostics/lib/qa_diagnostics/timeline.py +0 -71
  138. package/shared/frameworks/cypress/lib/cypress_analysis.py +0 -27
  139. package/shared/frameworks/playwright/lib/playwright_analysis.py +0 -167
  140. package/shared/frameworks/selenium/lib/selenium_analysis.py +0 -28
  141. package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +0 -24
  142. package/shared/tooling/qa_tool.py +0 -127
  143. /package/{shared/analysis/lib/qa_analysis → packages/engine/lib/analysis}/branding.json +0 -0
  144. /package/{shared → packages/engine/lib}/analysis/schemas/context.schema.json +0 -0
  145. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/analysis-result.schema.json +0 -0
  146. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/diagnosis.schema.json +0 -0
  147. /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/execution-result-min.schema.json +0 -0
@@ -0,0 +1,127 @@
1
+ // Credential, secret, token, and PII redaction.
2
+ //
3
+ // Every analyzer runs artifact text through redaction before it appears in a
4
+ // finding, a report, or stdout. Redaction happens as evidence is captured, not
5
+ // after — a secret must never reach a model's context or a log.
6
+ //
7
+ // Ported from qa_analysis/redaction.py, rule for rule and in the same order, and
8
+ // held to identical output by scripts/check-engine-parity.mjs. Python's inline
9
+ // `(?i)` / `(?im)` flags become JavaScript flag letters; every rule carries `g`
10
+ // because Python's `re.sub` replaces all occurrences.
11
+
12
+ export class RedactionError extends Error {}
13
+
14
+ // Ordered. Order matters: match structured, high-signal secrets (JWTs, provider
15
+ // keys) before generic fallbacks.
16
+ const RULES = [
17
+ { name: 'jwt', pattern: /eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/g },
18
+ { name: 'aws-access-key', pattern: /AKIA[0-9A-Z]{16}/g },
19
+ { name: 'github-token', pattern: /gh[pousr]_[A-Za-z0-9]{20,}/g },
20
+ { name: 'slack-token', pattern: /xox[baprs]-[A-Za-z0-9-]{10,}/g },
21
+ { name: 'openai-key', pattern: /sk-[A-Za-z0-9]{20,}/g },
22
+ { name: 'bearer', pattern: /\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi },
23
+ // Sensitive header lines: keep the header name, mask the value.
24
+ //
25
+ // The value is `[^\r\n]+` rather than `.+$`, and the indent is `[ \t]*` rather
26
+ // than `\s*`, because `$` and `\s` do not mean the same thing in Python and
27
+ // JavaScript on CRLF text: Python's `.+$` swallowed the carriage return and
28
+ // rewrote the line ending to LF. Anchoring on a negated class removes the
29
+ // difference and stops the redactor corrupting a document it only meant to mask.
30
+ {
31
+ name: 'auth-header',
32
+ pattern: /^([ \t]*(?:authorization|proxy-authorization)[ \t]*[:=][ \t]*)[^\r\n]+/gim,
33
+ replacement: '$1[REDACTED:auth-header]',
34
+ },
35
+ {
36
+ name: 'cookie-header',
37
+ pattern: /^([ \t]*(?:set-cookie|cookie)[ \t]*[:=][ \t]*)[^\r\n]+/gim,
38
+ replacement: '$1[REDACTED:cookie-header]',
39
+ },
40
+ // Secret-like assignments: key=value / "key": "value".
41
+ {
42
+ name: 'assigned-secret',
43
+ pattern: /(\b(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|client[_-]?secret)\b\s*[:=]\s*["']?)([^\s"'&]{4,})/gi,
44
+ replacement: '$1[REDACTED:secret]',
45
+ },
46
+ // Credentials in URLs and query strings.
47
+ {
48
+ name: 'url-credential',
49
+ pattern: /(:\/\/[^:/@\s]+:)([^@/\s]+)(@)/gi,
50
+ replacement: '$1[REDACTED:credential]$3',
51
+ },
52
+ {
53
+ name: 'query-secret',
54
+ pattern: /([?&](?:password|passwd|pwd|token|secret|api[_-]?key|access[_-]?key)=)([^&\s#]+)/gi,
55
+ replacement: '$1[REDACTED:secret]',
56
+ },
57
+ // PII: email addresses.
58
+ { name: 'email', pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g },
59
+ ];
60
+
61
+ const SENSITIVE_HEADERS = new Set([
62
+ 'authorization', 'proxy-authorization', 'cookie', 'set-cookie',
63
+ 'x-api-key', 'x-auth-token', 'api-key', 'x-csrf-token',
64
+ ]);
65
+
66
+ /** Return text with every recognized secret or PII value masked. */
67
+ export function redactText(text) {
68
+ if (!text) return text;
69
+ let result = String(text);
70
+ for (const rule of RULES) {
71
+ // A fresh regex per call: `g` regexes carry lastIndex, and a module-level
72
+ // pattern reused across calls would skip matches unpredictably.
73
+ const pattern = new RegExp(rule.pattern.source, rule.pattern.flags);
74
+ result = result.replace(pattern, rule.replacement ?? `[REDACTED:${rule.name}]`);
75
+ }
76
+ return result;
77
+ }
78
+
79
+ /**
80
+ * Return `[{type, start, end}]` for secrets found — never the values.
81
+ *
82
+ * Used to decide whether an artifact is safe to expose without surfacing the
83
+ * secret itself.
84
+ */
85
+ export function detectSecrets(text) {
86
+ const findings = [];
87
+ if (!text) return findings;
88
+ const source = String(text);
89
+ for (const rule of RULES) {
90
+ const pattern = new RegExp(rule.pattern.source, rule.pattern.flags);
91
+ for (const match of source.matchAll(pattern)) {
92
+ findings.push({ type: rule.name, start: match.index, end: match.index + match[0].length });
93
+ }
94
+ }
95
+ // Stable sort by position, so two secrets at the same offset keep rule order.
96
+ findings.sort((a, b) => a.start - b.start);
97
+ return findings;
98
+ }
99
+
100
+ /**
101
+ * Mask the values of sensitive HTTP headers.
102
+ *
103
+ * Accepts an object, or an array of `{name, value}` entries (the HAR shape), and
104
+ * returns the same shape with sensitive values replaced.
105
+ */
106
+ export function redactHeaders(headers) {
107
+ const mask = (name, value) =>
108
+ SENSITIVE_HEADERS.has(String(name).trim().toLowerCase())
109
+ ? '[REDACTED:header]'
110
+ : redactText(value);
111
+
112
+ if (Array.isArray(headers)) {
113
+ return headers.map((entry) =>
114
+ entry && typeof entry === 'object' && 'name' in entry
115
+ ? { ...entry, value: mask(String(entry.name), String(entry.value ?? '')) }
116
+ : entry,
117
+ );
118
+ }
119
+ if (headers && typeof headers === 'object') {
120
+ return Object.fromEntries(
121
+ Object.entries(headers).map(([key, value]) => [key, mask(key, String(value))]),
122
+ );
123
+ }
124
+ return headers;
125
+ }
126
+
127
+ export const RULE_NAMES = RULES.map((rule) => rule.name);
@@ -0,0 +1,76 @@
1
+ // Render a contract artifact as a presentation-grade HTML report.
2
+ //
3
+ // ## What this module is now
4
+ //
5
+ // A stable entry point. The rendering itself moved to `lib/report/`, which is split
6
+ // into theme, components, core, and export — because a single 700-line file could not
7
+ // hold an enterprise report and stay reviewable, and because the Markdown, SARIF,
8
+ // JUnit, and CSV renderings all need the same view model the HTML uses.
9
+ //
10
+ // This file stays because it is the *published* seam: `qa-tool.mjs analysis
11
+ // report-html` is documented in nine installed skills and in every report-pipeline
12
+ // reference. Moving code is cheap; moving a documented command out from under
13
+ // installed copies is not.
14
+ //
15
+ // ## Why the report is rendered rather than typed
16
+ //
17
+ // The first real `/qa-explore` run on a live application produced a valid artifact and
18
+ // a *lossy* report. Every finding in the contract carries `repro`, `actual`,
19
+ // `expected`, and `fixDirection` — all four required — and the hand-written HTML
20
+ // collapsed them into a single sentence:
21
+ //
22
+ // EXP-1 · high — Double-click Login fires two GraphQL auth requests
23
+ // Two identical POSTs to /graphql. Disable Login while in flight.
24
+ //
25
+ // The reader is left to infer what was expected, how to reproduce it, and what
26
+ // "correct" would look like. The data existed; the rendering discarded it.
27
+ //
28
+ // The second live run produced a beautiful report in which **every screenshot was
29
+ // broken**, because the declared paths were relative to the project root and the
30
+ // report was written inside the run folder. Both failures have the same cause: a human
31
+ // or a model retyping what a renderer should own. So the renderer owns it, and
32
+ // `lib/artifacts/manager.mjs` owns the difference between where a file is and how this
33
+ // document links to it.
34
+ //
35
+ // No dependencies, one self-contained file out, no external assets — a report must
36
+ // open from an email attachment on a plane.
37
+
38
+ import fs from 'node:fs';
39
+ import path from 'node:path';
40
+
41
+ import {
42
+ render as renderDocument, ReportError, supportedContracts, supportedModes,
43
+ versionStamp, versionLine,
44
+ } from '../report/export/html.mjs';
45
+
46
+ export { ReportError, supportedContracts, supportedModes, versionStamp, versionLine };
47
+
48
+ /**
49
+ * Render a full standalone HTML document for a supported artifact.
50
+ *
51
+ * `resultPath` and `outPath` are how evidence links come out correct: the first says
52
+ * what declared paths are relative to, the second says what hrefs must be relative to.
53
+ * Both are optional and both should be supplied — without them every artifact resolves
54
+ * against the process working directory, which is right only by luck.
55
+ */
56
+ export function render(result, options = {}) {
57
+ return renderDocument(result, options);
58
+ }
59
+
60
+ /** Read an artifact from disk and render it. */
61
+ export function renderFile(file, options = {}) {
62
+ let result;
63
+ try {
64
+ result = JSON.parse(fs.readFileSync(file, 'utf8'));
65
+ } catch (error) {
66
+ throw new ReportError(`could not read artifact at ${file}: ${error.message}`);
67
+ }
68
+ if (result === null || typeof result !== 'object' || Array.isArray(result)) {
69
+ throw new ReportError(`artifact at ${file} is not a JSON object`);
70
+ }
71
+ return renderDocument(result, {
72
+ ...options,
73
+ resultPath: path.resolve(file),
74
+ outPath: options.outPath ?? null,
75
+ });
76
+ }
@@ -0,0 +1,109 @@
1
+ // The canonical QA failure taxonomy and its deterministic classifier.
2
+ //
3
+ // Classification maps observed signals to one of a closed set of failure classes.
4
+ // It is rule-based and conservative: when no rule matches with sufficient signal,
5
+ // the result is UNKNOWN. Unknown is preferable to incorrect.
6
+ //
7
+ // Ported from qa_analysis/taxonomy.py with the rule order preserved exactly —
8
+ // order *is* the logic here (see the note on the first two rules) — and held to
9
+ // identical output by scripts/check-engine-parity.mjs.
10
+
11
+ export const ASSERTION = 'assertion-failure';
12
+ export const LOCATOR = 'locator-failure';
13
+ export const TIMEOUT = 'timeout';
14
+ export const NETWORK = 'network';
15
+ export const AUTH = 'authentication';
16
+ export const AUTHORIZATION = 'authorization';
17
+ export const ENVIRONMENT = 'environment';
18
+ export const CONFIGURATION = 'configuration';
19
+ export const INFRASTRUCTURE = 'infrastructure';
20
+ export const TEST_DATA = 'test-data';
21
+ export const APPLICATION_BUG = 'application-bug';
22
+ export const FRAMEWORK = 'framework-failure';
23
+ export const FLAKY = 'flaky';
24
+ export const UNKNOWN = 'unknown';
25
+
26
+ export const CLASSES = new Set([
27
+ ASSERTION, LOCATOR, TIMEOUT, NETWORK, AUTH, AUTHORIZATION, ENVIRONMENT,
28
+ CONFIGURATION, INFRASTRUCTURE, TEST_DATA, APPLICATION_BUG, FRAMEWORK,
29
+ FLAKY, UNKNOWN,
30
+ ]);
31
+
32
+ // Ordered rules: [classification, pattern, confidence, reason].
33
+ //
34
+ // Order encodes priority, and the first two rules exist because modern runners
35
+ // print a timeout budget in *every* assertion failure. Playwright's message for a
36
+ // plain text mismatch ends with "Timeout: 5000ms" even though nothing timed out,
37
+ // so a naive timeout rule captures assertion and locator failures alike and sends
38
+ // the reader to raise a timeout — the one action guaranteed not to help. The
39
+ // patterns below are derived from real captured runner output.
40
+ //
41
+ // The discriminators, from that output:
42
+ // element missing -> "Error: element(s) not found" (no Received: value)
43
+ // value mismatch -> "Expected: X" + "Received: Y" (element was resolved)
44
+ // real timeout -> a timeout with neither of the above
45
+ const RULES = [
46
+ [LOCATOR, /(no such element|element(?:\(s\))?\s+not\s+(?:found|visible|attached)|locator.*(?:resolved to 0|not found)|waiting for (?:locator|selector)|unable to locate element)/i, 0.8,
47
+ 'Error indicates the target element could not be found or resolved.'],
48
+ // A concrete expected-vs-received comparison is an assertion result, not a
49
+ // time budget: the runner resolved the target and compared values.
50
+ [ASSERTION, /expected:.*received:/is, 0.8,
51
+ 'Error shows a concrete expected-versus-received comparison, so the assertion did not hold.'],
52
+ [TIMEOUT, /(timeout|timed out|exceeded .*ms|deadline exceeded)/i, 0.75,
53
+ 'Error indicates an operation exceeded its time budget.'],
54
+ [AUTH, /(401 unauthorized|authentication failed|invalid (?:credentials|token)|login failed|not authenticated)/i, 0.85,
55
+ 'Error indicates an authentication failure — the identity could not be established.'],
56
+ [AUTHORIZATION, /(403 forbidden|permission denied|access denied|not authorized|forbidden\b|insufficient (?:permission|privilege))/i, 0.85,
57
+ 'Error indicates an authorization failure — the identity lacked permission.'],
58
+ [NETWORK, /(ECONNREFUSED| ENOTFOUND|net::ERR|connection (?:refused|reset)|5\d\d (?:internal server error|bad gateway|service unavailable)|fetch failed)/i, 0.8,
59
+ 'Error indicates a network or upstream service failure.'],
60
+ [ASSERTION, /(assertion|expect(?:ed)?\b|toBe|toEqual|toHaveText|toBeVisible|AssertionError|expected .* (?:to|but))/i, 0.7,
61
+ 'Error indicates an assertion did not hold.'],
62
+ [TEST_DATA, /(duplicate key|constraint violation|no rows|seed data|fixture .*(?:missing|not found)|invalid test data)/i, 0.7,
63
+ 'Error indicates a test-data problem.'],
64
+ [CONFIGURATION, /(cannot find module|config(?:uration)? (?:error|not found)|missing (?:config|environment variable)|unknown option)/i, 0.7,
65
+ 'Error indicates a configuration problem.'],
66
+ [ENVIRONMENT, /(base ?url|env(?:ironment)? .*(?:not set|missing)|ECONNREFUSED .*localhost|dev server)/i, 0.6,
67
+ 'Error indicates an environment problem such as a missing base URL or unreachable local service.'],
68
+ [INFRASTRUCTURE, /(out of memory|OOM|disk (?:full|space)|browser .*(?:crash|closed unexpectedly)|worker .*(?:died|crashed))/i, 0.7,
69
+ 'Error indicates an infrastructure problem such as a crash or resource exhaustion.'],
70
+ [FRAMEWORK, /(internal error|unexpected error in (?:playwright|selenium|cypress|webdriver)|driver .*mismatch)/i, 0.6,
71
+ 'Error indicates a fault in the test framework or driver itself.'],
72
+ ];
73
+
74
+ /**
75
+ * Classify a failure from its error message and optional HTTP status.
76
+ *
77
+ * Returns `{classification, confidence, reason}`. Never guesses: an unrecognized
78
+ * signal yields UNKNOWN at low confidence with an honest reason.
79
+ */
80
+ export function classify(message, httpStatus = null) {
81
+ const text = message || '';
82
+
83
+ // A concrete HTTP status is stronger evidence than message text.
84
+ if (httpStatus !== null && httpStatus !== undefined) {
85
+ if (httpStatus === 401) {
86
+ return { classification: AUTH, confidence: 0.9, reason: 'HTTP 401 indicates an authentication failure.' };
87
+ }
88
+ if (httpStatus === 403) {
89
+ return { classification: AUTHORIZATION, confidence: 0.9, reason: 'HTTP 403 indicates an authorization failure.' };
90
+ }
91
+ if (httpStatus >= 500 && httpStatus <= 599) {
92
+ return {
93
+ classification: NETWORK,
94
+ confidence: 0.85,
95
+ reason: `HTTP ${httpStatus} indicates an upstream server failure.`,
96
+ };
97
+ }
98
+ }
99
+
100
+ for (const [classification, pattern, confidence, reason] of RULES) {
101
+ if (pattern.test(text)) return { classification, confidence, reason };
102
+ }
103
+
104
+ return {
105
+ classification: UNKNOWN,
106
+ confidence: 0.2,
107
+ reason: 'No classification rule matched the available signals; manual review needed.',
108
+ };
109
+ }
@@ -0,0 +1,153 @@
1
+ // A dependency-free XML reader for the subset the pack's artifacts use.
2
+ //
3
+ // Node has no built-in XML parser, and taking one as a dependency is ruled out:
4
+ // this code is bundled into skills that run in other people's repositories, where
5
+ // every dependency is attack surface and an install burden (ADR-0009). So the
6
+ // subset we actually need is parsed here — element tags, attributes, text,
7
+ // nesting, comments, CDATA, and the five predefined entities.
8
+ //
9
+ // Deliberately NOT supported, because no artifact the pack reads uses them:
10
+ // namespaces (a prefixed tag is treated as an opaque name), DTD entity
11
+ // definitions, and processing instructions beyond being skipped. Anything
12
+ // unsupported raises — the parser never guesses its way past a document it does
13
+ // not understand, because a fabricated parse becomes a fabricated test result.
14
+
15
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" };
16
+
17
+ export class XmlError extends Error {}
18
+
19
+ function decode(text) {
20
+ if (!text.includes('&')) return text;
21
+ return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, body) => {
22
+ if (body[0] === '#') {
23
+ const code = body[1] === 'x' || body[1] === 'X'
24
+ ? parseInt(body.slice(2), 16)
25
+ : parseInt(body.slice(1), 10);
26
+ return Number.isFinite(code) && code >= 0 && code <= 0x10ffff
27
+ ? String.fromCodePoint(code)
28
+ : whole;
29
+ }
30
+ return Object.prototype.hasOwnProperty.call(ENTITIES, body) ? ENTITIES[body] : whole;
31
+ });
32
+ }
33
+
34
+ /**
35
+ * Parse an XML document into a node tree.
36
+ *
37
+ * A node is `{ tag, attrs, children, text }`. Text is the concatenation of the
38
+ * element's own character data, matching what `xml.etree` exposes closely enough
39
+ * for the artifacts the pack reads (which never mix text and children
40
+ * meaningfully).
41
+ *
42
+ * @param {string} source
43
+ * @returns {{tag: string, attrs: Record<string,string>, children: object[], text: string}}
44
+ */
45
+ export function parseXml(source) {
46
+ if (typeof source !== 'string') throw new XmlError('XML source must be a string');
47
+
48
+ let cursor = 0;
49
+ const root = { tag: null, attrs: {}, children: [], text: '' };
50
+ const stack = [root];
51
+ const top = () => stack[stack.length - 1];
52
+
53
+ // Reported against the position of the offending markup, not the cursor: when a
54
+ // mismatched close tag is found, the cursor still sits at the end of the last
55
+ // *good* tag, so using it points a reader at the wrong line of a large report.
56
+ const fail = (why, at = cursor) => {
57
+ const line = source.slice(0, at).split('\n').length;
58
+ throw new XmlError(`${why} (line ${line})`);
59
+ };
60
+
61
+ while (cursor < source.length) {
62
+ const open = source.indexOf('<', cursor);
63
+ if (open === -1) {
64
+ top().text += decode(source.slice(cursor));
65
+ break;
66
+ }
67
+ if (open > cursor) top().text += decode(source.slice(cursor, open));
68
+
69
+ if (source.startsWith('<?', open)) {
70
+ const end = source.indexOf('?>', open);
71
+ if (end === -1) fail('unterminated processing instruction', open);
72
+ cursor = end + 2;
73
+ continue;
74
+ }
75
+ if (source.startsWith('<!--', open)) {
76
+ const end = source.indexOf('-->', open);
77
+ if (end === -1) fail('unterminated comment', open);
78
+ cursor = end + 3;
79
+ continue;
80
+ }
81
+ if (source.startsWith('<![CDATA[', open)) {
82
+ const end = source.indexOf(']]>', open);
83
+ if (end === -1) fail('unterminated CDATA section', open);
84
+ // CDATA is literal: no entity decoding.
85
+ top().text += source.slice(open + 9, end);
86
+ cursor = end + 3;
87
+ continue;
88
+ }
89
+ if (source.startsWith('<!', open)) {
90
+ const end = source.indexOf('>', open);
91
+ if (end === -1) fail('unterminated declaration', open);
92
+ cursor = end + 1;
93
+ continue;
94
+ }
95
+
96
+ const close = source.indexOf('>', open);
97
+ if (close === -1) fail('unterminated tag', open);
98
+ const raw = source.slice(open + 1, close);
99
+ if (raw.length === 0) fail('empty tag', open);
100
+
101
+ if (raw[0] === '/') {
102
+ const name = raw.slice(1).trim();
103
+ if (stack.length === 1) fail(`closing tag </${name}> with no open element`, open);
104
+ const node = stack.pop();
105
+ if (node.tag !== name) fail(`expected </${node.tag}> but found </${name}>`, open);
106
+ cursor = close + 1;
107
+ continue;
108
+ }
109
+
110
+ const selfClosing = raw.endsWith('/');
111
+ const body = selfClosing ? raw.slice(0, -1) : raw;
112
+ const firstSpace = body.search(/\s/);
113
+ const tag = (firstSpace === -1 ? body : body.slice(0, firstSpace)).trim();
114
+ if (!tag) fail('empty tag name', open);
115
+
116
+ const attrs = {};
117
+ if (firstSpace !== -1) {
118
+ const attrSource = body.slice(firstSpace);
119
+ // Quoted values only. An unquoted attribute value is not valid XML, and
120
+ // accepting one would mean guessing where it ends.
121
+ const pattern = /([^\s=/]+)\s*=\s*("([^"]*)"|'([^']*)')/g;
122
+ let match;
123
+ let consumed = 0;
124
+ while ((match = pattern.exec(attrSource)) !== null) {
125
+ attrs[match[1]] = decode(match[3] ?? match[4] ?? '');
126
+ consumed = match.index + match[0].length;
127
+ }
128
+ if (attrSource.slice(consumed).trim().length > 0) {
129
+ fail(`unparsable attributes in <${tag}>: ${attrSource.slice(consumed).trim()}`, open);
130
+ }
131
+ }
132
+
133
+ const node = { tag, attrs, children: [], text: '' };
134
+ top().children.push(node);
135
+ if (!selfClosing) stack.push(node);
136
+ cursor = close + 1;
137
+ }
138
+
139
+ if (stack.length !== 1) fail(`unclosed element <${top().tag}>`);
140
+ if (root.children.length === 0) throw new XmlError('document has no root element');
141
+ if (root.children.length > 1) throw new XmlError('document must have exactly one root element');
142
+ return root.children[0];
143
+ }
144
+
145
+ /** First child element with this tag, or null — `Element.find` in xml.etree. */
146
+ export function find(node, tag) {
147
+ return node.children.find((child) => child.tag === tag) ?? null;
148
+ }
149
+
150
+ /** Every child element with this tag — `Element.findall` in xml.etree. */
151
+ export function findAll(node, tag) {
152
+ return node.children.filter((child) => child.tag === tag);
153
+ }
@@ -0,0 +1,107 @@
1
+ // A dependency-free ZIP reader for the entries the pack needs to read.
2
+ //
3
+ // Node has no archive API, and a dependency is ruled out for code that runs in
4
+ // other people's repositories (ADR-0009). Playwright writes its trace as a zip of
5
+ // newline-delimited JSON, so the central directory is walked here and each entry
6
+ // inflated with the built-in `zlib`.
7
+ //
8
+ // Supported: stored (method 0) and deflated (method 8) entries — the only two a
9
+ // zip writer produces in practice, and the only two Playwright emits. Anything
10
+ // else raises by name rather than returning empty bytes, because a silently empty
11
+ // trace reads as "nothing happened" instead of "could not be read".
12
+
13
+ import fs from 'node:fs';
14
+ import zlib from 'node:zlib';
15
+
16
+ const EOCD_SIGNATURE = 0x06054b50;
17
+ const CENTRAL_SIGNATURE = 0x02014b50;
18
+ const MAX_COMMENT = 0xffff;
19
+
20
+ export class ZipError extends Error {}
21
+
22
+ /**
23
+ * Every entry in the archive: `[{name, method, compressedSize, size, offset}]`.
24
+ *
25
+ * Read from the central directory, which is authoritative — the per-entry local
26
+ * headers may carry zeroed sizes when the writer streamed the file.
27
+ */
28
+ export function listEntries(buffer) {
29
+ const eocd = findEndOfCentralDirectory(buffer);
30
+ const count = buffer.readUInt16LE(eocd + 10);
31
+ let cursor = buffer.readUInt32LE(eocd + 16);
32
+ const entries = [];
33
+
34
+ for (let index = 0; index < count; index += 1) {
35
+ if (cursor + 46 > buffer.length || buffer.readUInt32LE(cursor) !== CENTRAL_SIGNATURE) {
36
+ throw new ZipError(`central directory entry ${index} is malformed`);
37
+ }
38
+ const method = buffer.readUInt16LE(cursor + 10);
39
+ const compressedSize = buffer.readUInt32LE(cursor + 20);
40
+ const size = buffer.readUInt32LE(cursor + 24);
41
+ const nameLength = buffer.readUInt16LE(cursor + 28);
42
+ const extraLength = buffer.readUInt16LE(cursor + 30);
43
+ const commentLength = buffer.readUInt16LE(cursor + 32);
44
+ const offset = buffer.readUInt32LE(cursor + 42);
45
+ const name = buffer.subarray(cursor + 46, cursor + 46 + nameLength).toString('utf8');
46
+ entries.push({ name, method, compressedSize, size, offset });
47
+ cursor += 46 + nameLength + extraLength + commentLength;
48
+ }
49
+ return entries;
50
+ }
51
+
52
+ /** The bytes of one entry, inflated if it was deflated. */
53
+ export function readEntry(buffer, entry) {
54
+ if (entry.offset + 30 > buffer.length) {
55
+ throw new ZipError(`entry ${entry.name} points past the end of the archive`);
56
+ }
57
+ const nameLength = buffer.readUInt16LE(entry.offset + 26);
58
+ const extraLength = buffer.readUInt16LE(entry.offset + 28);
59
+ const start = entry.offset + 30 + nameLength + extraLength;
60
+ const raw = buffer.subarray(start, start + entry.compressedSize);
61
+
62
+ if (entry.method === 0) return raw;
63
+ if (entry.method === 8) {
64
+ try {
65
+ return zlib.inflateRawSync(raw);
66
+ } catch (error) {
67
+ throw new ZipError(`entry ${entry.name} could not be inflated: ${error.message}`);
68
+ }
69
+ }
70
+ throw new ZipError(`entry ${entry.name} uses unsupported compression method ${entry.method}`);
71
+ }
72
+
73
+ /** Is this a readable ZIP archive? Locates the end-of-central-directory record. */
74
+ export function isZip(buffer) {
75
+ try {
76
+ findEndOfCentralDirectory(buffer);
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ /** Open a zip file and hand back `{entries, read}`. */
84
+ export function openZip(path) {
85
+ let buffer;
86
+ try {
87
+ buffer = fs.readFileSync(path);
88
+ } catch (error) {
89
+ throw new ZipError(`could not read ${path}: ${error.message}`);
90
+ }
91
+ const entries = listEntries(buffer);
92
+ return { entries, read: (entry) => readEntry(buffer, entry) };
93
+ }
94
+
95
+ /**
96
+ * The record is last in the file, possibly followed by a comment, so the tail is
97
+ * scanned backwards. Trusting the first four bytes instead would accept a
98
+ * truncated archive whose header still reads `PK\x03\x04`.
99
+ */
100
+ function findEndOfCentralDirectory(buffer) {
101
+ if (buffer.length < 22) throw new ZipError('too short to be a zip archive');
102
+ const earliest = Math.max(0, buffer.length - 22 - MAX_COMMENT);
103
+ for (let cursor = buffer.length - 22; cursor >= earliest; cursor -= 1) {
104
+ if (buffer.readUInt32LE(cursor) === EOCD_SIGNATURE) return cursor;
105
+ }
106
+ throw new ZipError('no end-of-central-directory record: not a zip archive');
107
+ }