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,290 @@
|
|
|
1
|
+
// Machine-readable exports: SARIF, JUnit, and CSV.
|
|
2
|
+
//
|
|
3
|
+
// These exist so a QA run can enter systems that were never going to read an HTML
|
|
4
|
+
// page. SARIF puts findings in GitHub's Security tab and any code-scanning viewer;
|
|
5
|
+
// JUnit puts them on a CI dashboard beside the unit tests; CSV puts them in the
|
|
6
|
+
// spreadsheet a QA lead is already tracking the release from.
|
|
7
|
+
//
|
|
8
|
+
// ## Where the mapping is honest and where it is lossy
|
|
9
|
+
//
|
|
10
|
+
// SARIF and JUnit were designed for static analysis and unit tests respectively, and
|
|
11
|
+
// an exploratory QA finding is neither. Rather than pretend otherwise:
|
|
12
|
+
//
|
|
13
|
+
// - A finding with no `page` has no meaningful file location, so SARIF gets the
|
|
14
|
+
// target URL as its artifact URI rather than an invented source path. A reader
|
|
15
|
+
// seeing `admin.example.com/users` knows it is a runtime finding, not line 12 of a
|
|
16
|
+
// file that does not exist.
|
|
17
|
+
// - JUnit's model has no severity, so severity travels in the failure `type` and the
|
|
18
|
+
// dimension in the classname. Nothing is dropped silently; it is relocated and
|
|
19
|
+
// documented.
|
|
20
|
+
//
|
|
21
|
+
// Anything that genuinely does not fit — evidence images, root-cause chains — stays in
|
|
22
|
+
// the JSON, which every one of these formats can point back to.
|
|
23
|
+
|
|
24
|
+
import { buildModel } from '../core/model.mjs';
|
|
25
|
+
import { SEVERITY } from '../theme/tokens.mjs';
|
|
26
|
+
import { dimensionLabel } from '../components/findings.mjs';
|
|
27
|
+
|
|
28
|
+
/** SARIF severity levels. SARIF has three; the pack has four, so medium and low share. */
|
|
29
|
+
const SARIF_LEVEL = Object.freeze({ critical: 'error', high: 'error', medium: 'warning', low: 'note' });
|
|
30
|
+
|
|
31
|
+
// SARIF's own 0–10 scale, so a viewer that ranks by score ranks the way the report does.
|
|
32
|
+
const SARIF_RANK = Object.freeze({ critical: 10, high: 8, medium: 5, low: 2 });
|
|
33
|
+
|
|
34
|
+
/** SARIF 2.1.0, one run, one rule per distinct dimension+severity pairing. */
|
|
35
|
+
export function renderSarif(result, options = {}) {
|
|
36
|
+
const model = buildModel(result, { ...options, hash: false });
|
|
37
|
+
|
|
38
|
+
const rules = new Map();
|
|
39
|
+
const results = model.findings.map((finding) => {
|
|
40
|
+
const ruleId = `qa-explore/${finding.dimension ?? 'general'}`;
|
|
41
|
+
if (!rules.has(ruleId)) {
|
|
42
|
+
rules.set(ruleId, {
|
|
43
|
+
id: ruleId,
|
|
44
|
+
name: `${dimensionLabel(finding.dimension)} finding`,
|
|
45
|
+
shortDescription: { text: `${dimensionLabel(finding.dimension)} defect found by exploratory QA` },
|
|
46
|
+
fullDescription: {
|
|
47
|
+
text:
|
|
48
|
+
'Reported by qa-explore, an AI-driven exploratory QA pass against a running application. ' +
|
|
49
|
+
'Each result carries the observed behaviour, the expected behaviour, and reproduction steps.',
|
|
50
|
+
},
|
|
51
|
+
defaultConfiguration: { level: SARIF_LEVEL[finding.severity] ?? 'note' },
|
|
52
|
+
properties: { tags: ['qa', 'exploratory', finding.dimension ?? 'general'] },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const location = finding.page ?? model.url ?? 'unknown';
|
|
57
|
+
const message = [
|
|
58
|
+
finding.actual,
|
|
59
|
+
`Expected: ${finding.expected}`,
|
|
60
|
+
`Fix: ${finding.fixDirection}`,
|
|
61
|
+
].join('\n\n');
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
ruleId,
|
|
65
|
+
level: SARIF_LEVEL[finding.severity] ?? 'note',
|
|
66
|
+
rank: SARIF_RANK[finding.severity] ?? 1,
|
|
67
|
+
message: { text: message },
|
|
68
|
+
locations: [
|
|
69
|
+
{
|
|
70
|
+
physicalLocation: {
|
|
71
|
+
artifactLocation: { uri: location },
|
|
72
|
+
region: { startLine: 1 },
|
|
73
|
+
},
|
|
74
|
+
message: { text: finding.title },
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
partialFingerprints: { findingId: String(finding.id) },
|
|
78
|
+
properties: {
|
|
79
|
+
findingId: finding.id,
|
|
80
|
+
severity: finding.severity,
|
|
81
|
+
dimension: finding.dimension,
|
|
82
|
+
status: finding.status,
|
|
83
|
+
businessImpact: finding.businessImpact ?? undefined,
|
|
84
|
+
rootCause: finding.rootCause?.summary ?? undefined,
|
|
85
|
+
regressionRisk: finding.regressionRisk?.level ?? undefined,
|
|
86
|
+
confidence: finding.confidence ?? undefined,
|
|
87
|
+
repro: finding.repro,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
94
|
+
version: '2.1.0',
|
|
95
|
+
runs: [
|
|
96
|
+
{
|
|
97
|
+
tool: {
|
|
98
|
+
driver: {
|
|
99
|
+
name: 'qa-engineer',
|
|
100
|
+
informationUri: 'https://github.com/abisheik88/qa-engineer',
|
|
101
|
+
rules: [...rules.values()],
|
|
102
|
+
version: model.skill?.version ?? undefined,
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
invocations: [
|
|
106
|
+
{
|
|
107
|
+
executionSuccessful: model.classification !== 'blocked',
|
|
108
|
+
endTimeUtc: model.generatedAt ?? undefined,
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
results,
|
|
112
|
+
},
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** XML-escape, for both text nodes and attribute values. */
|
|
118
|
+
function x(value) {
|
|
119
|
+
return String(value ?? '')
|
|
120
|
+
.replace(/&/g, '&')
|
|
121
|
+
.replace(/</g, '<')
|
|
122
|
+
.replace(/>/g, '>')
|
|
123
|
+
.replace(/"/g, '"')
|
|
124
|
+
.replace(/'/g, ''')
|
|
125
|
+
// A control character is legal in a JSON string and illegal in XML 1.0, so a
|
|
126
|
+
// console excerpt carrying one would produce a file no parser accepts.
|
|
127
|
+
// eslint-disable-next-line no-control-regex
|
|
128
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* JUnit XML.
|
|
133
|
+
*
|
|
134
|
+
* Two suites: the executed test cases (when the run had any) and the findings. Keeping
|
|
135
|
+
* them apart matters — a CI dashboard that mixes "the login case failed" with "the
|
|
136
|
+
* login page leaks a token into localStorage" cannot tell a regression from a review
|
|
137
|
+
* comment.
|
|
138
|
+
*/
|
|
139
|
+
export function renderJUnit(result, options = {}) {
|
|
140
|
+
const model = buildModel(result, { ...options, hash: false });
|
|
141
|
+
const suites = [];
|
|
142
|
+
|
|
143
|
+
const cases = model.testCases?.cases ?? [];
|
|
144
|
+
if (cases.length > 0) {
|
|
145
|
+
const bodies = cases.map((testCase) => {
|
|
146
|
+
const attributes =
|
|
147
|
+
`name="${x(testCase.title)}" classname="${x(`qa-explore.cases.${testCase.id}`)}"`;
|
|
148
|
+
if (testCase.status === 'pass') return ` <testcase ${attributes}/>`;
|
|
149
|
+
if (testCase.status === 'skipped') {
|
|
150
|
+
return ` <testcase ${attributes}><skipped/></testcase>`;
|
|
151
|
+
}
|
|
152
|
+
if (testCase.status === 'blocked') {
|
|
153
|
+
return (
|
|
154
|
+
` <testcase ${attributes}><skipped message="Blocked: could not be executed"/></testcase>`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const finding = model.findings.find((entry) => entry.id === testCase.findingId);
|
|
158
|
+
const detail = finding
|
|
159
|
+
? `${finding.actual}\n\nExpected: ${finding.expected}`
|
|
160
|
+
: (testCase.note ?? 'Case failed.');
|
|
161
|
+
return (
|
|
162
|
+
` <testcase ${attributes}>` +
|
|
163
|
+
`<failure type="case-failed" message="${x(testCase.title)}">${x(detail)}</failure>` +
|
|
164
|
+
'</testcase>'
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
suites.push(
|
|
168
|
+
` <testsuite name="qa-explore.cases" tests="${cases.length}" ` +
|
|
169
|
+
`failures="${cases.filter((c) => c.status === 'fail').length}" ` +
|
|
170
|
+
`skipped="${cases.filter((c) => c.status === 'skipped' || c.status === 'blocked').length}">\n` +
|
|
171
|
+
`${bodies.join('\n')}\n </testsuite>`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (model.findings.length > 0) {
|
|
176
|
+
const bodies = model.findings.map((finding) => {
|
|
177
|
+
const detail = [
|
|
178
|
+
finding.actual,
|
|
179
|
+
`Expected: ${finding.expected}`,
|
|
180
|
+
`Reproduction: ${finding.repro}`,
|
|
181
|
+
`Fix: ${finding.fixDirection}`,
|
|
182
|
+
].join('\n\n');
|
|
183
|
+
return (
|
|
184
|
+
` <testcase name="${x(`${finding.id} ${finding.title}`)}" ` +
|
|
185
|
+
`classname="${x(`qa-explore.findings.${finding.dimension ?? 'general'}`)}">` +
|
|
186
|
+
`<failure type="${x(finding.severity)}" message="${x(finding.title)}">${x(detail)}</failure>` +
|
|
187
|
+
'</testcase>'
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
suites.push(
|
|
191
|
+
` <testsuite name="qa-explore.findings" tests="${model.findings.length}" ` +
|
|
192
|
+
`failures="${model.findings.length}">\n${bodies.join('\n')}\n </testsuite>`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const totals = suites.length;
|
|
197
|
+
return (
|
|
198
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n' +
|
|
199
|
+
`<testsuites name="qa-explore" tests="${cases.length + model.findings.length}" ` +
|
|
200
|
+
`failures="${cases.filter((c) => c.status === 'fail').length + model.findings.length}"` +
|
|
201
|
+
(totals === 0 ? '/>\n' : `>\n${suites.join('\n')}\n</testsuites>\n`)
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** RFC 4180 quoting: wrap when needed, double any embedded quote. */
|
|
206
|
+
function csvCell(value) {
|
|
207
|
+
const text = String(value ?? '');
|
|
208
|
+
return /[",\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Findings as CSV.
|
|
213
|
+
*
|
|
214
|
+
* Column order is triage order, so the sheet is usable unsorted: id, severity, the two
|
|
215
|
+
* behaviours, then the context. A leading `` because Excel opens a UTF-8 CSV
|
|
216
|
+
* without one as mojibake, and a report that renders `â€"` in a stakeholder's
|
|
217
|
+
* spreadsheet has failed regardless of how correct the bytes are.
|
|
218
|
+
*/
|
|
219
|
+
export function renderCsv(result, options = {}) {
|
|
220
|
+
const model = buildModel(result, { ...options, hash: false });
|
|
221
|
+
const headers = [
|
|
222
|
+
'id', 'severity', 'dimension', 'status', 'title', 'page', 'actual', 'expected',
|
|
223
|
+
'businessImpact', 'repro', 'fixDirection', 'rootCause', 'regressionRisk',
|
|
224
|
+
'confidence', 'tags', 'affectedApis', 'evidenceCount',
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
const rows = model.findings.map((finding) =>
|
|
228
|
+
[
|
|
229
|
+
finding.id,
|
|
230
|
+
finding.severity,
|
|
231
|
+
finding.dimension,
|
|
232
|
+
finding.status,
|
|
233
|
+
finding.title,
|
|
234
|
+
finding.page ?? '',
|
|
235
|
+
finding.actual,
|
|
236
|
+
finding.expected,
|
|
237
|
+
finding.businessImpact ?? '',
|
|
238
|
+
finding.repro,
|
|
239
|
+
finding.fixDirection,
|
|
240
|
+
finding.rootCause?.summary ?? '',
|
|
241
|
+
finding.regressionRisk?.level ?? '',
|
|
242
|
+
finding.confidence ?? '',
|
|
243
|
+
(finding.tags ?? []).join(' '),
|
|
244
|
+
(finding.affectedApis ?? []).join(' '),
|
|
245
|
+
(finding.evidence ?? []).length,
|
|
246
|
+
]
|
|
247
|
+
.map(csvCell)
|
|
248
|
+
.join(','),
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
return `\ufeff${[headers.join(','), ...rows].join('\r\n')}\r\n`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A manifest of everything a shareable bundle should contain.
|
|
256
|
+
*
|
|
257
|
+
* The pack writes no archive itself — zipping is the caller's job and every platform
|
|
258
|
+
* already has a tool for it. What the pack owns is knowing *what belongs in the
|
|
259
|
+
* archive*, which is the part that goes wrong: a bundle missing three screenshots is
|
|
260
|
+
* indistinguishable from a complete one until someone opens it.
|
|
261
|
+
*/
|
|
262
|
+
export function bundleManifest(result, options = {}) {
|
|
263
|
+
const model = buildModel(result, options);
|
|
264
|
+
const files = model.registry
|
|
265
|
+
.list()
|
|
266
|
+
.filter((record) => record.declaredPath && !record.external)
|
|
267
|
+
.map((record) => ({
|
|
268
|
+
path: record.declaredPath,
|
|
269
|
+
kind: record.kind,
|
|
270
|
+
bytes: record.bytes,
|
|
271
|
+
sha256: record.sha256,
|
|
272
|
+
present: record.exists,
|
|
273
|
+
reason: record.exists ? undefined : record.missingReason,
|
|
274
|
+
}));
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
subject: model.subject,
|
|
278
|
+
generatedAt: model.generatedAt,
|
|
279
|
+
complete: files.every((file) => file.present),
|
|
280
|
+
counts: {
|
|
281
|
+
total: files.length,
|
|
282
|
+
present: files.filter((file) => file.present).length,
|
|
283
|
+
missing: files.filter((file) => !file.present).length,
|
|
284
|
+
},
|
|
285
|
+
totalBytes: files.reduce((sum, file) => sum + (file.bytes ?? 0), 0),
|
|
286
|
+
files,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export { SEVERITY };
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// The Markdown rendering.
|
|
2
|
+
//
|
|
3
|
+
// Markdown is the format a finding gets pasted into a Jira ticket, a pull request, or
|
|
4
|
+
// a Slack thread from, so it is written for *excerpting* rather than for reading end to
|
|
5
|
+
// end: each finding is a self-contained block with its own heading, and no finding
|
|
6
|
+
// depends on a table three sections above it to make sense.
|
|
7
|
+
//
|
|
8
|
+
// It is rendered from the same model as the HTML, which is the only reason the two can
|
|
9
|
+
// be trusted to agree. Producing the prose separately is exactly how the first live run
|
|
10
|
+
// ended up with a Markdown report that said things the JSON did not.
|
|
11
|
+
|
|
12
|
+
import { footerMarkdown } from '../../analysis/branding.mjs';
|
|
13
|
+
import { buildModel } from '../core/model.mjs';
|
|
14
|
+
import { SEVERITY, SEVERITY_ORDER } from '../theme/tokens.mjs';
|
|
15
|
+
import { dimensionLabel, STATUS_LABEL } from '../components/findings.mjs';
|
|
16
|
+
import { formatDuration, formatBytes } from '../components/primitives.mjs';
|
|
17
|
+
|
|
18
|
+
/** Escape the characters that would restructure a table cell or a heading. */
|
|
19
|
+
function cell(value) {
|
|
20
|
+
return String(value ?? '').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function table(headers, rows) {
|
|
24
|
+
if (rows.length === 0) return '';
|
|
25
|
+
return [
|
|
26
|
+
`| ${headers.join(' | ')} |`,
|
|
27
|
+
`| ${headers.map(() => '---').join(' | ')} |`,
|
|
28
|
+
...rows.map((row) => `| ${row.map(cell).join(' | ')} |`),
|
|
29
|
+
'',
|
|
30
|
+
].join('\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function findingBlock(finding, model) {
|
|
34
|
+
const severity = SEVERITY[finding.severity]?.label ?? finding.severity;
|
|
35
|
+
const lines = [
|
|
36
|
+
`### ${finding.id} · ${severity} — ${finding.title}`,
|
|
37
|
+
'',
|
|
38
|
+
`**Area** ${dimensionLabel(finding.dimension)} · ` +
|
|
39
|
+
`**Status** ${STATUS_LABEL[finding.status] ?? finding.status}` +
|
|
40
|
+
(finding.page ? ` · **Page** ${finding.page}` : ''),
|
|
41
|
+
'',
|
|
42
|
+
`**Current behaviour** — ${finding.actual}`,
|
|
43
|
+
'',
|
|
44
|
+
`**Expected behaviour** — ${finding.expected}`,
|
|
45
|
+
'',
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
if (finding.businessImpact) lines.push(`**Business impact** — ${finding.businessImpact}`, '');
|
|
49
|
+
|
|
50
|
+
lines.push('**Reproduction**', '');
|
|
51
|
+
if (Array.isArray(finding.steps) && finding.steps.length > 0) {
|
|
52
|
+
finding.steps.forEach((step, index) => lines.push(`${index + 1}. ${step}`));
|
|
53
|
+
} else {
|
|
54
|
+
lines.push(finding.repro);
|
|
55
|
+
}
|
|
56
|
+
lines.push('');
|
|
57
|
+
|
|
58
|
+
if (finding.rootCause?.summary) {
|
|
59
|
+
lines.push(`**Root cause** — ${finding.rootCause.summary}`, '');
|
|
60
|
+
for (const step of finding.rootCause.chain ?? []) lines.push(`- ${step}`);
|
|
61
|
+
if (finding.rootCause.chain?.length) lines.push('');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
lines.push(`**Suggested fix** — ${finding.fixDirection}`, '');
|
|
65
|
+
|
|
66
|
+
if (finding.regressionRisk?.level) {
|
|
67
|
+
lines.push(
|
|
68
|
+
`**Regression risk** — ${finding.regressionRisk.level}` +
|
|
69
|
+
(finding.regressionRisk.note ? `: ${finding.regressionRisk.note}` : ''),
|
|
70
|
+
'',
|
|
71
|
+
);
|
|
72
|
+
for (const item of finding.regressionRisk.retest ?? []) lines.push(`- Re-test: ${item}`);
|
|
73
|
+
if (finding.regressionRisk.retest?.length) lines.push('');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (finding.developerNotes) lines.push(`**Developer notes** — ${finding.developerNotes}`, '');
|
|
77
|
+
|
|
78
|
+
if (finding.metrics?.length) {
|
|
79
|
+
lines.push(
|
|
80
|
+
table(
|
|
81
|
+
['Measurement', 'Observed', 'Budget'],
|
|
82
|
+
finding.metrics.map((metric) => [metric.label, metric.value, metric.budget ?? '—']),
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Evidence goes through the registry so a missing file is *stated* in Markdown too,
|
|
88
|
+
// rather than becoming a dead image reference in a ticket.
|
|
89
|
+
const evidence = (finding.evidence ?? [])
|
|
90
|
+
.map((entry) => ({ entry, record: model.registry.forEvidence(entry) }))
|
|
91
|
+
.filter((pair) => pair.record);
|
|
92
|
+
if (evidence.length > 0) {
|
|
93
|
+
lines.push('**Evidence**', '');
|
|
94
|
+
for (const { entry, record } of evidence) {
|
|
95
|
+
if (!record.exists) {
|
|
96
|
+
lines.push(`- *Artifact missing* — \`${record.declaredPath}\` (${record.missingReason})`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (record.renderAs === 'image') {
|
|
100
|
+
lines.push(``, '');
|
|
101
|
+
} else {
|
|
102
|
+
lines.push(`- [${record.label ?? entry.type}](${record.href})`);
|
|
103
|
+
}
|
|
104
|
+
if (entry.excerpt) lines.push('', '```', entry.excerpt, '```', '');
|
|
105
|
+
}
|
|
106
|
+
lines.push('');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return lines.join('\n');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Render the whole report as Markdown. */
|
|
113
|
+
export function renderMarkdown(result, options = {}) {
|
|
114
|
+
const model = buildModel(result, options);
|
|
115
|
+
const out = [];
|
|
116
|
+
|
|
117
|
+
out.push(`# ${model.subject} — QA Report`, '');
|
|
118
|
+
out.push(
|
|
119
|
+
table(
|
|
120
|
+
['Field', 'Value'],
|
|
121
|
+
[
|
|
122
|
+
['URL', model.url ?? '—'],
|
|
123
|
+
['Generated', model.generatedAt ?? '—'],
|
|
124
|
+
model.environment ? ['Environment', model.environment] : null,
|
|
125
|
+
['Verdict', `**${model.verdict.label}** — ${model.verdict.blurb}`],
|
|
126
|
+
['Findings', String(model.totalFindings)],
|
|
127
|
+
model.durationMs !== null ? ['Duration', formatDuration(model.durationMs)] : null,
|
|
128
|
+
model.reportVersion ? ['Report version', `v${model.reportVersion}`] : null,
|
|
129
|
+
['Tester', 'AI-assisted QA (qa-explore)'],
|
|
130
|
+
].filter(Boolean),
|
|
131
|
+
),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
out.push('## Summary', '', model.summary, '');
|
|
135
|
+
|
|
136
|
+
if (model.verdict.risks.length > 0) {
|
|
137
|
+
out.push('### Why', '');
|
|
138
|
+
for (const risk of model.verdict.risks) out.push(`- ${risk}`);
|
|
139
|
+
out.push('');
|
|
140
|
+
}
|
|
141
|
+
if (model.verdict.recommendedAction) {
|
|
142
|
+
out.push(`**Recommended action** — ${model.verdict.recommendedAction}`, '');
|
|
143
|
+
}
|
|
144
|
+
if (model.verdict.estimatedFixHours) {
|
|
145
|
+
const { low, high } = model.verdict.estimatedFixHours;
|
|
146
|
+
out.push(`**Estimated fix time** — ${low === high ? low : `${low}–${high}`} hours`, '');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
out.push(
|
|
150
|
+
'## Findings by severity',
|
|
151
|
+
'',
|
|
152
|
+
table(
|
|
153
|
+
SEVERITY_ORDER.map((key) => SEVERITY[key].label),
|
|
154
|
+
[SEVERITY_ORDER.map((key) => String(model.severityCounts[key] ?? 0))],
|
|
155
|
+
),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
if (model.scope?.objective) out.push('## Scope', '', model.scope.objective, '');
|
|
159
|
+
if (model.scope?.covered?.length) {
|
|
160
|
+
out.push('**Exercised**', '');
|
|
161
|
+
for (const item of model.scope.covered) out.push(`- ${item}`);
|
|
162
|
+
out.push('');
|
|
163
|
+
}
|
|
164
|
+
if (model.notCovered.length > 0) {
|
|
165
|
+
out.push('**Not covered in this run**', '');
|
|
166
|
+
for (const item of model.notCovered) out.push(`- ${item}`);
|
|
167
|
+
out.push('');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (model.pages.length > 0) {
|
|
171
|
+
out.push(
|
|
172
|
+
'## Pages',
|
|
173
|
+
'',
|
|
174
|
+
table(
|
|
175
|
+
['Page', 'State', 'HTTP', 'Load', 'Requests'],
|
|
176
|
+
model.pages.map((page) => [
|
|
177
|
+
page.title || page.url,
|
|
178
|
+
page.status,
|
|
179
|
+
page.httpStatus ?? '—',
|
|
180
|
+
page.loadMs !== undefined ? formatDuration(page.loadMs) : '—',
|
|
181
|
+
page.requests ?? '—',
|
|
182
|
+
]),
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
out.push('## Findings', '');
|
|
188
|
+
if (model.findings.length === 0) {
|
|
189
|
+
out.push('_No findings recorded._', '');
|
|
190
|
+
} else {
|
|
191
|
+
const sorted = [...model.findings].sort(
|
|
192
|
+
(a, b) =>
|
|
193
|
+
(SEVERITY[a.severity] ?? SEVERITY.low).rank - (SEVERITY[b.severity] ?? SEVERITY.low).rank ||
|
|
194
|
+
String(a.id).localeCompare(String(b.id), 'en', { numeric: true }),
|
|
195
|
+
);
|
|
196
|
+
for (const finding of sorted) out.push(findingBlock(finding, model), '');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (model.performance) {
|
|
200
|
+
const rows = Object.entries(model.performance)
|
|
201
|
+
.filter(([, value]) => value !== null && value !== undefined && typeof value !== 'string')
|
|
202
|
+
.map(([key, value]) => [key, key.endsWith('Bytes') ? formatBytes(value) : String(value)]);
|
|
203
|
+
if (rows.length > 0) out.push('## Performance', '', table(['Metric', 'Value'], rows));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (model.network?.endpoints?.length) {
|
|
207
|
+
out.push(
|
|
208
|
+
'## API and network',
|
|
209
|
+
'',
|
|
210
|
+
table(
|
|
211
|
+
['Method', 'Endpoint', 'Status', 'Time', 'Size', 'Count', 'Issue'],
|
|
212
|
+
model.network.endpoints.slice(0, 40).map((endpoint) => [
|
|
213
|
+
endpoint.method,
|
|
214
|
+
endpoint.url,
|
|
215
|
+
endpoint.status ?? '—',
|
|
216
|
+
Number.isFinite(endpoint.durationMs) ? formatDuration(endpoint.durationMs) : '—',
|
|
217
|
+
Number.isFinite(endpoint.bytes) ? formatBytes(endpoint.bytes) : '—',
|
|
218
|
+
endpoint.count ?? 1,
|
|
219
|
+
endpoint.issue ?? '',
|
|
220
|
+
]),
|
|
221
|
+
),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (model.security?.checks?.length) {
|
|
226
|
+
out.push(
|
|
227
|
+
'## Security',
|
|
228
|
+
'',
|
|
229
|
+
table(
|
|
230
|
+
['Check', 'Result', 'Detail'],
|
|
231
|
+
model.security.checks.map((check) => [check.check, check.status, check.detail ?? '']),
|
|
232
|
+
),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (model.accessibility?.violations?.length) {
|
|
237
|
+
out.push(
|
|
238
|
+
'## Accessibility',
|
|
239
|
+
'',
|
|
240
|
+
table(
|
|
241
|
+
['Rule', 'Impact', 'Instances', 'Description'],
|
|
242
|
+
model.accessibility.violations.map((violation) => [
|
|
243
|
+
violation.rule, violation.impact, violation.count, violation.description ?? '',
|
|
244
|
+
]),
|
|
245
|
+
),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (model.testCases?.cases?.length) {
|
|
250
|
+
out.push(
|
|
251
|
+
'## Test case coverage',
|
|
252
|
+
'',
|
|
253
|
+
table(
|
|
254
|
+
['ID', 'Case', 'Result', 'Finding'],
|
|
255
|
+
model.testCases.cases.map((testCase) => [
|
|
256
|
+
testCase.id, testCase.title, testCase.status, testCase.findingId ?? '—',
|
|
257
|
+
]),
|
|
258
|
+
),
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (model.dbValidation?.comparisons?.length) {
|
|
263
|
+
out.push(
|
|
264
|
+
'## Data validation',
|
|
265
|
+
'',
|
|
266
|
+
table(
|
|
267
|
+
['Metric', 'UI', 'Source of truth', 'Result'],
|
|
268
|
+
model.dbValidation.comparisons.map((comparison) => [
|
|
269
|
+
comparison.metric, comparison.uiValue, comparison.sourceValue,
|
|
270
|
+
comparison.match ? 'Match' : 'Mismatch',
|
|
271
|
+
]),
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (model.whatWorksWell.length > 0) {
|
|
277
|
+
out.push('## What works well', '');
|
|
278
|
+
for (const item of model.whatWorksWell) out.push(`- ${item}`);
|
|
279
|
+
out.push('');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (model.fixOrder.length > 0) {
|
|
283
|
+
out.push('## Suggested fix order', '');
|
|
284
|
+
model.fixOrder.forEach((item, index) => out.push(`${index + 1}. ${item}`));
|
|
285
|
+
out.push('');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (model.recommendations.length > 0) {
|
|
289
|
+
out.push(
|
|
290
|
+
'## Recommendations',
|
|
291
|
+
'',
|
|
292
|
+
table(
|
|
293
|
+
['Action', 'Priority', 'Owner', 'Effort'],
|
|
294
|
+
model.recommendations.map((recommendation) => [
|
|
295
|
+
recommendation.action,
|
|
296
|
+
recommendation.priority,
|
|
297
|
+
recommendation.owner ?? '—',
|
|
298
|
+
recommendation.effort ?? '—',
|
|
299
|
+
]),
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const stats = model.artifactStats;
|
|
305
|
+
if (stats.total > 0) {
|
|
306
|
+
out.push(
|
|
307
|
+
'## Artifacts',
|
|
308
|
+
'',
|
|
309
|
+
`${stats.present} of ${stats.total} present (${formatBytes(stats.bytes)}).` +
|
|
310
|
+
(stats.missing > 0 ? ` **${stats.missing} missing.**` : ''),
|
|
311
|
+
'',
|
|
312
|
+
table(
|
|
313
|
+
['Kind', 'File', 'State'],
|
|
314
|
+
model.registry
|
|
315
|
+
.list()
|
|
316
|
+
.filter((record) => record.declaredPath)
|
|
317
|
+
.map((record) => [record.kind, record.declaredPath, record.exists ? 'Present' : 'Missing']),
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return `${out.join('\n').replace(/\n{3,}/g, '\n\n').trim()}\n\n${footerMarkdown()}`;
|
|
323
|
+
}
|