@kaizenreport/kensho-go 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brandon Ordoñez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # @kaizenreport/kensho-go
2
+
3
+ Convert `go test -json` output into a [Kensho v1](../schema) results bundle so
4
+ the Kensho CLI can render a rich static HTML report.
5
+
6
+ Go's standard test runner has no plugin system, so this adapter ships as a
7
+ **Node-based CLI** that consumes the structured JSON `go test` already emits.
8
+ Drop the optional `go-helper` module into your test code if you want
9
+ first-class steps, attachments, labels and links — same shape as
10
+ `kensho-pytest` and the JS adapters.
11
+
12
+ ## One-liner
13
+
14
+ ```bash
15
+ go test -json ./... | npx kensho-go --output kensho-results
16
+ npx kensho generate
17
+ npx kensho open
18
+ ```
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pnpm add -D @kaizenreport/kensho-go @kaizenreport/kensho
24
+ # or
25
+ npm install --save-dev @kaizenreport/kensho-go @kaizenreport/kensho
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```bash
31
+ # pipe the JSON stream straight in
32
+ go test -json ./... | npx kensho-go --output kensho-results
33
+
34
+ # or save it first (handy in CI for re-runnable conversions)
35
+ go test -json ./... > gotest.json
36
+ npx kensho-go --input gotest.json --output kensho-results
37
+
38
+ # merge multiple files
39
+ npx kensho-go --input unit.json --input integration.json --output kensho-results
40
+ ```
41
+
42
+ | Flag | Effect |
43
+ | ------------------------------- | --------------------------------------------------------- |
44
+ | `--input <file>`, `-i` | Read events from a file. Pass multiple times to merge. |
45
+ | `--output <dir>`, `-o` | Output directory (default `kensho-results`). |
46
+ | `--project-name <name>` | Project name to embed in `run.json`. |
47
+ | `--project-slug <slug>` | Project slug for the platform. |
48
+ | `--run-id <id>` | Override the auto-generated run id. |
49
+ | `--subtests cases\|children` | `cases` (default) — every `t.Run` becomes its own case. `children` — sub-tests roll up as nested steps under the parent. |
50
+
51
+ ## Mapping (`go test -json` → Kensho)
52
+
53
+ | Action | Effect |
54
+ | ------------ | ------------------------------------------------------------- |
55
+ | `run` | Open a case (or sub-case) with its `startedAt`. |
56
+ | `output` | Append the line to `case.logs[]`. `KENSHO_META:` lines from the helper module are parsed out separately. |
57
+ | `pass` | Close the case with `status: 'pass'` and `duration` from `Elapsed`. |
58
+ | `fail` | Close with `status: 'fail'`. The captured output becomes `errors[].stack`. |
59
+ | `skip` | Close with `status: 'skip'`. |
60
+ | `panic:` in output | Forces `status: 'fail'` (Go has no `broken` concept; the converter reserves it for events with no terminal action — usually a build failure). |
61
+
62
+ Stable case IDs come from `<Package>::<Test>` hashed via the same FNV-1a-based
63
+ algorithm the JS adapters use, so a Go package's history lines up with other
64
+ runs of the same suite on the platform.
65
+
66
+ Severity is detected from the test name (`Test_blocker_*`, `Test_critical_*`,
67
+ …) or from sub-test names (`t.Run("severity:critical", …)`, `t.Run("@critical")`).
68
+ Inline `@tag` substrings in the test name become `case.tags`.
69
+
70
+ ## Optional Go helper module
71
+
72
+ For first-class metadata, drop the helper module under `packages/go/go-helper/`
73
+ into your project (it's a separate Go module — `go get` it directly from this
74
+ repo, or vendor it):
75
+
76
+ ```go
77
+ package mything
78
+
79
+ import (
80
+ "testing"
81
+ kensho "github.com/kaizenreport/kensho-go-helper"
82
+ )
83
+
84
+ func TestLoginHappyPath(t *testing.T) {
85
+ kensho.Severity(t, "critical")
86
+ kensho.Feature(t, "Authentication")
87
+ kensho.Label(t, "team", "growth")
88
+ kensho.Link(t, "https://jira.example.com/browse/PROJ-123",
89
+ kensho.LinkOpts{Kind: "jira", Label: "PROJ-123"})
90
+
91
+ kensho.Step(t, "open the login page", func() {
92
+ kensho.Step(t, "warm up CDN", func() {
93
+ // …
94
+ })
95
+ })
96
+
97
+ kensho.Step(t, "submit credentials", func() {
98
+ // …
99
+ })
100
+
101
+ kensho.Attach(t, "/tmp/login.png", kensho.AttachOpts{Kind: "screenshot"})
102
+ }
103
+ ```
104
+
105
+ | Helper | What it does |
106
+ | ------------------------------------------ | ------------------------------------------------------- |
107
+ | `kensho.Step(t, title, func() { … })` | Opens a Kensho step. Nests automatically. Marks `fail` on panic or `t.Failed()`. |
108
+ | `kensho.Attach(t, path, opts…)` | Registers a file. The Node CLI copies it into `kensho-results/attachments/<caseId>/`. |
109
+ | `kensho.Label(t, key, value)` | Adds a free-form `key=value` to `case.labels`. |
110
+ | `kensho.Link(t, url, opts…)` | Adds a hyperlink. |
111
+ | `kensho.Severity(t, value)` | Sets `case.severity`. |
112
+ | `kensho.Feature/Epic/Scenario(t, value)` | Populates `case.behavior.*`. |
113
+ | `kensho.Tag(t, value)` | Adds a tag. |
114
+ | `kensho.Parameter(t, name, value, opts…)` | Records a parameter (table-driven inputs). |
115
+
116
+ The helper writes nothing to stdout — only `KENSHO_META: {...}` lines via
117
+ `t.Logf`. Outside of `go test -json` the lines are harmless test log output.
118
+
119
+ ## Why a Node CLI?
120
+
121
+ Go's std `testing` package doesn't expose a plugin / reporter API, but
122
+ `go test -json` is a stable, structured stream. Routing through the same
123
+ Node CLI used by the rest of the toolchain keeps the install footprint
124
+ zero-Go-dependency for the converter and zero-Node-dependency for users
125
+ who only want the helper module.
126
+
127
+ ## License
128
+
129
+ Apache-2.0.
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+ // kensho-go — convert `go test -json` output into kensho-results/.
3
+ //
4
+ // go test -json ./... | npx kensho-go --output kensho-results
5
+ // npx kensho-go --input gotest.json --output kensho-results
6
+ //
7
+ // Then:
8
+ //
9
+ // npx kensho generate
10
+ // npx kensho open
11
+
12
+ import { convertGoEvents, readEvents, readEventsFromStream } from '../src/index.js';
13
+
14
+ function parseArgs(argv) {
15
+ const out = {
16
+ inputs: [],
17
+ output: 'kensho-results',
18
+ subtests: 'cases',
19
+ };
20
+ for (let i = 0; i < argv.length; i++) {
21
+ const a = argv[i];
22
+ if (a === '--input' || a === '-i') out.inputs.push(argv[++i]);
23
+ else if (a === '--output' || a === '-o') out.output = argv[++i];
24
+ else if (a === '--project-name') out.projectName = argv[++i];
25
+ else if (a === '--project-slug') out.projectSlug = argv[++i];
26
+ else if (a === '--run-id') out.runId = argv[++i];
27
+ else if (a.startsWith('--subtests=')) out.subtests = a.slice('--subtests='.length);
28
+ else if (a === '--subtests') out.subtests = argv[++i];
29
+ else if (a === '--help' || a === '-h') out.help = true;
30
+ else if (!a.startsWith('-')) out.inputs.push(a);
31
+ }
32
+ return out;
33
+ }
34
+
35
+ const argv = parseArgs(process.argv.slice(2));
36
+
37
+ if (argv.help) {
38
+ console.log(`Usage: kensho-go [--input <file>] [--output kensho-results]
39
+ [--project-name <name>] [--project-slug <slug>]
40
+ [--run-id <id>] [--subtests cases|children]
41
+
42
+ Reads \`go test -json\` events and writes a kensho-results/ bundle.
43
+
44
+ Common flows:
45
+
46
+ go test -json ./... | npx kensho-go --output kensho-results
47
+ npx kensho-go --input gotest.json --output kensho-results
48
+
49
+ Options:
50
+ --input, -i Path to a file with go test -json output (omit to read stdin).
51
+ Pass --input multiple times to merge several files.
52
+ --output, -o Output directory (default: kensho-results).
53
+ --project-name Project name to embed in run.json (default: "Unknown project").
54
+ --project-slug Project slug (default: derived from name or "unknown").
55
+ --run-id Override the auto-generated run id.
56
+ --subtests "cases" (default) — every \`t.Run\` becomes its own Kensho case.
57
+ "children" — sub-tests roll up as nested steps under the parent.
58
+
59
+ After conversion, render the report with the Kensho CLI:
60
+
61
+ npx kensho generate
62
+ npx kensho open
63
+ `);
64
+ process.exit(0);
65
+ }
66
+
67
+ (async () => {
68
+ let events;
69
+ try {
70
+ if (argv.inputs.length) {
71
+ events = readEvents(argv.inputs.length === 1 ? argv.inputs[0] : argv.inputs);
72
+ } else if (!process.stdin.isTTY) {
73
+ events = await readEventsFromStream(process.stdin);
74
+ } else {
75
+ console.error('[kensho-go] no input. Pipe `go test -json` output in, or pass --input <file>.');
76
+ process.exit(2);
77
+ }
78
+ } catch (e) {
79
+ console.error('[kensho-go] failed to read input:', e && e.message);
80
+ process.exit(2);
81
+ }
82
+
83
+ if (!events.length) {
84
+ console.error('[kensho-go] no events parsed — is the input actually `go test -json` output?');
85
+ process.exit(3);
86
+ }
87
+
88
+ try {
89
+ const res = convertGoEvents({
90
+ events,
91
+ output: argv.output,
92
+ project: { name: argv.projectName, slug: argv.projectSlug },
93
+ runId: argv.runId,
94
+ subtests: argv.subtests,
95
+ });
96
+ if (!res.valid) process.exit(4);
97
+ } catch (e) {
98
+ console.error('[kensho-go] conversion failed:', e && e.message);
99
+ if (process.env.KENSHO_DEBUG) console.error(e.stack);
100
+ process.exit(2);
101
+ }
102
+ })();
@@ -0,0 +1,3 @@
1
+ module github.com/kaizenreport/kensho-go-helper
2
+
3
+ go 1.21
@@ -0,0 +1,203 @@
1
+ // Package kensho is the Go-side companion to @kaizenreport/kensho-go.
2
+ //
3
+ // It writes structured `KENSHO_META: {...}` lines via t.Logf so the Node CLI
4
+ // (`kensho-go`) can fold first-class steps, attachments, labels, and links
5
+ // into the generated report. This mirrors the helper API exposed by
6
+ // kensho-pytest, kensho-playwright, and the rest of the Kensho adapters.
7
+ //
8
+ // Example:
9
+ //
10
+ // import (
11
+ // "testing"
12
+ // kensho "github.com/kaizenreport/kensho-go-helper"
13
+ // )
14
+ //
15
+ // func TestLogin(t *testing.T) {
16
+ // kensho.Severity(t, "critical")
17
+ // kensho.Feature(t, "Authentication")
18
+ // kensho.Label(t, "team", "growth")
19
+ // kensho.Link(t, "https://jira.example.com/browse/PROJ-123",
20
+ // kensho.LinkOpts{Kind: "jira", Label: "PROJ-123"})
21
+ //
22
+ // kensho.Step(t, "open the login page", func() {
23
+ // // …
24
+ // })
25
+ // kensho.Attach(t, "/tmp/screenshot.png", kensho.AttachOpts{Kind: "screenshot"})
26
+ // }
27
+ //
28
+ // The helper writes nothing to stdout — only structured tag lines via t.Logf.
29
+ // All functions are no-ops when t is nil so test utilities can be shared with
30
+ // non-test code without a separate code path.
31
+ package kensho
32
+
33
+ import (
34
+ "encoding/json"
35
+ "fmt"
36
+ "sync/atomic"
37
+ "testing"
38
+ "time"
39
+ )
40
+
41
+ // LinkOpts carries optional fields for Link.
42
+ type LinkOpts struct {
43
+ Kind string // "jira" | "github" | "runbook" | …
44
+ Label string // human label displayed on the chip
45
+ }
46
+
47
+ // AttachOpts carries optional fields for Attach.
48
+ type AttachOpts struct {
49
+ Name string // override destination filename
50
+ Kind string // schema attachment kind override (screenshot, video, log, …)
51
+ MimeType string // MIME type override
52
+ }
53
+
54
+ // ParameterOpts carries optional fields for Parameter.
55
+ type ParameterOpts struct {
56
+ Kind string // "argument" | "context" | "env" | "data-row"
57
+ }
58
+
59
+ var stepCounter uint64
60
+
61
+ // Severity records the case severity (blocker | critical | normal | minor | trivial).
62
+ func Severity(t testing.TB, value string) {
63
+ emit(t, map[string]any{"kind": "severity", "value": value})
64
+ }
65
+
66
+ // Tag attaches a free-form tag to the case.
67
+ func Tag(t testing.TB, value string) {
68
+ emit(t, map[string]any{"kind": "tag", "value": value})
69
+ }
70
+
71
+ // Feature records the behavior feature (e.g. "Authentication").
72
+ func Feature(t testing.TB, value string) {
73
+ emit(t, map[string]any{"kind": "feature", "value": value})
74
+ }
75
+
76
+ // Epic records the behavior epic.
77
+ func Epic(t testing.TB, value string) {
78
+ emit(t, map[string]any{"kind": "epic", "value": value})
79
+ }
80
+
81
+ // Scenario records the behavior scenario.
82
+ func Scenario(t testing.TB, value string) {
83
+ emit(t, map[string]any{"kind": "scenario", "value": value})
84
+ }
85
+
86
+ // Label sets a free-form key/value on the case.
87
+ func Label(t testing.TB, key, value string) {
88
+ emit(t, map[string]any{"kind": "label", "key": key, "value": value})
89
+ }
90
+
91
+ // Link attaches a hyperlink (Jira ticket, runbook, PR…) to the case.
92
+ func Link(t testing.TB, url string, opts ...LinkOpts) {
93
+ rec := map[string]any{"kind": "link", "url": url}
94
+ if len(opts) > 0 {
95
+ o := opts[0]
96
+ if o.Kind != "" {
97
+ rec["linkKind"] = o.Kind
98
+ }
99
+ if o.Label != "" {
100
+ rec["label"] = o.Label
101
+ }
102
+ }
103
+ emit(t, rec)
104
+ }
105
+
106
+ // Parameter records a test parameter (e.g. table-driven inputs).
107
+ func Parameter(t testing.TB, name, value string, opts ...ParameterOpts) {
108
+ rec := map[string]any{"kind": "parameter", "name": name, "value": value}
109
+ if len(opts) > 0 && opts[0].Kind != "" {
110
+ rec["paramKind"] = opts[0].Kind
111
+ }
112
+ emit(t, rec)
113
+ }
114
+
115
+ // Attach registers a file to be copied into kensho-results/attachments/<caseId>/
116
+ // at conversion time. The file path must exist on disk when `kensho-go` runs.
117
+ func Attach(t testing.TB, path string, opts ...AttachOpts) {
118
+ rec := map[string]any{"kind": "attach", "path": path}
119
+ if len(opts) > 0 {
120
+ o := opts[0]
121
+ if o.Name != "" {
122
+ rec["name"] = o.Name
123
+ }
124
+ if o.Kind != "" {
125
+ rec["kind"] = "attach" // keep schema kind in attach-record below
126
+ rec["attachKind"] = o.Kind
127
+ }
128
+ if o.MimeType != "" {
129
+ rec["mimeType"] = o.MimeType
130
+ }
131
+ }
132
+ // The Node CLI reads `kind: attach` and falls back to a sensible default
133
+ // for the on-disk attachment kind; AttachOpts.Kind is forwarded as
134
+ // `attachKind` so we can keep the meta envelope's `kind` discriminator
135
+ // stable.
136
+ emit(t, rec)
137
+ }
138
+
139
+ // Step opens a Kensho step around fn. Steps may be nested by calling Step
140
+ // inside fn. If fn panics or marks the test as failed, the step is recorded
141
+ // with status `fail`.
142
+ func Step(t testing.TB, title string, fn func()) {
143
+ if t == nil {
144
+ if fn != nil {
145
+ fn()
146
+ }
147
+ return
148
+ }
149
+ id := fmt.Sprintf("s%d", atomic.AddUint64(&stepCounter, 1))
150
+ emit(t, map[string]any{
151
+ "kind": "step_start",
152
+ "id": id,
153
+ "title": title,
154
+ "t": time.Now().UnixMilli(),
155
+ })
156
+
157
+ status := "pass"
158
+ failed := false
159
+ defer func() {
160
+ if r := recover(); r != nil {
161
+ status = "fail"
162
+ emit(t, map[string]any{
163
+ "kind": "step_end",
164
+ "id": id,
165
+ "status": status,
166
+ "t": time.Now().UnixMilli(),
167
+ })
168
+ panic(r)
169
+ }
170
+ if failed || t.Failed() {
171
+ status = "fail"
172
+ }
173
+ emit(t, map[string]any{
174
+ "kind": "step_end",
175
+ "id": id,
176
+ "status": status,
177
+ "t": time.Now().UnixMilli(),
178
+ })
179
+ }()
180
+
181
+ if fn != nil {
182
+ fn()
183
+ }
184
+ if t.Failed() {
185
+ failed = true
186
+ }
187
+ }
188
+
189
+ // emit writes the meta record. We use t.Logf so it's interleaved with the
190
+ // surrounding test output; the Node CLI parses any line starting with
191
+ // "KENSHO_META:" out of the captured stream.
192
+ func emit(t testing.TB, rec map[string]any) {
193
+ if t == nil {
194
+ return
195
+ }
196
+ b, err := json.Marshal(rec)
197
+ if err != nil {
198
+ return
199
+ }
200
+ t.Helper()
201
+ // Newline is added by t.Logf; the prefix is what the converter looks for.
202
+ t.Logf("KENSHO_META: %s", string(b))
203
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@kaizenreport/kensho-go",
3
+ "version": "0.1.0",
4
+ "description": "Convert `go test -json` output into a kensho-results/ bundle so the Kensho CLI can render a rich HTML report.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "kensho-go": "./bin/kensho-go.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "go-helper",
14
+ "README.md"
15
+ ],
16
+ "dependencies": {
17
+ "@kaizenreport/kensho-schema": "0.1.0"
18
+ },
19
+ "engines": {
20
+ "node": ">=22"
21
+ },
22
+ "license": "Apache-2.0",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/brandon1794/kensho.git",
29
+ "directory": "packages/go"
30
+ },
31
+ "homepage": "https://github.com/brandon1794/kensho/tree/main/packages/go#readme",
32
+ "bugs": {
33
+ "url": "https://github.com/brandon1794/kensho/issues"
34
+ }
35
+ }
package/src/index.js ADDED
@@ -0,0 +1,628 @@
1
+ // @kaizenreport/kensho-go — convert `go test -json` output into a
2
+ // kensho-results/ bundle. Pure stdlib JS, no deps beyond the schema.
3
+ //
4
+ // `go test -json` emits one JSON object per line, with these Actions we care
5
+ // about:
6
+ //
7
+ // run — a test (or sub-test) started.
8
+ // output — captured stdout/stderr line for that test.
9
+ // pass — the test finished successfully.
10
+ // fail — the test finished with at least one assertion failure or panic.
11
+ // skip — the test was skipped (t.Skip / build-tag).
12
+ // pause / cont — t.Parallel sequencing; we ignore for case timing.
13
+ //
14
+ // We bucket events by (Package, Test). Top-level tests become Kensho cases.
15
+ // Sub-tests created with `t.Run("sub", ...)` become children of their parent;
16
+ // by default each sub-test is its own Kensho case (so the dashboard sees the
17
+ // full table-driven matrix), but the caller can opt into folding them into
18
+ // child steps with `--subtests=children`.
19
+ //
20
+ // A test that emits a Go panic (`panic:` in its captured output) is mapped
21
+ // to Kensho `fail` — Go's std test runner has no `broken` concept, so the
22
+ // schema's "infrastructure failure" bucket is reserved for things like
23
+ // missing input files at the converter layer.
24
+ //
25
+ // Helper hooks: tests using the optional `kensho` Go module write structured
26
+ // records via t.Logf as `KENSHO_META: <json>` lines. We parse those out so
27
+ // users get first-class steps, attachments, labels and links matching the
28
+ // other adapters.
29
+
30
+ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, statSync } from 'node:fs';
31
+ import { resolve, basename, extname, isAbsolute } from 'node:path';
32
+ import { createHash, randomUUID } from 'node:crypto';
33
+ import { emptyRun, computeTotals, stableCaseId, validateRun, envInfo } from '@kaizenreport/kensho-schema';
34
+
35
+ const SEVERITY_NAMES = ['blocker', 'critical', 'normal', 'minor', 'trivial'];
36
+
37
+ const MIME_BY_EXT = {
38
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
39
+ '.webp': 'image/webp', '.webm': 'video/webm', '.mp4': 'video/mp4',
40
+ '.zip': 'application/zip', '.html': 'text/html',
41
+ '.json': 'application/json', '.txt': 'text/plain', '.log': 'text/plain',
42
+ '.har': 'application/json',
43
+ };
44
+
45
+ const KIND_BY_EXT = {
46
+ '.png': 'screenshot', '.jpg': 'screenshot', '.jpeg': 'screenshot', '.webp': 'screenshot',
47
+ '.webm': 'video', '.mp4': 'video',
48
+ '.zip': 'trace',
49
+ '.html': 'html', '.json': 'json', '.txt': 'text', '.log': 'log',
50
+ '.har': 'har',
51
+ };
52
+
53
+ // envInfo() is imported from @kaizenreport/kensho-schema below.
54
+
55
+ // --- input ingestion ---------------------------------------------------------
56
+
57
+ function parseLines(text) {
58
+ const events = [];
59
+ for (const raw of String(text || '').split(/\r?\n/)) {
60
+ const line = raw.trim();
61
+ if (!line) continue;
62
+ if (line[0] !== '{') continue; // tolerate stray tool output before/after the stream
63
+ try {
64
+ events.push(JSON.parse(line));
65
+ } catch {
66
+ // Ignore malformed lines — `go test -json` very occasionally emits build
67
+ // diagnostics on stderr that get tee'd in.
68
+ }
69
+ }
70
+ return events;
71
+ }
72
+
73
+ export function readEvents(input) {
74
+ if (input == null) return [];
75
+ if (Array.isArray(input)) return input.flatMap(p => parseLines(readFileSync(p, 'utf8')));
76
+ return parseLines(readFileSync(input, 'utf8'));
77
+ }
78
+
79
+ export async function readEventsFromStream(stream) {
80
+ return new Promise((res, rej) => {
81
+ const chunks = [];
82
+ stream.on('data', c => chunks.push(c));
83
+ stream.on('end', () => res(parseLines(Buffer.concat(chunks).toString('utf8'))));
84
+ stream.on('error', rej);
85
+ });
86
+ }
87
+
88
+ // --- helper meta protocol ----------------------------------------------------
89
+ //
90
+ // The optional Go helper writes `KENSHO_META: <json>` lines via t.Logf.
91
+ // Each meta record carries a `kind` so we know how to fold it into the case.
92
+ // Recognised kinds:
93
+ //
94
+ // step_start { id, title, action?, parent?, t }
95
+ // step_end { id, status, t }
96
+ // attach { name, path, kind?, mimeType?, stepId? }
97
+ // label { key, value }
98
+ // link { url, kind?, label? }
99
+ // severity { value }
100
+ // tag { value }
101
+ // feature/epic/scenario { value }
102
+ // parameter { name, value, kind? }
103
+
104
+ const META_PREFIX = 'KENSHO_META:';
105
+
106
+ function parseMeta(line) {
107
+ const idx = line.indexOf(META_PREFIX);
108
+ if (idx < 0) return null;
109
+ const json = line.slice(idx + META_PREFIX.length).trim();
110
+ if (!json.startsWith('{')) return null;
111
+ try {
112
+ return JSON.parse(json);
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ // --- core conversion ---------------------------------------------------------
119
+
120
+ function severityFromName(name) {
121
+ // Recognise the conventions documented in the README:
122
+ // Test_blocker_*, Test_critical_*, Test_normal_*, Test_minor_*, Test_trivial_*
123
+ // …or sub-tests named e.g. "severity:critical" / "@critical".
124
+ const lower = String(name || '').toLowerCase();
125
+ for (const sev of SEVERITY_NAMES) {
126
+ if (new RegExp(`(?:^|[^a-z])${sev}(?:$|[^a-z])`).test(lower)) return sev;
127
+ }
128
+ return undefined;
129
+ }
130
+
131
+ function extractTags(name) {
132
+ const tags = [];
133
+ const re = /@([\w-]+)/g;
134
+ let m;
135
+ while ((m = re.exec(String(name || '')))) tags.push(m[1]);
136
+ return tags;
137
+ }
138
+
139
+ function packagePathToFile(pkg, file) {
140
+ // `Package` is the import path (e.g. github.com/foo/bar/internal). When the
141
+ // helper meta isn't present we don't know the precise file, so we synthesise
142
+ // a stable path from the import path so stableCaseId is consistent across
143
+ // runs without coupling to the user's $GOPATH.
144
+ if (file) return file;
145
+ if (!pkg) return undefined;
146
+ return pkg.split('/').join('/') + '/*_test.go';
147
+ }
148
+
149
+ function safeIso(t) {
150
+ if (!t) return undefined;
151
+ const d = new Date(t);
152
+ if (Number.isNaN(d.getTime())) return undefined;
153
+ return d.toISOString();
154
+ }
155
+
156
+ function splitTestPath(test) {
157
+ // Go convention: parent test and sub-tests joined by "/"
158
+ return String(test || '').split('/');
159
+ }
160
+
161
+ function fileHash(p) {
162
+ try {
163
+ const h = createHash('sha256');
164
+ h.update(statSync(p).size + ':' + p);
165
+ return h.digest('hex').slice(0, 16);
166
+ } catch { return undefined; }
167
+ }
168
+
169
+ function shortId(prefix) {
170
+ return prefix + '_' + randomUUID().replace(/-/g, '').slice(0, 10);
171
+ }
172
+
173
+ /**
174
+ * Convert a stream of `go test -json` events into a kensho-results/ bundle.
175
+ *
176
+ * @param {{
177
+ * events: object[],
178
+ * output: string,
179
+ * project?: { name?: string, slug?: string, url?: string },
180
+ * runId?: string,
181
+ * subtests?: 'cases' | 'children',
182
+ * }} opts
183
+ */
184
+ export function convertGoEvents(opts) {
185
+ const events = opts.events || [];
186
+ const outDir = resolve(process.cwd(), opts.output || 'kensho-results');
187
+ const casesDir = resolve(outDir, 'cases');
188
+ const attachmentsDir = resolve(outDir, 'attachments');
189
+ mkdirSync(outDir, { recursive: true });
190
+ mkdirSync(casesDir, { recursive: true });
191
+ mkdirSync(attachmentsDir, { recursive: true });
192
+
193
+ const subtests = opts.subtests === 'children' ? 'children' : 'cases';
194
+ const startedAt = new Date().toISOString();
195
+
196
+ // Two-pass: first build per-(pkg,test) buckets, then flatten into cases
197
+ // honoring the sub-test mode.
198
+ /** @type {Map<string, {
199
+ * pkg: string,
200
+ * test: string,
201
+ * parts: string[],
202
+ * startedAt?: string,
203
+ * finishedAt?: string,
204
+ * elapsed?: number,
205
+ * status?: string,
206
+ * panic?: boolean,
207
+ * output: string[],
208
+ * meta: object[],
209
+ * parentKey?: string,
210
+ * }>} */
211
+ const buckets = new Map();
212
+ const orderedKeys = [];
213
+
214
+ const keyFor = (pkg, test) => `${pkg}::${test}`;
215
+
216
+ for (const ev of events) {
217
+ if (!ev || !ev.Action) continue;
218
+ if (!ev.Test) continue; // package-level pass/fail/output — ignore for case building
219
+ const key = keyFor(ev.Package || '', ev.Test);
220
+ let b = buckets.get(key);
221
+ if (!b) {
222
+ const parts = splitTestPath(ev.Test);
223
+ const parentKey = parts.length > 1 ? keyFor(ev.Package || '', parts.slice(0, -1).join('/')) : undefined;
224
+ b = {
225
+ pkg: ev.Package || '',
226
+ test: ev.Test,
227
+ parts,
228
+ output: [],
229
+ meta: [],
230
+ parentKey,
231
+ };
232
+ buckets.set(key, b);
233
+ orderedKeys.push(key);
234
+ }
235
+ switch (ev.Action) {
236
+ case 'run':
237
+ b.startedAt = safeIso(ev.Time) || b.startedAt;
238
+ break;
239
+ case 'output': {
240
+ const txt = String(ev.Output || '');
241
+ const meta = parseMeta(txt);
242
+ if (meta) b.meta.push(meta);
243
+ else if (txt.length) b.output.push(txt.replace(/\n$/, ''));
244
+ if (/^\s*panic:/m.test(txt)) b.panic = true;
245
+ break;
246
+ }
247
+ case 'pass':
248
+ case 'fail':
249
+ case 'skip':
250
+ b.status = ev.Action;
251
+ b.finishedAt = safeIso(ev.Time) || b.finishedAt;
252
+ if (typeof ev.Elapsed === 'number') b.elapsed = ev.Elapsed;
253
+ break;
254
+ // pause / cont / bench — ignored
255
+ }
256
+ }
257
+
258
+ const usedIds = new Set();
259
+ /** @type {object[]} */
260
+ const cases = [];
261
+
262
+ for (const key of orderedKeys) {
263
+ const b = buckets.get(key);
264
+ if (!b) continue;
265
+ const isSub = b.parts.length > 1;
266
+ if (subtests === 'children' && isSub) continue; // handled by the parent
267
+
268
+ const caseObj = bucketToCase(b, buckets, subtests, usedIds);
269
+ // Materialise attachments declared via meta into the on-disk attachments
270
+ // dir so the bundle is self-contained — must happen before we write the
271
+ // case JSON so the attachment records are present.
272
+ materialiseAttachments(caseObj, attachmentsDir, outDir);
273
+ cases.push(caseObj);
274
+ writeFileSync(resolve(casesDir, caseObj.id + '.json'), JSON.stringify(caseObj, null, 2));
275
+ }
276
+
277
+ const finishedAt = new Date().toISOString();
278
+ const run = emptyRun({
279
+ id: opts.runId || ('run_' + new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14)),
280
+ project: {
281
+ name: opts.project?.name || 'Unknown project',
282
+ slug: opts.project?.slug || 'unknown',
283
+ url: opts.project?.url,
284
+ },
285
+ framework: { name: 'go-test', version: '0.1.0' },
286
+ env: envInfo(),
287
+ startedAt,
288
+ });
289
+ run.finishedAt = finishedAt;
290
+ run.durationMs = Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt));
291
+ run.testCases = cases;
292
+ run.totals = computeTotals(cases);
293
+
294
+ writeFileSync(resolve(outDir, 'run.json'), JSON.stringify(run, null, 2));
295
+ const { ok, errors } = validateRun(run);
296
+ if (!ok) {
297
+ console.warn('[kensho-go] run.json failed validation:');
298
+ for (const e of errors.slice(0, 8)) console.warn(' -', e);
299
+ }
300
+ console.log(`[kensho-go] wrote ${cases.length} cases + run.json to ${outDir}`);
301
+ return { outputDir: outDir, cases: cases.length, valid: ok };
302
+ }
303
+
304
+ function bucketToCase(b, buckets, subtests, usedIds) {
305
+ const fullName = `${b.pkg}::${b.test}`;
306
+ const filePath = packagePathToFile(b.pkg);
307
+ let id = stableCaseId(fullName, filePath);
308
+ if (usedIds.has(id)) {
309
+ let i = 2;
310
+ while (usedIds.has(id + '_' + i)) i++;
311
+ id = id + '_' + i;
312
+ }
313
+ usedIds.add(id);
314
+
315
+ const suite = b.pkg ? b.pkg.split('/').filter(Boolean) : [];
316
+ const tags = Array.from(new Set([...extractTags(b.test), ...metaTags(b.meta)]));
317
+
318
+ const status = mapStatus(b);
319
+ const startedAt = b.startedAt || new Date().toISOString();
320
+ const duration = Math.max(0, Math.round((b.elapsed || 0) * 1000));
321
+ const finishedAt = b.finishedAt || new Date(Date.parse(startedAt) + duration).toISOString();
322
+
323
+ const errors = collectErrors(b);
324
+ const logs = collectLogs(b, startedAt);
325
+ const labels = metaLabels(b.meta);
326
+ const links = metaLinks(b.meta);
327
+ const parameters = metaParameters(b.meta);
328
+ const behavior = metaBehavior(b.meta);
329
+ const severity = metaSeverity(b.meta) || severityFromName(lastPart(b.test));
330
+
331
+ let steps = metaSteps(b.meta);
332
+
333
+ // Collect attach meta — the actual file copy happens in
334
+ // materialiseAttachments after the case object is in hand.
335
+ const attachMeta = b.meta.filter(m => m && m.kind === 'attach' && m.path);
336
+
337
+ // When sub-tests are folded into children, build a synthetic step tree
338
+ // from each sub-bucket and append it to this case's `steps`.
339
+ if (subtests === 'children') {
340
+ const childKeys = Array.from(buckets.keys()).filter(k => {
341
+ const cb = buckets.get(k);
342
+ return cb && cb.parentKey === keyFor(b.pkg, b.test) && cb.parts.length === b.parts.length + 1;
343
+ });
344
+ childKeys.sort();
345
+ for (const childKey of childKeys) {
346
+ const cb = buckets.get(childKey);
347
+ const childStep = subBucketToStep(cb, buckets, b.startedAt || startedAt);
348
+ if (childStep) steps.push(childStep);
349
+ }
350
+ }
351
+
352
+ const caseObj = {
353
+ id,
354
+ name: lastPart(b.test),
355
+ fullName,
356
+ filePath,
357
+ suite,
358
+ tags,
359
+ status,
360
+ startedAt,
361
+ finishedAt,
362
+ duration,
363
+ retries: 0,
364
+ platform: process.platform,
365
+ };
366
+ if (severity) caseObj.severity = severity;
367
+ if (Object.keys(behavior).length) caseObj.behavior = behavior;
368
+ if (Object.keys(labels).length) caseObj.labels = labels;
369
+ if (links.length) caseObj.links = links;
370
+ if (parameters.length) caseObj.parameters = parameters;
371
+ if (steps.length) caseObj.steps = steps;
372
+ if (errors.length) caseObj.errors = errors;
373
+ if (logs.length) caseObj.logs = logs;
374
+ if (attachMeta.length) caseObj._attachMeta = attachMeta;
375
+ return caseObj;
376
+ }
377
+
378
+ function subBucketToStep(b, buckets, parentStartedAt) {
379
+ if (!b) return null;
380
+ const startedAt = b.startedAt || parentStartedAt || new Date().toISOString();
381
+ const duration = Math.max(0, Math.round((b.elapsed || 0) * 1000));
382
+ const status = mapStatus(b);
383
+ const stepStatus = status === 'broken' ? 'fail' : status; // step enum has no 'broken'
384
+ const step = {
385
+ id: shortId('step'),
386
+ title: lastPart(b.test),
387
+ status: stepStatus,
388
+ startedAt,
389
+ duration,
390
+ phase: 'body',
391
+ };
392
+ // recurse into deeper sub-tests
393
+ const childKeys = Array.from(buckets.keys()).filter(k => {
394
+ const cb = buckets.get(k);
395
+ return cb && cb.parentKey === keyFor(b.pkg, b.test) && cb.parts.length === b.parts.length + 1;
396
+ });
397
+ if (childKeys.length) {
398
+ step.children = [];
399
+ for (const ck of childKeys.sort()) {
400
+ const child = subBucketToStep(buckets.get(ck), buckets, startedAt);
401
+ if (child) step.children.push(child);
402
+ }
403
+ }
404
+ // Capture failure output as an assertion stack so the viewer renders it.
405
+ if (status === 'fail') {
406
+ const stack = b.output.join('\n').trim();
407
+ if (stack) step.assertion = { stack };
408
+ }
409
+ return step;
410
+ }
411
+
412
+ function keyFor(pkg, test) { return `${pkg}::${test}`; }
413
+
414
+ function lastPart(test) {
415
+ const parts = splitTestPath(test);
416
+ return parts[parts.length - 1];
417
+ }
418
+
419
+ function mapStatus(b) {
420
+ if (b.panic && b.status !== 'pass') return 'fail';
421
+ if (b.status === 'pass') return 'pass';
422
+ if (b.status === 'fail') return 'fail';
423
+ if (b.status === 'skip') return 'skip';
424
+ // No terminal action recorded — likely a build failure or interrupted run.
425
+ return 'broken';
426
+ }
427
+
428
+ function collectErrors(b) {
429
+ if (b.status !== 'fail' && !b.panic) return [];
430
+ const text = b.output.join('\n');
431
+ // First, look for a panic stanza — `panic: <message>` followed by goroutine
432
+ // frames is the canonical Go panic shape.
433
+ const panicMatch = /^\s*panic:\s*(.*)$/m.exec(text);
434
+ if (panicMatch) {
435
+ return [{
436
+ message: panicMatch[1].trim() || 'panic',
437
+ stack: text.trim(),
438
+ type: 'panic',
439
+ }];
440
+ }
441
+ // Otherwise pull the first `--- FAIL: …` block plus the surrounding lines.
442
+ const failRe = /^\s*([\w./_-]+\.go:\d+):\s*(.*)$/m;
443
+ const m = failRe.exec(text);
444
+ if (m) {
445
+ return [{
446
+ message: m[2].trim() || 'test failed',
447
+ stack: text.trim(),
448
+ }];
449
+ }
450
+ // Fallback — first non-empty line.
451
+ const first = text.split(/\n/).map(s => s.trim()).find(Boolean);
452
+ return [{
453
+ message: first || 'test failed',
454
+ stack: text.trim() || undefined,
455
+ }];
456
+ }
457
+
458
+ function collectLogs(b, startedAt) {
459
+ const logs = [];
460
+ const baseMs = Date.parse(startedAt);
461
+ let i = 0;
462
+ for (const line of b.output) {
463
+ const trimmed = line.replace(/\s+$/, '');
464
+ if (!trimmed) continue;
465
+ if (trimmed.startsWith(META_PREFIX)) continue;
466
+ // Skip the structural `=== RUN`, `--- PASS`, `--- FAIL` lines — they're
467
+ // noise for the report viewer (the case status/duration already capture
468
+ // that information).
469
+ if (/^\s*===\s+(RUN|PAUSE|CONT|NAME)/.test(trimmed)) continue;
470
+ if (/^\s*---\s+(PASS|FAIL|SKIP)/.test(trimmed)) continue;
471
+ if (/^\s*PASS\s*$/.test(trimmed)) continue;
472
+ if (/^\s*FAIL\s*$/.test(trimmed)) continue;
473
+ if (/^\s*ok\s+\S+\s+\S+\s*$/.test(trimmed)) continue;
474
+ const level = /panic|error|FAIL/i.test(trimmed) ? 'error'
475
+ : /warn/i.test(trimmed) ? 'warn'
476
+ : 'info';
477
+ logs.push({ t: Math.max(0, i++), level, msg: trimmed });
478
+ }
479
+ return logs;
480
+ }
481
+
482
+ function metaTags(meta) {
483
+ return meta.filter(m => m && m.kind === 'tag' && m.value).map(m => String(m.value));
484
+ }
485
+
486
+ function metaLabels(meta) {
487
+ const out = {};
488
+ for (const m of meta) {
489
+ if (m && m.kind === 'label' && m.key) out[String(m.key)] = String(m.value ?? '');
490
+ }
491
+ return out;
492
+ }
493
+
494
+ function metaLinks(meta) {
495
+ const out = [];
496
+ for (const m of meta) {
497
+ if (m && m.kind === 'link' && m.url) {
498
+ const link = { url: String(m.url) };
499
+ if (m.linkKind) link.kind = String(m.linkKind);
500
+ if (m.label) link.label = String(m.label);
501
+ out.push(link);
502
+ }
503
+ }
504
+ return out;
505
+ }
506
+
507
+ function metaParameters(meta) {
508
+ return meta
509
+ .filter(m => m && m.kind === 'parameter' && m.name)
510
+ .map(m => ({
511
+ name: String(m.name),
512
+ value: String(m.value ?? ''),
513
+ ...(m.paramKind ? { kind: String(m.paramKind) } : {}),
514
+ }));
515
+ }
516
+
517
+ function metaBehavior(meta) {
518
+ const out = {};
519
+ for (const m of meta) {
520
+ if (!m || !m.value) continue;
521
+ if (m.kind === 'feature') out.feature = String(m.value);
522
+ else if (m.kind === 'epic') out.epic = String(m.value);
523
+ else if (m.kind === 'scenario' || m.kind === 'story') out.scenario = String(m.value);
524
+ }
525
+ return out;
526
+ }
527
+
528
+ function metaSeverity(meta) {
529
+ for (const m of meta) {
530
+ if (m && m.kind === 'severity' && m.value) {
531
+ const v = String(m.value).toLowerCase();
532
+ if (SEVERITY_NAMES.includes(v)) return v;
533
+ }
534
+ }
535
+ return undefined;
536
+ }
537
+
538
+ function metaSteps(meta) {
539
+ // Build the step tree from interleaved step_start / step_end records.
540
+ // `id` strings are user-supplied so we use them as the join key; if a
541
+ // user forgets to call `step_end`, the unmatched step is left as `pass`
542
+ // (no duration available).
543
+ const open = []; // stack of open steps (innermost last)
544
+ const roots = [];
545
+ for (const m of meta) {
546
+ if (!m) continue;
547
+ if (m.kind === 'step_start') {
548
+ const step = {
549
+ id: m.id ? `step_${String(m.id)}` : shortId('step'),
550
+ _userId: m.id,
551
+ title: String(m.title || 'step'),
552
+ status: 'pass',
553
+ startedAt: safeIso(m.t) || new Date().toISOString(),
554
+ duration: 0,
555
+ phase: 'body',
556
+ _startedMs: typeof m.t === 'number' ? m.t : Date.parse(safeIso(m.t) || new Date().toISOString()),
557
+ };
558
+ if (m.action) step.action = String(m.action);
559
+ const parent = open[open.length - 1];
560
+ if (parent) (parent.children ||= []).push(step);
561
+ else roots.push(step);
562
+ open.push(step);
563
+ } else if (m.kind === 'step_end') {
564
+ // close the matching step (by id or topmost)
565
+ let idx = -1;
566
+ if (m.id) {
567
+ for (let i = open.length - 1; i >= 0; i--) {
568
+ if (open[i]._userId === m.id) { idx = i; break; }
569
+ }
570
+ }
571
+ if (idx === -1) idx = open.length - 1;
572
+ if (idx === -1) continue;
573
+ const step = open.splice(idx, 1)[0];
574
+ if (m.status && (m.status === 'pass' || m.status === 'fail' || m.status === 'skip')) {
575
+ step.status = m.status;
576
+ }
577
+ const endMs = typeof m.t === 'number' ? m.t : Date.parse(safeIso(m.t) || new Date().toISOString());
578
+ step.duration = Math.max(0, Math.round(endMs - (step._startedMs || endMs)));
579
+ delete step._startedMs;
580
+ delete step._userId;
581
+ }
582
+ }
583
+ // Force-close any leaks — we never want a partial step to leave dangling
584
+ // _internal fields in the output JSON.
585
+ for (const step of open) {
586
+ delete step._startedMs;
587
+ delete step._userId;
588
+ }
589
+ // Strip helpers from nested children too.
590
+ const strip = (s) => {
591
+ if (!s) return;
592
+ delete s._startedMs;
593
+ delete s._userId;
594
+ (s.children || []).forEach(strip);
595
+ };
596
+ roots.forEach(strip);
597
+ return roots;
598
+ }
599
+
600
+ function materialiseAttachments(caseObj, attachmentsRoot, outDir) {
601
+ // The Go helper uses meta with kind "attach" — we copy the file into the
602
+ // bundle so the report directory is self-contained.
603
+ if (!Array.isArray(caseObj._attachMeta)) return;
604
+ for (const att of caseObj._attachMeta) {
605
+ if (!att.path) continue;
606
+ const src = isAbsolute(att.path) ? att.path : resolve(process.cwd(), att.path);
607
+ if (!existsSync(src)) continue;
608
+ const destDir = resolve(attachmentsRoot, caseObj.id);
609
+ mkdirSync(destDir, { recursive: true });
610
+ const ext = extname(src).toLowerCase();
611
+ const attId = shortId('att');
612
+ const destName = `${attId}_${att.name || basename(src)}`;
613
+ const dest = resolve(destDir, destName);
614
+ try { copyFileSync(src, dest); } catch { continue; }
615
+ const rec = {
616
+ id: attId,
617
+ kind: att.kind || KIND_BY_EXT[ext] || 'text',
618
+ relativePath: dest.slice(outDir.length).replace(/^[\/\\]/, ''),
619
+ mimeType: att.mimeType || MIME_BY_EXT[ext] || 'application/octet-stream',
620
+ };
621
+ const sz = (() => { try { return statSync(dest).size; } catch { return undefined; } })();
622
+ if (sz !== undefined) rec.sizeBytes = sz;
623
+ const sha = fileHash(dest);
624
+ if (sha) rec.sha256 = sha;
625
+ (caseObj.attachments ||= []).push(rec);
626
+ }
627
+ delete caseObj._attachMeta;
628
+ }