@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 +21 -0
- package/README.md +129 -0
- package/bin/kensho-go.js +102 -0
- package/go-helper/go.mod +3 -0
- package/go-helper/kensho.go +203 -0
- package/package.json +35 -0
- package/src/index.js +628 -0
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.
|
package/bin/kensho-go.js
ADDED
|
@@ -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
|
+
})();
|
package/go-helper/go.mod
ADDED
|
@@ -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
|
+
}
|