@reporters/web 1.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/README.md +62 -0
- package/dist/chunk-MZ7XNO2N.js +60 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +121 -0
- package/dist/sink.d.ts +24 -0
- package/dist/sink.js +26 -0
- package/dist/viewer/index.html +275 -0
- package/dist/viewer.global.js +263 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
 [](https://codecov.io/gh/MoLow/reporters)
|
|
2
|
+
|
|
3
|
+
# Web Reporter
|
|
4
|
+
|
|
5
|
+
Read your `node:test` run in the browser — a rich, interactive tree with live
|
|
6
|
+
updates, search, and inline failure diffs.
|
|
7
|
+
|
|
8
|
+
`@reporters/web` streams the run as an **NDJSON** event log, and ships a React
|
|
9
|
+
**viewer** that renders it: pass/fail counts and progress at a glance, the full
|
|
10
|
+
suite tree with per-test durations, ANSI-colored error output, and failing
|
|
11
|
+
tests auto-expanded with their assertion diff and stack trace.
|
|
12
|
+
|
|
13
|
+
[](https://molow.github.io/reporters/?src=https://raw.githubusercontent.com/MoLow/reporters/5393ed7b104f42d90bb930ad89854d8fdff6785b/packages/web/assets/demo-run.ndjson)
|
|
14
|
+
|
|
15
|
+
**[▶ Open the live demo](https://molow.github.io/reporters/?src=https://raw.githubusercontent.com/MoLow/reporters/5393ed7b104f42d90bb930ad89854d8fdff6785b/packages/web/assets/demo-run.ndjson)** — the screenshot above, in the hosted viewer.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
node --test-reporter=@reporters/web --test-reporter-destination=run.ndjson --test
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
On a dev machine that command does everything: it writes the NDJSON log **and**
|
|
24
|
+
opens a live-updating browser view of the run.
|
|
25
|
+
|
|
26
|
+
## Viewing a run
|
|
27
|
+
|
|
28
|
+
The NDJSON is rendered by the tree viewer, reached three ways:
|
|
29
|
+
|
|
30
|
+
- **Standalone** — on a dev machine, when given a file destination, the reporter
|
|
31
|
+
also starts a local server for the viewer and opens your browser to a
|
|
32
|
+
live-updating view (it polls the growing NDJSON over HTTP Range — no
|
|
33
|
+
`file://`/CORS limits). It never opens in CI. Force it on/off with the `open`
|
|
34
|
+
option (2nd reporter arg) or `REPORTERS_OPEN=1|0`.
|
|
35
|
+
|
|
36
|
+
- **Through [`@reporters/mux`](https://github.com/MoLow/reporters/tree/main/packages/mux)**
|
|
37
|
+
with the `httpServer()` sink — the reporter stays a pure emitter and the sink
|
|
38
|
+
serves the viewer + growing NDJSON over HTTP Range, opening your browser:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
// mux.config.js
|
|
42
|
+
import { httpServer } from '@reporters/web/sink';
|
|
43
|
+
export default {
|
|
44
|
+
// pass `options: { open: false }` on the route if it shouldn't open a browser
|
|
45
|
+
local: [{ reporter: '@reporters/web', sink: httpServer() }],
|
|
46
|
+
};
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- **Hosted viewer** — host the NDJSON anywhere (a gist, an S3 bucket, a CI
|
|
50
|
+
artifact, a raw GitHub URL) and open:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
https://molow.github.io/reporters/?src=<url-to-your-run.ndjson>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The viewer polls the file as it grows using HTTP Range, so this works for
|
|
57
|
+
runs that are still in progress — share the link and teammates watch the same
|
|
58
|
+
run live.
|
|
59
|
+
|
|
60
|
+
Built on the shared [`@reporters/tree-core`](https://github.com/MoLow/reporters/tree/main/packages/tree-core)
|
|
61
|
+
model (also used by [`@reporters/live`](https://github.com/MoLow/reporters/tree/main/packages/live)) —
|
|
62
|
+
the same run state, rendered in the browser instead of the terminal.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// src/server.ts
|
|
2
|
+
import { createServer } from "http";
|
|
3
|
+
import { readFileSync } from "fs";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
function viewerPage() {
|
|
6
|
+
try {
|
|
7
|
+
return readFileSync(fileURLToPath(new URL("./viewer/index.html", import.meta.url)), "utf8");
|
|
8
|
+
} catch {
|
|
9
|
+
return '<!doctype html><meta charset="utf-8"><p>viewer bundle missing \u2014 run the package build</p>';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function startViewerServer(host = "127.0.0.1") {
|
|
13
|
+
const page = viewerPage();
|
|
14
|
+
let buffer = Buffer.alloc(0);
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
const server = createServer((req, res) => {
|
|
17
|
+
const path = req.url.split("?")[0];
|
|
18
|
+
if (path === "/run.ndjson") {
|
|
19
|
+
const range = /^bytes=(\d+)-/.exec(req.headers.range ?? "");
|
|
20
|
+
if (range) {
|
|
21
|
+
const startByte = Number(range[1]);
|
|
22
|
+
if (startByte >= buffer.length) {
|
|
23
|
+
res.writeHead(416).end();
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
res.writeHead(206, {
|
|
27
|
+
"content-type": "application/x-ndjson",
|
|
28
|
+
"accept-ranges": "bytes",
|
|
29
|
+
"content-range": `bytes ${startByte}-${buffer.length - 1}/${buffer.length}`
|
|
30
|
+
});
|
|
31
|
+
res.end(buffer.subarray(startByte));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
res.writeHead(200, { "content-type": "application/x-ndjson", "accept-ranges": "bytes" });
|
|
35
|
+
res.end(buffer);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
39
|
+
res.end(page);
|
|
40
|
+
});
|
|
41
|
+
server.listen(0, host, () => {
|
|
42
|
+
const { port } = server.address();
|
|
43
|
+
resolve({
|
|
44
|
+
url: `http://${host}:${port}/?src=/run.ndjson`,
|
|
45
|
+
push(chunk) {
|
|
46
|
+
buffer = Buffer.concat([buffer, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)]);
|
|
47
|
+
},
|
|
48
|
+
close() {
|
|
49
|
+
return new Promise((res) => {
|
|
50
|
+
server.close(() => res());
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export {
|
|
59
|
+
startViewerServer
|
|
60
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
type DiagnosticLevel = 'info' | 'warn' | 'error';
|
|
2
|
+
/** The fields of a `node:test` reporter event that the store consumes. */
|
|
3
|
+
interface TestEventData {
|
|
4
|
+
name?: string;
|
|
5
|
+
nesting?: number;
|
|
6
|
+
file?: string;
|
|
7
|
+
testId?: number;
|
|
8
|
+
parentId?: number;
|
|
9
|
+
line?: number;
|
|
10
|
+
column?: number;
|
|
11
|
+
tags?: string[];
|
|
12
|
+
todo?: boolean | string;
|
|
13
|
+
skip?: boolean | string;
|
|
14
|
+
message?: string;
|
|
15
|
+
level?: DiagnosticLevel;
|
|
16
|
+
count?: number;
|
|
17
|
+
type?: 'suite' | 'test';
|
|
18
|
+
details?: {
|
|
19
|
+
duration_ms?: number;
|
|
20
|
+
error?: unknown;
|
|
21
|
+
type?: string;
|
|
22
|
+
passed?: boolean;
|
|
23
|
+
};
|
|
24
|
+
counts?: Record<string, number>;
|
|
25
|
+
duration_ms?: number;
|
|
26
|
+
success?: boolean;
|
|
27
|
+
}
|
|
28
|
+
interface TestEvent {
|
|
29
|
+
type: string;
|
|
30
|
+
data: TestEventData;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface WebOptions {
|
|
34
|
+
/** Serve a live browser view of the run and open it. Defaults to detecting an
|
|
35
|
+
* interactive terminal (a TTY, not CI); `REPORTERS_OPEN=1|0` also overrides. */
|
|
36
|
+
open?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* A `node:test` reporter that emits the run as a raw NDJSON event log — one
|
|
40
|
+
* JSON-safe wire line per event — to whatever `--test-reporter-destination`
|
|
41
|
+
* points at.
|
|
42
|
+
*
|
|
43
|
+
* Run standalone on a dev machine it also serves a live browser view and opens
|
|
44
|
+
* it, the same way it behaves through `@reporters/mux`'s `httpServer()` sink.
|
|
45
|
+
* Control this with the `open` option or `REPORTERS_OPEN=1|0`; it never opens
|
|
46
|
+
* in CI by default. Through mux the reporter is a pure emitter and the sink owns
|
|
47
|
+
* viewing.
|
|
48
|
+
*/
|
|
49
|
+
declare function web(source: AsyncIterable<TestEvent>, options?: WebOptions): AsyncGenerator<string>;
|
|
50
|
+
|
|
51
|
+
export { type WebOptions, web as default };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
startViewerServer
|
|
3
|
+
} from "./chunk-MZ7XNO2N.js";
|
|
4
|
+
|
|
5
|
+
// ../tree-core/src/wire.ts
|
|
6
|
+
function flattenError(raw) {
|
|
7
|
+
if (raw == null) return void 0;
|
|
8
|
+
const err = raw;
|
|
9
|
+
return {
|
|
10
|
+
message: err.message ?? String(err),
|
|
11
|
+
stack: err.stack,
|
|
12
|
+
name: err.name,
|
|
13
|
+
cause: err.cause instanceof Error ? flattenError(err.cause) : err.cause
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function toWireEvent(event) {
|
|
17
|
+
const d = event.data ?? {};
|
|
18
|
+
const data = {};
|
|
19
|
+
if (d.name != null) data.name = d.name;
|
|
20
|
+
if (d.nesting != null) data.nesting = d.nesting;
|
|
21
|
+
if (d.file != null) data.file = d.file;
|
|
22
|
+
if (d.testId != null) data.testId = d.testId;
|
|
23
|
+
if (d.parentId != null) data.parentId = d.parentId;
|
|
24
|
+
if (d.line != null) data.line = d.line;
|
|
25
|
+
if (d.column != null) data.column = d.column;
|
|
26
|
+
if (d.tags != null) data.tags = d.tags;
|
|
27
|
+
if (d.todo != null) data.todo = d.todo;
|
|
28
|
+
if (d.skip != null) data.skip = d.skip;
|
|
29
|
+
if (d.message != null) data.message = d.message;
|
|
30
|
+
if (d.level != null) data.level = d.level;
|
|
31
|
+
if (d.count != null) data.count = d.count;
|
|
32
|
+
if (d.type != null) data.type = d.type;
|
|
33
|
+
if (d.counts != null) data.counts = d.counts;
|
|
34
|
+
if (d.duration_ms != null) data.duration_ms = d.duration_ms;
|
|
35
|
+
if (d.success != null) data.success = d.success;
|
|
36
|
+
if (d.details != null) {
|
|
37
|
+
data.details = {
|
|
38
|
+
duration_ms: d.details.duration_ms,
|
|
39
|
+
type: d.details.type,
|
|
40
|
+
passed: d.details.passed,
|
|
41
|
+
error: flattenError(d.details.error)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
return { type: event.type, data };
|
|
45
|
+
}
|
|
46
|
+
function serializeWireLine(event) {
|
|
47
|
+
return `${JSON.stringify(toWireEvent(event))}
|
|
48
|
+
`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ../tree-core/src/format.ts
|
|
52
|
+
var SECOND = 1e3;
|
|
53
|
+
var MINUTE = 60 * SECOND;
|
|
54
|
+
var HOUR = 60 * MINUTE;
|
|
55
|
+
|
|
56
|
+
// src/open.ts
|
|
57
|
+
import { spawn } from "child_process";
|
|
58
|
+
function soleFileDestination(execArgv = process.execArgv) {
|
|
59
|
+
const flag = "--test-reporter-destination";
|
|
60
|
+
const dests = [];
|
|
61
|
+
for (let i = 0; i < execArgv.length; i += 1) {
|
|
62
|
+
if (execArgv[i] === flag && execArgv[i + 1]) dests.push(execArgv[i + 1]);
|
|
63
|
+
else if (execArgv[i].startsWith(`${flag}=`)) dests.push(execArgv[i].slice(flag.length + 1));
|
|
64
|
+
}
|
|
65
|
+
const files = dests.filter((d) => d && d !== "stdout" && d !== "stderr");
|
|
66
|
+
return files.length === 1 ? files[0] : void 0;
|
|
67
|
+
}
|
|
68
|
+
function isCI(env = process.env) {
|
|
69
|
+
return Boolean(env.CI || env.CONTINUOUS_INTEGRATION || env.GITHUB_ACTIONS || env.GITLAB_CI || env.BUILDKITE);
|
|
70
|
+
}
|
|
71
|
+
function shouldOpen(open, env = process.env, isTTY = Boolean(process.stdout.isTTY)) {
|
|
72
|
+
if (open !== void 0) return open;
|
|
73
|
+
const flag = env.REPORTERS_OPEN;
|
|
74
|
+
if (flag === "1" || flag === "true") return true;
|
|
75
|
+
if (flag === "0" || flag === "false") return false;
|
|
76
|
+
return isTTY && !isCI(env);
|
|
77
|
+
}
|
|
78
|
+
function openCommand(url, platform = process.platform) {
|
|
79
|
+
if (platform === "darwin") return ["open", [url]];
|
|
80
|
+
if (platform === "win32") return ["cmd", ["/c", "start", "", url]];
|
|
81
|
+
return ["xdg-open", [url]];
|
|
82
|
+
}
|
|
83
|
+
function openInBrowser(url) {
|
|
84
|
+
const [cmd, args] = openCommand(url);
|
|
85
|
+
try {
|
|
86
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
87
|
+
child.on("error", () => {
|
|
88
|
+
});
|
|
89
|
+
child.unref();
|
|
90
|
+
} catch {
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
var internals = { openInBrowser };
|
|
94
|
+
|
|
95
|
+
// src/index.ts
|
|
96
|
+
function hint(message) {
|
|
97
|
+
process.stderr.write(`${message}
|
|
98
|
+
`);
|
|
99
|
+
}
|
|
100
|
+
async function* web(source, options = {}) {
|
|
101
|
+
const dest = soleFileDestination();
|
|
102
|
+
const serve = shouldOpen(options.open);
|
|
103
|
+
let server;
|
|
104
|
+
if (serve) {
|
|
105
|
+
server = await startViewerServer();
|
|
106
|
+
internals.openInBrowser(server.url);
|
|
107
|
+
hint(`
|
|
108
|
+
@reporters/web: live report at ${server.url}`);
|
|
109
|
+
}
|
|
110
|
+
for await (const event of source) {
|
|
111
|
+
const line = serializeWireLine(event);
|
|
112
|
+
server?.push(line);
|
|
113
|
+
if (dest || !server) yield line;
|
|
114
|
+
}
|
|
115
|
+
if (!server) return;
|
|
116
|
+
if (process.stdin.isTTY) return;
|
|
117
|
+
await server.close();
|
|
118
|
+
}
|
|
119
|
+
export {
|
|
120
|
+
web as default
|
|
121
|
+
};
|
package/dist/sink.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
interface Sink {
|
|
2
|
+
/** Start servers/streams; resolves once ready to receive writes. */
|
|
3
|
+
start?(): Promise<void>;
|
|
4
|
+
write(chunk: string | Buffer): void | Promise<void>;
|
|
5
|
+
/** Periodic durability hook (e.g. a future s3 re-PUT). */
|
|
6
|
+
flush?(): Promise<void>;
|
|
7
|
+
/** The run ended; release resources. */
|
|
8
|
+
close(): Promise<void>;
|
|
9
|
+
/** How a human views what was written, if anything. */
|
|
10
|
+
viewerUrl?(): string | undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface HttpServerOptions {
|
|
14
|
+
host?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A sink that serves the run locally: the viewer page at `/` and the growing
|
|
18
|
+
* NDJSON at `/run.ndjson` (HTTP Range aware, so the viewer polls appended bytes
|
|
19
|
+
* — no `file://`/CORS limits). Stays alive after the run while attached to an
|
|
20
|
+
* interactive terminal; otherwise shuts down so the process can exit.
|
|
21
|
+
*/
|
|
22
|
+
declare function httpServer(opts?: HttpServerOptions): Sink;
|
|
23
|
+
|
|
24
|
+
export { type HttpServerOptions, httpServer };
|
package/dist/sink.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import {
|
|
2
|
+
startViewerServer
|
|
3
|
+
} from "./chunk-MZ7XNO2N.js";
|
|
4
|
+
|
|
5
|
+
// src/sink.ts
|
|
6
|
+
function httpServer(opts = {}) {
|
|
7
|
+
let server;
|
|
8
|
+
return {
|
|
9
|
+
async start() {
|
|
10
|
+
server = await startViewerServer(opts.host);
|
|
11
|
+
},
|
|
12
|
+
write(chunk) {
|
|
13
|
+
server?.push(chunk);
|
|
14
|
+
},
|
|
15
|
+
viewerUrl() {
|
|
16
|
+
return server?.url;
|
|
17
|
+
},
|
|
18
|
+
async close() {
|
|
19
|
+
if (process.stdin.isTTY) return;
|
|
20
|
+
await server?.close();
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export {
|
|
25
|
+
httpServer
|
|
26
|
+
};
|