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.
- package/COMPATIBILITY.md +26 -12
- package/README.md +182 -84
- package/package.json +11 -20
- package/packages/engine/bin/qa-engine.mjs +560 -0
- package/packages/engine/lib/analysis/branding.mjs +187 -0
- package/packages/engine/lib/analysis/context.mjs +294 -0
- package/packages/engine/lib/analysis/contracts.mjs +149 -0
- package/packages/engine/lib/analysis/diff-guard.mjs +425 -0
- package/packages/engine/lib/analysis/discovery.mjs +220 -0
- package/packages/engine/lib/analysis/evidence.mjs +116 -0
- package/packages/engine/lib/analysis/har.mjs +124 -0
- package/packages/engine/lib/analysis/junit.mjs +126 -0
- package/packages/engine/lib/analysis/network.mjs +237 -0
- package/packages/engine/lib/analysis/redaction.mjs +127 -0
- package/packages/engine/lib/analysis/report-html.mjs +76 -0
- package/packages/engine/lib/analysis/taxonomy.mjs +109 -0
- package/packages/engine/lib/analysis/xml.mjs +153 -0
- package/packages/engine/lib/analysis/zip.mjs +107 -0
- package/packages/engine/lib/artifacts/manager.mjs +453 -0
- package/packages/engine/lib/artifacts/mime.mjs +109 -0
- package/packages/engine/lib/artifacts/zip-write.mjs +143 -0
- package/packages/engine/lib/diagnostics/engine.mjs +165 -0
- package/packages/engine/lib/diagnostics/internal-contracts.mjs +50 -0
- package/packages/engine/lib/diagnostics/prioritization.mjs +99 -0
- package/packages/engine/lib/diagnostics/repair.mjs +73 -0
- package/packages/engine/lib/diagnostics/root-cause.mjs +92 -0
- package/packages/engine/lib/diagnostics/timeline.mjs +101 -0
- package/packages/engine/lib/frameworks/junit-frameworks.mjs +48 -0
- package/packages/engine/lib/frameworks/playwright.mjs +158 -0
- package/packages/engine/lib/report/components/charts.mjs +424 -0
- package/packages/engine/lib/report/components/evidence.mjs +207 -0
- package/packages/engine/lib/report/components/findings.mjs +258 -0
- package/packages/engine/lib/report/components/nav.mjs +99 -0
- package/packages/engine/lib/report/components/primitives.mjs +246 -0
- package/packages/engine/lib/report/components/runtime.mjs +246 -0
- package/packages/engine/lib/report/components/timeline.mjs +65 -0
- package/packages/engine/lib/report/core/model.mjs +270 -0
- package/packages/engine/lib/report/core/normalize.mjs +226 -0
- package/packages/engine/lib/report/core/sections.mjs +978 -0
- package/packages/engine/lib/report/export/bundle.mjs +293 -0
- package/packages/engine/lib/report/export/html.mjs +183 -0
- package/packages/engine/lib/report/export/machine.mjs +290 -0
- package/packages/engine/lib/report/export/markdown.mjs +323 -0
- package/packages/engine/lib/report/schemas/qa-report.schema.json +555 -0
- package/packages/engine/lib/report/theme/css.mjs +529 -0
- package/packages/engine/lib/report/theme/tokens.mjs +137 -0
- package/packages/engine/lib/report/version.mjs +78 -0
- package/packages/engine/package.json +14 -0
- package/packages/installer/lib/agents/targets.mjs +90 -0
- package/packages/installer/lib/agents/user-level.mjs +80 -0
- package/packages/installer/lib/cli/flags.mjs +20 -2
- package/packages/installer/lib/commands/doctor.mjs +13 -20
- package/packages/installer/lib/commands/install.mjs +160 -91
- package/packages/installer/lib/commands/repair.mjs +10 -6
- package/packages/installer/lib/commands/self-test.mjs +4 -3
- package/packages/installer/lib/commands/uninstall.mjs +14 -8
- package/packages/installer/lib/commands/update.mjs +9 -4
- package/packages/installer/lib/commands/verify.mjs +13 -12
- package/packages/installer/lib/constants.mjs +13 -0
- package/packages/installer/lib/core/bundle.mjs +69 -92
- package/packages/installer/lib/core/conflict.mjs +5 -4
- package/packages/installer/lib/core/fs-safe.mjs +146 -6
- package/packages/installer/lib/core/integrity.mjs +59 -0
- package/packages/installer/lib/core/lockfile.mjs +19 -3
- package/packages/installer/lib/core/manifest.mjs +48 -57
- package/packages/installer/lib/core/plan.mjs +213 -0
- package/packages/installer/lib/core/qa-home.mjs +145 -0
- package/packages/installer/lib/core/scope.mjs +274 -0
- package/packages/installer/lib/core/validate-install.mjs +49 -31
- package/packages/installer/package.json +1 -1
- package/packages/installer/schemas/qa-lock.schema.json +119 -21
- package/shared/tooling/qa-tool.mjs +161 -0
- package/skills/qa-api/SKILL.md +5 -5
- package/skills/qa-api/references/deterministic-tooling.md +34 -32
- package/skills/qa-api/references/evidence-and-reporting.md +5 -5
- package/skills/qa-api/scripts/qa-tool.mjs +162 -0
- package/skills/qa-audit/SKILL.md +5 -5
- package/skills/qa-audit/references/deterministic-tooling.md +34 -32
- package/skills/qa-audit/references/evidence-and-reporting.md +5 -5
- package/skills/qa-audit/scripts/qa-tool.mjs +162 -0
- package/skills/qa-debug/SKILL.md +6 -6
- package/skills/qa-debug/references/deterministic-tooling.md +34 -32
- package/skills/qa-debug/references/diagnostic-engine.md +3 -3
- package/skills/qa-debug/references/evidence-and-reporting.md +5 -5
- package/skills/qa-debug/scripts/qa-tool.mjs +162 -0
- package/skills/qa-explore/SKILL.md +31 -15
- package/skills/qa-explore/contracts/explore-result.schema.json +517 -11
- package/skills/qa-explore/references/api-replay.md +40 -1
- package/skills/qa-explore/references/deterministic-tooling.md +34 -32
- package/skills/qa-explore/references/evidence-and-reporting.md +5 -5
- package/skills/qa-explore/references/report-pipeline.md +266 -96
- package/skills/qa-explore/scripts/qa-tool.mjs +162 -0
- package/skills/qa-fix/SKILL.md +4 -4
- package/skills/qa-fix/references/deterministic-tooling.md +34 -32
- package/skills/qa-fix/references/diagnostic-engine.md +3 -3
- package/skills/qa-fix/references/evidence-and-reporting.md +5 -5
- package/skills/qa-fix/scripts/qa-tool.mjs +162 -0
- package/skills/qa-flaky/SKILL.md +4 -4
- package/skills/qa-flaky/references/deterministic-tooling.md +34 -32
- package/skills/qa-flaky/references/evidence-and-reporting.md +5 -5
- package/skills/qa-flaky/scripts/qa-tool.mjs +162 -0
- package/skills/qa-generate/references/evidence-and-reporting.md +5 -5
- package/skills/qa-init/SKILL.md +4 -4
- package/skills/qa-init/references/deterministic-tooling.md +34 -32
- package/skills/qa-init/references/evidence-and-reporting.md +5 -5
- package/skills/qa-init/scripts/qa-tool.mjs +162 -0
- package/skills/qa-report/SKILL.md +7 -7
- package/skills/qa-report/references/deterministic-tooling.md +34 -32
- package/skills/qa-report/references/diagnostic-engine.md +3 -3
- package/skills/qa-report/references/evidence-and-reporting.md +5 -5
- package/skills/qa-report/scripts/qa-tool.mjs +162 -0
- package/skills/qa-review/references/evidence-and-reporting.md +5 -5
- package/skills/qa-run/SKILL.md +6 -6
- package/skills/qa-run/references/deterministic-tooling.md +34 -32
- package/skills/qa-run/references/evidence-and-reporting.md +5 -5
- package/skills/qa-run/scripts/qa-tool.mjs +162 -0
- package/shared/analysis/lib/qa_analysis/__init__.py +0 -11
- package/shared/analysis/lib/qa_analysis/branding.py +0 -175
- package/shared/analysis/lib/qa_analysis/cli.py +0 -144
- package/shared/analysis/lib/qa_analysis/context.py +0 -233
- package/shared/analysis/lib/qa_analysis/contracts.py +0 -158
- package/shared/analysis/lib/qa_analysis/diff_guard.py +0 -327
- package/shared/analysis/lib/qa_analysis/discovery.py +0 -113
- package/shared/analysis/lib/qa_analysis/evidence.py +0 -128
- package/shared/analysis/lib/qa_analysis/har.py +0 -87
- package/shared/analysis/lib/qa_analysis/junit.py +0 -104
- package/shared/analysis/lib/qa_analysis/redaction.py +0 -107
- package/shared/analysis/lib/qa_analysis/report_html.py +0 -781
- package/shared/analysis/lib/qa_analysis/taxonomy.py +0 -98
- package/shared/diagnostics/lib/qa_diagnostics/__init__.py +0 -13
- package/shared/diagnostics/lib/qa_diagnostics/cli.py +0 -124
- package/shared/diagnostics/lib/qa_diagnostics/engine.py +0 -143
- package/shared/diagnostics/lib/qa_diagnostics/internal_contracts.py +0 -75
- package/shared/diagnostics/lib/qa_diagnostics/prioritization.py +0 -101
- package/shared/diagnostics/lib/qa_diagnostics/repair.py +0 -72
- package/shared/diagnostics/lib/qa_diagnostics/root_cause.py +0 -89
- package/shared/diagnostics/lib/qa_diagnostics/timeline.py +0 -71
- package/shared/frameworks/cypress/lib/cypress_analysis.py +0 -27
- package/shared/frameworks/playwright/lib/playwright_analysis.py +0 -167
- package/shared/frameworks/selenium/lib/selenium_analysis.py +0 -28
- package/shared/frameworks/webdriverio/lib/webdriverio_analysis.py +0 -24
- package/shared/tooling/qa_tool.py +0 -127
- /package/{shared/analysis/lib/qa_analysis → packages/engine/lib/analysis}/branding.json +0 -0
- /package/{shared → packages/engine/lib}/analysis/schemas/context.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/analysis-result.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/diagnosis.schema.json +0 -0
- /package/{shared → packages/engine/lib}/diagnostics/schemas/internal/execution-result-min.schema.json +0 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// The evidence and finding model shared by every analyzer.
|
|
2
|
+
//
|
|
3
|
+
// Every finding an analyzer produces carries the same structure, so downstream
|
|
4
|
+
// skills (qa-debug, qa-report, qa-fix) consume one shape regardless of which
|
|
5
|
+
// analyzer or framework produced it. Text fields are redacted at construction.
|
|
6
|
+
//
|
|
7
|
+
// Ported from qa_analysis/evidence.py. Python used dataclasses with a `to_dict()`;
|
|
8
|
+
// here each factory returns the serialized shape directly, because that shape —
|
|
9
|
+
// not the object — is what every consumer actually uses, and one representation
|
|
10
|
+
// cannot drift from the other.
|
|
11
|
+
|
|
12
|
+
import { redactText } from './redaction.mjs';
|
|
13
|
+
|
|
14
|
+
export const EVIDENCE_TYPES = new Set([
|
|
15
|
+
'trace', 'har', 'junit', 'report', 'console', 'network', 'stdout',
|
|
16
|
+
'stderr', 'screenshot', 'video', 'log', 'file', 'diff',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/** ISO 8601 UTC timestamp. Isolated so tests can substitute it. */
|
|
20
|
+
export function utcNow() {
|
|
21
|
+
return new Date().toISOString();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One observation supporting a finding. Excerpts are redacted here, at
|
|
26
|
+
* construction, so no caller can forget to do it.
|
|
27
|
+
*/
|
|
28
|
+
export function evidence({ type, description, source, excerpt = '' }) {
|
|
29
|
+
if (!EVIDENCE_TYPES.has(type)) {
|
|
30
|
+
throw new Error(`unknown evidence type: ${type}`);
|
|
31
|
+
}
|
|
32
|
+
const redacted = redactText(excerpt);
|
|
33
|
+
const entry = { type, description, source };
|
|
34
|
+
if (redacted) entry.excerpt = redacted;
|
|
35
|
+
return entry;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A single diagnostic conclusion, traceable to a specific artifact.
|
|
40
|
+
*
|
|
41
|
+
* Carries everything the evidence model requires: the artifact and location it
|
|
42
|
+
* came from, when, why, the supporting evidence, a calibrated confidence, the
|
|
43
|
+
* affected tests, related artifacts, and recommended actions.
|
|
44
|
+
*/
|
|
45
|
+
export function finding({
|
|
46
|
+
classification,
|
|
47
|
+
reason,
|
|
48
|
+
artifact: artifactPath,
|
|
49
|
+
location,
|
|
50
|
+
confidence = null,
|
|
51
|
+
timestamp = undefined,
|
|
52
|
+
evidence: items = [],
|
|
53
|
+
affectedTests = [],
|
|
54
|
+
relatedArtifacts = [],
|
|
55
|
+
recommendations = [],
|
|
56
|
+
}) {
|
|
57
|
+
const result = {
|
|
58
|
+
classification,
|
|
59
|
+
reason,
|
|
60
|
+
artifact: artifactPath,
|
|
61
|
+
location,
|
|
62
|
+
timestamp: timestamp ?? utcNow(),
|
|
63
|
+
evidence: items,
|
|
64
|
+
affectedTests,
|
|
65
|
+
relatedArtifacts,
|
|
66
|
+
recommendations,
|
|
67
|
+
};
|
|
68
|
+
if (confidence !== null && confidence !== undefined) result.confidence = confidence;
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The envelope an analyzer emits: findings plus the artifacts it examined.
|
|
74
|
+
*
|
|
75
|
+
* A downstream skill wraps this in its own output contract; on its own it is the
|
|
76
|
+
* deterministic, machine-readable result of one analysis.
|
|
77
|
+
*/
|
|
78
|
+
export function analyzerOutput({
|
|
79
|
+
analyzer,
|
|
80
|
+
findings = [],
|
|
81
|
+
artifacts = [],
|
|
82
|
+
warnings = [],
|
|
83
|
+
generatedAt = undefined,
|
|
84
|
+
}) {
|
|
85
|
+
return {
|
|
86
|
+
analyzer,
|
|
87
|
+
generatedAt: generatedAt ?? utcNow(),
|
|
88
|
+
findings,
|
|
89
|
+
artifacts,
|
|
90
|
+
warnings,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A discovered artifact, in the common model shared with the execution engine. */
|
|
95
|
+
export function artifact({
|
|
96
|
+
type,
|
|
97
|
+
location,
|
|
98
|
+
framework = 'unknown',
|
|
99
|
+
ownership = 'qa-analysis',
|
|
100
|
+
timestamp = undefined,
|
|
101
|
+
mediaType = '',
|
|
102
|
+
testRef = '',
|
|
103
|
+
present = true,
|
|
104
|
+
}) {
|
|
105
|
+
const entry = {
|
|
106
|
+
type,
|
|
107
|
+
location,
|
|
108
|
+
framework,
|
|
109
|
+
timestamp: timestamp ?? utcNow(),
|
|
110
|
+
ownership,
|
|
111
|
+
present,
|
|
112
|
+
};
|
|
113
|
+
if (mediaType) entry.mediaType = mediaType;
|
|
114
|
+
if (testRef) entry.testRef = testRef;
|
|
115
|
+
return entry;
|
|
116
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// HAR (HTTP Archive) parser.
|
|
2
|
+
//
|
|
3
|
+
// Framework-agnostic: a HAR is a standard JSON format, whoever produced it.
|
|
4
|
+
// Extracts request outcomes, flags failures and slow calls, and redacts headers
|
|
5
|
+
// and credentialed URLs before anything is exposed. A malformed HAR raises.
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
|
|
9
|
+
import { redactText, redactHeaders } from './redaction.mjs';
|
|
10
|
+
import { MalformedArtifact } from './junit.mjs';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse HAR JSON into a redacted network summary.
|
|
14
|
+
*
|
|
15
|
+
* `{entries, failures, slow, redacted: true}`, where each entry is
|
|
16
|
+
* `{method, url, status, durationMs, requestHeaders, responseHeaders}`. Failures
|
|
17
|
+
* are entries with status >= 400 or status 0 (no response at all).
|
|
18
|
+
*/
|
|
19
|
+
export function parseHarData(data, { slowMs = 1000, label = '<input>' } = {}) {
|
|
20
|
+
const rawEntries = data?.log?.entries;
|
|
21
|
+
if (!Array.isArray(rawEntries)) {
|
|
22
|
+
throw new MalformedArtifact(`not a HAR document at ${label}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const entries = rawEntries.map((item) => {
|
|
26
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item)) {
|
|
27
|
+
throw new MalformedArtifact(`HAR log.entries contains a non-object at ${label}`);
|
|
28
|
+
}
|
|
29
|
+
const request = item.request ?? {};
|
|
30
|
+
const response = item.response ?? {};
|
|
31
|
+
return {
|
|
32
|
+
method: request.method ?? '',
|
|
33
|
+
// Redaction strips any credentials embedded in the URL.
|
|
34
|
+
url: redactText(request.url ?? ''),
|
|
35
|
+
status: toInt(response.status),
|
|
36
|
+
durationMs: toMillis(item?.time, label),
|
|
37
|
+
requestHeaders: redactHeaders(request.headers ?? []),
|
|
38
|
+
responseHeaders: redactHeaders(response.headers ?? []),
|
|
39
|
+
// Three facts a report needs that a summary of failures cannot supply: how big
|
|
40
|
+
// the response was, when it started relative to the others, and whether the
|
|
41
|
+
// server said anything about caching. All three are read straight from the HAR
|
|
42
|
+
// — none is inferred — and all three are absent from many HARs, which is why
|
|
43
|
+
// each has an explicit "unknown" rather than a plausible zero.
|
|
44
|
+
bytes: bodyBytes(response),
|
|
45
|
+
startedAt: typeof item.startedDateTime === 'string' ? item.startedDateTime : null,
|
|
46
|
+
cacheControl: headerValue(response.headers, 'cache-control'),
|
|
47
|
+
etag: headerValue(response.headers, 'etag'),
|
|
48
|
+
resourceType: typeof item._resourceType === 'string' ? item._resourceType : null,
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
entries,
|
|
54
|
+
failures: entries.filter((e) => e.status === 0 || e.status >= 400),
|
|
55
|
+
slow: entries.filter((e) => e.durationMs >= slowMs),
|
|
56
|
+
redacted: true,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read and parse a HAR file from disk. */
|
|
61
|
+
export function parseHar(path, { slowMs = 1000 } = {}) {
|
|
62
|
+
let data;
|
|
63
|
+
try {
|
|
64
|
+
data = JSON.parse(fs.readFileSync(path, 'utf8'));
|
|
65
|
+
} catch (error) {
|
|
66
|
+
throw new MalformedArtifact(`could not parse HAR at ${path}: ${error.message}`);
|
|
67
|
+
}
|
|
68
|
+
return parseHarData(data, { slowMs, label: path });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Response body size in bytes, or null when the HAR does not say.
|
|
73
|
+
*
|
|
74
|
+
* `content.size` is the decoded size and `bodySize` the bytes on the wire; either is
|
|
75
|
+
* `-1` when the producer did not record it. Null rather than 0, because "we do not
|
|
76
|
+
* know how big this was" and "this response was empty" are different findings and a
|
|
77
|
+
* report that conflates them invents a fact.
|
|
78
|
+
*/
|
|
79
|
+
function bodyBytes(response) {
|
|
80
|
+
for (const candidate of [response?.content?.size, response?.bodySize]) {
|
|
81
|
+
const number = Number(candidate);
|
|
82
|
+
if (Number.isFinite(number) && number >= 0) return Math.trunc(number);
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A header's value, case-insensitively, or null. */
|
|
88
|
+
function headerValue(headers, name) {
|
|
89
|
+
if (!Array.isArray(headers)) return null;
|
|
90
|
+
const wanted = name.toLowerCase();
|
|
91
|
+
const found = headers.find((header) => String(header?.name ?? '').toLowerCase() === wanted);
|
|
92
|
+
return found ? String(found.value ?? '') : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A status that is absent or unreadable is 0 — "no response", which is a failure. */
|
|
96
|
+
function toInt(value) {
|
|
97
|
+
const number = Number(value);
|
|
98
|
+
return Number.isFinite(number) ? Math.trunc(number) : 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* HAR `time` is milliseconds already, rounded to a whole number.
|
|
103
|
+
*
|
|
104
|
+
* A value that is present but not a number raises rather than becoming 0: the
|
|
105
|
+
* same rule as the JUnit parser, and for the same reason — a fabricated duration
|
|
106
|
+
* makes a malformed document look like a clean measurement.
|
|
107
|
+
*/
|
|
108
|
+
function toMillis(raw, label) {
|
|
109
|
+
if (raw === undefined || raw === null || raw === '') return 0;
|
|
110
|
+
const number = Number(raw);
|
|
111
|
+
if (!Number.isFinite(number)) {
|
|
112
|
+
throw new MalformedArtifact(`entry time='${raw}' is not a number at ${label}`);
|
|
113
|
+
}
|
|
114
|
+
return roundHalfToEven(number);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Python's rounding rule, so a `.5` boundary lands on the same integer. */
|
|
118
|
+
function roundHalfToEven(value) {
|
|
119
|
+
const floor = Math.floor(value);
|
|
120
|
+
const remainder = value - floor;
|
|
121
|
+
if (remainder > 0.5) return floor + 1;
|
|
122
|
+
if (remainder < 0.5) return floor;
|
|
123
|
+
return floor % 2 === 0 ? floor : floor + 1;
|
|
124
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// JUnit XML parser.
|
|
2
|
+
//
|
|
3
|
+
// Framework-agnostic: Playwright, Selenium, Cypress, WebdriverIO, and most unit
|
|
4
|
+
// runners emit JUnit XML, so this one parser normalizes them all into the pack's
|
|
5
|
+
// per-test result shape. This is the concrete proof that different frameworks
|
|
6
|
+
// share one contract — only where the file lives differs, not how it is read.
|
|
7
|
+
//
|
|
8
|
+
// Parses deterministically; a malformed document raises rather than guessing.
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
|
|
12
|
+
import { parseXml, find, findAll, XmlError } from './xml.mjs';
|
|
13
|
+
import { redactText } from './redaction.mjs';
|
|
14
|
+
|
|
15
|
+
/** Raised when an artifact cannot be parsed. Never swallowed into a guess. */
|
|
16
|
+
export class MalformedArtifact extends Error {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse JUnit XML text into a normalized result.
|
|
20
|
+
*
|
|
21
|
+
* `{tests: {total, passed, failed, skipped}, executed: [...]}`, where each
|
|
22
|
+
* executed entry is `{title, file, status, durationMs, message?}`.
|
|
23
|
+
*/
|
|
24
|
+
export function parseJUnitText(source, label = '<input>') {
|
|
25
|
+
let root;
|
|
26
|
+
try {
|
|
27
|
+
root = parseXml(source);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error instanceof XmlError) {
|
|
30
|
+
throw new MalformedArtifact(`could not parse JUnit XML at ${label}: ${error.message}`);
|
|
31
|
+
}
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Accept either a <testsuites> root or a single <testsuite> root.
|
|
36
|
+
let suites;
|
|
37
|
+
if (root.tag === 'testsuites') suites = findAll(root, 'testsuite');
|
|
38
|
+
else if (root.tag === 'testsuite') suites = [root];
|
|
39
|
+
else throw new MalformedArtifact(`not a JUnit document (root <${root.tag}>) at ${label}`);
|
|
40
|
+
|
|
41
|
+
const executed = [];
|
|
42
|
+
for (const suite of suites) {
|
|
43
|
+
for (const testcase of findAll(suite, 'testcase')) {
|
|
44
|
+
const failure = find(testcase, 'failure');
|
|
45
|
+
const error = find(testcase, 'error');
|
|
46
|
+
const skipped = find(testcase, 'skipped');
|
|
47
|
+
|
|
48
|
+
let status;
|
|
49
|
+
let message = '';
|
|
50
|
+
if (failure !== null || error !== null) {
|
|
51
|
+
status = 'failed';
|
|
52
|
+
const node = failure !== null ? failure : error;
|
|
53
|
+
message = redactText((node.attrs.message || node.text || '').trim());
|
|
54
|
+
} else if (skipped !== null) {
|
|
55
|
+
status = 'skipped';
|
|
56
|
+
message = redactText((skipped.attrs.message || '').trim());
|
|
57
|
+
} else {
|
|
58
|
+
status = 'passed';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const entry = {
|
|
62
|
+
title: testcase.attrs.name ?? '',
|
|
63
|
+
file: testcase.attrs.classname ?? '',
|
|
64
|
+
status,
|
|
65
|
+
durationMs: durationMs(testcase.attrs.time, label),
|
|
66
|
+
};
|
|
67
|
+
if (message) entry.message = message;
|
|
68
|
+
executed.push(entry);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
tests: {
|
|
74
|
+
total: executed.length,
|
|
75
|
+
passed: executed.filter((e) => e.status === 'passed').length,
|
|
76
|
+
failed: executed.filter((e) => e.status === 'failed').length,
|
|
77
|
+
skipped: executed.filter((e) => e.status === 'skipped').length,
|
|
78
|
+
},
|
|
79
|
+
executed,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `time` in seconds becomes whole milliseconds.
|
|
85
|
+
*
|
|
86
|
+
* Two subtleties, both pinned by the parity corpus:
|
|
87
|
+
*
|
|
88
|
+
* 1. An absent or empty attribute is zero; a value that is present but not a
|
|
89
|
+
* finite number raises. Guessing zero there would turn a malformed document
|
|
90
|
+
* into a plausible result, which is the one thing this parser must not do.
|
|
91
|
+
* 2. Python's `round()` rounds half to *even* and JavaScript's `Math.round`
|
|
92
|
+
* rounds half *up*, so `time="0.0005"` gives 0 in Python and 1 here unless
|
|
93
|
+
* the tie is broken the same way. Runners do emit such values.
|
|
94
|
+
*/
|
|
95
|
+
function durationMs(raw, label) {
|
|
96
|
+
if (raw === undefined || raw === null || raw === '') return 0;
|
|
97
|
+
// Reject what parseFloat would happily truncate ("1.2.3", "5s", "0x10").
|
|
98
|
+
if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(String(raw).trim())) {
|
|
99
|
+
throw new MalformedArtifact(`testcase time='${raw}' is not a number at ${label}`);
|
|
100
|
+
}
|
|
101
|
+
const seconds = Number(raw);
|
|
102
|
+
if (!Number.isFinite(seconds)) {
|
|
103
|
+
throw new MalformedArtifact(`testcase time='${raw}' is not a finite number at ${label}`);
|
|
104
|
+
}
|
|
105
|
+
return roundHalfToEven(seconds * 1000);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Python's rounding rule, so a `.5` boundary lands on the same integer. */
|
|
109
|
+
function roundHalfToEven(value) {
|
|
110
|
+
const floor = Math.floor(value);
|
|
111
|
+
const remainder = value - floor;
|
|
112
|
+
if (remainder > 0.5) return floor + 1;
|
|
113
|
+
if (remainder < 0.5) return floor;
|
|
114
|
+
return floor % 2 === 0 ? floor : floor + 1;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Read and parse a JUnit XML file from disk. */
|
|
118
|
+
export function parseJUnit(path) {
|
|
119
|
+
let source;
|
|
120
|
+
try {
|
|
121
|
+
source = fs.readFileSync(path, 'utf8');
|
|
122
|
+
} catch (error) {
|
|
123
|
+
throw new MalformedArtifact(`could not parse JUnit XML at ${path}: ${error.message}`);
|
|
124
|
+
}
|
|
125
|
+
return parseJUnitText(source, path);
|
|
126
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Turning a HAR into the report's `network` block.
|
|
2
|
+
//
|
|
3
|
+
// ## Why this exists
|
|
4
|
+
//
|
|
5
|
+
// `qa-explore` claimed an API dimension and backed it with forty-eight lines of prose
|
|
6
|
+
// telling the model what to look for. The model then counted requests by eye, decided
|
|
7
|
+
// which were slow, and judged which were duplicates — three things a model is bad at
|
|
8
|
+
// and a parser is perfect at. The pack's founding rule is that deterministic code owns
|
|
9
|
+
// facts and the model owns explanation; for this dimension the model owned both.
|
|
10
|
+
//
|
|
11
|
+
// So the counting moves here. What comes out is the `network` block of the report
|
|
12
|
+
// contract, ready to validate: totals, per-endpoint timings and sizes, and a flag on
|
|
13
|
+
// each endpoint saying what is wrong with it. The model's job becomes what it is
|
|
14
|
+
// actually good at — deciding whether a duplicate request *matters* to this product,
|
|
15
|
+
// and writing the finding.
|
|
16
|
+
//
|
|
17
|
+
// ## Facts, and inferences that are labelled as such
|
|
18
|
+
//
|
|
19
|
+
// Two different kinds of claim come out of a HAR, and conflating them is how a report
|
|
20
|
+
// starts asserting things it cannot support:
|
|
21
|
+
//
|
|
22
|
+
// fact count, status, duration, byte size, start offset, header presence.
|
|
23
|
+
// Read directly. If the HAR does not record it, the answer is null, never
|
|
24
|
+
// a plausible zero.
|
|
25
|
+
// inference duplicate, polling, n+1, uncached. Derived by a stated rule, over a
|
|
26
|
+
// stated threshold, and every one is reported with the evidence that
|
|
27
|
+
// triggered it so a reader can disagree.
|
|
28
|
+
//
|
|
29
|
+
// Nothing here decides severity. A duplicated analytics beacon and a duplicated payment
|
|
30
|
+
// request are the same shape in a HAR and wildly different findings, and only something
|
|
31
|
+
// that understands the product can tell them apart.
|
|
32
|
+
|
|
33
|
+
import { parseHar, parseHarData } from './har.mjs';
|
|
34
|
+
|
|
35
|
+
/** Defaults, all overridable, all reported back in the output so a reader sees them. */
|
|
36
|
+
export const DEFAULTS = Object.freeze({
|
|
37
|
+
slowMs: 1000,
|
|
38
|
+
// 512 KB. Below this a payload is rarely the thing worth reporting; above it, on a
|
|
39
|
+
// mobile connection, it is.
|
|
40
|
+
largeBytes: 512 * 1024,
|
|
41
|
+
// Three or more identical requests at a regular cadence reads as polling rather than
|
|
42
|
+
// as a burst of duplicates. Two is a double-submit; ten evenly spaced is a timer.
|
|
43
|
+
pollingMinimum: 3,
|
|
44
|
+
// Requests to the same path shape within this window look like one screen's worth of
|
|
45
|
+
// work, which is what makes an N+1 visible rather than just "a busy session".
|
|
46
|
+
nPlusOneWindowMs: 2000,
|
|
47
|
+
nPlusOneMinimum: 4,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/** The path portion of a URL, without query or fragment. */
|
|
51
|
+
function pathOf(url) {
|
|
52
|
+
const text = String(url ?? '');
|
|
53
|
+
const withoutScheme = text.includes('://') ? text.slice(text.indexOf('://') + 3) : text;
|
|
54
|
+
const slash = withoutScheme.indexOf('/');
|
|
55
|
+
const rest = slash === -1 ? '/' : withoutScheme.slice(slash);
|
|
56
|
+
return rest.split('?')[0].split('#')[0] || '/';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A path with its identifier segments replaced, so `/users/41` and `/users/42` collapse.
|
|
61
|
+
*
|
|
62
|
+
* This is what makes an N+1 detectable. The rule is deliberately narrow — numeric
|
|
63
|
+
* segments, UUIDs, and long hex strings — because a looser one collapses genuinely
|
|
64
|
+
* different endpoints and reports an N+1 that is not there.
|
|
65
|
+
*/
|
|
66
|
+
export function templatePath(url) {
|
|
67
|
+
return pathOf(url)
|
|
68
|
+
.split('/')
|
|
69
|
+
.map((segment) => {
|
|
70
|
+
if (/^\d+$/.test(segment)) return ':id';
|
|
71
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)) return ':uuid';
|
|
72
|
+
if (/^[0-9a-f]{16,}$/i.test(segment)) return ':hash';
|
|
73
|
+
return segment;
|
|
74
|
+
})
|
|
75
|
+
.join('/');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Milliseconds from the first request, or null when the HAR has no timestamps. */
|
|
79
|
+
function startOffsets(entries) {
|
|
80
|
+
const times = entries.map((entry) => {
|
|
81
|
+
const parsed = entry.startedAt ? Date.parse(entry.startedAt) : Number.NaN;
|
|
82
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
83
|
+
});
|
|
84
|
+
const known = times.filter((value) => value !== null);
|
|
85
|
+
if (known.length === 0) return times.map(() => null);
|
|
86
|
+
const origin = Math.min(...known);
|
|
87
|
+
return times.map((value) => (value === null ? null : value - origin));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Is this response cacheable and uncached?
|
|
92
|
+
*
|
|
93
|
+
* Only asked of static assets, and only when the server said nothing at all. A 200 for
|
|
94
|
+
* a script with neither `Cache-Control` nor `ETag` is a real finding; the same absence
|
|
95
|
+
* on a JSON API response usually is not, because that response is often meant to be
|
|
96
|
+
* fresh every time. Guessing on the second produces noise that trains a reader to
|
|
97
|
+
* ignore the whole section.
|
|
98
|
+
*/
|
|
99
|
+
function looksUncached(entry) {
|
|
100
|
+
if (entry.status !== 200) return false;
|
|
101
|
+
if (entry.cacheControl || entry.etag) return false;
|
|
102
|
+
const path = pathOf(entry.url).toLowerCase();
|
|
103
|
+
return /\.(js|mjs|css|png|jpe?g|gif|webp|avif|svg|woff2?|ttf|eot|ico)$/.test(path);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Evenly spaced repeats read as a timer rather than as a burst. */
|
|
107
|
+
function looksLikePolling(offsets, minimum) {
|
|
108
|
+
const known = offsets.filter((value) => value !== null).sort((a, b) => a - b);
|
|
109
|
+
if (known.length < minimum) return false;
|
|
110
|
+
const gaps = known.slice(1).map((value, index) => value - known[index]);
|
|
111
|
+
if (gaps.length < 2) return false;
|
|
112
|
+
const mean = gaps.reduce((sum, gap) => sum + gap, 0) / gaps.length;
|
|
113
|
+
if (mean < 250) return false; // a burst, not a poll
|
|
114
|
+
// Within 35% of the mean gap, every time: a scheduler, not a user.
|
|
115
|
+
return gaps.every((gap) => Math.abs(gap - mean) <= mean * 0.35);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Build the contract's `network` block from parsed HAR entries.
|
|
120
|
+
*
|
|
121
|
+
* Exported separately from the file-reading path so a caller that already has entries —
|
|
122
|
+
* a browser adapter capturing them live, say — can use the same analysis without
|
|
123
|
+
* writing a HAR to disk first.
|
|
124
|
+
*/
|
|
125
|
+
export function analyzeEntries(entries, options = {}) {
|
|
126
|
+
const config = { ...DEFAULTS, ...options };
|
|
127
|
+
const offsets = startOffsets(entries);
|
|
128
|
+
const enriched = entries.map((entry, index) => ({ ...entry, startedMs: offsets[index] }));
|
|
129
|
+
|
|
130
|
+
// Group by what actually identifies a call: the method and the full URL. Two GETs of
|
|
131
|
+
// the same path with different query strings are different requests, and collapsing
|
|
132
|
+
// them would report a duplicate that is not one.
|
|
133
|
+
const groups = new Map();
|
|
134
|
+
for (const entry of enriched) {
|
|
135
|
+
const key = `${entry.method} ${entry.url}`;
|
|
136
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
137
|
+
groups.get(key).push(entry);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// N+1: many calls to one path *shape* inside a short window.
|
|
141
|
+
const byTemplate = new Map();
|
|
142
|
+
for (const entry of enriched) {
|
|
143
|
+
const key = `${entry.method} ${templatePath(entry.url)}`;
|
|
144
|
+
if (!byTemplate.has(key)) byTemplate.set(key, []);
|
|
145
|
+
byTemplate.get(key).push(entry);
|
|
146
|
+
}
|
|
147
|
+
const nPlusOne = new Set();
|
|
148
|
+
for (const [key, group] of byTemplate) {
|
|
149
|
+
if (group.length < config.nPlusOneMinimum) continue;
|
|
150
|
+
// Distinct URLs, or it is a duplicate rather than an N+1.
|
|
151
|
+
if (new Set(group.map((entry) => entry.url)).size < config.nPlusOneMinimum) continue;
|
|
152
|
+
const known = group.map((entry) => entry.startedMs).filter((value) => value !== null);
|
|
153
|
+
if (known.length > 1 && Math.max(...known) - Math.min(...known) > config.nPlusOneWindowMs) continue;
|
|
154
|
+
nPlusOne.add(key);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const endpoints = [];
|
|
158
|
+
for (const [, group] of groups) {
|
|
159
|
+
const first = group[0];
|
|
160
|
+
const durations = group.map((entry) => entry.durationMs).filter((value) => Number.isFinite(value));
|
|
161
|
+
const sizes = group.map((entry) => entry.bytes).filter((value) => value !== null);
|
|
162
|
+
const worstStatus = group.reduce(
|
|
163
|
+
(worst, entry) => (statusRank(entry.status) > statusRank(worst) ? entry.status : worst),
|
|
164
|
+
first.status,
|
|
165
|
+
);
|
|
166
|
+
const slowest = durations.length > 0 ? Math.max(...durations) : 0;
|
|
167
|
+
const bytes = sizes.length > 0 ? Math.max(...sizes) : null;
|
|
168
|
+
const templateKey = `${first.method} ${templatePath(first.url)}`;
|
|
169
|
+
|
|
170
|
+
// One flag per endpoint, worst first: a failing request that is also slow is
|
|
171
|
+
// reported as failing, because that is what the reader must act on.
|
|
172
|
+
let issue;
|
|
173
|
+
if (worstStatus === 0 || worstStatus >= 400) issue = 'failed';
|
|
174
|
+
else if (slowest >= config.slowMs) issue = 'slow';
|
|
175
|
+
else if (looksLikePolling(group.map((entry) => entry.startedMs), config.pollingMinimum)) issue = 'polling';
|
|
176
|
+
else if (group.length > 1) issue = 'duplicate';
|
|
177
|
+
else if (nPlusOne.has(templateKey)) issue = 'n-plus-one';
|
|
178
|
+
else if (bytes !== null && bytes >= config.largeBytes) issue = 'large-payload';
|
|
179
|
+
else if (looksUncached(first)) issue = 'uncached';
|
|
180
|
+
|
|
181
|
+
endpoints.push({
|
|
182
|
+
method: first.method,
|
|
183
|
+
url: first.url,
|
|
184
|
+
...(Number.isFinite(worstStatus) ? { status: worstStatus } : {}),
|
|
185
|
+
durationMs: slowest,
|
|
186
|
+
...(bytes !== null ? { bytes } : {}),
|
|
187
|
+
count: group.length,
|
|
188
|
+
...(first.startedMs !== null ? { startedMs: first.startedMs } : {}),
|
|
189
|
+
...(issue ? { issue } : {}),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Worst first, then slowest: the order a reader wants without sorting anything.
|
|
194
|
+
endpoints.sort(
|
|
195
|
+
(a, b) => issueRank(a.issue) - issueRank(b.issue) || (b.durationMs ?? 0) - (a.durationMs ?? 0),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const totalBytes = enriched.reduce((sum, entry) => sum + (entry.bytes ?? 0), 0);
|
|
199
|
+
const anySizes = enriched.some((entry) => entry.bytes !== null);
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
totalRequests: enriched.length,
|
|
203
|
+
failedRequests: enriched.filter((entry) => entry.status === 0 || entry.status >= 400).length,
|
|
204
|
+
slowRequests: enriched.filter((entry) => entry.durationMs >= config.slowMs).length,
|
|
205
|
+
// Requests beyond the first for each identical call — the count of *wasted* calls,
|
|
206
|
+
// not the count of endpoints that were repeated.
|
|
207
|
+
duplicateRequests: [...groups.values()].reduce((sum, group) => sum + (group.length - 1), 0),
|
|
208
|
+
...(anySizes ? { totalBytes } : {}),
|
|
209
|
+
slowThresholdMs: config.slowMs,
|
|
210
|
+
endpoints,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function statusRank(status) {
|
|
215
|
+
if (status === 0) return 3;
|
|
216
|
+
if (status >= 500) return 2;
|
|
217
|
+
if (status >= 400) return 1;
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const ISSUE_ORDER = ['failed', 'slow', 'n-plus-one', 'duplicate', 'polling', 'large-payload', 'uncached'];
|
|
222
|
+
function issueRank(issue) {
|
|
223
|
+
const index = ISSUE_ORDER.indexOf(issue);
|
|
224
|
+
return index === -1 ? ISSUE_ORDER.length : index;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Analyze a HAR file on disk. Returns the contract's `network` block. */
|
|
228
|
+
export function analyzeHar(path, options = {}) {
|
|
229
|
+
const parsed = parseHar(path, { slowMs: options.slowMs ?? DEFAULTS.slowMs });
|
|
230
|
+
return analyzeEntries(parsed.entries, options);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Analyze already-parsed HAR JSON, for a caller holding the document in memory. */
|
|
234
|
+
export function analyzeHarData(data, options = {}) {
|
|
235
|
+
const parsed = parseHarData(data, { slowMs: options.slowMs ?? DEFAULTS.slowMs });
|
|
236
|
+
return analyzeEntries(parsed.entries, options);
|
|
237
|
+
}
|