canary-test-cli 7.1.0 → 8.0.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 (128) hide show
  1. package/agents/skills/README.md +327 -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-batwoman/SKILL.md +119 -0
  7. package/agents/skills/claude-code/canary-blackhawk/SKILL.md +170 -0
  8. package/agents/skills/claude-code/canary-blackhawk/scripts/cli.mjs +188 -0
  9. package/agents/skills/claude-code/canary-blackhawk/scripts/rules.mjs +120 -0
  10. package/agents/skills/claude-code/canary-blackhawk/scripts/scanner.mjs +244 -0
  11. package/agents/skills/claude-code/canary-blackhawk/scripts/string-literals.mjs +116 -0
  12. package/agents/skills/claude-code/canary-cassandra/SKILL.md +187 -0
  13. package/agents/skills/claude-code/canary-cassandra/scripts/cli.mjs +270 -0
  14. package/agents/skills/claude-code/canary-cassandra/scripts/engine.mjs +95 -0
  15. package/agents/skills/claude-code/canary-ci-ready/SKILL.md +178 -0
  16. package/agents/skills/claude-code/canary-ci-ready/skill.yaml +14 -0
  17. package/agents/skills/claude-code/canary-company-knowledge/SKILL.md +196 -0
  18. package/agents/skills/claude-code/canary-critical-areas/SKILL.md +142 -0
  19. package/agents/skills/claude-code/canary-critical-areas/skill.yaml +16 -0
  20. package/agents/skills/claude-code/canary-edge-case-discovery/SKILL.md +160 -0
  21. package/agents/skills/claude-code/canary-edge-case-discovery/skill.yaml +16 -0
  22. package/agents/skills/claude-code/canary-fail-fast/SKILL.md +75 -0
  23. package/agents/skills/claude-code/canary-fail-fast/scripts/cli.mjs +118 -0
  24. package/agents/skills/claude-code/canary-fail-fast/scripts/digest.mjs +69 -0
  25. package/agents/skills/claude-code/canary-fail-fast/scripts/failures.mjs +60 -0
  26. package/agents/skills/claude-code/canary-fail-fast/scripts/fastfail_check.mjs +43 -0
  27. package/agents/skills/claude-code/canary-fail-fast/scripts/parse.mjs +149 -0
  28. package/agents/skills/claude-code/canary-failure-impact/SKILL.md +153 -0
  29. package/agents/skills/claude-code/canary-failure-impact/skill.yaml +15 -0
  30. package/agents/skills/claude-code/canary-fleet-health/SKILL.md +197 -0
  31. package/agents/skills/claude-code/canary-generate-test/SKILL.md +185 -0
  32. package/agents/skills/claude-code/canary-instrument/SKILL.md +157 -0
  33. package/agents/skills/claude-code/canary-instrument/scripts/cli.mjs +178 -0
  34. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs +96 -0
  35. package/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/playwright-fixture.ts +44 -0
  36. package/agents/skills/claude-code/canary-instrument/scripts/run_types.mjs +81 -0
  37. package/agents/skills/claude-code/canary-instrument/scripts/span_reader.mjs +187 -0
  38. package/agents/skills/claude-code/canary-katana/SKILL.md +243 -0
  39. package/agents/skills/claude-code/canary-katana/scripts/alarm.mjs +296 -0
  40. package/agents/skills/claude-code/canary-katana/scripts/cli.mjs +247 -0
  41. package/agents/skills/claude-code/canary-katana/scripts/diffscan.mjs +0 -0
  42. package/agents/skills/claude-code/canary-katana/scripts/ledger.mjs +183 -0
  43. package/agents/skills/claude-code/canary-pr-guardian/SKILL.md +144 -0
  44. package/agents/skills/claude-code/canary-pr-guardian/skill.yaml +17 -0
  45. package/agents/skills/claude-code/canary-promote-test/SKILL.md +228 -0
  46. package/agents/skills/claude-code/canary-savant/SKILL.md +233 -0
  47. package/agents/skills/claude-code/canary-savant/scripts/cli.mjs +274 -0
  48. package/agents/skills/claude-code/canary-savant/scripts/restoration.mjs +274 -0
  49. package/agents/skills/claude-code/canary-savant/scripts/rules.mjs +168 -0
  50. package/agents/skills/claude-code/canary-savant/scripts/runner.mjs +572 -0
  51. package/agents/skills/claude-code/canary-savant/scripts/scanner.mjs +374 -0
  52. package/agents/skills/claude-code/canary-savant/scripts/string-literals.mjs +116 -0
  53. package/agents/skills/claude-code/canary-screech/SKILL.md +109 -0
  54. package/agents/skills/claude-code/canary-screech/scripts/blast.mjs +125 -0
  55. package/agents/skills/claude-code/canary-screech/scripts/cli.mjs +128 -0
  56. package/agents/skills/claude-code/canary-screech/scripts/cluster.mjs +97 -0
  57. package/agents/skills/claude-code/canary-screech/scripts/history.mjs +73 -0
  58. package/agents/skills/claude-code/canary-screech/scripts/redness.mjs +94 -0
  59. package/agents/skills/claude-code/canary-setup-harness/SKILL.md +263 -0
  60. package/agents/skills/claude-code/canary-shadow/SKILL.md +131 -0
  61. package/agents/skills/claude-code/canary-shadow/scripts/cases.example.json +32 -0
  62. package/agents/skills/claude-code/canary-shadow/scripts/cli.mjs +195 -0
  63. package/agents/skills/claude-code/canary-ship/SKILL.md +177 -0
  64. package/agents/skills/claude-code/canary-ship/skill.yaml +16 -0
  65. package/agents/skills/claude-code/canary-strix/SKILL.md +130 -0
  66. package/agents/skills/claude-code/canary-strix/scripts/cli.mjs +255 -0
  67. package/agents/skills/claude-code/canary-strix/scripts/scanner.mjs +252 -0
  68. package/agents/skills/claude-code/canary-strix/scripts/terms.mjs +132 -0
  69. package/agents/skills/claude-code/canary-test-pipeline/SKILL.md +159 -0
  70. package/agents/skills/claude-code/canary-test-pipeline/skill.yaml +19 -0
  71. package/agents/skills/claude-code/canary-test-reporter/SKILL.md +138 -0
  72. package/agents/skills/claude-code/canary-test-reporter/scripts/cli.mjs +98 -0
  73. package/agents/skills/claude-code/canary-test-reporter/scripts/json_report.mjs +58 -0
  74. package/agents/skills/claude-code/canary-test-reporter/scripts/parse.mjs +216 -0
  75. package/agents/skills/claude-code/canary-test-reporter/scripts/render.mjs +114 -0
  76. package/agents/skills/lib/parse-args.mjs +275 -0
  77. package/dist/engine/analysis/batwoman/audit.js +39 -0
  78. package/dist/engine/analysis/batwoman/closure.js +159 -0
  79. package/dist/engine/analysis/batwoman/gh-history.js +119 -0
  80. package/dist/engine/analysis/batwoman/probes.js +195 -0
  81. package/dist/engine/analysis/batwoman/registry.js +142 -0
  82. package/dist/engine/analysis/batwoman/render.js +194 -0
  83. package/dist/engine/analysis/batwoman/run-window.js +122 -0
  84. package/dist/engine/analysis/batwoman/text.js +84 -0
  85. package/dist/engine/analysis/batwoman/triggers.js +122 -0
  86. package/dist/engine/analysis/batwoman/verdict.js +64 -0
  87. package/dist/engine/analysis/cli.js +47 -14
  88. package/dist/engine/analysis/gh-flaky/gh-run-attempts.js +206 -0
  89. package/dist/engine/batwoman-cli.js +119 -0
  90. package/dist/engine/ci-ready-cli.js +71 -0
  91. package/dist/engine/cli-commands.js +49 -72
  92. package/dist/engine/cli.core.js +16 -0
  93. package/dist/engine/company-knowledge-cli.js +10 -2
  94. package/dist/engine/core/ci-ready.js +112 -0
  95. package/dist/engine/core/company-knowledge.js +8 -0
  96. package/dist/engine/core/migrator.js +147 -20
  97. package/dist/engine/core/permission-matrix.js +219 -0
  98. package/dist/engine/core/quality-scorer.js +27 -19
  99. package/dist/engine/core/scaling-curve.js +143 -0
  100. package/dist/engine/core/skill-dispatch.js +115 -0
  101. package/dist/engine/core/skill-examples.js +103 -3
  102. package/dist/engine/core/skill-registry.js +59 -4
  103. package/dist/engine/core/string-literals.js +3 -1
  104. package/dist/engine/core/test-files.js +77 -0
  105. package/dist/engine/core/vacuity-scanner.js +330 -15
  106. package/dist/engine/core/workflow-discovery.js +41 -23
  107. package/dist/engine/guardian/adjudication-github.js +136 -0
  108. package/dist/engine/guardian/adjudication.js +119 -340
  109. package/dist/engine/guardian/analysis-emit.js +7 -2
  110. package/dist/engine/guardian/cli.js +277 -249
  111. package/dist/engine/guardian/coverage.js +2 -1
  112. package/dist/engine/guardian/diff-coverage/coverage-delta.js +162 -0
  113. package/dist/engine/guardian/diff-coverage/formats/cobertura.js +45 -1
  114. package/dist/engine/guardian/diff-coverage/orchestrator.js +25 -21
  115. package/dist/engine/guardian/diff-coverage/paths.js +5 -9
  116. package/dist/engine/guardian/diff-coverage/report-tier.js +88 -12
  117. package/dist/engine/guardian/diff-extractor.js +31 -32
  118. package/dist/engine/guardian/pr-check.js +354 -223
  119. package/dist/engine/guardian/pr-comment.js +35 -58
  120. package/dist/engine/guardian/weak-test.js +236 -0
  121. package/dist/engine/mcp-server.js +67 -4
  122. package/dist/engine/permission-matrix-cli.js +51 -0
  123. package/dist/engine/scaling-curve-cli.js +147 -0
  124. package/dist/engine/skills-cli.js +171 -51
  125. package/dist/engine/workflow-cli.js +85 -65
  126. package/dist/reporters/testtracker.d.ts +1 -1
  127. package/dist/reporters/testtracker.js +1 -1
  128. package/package.json +3 -2
@@ -0,0 +1,157 @@
1
+ ---
2
+ name: canary-instrument
3
+ description:
4
+ Instrument a Playwright run with OpenTelemetry and emit a run.json artifact
5
+ correlating every test to the outbound HTTP requests it made — "which test
6
+ made which request?" — with zero manual bookkeeping in test code. Trace-only
7
+ v1 contract, additive-safe for future pytest/k6/node producers. Self-contained
8
+ (bundles its own run-type factories and span reader).
9
+ cli: scripts/cli.mjs
10
+ requires: [node>=20]
11
+ ---
12
+
13
+ # Canary Instrument
14
+
15
+ Correlate every outbound HTTP request in a Playwright run to the test that made
16
+ it, using OTel span parent/child relationships. Zero required external
17
+ dependencies to produce output — default file-based span export, no OTel
18
+ collector needed.
19
+
20
+ ## Setup (two manual steps, once per suite)
21
+
22
+ This skill ships fixture _files_ you wire into your own suite — it does not
23
+ vendor OTel as a dependency of the `canary` package itself. Install these first:
24
+
25
+ ```bash
26
+ npm install --save-dev \
27
+ @opentelemetry/sdk-node @opentelemetry/api \
28
+ @opentelemetry/auto-instrumentations-node \
29
+ @opentelemetry/exporter-trace-otlp-http
30
+ ```
31
+
32
+ **1. Bootstrap the OTel SDK before Playwright starts** — add `NODE_OPTIONS` to
33
+ your test command (or `playwright.config.ts`'s `webServer`/CI step):
34
+
35
+ ```bash
36
+ NODE_OPTIONS="--import ./node_modules/canary/agents/skills/claude-code/canary-instrument/scripts/otel_bootstrap/instrument.mjs" \
37
+ npx playwright test
38
+ ```
39
+
40
+ Copy `otel_bootstrap/instrument.mjs` into your repo (e.g. `otel/instrument.mjs`)
41
+ if you'd rather not reference the path inside `node_modules`.
42
+
43
+ **2. Merge the root-span fixture into your `fixtures.ts`:**
44
+
45
+ ```ts
46
+ import { test as base } from '@playwright/test';
47
+ import { withTestSpan } from './otel_bootstrap/playwright-fixture';
48
+
49
+ export const test = withTestSpan(base);
50
+ ```
51
+
52
+ Every test using this `test` export now opens a root span carrying
53
+ `test.id`/`test.title`/`test.file`, and every HTTP call the test makes nests
54
+ under it automatically — no manual span code in individual tests.
55
+
56
+ ## Invocation
57
+
58
+ ```bash
59
+ canary skills run canary-instrument -- \
60
+ --spans test-results/trace --output test-results \
61
+ [--suite-type e2e_ui]
62
+
63
+ # Usage and options (exits 0; --spans/--output are not required for --help):
64
+ canary skills run canary-instrument -- --help
65
+ ```
66
+
67
+ An unknown flag, a missing required flag, and a value-flag left without its
68
+ value are all usage errors (exit 2). `--help` short-circuits ahead of the
69
+ required-argument check, so it never complains about `--spans`/`--output`.
70
+
71
+ Writes `test-results/run.json`. Creates `--output` if it doesn't exist.
72
+ Missing/empty `--spans` produces `trace: {spans_total: 0, by_test: []}`, not a
73
+ failure. `--suite-type` is a free-form string (no enum) — pass whatever label
74
+ describes your suite.
75
+
76
+ ## `run.json` v1 contract (trace-only)
77
+
78
+ ```jsonc
79
+ {
80
+ "schema_version": 1,
81
+ "suite_type": "",
82
+ "generated_at": "2026-07-15T18:00:00+00:00",
83
+ "trace": {
84
+ "spans_total": 124,
85
+ "by_test": [
86
+ {
87
+ "test_id": "users-spec:1", // "__setup__" for orphan traffic
88
+ "test_title": "lists users",
89
+ "test_file": "tests/users.spec.ts",
90
+ "trace_id": "abc123...",
91
+ "outcome": "passed",
92
+ "requests": [
93
+ {
94
+ "method": "GET",
95
+ "url": "http://localhost:3000/users/1",
96
+ "route": "/users/:id",
97
+ "status": 200,
98
+ "duration_ms": 12.4,
99
+ "span_id": "def456...",
100
+ "started_at": "2026-07-15T18:00:01+00:00",
101
+ },
102
+ ],
103
+ },
104
+ ],
105
+ },
106
+ }
107
+ ```
108
+
109
+ No `coverage` key, no `canary_run_id` key — cut for v1 (see
110
+ `docs/knowledge/decisions/0006-otel-test-side-tracing.md` and
111
+ `docs/changes/canary-instrument/proposal.md`). Additive-only evolution: new
112
+ optional fields may appear later; existing fields never change meaning.
113
+
114
+ ## Sending spans to a collector (optional)
115
+
116
+ Set `OTEL_EXPORTER_OTLP_ENDPOINT` before the test run and spans are
117
+ _additionally_ streamed there — the file exporter still writes
118
+ `test-results/trace/otel-spans.*.jsonl` either way, so `canary-instrument`'s own
119
+ correlation is never dependent on a collector being up. If your org's endpoint
120
+ is recorded in company-knowledge, export it first:
121
+
122
+ ```bash
123
+ export OTEL_EXPORTER_OTLP_ENDPOINT="$(canary company-knowledge show --json | jq -r '.otel_exporter_endpoint')"
124
+ ```
125
+
126
+ See `docs/guides/company-knowledge.md` for the `otel_exporter_endpoint` field.
127
+
128
+ ## CI wiring (GitHub Actions)
129
+
130
+ ```yaml
131
+ - name: Run Playwright (instrumented)
132
+ env:
133
+ NODE_OPTIONS: '--import ./otel/instrument.mjs'
134
+ run:
135
+ npx playwright test --reporter=json --output-file=test-results/results.json
136
+
137
+ - name: Correlate tests to HTTP spans
138
+ if: always()
139
+ run: |
140
+ canary skills run canary-instrument -- \
141
+ --spans test-results/trace --output test-results
142
+
143
+ - name: Upload run.json
144
+ if: always()
145
+ uses: actions/upload-artifact@v4
146
+ with:
147
+ name: run-trace
148
+ path: test-results/run.json
149
+ ```
150
+
151
+ ## Related skills
152
+
153
+ - `canary-test-reporter` — Markdown/JSON test summary; `run.json`'s `by_test[]`
154
+ rows are structurally similar to its `TestResult` shape (title/status as join
155
+ keys) — a future consumer can read both artifacts with one join key.
156
+ - `canary-fail-fast` — aborts a broken run early; use alongside this skill for
157
+ complete CI coverage.
@@ -0,0 +1,178 @@
1
+ #!/usr/bin/env node
2
+ // canary-instrument -- correlate a Playwright run's tests to their outbound
3
+ // HTTP spans. Ported from cli.py, behavior-preserving.
4
+ //
5
+ // Reads OTel span JSONL files written by otel_bootstrap/instrument.mjs (see
6
+ // SKILL.md for the two manual wiring steps), resolves each Playwright test's
7
+ // root span (set by otel_bootstrap/playwright-fixture.ts), attaches HTTP child
8
+ // spans, and writes a run.json v1 artifact (trace-only; see run_types.mjs for
9
+ // the contract).
10
+ //
11
+ // Invoked via:
12
+ // canary skills run canary-instrument -- \
13
+ // --spans test-results/trace --output test-results [--suite-type e2e_ui]
14
+ //
15
+ // Missing/empty --spans is not a failure -- it produces an empty trace block.
16
+ // Self-contained -- no external skill dependency.
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import {
21
+ createParser,
22
+ formatUsageError,
23
+ EXIT_USAGE,
24
+ } from '../../../lib/parse-args.mjs';
25
+
26
+ import { RunArtifact } from './run_types.mjs';
27
+ import { readTraces } from './span_reader.mjs';
28
+
29
+ const USAGE =
30
+ 'usage: canary-instrument [-h] --spans PATH --output PATH\n' +
31
+ ' [--suite-type TYPE]\n' +
32
+ '\n' +
33
+ "Correlate a Playwright run's tests to their outbound HTTP spans and write a\n" +
34
+ 'run.json v1 artifact.\n' +
35
+ '\n' +
36
+ 'options:\n' +
37
+ ' -h, --help show this help message and exit\n' +
38
+ ' --spans PATH directory of OTel span JSONL files to read\n' +
39
+ ' --output PATH directory to write run.json into (created if missing)\n' +
40
+ ' --suite-type TYPE suite label recorded in the artifact (e.g. e2e_ui)';
41
+
42
+ /**
43
+ * Both value flags are required, and `--suite-type` is a free-form label. Two
44
+ * deliberate divergences from argparse survive the move to the shared parser
45
+ * (#479): flags must be spelled in full (no prefix abbreviation, so `--out` is
46
+ * an unknown flag rather than `--output`), and an empty value is now REJECTED
47
+ * rather than passed through -- `--spans=` used to yield an empty trace, which
48
+ * is the missing-value case wearing a disguise.
49
+ */
50
+ export const CLI_SPEC = {
51
+ prog: 'canary-instrument',
52
+ values: {
53
+ '--spans': { key: 'spans' },
54
+ '--output': { key: 'output' },
55
+ '--suite-type': { key: 'suiteType' },
56
+ },
57
+ defaults: { suiteType: '' },
58
+ required: ['--spans', '--output'],
59
+ };
60
+
61
+ const parseArgs = createParser(CLI_SPEC);
62
+
63
+ /**
64
+ * Serialize like Python's json.dumps(obj, indent=2): 2-space indent AND
65
+ * ensure_ascii=True. JSON.stringify already matches the indentation and empty
66
+ * container ("[]"/"{}") forms, but leaves non-ASCII raw -- so escape every code
67
+ * unit above 0x7F to a \uXXXX sequence (astral chars become their two UTF-16
68
+ * surrogate escapes, exactly as Python emits them). No trailing newline,
69
+ * matching cli.py's write_text.
70
+ */
71
+ function dumpsIndent2Ascii(obj) {
72
+ const nonAscii = /[\u0080-\uffff]/g;
73
+ return JSON.stringify(obj, null, 2).replace(
74
+ nonAscii,
75
+ (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, '0')}`,
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Python's datetime.now(timezone.utc).isoformat() renders a "+00:00" offset;
81
+ * JS toISOString() renders "Z". Normalize the suffix so generated_at keeps the
82
+ * documented "+00:00" convention. (Python emits microseconds, JS milliseconds;
83
+ * generated_at is a wall-clock stamp and is never asserted against.)
84
+ */
85
+ function nowIso() {
86
+ return new Date().toISOString().replace('Z', '+00:00');
87
+ }
88
+
89
+ export function main(argv = []) {
90
+ const { opts: args, help, error } = parseArgs(argv);
91
+
92
+ if (help) {
93
+ console.log(USAGE);
94
+ return 0;
95
+ }
96
+ if (error) {
97
+ console.error(formatUsageError(CLI_SPEC.prog, error));
98
+ return EXIT_USAGE;
99
+ }
100
+
101
+ const spansDir = args.spans;
102
+ if (existsButNotDir(spansDir)) {
103
+ console.error(`canary-instrument: --spans is not a directory: ${spansDir}`);
104
+ return 1;
105
+ }
106
+
107
+ const trace = readTraces(spansDir);
108
+ const artifact = RunArtifact({
109
+ schema_version: 1,
110
+ suite_type: args.suiteType,
111
+ generated_at: nowIso(),
112
+ trace,
113
+ });
114
+
115
+ // A run.json whose requests carry no URL is worse than no run.json. The
116
+ // artifact's whole purpose is answering "which test hit which endpoint", so a
117
+ // consumer that reads URL-less requests computes zero endpoints matched and
118
+ // renders a clean 0% as though it were a measurement. Refuse to write it.
119
+ //
120
+ // Note this is NOT the empty-spans case, which is deliberately fine (see the
121
+ // header): zero spans yields an empty trace and exit 0, and "0 spans, 0
122
+ // buckets" describes itself honestly. What fails here is the drift signature --
123
+ // spans WERE captured and correlated, and every one of them lost its URL,
124
+ // which is what an attribute-name mismatch looks like from the outside.
125
+ const requests = trace.by_test.flatMap((t) => t.requests ?? []);
126
+ const withUrl = requests.filter((r) => r.url);
127
+ if (requests.length > 0 && withUrl.length === 0) {
128
+ console.error(
129
+ `canary-instrument: correlated ${requests.length} request(s) across ` +
130
+ `${trace.spans_total} span(s) and NONE carried a URL. Refusing to write ` +
131
+ `an artifact that would read as "no endpoints exercised".`,
132
+ );
133
+ console.error(
134
+ 'This is what an OTel semantic-convention mismatch looks like: the spans ' +
135
+ 'are present and attributed, but the URL attribute this reader expects is ' +
136
+ 'not the one the instrumentation emitted. Check the attribute keys on an ' +
137
+ 'HTTP span in --spans against requestUrl() in span_reader.mjs.',
138
+ );
139
+ return 1;
140
+ }
141
+
142
+ const outputDir = args.output;
143
+ fs.mkdirSync(outputDir, { recursive: true });
144
+ const outPath = path.join(outputDir, 'run.json');
145
+ fs.writeFileSync(outPath, dumpsIndent2Ascii(artifact), 'utf8');
146
+
147
+ console.log(
148
+ `canary-instrument: wrote ${outPath} ` +
149
+ `(${trace.spans_total} spans, ${trace.by_test.length} test buckets, ` +
150
+ `${withUrl.length}/${requests.length} request(s) with a URL)`,
151
+ );
152
+ // Partial loss is advisory: a mixed run is still usable, but silence would let
153
+ // a growing blind spot pass for a shrinking one.
154
+ if (withUrl.length < requests.length) {
155
+ console.warn(
156
+ `canary-instrument: ${requests.length - withUrl.length} request(s) had no ` +
157
+ 'resolvable URL and will not match any endpoint.',
158
+ );
159
+ }
160
+ return 0;
161
+ }
162
+
163
+ function existsButNotDir(p) {
164
+ try {
165
+ return !fs.statSync(p).isDirectory();
166
+ } catch {
167
+ return false; // missing path is fine -- yields an empty trace
168
+ }
169
+ }
170
+
171
+ // Direct execution (the skill runner execs this file via its shebang).
172
+ //
173
+ // `process.exitCode`, not `process.exit()`: a large `--json` payload exceeds
174
+ // the pipe buffer, and `process.exit` tears the process down mid-write, leaving
175
+ // truncated JSON that still exits 0 (#791).
176
+ if (import.meta.url === `file://${process.argv[1]}`) {
177
+ process.exitCode = main(process.argv.slice(2));
178
+ }
@@ -0,0 +1,96 @@
1
+ // otel_bootstrap/instrument.mjs
2
+ //
3
+ // Node OTel SDK bootstrap for canary-instrument. Import via:
4
+ // NODE_OPTIONS="--import ./otel_bootstrap/instrument.mjs" npx playwright test
5
+ //
6
+ // Default: writes one JSON span per line to
7
+ // test-results/trace/otel-spans.<TEST_WORKER_INDEX>.jsonl
8
+ // (no collector required — this is what scripts/span_reader.mjs reads).
9
+ // When OTEL_EXPORTER_OTLP_ENDPOINT is set, spans are *additionally*
10
+ // streamed to that collector via OTLPTraceExporter; the file path above is
11
+ // unaffected either way.
12
+ //
13
+ // Auto-instruments HTTP/undici only — fs instrumentation is disabled so
14
+ // Playwright's own file I/O doesn't show up as noise spans.
15
+ //
16
+ // Consumer-supplied dependencies (not vendored by this skill):
17
+ // @opentelemetry/sdk-node @opentelemetry/api
18
+ // @opentelemetry/auto-instrumentations-node
19
+ // @opentelemetry/exporter-trace-otlp-http
20
+
21
+ import { NodeSDK } from '@opentelemetry/sdk-node';
22
+ import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
23
+ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
24
+ import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+
28
+ const workerIndex = process.env.TEST_WORKER_INDEX ?? '0';
29
+ const outDir = path.join(process.cwd(), 'test-results', 'trace');
30
+ fs.mkdirSync(outDir, { recursive: true });
31
+ // Synchronous fd, not fs.createWriteStream: Playwright's worker process
32
+ // calls process.exit() once its assigned tests finish (it does not wait for
33
+ // the event loop to drain), which cuts off any pending async stream writes
34
+ // before they reach disk. fs.writeSync makes each export() call durable
35
+ // immediately, so span data survives that hard exit — see
36
+ // docs/changes/canary-instrument/proposal.md's ADR reference for why this
37
+ // skill can't assume a graceful shutdown hook will run.
38
+ const outFd = fs.openSync(
39
+ path.join(outDir, `otel-spans.${workerIndex}.jsonl`),
40
+ 'a',
41
+ );
42
+
43
+ /** Minimal file exporter — one JSON span per line, matches span_reader.mjs. */
44
+ class JsonlFileSpanExporter {
45
+ export(spans, resultCallback) {
46
+ for (const span of spans) {
47
+ const [startSec, startNs] = span.startTime;
48
+ const [durSec, durNs] = span.duration;
49
+ fs.writeSync(outFd, JSON.stringify({
50
+ traceId: span.spanContext().traceId,
51
+ spanId: span.spanContext().spanId,
52
+ // ReadableSpan.parentSpanId was replaced by parentSpanContext in
53
+ // the current @opentelemetry/sdk-trace* line this skill installs
54
+ // (span.parentSpanId is undefined there, so JSON.stringify silently
55
+ // dropped the key entirely) — read the id off parentSpanContext.
56
+ parentSpanId: span.parentSpanContext?.spanId ?? null,
57
+ name: span.name,
58
+ startTime: new Date(startSec * 1000 + startNs / 1e6).toISOString(),
59
+ duration_ms: durSec * 1000 + durNs / 1e6,
60
+ attributes: span.attributes,
61
+ }) + '\n');
62
+ }
63
+ resultCallback({ code: 0 });
64
+ }
65
+
66
+ shutdown() {
67
+ try {
68
+ fs.closeSync(outFd);
69
+ } catch {
70
+ // already closed (e.g. shutdown() invoked twice) — fine.
71
+ }
72
+ return Promise.resolve();
73
+ }
74
+ }
75
+
76
+ const spanProcessors = [new SimpleSpanProcessor(new JsonlFileSpanExporter())];
77
+
78
+ if (process.env.OTEL_EXPORTER_OTLP_ENDPOINT) {
79
+ spanProcessors.push(
80
+ new SimpleSpanProcessor(
81
+ new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT }),
82
+ ),
83
+ );
84
+ }
85
+
86
+ const sdk = new NodeSDK({
87
+ spanProcessors,
88
+ instrumentations: [
89
+ getNodeAutoInstrumentations({
90
+ '@opentelemetry/instrumentation-fs': { enabled: false },
91
+ }),
92
+ ],
93
+ });
94
+
95
+ sdk.start();
96
+ process.on('exit', () => sdk.shutdown());
@@ -0,0 +1,44 @@
1
+ // otel_bootstrap/playwright-fixture.ts
2
+ //
3
+ // withTestSpan(base) wraps a Playwright `test` object with an `auto`
4
+ // fixture that opens one root span per test (test.id/test.title/test.file
5
+ // attributes), activates it as the OTel active context so the test's HTTP
6
+ // calls nest as child spans (the whole correlation trick this skill relies
7
+ // on), and closes it in teardown with test.outcome set from
8
+ // testInfo.status. Merge into your own fixtures.ts:
9
+ //
10
+ // import { test as base } from '@playwright/test';
11
+ // import { withTestSpan } from './otel_bootstrap/playwright-fixture';
12
+ // export const test = withTestSpan(base);
13
+ //
14
+ // Root-span-via-fixture (not a custom reporter) is deliberate — reporters
15
+ // run in Playwright's main process and can't establish the OTel active
16
+ // context the HTTP auto-instrumentation needs to nest child spans. See
17
+ // docs/knowledge/decisions/0006-otel-test-side-tracing.md.
18
+
19
+ import type { TestType } from '@playwright/test';
20
+ import { trace, context } from '@opentelemetry/api';
21
+
22
+ const tracer = trace.getTracer('canary-instrument');
23
+
24
+ export function withTestSpan<T extends TestType<any, any>>(base: T): T {
25
+ return base.extend({
26
+ _rootSpan: [
27
+ async ({}, use, testInfo) => {
28
+ const span = tracer.startSpan(testInfo.title, {
29
+ attributes: {
30
+ 'test.id': testInfo.titlePath.join(':'),
31
+ 'test.title': testInfo.title,
32
+ 'test.file': testInfo.file,
33
+ },
34
+ });
35
+ await context.with(trace.setSpan(context.active(), span), async () => {
36
+ await use();
37
+ });
38
+ span.setAttribute('test.outcome', testInfo.status ?? 'unknown');
39
+ span.end();
40
+ },
41
+ { auto: true },
42
+ ] as any,
43
+ }) as T;
44
+ }
@@ -0,0 +1,81 @@
1
+ // run_types -- the run.json v1 contract (trace-only), ported from the Python
2
+ // dataclasses one-for-one as the skill moves to JS.
3
+ //
4
+ // Each factory returns a plain object whose keys are declared in exactly the
5
+ // order the Python dataclass declared its fields, so JSON.stringify emits the
6
+ // same key order the old dataclasses.asdict() did. `toDict()` is therefore an
7
+ // identity pass -- the object already IS the on-disk dict shape.
8
+ //
9
+ // No `coverage` key and no `canary_run_id` key exist anywhere in this module:
10
+ // both were cut for v1 (coverage is a separate future skill; canary_run_id has
11
+ // no consumer yet). Additive-only evolution: new optional fields may be
12
+ // appended later; existing fields never change meaning.
13
+
14
+ /**
15
+ * @typedef {{method: string, url: string, route: (string|null),
16
+ * status: (number|null), duration_ms: number, span_id: string,
17
+ * started_at: string}} RequestSpanRow
18
+ */
19
+
20
+ /**
21
+ * @typedef {{test_id: string, test_title: string, test_file: string,
22
+ * trace_id: string, outcome: string, requests: RequestSpanRow[]}} TestTraceRow
23
+ */
24
+
25
+ /** One outbound HTTP request span. Field order matches the Python dataclass. */
26
+ export function RequestSpan({
27
+ method,
28
+ url,
29
+ route,
30
+ status,
31
+ duration_ms,
32
+ span_id,
33
+ started_at,
34
+ }) {
35
+ return { method, url, route, status, duration_ms, span_id, started_at };
36
+ }
37
+
38
+ /**
39
+ * One test's trace bucket. `test_id` is "__setup__" for orphan (rootless)
40
+ * traffic. `requests` defaults to a fresh empty array (mirrors the Python
41
+ * `field(default_factory=list)`).
42
+ * @param {{test_id: string, test_title: string, test_file: string,
43
+ * trace_id: string, outcome: string, requests?: RequestSpanRow[]}} f
44
+ */
45
+ export function TestTrace({
46
+ test_id,
47
+ test_title,
48
+ test_file,
49
+ trace_id,
50
+ outcome,
51
+ requests = [],
52
+ }) {
53
+ return { test_id, test_title, test_file, trace_id, outcome, requests };
54
+ }
55
+
56
+ /**
57
+ * The trace block: total request spans + per-test buckets.
58
+ * @param {{spans_total: number, by_test?: TestTraceRow[]}} f
59
+ */
60
+ export function Trace({ spans_total, by_test = [] }) {
61
+ return { spans_total, by_test };
62
+ }
63
+
64
+ /** The top-level run.json artifact. */
65
+ export function RunArtifact({
66
+ schema_version,
67
+ suite_type,
68
+ generated_at,
69
+ trace,
70
+ }) {
71
+ return { schema_version, suite_type, generated_at, trace };
72
+ }
73
+
74
+ /**
75
+ * Mirror of the Python `RunArtifact.to_dict()` (a plain `asdict()`). Because
76
+ * the factories already build plain nested objects in field order, this is an
77
+ * identity function -- kept for API parity with the Python contract.
78
+ */
79
+ export function toDict(artifact) {
80
+ return artifact;
81
+ }