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.
Files changed (122) hide show
  1. package/agents/skills/README.md +308 -0
  2. package/agents/skills/canary:generate.md +49 -0
  3. package/agents/skills/canary:init.md +37 -0
  4. package/agents/skills/canary:migrate.md +66 -0
  5. package/agents/skills/claude-code/canary-add-framework/SKILL.md +248 -0
  6. package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
  7. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  11. package/agents/skills/claude-code/canary-cassandra/SKILL.md +180 -0
  12. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +268 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  14. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +161 -0
  15. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  16. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  17. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  18. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  19. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  21. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  22. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  27. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  28. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  29. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +196 -0
  30. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  31. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  32. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  37. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  38. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  42. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +129 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  44. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  45. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  46. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  52. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  53. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  54. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  55. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  56. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  57. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  58. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  59. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  60. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  61. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  62. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  63. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  64. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  65. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  66. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  67. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  68. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  69. package/agents/skills/lib/parse-args.mjs +214 -0
  70. package/dist/engine/analysis/cli.js +116 -54
  71. package/dist/engine/analysis/engine.js +34 -16
  72. package/dist/engine/analysis/reports.js +5 -4
  73. package/dist/engine/cli-commands.js +252 -106
  74. package/dist/engine/cli-common.js +15 -24
  75. package/dist/engine/cli.core.js +37 -11
  76. package/dist/engine/cli.js +2 -2
  77. package/dist/engine/company-knowledge-cli.js +2 -2
  78. package/dist/engine/core/adoption.js +408 -0
  79. package/dist/engine/core/framework-probes.js +7 -7
  80. package/dist/engine/core/fs-glob.js +2 -2
  81. package/dist/engine/core/gate-result.js +17 -0
  82. package/dist/engine/core/migrator.js +9 -17
  83. package/dist/engine/core/pattern-matcher.js +23 -5
  84. package/dist/engine/core/persona.js +421 -0
  85. package/dist/engine/core/promotion-verdict.js +261 -0
  86. package/dist/engine/core/quality-scorer.js +15 -2
  87. package/dist/engine/core/reporter.js +1 -9
  88. package/dist/engine/core/skill-dispatch.js +115 -0
  89. package/dist/engine/core/skill-examples.js +392 -0
  90. package/dist/engine/core/skill-registry.js +59 -4
  91. package/dist/engine/core/skill-surfaces.js +307 -0
  92. package/dist/engine/core/static-linter.js +310 -38
  93. package/dist/engine/core/test-files.js +77 -0
  94. package/dist/engine/core/ticket-updater.js +1 -7
  95. package/dist/engine/core/vacuity-scanner.js +726 -0
  96. package/dist/engine/core/workflow-discovery.js +2 -8
  97. package/dist/engine/core/workspace-detect.js +7 -6
  98. package/dist/engine/data/personas/registry.json +36 -0
  99. package/dist/engine/guardian/adjudication.js +5 -5
  100. package/dist/engine/guardian/analysis-emit.js +19 -28
  101. package/dist/engine/guardian/cli.js +150 -51
  102. package/dist/engine/guardian/coverage.js +1 -1
  103. package/dist/engine/guardian/diff-coverage/heuristic-tier.js +1 -1
  104. package/dist/engine/guardian/diff-coverage/orchestrator.js +2 -2
  105. package/dist/engine/guardian/pr-check.js +322 -33
  106. package/dist/engine/guardian/pr-comment.js +4 -3
  107. package/dist/engine/history/cli.js +210 -6
  108. package/dist/engine/history/ndjson-store.js +9 -5
  109. package/dist/engine/history/record.js +34 -5
  110. package/dist/engine/history/run-recorder.js +165 -0
  111. package/dist/engine/history/schema.js +25 -7
  112. package/dist/engine/history/store.js +9 -0
  113. package/dist/engine/mcp-server.js +35 -13
  114. package/dist/engine/skills-cli.js +249 -23
  115. package/dist/engine/util/ensure-ascii.js +37 -0
  116. package/dist/engine/workflow-cli.js +6 -6
  117. package/dist/gate-result.d.ts +11 -0
  118. package/dist/gate-result.js +18 -0
  119. package/dist/reporters/testtracker.d.ts +1 -1
  120. package/dist/reporters/testtracker.js +1 -1
  121. package/dist/uninstall.js +12 -5
  122. 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
+ }