qa-engineer 0.9.2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/COMPATIBILITY.md +26 -12
- package/README.md +20 -30
- package/package.json +11 -20
- package/packages/engine/bin/qa-engine.mjs +336 -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 +90 -0
- package/packages/engine/lib/analysis/junit.mjs +126 -0
- package/packages/engine/lib/analysis/redaction.mjs +127 -0
- package/packages/engine/lib/analysis/report-html.mjs +763 -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/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/package.json +14 -0
- package/packages/installer/lib/commands/doctor.mjs +7 -16
- package/packages/installer/lib/commands/install.mjs +28 -0
- package/packages/installer/lib/core/bundle.mjs +69 -92
- package/packages/installer/lib/core/manifest.mjs +48 -57
- package/packages/installer/lib/core/validate-install.mjs +12 -19
- package/packages/installer/package.json +1 -1
- package/shared/tooling/qa-tool.mjs +125 -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 +126 -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 +126 -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 +126 -0
- package/skills/qa-explore/SKILL.md +6 -6
- 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 +3 -3
- package/skills/qa-explore/scripts/qa-tool.mjs +126 -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 +126 -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 +126 -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 +126 -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 +126 -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 +126 -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,73 @@
|
|
|
1
|
+
// Deterministic repair planning.
|
|
2
|
+
//
|
|
3
|
+
// Turns a root cause into a repair *plan* — never code. It decides whether the
|
|
4
|
+
// failure is test-side repairable at all, and if so proposes an abstract change,
|
|
5
|
+
// the candidate type, the risk, and a rollback. qa-fix consumes these plans; the
|
|
6
|
+
// plan is always gated by the diff guard and always requires permission before any
|
|
7
|
+
// edit is applied.
|
|
8
|
+
|
|
9
|
+
import * as taxonomy from '../analysis/taxonomy.mjs';
|
|
10
|
+
|
|
11
|
+
// classification -> [repairable, candidate type, abstract change, risk].
|
|
12
|
+
// Only test-side causes are repairable; product, network, infra, authorization and
|
|
13
|
+
// environment failures are escalations, not repairs.
|
|
14
|
+
const PLANS = {
|
|
15
|
+
[taxonomy.LOCATOR]: [true, 'locator-update',
|
|
16
|
+
'Update the failing locator to target the same element in the current DOM.', 'low'],
|
|
17
|
+
[taxonomy.ASSERTION]: [true, 'assertion-improvement',
|
|
18
|
+
'Correct the assertion to match the intended behavior, or confirm a product bug first.', 'medium'],
|
|
19
|
+
[taxonomy.TIMEOUT]: [true, 'wait-strategy',
|
|
20
|
+
'Replace a fixed or missing wait with a web-first wait on the awaited condition.', 'medium'],
|
|
21
|
+
[taxonomy.FLAKY]: [true, 'synchronization',
|
|
22
|
+
'Remove the race by awaiting the real condition; add a tracked quarantine only if needed.', 'medium'],
|
|
23
|
+
[taxonomy.TEST_DATA]: [true, 'test-data',
|
|
24
|
+
'Repair or reseed the test data the scenario depends on.', 'medium'],
|
|
25
|
+
[taxonomy.CONFIGURATION]: [true, 'configuration',
|
|
26
|
+
'Correct the test configuration the run depends on.', 'low'],
|
|
27
|
+
[taxonomy.ENVIRONMENT]: [false, 'environment',
|
|
28
|
+
'Fix the environment (base URL, service availability); not a test-side repair.', 'n/a'],
|
|
29
|
+
[taxonomy.AUTH]: [true, 'authentication',
|
|
30
|
+
"Repair the test's credentials or auth setup; do not weaken the check.", 'medium'],
|
|
31
|
+
[taxonomy.AUTHORIZATION]: [false, 'authorization',
|
|
32
|
+
'Grant the test account permission or use an authorized role; not a code repair.', 'n/a'],
|
|
33
|
+
[taxonomy.NETWORK]: [false, 'network',
|
|
34
|
+
'Investigate the upstream service; not a test-side repair.', 'n/a'],
|
|
35
|
+
[taxonomy.INFRASTRUCTURE]: [false, 'infrastructure',
|
|
36
|
+
'Escalate to CI/infra; not a test-side repair.', 'n/a'],
|
|
37
|
+
[taxonomy.APPLICATION_BUG]: [false, 'application-bug',
|
|
38
|
+
'File a product bug; the test correctly caught a real defect.', 'n/a'],
|
|
39
|
+
[taxonomy.FRAMEWORK]: [false, 'framework',
|
|
40
|
+
'Update or pin the framework/driver; not a test-side repair.', 'n/a'],
|
|
41
|
+
[taxonomy.UNKNOWN]: [false, 'unknown',
|
|
42
|
+
'Investigate further before any repair.', 'n/a'],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Produce a repair plan for a root cause.
|
|
47
|
+
*
|
|
48
|
+
* `{repairable, candidateType, proposedChanges, affectedFiles, risk,
|
|
49
|
+
* permissionRequired, rollbackStrategy, safetyReview}`. Never contains code.
|
|
50
|
+
*/
|
|
51
|
+
export function planRepair(rootCause, affectedFiles = null) {
|
|
52
|
+
const [repairable, candidate, change, risk] =
|
|
53
|
+
PLANS[rootCause.classification] ?? PLANS[taxonomy.UNKNOWN];
|
|
54
|
+
|
|
55
|
+
const safety = repairable
|
|
56
|
+
? 'Any edit will be checked by the diff guard before it is proposed as complete; ' +
|
|
57
|
+
'the guard rejects removed assertions, added skips, forced passes, and timeout inflation.'
|
|
58
|
+
: 'No test-side edit is appropriate; this is an escalation, not a repair.';
|
|
59
|
+
const rollback = repairable
|
|
60
|
+
? 'No source is changed without approval; revert the proposed edits to roll back.'
|
|
61
|
+
: 'Not applicable — no change is proposed.';
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
repairable,
|
|
65
|
+
candidateType: candidate,
|
|
66
|
+
proposedChanges: repairable ? [change] : [],
|
|
67
|
+
affectedFiles: [...(affectedFiles ?? [])],
|
|
68
|
+
risk,
|
|
69
|
+
permissionRequired: true,
|
|
70
|
+
rollbackStrategy: rollback,
|
|
71
|
+
safetyReview: safety,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Deterministic root-cause analysis.
|
|
2
|
+
//
|
|
3
|
+
// Turns a failure signal into a classified root cause with the four things every
|
|
4
|
+
// classification must carry: a taxonomy class (with confidence and evidence-backed
|
|
5
|
+
// reason), a recommended action, and an owner. It reuses the analysis platform's
|
|
6
|
+
// failure taxonomy; it adds the ownership and recommendation mappings and the
|
|
7
|
+
// metadata-driven classes (flaky) the taxonomy cannot infer from a message alone.
|
|
8
|
+
//
|
|
9
|
+
// No unsupported conclusions: a signal that matches no rule is `unknown`.
|
|
10
|
+
|
|
11
|
+
import * as taxonomy from '../analysis/taxonomy.mjs';
|
|
12
|
+
|
|
13
|
+
/** classification -> the party that typically owns the fix. */
|
|
14
|
+
export const OWNERSHIP = {
|
|
15
|
+
[taxonomy.ASSERTION]: 'test-author-or-product',
|
|
16
|
+
[taxonomy.LOCATOR]: 'test-author',
|
|
17
|
+
[taxonomy.TIMEOUT]: 'test-author-or-environment',
|
|
18
|
+
[taxonomy.NETWORK]: 'backend-or-infrastructure',
|
|
19
|
+
[taxonomy.AUTH]: 'auth-or-test-setup',
|
|
20
|
+
[taxonomy.AUTHORIZATION]: 'permissions-or-test-account',
|
|
21
|
+
[taxonomy.ENVIRONMENT]: 'environment-owner',
|
|
22
|
+
[taxonomy.CONFIGURATION]: 'config-owner',
|
|
23
|
+
[taxonomy.INFRASTRUCTURE]: 'ci-or-infrastructure',
|
|
24
|
+
[taxonomy.TEST_DATA]: 'test-data-owner',
|
|
25
|
+
[taxonomy.APPLICATION_BUG]: 'product',
|
|
26
|
+
[taxonomy.FRAMEWORK]: 'framework-or-driver',
|
|
27
|
+
[taxonomy.FLAKY]: 'test-author',
|
|
28
|
+
[taxonomy.UNKNOWN]: 'needs-triage',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// classification -> the safe recommended action (implements the analysis
|
|
32
|
+
// platform's recommendation-guidelines; never recommends forcing a pass).
|
|
33
|
+
export const RECOMMENDATION = {
|
|
34
|
+
[taxonomy.ASSERTION]: 'Confirm whether the app or the expectation is wrong; fix whichever is genuinely incorrect.',
|
|
35
|
+
[taxonomy.LOCATOR]: 'Inspect the current DOM and update the locator to target the same element.',
|
|
36
|
+
[taxonomy.TIMEOUT]: 'Investigate the slowness; raise a wait only if the operation is legitimately slower.',
|
|
37
|
+
[taxonomy.NETWORK]: 'Check the upstream service and the request; retry only if the failure is genuinely transient.',
|
|
38
|
+
[taxonomy.AUTH]: 'Fix the credentials or auth setup; do not weaken the authentication check.',
|
|
39
|
+
[taxonomy.AUTHORIZATION]: 'Grant the test account the needed permission or use an authorized role; do not bypass the check.',
|
|
40
|
+
[taxonomy.ENVIRONMENT]: 'Fix the environment (base URL, service availability); the test is likely fine.',
|
|
41
|
+
[taxonomy.CONFIGURATION]: 'Correct the configuration; do not work around it in the test.',
|
|
42
|
+
[taxonomy.INFRASTRUCTURE]: 'Escalate to CI or infrastructure owners; add resources, do not shrink the suite.',
|
|
43
|
+
[taxonomy.TEST_DATA]: 'Repair or reseed the data; do not delete the assertion that caught the gap.',
|
|
44
|
+
[taxonomy.APPLICATION_BUG]: 'File a bug against the product; do NOT modify the test to pass.',
|
|
45
|
+
[taxonomy.FRAMEWORK]: 'Update or pin the framework/driver; report upstream if it is a genuine defect.',
|
|
46
|
+
[taxonomy.FLAKY]: 'Stabilize the test (fix the race or synchronization); quarantine only with a tracking issue.',
|
|
47
|
+
[taxonomy.UNKNOWN]: 'Investigate further; the evidence was insufficient to classify.',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Classify a failure signal into a root cause.
|
|
52
|
+
*
|
|
53
|
+
* `signal` may carry: message, httpStatus, retries, finalStatus, classification,
|
|
54
|
+
* confidence, reason, evidence. Returns
|
|
55
|
+
* `{classification, confidence, reason, ownership, recommendation, evidence}`.
|
|
56
|
+
*/
|
|
57
|
+
export function analyze(signal) {
|
|
58
|
+
const message = signal.message ?? '';
|
|
59
|
+
const httpStatus = signal.httpStatus;
|
|
60
|
+
const retries = signal.retries || 0;
|
|
61
|
+
const finalStatus = signal.finalStatus;
|
|
62
|
+
const provided = signal.classification;
|
|
63
|
+
|
|
64
|
+
let classification;
|
|
65
|
+
let confidence;
|
|
66
|
+
let reason;
|
|
67
|
+
|
|
68
|
+
// Flakiness is a metadata signal, not a message pattern: a test that needed a
|
|
69
|
+
// retry to pass, or is explicitly flagged flaky, is nondeterministic.
|
|
70
|
+
if (finalStatus === 'flaky' || (retries > 0 && ['passed', 'flaky'].includes(finalStatus))) {
|
|
71
|
+
classification = taxonomy.FLAKY;
|
|
72
|
+
confidence = 0.8;
|
|
73
|
+
reason = 'The test passed only after a retry, indicating nondeterministic behavior.';
|
|
74
|
+
} else if (taxonomy.CLASSES.has(provided)) {
|
|
75
|
+
// The analysis platform already classified this deterministically; trust it
|
|
76
|
+
// rather than re-deriving from the message.
|
|
77
|
+
classification = provided;
|
|
78
|
+
confidence = 'confidence' in signal ? signal.confidence : 0.8;
|
|
79
|
+
reason = signal.reason || `Classified ${provided} by the analysis platform.`;
|
|
80
|
+
} else {
|
|
81
|
+
({ classification, confidence, reason } = taxonomy.classify(message, httpStatus ?? null));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
classification,
|
|
86
|
+
confidence,
|
|
87
|
+
reason,
|
|
88
|
+
ownership: OWNERSHIP[classification] ?? 'needs-triage',
|
|
89
|
+
recommendation: RECOMMENDATION[classification] ?? RECOMMENDATION[taxonomy.UNKNOWN],
|
|
90
|
+
evidence: signal.evidence ?? [],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Deterministic timeline reconstruction.
|
|
2
|
+
//
|
|
3
|
+
// Reconstructs the ordered sequence of a run — start, browser launch, navigation,
|
|
4
|
+
// requests and responses, console errors, assertions, failure, cleanup — from the
|
|
5
|
+
// execution result and the analysis findings. It records only stages for which there
|
|
6
|
+
// is evidence; it never invents an event to fill the shape. Reusable by qa-debug and
|
|
7
|
+
// qa-report.
|
|
8
|
+
|
|
9
|
+
// Canonical phase order, used to sort events that share (or lack) a timestamp.
|
|
10
|
+
export const PHASE_ORDER = [
|
|
11
|
+
'execution-start', 'browser-launch', 'navigation', 'request', 'response',
|
|
12
|
+
'console-error', 'assertion', 'failure', 'cleanup', 'execution-finish',
|
|
13
|
+
];
|
|
14
|
+
const PHASE_INDEX = new Map(PHASE_ORDER.map((phase, index) => [phase, index]));
|
|
15
|
+
|
|
16
|
+
// Evidence type -> the timeline phase it contributes.
|
|
17
|
+
const EVIDENCE_PHASE = {
|
|
18
|
+
network: 'response',
|
|
19
|
+
console: 'console-error',
|
|
20
|
+
trace: 'navigation',
|
|
21
|
+
junit: 'assertion',
|
|
22
|
+
report: 'assertion',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build an ordered timeline: `[{order, phase, detail, source, timestamp?}]`.
|
|
27
|
+
*
|
|
28
|
+
* Deterministic: same inputs, same timeline. Only evidenced stages appear.
|
|
29
|
+
*/
|
|
30
|
+
export function buildTimeline(executionResult, findings = null) {
|
|
31
|
+
const events = [];
|
|
32
|
+
const execution = (executionResult ?? {}).execution ?? {};
|
|
33
|
+
|
|
34
|
+
const started = execution.startedAt;
|
|
35
|
+
if (started || (executionResult !== null && executionResult !== undefined && truthy(executionResult))) {
|
|
36
|
+
events.push(event('execution-start', 'Run started', 'execution-result', started ?? null));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Contribute a stage per evidence entry we actually have.
|
|
40
|
+
for (const finding of findings ?? []) {
|
|
41
|
+
for (const item of finding.evidence ?? []) {
|
|
42
|
+
const phase = EVIDENCE_PHASE[item.type];
|
|
43
|
+
if (phase) {
|
|
44
|
+
events.push(event(phase, item.description ?? '', item.source ?? 'analysis', null));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A failure stage per failed test recorded in the result.
|
|
50
|
+
for (const test of (executionResult ?? {}).executed ?? []) {
|
|
51
|
+
if (test.status === 'failed') {
|
|
52
|
+
events.push(event('failure', `Test failed: ${test.title ?? ''}`,
|
|
53
|
+
test.file ?? 'execution-result', null));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const finished = execution.finishedAt;
|
|
58
|
+
if (finished) {
|
|
59
|
+
events.push(event('cleanup', 'Run cleaned up', 'execution-result', null));
|
|
60
|
+
events.push(event('execution-finish', 'Run finished', 'execution-result', finished));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Deterministic order: by canonical phase first (it encodes the logical
|
|
64
|
+
// sequence), then by timestamp within a phase, then by insertion order. Phase
|
|
65
|
+
// order is primary because per-event wall-clock times are often unavailable.
|
|
66
|
+
const decorated = events.map((entry, seq) => ({ entry, seq }));
|
|
67
|
+
decorated.sort((a, b) => {
|
|
68
|
+
const phaseDelta = (PHASE_INDEX.get(a.entry.phase) ?? 99) - (PHASE_INDEX.get(b.entry.phase) ?? 99);
|
|
69
|
+
if (phaseDelta !== 0) return phaseDelta;
|
|
70
|
+
const left = a.entry.timestamp || '';
|
|
71
|
+
const right = b.entry.timestamp || '';
|
|
72
|
+
if (left !== right) return left < right ? -1 : 1;
|
|
73
|
+
return a.seq - b.seq;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Key order matters: these events are serialized and compared byte-for-byte
|
|
77
|
+
// against the Python implementation, which builds phase/detail/source/timestamp
|
|
78
|
+
// and then appends `order`. Assigning `order` before `timestamp` would produce
|
|
79
|
+
// the same data in a different JSON shape.
|
|
80
|
+
return decorated.map(({ entry }, order) => {
|
|
81
|
+
const result = { phase: entry.phase, detail: entry.detail, source: entry.source };
|
|
82
|
+
if (entry.timestamp !== null && entry.timestamp !== undefined) result.timestamp = entry.timestamp;
|
|
83
|
+
result.order = order;
|
|
84
|
+
return result;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function event(phase, detail, source, timestamp) {
|
|
89
|
+
return { phase, detail, source, timestamp };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Python's `if started or execution_result:` treats an empty dict as falsey, so a
|
|
94
|
+
* bare `{}` produces no start event while `{"tests": {...}}` does. JavaScript
|
|
95
|
+
* considers every object truthy, so the emptiness has to be asked about directly.
|
|
96
|
+
*/
|
|
97
|
+
function truthy(value) {
|
|
98
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
99
|
+
if (value && typeof value === 'object') return Object.keys(value).length > 0;
|
|
100
|
+
return Boolean(value);
|
|
101
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The JUnit-XML frameworks: Selenium, Cypress, WebdriverIO.
|
|
2
|
+
//
|
|
3
|
+
// All three emit JUnit XML, so their normalization *is* the framework-agnostic JUnit
|
|
4
|
+
// parser — each adapter only points at the right artifact and tags provenance. That
|
|
5
|
+
// thinness is the proof of the pack's multi-framework claim: the second, third and
|
|
6
|
+
// fourth frameworks cost a few lines each, because the contract and the parsing are
|
|
7
|
+
// shared and only the artifact location differs (ADR-0013).
|
|
8
|
+
//
|
|
9
|
+
// The three lived in separate Python modules that were identical but for a name and
|
|
10
|
+
// a glob list. Here they are one table, because three copies of the same four lines
|
|
11
|
+
// is three places for them to drift.
|
|
12
|
+
|
|
13
|
+
import { parseJUnit } from '../analysis/junit.mjs';
|
|
14
|
+
import { classify } from '../analysis/taxonomy.mjs';
|
|
15
|
+
|
|
16
|
+
/** framework -> where that framework's runners conventionally write JUnit XML. */
|
|
17
|
+
export const RESULT_GLOBS = {
|
|
18
|
+
selenium: ['**/target/surefire-reports/*.xml', '**/test-results/*.xml', '**/junit*.xml'],
|
|
19
|
+
cypress: ['**/results/*.xml', '**/cypress/results/*.xml', '**/junit*.xml'],
|
|
20
|
+
webdriverio: ['**/junit*.xml', '**/results/*.xml', '**/test-results/*.xml'],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const FRAMEWORKS = Object.keys(RESULT_GLOBS);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Normalize a run into the shared result shape.
|
|
27
|
+
*
|
|
28
|
+
* Identical output for every framework — that is the point. The `framework`
|
|
29
|
+
* argument selects nothing in the parsing; it exists so a caller can be explicit
|
|
30
|
+
* about provenance and so an unknown name is refused rather than silently treated
|
|
31
|
+
* as one of the three.
|
|
32
|
+
*/
|
|
33
|
+
export function normalize(framework, junitPath) {
|
|
34
|
+
if (!Object.prototype.hasOwnProperty.call(RESULT_GLOBS, framework)) {
|
|
35
|
+
throw new Error(`unknown JUnit framework: ${framework} (known: ${FRAMEWORKS.join(', ')})`);
|
|
36
|
+
}
|
|
37
|
+
return parseJUnit(junitPath);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Classify a failure using the shared taxonomy.
|
|
42
|
+
*
|
|
43
|
+
* There is no per-framework classifier, because failure classes are
|
|
44
|
+
* framework-agnostic: a missing element is a locator failure whoever reported it.
|
|
45
|
+
*/
|
|
46
|
+
export function classifyFailure(message, httpStatus = null) {
|
|
47
|
+
return classify(message, httpStatus);
|
|
48
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Playwright-specific analyzers: trace and JSON report.
|
|
2
|
+
//
|
|
3
|
+
// This is the Playwright *adapter* for the analysis platform. It depends on the
|
|
4
|
+
// framework-agnostic analysis core for the evidence model, redaction, and the
|
|
5
|
+
// failure taxonomy; it adds only what is Playwright-specific — the shape of a
|
|
6
|
+
// trace.zip and of Playwright's JSON reporter. Everything it emits is in the shared
|
|
7
|
+
// normalized shape, so nothing downstream knows it was Playwright.
|
|
8
|
+
//
|
|
9
|
+
// Framework knowledge stays inside the adapter: the core never grows a
|
|
10
|
+
// `--framework` flag (ADR-0013).
|
|
11
|
+
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
|
|
14
|
+
import { MalformedArtifact } from '../analysis/junit.mjs';
|
|
15
|
+
import { redactText } from '../analysis/redaction.mjs';
|
|
16
|
+
import * as taxonomy from '../analysis/taxonomy.mjs';
|
|
17
|
+
import { openZip, isZip, ZipError } from '../analysis/zip.mjs';
|
|
18
|
+
|
|
19
|
+
export const FRAMEWORK = 'playwright';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse Playwright's JSON reporter output into the normalized result shape.
|
|
23
|
+
*
|
|
24
|
+
* Mirrors the JUnit parser's output (`{tests, executed}`) so a Playwright run
|
|
25
|
+
* normalizes identically whether it emitted JSON or JUnit.
|
|
26
|
+
*/
|
|
27
|
+
export function parseReport(path) {
|
|
28
|
+
let data;
|
|
29
|
+
try {
|
|
30
|
+
data = JSON.parse(fs.readFileSync(path, 'utf8'));
|
|
31
|
+
} catch (error) {
|
|
32
|
+
throw new MalformedArtifact(`could not parse Playwright report at ${path}: ${error.message}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const executed = [];
|
|
36
|
+
for (const suite of data.suites ?? []) walkSuite(suite, executed);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
tests: {
|
|
40
|
+
total: executed.length,
|
|
41
|
+
passed: executed.filter((e) => e.status === 'passed').length,
|
|
42
|
+
failed: executed.filter((e) => e.status === 'failed').length,
|
|
43
|
+
skipped: executed.filter((e) => e.status === 'skipped').length,
|
|
44
|
+
},
|
|
45
|
+
executed,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function walkSuite(suite, executed) {
|
|
50
|
+
for (const spec of suite.specs ?? []) {
|
|
51
|
+
for (const test of spec.tests ?? []) {
|
|
52
|
+
const results = test.results ?? [];
|
|
53
|
+
const status = statusOf(results);
|
|
54
|
+
let message = '';
|
|
55
|
+
if (status === 'failed' && results.length > 0) {
|
|
56
|
+
const errors = results[results.length - 1].errors ?? [];
|
|
57
|
+
if (errors.length > 0) {
|
|
58
|
+
message = redactText((errors[0].message ?? '').trim());
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const entry = {
|
|
62
|
+
title: spec.title ?? '',
|
|
63
|
+
file: suite.file ?? '',
|
|
64
|
+
status,
|
|
65
|
+
durationMs: results.length > 0 ? (results[results.length - 1].duration ?? 0) : 0,
|
|
66
|
+
retries: Math.max(0, results.length - 1),
|
|
67
|
+
};
|
|
68
|
+
if (message) entry.message = message;
|
|
69
|
+
executed.push(entry);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (const child of suite.suites ?? []) walkSuite(child, executed);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function statusOf(results) {
|
|
76
|
+
if (results.length === 0) return 'skipped';
|
|
77
|
+
const final = results[results.length - 1].status;
|
|
78
|
+
if (final === 'passed' || final === 'expected') return results.length > 1 ? 'flaky' : 'passed';
|
|
79
|
+
if (final === 'skipped') return 'skipped';
|
|
80
|
+
return 'failed';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extract a deterministic summary from a Playwright trace.zip.
|
|
85
|
+
*
|
|
86
|
+
* A trace is a zip of newline-delimited JSON event files. This lists the actions,
|
|
87
|
+
* surfaces the last error, and counts console and network events — enough for a
|
|
88
|
+
* diagnostic skill to reason over, without decoding the full binary.
|
|
89
|
+
*/
|
|
90
|
+
export function analyzeTrace(path) {
|
|
91
|
+
let archive;
|
|
92
|
+
try {
|
|
93
|
+
const buffer = fs.readFileSync(path);
|
|
94
|
+
if (!isZip(buffer)) throw new MalformedArtifact(`not a valid trace zip: ${path}`);
|
|
95
|
+
archive = openZip(path);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error instanceof MalformedArtifact) throw error;
|
|
98
|
+
throw new MalformedArtifact(`not a valid trace zip: ${path}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const actions = [];
|
|
102
|
+
const errors = [];
|
|
103
|
+
let console = 0;
|
|
104
|
+
let network = 0;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const named = archive.entries.filter(
|
|
108
|
+
(entry) => entry.name.endsWith('.trace') || entry.name.endsWith('.jsonl') || entry.name.includes('trace'),
|
|
109
|
+
);
|
|
110
|
+
for (const entry of named.length > 0 ? named : archive.entries) {
|
|
111
|
+
const text = archive.read(entry).toString('utf8');
|
|
112
|
+
for (const line of text.split('\n')) {
|
|
113
|
+
const raw = line.trim();
|
|
114
|
+
if (!raw || !raw.startsWith('{')) continue;
|
|
115
|
+
let event;
|
|
116
|
+
try {
|
|
117
|
+
event = JSON.parse(raw);
|
|
118
|
+
} catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const kind = event.type ?? event.kind;
|
|
122
|
+
if (kind === 'action' || kind === 'before') {
|
|
123
|
+
actions.push(redactText(String(event.apiName ?? event.method ?? '')));
|
|
124
|
+
} else if (kind === 'console') {
|
|
125
|
+
console += 1;
|
|
126
|
+
} else if (kind === 'resource' || kind === 'network' || kind === 'http') {
|
|
127
|
+
network += 1;
|
|
128
|
+
}
|
|
129
|
+
if (event.error || kind === 'error') {
|
|
130
|
+
const message = event.error && typeof event.error === 'object'
|
|
131
|
+
? event.error.message
|
|
132
|
+
: event.message;
|
|
133
|
+
if (message) errors.push(redactText(String(message)));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error instanceof ZipError) {
|
|
139
|
+
throw new MalformedArtifact(`could not read trace ${path}: ${error.message}`);
|
|
140
|
+
}
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const lastError = errors.length > 0 ? errors[errors.length - 1] : '';
|
|
145
|
+
const classified = lastError
|
|
146
|
+
? taxonomy.classify(lastError)
|
|
147
|
+
: { classification: taxonomy.UNKNOWN, confidence: 0.2, reason: 'No error found in trace.' };
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
actions: actions.filter(Boolean),
|
|
151
|
+
consoleEvents: console,
|
|
152
|
+
networkEvents: network,
|
|
153
|
+
errors,
|
|
154
|
+
classification: classified.classification,
|
|
155
|
+
confidence: classified.confidence,
|
|
156
|
+
reason: classified.reason,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@qa-engineer/engine",
|
|
3
|
+
"version": "0.9.2",
|
|
4
|
+
"description": "Deterministic QA analysis engine — zero dependencies, Node only",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"private": true,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=18.17"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
"./analysis/*": "./lib/analysis/*.mjs"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -6,8 +6,8 @@ import { EXIT, SHARED_SKILLS_DIR } from '../constants.mjs';
|
|
|
6
6
|
import { resolveSourceRoot, resolveProjectRoot } from '../core/paths.mjs';
|
|
7
7
|
import { VERSION, SPEC_REVISION } from '../version.mjs';
|
|
8
8
|
import { AGENTS, resolveInstallTargets, listAgentIds } from '../agents/registry.mjs';
|
|
9
|
-
import { packHasBundles,
|
|
10
|
-
import { BUNDLE_DEST, BUNDLE_MANIFEST
|
|
9
|
+
import { packHasBundles, verifyEngine } from '../core/bundle.mjs';
|
|
10
|
+
import { BUNDLE_DEST, BUNDLE_MANIFEST } from '../core/manifest.mjs';
|
|
11
11
|
import { readLock } from '../core/lockfile.mjs';
|
|
12
12
|
import { createLogger } from '../core/logger.mjs';
|
|
13
13
|
import { parseCommonFlags } from '../cli/flags.mjs';
|
|
@@ -46,7 +46,6 @@ Each failure includes an exact repair command.`);
|
|
|
46
46
|
const detected = AGENTS.filter((a) => a.detect(root)).map((a) => a.id);
|
|
47
47
|
const targets = resolveInstallTargets(root, opts.agents).map((a) => a.id);
|
|
48
48
|
const lock = readLock(root);
|
|
49
|
-
const python = findPython();
|
|
50
49
|
const gitOk = hasGit(root);
|
|
51
50
|
const scan = scanProject(root, opts.agents);
|
|
52
51
|
const validation = lock ? validateInstall(root) : null;
|
|
@@ -62,13 +61,6 @@ Each failure includes an exact repair command.`);
|
|
|
62
61
|
message: `Node ${process.version}`,
|
|
63
62
|
hint: nodeMajor >= 18 ? undefined : 'upgrade to Node.js 18.18+',
|
|
64
63
|
});
|
|
65
|
-
checklist.push({
|
|
66
|
-
section: 'Environment',
|
|
67
|
-
id: 'python',
|
|
68
|
-
ok: Boolean(python),
|
|
69
|
-
message: python ? `${python.bin} (${python.version})` : 'Python not found',
|
|
70
|
-
hint: python ? undefined : 'install Python 3.8+ for analysis/diagnostics engines',
|
|
71
|
-
});
|
|
72
64
|
checklist.push({
|
|
73
65
|
section: 'Environment',
|
|
74
66
|
id: 'git',
|
|
@@ -114,18 +106,18 @@ Each failure includes an exact repair command.`);
|
|
|
114
106
|
});
|
|
115
107
|
}
|
|
116
108
|
|
|
117
|
-
// Deep
|
|
118
|
-
|
|
109
|
+
// Deep check: the bundled engine must RUN, not merely be present. It runs under
|
|
110
|
+
// this same Node, so there is no interpreter to look for and no reason to skip.
|
|
111
|
+
if (lock && packHasBundles()) {
|
|
119
112
|
const bundledSkill = Object.keys(BUNDLE_MANIFEST)[0];
|
|
120
113
|
const libDir = path.join(root, SHARED_SKILLS_DIR, bundledSkill, BUNDLE_DEST);
|
|
121
114
|
const claudeLib = path.join(root, '.claude', 'skills', bundledSkill, BUNDLE_DEST);
|
|
122
115
|
const resolvedLib = fs.existsSync(libDir) ? libDir : fs.existsSync(claudeLib) ? claudeLib : null;
|
|
123
116
|
if (resolvedLib) {
|
|
124
|
-
const
|
|
125
|
-
const result = verifyImports({ pythonBin: python.bin, libDir: resolvedLib, packages });
|
|
117
|
+
const result = verifyEngine({ libDir: resolvedLib });
|
|
126
118
|
checklist.push({
|
|
127
119
|
section: 'QA Engineer Pack',
|
|
128
|
-
id: 'engine-
|
|
120
|
+
id: 'engine-runs',
|
|
129
121
|
ok: result.ok,
|
|
130
122
|
message: result.ok ? 'bundled engine runs cleanly' : `engine check failed: ${result.stderr}`,
|
|
131
123
|
hint: result.ok ? undefined : 'run: qa repair',
|
|
@@ -149,7 +141,6 @@ Each failure includes an exact repair command.`);
|
|
|
149
141
|
installTargets: targets,
|
|
150
142
|
lockfilePresent: Boolean(lock),
|
|
151
143
|
lockfilePack: lock?.pack ?? null,
|
|
152
|
-
python,
|
|
153
144
|
bundles: packHasBundles(),
|
|
154
145
|
node: process.version,
|
|
155
146
|
git: gitOk,
|
|
@@ -226,6 +226,30 @@ export async function executeInstall({
|
|
|
226
226
|
tx.write(entry.path, entry.content);
|
|
227
227
|
}
|
|
228
228
|
|
|
229
|
+
// Files the previous install owned that this one does not: remove them.
|
|
230
|
+
//
|
|
231
|
+
// Without this, anything that leaves the pack between versions stays on disk
|
|
232
|
+
// forever, and `verify` cannot see it — verify checks that lockfile entries are
|
|
233
|
+
// present and unmodified, and an orphan is in no lockfile. Upgrading 0.9.1 to
|
|
234
|
+
// 0.9.3, which replaced the Python engine with a Node one, left 154 dead Python
|
|
235
|
+
// files in the user's repository, reported as a clean install and ready to be
|
|
236
|
+
// committed.
|
|
237
|
+
//
|
|
238
|
+
// Only files the pack itself wrote are touched: the candidates come from the
|
|
239
|
+
// prior lockfile, which is the record of what this installer created. Each goes
|
|
240
|
+
// through the same Transaction as a write, so it is backed up first and restored
|
|
241
|
+
// if any later step fails.
|
|
242
|
+
const orphans = [];
|
|
243
|
+
if (priorLock && !dryRun) {
|
|
244
|
+
const stillOwned = new Set(unique.map((entry) => entry.path));
|
|
245
|
+
for (const previous of priorLock.files ?? []) {
|
|
246
|
+
if (previous.path === LOCKFILE || stillOwned.has(previous.path)) continue;
|
|
247
|
+
if (!fs.existsSync(path.join(root, previous.path))) continue;
|
|
248
|
+
orphans.push(previous.path);
|
|
249
|
+
tx.delete(previous.path);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
229
253
|
reportProgress(INSTALL_STEPS[1].label, 2);
|
|
230
254
|
reportProgress(INSTALL_STEPS[2].label, 3);
|
|
231
255
|
|
|
@@ -275,6 +299,9 @@ export async function executeInstall({
|
|
|
275
299
|
if (!json) logger.ok(`dry run: would write ${summary.written} file(s)`);
|
|
276
300
|
} else if (!json) {
|
|
277
301
|
logger.ok(`installed ${unique.length} file(s); lockfile ${lockPath(root)}`);
|
|
302
|
+
if (orphans.length > 0) {
|
|
303
|
+
logger.ok(`removed ${orphans.length} file(s) the previous version owned and this one does not`);
|
|
304
|
+
}
|
|
278
305
|
for (const step of INSTALL_STEPS) logger.ok(step.label);
|
|
279
306
|
reportInvocation(agents, logger);
|
|
280
307
|
reportFrameworkFit(root, logger);
|
|
@@ -288,6 +315,7 @@ export async function executeInstall({
|
|
|
288
315
|
skills: skills.length,
|
|
289
316
|
files: unique.length,
|
|
290
317
|
lockfile: dryRun ? null : LOCKFILE,
|
|
318
|
+
removed: orphans,
|
|
291
319
|
validation,
|
|
292
320
|
};
|
|
293
321
|
}
|