@reporters/mux 1.0.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 ADDED
@@ -0,0 +1,97 @@
1
+ ![tests](https://github.com/MoLow/reporters/actions/workflows/test.yaml/badge.svg?branch=main) [![codecov](https://codecov.io/gh/MoLow/reporters/branch/main/graph/badge.svg?token=0LFVC8SCQV)](https://codecov.io/gh/MoLow/reporters)
2
+
3
+ # Reporter Multiplexer
4
+
5
+ One `--test-reporter` flag, every environment covered.
6
+
7
+ `@reporters/mux` is environment-aware routing for `node:test` reporters.
8
+ Register it once; it reads a `mux.config` file, picks the active **profile**
9
+ (`ci` vs `local`, auto-detected), tees the test-event stream to every configured
10
+ **reporter**, and pipes each reporter's output into its **sink** —
11
+ `stdout`/`stderr`, a file, a local HTTP viewer, or a remote upload with a
12
+ shareable report URL.
13
+
14
+ The same command gives you a live interactive tree at your desk, and a CI log
15
+ plus a browsable report link on the build server:
16
+
17
+ ![the same mux command rendering a live tree locally and a CI log with a report link under REPORTERS_PROFILE=ci](https://raw.githubusercontent.com/MoLow/reporters/5393ed7b104f42d90bb930ad89854d8fdff6785b/packages/mux/assets/cli.gif)
18
+
19
+ That `report at` link is [`@reporters/web`](https://github.com/MoLow/reporters/tree/main/packages/web)'s
20
+ run being delivered through a sink — it opens as an interactive tree in the
21
+ browser (**[live demo](https://molow.github.io/reporters/?src=https://raw.githubusercontent.com/MoLow/reporters/5393ed7b104f42d90bb930ad89854d8fdff6785b/packages/web/assets/demo-run.ndjson)**):
22
+
23
+ [![the browser viewer rendering the delivered run](https://raw.githubusercontent.com/MoLow/reporters/5393ed7b104f42d90bb930ad89854d8fdff6785b/packages/web/assets/viewer.png)](https://molow.github.io/reporters/?src=https://raw.githubusercontent.com/MoLow/reporters/5393ed7b104f42d90bb930ad89854d8fdff6785b/packages/web/assets/demo-run.ndjson)
24
+
25
+ ## Usage
26
+
27
+ ```bash
28
+ node --test-reporter=@reporters/mux --test
29
+ ```
30
+
31
+ ## Config
32
+
33
+ Create a `mux.config.js` (or `.mjs`/`.ts`), discovered by walking up from the
34
+ current directory. It default-exports a map of profile name → routes:
35
+
36
+ ```js
37
+ import { httpServer } from '@reporters/web/sink';
38
+ import { gist } from '@reporters/sink';
39
+ import live from '@reporters/live';
40
+
41
+ export default {
42
+ local: [
43
+ { reporter: live, sink: 'stdout' },
44
+ { reporter: '@reporters/web', sink: httpServer() }, // serves localhost, opens the browser
45
+ ],
46
+ ci: [
47
+ { reporter: '@reporters/gh', sink: 'stdout' },
48
+ { reporter: '@reporters/web', sink: gist() }, // uploads the run, links the hosted viewer
49
+ ],
50
+ };
51
+ ```
52
+
53
+ Both async-generator reporters (like `@reporters/live`, `@reporters/web`) and
54
+ Transform-stream reporters (like `@reporters/gh`) are supported as route reporters.
55
+
56
+ ### Route fields
57
+
58
+ - `reporter` — a reporter function, a Transform/Duplex stream, or a module specifier `mux` will `import()`.
59
+ - `options` — optional 2nd-arg object passed to the reporter (e.g.
60
+ `@reporters/web` accepts `{ open: boolean }`); most reporters need none.
61
+ - `sink` — `'stdout'`, `'stderr'`, a file path, or a `Sink` object.
62
+ - `open` — open the sink's viewer URL in a browser. Defaults to on locally, off
63
+ in CI. `open: false` opts out; `REPORTERS_OPEN=1|0` forces it.
64
+
65
+ ### Profile resolution
66
+
67
+ `REPORTERS_PROFILE` if set; otherwise `ci` when a CI environment is detected
68
+ (`CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, `BUILDKITE`, …), else `local`. Profile
69
+ names are yours — add a `nightly` or `benchmark` profile and select it with
70
+ `REPORTERS_PROFILE=nightly`.
71
+
72
+ ## Sinks
73
+
74
+ A sink decides where a reporter's bytes go — and, for viewers, where a human
75
+ looks:
76
+
77
+ ```ts
78
+ interface Sink {
79
+ start?(): Promise<void>;
80
+ write(chunk: string | Buffer): void | Promise<void>;
81
+ flush?(): Promise<void>;
82
+ close(): Promise<void>;
83
+ viewerUrl?(): string | undefined;
84
+ }
85
+ ```
86
+
87
+ Built in: `'stdout'`, `'stderr'`, and file paths. Beyond those:
88
+
89
+ - [`@reporters/web`](https://github.com/MoLow/reporters/tree/main/packages/web)
90
+ ships `httpServer()` — serves the viewer on localhost over the growing run.
91
+ - [`@reporters/sink`](https://github.com/MoLow/reporters/tree/main/packages/sink)
92
+ ships `gist()` and `s3()` — upload the run where a browser can fetch it, so
93
+ the hosted viewer can render CI runs — plus `remoteSink()` to bring your own
94
+ transport.
95
+
96
+ When a sink exposes a `viewerUrl`, mux prints `report at <url>` to stderr and —
97
+ on GitHub Actions — adds a **View report** link to the job summary.
@@ -0,0 +1,79 @@
1
+ import { Duplex } from 'node:stream';
2
+
3
+ type DiagnosticLevel = 'info' | 'warn' | 'error';
4
+ /** The fields of a `node:test` reporter event that the store consumes. */
5
+ interface TestEventData {
6
+ name?: string;
7
+ nesting?: number;
8
+ file?: string;
9
+ testId?: number;
10
+ parentId?: number;
11
+ line?: number;
12
+ column?: number;
13
+ tags?: string[];
14
+ todo?: boolean | string;
15
+ skip?: boolean | string;
16
+ message?: string;
17
+ level?: DiagnosticLevel;
18
+ count?: number;
19
+ type?: 'suite' | 'test';
20
+ details?: {
21
+ duration_ms?: number;
22
+ error?: unknown;
23
+ type?: string;
24
+ passed?: boolean;
25
+ };
26
+ counts?: Record<string, number>;
27
+ duration_ms?: number;
28
+ success?: boolean;
29
+ }
30
+ interface TestEvent {
31
+ type: string;
32
+ data: TestEventData;
33
+ }
34
+
35
+ interface Sink {
36
+ /** Start servers/streams; resolves once ready to receive writes. */
37
+ start?(): Promise<void>;
38
+ write(chunk: string | Buffer): void | Promise<void>;
39
+ /** Periodic durability hook (e.g. a future s3 re-PUT). */
40
+ flush?(): Promise<void>;
41
+ /** The run ended; release resources. */
42
+ close(): Promise<void>;
43
+ /** How a human views what was written, if anything. */
44
+ viewerUrl?(): string | undefined;
45
+ }
46
+ type SinkSpec = string | Sink;
47
+
48
+ /** A `node:test` reporter: pure transform from events to output bytes. */
49
+ type Reporter = (source: AsyncIterable<TestEvent>, options?: unknown) => AsyncGenerator<string>;
50
+ interface Route {
51
+ /** A reporter: a generator function, a Duplex/Transform stream instance, or a
52
+ * module specifier `mux` will `import()` to either shape. */
53
+ reporter: Reporter | Duplex | string;
54
+ /** Optional 2nd-arg passed to the reporter (only for output-format variants). */
55
+ options?: unknown;
56
+ /** Where the reporter's bytes go: 'stdout' | 'stderr' | a file path | a Sink. */
57
+ sink: SinkSpec;
58
+ /** Open the sink's viewer URL in a browser. Defaults to on locally, off in CI. */
59
+ open?: boolean;
60
+ }
61
+ /** Map of profile name -> the routes active under that profile. */
62
+ type MuxConfig = Record<string, Route[]>;
63
+
64
+ /** A reporter function, a stream instance, or a module specifier to `import()`. */
65
+ declare function resolveReporter(reporter: Reporter | Duplex | string): Promise<Reporter | Duplex>;
66
+ /**
67
+ * Run every route in `config`'s active profile against `source`: tee the events,
68
+ * run each route's reporter, pipe its bytes into the route's sink, and open the
69
+ * sink's viewer URL when the gate allows. `open` is injected for testability.
70
+ */
71
+ declare function runRoutes(source: AsyncIterable<TestEvent>, config: MuxConfig, env?: NodeJS.ProcessEnv, open?: (url: string) => void): Promise<void>;
72
+ /**
73
+ * The routing reporter. Reads `mux.config`, resolves the active profile, and
74
+ * fans the event stream out to each route's reporter + sink. It yields nothing
75
+ * to Node — all output flows through the sinks it drives directly.
76
+ */
77
+ declare function mux(source: AsyncIterable<TestEvent>): AsyncGenerator<string>;
78
+
79
+ export { type MuxConfig, type Reporter, type Route, type Sink, type SinkSpec, mux as default, resolveReporter, runRoutes };
package/dist/index.js ADDED
@@ -0,0 +1,226 @@
1
+ // src/index.ts
2
+ import { Readable } from "stream";
3
+
4
+ // src/config.ts
5
+ import { existsSync } from "fs";
6
+ import { dirname, join } from "path";
7
+ import { pathToFileURL } from "url";
8
+ var CONFIG_NAMES = ["mux.config.js", "mux.config.mjs", "mux.config.ts"];
9
+ function findConfigFile(startDir = process.cwd()) {
10
+ let dir = startDir;
11
+ for (; ; ) {
12
+ for (const name of CONFIG_NAMES) {
13
+ const candidate = join(dir, name);
14
+ if (existsSync(candidate)) return candidate;
15
+ }
16
+ const parent = dirname(dir);
17
+ if (parent === dir) return void 0;
18
+ dir = parent;
19
+ }
20
+ }
21
+ async function loadConfig(startDir = process.cwd()) {
22
+ const file = findConfigFile(startDir);
23
+ if (!file) throw new Error(`mux: no mux.config.{js,mjs,ts} found (searched up from ${startDir})`);
24
+ const mod = await import(pathToFileURL(file).href);
25
+ return mod.default ?? mod;
26
+ }
27
+
28
+ // src/profile.ts
29
+ function isCI(env = process.env) {
30
+ return Boolean(
31
+ env.CI || env.CONTINUOUS_INTEGRATION || env.GITHUB_ACTIONS || env.GITLAB_CI || env.BUILDKITE
32
+ );
33
+ }
34
+ function resolveProfileName(env = process.env) {
35
+ return env.REPORTERS_PROFILE || (isCI(env) ? "ci" : "local");
36
+ }
37
+ function resolveProfile(config, env = process.env) {
38
+ const name = resolveProfileName(env);
39
+ const routes = config[name];
40
+ if (!routes) {
41
+ const available = Object.keys(config).join(", ") || "none";
42
+ throw new Error(`mux: no profile "${name}" in config (available: ${available})`);
43
+ }
44
+ return routes;
45
+ }
46
+
47
+ // src/broadcast.ts
48
+ var AsyncQueue = class {
49
+ values = [];
50
+ waiters = [];
51
+ done = false;
52
+ failed = false;
53
+ error;
54
+ push(value) {
55
+ const waiter = this.waiters.shift();
56
+ if (waiter) waiter.resolve({ value, done: false });
57
+ else this.values.push(value);
58
+ }
59
+ close() {
60
+ this.done = true;
61
+ let waiter = this.waiters.shift();
62
+ while (waiter) {
63
+ waiter.resolve({ value: void 0, done: true });
64
+ waiter = this.waiters.shift();
65
+ }
66
+ }
67
+ /** Surface `err` to consumers once their buffered values have drained. */
68
+ fail(err) {
69
+ this.failed = true;
70
+ this.error = err;
71
+ let waiter = this.waiters.shift();
72
+ while (waiter) {
73
+ waiter.reject(err);
74
+ waiter = this.waiters.shift();
75
+ }
76
+ }
77
+ [Symbol.asyncIterator]() {
78
+ return {
79
+ next: () => {
80
+ if (this.values.length) return Promise.resolve({ value: this.values.shift(), done: false });
81
+ if (this.failed) return Promise.reject(this.error);
82
+ if (this.done) return Promise.resolve({ value: void 0, done: true });
83
+ return new Promise((resolve2, reject) => {
84
+ this.waiters.push({ resolve: resolve2, reject });
85
+ });
86
+ }
87
+ };
88
+ }
89
+ };
90
+ function broadcast(source, n) {
91
+ if (n === 0) return [];
92
+ const queues = Array.from({ length: n }, () => new AsyncQueue());
93
+ (async () => {
94
+ try {
95
+ for await (const item of source) for (const q of queues) q.push(item);
96
+ for (const q of queues) q.close();
97
+ } catch (err) {
98
+ for (const q of queues) q.fail(err);
99
+ }
100
+ })();
101
+ return queues;
102
+ }
103
+
104
+ // src/sink.ts
105
+ import { createWriteStream } from "fs";
106
+ import { resolve } from "path";
107
+ function streamSink(stream) {
108
+ return {
109
+ write(chunk) {
110
+ stream.write(chunk);
111
+ },
112
+ async close() {
113
+ }
114
+ };
115
+ }
116
+ function fileSink(path) {
117
+ const full = resolve(path);
118
+ let stream;
119
+ return {
120
+ async start() {
121
+ stream = createWriteStream(full);
122
+ },
123
+ write(chunk) {
124
+ (stream ?? (stream = createWriteStream(full))).write(chunk);
125
+ },
126
+ async close() {
127
+ const s = stream;
128
+ if (!s) return;
129
+ await new Promise((res, rej) => {
130
+ s.end((err) => {
131
+ if (err) return rej(err);
132
+ return res();
133
+ });
134
+ });
135
+ }
136
+ };
137
+ }
138
+ function resolveSink(spec) {
139
+ if (typeof spec !== "string") return spec;
140
+ if (spec === "stdout") return streamSink(process.stdout);
141
+ if (spec === "stderr") return streamSink(process.stderr);
142
+ return fileSink(spec);
143
+ }
144
+
145
+ // src/open.ts
146
+ import { spawn } from "child_process";
147
+ import { appendFileSync } from "fs";
148
+ function shouldOpen(routeOpen, env = process.env) {
149
+ const flag = env.REPORTERS_OPEN;
150
+ if (flag === "1" || flag === "true") return true;
151
+ if (flag === "0" || flag === "false") return false;
152
+ if (routeOpen === false) return false;
153
+ return !isCI(env);
154
+ }
155
+ function openCommand(url, platform = process.platform) {
156
+ if (platform === "darwin") return ["open", [url]];
157
+ if (platform === "win32") return ["cmd", ["/c", "start", "", url]];
158
+ return ["xdg-open", [url]];
159
+ }
160
+ function openInBrowser(url) {
161
+ const [cmd, args] = openCommand(url);
162
+ try {
163
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
164
+ child.on("error", () => {
165
+ });
166
+ child.unref();
167
+ } catch {
168
+ }
169
+ }
170
+ function announce(url, env = process.env) {
171
+ process.stderr.write(`
172
+ @reporters/mux: report at ${url}
173
+ `);
174
+ if (env.GITHUB_STEP_SUMMARY) {
175
+ try {
176
+ appendFileSync(env.GITHUB_STEP_SUMMARY, `
177
+ [View report](${url})
178
+ `);
179
+ } catch {
180
+ }
181
+ }
182
+ }
183
+ var internals = { openInBrowser, announce };
184
+
185
+ // src/index.ts
186
+ function isStreamReporter(value) {
187
+ return typeof value === "object" && value !== null && typeof value.pipe === "function";
188
+ }
189
+ async function resolveReporter(reporter) {
190
+ if (typeof reporter === "function" || isStreamReporter(reporter)) return reporter;
191
+ const mod = await import(reporter);
192
+ const resolved = mod.default;
193
+ if (typeof resolved === "function" || isStreamReporter(resolved)) return resolved;
194
+ throw new Error(`mux: reporter "${reporter}" must default-export a generator function or a stream`);
195
+ }
196
+ async function runRoutes(source, config, env = process.env, open = internals.openInBrowser) {
197
+ const routes = resolveProfile(config, env);
198
+ const streams = broadcast(source, routes.length);
199
+ await Promise.all(routes.map(async (route, i) => {
200
+ const reporter = await resolveReporter(route.reporter);
201
+ const sink = resolveSink(route.sink);
202
+ const stage = typeof reporter === "function" ? (src) => reporter(src, route.options) : reporter;
203
+ try {
204
+ if (sink.start) await sink.start();
205
+ const url = sink.viewerUrl?.();
206
+ if (url) {
207
+ internals.announce(url, env);
208
+ if (shouldOpen(route.open, env)) open(url);
209
+ }
210
+ const output = Readable.from(streams[i]).compose(stage);
211
+ for await (const chunk of output) await sink.write(chunk);
212
+ if (sink.flush) await sink.flush();
213
+ } finally {
214
+ await sink.close();
215
+ }
216
+ }));
217
+ }
218
+ async function* mux(source) {
219
+ const config = await loadConfig();
220
+ await runRoutes(source, config);
221
+ }
222
+ export {
223
+ mux as default,
224
+ resolveReporter,
225
+ runRoutes
226
+ };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@reporters/mux",
3
+ "version": "1.0.0",
4
+ "description": "Environment-aware routing reporter for `node:test`: tee the event stream to multiple reporters and sinks",
5
+ "type": "module",
6
+ "keywords": [
7
+ "node:test",
8
+ "test",
9
+ "reporter",
10
+ "reporters",
11
+ "routing",
12
+ "multiplexer"
13
+ ],
14
+ "files": [
15
+ "./dist"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ }
22
+ },
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "scripts": {
26
+ "build": "rm -rf dist && tsup",
27
+ "prepack": "rm -rf dist && tsup",
28
+ "pretest": "rm -rf dist && tsup",
29
+ "test": "node --test-reporter=@reporters/mux --test"
30
+ },
31
+ "devDependencies": {
32
+ "@reporters/tree-core": "workspace:^",
33
+ "tsup": "^8.5.0",
34
+ "typescript": "^5.7.0"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/MoLow/reporters/issues"
38
+ },
39
+ "homepage": "https://github.com/MoLow/reporters/tree/main/packages/mux",
40
+ "repository": "https://github.com/MoLow/reporters.git",
41
+ "author": "Moshe Atlow",
42
+ "license": "MIT"
43
+ }