fumadocs-test-reports 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 +114 -0
- package/css/preset.css +300 -0
- package/dist/tunit/index.d.ts +3 -0
- package/dist/tunit/index.js +2 -0
- package/dist/tunit/parse.d.ts +9 -0
- package/dist/tunit/parse.js +180 -0
- package/dist/tunit/types.d.ts +121 -0
- package/dist/tunit/ui/index.d.ts +4 -0
- package/dist/tunit/ui/index.js +3 -0
- package/dist/tunit/ui/labels.d.ts +3 -0
- package/dist/tunit/ui/labels.js +93 -0
- package/dist/tunit/ui/presentation.js +55 -0
- package/dist/tunit/ui/report-link.d.ts +12 -0
- package/dist/tunit/ui/report-link.js +23 -0
- package/dist/tunit/ui/report.d.ts +13 -0
- package/dist/tunit/ui/report.js +265 -0
- package/dist/tunit/ui/trace-timeline.js +81 -0
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +2 -0
- package/dist/ui/primitives.d.ts +32 -0
- package/dist/ui/primitives.js +48 -0
- package/package.json +52 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 shenxianovo
|
|
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,114 @@
|
|
|
1
|
+
# fumadocs-test-reports
|
|
2
|
+
|
|
3
|
+
Framework-native test report presenters and shared React UI primitives for Fumadocs.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
import { parseTUnitReport } from 'fumadocs-test-reports/tunit';
|
|
9
|
+
import { TUnitReport, TUnitReportLink } from 'fumadocs-test-reports/tunit/ui';
|
|
10
|
+
import input from './run.tunit-report.json';
|
|
11
|
+
|
|
12
|
+
const report = parseTUnitReport(input);
|
|
13
|
+
|
|
14
|
+
<TUnitReport report={report} />;
|
|
15
|
+
<TUnitReportLink report={report} href="/docs/reports/run" />;
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Install the package in your Fumadocs application:
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pnpm add fumadocs-test-reports
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
React 19.2+ and Fumadocs UI 16.16.2+ are peer dependencies. The parser entry does not import React or Fumadocs UI.
|
|
25
|
+
|
|
26
|
+
Import the stylesheet after your Fumadocs styles:
|
|
27
|
+
|
|
28
|
+
```css
|
|
29
|
+
@import 'fumadocs-test-reports/css/preset.css';
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Architecture
|
|
33
|
+
|
|
34
|
+
During 0.x development, changes replace the current API and data model directly. Deprecated APIs and version-dependent compatibility branches are not retained.
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
TUnit JSON → parse → TUnit native fields → TUnit presenter → shared UI primitives
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Each framework owns its parser, native types and presenter. Shared UI primitives accept display props, such as text, a visual tone or a duration. Future presenters can combine these primitives with their framework-specific traces, steps or artifacts.
|
|
41
|
+
|
|
42
|
+
Public entries:
|
|
43
|
+
|
|
44
|
+
- `/tunit`: `parseTUnitReport`, `TUnitParseError` and native `TUnit*` types.
|
|
45
|
+
- `/tunit/ui`: `TUnitReport` and `TUnitReportLink`.
|
|
46
|
+
- `/ui`: `StatusBadge`, `Duration`, `ErrorDetail` and `StdoutPanel`.
|
|
47
|
+
- `/css/preset.css`: report-specific layout and status styles using Fumadocs theme variables.
|
|
48
|
+
|
|
49
|
+
## Native TUnit data
|
|
50
|
+
|
|
51
|
+
`parseTUnitReport` accepts decoded `.tunit-report.json` sidecar JSON, schema version 1. It validates the fields consumed by the presenter, then returns the input object unchanged. The caller owns file reads, network requests and routing.
|
|
52
|
+
|
|
53
|
+
Names and values remain native: `assemblyName`, `groups[].tests`, `displayName`, `output`, `errorOutput`, `exception.innerException`, `retryAttempt`, `spanId` and `parentSpanId`. Unknown fields, arbitrary status strings and explicit null values remain present. The parser adds no fields, defaults or status mappings.
|
|
54
|
+
|
|
55
|
+
The minimum shape contains `schemaVersion`, `assemblyName`, `groups` and each group's `className` and `tests`. A test requires `id`, `displayName` and `status`. Metadata and optional payloads are checked when supplied. Invalid known fields, duplicate test IDs or unsupported schema versions throw `TUnitParseError`; its `path` identifies the JSON field.
|
|
56
|
+
|
|
57
|
+
The presenter reads TUnit's supplied `summary` counters. For minimal reports without these counters, it derives display counts from the native tests. Retries remain attempt history within a test. Timestamp text is displayed as supplied, and duration values are milliseconds.
|
|
58
|
+
|
|
59
|
+
`TUnitReport` provides search, native outcome filters, details, output, retry attempts and a TUnit trace timeline. `selectedTestId` and `onTestSelect` let the host implement permalinks. `TUnitReportLink` embeds a link to the same native report in a document.
|
|
60
|
+
|
|
61
|
+
The presenter reuses Fumadocs Tabs, Accordion, Card, CodeBlock, Callout and button variants. Custom styles cover the test list, metrics, status badges and trace bars. Native components provide tab keyboard navigation, disclosure controls and code-copy buttons.
|
|
62
|
+
|
|
63
|
+
Report layout uses container queries: test columns and metrics adapt to the report's available width, including space consumed by a host sidebar or table of contents. Match your Fumadocs layout with its corresponding page components. Spacious and Glass also require their layout styles, imported as `fumadocs-ui/css/generated/spacious.css` or `fumadocs-ui/css/generated/glass.css`.
|
|
64
|
+
|
|
65
|
+
Use the host's `<DocsPage full>` for a dedicated report page. Spacious's native page width is 760 px by default; `full` sets the limit to 1200 px, while the report adapts to the available space inside it.
|
|
66
|
+
|
|
67
|
+
Flux in `@fumadocs/base-ui@16.16.2` has a page-container width issue: its centred outer layout can shrink the page when content uses inline-size containment. The workspace example applies a dependency patch that adds `w-full` to Flux's native `main`; it keeps the native page limits and alignment. This patch is not included in the published report package. A host using this affected Flux version needs the same dependency fix. Other demonstrated layouts use their native page configuration directly.
|
|
68
|
+
|
|
69
|
+
Both support `locale="en"` (default) and `locale="zh"`. Fumadocs layouts and Next.js routing belong to the host application.
|
|
70
|
+
|
|
71
|
+
## Shared UI primitives
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
import { StatusBadge, Duration, ErrorDetail, StdoutPanel } from 'fumadocs-test-reports/ui';
|
|
75
|
+
|
|
76
|
+
<StatusBadge status="failed" tone="danger" />;
|
|
77
|
+
<Duration value={42} />;
|
|
78
|
+
<ErrorDetail message="Expected 400, received 200" stack="at example.test.ts:12" />;
|
|
79
|
+
<StdoutPanel content="Connection refused" title="Standard error" />;
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`StatusBadge` displays the caller's text with a visual tone. The presenter decides what a native status means. `Duration` treats a missing or null value as unknown and displays a recorded zero as zero. Error and output components render plain, escaped text; the presenter owns exception nesting and retry semantics.
|
|
83
|
+
|
|
84
|
+
This version implements TUnit. Additional frameworks will have their own native parsers and presenters. Dashboard, history and cross-framework search are future features.
|
|
85
|
+
|
|
86
|
+
References: [TUnit's native report model](https://github.com/thomhurst/TUnit/blob/main/src/TUnit.Engine/Reporters/Html/HtmlReportDataModel.cs), [its sidecar writer](https://github.com/thomhurst/TUnit/blob/main/src/TUnit.Engine/Reporters/Aggregation/ReportDataJson.cs), and [Fumadocs OpenAPI's package layout](https://github.com/fuma-nama/fumadocs/tree/dev/packages/openapi).
|
|
87
|
+
|
|
88
|
+
## Local release
|
|
89
|
+
|
|
90
|
+
Run the workspace checks from the repository root:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
pnpm test --run
|
|
94
|
+
pnpm types:check
|
|
95
|
+
pnpm build
|
|
96
|
+
pnpm lint
|
|
97
|
+
pnpm lint:format
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Review the public package from `packages/test-reports`:
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
npm publish --dry-run --ignore-scripts
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`pnpm pack` rebuilds the package through `prepack`. Published files are limited to `dist`, `css`, the package README, license and manifest. Example reports and test fixtures stay in the repository.
|
|
107
|
+
|
|
108
|
+
After logging in to npm, publish from `packages/test-reports`:
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
pnpm publish --access public --no-git-checks
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The manifest specifies the public npm registry. Publishing requires npm account access and any authentication challenge configured for that account.
|
package/css/preset.css
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
.ftr-report,
|
|
2
|
+
.ftr-summary,
|
|
3
|
+
.ftr-ui {
|
|
4
|
+
color: var(--color-fd-foreground);
|
|
5
|
+
font-size: 0.875rem;
|
|
6
|
+
line-height: 1.6;
|
|
7
|
+
}
|
|
8
|
+
.ftr-report {
|
|
9
|
+
width: 100%;
|
|
10
|
+
min-width: 0;
|
|
11
|
+
container: ftr-report / inline-size;
|
|
12
|
+
}
|
|
13
|
+
.ftr-run-heading {
|
|
14
|
+
margin-bottom: 1.25rem;
|
|
15
|
+
}
|
|
16
|
+
.ftr-run-heading h2 {
|
|
17
|
+
font-size: 1.25rem;
|
|
18
|
+
font-weight: 600;
|
|
19
|
+
overflow-wrap: anywhere;
|
|
20
|
+
}
|
|
21
|
+
.ftr-run-meta {
|
|
22
|
+
display: flex;
|
|
23
|
+
flex-wrap: wrap;
|
|
24
|
+
gap: 0.375rem 1rem;
|
|
25
|
+
color: var(--color-fd-muted-foreground);
|
|
26
|
+
font-size: 0.75rem;
|
|
27
|
+
margin-top: 0.5rem;
|
|
28
|
+
}
|
|
29
|
+
.ftr-metrics {
|
|
30
|
+
display: grid;
|
|
31
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
32
|
+
border: 1px solid var(--color-fd-border);
|
|
33
|
+
border-radius: 0.75rem;
|
|
34
|
+
background: var(--color-fd-card);
|
|
35
|
+
margin-bottom: 1.5rem;
|
|
36
|
+
overflow: hidden;
|
|
37
|
+
}
|
|
38
|
+
.ftr-metrics > div {
|
|
39
|
+
padding: 1rem;
|
|
40
|
+
display: flex;
|
|
41
|
+
flex-direction: column;
|
|
42
|
+
gap: 0.25rem;
|
|
43
|
+
border-inline-end: 1px solid var(--color-fd-border);
|
|
44
|
+
}
|
|
45
|
+
.ftr-metrics > div:last-child {
|
|
46
|
+
border-inline-end: 0;
|
|
47
|
+
}
|
|
48
|
+
.ftr-metrics span,
|
|
49
|
+
.ftr-metrics small {
|
|
50
|
+
color: var(--color-fd-muted-foreground);
|
|
51
|
+
font-size: 0.75rem;
|
|
52
|
+
}
|
|
53
|
+
.ftr-metrics strong {
|
|
54
|
+
font-size: 1.5rem;
|
|
55
|
+
font-weight: 600;
|
|
56
|
+
}
|
|
57
|
+
.ftr-toolbar {
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
justify-content: space-between;
|
|
61
|
+
gap: 1rem;
|
|
62
|
+
margin-bottom: 0.75rem;
|
|
63
|
+
}
|
|
64
|
+
.ftr-toolbar h3,
|
|
65
|
+
.ftr-detail-heading h3 {
|
|
66
|
+
font-size: 1rem;
|
|
67
|
+
font-weight: 600;
|
|
68
|
+
}
|
|
69
|
+
.ftr-toolbar input {
|
|
70
|
+
font: inherit;
|
|
71
|
+
border: 1px solid var(--color-fd-border);
|
|
72
|
+
padding: 0.5rem 0.75rem;
|
|
73
|
+
border-radius: 0.5rem;
|
|
74
|
+
background: var(--color-fd-background);
|
|
75
|
+
color: inherit;
|
|
76
|
+
width: min(100%, 300px);
|
|
77
|
+
}
|
|
78
|
+
.ftr-toolbar input:focus-visible {
|
|
79
|
+
outline: 2px solid var(--color-fd-ring);
|
|
80
|
+
outline-offset: 2px;
|
|
81
|
+
}
|
|
82
|
+
.ftr-filters {
|
|
83
|
+
display: flex;
|
|
84
|
+
gap: 0.25rem;
|
|
85
|
+
flex-wrap: wrap;
|
|
86
|
+
margin-bottom: 1rem;
|
|
87
|
+
}
|
|
88
|
+
.ftr-filters small {
|
|
89
|
+
opacity: 0.7;
|
|
90
|
+
margin-inline-start: 0.375rem;
|
|
91
|
+
}
|
|
92
|
+
.ftr-workspace {
|
|
93
|
+
display: grid;
|
|
94
|
+
grid-template-columns: minmax(250px, 0.9fr) minmax(300px, 1.1fr);
|
|
95
|
+
border: 1px solid var(--color-fd-border);
|
|
96
|
+
border-radius: 0.75rem;
|
|
97
|
+
overflow: hidden;
|
|
98
|
+
}
|
|
99
|
+
.ftr-list {
|
|
100
|
+
border-inline-end: 1px solid var(--color-fd-border);
|
|
101
|
+
max-height: 700px;
|
|
102
|
+
overflow: auto;
|
|
103
|
+
}
|
|
104
|
+
.ftr-group {
|
|
105
|
+
padding: 0.75rem 1rem;
|
|
106
|
+
background: var(--color-fd-card);
|
|
107
|
+
border-bottom: 1px solid var(--color-fd-border);
|
|
108
|
+
font-size: 0.75rem;
|
|
109
|
+
}
|
|
110
|
+
.ftr-group small {
|
|
111
|
+
display: block;
|
|
112
|
+
color: var(--color-fd-muted-foreground);
|
|
113
|
+
overflow-wrap: anywhere;
|
|
114
|
+
}
|
|
115
|
+
.ftr-test {
|
|
116
|
+
width: 100%;
|
|
117
|
+
justify-content: start;
|
|
118
|
+
align-items: center;
|
|
119
|
+
text-align: start;
|
|
120
|
+
gap: 0.625rem;
|
|
121
|
+
padding: 0.875rem;
|
|
122
|
+
border-radius: 0;
|
|
123
|
+
border-bottom: 1px solid var(--color-fd-border);
|
|
124
|
+
}
|
|
125
|
+
.ftr-test[aria-pressed='true'] {
|
|
126
|
+
background: var(--color-fd-accent);
|
|
127
|
+
box-shadow: inset 2px 0 var(--color-fd-primary);
|
|
128
|
+
}
|
|
129
|
+
.ftr-test-name {
|
|
130
|
+
flex: 1;
|
|
131
|
+
min-width: 0;
|
|
132
|
+
overflow-wrap: anywhere;
|
|
133
|
+
font-size: 0.8125rem;
|
|
134
|
+
}
|
|
135
|
+
.ftr-test > small {
|
|
136
|
+
white-space: nowrap;
|
|
137
|
+
color: var(--color-fd-muted-foreground);
|
|
138
|
+
font-size: 0.75rem;
|
|
139
|
+
}
|
|
140
|
+
.ftr-badge {
|
|
141
|
+
display: inline-flex;
|
|
142
|
+
align-items: center;
|
|
143
|
+
flex-shrink: 0;
|
|
144
|
+
font-size: 0.75rem;
|
|
145
|
+
font-weight: 500;
|
|
146
|
+
padding: 0.125rem 0.5rem;
|
|
147
|
+
border-radius: 0.375rem;
|
|
148
|
+
white-space: nowrap;
|
|
149
|
+
border: 1px solid color-mix(in srgb, var(--ftr-tone) 40%, transparent);
|
|
150
|
+
background: color-mix(in srgb, var(--ftr-tone) 10%, transparent);
|
|
151
|
+
color: var(--color-fd-foreground);
|
|
152
|
+
}
|
|
153
|
+
.ftr-tone-success {
|
|
154
|
+
--ftr-tone: var(--color-fd-success);
|
|
155
|
+
}
|
|
156
|
+
.ftr-tone-danger {
|
|
157
|
+
--ftr-tone: var(--color-fd-error);
|
|
158
|
+
}
|
|
159
|
+
.ftr-tone-neutral {
|
|
160
|
+
--ftr-tone: var(--color-fd-muted-foreground);
|
|
161
|
+
}
|
|
162
|
+
.ftr-tone-info {
|
|
163
|
+
--ftr-tone: var(--color-fd-info);
|
|
164
|
+
}
|
|
165
|
+
.ftr-detail {
|
|
166
|
+
min-width: 0;
|
|
167
|
+
}
|
|
168
|
+
.ftr-detail-heading {
|
|
169
|
+
padding: 1rem;
|
|
170
|
+
}
|
|
171
|
+
.ftr-detail-heading h3 {
|
|
172
|
+
overflow-wrap: anywhere;
|
|
173
|
+
margin: 0.5rem 0;
|
|
174
|
+
}
|
|
175
|
+
.ftr-detail-heading > small {
|
|
176
|
+
color: var(--color-fd-muted-foreground);
|
|
177
|
+
}
|
|
178
|
+
.ftr-detail-tabs {
|
|
179
|
+
margin: 0;
|
|
180
|
+
border-radius: 0;
|
|
181
|
+
border-inline: 0;
|
|
182
|
+
border-bottom: 0;
|
|
183
|
+
}
|
|
184
|
+
.ftr-fields {
|
|
185
|
+
display: grid;
|
|
186
|
+
grid-template-columns: 85px minmax(0, 1fr);
|
|
187
|
+
gap: 0.75rem;
|
|
188
|
+
font-size: 0.75rem;
|
|
189
|
+
margin: 0;
|
|
190
|
+
}
|
|
191
|
+
.ftr-fields dt {
|
|
192
|
+
color: var(--color-fd-muted-foreground);
|
|
193
|
+
overflow-wrap: anywhere;
|
|
194
|
+
}
|
|
195
|
+
.ftr-fields dd {
|
|
196
|
+
margin: 0;
|
|
197
|
+
overflow-wrap: anywhere;
|
|
198
|
+
}
|
|
199
|
+
.ftr-field {
|
|
200
|
+
display: contents;
|
|
201
|
+
}
|
|
202
|
+
.ftr-codeblock code {
|
|
203
|
+
white-space: pre-wrap;
|
|
204
|
+
overflow-wrap: anywhere;
|
|
205
|
+
padding-inline: 1rem;
|
|
206
|
+
}
|
|
207
|
+
.ftr-error-message {
|
|
208
|
+
white-space: pre-wrap;
|
|
209
|
+
overflow-wrap: anywhere;
|
|
210
|
+
}
|
|
211
|
+
.ftr-output,
|
|
212
|
+
.ftr-attempts {
|
|
213
|
+
max-height: 600px;
|
|
214
|
+
overflow: auto;
|
|
215
|
+
}
|
|
216
|
+
.ftr-attempt {
|
|
217
|
+
padding: 0.75rem 0;
|
|
218
|
+
border-bottom: 1px solid var(--color-fd-border);
|
|
219
|
+
}
|
|
220
|
+
.ftr-attempt h4 {
|
|
221
|
+
display: flex;
|
|
222
|
+
gap: 0.625rem;
|
|
223
|
+
align-items: center;
|
|
224
|
+
font-weight: 500;
|
|
225
|
+
}
|
|
226
|
+
.ftr-traces {
|
|
227
|
+
max-height: 600px;
|
|
228
|
+
overflow: auto;
|
|
229
|
+
}
|
|
230
|
+
.ftr-span-heading {
|
|
231
|
+
display: grid;
|
|
232
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
233
|
+
gap: 0.25rem 0.625rem;
|
|
234
|
+
width: 100%;
|
|
235
|
+
text-align: start;
|
|
236
|
+
font-size: 0.75rem;
|
|
237
|
+
}
|
|
238
|
+
.ftr-span-heading > span {
|
|
239
|
+
overflow-wrap: anywhere;
|
|
240
|
+
}
|
|
241
|
+
.ftr-span-heading > small {
|
|
242
|
+
color: var(--color-fd-muted-foreground);
|
|
243
|
+
}
|
|
244
|
+
.ftr-track {
|
|
245
|
+
grid-column: 1 / -1;
|
|
246
|
+
display: block;
|
|
247
|
+
height: 5px;
|
|
248
|
+
border-radius: 3px;
|
|
249
|
+
background: var(--color-fd-muted);
|
|
250
|
+
margin: 0.375rem 0;
|
|
251
|
+
overflow: hidden;
|
|
252
|
+
}
|
|
253
|
+
.ftr-track span {
|
|
254
|
+
display: block;
|
|
255
|
+
height: 100%;
|
|
256
|
+
min-width: 2px;
|
|
257
|
+
background: var(--color-fd-primary);
|
|
258
|
+
opacity: 0.6;
|
|
259
|
+
border-radius: 3px;
|
|
260
|
+
}
|
|
261
|
+
.ftr-empty {
|
|
262
|
+
padding: 1rem;
|
|
263
|
+
color: var(--color-fd-muted-foreground);
|
|
264
|
+
font-size: 0.875rem;
|
|
265
|
+
}
|
|
266
|
+
.ftr-summary-footer {
|
|
267
|
+
display: flex;
|
|
268
|
+
gap: 0.75rem;
|
|
269
|
+
justify-content: space-between;
|
|
270
|
+
align-items: center;
|
|
271
|
+
margin-top: 0.75rem;
|
|
272
|
+
}
|
|
273
|
+
@container ftr-report (max-width: 850px) {
|
|
274
|
+
.ftr-workspace {
|
|
275
|
+
grid-template-columns: minmax(0, 1fr);
|
|
276
|
+
}
|
|
277
|
+
.ftr-list {
|
|
278
|
+
border-inline-end: 0;
|
|
279
|
+
border-bottom: 1px solid var(--color-fd-border);
|
|
280
|
+
max-height: 350px;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
@container ftr-report (max-width: 600px) {
|
|
284
|
+
.ftr-metrics {
|
|
285
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
286
|
+
}
|
|
287
|
+
.ftr-metrics > div:nth-child(2) {
|
|
288
|
+
border-inline-end: 0;
|
|
289
|
+
}
|
|
290
|
+
.ftr-metrics > div:nth-child(-n + 2) {
|
|
291
|
+
border-bottom: 1px solid var(--color-fd-border);
|
|
292
|
+
}
|
|
293
|
+
.ftr-toolbar {
|
|
294
|
+
align-items: stretch;
|
|
295
|
+
flex-direction: column;
|
|
296
|
+
}
|
|
297
|
+
.ftr-toolbar input {
|
|
298
|
+
width: 100%;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { TUnitAttempt, TUnitException, TUnitKeyValue, TUnitReport, TUnitReportSummary, TUnitSpan, TUnitTestGroup, TUnitTestResult } from "./types.js";
|
|
2
|
+
import { TUnitParseError, parseTUnitReport } from "./parse.js";
|
|
3
|
+
export { type TUnitAttempt, type TUnitException, type TUnitKeyValue, TUnitParseError, type TUnitReport, type TUnitReportSummary, type TUnitSpan, type TUnitTestGroup, type TUnitTestResult, parseTUnitReport };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { TUnitReport } from "./types.js";
|
|
2
|
+
//#region src/tunit/parse.d.ts
|
|
3
|
+
export declare class TUnitParseError extends Error {
|
|
4
|
+
readonly path: string;
|
|
5
|
+
constructor(path: string, message: string);
|
|
6
|
+
}
|
|
7
|
+
/** Validate decoded sidecar JSON and return its native structure unchanged. */
|
|
8
|
+
export declare function parseTUnitReport(input: unknown): TUnitReport;
|
|
9
|
+
//#endregion
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
//#region src/tunit/parse.ts
|
|
2
|
+
var TUnitParseError = class extends Error {
|
|
3
|
+
constructor(path, message) {
|
|
4
|
+
super(`${path}: ${message}`);
|
|
5
|
+
this.path = path;
|
|
6
|
+
this.name = "TUnitParseError";
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
function object(value, path) {
|
|
10
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new TUnitParseError(path, "expected an object");
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function text(value, path, required = false) {
|
|
14
|
+
if (value == null && !required) return;
|
|
15
|
+
if (typeof value !== "string") throw new TUnitParseError(path, "expected a string");
|
|
16
|
+
}
|
|
17
|
+
function nonempty(value, path) {
|
|
18
|
+
text(value, path, true);
|
|
19
|
+
if (!value.trim()) throw new TUnitParseError(path, "expected a non-empty string");
|
|
20
|
+
}
|
|
21
|
+
function number(value, path, required = false, integer = false) {
|
|
22
|
+
if (value == null && !required) return;
|
|
23
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || integer && !Number.isSafeInteger(value)) throw new TUnitParseError(path, integer ? "expected a non-negative safe integer" : "expected a finite non-negative number");
|
|
24
|
+
}
|
|
25
|
+
function timestamp(value, path) {
|
|
26
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new TUnitParseError(path, "expected finite epoch milliseconds");
|
|
27
|
+
}
|
|
28
|
+
function strings(value, fields, path) {
|
|
29
|
+
for (const field of fields) text(value[field], `${path}.${field}`);
|
|
30
|
+
}
|
|
31
|
+
function list(value, path, validate, required = false) {
|
|
32
|
+
if (value == null && !required) return;
|
|
33
|
+
if (!Array.isArray(value)) throw new TUnitParseError(path, "expected an array");
|
|
34
|
+
value.forEach((item, index) => validate(item, `${path}[${index}]`));
|
|
35
|
+
}
|
|
36
|
+
function summary(value, path) {
|
|
37
|
+
if (value == null) return;
|
|
38
|
+
const s = object(value, path);
|
|
39
|
+
for (const field of [
|
|
40
|
+
"total",
|
|
41
|
+
"passed",
|
|
42
|
+
"failed",
|
|
43
|
+
"skipped",
|
|
44
|
+
"cancelled",
|
|
45
|
+
"timedOut",
|
|
46
|
+
"flaky"
|
|
47
|
+
]) number(s[field], `${path}.${field}`, true, true);
|
|
48
|
+
}
|
|
49
|
+
function exception(value, path, depth = 0) {
|
|
50
|
+
if (value == null) return;
|
|
51
|
+
if (depth > 32) throw new TUnitParseError(path, "exception nesting exceeds 32 levels");
|
|
52
|
+
const e = object(value, path);
|
|
53
|
+
strings(e, [
|
|
54
|
+
"type",
|
|
55
|
+
"message",
|
|
56
|
+
"stackTrace"
|
|
57
|
+
], path);
|
|
58
|
+
exception(e.innerException, `${path}.innerException`, depth + 1);
|
|
59
|
+
}
|
|
60
|
+
function keyValue(value, path) {
|
|
61
|
+
const kv = object(value, path);
|
|
62
|
+
text(kv.key, `${path}.key`, true);
|
|
63
|
+
text(kv.value, `${path}.value`, true);
|
|
64
|
+
}
|
|
65
|
+
function attempt(value, path) {
|
|
66
|
+
const a = object(value, path);
|
|
67
|
+
nonempty(a.status, `${path}.status`);
|
|
68
|
+
number(a.durationMs, `${path}.durationMs`);
|
|
69
|
+
strings(a, [
|
|
70
|
+
"exceptionType",
|
|
71
|
+
"exceptionMessage",
|
|
72
|
+
"stackTrace"
|
|
73
|
+
], path);
|
|
74
|
+
}
|
|
75
|
+
function test(value, path) {
|
|
76
|
+
const t = object(value, path);
|
|
77
|
+
for (const field of [
|
|
78
|
+
"id",
|
|
79
|
+
"displayName",
|
|
80
|
+
"status"
|
|
81
|
+
]) nonempty(t[field], `${path}.${field}`);
|
|
82
|
+
strings(t, [
|
|
83
|
+
"methodName",
|
|
84
|
+
"className",
|
|
85
|
+
"startTime",
|
|
86
|
+
"endTime",
|
|
87
|
+
"output",
|
|
88
|
+
"errorOutput",
|
|
89
|
+
"filePath",
|
|
90
|
+
"sourceRelativePath",
|
|
91
|
+
"skipReason",
|
|
92
|
+
"traceId",
|
|
93
|
+
"spanId"
|
|
94
|
+
], path);
|
|
95
|
+
number(t.durationMs, `${path}.durationMs`);
|
|
96
|
+
for (const field of [
|
|
97
|
+
"lineNumber",
|
|
98
|
+
"endLineNumber",
|
|
99
|
+
"retryAttempt"
|
|
100
|
+
]) number(t[field], `${path}.${field}`, false, true);
|
|
101
|
+
exception(t.exception, `${path}.exception`);
|
|
102
|
+
list(t.categories, `${path}.categories`, (item, p) => text(item, p, true));
|
|
103
|
+
list(t.customProperties, `${path}.customProperties`, keyValue);
|
|
104
|
+
list(t.attempts, `${path}.attempts`, attempt);
|
|
105
|
+
list(t.additionalTraceIds, `${path}.additionalTraceIds`, nonempty);
|
|
106
|
+
}
|
|
107
|
+
function span(value, path) {
|
|
108
|
+
const s = object(value, path);
|
|
109
|
+
for (const field of [
|
|
110
|
+
"traceId",
|
|
111
|
+
"spanId",
|
|
112
|
+
"name"
|
|
113
|
+
]) nonempty(s[field], `${path}.${field}`);
|
|
114
|
+
strings(s, [
|
|
115
|
+
"parentSpanId",
|
|
116
|
+
"spanType",
|
|
117
|
+
"source",
|
|
118
|
+
"kind",
|
|
119
|
+
"status",
|
|
120
|
+
"statusMessage"
|
|
121
|
+
], path);
|
|
122
|
+
timestamp(s.startTimeMs, `${path}.startTimeMs`);
|
|
123
|
+
number(s.durationMs, `${path}.durationMs`, true);
|
|
124
|
+
list(s.tags, `${path}.tags`, keyValue);
|
|
125
|
+
list(s.events, `${path}.events`, (value, p) => {
|
|
126
|
+
const e = object(value, p);
|
|
127
|
+
nonempty(e.name, `${p}.name`);
|
|
128
|
+
timestamp(e.timestampMs, `${p}.timestampMs`);
|
|
129
|
+
list(e.tags, `${p}.tags`, keyValue);
|
|
130
|
+
});
|
|
131
|
+
list(s.links, `${path}.links`, (value, p) => {
|
|
132
|
+
const link = object(value, p);
|
|
133
|
+
nonempty(link.traceId, `${p}.traceId`);
|
|
134
|
+
nonempty(link.spanId, `${p}.spanId`);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
/** Validate decoded sidecar JSON and return its native structure unchanged. */
|
|
138
|
+
function parseTUnitReport(input) {
|
|
139
|
+
const r = object(input, "$");
|
|
140
|
+
if (r.schemaVersion !== 1) throw new TUnitParseError("$.schemaVersion", "expected supported TUnit schema version 1");
|
|
141
|
+
nonempty(r.assemblyName, "$.assemblyName");
|
|
142
|
+
strings(r, [
|
|
143
|
+
"publicationGeneration",
|
|
144
|
+
"machineName",
|
|
145
|
+
"timestamp",
|
|
146
|
+
"tunitVersion",
|
|
147
|
+
"operatingSystem",
|
|
148
|
+
"runtimeVersion",
|
|
149
|
+
"filter",
|
|
150
|
+
"commitSha",
|
|
151
|
+
"branch",
|
|
152
|
+
"pullRequestNumber",
|
|
153
|
+
"repositorySlug",
|
|
154
|
+
"artifactUrl"
|
|
155
|
+
], "$");
|
|
156
|
+
number(r.totalDurationMs, "$.totalDurationMs");
|
|
157
|
+
summary(r.summary, "$.summary");
|
|
158
|
+
const ids = /* @__PURE__ */ new Set();
|
|
159
|
+
list(r.groups, "$.groups", (value, path) => {
|
|
160
|
+
const g = object(value, path);
|
|
161
|
+
nonempty(g.className, `${path}.className`);
|
|
162
|
+
text(g.namespace, `${path}.namespace`);
|
|
163
|
+
summary(g.summary, `${path}.summary`);
|
|
164
|
+
list(g.tests, `${path}.tests`, (value, p) => {
|
|
165
|
+
test(value, p);
|
|
166
|
+
const id = value.id;
|
|
167
|
+
if (ids.has(id)) throw new TUnitParseError(`${p}.id`, `duplicate result id ${id}`);
|
|
168
|
+
ids.add(id);
|
|
169
|
+
}, true);
|
|
170
|
+
}, true);
|
|
171
|
+
list(r.spans, "$.spans", span);
|
|
172
|
+
if (r.sourceLinks != null) {
|
|
173
|
+
const links = object(r.sourceLinks, "$.sourceLinks");
|
|
174
|
+
for (const field of ["lineUrl", "rangeUrl"]) text(links[field], `$.sourceLinks.${field}`, true);
|
|
175
|
+
text(links.rawUrl, "$.sourceLinks.rawUrl");
|
|
176
|
+
}
|
|
177
|
+
return input;
|
|
178
|
+
}
|
|
179
|
+
//#endregion
|
|
180
|
+
export { TUnitParseError, parseTUnitReport };
|