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,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
+ }
@@ -0,0 +1,187 @@
1
+ // span_reader -- merge/correlate OTel span JSONL files into a Trace (pure,
2
+ // read-only). Ported from Python span_reader.py, behavior-for-behavior.
3
+ //
4
+ // Reads one or more `otel-spans.<worker>.jsonl` files (one JSON span object
5
+ // per line, written by otel_bootstrap/instrument.mjs), groups spans by
6
+ // `traceId`, resolves each trace's root span (the one carrying a `test.id`
7
+ // attribute -- set by otel_bootstrap/playwright-fixture.ts's root-span
8
+ // fixture), and attaches that trace's HTTP child spans to the resolved test.
9
+ // Traces with no `test.id`-attributed root bucket their HTTP spans under the
10
+ // synthetic test id "__setup__" (traffic outside any test, e.g. global setup).
11
+ //
12
+ // Assumed span envelope: {traceId, spanId, parentSpanId, name, startTime,
13
+ // duration_ms, attributes{}} -- matches exactly what instrument.mjs's
14
+ // JsonlFileSpanExporter writes; no `endTime` key is emitted (duration_ms +
15
+ // startTime cover it). HTTP attributes keyed http.method/http.request.method,
16
+ // http.url, http.route, http.status_code.
17
+
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+
21
+ import { RequestSpan, TestTrace, Trace } from './run_types.mjs';
22
+
23
+ const SETUP_TEST_ID = '__setup__';
24
+
25
+ // Mirrors Python's Path.glob("otel-spans.*.jsonl"): the `*` matches any run of
26
+ // characters (including none) within a single path segment.
27
+ const SPAN_FILE_RE = /^otel-spans\..*\.jsonl$/;
28
+
29
+ /**
30
+ * Read every `otel-spans.*.jsonl` file under `spansDir`, correlate spans to
31
+ * their test roots, and return the Trace. A missing or non-directory path
32
+ * yields an empty trace (never throws) -- the same as the Python version.
33
+ * @param {string} spansDir
34
+ */
35
+ export function readTraces(spansDir) {
36
+ const byTrace = new Map();
37
+
38
+ if (isDir(spansDir)) {
39
+ const files = fs
40
+ .readdirSync(spansDir)
41
+ .filter((name) => SPAN_FILE_RE.test(name))
42
+ .sort();
43
+ for (const name of files) {
44
+ for (const span of readJsonl(path.join(spansDir, name))) {
45
+ const traceId = span.traceId;
46
+ if (!traceId) continue;
47
+ if (!byTrace.has(traceId)) byTrace.set(traceId, []);
48
+ byTrace.get(traceId).push(span);
49
+ }
50
+ }
51
+ }
52
+
53
+ const byTest = [];
54
+ const setupRequests = [];
55
+ let spansTotal = 0;
56
+
57
+ for (const [traceId, spans] of byTrace) {
58
+ const root = spans.find(isTestRoot) ?? null;
59
+ const httpSpans = spans.filter((s) => s !== root && isHttpSpan(s));
60
+ const requests = httpSpans.map(toRequestSpan);
61
+ spansTotal += requests.length;
62
+
63
+ if (root === null) {
64
+ setupRequests.push(...requests);
65
+ continue;
66
+ }
67
+
68
+ const attrs = root.attributes ?? {};
69
+ byTest.push(
70
+ TestTrace({
71
+ test_id: attrs['test.id'] ?? '',
72
+ test_title: attrs['test.title'] ?? '',
73
+ test_file: attrs['test.file'] ?? '',
74
+ trace_id: traceId,
75
+ outcome: attrs['test.outcome'] ?? '',
76
+ requests,
77
+ }),
78
+ );
79
+ }
80
+
81
+ if (setupRequests.length) {
82
+ byTest.push(
83
+ TestTrace({
84
+ test_id: SETUP_TEST_ID,
85
+ test_title: '',
86
+ test_file: '',
87
+ trace_id: '',
88
+ outcome: '',
89
+ requests: setupRequests,
90
+ }),
91
+ );
92
+ }
93
+
94
+ return Trace({ spans_total: spansTotal, by_test: byTest });
95
+ }
96
+
97
+ function isDir(p) {
98
+ try {
99
+ return fs.statSync(p).isDirectory();
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ function readJsonl(filePath) {
106
+ const spans = [];
107
+ // NDJSON records are \n- or \r\n-delimited. We intentionally do NOT split on
108
+ // the exotic Unicode line separators that Python's str.splitlines() also
109
+ // breaks on (U+2028, U+2029, U+0085, ...): instrument.mjs writes those RAW
110
+ // inside JSON string values, so splitting on them would fragment a valid
111
+ // record and misattribute its request to __setup__.
112
+ for (const raw of fs.readFileSync(filePath, 'utf8').split(/\r\n|\n/)) {
113
+ const line = raw.trim();
114
+ if (!line) continue;
115
+ try {
116
+ spans.push(JSON.parse(line));
117
+ } catch {
118
+ continue; // malformed/torn line (e.g. a crashed worker's last write)
119
+ }
120
+ }
121
+ return spans;
122
+ }
123
+
124
+ function isTestRoot(span) {
125
+ return 'test.id' in (span.attributes ?? {});
126
+ }
127
+
128
+ function isHttpSpan(span) {
129
+ const attrs = span.attributes ?? {};
130
+ return 'http.method' in attrs || 'http.request.method' in attrs;
131
+ }
132
+
133
+ /**
134
+ * Reconstruct the request URL across both OTel HTTP semantic conventions.
135
+ *
136
+ * The old convention put the whole thing in one attribute, `http.url`. The
137
+ * current one (stable since semconv 1.23) splits it: `url.full` on some
138
+ * instrumentations, and otherwise the pieces -- `url.scheme`, `server.address`,
139
+ * `server.port`, `url.path`. `@opentelemetry/auto-instrumentations-node`, which
140
+ * `otel_bootstrap/instrument.mjs` tells consumers to install, emits the SPLIT
141
+ * form and no `http.url` at all.
142
+ *
143
+ * Reading only `http.url` therefore produced `url: ""` on every request while
144
+ * the reader still reported a full span count and exit 0 -- the artifact looked
145
+ * written and was empty of the one field a coverage consumer needs. Port is
146
+ * omitted when it is the scheme default, so the URL matches what a spec or a
147
+ * HAR would say.
148
+ */
149
+ function requestUrl(attrs) {
150
+ const direct = attrs['url.full'] ?? attrs['http.url'];
151
+ if (direct) return direct;
152
+
153
+ const scheme = attrs['url.scheme'] ?? attrs['http.scheme'];
154
+ const host =
155
+ attrs['server.address'] ?? attrs['net.peer.name'] ?? attrs['http.host'];
156
+ const path = attrs['url.path'] ?? attrs['http.target'] ?? '';
157
+ if (!scheme || !host) return typeof path === 'string' ? path : '';
158
+
159
+ const port = attrs['server.port'] ?? attrs['net.peer.port'];
160
+ const isDefaultPort =
161
+ port == null ||
162
+ (scheme === 'http' && +port === 80) ||
163
+ (scheme === 'https' && +port === 443);
164
+ const authority = isDefaultPort ? host : `${host}:${port}`;
165
+ const query = attrs['url.query'] ? `?${attrs['url.query']}` : '';
166
+ return `${scheme}://${authority}${path}${query}`;
167
+ }
168
+
169
+ function toRequestSpan(span) {
170
+ const attrs = span.attributes ?? {};
171
+ const method = attrs['http.method'] || attrs['http.request.method'] || '';
172
+ return RequestSpan({
173
+ method,
174
+ url: requestUrl(attrs),
175
+ // `http.route` is a SERVER-side attribute and is never present on the client
176
+ // spans this reader consumes. Kept for producers that do supply it (a future
177
+ // server-side producer under the same v1 contract), but its absence here is
178
+ // normal, not a gap.
179
+ route: attrs['http.route'] ?? null,
180
+ // Renamed in the current convention. Old name kept as a fallback.
181
+ status:
182
+ attrs['http.response.status_code'] ?? attrs['http.status_code'] ?? null,
183
+ duration_ms: span.duration_ms ?? 0,
184
+ span_id: span.spanId ?? '',
185
+ started_at: span.startTime ?? '',
186
+ });
187
+ }