canary-test-cli 7.0.0 → 7.2.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/agents/skills/README.md +308 -0
- package/agents/skills/canary:generate.md +49 -0
- package/agents/skills/canary:init.md +37 -0
- package/agents/skills/canary:migrate.md +66 -0
- package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
- package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
- package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
- package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
- package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
- package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
- package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
- package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
- package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
- package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
- package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
- package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
- package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
- package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
- package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
- package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
- package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
- package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
- package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
- package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
- package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
- package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
- package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
- package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
- package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
- package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
- package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
- package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
- package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
- package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
- package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
- package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
- package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
- package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
- package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
- package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
- package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
- package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
- package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
- package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
- package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
- package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
- package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
- package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
- package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
- package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
- package/agents/skills/lib/parse-args.mjs +214 -0
- package/dist/engine/analysis/cli.js +116 -54
- package/dist/engine/analysis/engine.js +34 -16
- package/dist/engine/analysis/reports.js +5 -4
- package/dist/engine/cli-commands.js +252 -106
- package/dist/engine/cli-common.js +15 -24
- package/dist/engine/cli.core.js +37 -11
- package/dist/engine/cli.js +2 -2
- package/dist/engine/company-knowledge-cli.js +2 -2
- package/dist/engine/core/adoption.js +408 -0
- package/dist/engine/core/framework-probes.js +7 -7
- package/dist/engine/core/fs-glob.js +2 -2
- package/dist/engine/core/gate-result.js +17 -0
- package/dist/engine/core/migrator.js +9 -17
- package/dist/engine/core/pattern-matcher.js +23 -5
- package/dist/engine/core/persona.js +421 -0
- package/dist/engine/core/promotion-verdict.js +261 -0
- package/dist/engine/core/quality-scorer.js +15 -2
- package/dist/engine/core/reporter.js +1 -9
- package/dist/engine/core/skill-dispatch.js +115 -0
- package/dist/engine/core/skill-examples.js +392 -0
- package/dist/engine/core/skill-registry.js +59 -4
- package/dist/engine/core/skill-surfaces.js +307 -0
- package/dist/engine/core/static-linter.js +310 -38
- package/dist/engine/core/test-files.js +77 -0
- package/dist/engine/core/ticket-updater.js +1 -7
- package/dist/engine/core/vacuity-scanner.js +726 -0
- package/dist/engine/core/workflow-discovery.js +2 -8
- package/dist/engine/core/workspace-detect.js +7 -6
- package/dist/engine/data/personas/registry.json +36 -0
- package/dist/engine/guardian/adjudication.js +5 -5
- package/dist/engine/guardian/analysis-emit.js +19 -28
- package/dist/engine/guardian/cli.js +150 -51
- package/dist/engine/guardian/coverage.js +1 -1
- package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
- package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
- package/dist/engine/guardian/pr-check.js +322 -33
- package/dist/engine/guardian/pr-comment.js +4 -3
- package/dist/engine/history/cli.js +210 -6
- package/dist/engine/history/ndjson-store.js +9 -5
- package/dist/engine/history/record.js +34 -5
- package/dist/engine/history/run-recorder.js +165 -0
- package/dist/engine/history/schema.js +25 -7
- package/dist/engine/history/store.js +9 -0
- package/dist/engine/mcp-server.js +35 -13
- package/dist/engine/skills-cli.js +249 -23
- package/dist/engine/util/ensure-ascii.js +37 -0
- package/dist/engine/workflow-cli.js +6 -6
- package/dist/gate-result.d.ts +11 -0
- package/dist/gate-result.js +18 -0
- package/dist/reporters/testtracker.d.ts +1 -1
- package/dist/reporters/testtracker.js +1 -1
- package/dist/uninstall.js +12 -5
- package/package.json +3 -2
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// canary-test-reporter -- Playwright JSON -> Markdown + JSON report. Ported
|
|
3
|
+
// behavior-for-behavior from the Python original.
|
|
4
|
+
//
|
|
5
|
+
// --results <path> required: Playwright JSON results file
|
|
6
|
+
// --markdown-out <path> write Markdown report to file (stdout if neither
|
|
7
|
+
// --markdown-out nor --json-out is given)
|
|
8
|
+
// --json-out <path> write JSON report to file
|
|
9
|
+
//
|
|
10
|
+
// Exit code: 1 when any test failed, else 0.
|
|
11
|
+
//
|
|
12
|
+
// Invoked via `canary skills run canary-test-reporter -- --results <json>`.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
createParser,
|
|
18
|
+
formatUsageError,
|
|
19
|
+
EXIT_USAGE,
|
|
20
|
+
} from '../../../lib/parse-args.mjs';
|
|
21
|
+
import { parseResults } from './parse.mjs';
|
|
22
|
+
import { renderMarkdown } from './render.mjs';
|
|
23
|
+
import { renderJson } from './json_report.mjs';
|
|
24
|
+
|
|
25
|
+
const PREFIX = 'canary-test-reporter:';
|
|
26
|
+
|
|
27
|
+
const USAGE =
|
|
28
|
+
'usage: canary-test-reporter [-h] --results PATH [--markdown-out PATH]\n' +
|
|
29
|
+
' [--json-out PATH]\n' +
|
|
30
|
+
'\n' +
|
|
31
|
+
'Playwright JSON results -> Markdown + JSON test report.';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `--results` is required; the optional writers default to null so main can
|
|
35
|
+
* tell "not asked for" from "asked for, empty". The four shared invariants live
|
|
36
|
+
* in the shared parser (#479).
|
|
37
|
+
*/
|
|
38
|
+
export const CLI_SPEC = {
|
|
39
|
+
prog: 'canary-test-reporter',
|
|
40
|
+
values: {
|
|
41
|
+
'--results': { key: 'results' },
|
|
42
|
+
'--markdown-out': { key: 'markdownOut' },
|
|
43
|
+
'--json-out': { key: 'jsonOut' },
|
|
44
|
+
},
|
|
45
|
+
required: ['--results'],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const parseArgs = createParser(CLI_SPEC);
|
|
49
|
+
|
|
50
|
+
export function main(argv = []) {
|
|
51
|
+
const { opts: args, help, error } = parseArgs(argv);
|
|
52
|
+
|
|
53
|
+
if (help) {
|
|
54
|
+
console.log(USAGE);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
if (error) {
|
|
58
|
+
console.error(formatUsageError(CLI_SPEC.prog, error));
|
|
59
|
+
return EXIT_USAGE;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!fs.existsSync(args.results)) {
|
|
63
|
+
console.error(`${PREFIX} results file not found: ${args.results}`);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let data;
|
|
68
|
+
try {
|
|
69
|
+
data = parseResults(args.results);
|
|
70
|
+
} catch (exc) {
|
|
71
|
+
console.error(`${PREFIX} ${exc.message}`);
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const markdown = renderMarkdown(data);
|
|
76
|
+
|
|
77
|
+
if (args.markdownOut) {
|
|
78
|
+
fs.writeFileSync(args.markdownOut, markdown, 'utf8');
|
|
79
|
+
} else if (!args.jsonOut) {
|
|
80
|
+
// Python `print(markdown, end="")` -- no trailing newline is added.
|
|
81
|
+
process.stdout.write(markdown);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (args.jsonOut) {
|
|
85
|
+
fs.writeFileSync(args.jsonOut, renderJson(data), 'utf8');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return data.failed > 0 ? 1 : 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Direct execution (the skill runner execs this file via its shebang).
|
|
92
|
+
//
|
|
93
|
+
// `process.exitCode`, not `process.exit()`: a large `--json` payload exceeds
|
|
94
|
+
// the pipe buffer, and `process.exit` tears the process down mid-write, leaving
|
|
95
|
+
// truncated JSON that still exits 0 (#791).
|
|
96
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
97
|
+
process.exitCode = main(process.argv.slice(2));
|
|
98
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// json_report -- JSON serializer for Playwright test results (self-contained,
|
|
2
|
+
// pure). Ported behavior-for-behavior from the Python original.
|
|
3
|
+
//
|
|
4
|
+
// The field NAMES and ORDER are a downstream contract (a flat, TCM-row-ish
|
|
5
|
+
// shape); do not rename or reorder them.
|
|
6
|
+
//
|
|
7
|
+
// Accepted numeric-repr limitation (input-shape-dependent, consistent with the
|
|
8
|
+
// canary-instrument port): Python json.dumps renders a whole-number FLOAT with a
|
|
9
|
+
// trailing ".0" (300.0 -> "300.0"), but JS Number cannot distinguish 300 from
|
|
10
|
+
// 300.0, so a whole-number float duration serializes without the ".0". This is
|
|
11
|
+
// unreachable in practice -- Playwright's reporter emits integer millisecond
|
|
12
|
+
// durations, and genuinely fractional floats (12.5) already agree byte-for-byte.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Serialize like Python's json.dumps(obj, indent=2) with the default
|
|
16
|
+
* ensure_ascii=True. JSON.stringify already matches the 2-space indentation and
|
|
17
|
+
* the empty-container ("[]"/"{}") forms, but leaves non-ASCII raw -- so escape
|
|
18
|
+
* every code unit above 0x7F to a \uXXXX sequence (astral chars become their two
|
|
19
|
+
* UTF-16 surrogate escapes, exactly as Python emits them). This keeps the JSON
|
|
20
|
+
* artifact byte-identical when a title/error/file carries an emoji, accented, or
|
|
21
|
+
* CJK character (or an em-dash inside a Playwright diff).
|
|
22
|
+
*/
|
|
23
|
+
function dumpsIndent2Ascii(obj) {
|
|
24
|
+
const nonAscii = /[\u0080-\uffff]/g;
|
|
25
|
+
return JSON.stringify(obj, null, 2).replace(
|
|
26
|
+
nonAscii,
|
|
27
|
+
(ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Serialize a ReportData to the pinned JSON report string (indent=2). */
|
|
32
|
+
export function renderJson(data) {
|
|
33
|
+
// Python: datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") -- second
|
|
34
|
+
// precision, no milliseconds. toISOString() carries `.mmmZ`; strip it so the
|
|
35
|
+
// emitted bytes match the Python format exactly.
|
|
36
|
+
const generatedAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
37
|
+
const output = {
|
|
38
|
+
version: 1,
|
|
39
|
+
generated_at: generatedAt,
|
|
40
|
+
summary: {
|
|
41
|
+
total: data.total,
|
|
42
|
+
passed: data.passed,
|
|
43
|
+
failed: data.failed,
|
|
44
|
+
flaky: data.flaky,
|
|
45
|
+
skipped: data.skipped,
|
|
46
|
+
duration_ms: data.duration_ms,
|
|
47
|
+
},
|
|
48
|
+
results: data.results.map((r) => ({
|
|
49
|
+
title: r.title,
|
|
50
|
+
status: r.status,
|
|
51
|
+
file: r.file,
|
|
52
|
+
line: r.line,
|
|
53
|
+
duration_ms: r.duration_ms,
|
|
54
|
+
error: r.error,
|
|
55
|
+
})),
|
|
56
|
+
};
|
|
57
|
+
return dumpsIndent2Ascii(output);
|
|
58
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// parse -- full-fidelity Playwright JSON parser (self-contained). Ported
|
|
2
|
+
// behavior-for-behavior from the Python original.
|
|
3
|
+
//
|
|
4
|
+
// Walks the Playwright JSON reporter's nested suites/specs/tests and classifies
|
|
5
|
+
// each test as passed, failed, flaky, or skipped. A failed/unexpected test with
|
|
6
|
+
// a passing retry is flaky and carries no error. Leading non-JSON banners are
|
|
7
|
+
// stripped before parsing (matches the reporter's defensive indexOf('{')).
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {{title: string, status: string, file: (string|null),
|
|
13
|
+
* line: (number|null), duration_ms: (number|null),
|
|
14
|
+
* error: (string|null)}} TestResult
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Build a TestResult with the Python dataclass defaults (absent -> null). */
|
|
18
|
+
export function TestResult(fields) {
|
|
19
|
+
return {
|
|
20
|
+
title: fields.title,
|
|
21
|
+
status: fields.status,
|
|
22
|
+
file: fields.file ?? null,
|
|
23
|
+
line: fields.line ?? null,
|
|
24
|
+
duration_ms: fields.duration_ms ?? null,
|
|
25
|
+
error: fields.error ?? null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {{total: number, passed: number, failed: number, flaky: number,
|
|
31
|
+
* skipped: number, duration_ms: number,
|
|
32
|
+
* results: TestResult[]}} ReportData
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Build a ReportData (mirrors the Python dataclass; all fields required). */
|
|
36
|
+
export function ReportData(fields) {
|
|
37
|
+
return {
|
|
38
|
+
total: fields.total,
|
|
39
|
+
passed: fields.passed,
|
|
40
|
+
failed: fields.failed,
|
|
41
|
+
flaky: fields.flaky,
|
|
42
|
+
skipped: fields.skipped,
|
|
43
|
+
duration_ms: fields.duration_ms,
|
|
44
|
+
results: fields.results,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isObject(x) {
|
|
49
|
+
return x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Mirror Python dict.get: `.get` on a non-dict (str/list/number/None) raises
|
|
53
|
+
// AttributeError, which parseResults converts into a structured error. Here a
|
|
54
|
+
// non-object access throws TypeError, caught by the same conversion.
|
|
55
|
+
function dget(obj, key, dflt) {
|
|
56
|
+
if (!isObject(obj)) throw new TypeError('value is not an object');
|
|
57
|
+
return key in obj ? obj[key] : dflt;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Mirror Python's `container or []` for a for-loop target. Python truthiness
|
|
61
|
+
// differs from JS: an empty object `{}` and empty string `""` are FALSY in
|
|
62
|
+
// Python but TRUTHY in JS. A naive `x || []` would turn `{"suites": {}}` into a
|
|
63
|
+
// thrown "unexpected structure" (JS iterates the truthy `{}`) where Python
|
|
64
|
+
// quietly iterates nothing -- flipping a CI pass into a fail. So:
|
|
65
|
+
// - any Python-falsy value (null/undefined/false/0/""/[]/{}) -> [] (iterate nothing)
|
|
66
|
+
// - an Array -> the array
|
|
67
|
+
// - any other truthy value (non-empty object/string/number) -> returned as-is,
|
|
68
|
+
// so `for...of` / `.some` reproduces Python's iterate-then-`.get`-fails path
|
|
69
|
+
// (a string iterates its chars; a non-empty object/number is non-iterable and
|
|
70
|
+
// throws), which surfaces as the same "unexpected structure" error.
|
|
71
|
+
function asItems(v) {
|
|
72
|
+
if (Array.isArray(v)) return v;
|
|
73
|
+
if (v && typeof v === 'object') return Object.keys(v).length ? v : [];
|
|
74
|
+
if (v && typeof v !== 'object') return v; // non-empty string / truthy number
|
|
75
|
+
return []; // null/undefined/false/0/""
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Classify a raw Playwright status into passed/failed/flaky/skipped. */
|
|
79
|
+
function classify(rawStatus, testResults) {
|
|
80
|
+
if (rawStatus === 'skipped' || rawStatus === 'pending') return 'skipped';
|
|
81
|
+
if (rawStatus === 'passed' || rawStatus === 'expected') return 'passed';
|
|
82
|
+
if (rawStatus === 'flaky') return 'flaky';
|
|
83
|
+
if (rawStatus === 'failed' || rawStatus === 'unexpected') {
|
|
84
|
+
const hasPassingRetry = testResults.some((r) => {
|
|
85
|
+
const s = dget(r, 'status', undefined);
|
|
86
|
+
return s === 'passed' || s === 'expected';
|
|
87
|
+
});
|
|
88
|
+
return hasPassingRetry ? 'flaky' : 'failed';
|
|
89
|
+
}
|
|
90
|
+
return 'failed';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Parse a Playwright JSON results file into a full ReportData. */
|
|
94
|
+
export function parseResults(resultsPath) {
|
|
95
|
+
if (!fs.existsSync(resultsPath)) {
|
|
96
|
+
return ReportData({
|
|
97
|
+
total: 0,
|
|
98
|
+
passed: 0,
|
|
99
|
+
failed: 0,
|
|
100
|
+
flaky: 0,
|
|
101
|
+
skipped: 0,
|
|
102
|
+
duration_ms: 0,
|
|
103
|
+
results: [],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let text = fs.readFileSync(resultsPath, 'utf8');
|
|
108
|
+
const braceAt = text.indexOf('{');
|
|
109
|
+
if (braceAt > 0) text = text.slice(braceAt);
|
|
110
|
+
|
|
111
|
+
let data;
|
|
112
|
+
try {
|
|
113
|
+
data = JSON.parse(text);
|
|
114
|
+
} catch (exc) {
|
|
115
|
+
throw new Error(`results file is not valid JSON: ${exc.message}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!isObject(data)) {
|
|
119
|
+
throw new Error("results file's top-level value must be an object");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const results = [];
|
|
123
|
+
try {
|
|
124
|
+
for (const suite of asItems(data.suites)) {
|
|
125
|
+
processSuite(suite, results, '', '');
|
|
126
|
+
}
|
|
127
|
+
} catch (exc) {
|
|
128
|
+
if (exc instanceof TypeError) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`results file has an unexpected structure: ${exc.message}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
throw exc;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const passed = results.filter((r) => r.status === 'passed').length;
|
|
137
|
+
const failed = results.filter((r) => r.status === 'failed').length;
|
|
138
|
+
const flaky = results.filter((r) => r.status === 'flaky').length;
|
|
139
|
+
const skipped = results.filter((r) => r.status === 'skipped').length;
|
|
140
|
+
const totalMs = results.reduce((acc, r) => acc + (r.duration_ms || 0), 0);
|
|
141
|
+
|
|
142
|
+
return ReportData({
|
|
143
|
+
total: results.length,
|
|
144
|
+
passed,
|
|
145
|
+
failed,
|
|
146
|
+
flaky,
|
|
147
|
+
skipped,
|
|
148
|
+
duration_ms: totalMs,
|
|
149
|
+
results,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function processSuite(suite, results, parentPath, suiteFile) {
|
|
154
|
+
const suiteTitle = dget(suite, 'title', '');
|
|
155
|
+
const suitePath = parentPath ? `${parentPath} > ${suiteTitle}` : suiteTitle;
|
|
156
|
+
const currentFile = dget(suite, 'file', undefined) || suiteFile;
|
|
157
|
+
|
|
158
|
+
for (const child of asItems(dget(suite, 'suites', []))) {
|
|
159
|
+
processSuite(child, results, suitePath, currentFile);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const spec of asItems(dget(suite, 'specs', []))) {
|
|
163
|
+
const specTitle = dget(spec, 'title', '');
|
|
164
|
+
const specPath = `${suitePath} > ${specTitle}`;
|
|
165
|
+
const specLocation = dget(spec, 'location', undefined) || {};
|
|
166
|
+
for (const test of asItems(dget(spec, 'tests', []))) {
|
|
167
|
+
const rawTestTitle = dget(test, 'title', null);
|
|
168
|
+
const testLocation = dget(test, 'location', undefined) || {};
|
|
169
|
+
const testResults = asItems(dget(test, 'results', undefined));
|
|
170
|
+
const rawStatus = dget(test, 'status', 'unknown');
|
|
171
|
+
|
|
172
|
+
const status = classify(rawStatus, testResults);
|
|
173
|
+
|
|
174
|
+
let error = null;
|
|
175
|
+
let duration = null;
|
|
176
|
+
if (testResults.length) {
|
|
177
|
+
const last = testResults[testResults.length - 1];
|
|
178
|
+
duration = dget(last, 'duration', null);
|
|
179
|
+
if (status === 'failed') {
|
|
180
|
+
const err = dget(last, 'error', undefined) || {};
|
|
181
|
+
error = dget(err, 'message', null);
|
|
182
|
+
if (error === null || error === undefined) {
|
|
183
|
+
const errs = dget(last, 'errors', undefined) || [];
|
|
184
|
+
if (errs.length) error = dget(errs[0], 'message', null);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const title =
|
|
190
|
+
rawTestTitle && rawTestTitle !== specTitle
|
|
191
|
+
? `${specPath} > ${rawTestTitle}`
|
|
192
|
+
: specPath;
|
|
193
|
+
|
|
194
|
+
const testLine = dget(testLocation, 'line', null);
|
|
195
|
+
const line =
|
|
196
|
+
testLine !== null && testLine !== undefined
|
|
197
|
+
? testLine
|
|
198
|
+
: dget(specLocation, 'line', null);
|
|
199
|
+
|
|
200
|
+
results.push(
|
|
201
|
+
TestResult({
|
|
202
|
+
title,
|
|
203
|
+
status,
|
|
204
|
+
file:
|
|
205
|
+
dget(testLocation, 'file', undefined) ||
|
|
206
|
+
dget(specLocation, 'file', undefined) ||
|
|
207
|
+
currentFile ||
|
|
208
|
+
null,
|
|
209
|
+
line,
|
|
210
|
+
duration_ms: duration,
|
|
211
|
+
error,
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// render -- Markdown renderer for Playwright test results (self-contained,
|
|
2
|
+
// pure). Ported behavior-for-behavior from the Python original.
|
|
3
|
+
//
|
|
4
|
+
// Output glyphs are kept as \u escapes so the source stays ASCII while the
|
|
5
|
+
// emitted Markdown bytes are byte-identical to the Python original:
|
|
6
|
+
// MIDDOT U+00B7 MIDDLE DOT -- status-line separator
|
|
7
|
+
// ELLIPSIS U+2026 HORIZONTAL ELLIPSIS -- error-truncation marker
|
|
8
|
+
// EMDASH U+2014 EM DASH -- flaky-line separator
|
|
9
|
+
const MIDDOT = '\u00b7';
|
|
10
|
+
const ELLIPSIS = '\u2026';
|
|
11
|
+
const EMDASH = '\u2014';
|
|
12
|
+
|
|
13
|
+
const ERROR_LINE_LIMIT = 10;
|
|
14
|
+
|
|
15
|
+
// Reproduce Python str.splitlines(): it splits on \n, \r, \r\n, \v, \f, and the
|
|
16
|
+
// \x1c-\x1e / \x85 / \u2028 / \u2029 boundaries, and yields NO trailing empty
|
|
17
|
+
// element for a trailing boundary. A naive "a\n".split("\n") would add a
|
|
18
|
+
// spurious blank line inside the fenced error block, so match splitlines.
|
|
19
|
+
function splitlines(s) {
|
|
20
|
+
const re = /\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/g;
|
|
21
|
+
const parts = [];
|
|
22
|
+
let last = 0;
|
|
23
|
+
let m;
|
|
24
|
+
while ((m = re.exec(s)) !== null) {
|
|
25
|
+
parts.push(s.slice(last, m.index));
|
|
26
|
+
last = m.index + m[0].length;
|
|
27
|
+
}
|
|
28
|
+
if (last < s.length) parts.push(s.slice(last));
|
|
29
|
+
return parts;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Match Python's f"{ms/1000:.1f}" byte-for-byte, including round-half-to-EVEN
|
|
33
|
+
// on exact ties. JS `(ms/1000).toFixed(1)` rounds half-UP, so it diverges
|
|
34
|
+
// wherever ms/1000 lands on an exactly-representable .5-tenths tie -- which for
|
|
35
|
+
// integer milliseconds is precisely ms == 250 (mod 500) (x.25 / x.75, the only
|
|
36
|
+
// dyadic half-tenths): 250ms -> Python "0.2s" vs toFixed "0.3s". At those ties
|
|
37
|
+
// the two candidate tenths are floor(ms/100) and +1; Python picks the even one.
|
|
38
|
+
// Non-tie values are not exact halves, so toFixed already rounds them to the
|
|
39
|
+
// same nearest tenth Python does. Assumes the integer-ms contract (a fractional
|
|
40
|
+
// duration is rounded by toFixed, matching Python for all non-tie inputs).
|
|
41
|
+
function formatDurationSeconds(ms) {
|
|
42
|
+
if (ms % 500 === 250) {
|
|
43
|
+
const lowerTenths = Math.floor(ms / 100);
|
|
44
|
+
const tenths = lowerTenths % 2 === 0 ? lowerTenths : lowerTenths + 1;
|
|
45
|
+
return (tenths / 10).toFixed(1);
|
|
46
|
+
}
|
|
47
|
+
return (ms / 1000).toFixed(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatLocation(r) {
|
|
51
|
+
if (!r.file) return '';
|
|
52
|
+
return r.line ? '`' + r.file + ':' + r.line + '`' : '`' + r.file + '`';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Render a ReportData to the themed Markdown report. */
|
|
56
|
+
export function renderMarkdown(data) {
|
|
57
|
+
const parts = ['# Test Report\n\n'];
|
|
58
|
+
|
|
59
|
+
// Status line, e.g.:
|
|
60
|
+
// **2 failed** <MIDDOT> **1 flaky** <MIDDOT> **14 passed** <MIDDOT>
|
|
61
|
+
// **1 skipped** <MIDDOT> 18 tests <MIDDOT> 12.4s
|
|
62
|
+
const chips = [];
|
|
63
|
+
if (data.failed) chips.push(`**${data.failed} failed**`);
|
|
64
|
+
if (data.flaky) chips.push(`**${data.flaky} flaky**`);
|
|
65
|
+
if (data.passed) chips.push(`**${data.passed} passed**`);
|
|
66
|
+
if (data.skipped) chips.push(`**${data.skipped} skipped**`);
|
|
67
|
+
const durationS = formatDurationSeconds(data.duration_ms);
|
|
68
|
+
let statusLine = `${data.total} tests ${MIDDOT} ${durationS}s\n`;
|
|
69
|
+
if (chips.length) {
|
|
70
|
+
statusLine = chips.join(` ${MIDDOT} `) + ` ${MIDDOT} ` + statusLine;
|
|
71
|
+
}
|
|
72
|
+
parts.push(statusLine);
|
|
73
|
+
|
|
74
|
+
// Failed section
|
|
75
|
+
const failed = data.results.filter((r) => r.status === 'failed');
|
|
76
|
+
if (failed.length) {
|
|
77
|
+
parts.push(`\n## Failed (${failed.length})\n`);
|
|
78
|
+
for (const r of failed) {
|
|
79
|
+
parts.push(`\n### ${r.title}\n`);
|
|
80
|
+
const loc = formatLocation(r);
|
|
81
|
+
if (loc) parts.push(`\n${loc}\n`);
|
|
82
|
+
if (r.error) {
|
|
83
|
+
let lines = splitlines(r.error);
|
|
84
|
+
if (lines.length > ERROR_LINE_LIMIT) {
|
|
85
|
+
lines = lines
|
|
86
|
+
.slice(0, ERROR_LINE_LIMIT)
|
|
87
|
+
.concat([`${ELLIPSIS} (truncated)`]);
|
|
88
|
+
}
|
|
89
|
+
parts.push(`\n\`\`\`\n${lines.join('\n')}\n\`\`\`\n`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Flaky section
|
|
95
|
+
const flaky = data.results.filter((r) => r.status === 'flaky');
|
|
96
|
+
if (flaky.length) {
|
|
97
|
+
parts.push(`\n## Flaky (${flaky.length})\n`);
|
|
98
|
+
for (const r of flaky) {
|
|
99
|
+
const loc = formatLocation(r);
|
|
100
|
+
parts.push(`\n- ${loc ? loc + ` ${EMDASH} ` : ''}${r.title}\n`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Summary table
|
|
105
|
+
parts.push('\n## Summary\n');
|
|
106
|
+
parts.push('\n| Status | Count |\n| --- | --- |\n');
|
|
107
|
+
parts.push(`| Passed | ${data.passed} |\n`);
|
|
108
|
+
parts.push(`| Failed | ${data.failed} |\n`);
|
|
109
|
+
parts.push(`| Flaky | ${data.flaky} |\n`);
|
|
110
|
+
parts.push(`| Skipped | ${data.skipped} |\n`);
|
|
111
|
+
parts.push(`| **Total** | **${data.total}** |\n`);
|
|
112
|
+
|
|
113
|
+
return parts.join('');
|
|
114
|
+
}
|