@zyplux/spectra 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sergiy Yeskov
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
+ # @zyplux/spectra
2
+
3
+ Shared Vitest fixtures, matchers, polling, and journal reporting for Node CLIs and libraries. Fakes swap in at the lowest boundary (`node:child_process`, `node:readline/promises`, `node:timers/promises`, `fetch`, `console`, `process.env`) so tests exercise only public interfaces.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pnpm add -D @zyplux/spectra
9
+ ```
10
+
11
+ Each fake needs the Node module it stands in for mocked in that project's own vitest setup file — only the modules whose fakes you use:
12
+
13
+ ```ts
14
+ import { vi } from 'vitest';
15
+
16
+ vi.mock('node:child_process', async importOriginal => {
17
+ const actual = await importOriginal<typeof import('node:child_process')>();
18
+ return { ...actual, spawn: vi.fn(actual.spawn) }; // shell fake
19
+ });
20
+ // node:readline/promises -> createInterface (prompt fake); node:timers/promises -> setTimeout (instant sleeps)
21
+ ```
22
+
23
+ Each factory wraps the original, so the module stays fully live until a fake installs an implementation. A fake whose module is unmocked says which one to add.
24
+
25
+ ## Use
26
+
27
+ Each story domain has a named fixture module, such as `stories/repositories/repositories.ts`. Extend a base with the subjects that its tests need:
28
+
29
+ ```ts
30
+ import { runPushBranch } from '@example/app';
31
+ import { cliTest } from '@zyplux/spectra/library-test-api';
32
+
33
+ export const test = cliTest.extend('runPushBranch', () => runPushBranch);
34
+ export { describe, expect } from 'vitest';
35
+ ```
36
+
37
+ ```ts
38
+ import { describe, expect, test } from './repositories.ts';
39
+
40
+ describe('1.1 pushing a branch', () => {
41
+ test('1.1.1 pushes and reports the PR url', async ({ logs, runPushBranch, shell }) => {
42
+ shell.on('git rev-parse --abbrev-ref HEAD', 'feat-x');
43
+ shell.on('git push', '');
44
+
45
+ await runPushBranch({ command: 'push-branch', hold: false, ready: false });
46
+
47
+ expect(shell).toHaveRun('git push --set-upstream origin feat-x');
48
+ expect(logs).toHaveLogged('PR (draft): https://github.com/acme/repo/pull/1');
49
+ });
50
+ });
51
+ ```
52
+
53
+ ## Entry points
54
+
55
+ Each tool has a named entry point. Import the Vitest bases from `/library-test-api`; load the reporter from `/journald-reporter` in Vitest configuration.
56
+
57
+ | Import path after `@zyplux/spectra` | Purpose |
58
+ | --- | --- |
59
+ | `/library-test-api` | Library and CLI Vitest bases |
60
+ | `/shell-fake`, `/fetch-fake`, `/prompt-fake` | Fakes for subprocesses, HTTP, and prompts |
61
+ | `/cli-runner`, `/temp-directory`, `/poll-until` | CLI execution, scratch directories, and test polling |
62
+ | `/test-matchers` | Register shared Vitest matchers |
63
+ | `/console-capture` | Capture console messages |
64
+ | `/journald-reporter` | Record Vitest results and console output in systemd's journal |
65
+ | `/package.json` | Package metadata |
66
+
67
+ Internal helpers stay beside the tools that use them: pattern matching and module mock validation live in `helpers`; journal stream writing lives in `reporters`.
68
+
69
+ ## Bases
70
+
71
+ - `libraryTest` — lazy fixtures: `shell` (fake `node:child_process` spawn, installed only when destructured), `tempDir` (auto-removed scratch directory with `path`, `write`, `exists`).
72
+ - `cliTest` — extends `libraryTest`; automatically captures and silences `console` (`logs`), makes `node:timers/promises` sleeps instant, and installs `network` (fake `fetch`). It adds lazy `prompt` (fake `node:readline/promises` interface that records every question and answers with an empty string) and `env` (`set(name, value)` stubs an env var for the test).
73
+
74
+ ## Fakes and helpers
75
+
76
+ - `createShellFake()` — routes commands (`on(pattern, ...replies)`, later routes win, the last reply repeats; `otherwise(reply)` sets a fallback, unrouted commands throw) and records `calls` (`{ argv, cwd?, env?, program, stdin? }`), `commands` (rendered strings), `commandsMatching(pattern)`.
77
+ - `createConsoleCapture()` — records `logLines`/`warnLines`/`errorLines`.
78
+ - `createFetchFake()` — routes urls (`on(prefixOrRegExp, reply)`, `otherwise(reply)`) and records `requests`; `okResponse()`/`notFoundResponse()` build replies.
79
+ - `createPromptFake()` — records `question()` messages and immediately returns an empty string, simulating Enter without typed input. It lets tests exercise terminal prompts without waiting for keyboard input.
80
+ - `createTempDir()` — `path`, `write(relativePath, content)`, `exists(relativePath)`, `remove()`.
81
+
82
+ ## Matchers
83
+
84
+ Importing a base registers domain matchers via `expect.extend`:
85
+
86
+ - `expect(shell).toHaveRun(command)` — the exact rendered command ran.
87
+ - `expect(shell).toHaveRunMatching(pattern)` — some command matches (string = command prefix at a word boundary, same as `on`; RegExp = test); negate with `.not` for "never ran".
88
+ - `expect(logs).toHaveLogged(line?)` / `toHaveWarned(line?)` / `toHaveErrored(line?)` — a captured line equals the string (or matches the RegExp); with no argument, that the channel captured anything, so `.not.toHaveWarned()` asserts silence.
89
+ - `expect(items).toContainExactElementsInAnyOrder(expected)` — nested elements match regardless of order, including duplicate counts.
90
+ - `expect(items).toContainNoDuplicates()` — no two elements are deeply equal.
91
+ - `expect(items).toHaveNumberOfElements(count)` — the collection has exactly that many elements.
92
+
93
+ ## Polling
94
+
95
+ `pollUntil(condition, { timeout, interval?, grace? })` waits for an asynchronous condition using Vitest's `expect.poll`. The condition receives an abort signal. A grace period requires a second matching observation after that delay. The return is `{ settled, error }`; deadlines also bound pending observations and grace periods.
96
+
97
+ ## Journal reporting
98
+
99
+ Add the reporter alongside Vitest's normal terminal reporters:
100
+
101
+ ```ts
102
+ import { JournaldReporter } from '@zyplux/spectra/journald-reporter';
103
+ import { configDefaults, defineConfig } from 'vitest/config';
104
+
105
+ export default defineConfig({
106
+ test: {
107
+ reporters: [...configDefaults.reporters, new JournaldReporter({ identifier: 'my-repo' })],
108
+ },
109
+ });
110
+ ```
111
+
112
+ Named projects record summaries and failure details under `my-repo-<project>`; console messages use `my-repo-<project>-console`. Unnamed projects use `my-repo` and `my-repo-console`. Read them with `journalctl -t my-repo-<project>`. Recording is enabled when the systemd journal socket exists. The `isEnabled` option explicitly controls recording; connection errors are reported to the terminal.
113
+
114
+ The optional `socketPath` selects a journal stream socket; its default is `/run/systemd/journal/stdout`.
@@ -0,0 +1,10 @@
1
+ export type FetchFake = {
2
+ install: () => () => void;
3
+ on: (match: RegExp | string, reply: FetchReply) => void;
4
+ otherwise: (reply: FetchReply) => void;
5
+ requests: string[];
6
+ };
7
+ export type FetchReply = (url: string) => Promise<Response> | Response;
8
+ export declare const notFoundResponse: () => Response;
9
+ export declare const okResponse: () => Response;
10
+ export declare const createFetchFake: () => FetchFake;
@@ -0,0 +1,40 @@
1
+ import { vi } from 'vitest';
2
+ const HTTP_NOT_FOUND = 404;
3
+ const HTTP_OK = 200;
4
+ export const notFoundResponse = () => new Response(undefined, { status: HTTP_NOT_FOUND });
5
+ export const okResponse = () => new Response(undefined, { status: HTTP_OK });
6
+ const isUrlMatch = (url, match) => typeof match === 'string' ? url.startsWith(match) : match.test(url);
7
+ const getRequestUrl = (input) => {
8
+ if (typeof input === 'string')
9
+ return input;
10
+ return input instanceof URL ? input.href : input.url;
11
+ };
12
+ export const createFetchFake = () => {
13
+ const requests = [];
14
+ const routes = [];
15
+ let fallback = notFoundResponse;
16
+ const respond = (url) => {
17
+ const reply = routes.findLast(candidate => isUrlMatch(url, candidate.match))?.reply ?? fallback;
18
+ return reply(url);
19
+ };
20
+ const fakeFetch = async (input) => {
21
+ const url = getRequestUrl(input);
22
+ requests.push(url);
23
+ return respond(url);
24
+ };
25
+ return {
26
+ install: () => {
27
+ vi.stubGlobal('fetch', fakeFetch);
28
+ return () => {
29
+ vi.unstubAllGlobals();
30
+ };
31
+ },
32
+ on: (match, reply) => {
33
+ routes.push({ match, reply });
34
+ },
35
+ otherwise: reply => {
36
+ fallback = reply;
37
+ },
38
+ requests,
39
+ };
40
+ };
@@ -0,0 +1,5 @@
1
+ export type PromptFake = {
2
+ install: () => () => void;
3
+ messages: string[];
4
+ };
5
+ export declare const createPromptFake: () => PromptFake;
@@ -0,0 +1,29 @@
1
+ import { createInterface, Interface } from 'node:readline/promises';
2
+ import { PassThrough } from 'node:stream';
3
+ import { vi } from 'vitest';
4
+ import { requireMockedModule } from '#require-mocked-module';
5
+ class PromptProbe extends Interface {
6
+ messages;
7
+ constructor(messages) {
8
+ super({ input: new PassThrough() });
9
+ this.messages = messages;
10
+ }
11
+ question(message) {
12
+ this.messages.push(message);
13
+ return Promise.resolve('');
14
+ }
15
+ }
16
+ export const createPromptFake = () => {
17
+ const messages = [];
18
+ const createInterfaceMock = vi.mocked(createInterface);
19
+ return {
20
+ install: () => {
21
+ requireMockedModule(createInterface, 'node:readline/promises', 'createInterface');
22
+ createInterfaceMock.mockImplementation(() => new PromptProbe(messages));
23
+ return () => {
24
+ createInterfaceMock.mockReset();
25
+ };
26
+ },
27
+ messages,
28
+ };
29
+ };
@@ -0,0 +1,20 @@
1
+ export type ShellCall = {
2
+ argv: string[];
3
+ cwd?: string;
4
+ env?: Record<string, string | undefined>;
5
+ program: string;
6
+ stdin?: string;
7
+ };
8
+ export type ShellFake = {
9
+ calls: ShellCall[];
10
+ commands: string[];
11
+ commandsMatching: (pattern: RegExp | string) => string[];
12
+ install: () => () => void;
13
+ on: (pattern: RegExp | string, ...replies: [ShellReply, ...ShellReply[]]) => void;
14
+ otherwise: (...replies: [ShellReply, ...ShellReply[]]) => void;
15
+ };
16
+ export type ShellReply = ((command: string) => string) | string | {
17
+ exitCode: number;
18
+ stdout: string;
19
+ };
20
+ export declare const createShellFake: () => ShellFake;
@@ -0,0 +1,112 @@
1
+ import { ChildProcess, spawn } from 'node:child_process';
2
+ import { PassThrough, Writable } from 'node:stream';
3
+ import { vi } from 'vitest';
4
+ import { isPatternMatch } from '#pattern-match';
5
+ import { requireMockedModule } from '#require-mocked-module';
6
+ const isCommandMatch = (command, pattern) => typeof pattern === 'string'
7
+ ? command.startsWith(pattern) && (command.length === pattern.length || command[pattern.length] === ' ')
8
+ : isPatternMatch(command, pattern);
9
+ const takeReply = ({ replies }) => {
10
+ const reply = replies.length > 1 ? replies.shift() : replies[0];
11
+ if (reply === undefined)
12
+ throw new Error('shell route has no replies left');
13
+ return reply;
14
+ };
15
+ const envOverlay = (env = {}) => {
16
+ const overlaid = Object.entries(env).filter(([name, value]) => !Object.hasOwn(process.env, name) || process.env[name] !== value);
17
+ return overlaid.length === 0 ? undefined : Object.fromEntries(overlaid);
18
+ };
19
+ const createFakeChild = ({ exitCode, stdout: output }, onStdin) => {
20
+ const stdout = new PassThrough();
21
+ const stderr = new PassThrough();
22
+ const written = [];
23
+ const stdin = new Writable({
24
+ final: done => {
25
+ onStdin(Buffer.concat(written).toString());
26
+ done();
27
+ },
28
+ write: (chunk, _encoding, done) => {
29
+ written.push(Buffer.from(chunk));
30
+ done();
31
+ },
32
+ });
33
+ const child = Object.assign(new ChildProcess(), { stderr, stdin, stdout });
34
+ let flowing = 2;
35
+ const closeWhenDrained = () => {
36
+ flowing -= 1;
37
+ if (flowing === 0)
38
+ child.emit('close', exitCode);
39
+ };
40
+ stdout.on('end', closeWhenDrained);
41
+ stderr.on('end', closeWhenDrained);
42
+ queueMicrotask(() => {
43
+ stdout.end(output);
44
+ stderr.end();
45
+ stdout.resume();
46
+ stderr.resume();
47
+ });
48
+ return child;
49
+ };
50
+ const silenceStandardStreams = () => {
51
+ const silenced = [
52
+ vi.spyOn(process.stdout, 'write').mockReturnValue(true),
53
+ vi.spyOn(process.stderr, 'write').mockReturnValue(true),
54
+ ];
55
+ return () => {
56
+ for (const stream of silenced)
57
+ stream.mockRestore();
58
+ };
59
+ };
60
+ export const createShellFake = () => {
61
+ const calls = [];
62
+ const commands = [];
63
+ const routes = [];
64
+ let fallback;
65
+ const resolveReply = (command) => {
66
+ const route = routes.findLast(candidate => isCommandMatch(command, candidate.pattern)) ?? fallback;
67
+ if (route === undefined) {
68
+ const registered = routes.map(known => ` ${String(known.pattern)}`).join('\n');
69
+ throw new Error(`no shell route matches: ${command}\nregistered routes:\n${registered}`);
70
+ }
71
+ const reply = takeReply(route);
72
+ const resolved = typeof reply === 'function' ? reply(command) : reply;
73
+ return typeof resolved === 'string' ? { exitCode: 0, stdout: resolved } : resolved;
74
+ };
75
+ const spawnFake = (program, args = [], options = {}) => {
76
+ const argv = [...args];
77
+ const command = [program, ...argv].join(' ');
78
+ const overlay = envOverlay(options.env);
79
+ const call = {
80
+ argv,
81
+ program,
82
+ ...(typeof options.cwd === 'string' && { cwd: options.cwd }),
83
+ ...(overlay !== undefined && { env: overlay }),
84
+ };
85
+ calls.push(call);
86
+ commands.push(command);
87
+ return createFakeChild(resolveReply(command), text => {
88
+ call.stdin = text;
89
+ });
90
+ };
91
+ return {
92
+ calls,
93
+ commands,
94
+ commandsMatching: pattern => commands.filter(command => isCommandMatch(command, pattern)),
95
+ install: () => {
96
+ requireMockedModule(spawn, 'node:child_process', 'spawn');
97
+ const spawnMock = vi.mocked(spawn);
98
+ spawnMock.mockImplementation(spawnFake);
99
+ const restoreStreams = silenceStandardStreams();
100
+ return () => {
101
+ restoreStreams();
102
+ spawnMock.mockReset();
103
+ };
104
+ },
105
+ on: (pattern, ...replies) => {
106
+ routes.push({ pattern, replies: [...replies] });
107
+ },
108
+ otherwise: (...replies) => {
109
+ fallback = { pattern: '', replies: [...replies] };
110
+ },
111
+ };
112
+ };
@@ -0,0 +1,14 @@
1
+ export type CliIo = {
2
+ onExit: (exitCode: number) => never;
3
+ stderr: (line: string) => void;
4
+ stdout: (line: string) => void;
5
+ };
6
+ export type CliMain = (args: readonly string[], io: CliIo) => Promise<void>;
7
+ export type CliRunner = {
8
+ run: (...args: string[]) => Promise<void>;
9
+ };
10
+ export declare class CliExitError extends Error {
11
+ readonly exitCode: number;
12
+ constructor(exitCode: number);
13
+ }
14
+ export declare const createCliRunner: (main: CliMain) => CliRunner;
@@ -0,0 +1,21 @@
1
+ export class CliExitError extends Error {
2
+ exitCode;
3
+ constructor(exitCode) {
4
+ super(`cli exited with code ${exitCode}`);
5
+ this.name = 'CliExitError';
6
+ this.exitCode = exitCode;
7
+ }
8
+ }
9
+ export const createCliRunner = (main) => ({
10
+ run: (...args) => main(args, {
11
+ onExit: exitCode => {
12
+ throw new CliExitError(exitCode);
13
+ },
14
+ stderr: line => {
15
+ console.error(line);
16
+ },
17
+ stdout: line => {
18
+ console.log(line);
19
+ },
20
+ }),
21
+ });
@@ -0,0 +1 @@
1
+ export declare const isPatternMatch: (input: string, pattern: RegExp) => boolean;
@@ -0,0 +1,4 @@
1
+ export const isPatternMatch = (input, pattern) => {
2
+ pattern.lastIndex = 0;
3
+ return pattern.test(input);
4
+ };
@@ -0,0 +1,11 @@
1
+ type PollCondition = (signal: AbortSignal) => boolean | Promise<boolean>;
2
+ type PollOptions = {
3
+ grace?: number;
4
+ interval?: number;
5
+ timeout: number;
6
+ };
7
+ export declare const pollUntil: (isSettled: PollCondition, { grace, interval, timeout }: PollOptions) => Promise<{
8
+ error: unknown;
9
+ settled: boolean;
10
+ }>;
11
+ export {};
@@ -0,0 +1,20 @@
1
+ import { setTimeout } from 'node:timers/promises';
2
+ import { expect } from 'vitest';
3
+ export const pollUntil = async (isSettled, { grace, interval, timeout }) => {
4
+ try {
5
+ await expect
6
+ .poll(async ({ signal }) => {
7
+ if (!(await isSettled(signal)))
8
+ return false;
9
+ if (grace === undefined)
10
+ return true;
11
+ await setTimeout(grace, undefined, { signal });
12
+ return isSettled(signal);
13
+ }, { ...(interval !== undefined && { interval }), timeout })
14
+ .toBe(true);
15
+ return { error: undefined, settled: true };
16
+ }
17
+ catch (error) {
18
+ return { error, settled: false };
19
+ }
20
+ };
@@ -0,0 +1 @@
1
+ export declare const requireMockedModule: (subject: unknown, moduleId: string, replaced: string) => void;
@@ -0,0 +1,6 @@
1
+ import { vi } from 'vitest';
2
+ export const requireMockedModule = (subject, moduleId, replaced) => {
3
+ if (vi.isMockFunction(subject))
4
+ return;
5
+ throw new Error(`${moduleId} is not mocked — this project's vitest setupFiles must vi.mock('${moduleId}', …) with ${replaced} wrapped in vi.fn (see @zyplux/spectra README)`);
6
+ };
@@ -0,0 +1,7 @@
1
+ export type TempDir = {
2
+ exists: (relativePath: string) => boolean;
3
+ path: string;
4
+ remove: () => Promise<void>;
5
+ write: (relativePath: string, content: string) => Promise<string>;
6
+ };
7
+ export declare const createTempDir: (prefix?: string) => Promise<TempDir>;
@@ -0,0 +1,18 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ export const createTempDir = async (prefix = 'zyplux-test-') => {
6
+ const dir = await mkdtemp(path.join(tmpdir(), prefix));
7
+ return {
8
+ exists: relativePath => existsSync(path.join(dir, relativePath)),
9
+ path: dir,
10
+ remove: () => rm(dir, { force: true, recursive: true }),
11
+ write: async (relativePath, content) => {
12
+ const filePath = path.join(dir, relativePath);
13
+ await mkdir(path.dirname(filePath), { recursive: true });
14
+ await writeFile(filePath, content, 'utf8');
15
+ return filePath;
16
+ },
17
+ };
18
+ };
@@ -0,0 +1,34 @@
1
+ import type { FetchFake } from './fakes/fetch-fake.ts';
2
+ import type { PromptFake } from './fakes/prompt-fake.ts';
3
+ import type { ShellFake } from './fakes/shell-fake.ts';
4
+ import type { TempDir } from './helpers/temp-directory.ts';
5
+ import './test-matchers.ts';
6
+ import type { ConsoleCapture } from './reporters/console-capture.ts';
7
+ export type EnvStub = {
8
+ set: (name: string, value: string) => void;
9
+ };
10
+ export declare const makeFixture: <Subject>(subject: Subject) => ({}: object, use: (subject: Subject) => Promise<void>) => Promise<void>;
11
+ export type LibraryFixtures = {
12
+ shell: ShellFake;
13
+ tempDir: TempDir;
14
+ };
15
+ export declare const libraryTest: import("vitest").TestAPI<{
16
+ shell: ShellFake;
17
+ tempDir: TempDir;
18
+ }>;
19
+ export type CliFixtures = {
20
+ env: EnvStub;
21
+ instantSleep: undefined;
22
+ logs: ConsoleCapture;
23
+ network: FetchFake;
24
+ prompt: PromptFake;
25
+ };
26
+ export declare const cliTest: import("vitest").TestAPI<{
27
+ prompt: PromptFake;
28
+ shell: ShellFake;
29
+ tempDir: TempDir;
30
+ env: EnvStub;
31
+ instantSleep: undefined;
32
+ logs: ConsoleCapture;
33
+ network: FetchFake;
34
+ }>;
@@ -0,0 +1,96 @@
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ import { test as base, vi } from 'vitest';
3
+ import { requireMockedModule } from '#require-mocked-module';
4
+ import "./test-matchers.js";
5
+ import { createFetchFake } from "./fakes/fetch-fake.js";
6
+ import { createPromptFake } from "./fakes/prompt-fake.js";
7
+ import { createShellFake } from "./fakes/shell-fake.js";
8
+ import { createTempDir } from "./helpers/temp-directory.js";
9
+ import { createConsoleCapture } from "./reporters/console-capture.js";
10
+ export const makeFixture = (subject) => async ({}, use) => {
11
+ await use(subject);
12
+ };
13
+ export const libraryTest = base.extend({
14
+ shell: async ({}, use) => {
15
+ const shell = createShellFake();
16
+ const restore = shell.install();
17
+ try {
18
+ await use(shell);
19
+ }
20
+ finally {
21
+ restore();
22
+ }
23
+ },
24
+ tempDir: async ({}, use) => {
25
+ const tempDir = await createTempDir();
26
+ try {
27
+ await use(tempDir);
28
+ }
29
+ finally {
30
+ await tempDir.remove();
31
+ }
32
+ },
33
+ });
34
+ export const cliTest = libraryTest.extend({
35
+ env: async ({}, use) => {
36
+ try {
37
+ await use({
38
+ set: (name, value) => {
39
+ vi.stubEnv(name, value);
40
+ },
41
+ });
42
+ }
43
+ finally {
44
+ vi.unstubAllEnvs();
45
+ }
46
+ },
47
+ instantSleep: [
48
+ async ({}, use) => {
49
+ requireMockedModule(sleep, 'node:timers/promises', 'setTimeout');
50
+ vi.mocked(sleep).mockResolvedValue(undefined);
51
+ try {
52
+ await use(undefined);
53
+ }
54
+ finally {
55
+ vi.mocked(sleep).mockReset();
56
+ }
57
+ },
58
+ { auto: true },
59
+ ],
60
+ logs: [
61
+ async ({}, use) => {
62
+ const logs = createConsoleCapture();
63
+ const restore = logs.install();
64
+ try {
65
+ await use(logs);
66
+ }
67
+ finally {
68
+ restore();
69
+ }
70
+ },
71
+ { auto: true },
72
+ ],
73
+ network: [
74
+ async ({}, use) => {
75
+ const network = createFetchFake();
76
+ const restore = network.install();
77
+ try {
78
+ await use(network);
79
+ }
80
+ finally {
81
+ restore();
82
+ }
83
+ },
84
+ { auto: true },
85
+ ],
86
+ prompt: async ({}, use) => {
87
+ const prompt = createPromptFake();
88
+ const restore = prompt.install();
89
+ try {
90
+ await use(prompt);
91
+ }
92
+ finally {
93
+ restore();
94
+ }
95
+ },
96
+ });
@@ -0,0 +1,7 @@
1
+ export type ConsoleCapture = {
2
+ errorLines: string[];
3
+ install: () => () => void;
4
+ logLines: string[];
5
+ warnLines: string[];
6
+ };
7
+ export declare const createConsoleCapture: () => ConsoleCapture;
@@ -0,0 +1,28 @@
1
+ const renderLine = (parts) => parts.map(String).join(' ');
2
+ export const createConsoleCapture = () => {
3
+ const errorLines = [];
4
+ const logLines = [];
5
+ const warnLines = [];
6
+ return {
7
+ errorLines,
8
+ install: () => {
9
+ const original = { error: console.error, log: console.log, warn: console.warn };
10
+ console.error = (...parts) => {
11
+ errorLines.push(renderLine(parts));
12
+ };
13
+ console.log = (...parts) => {
14
+ logLines.push(renderLine(parts));
15
+ };
16
+ console.warn = (...parts) => {
17
+ warnLines.push(renderLine(parts));
18
+ };
19
+ return () => {
20
+ console.error = original.error;
21
+ console.log = original.log;
22
+ console.warn = original.warn;
23
+ };
24
+ },
25
+ logLines,
26
+ warnLines,
27
+ };
28
+ };
@@ -0,0 +1,4 @@
1
+ export declare const openJournalWriter: (identifier: string, socketPath: string) => {
2
+ close: () => Promise<void>;
3
+ write: (lines: readonly string[]) => void;
4
+ };
@@ -0,0 +1,27 @@
1
+ import { createConnection } from 'node:net';
2
+ export const openJournalWriter = (identifier, socketPath) => {
3
+ const writer = createConnection(socketPath);
4
+ let failure;
5
+ const closed = new Promise(resolve => {
6
+ writer.on('error', error => {
7
+ failure ??= error;
8
+ });
9
+ writer.on('close', () => {
10
+ resolve();
11
+ });
12
+ });
13
+ // sd_journal_stream_fd header: identifier, unit, priority, level prefixes, and three forwarding flags.
14
+ writer.write(`${identifier}\n\n6\n1\n0\n0\n0\n`);
15
+ return {
16
+ close: async () => {
17
+ writer.end();
18
+ await closed;
19
+ if (failure)
20
+ console.error(`recording ${identifier} to the journal failed`, failure);
21
+ },
22
+ write: (lines) => {
23
+ if (lines.length > 0 && !writer.destroyed)
24
+ writer.write(`${lines.join('\n')}\n`);
25
+ },
26
+ };
27
+ };
@@ -0,0 +1,16 @@
1
+ import type { UserConsoleLog } from 'vitest';
2
+ import type { Reporter, SerializedError, TestModule, TestRunEndReason, Vitest } from 'vitest/node';
3
+ type JournaldReporterOptions = {
4
+ identifier?: string;
5
+ isEnabled?: boolean;
6
+ socketPath?: string;
7
+ };
8
+ export declare class JournaldReporter implements Reporter {
9
+ #private;
10
+ constructor({ identifier, socketPath, isEnabled, }?: JournaldReporterOptions);
11
+ onInit(context: Vitest): void;
12
+ onTestRunEnd(modules: readonly TestModule[], errors: readonly SerializedError[], reason: TestRunEndReason): Promise<void>;
13
+ onTestRunStart(): void;
14
+ onUserConsoleLog({ content, taskId, type }: UserConsoleLog): void;
15
+ }
16
+ export {};
@@ -0,0 +1,91 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ const INFO_PREFIX = '<6>';
4
+ const WARNING_PREFIX = '<4>';
5
+ const ERROR_PREFIX = '<3>';
6
+ import { openJournalWriter } from "./journal-writer.js";
7
+ const formatErrors = (errors) => errors.flatMap(error => {
8
+ const stack = error.stack ?? '';
9
+ const message = error.message;
10
+ const text = stack.includes(message) ? stack : `${message}\n${stack}`;
11
+ return text
12
+ .trimEnd()
13
+ .split('\n')
14
+ .map(line => `${ERROR_PREFIX} ${line}`);
15
+ });
16
+ const formatFailures = (module) => {
17
+ const modulePath = path.relative(process.cwd(), module.moduleId);
18
+ return [
19
+ ...[module, ...module.children.allSuites()].flatMap(suite => suite.errors().length === 0
20
+ ? []
21
+ : [
22
+ `${ERROR_PREFIX}FAIL ${modulePath}${suite.type === 'module' ? '' : ` > ${suite.fullName}`}`,
23
+ ...formatErrors(suite.errors()),
24
+ ]),
25
+ ...[...module.children.allTests('failed')].flatMap(test => [
26
+ `${ERROR_PREFIX}FAIL ${modulePath} > ${test.fullName}`,
27
+ ...formatErrors(test.result().errors ?? []),
28
+ ]),
29
+ ];
30
+ };
31
+ export class JournaldReporter {
32
+ #context;
33
+ #identifier;
34
+ #isEnabled;
35
+ #socketPath;
36
+ #startedAt = '';
37
+ #writers = new Map();
38
+ constructor({ identifier = 'spectra', socketPath = '/run/systemd/journal/stdout', isEnabled = existsSync(socketPath), } = {}) {
39
+ this.#identifier = identifier;
40
+ this.#isEnabled = isEnabled;
41
+ this.#socketPath = socketPath;
42
+ }
43
+ onInit(context) {
44
+ this.#context = context;
45
+ }
46
+ async onTestRunEnd(modules, errors, reason) {
47
+ const projects = Map.groupBy(modules, module => module.project.name);
48
+ for (const [project, projectModules] of projects) {
49
+ const tests = projectModules.flatMap(module => [...module.children.allTests()]);
50
+ const passed = tests.filter(test => test.result().state === 'passed').length;
51
+ const failed = tests.filter(test => test.result().state === 'failed').length;
52
+ const pending = tests.length - passed - failed;
53
+ const hasFailed = projectModules.some(module => !module.ok()) || errors.length > 0 || reason === 'interrupted';
54
+ this.#write(project ? `${this.#identifier}-${project}` : this.#identifier, [
55
+ `${INFO_PREFIX}run start ${this.#startedAt}`,
56
+ ...projectModules.flatMap(module => formatFailures(module)),
57
+ `${hasFailed ? ERROR_PREFIX : INFO_PREFIX}${hasFailed ? 'FAIL' : 'pass'} — ${tests.length} tests: ${passed} passed, ${failed} failed, ${pending} pending`,
58
+ ]);
59
+ }
60
+ if (errors.length > 0)
61
+ this.#write(this.#identifier, formatErrors(errors));
62
+ for (const writer of this.#writers.values())
63
+ await writer.close();
64
+ this.#writers.clear();
65
+ }
66
+ onTestRunStart() {
67
+ this.#startedAt = new Date().toISOString();
68
+ }
69
+ onUserConsoleLog({ content, taskId, type }) {
70
+ const trimmed = content.trimEnd();
71
+ if (!trimmed)
72
+ return;
73
+ const entity = taskId ? this.#context?.state.getReportedEntityById(taskId) : undefined;
74
+ const module = entity?.type === 'module' ? entity : entity?.module;
75
+ const identifier = module?.project.name ? `${this.#identifier}-${module.project.name}` : this.#identifier;
76
+ const modulePath = module ? path.relative(process.cwd(), module.moduleId) : '<no test>';
77
+ const source = entity && entity.type !== 'module' ? `${modulePath} > ${entity.fullName}` : modulePath;
78
+ const prefix = type === 'stdout' ? INFO_PREFIX : WARNING_PREFIX;
79
+ this.#write(`${identifier}-console`, trimmed.split('\n').map(line => `${prefix}${source} ${line}`));
80
+ }
81
+ #write(identifier, lines) {
82
+ if (!this.#isEnabled)
83
+ return;
84
+ let writer = this.#writers.get(identifier);
85
+ if (!writer) {
86
+ writer = openJournalWriter(identifier, this.#socketPath);
87
+ this.#writers.set(identifier, writer);
88
+ }
89
+ writer.write(lines);
90
+ }
91
+ }
@@ -0,0 +1,51 @@
1
+ import { expect } from 'vitest';
2
+ import type { ShellFake } from './fakes/shell-fake.ts';
3
+ import type { ConsoleCapture } from './reporters/console-capture.ts';
4
+ export type LineMatch = RegExp | string;
5
+ export declare const registerMatchers: <Registered extends Parameters<typeof expect.extend>[0]>(matchers: Registered) => Registered;
6
+ export declare const testMatchers: {
7
+ toContainExactElementsInAnyOrder: (received: readonly unknown[], expected: readonly unknown[]) => {
8
+ message: () => string;
9
+ pass: boolean;
10
+ };
11
+ toContainNoDuplicates: (received: readonly unknown[]) => {
12
+ message: () => string;
13
+ pass: boolean;
14
+ };
15
+ toHaveErrored: ({ errorLines }: ConsoleCapture, line?: LineMatch) => {
16
+ message: () => string;
17
+ pass: boolean;
18
+ };
19
+ toHaveLogged: ({ logLines }: ConsoleCapture, line?: LineMatch) => {
20
+ message: () => string;
21
+ pass: boolean;
22
+ };
23
+ toHaveNumberOfElements: (received: readonly unknown[], expected: number) => {
24
+ message: () => string;
25
+ pass: boolean;
26
+ };
27
+ toHaveRun: ({ commands }: ShellFake, command: string) => {
28
+ message: () => string;
29
+ pass: boolean;
30
+ };
31
+ toHaveRunMatching: (shell: ShellFake, pattern: RegExp | string) => {
32
+ message: () => string;
33
+ pass: boolean;
34
+ };
35
+ toHaveWarned: ({ warnLines }: ConsoleCapture, line?: LineMatch) => {
36
+ message: () => string;
37
+ pass: boolean;
38
+ };
39
+ };
40
+ declare module 'vitest' {
41
+ interface Matchers<R, T> {
42
+ toContainExactElementsInAnyOrder: (expected: readonly unknown[]) => R;
43
+ toContainNoDuplicates: () => R;
44
+ toHaveErrored: (line?: LineMatch) => R;
45
+ toHaveLogged: (line?: LineMatch) => R;
46
+ toHaveNumberOfElements: T extends readonly unknown[] ? (expected: number) => R : never;
47
+ toHaveRun: (command: string) => R;
48
+ toHaveRunMatching: (pattern: RegExp | string) => R;
49
+ toHaveWarned: (line?: LineMatch) => R;
50
+ }
51
+ }
@@ -0,0 +1,62 @@
1
+ import { isDeepStrictEqual } from 'node:util';
2
+ import { expect } from 'vitest';
3
+ import { isPatternMatch } from '#pattern-match';
4
+ export const registerMatchers = (matchers) => {
5
+ expect.extend(matchers);
6
+ return matchers;
7
+ };
8
+ const isLineMatch = (line, expected) => typeof expected === 'string' ? line === expected : isPatternMatch(line, expected);
9
+ const renderLines = (label, lines) => lines.length === 0 ? `${label}: (none)` : `${label}:\n${lines.map(line => ` ${line}`).join('\n')}`;
10
+ const lineListResult = (lines, label, expected) => {
11
+ const isPass = expected === undefined ? lines.length > 0 : lines.some(line => isLineMatch(line, expected));
12
+ const observation = expected === undefined
13
+ ? `${label} were ${isPass ? 'captured' : 'empty'}`
14
+ : `${label} ${isPass ? 'include' : 'do not include'} ${String(expected)}`;
15
+ return { message: () => `${observation}\n${renderLines(label, lines)}`, pass: isPass };
16
+ };
17
+ const unmatchedElements = (received, expected) => {
18
+ const remaining = [...expected];
19
+ return received.filter(item => {
20
+ const index = remaining.findIndex(candidate => isDeepStrictEqual(candidate, item));
21
+ if (index === -1)
22
+ return true;
23
+ remaining.splice(index, 1);
24
+ return false;
25
+ });
26
+ };
27
+ export const testMatchers = registerMatchers({
28
+ toContainExactElementsInAnyOrder: (received, expected) => {
29
+ const extra = unmatchedElements(received, expected);
30
+ const missing = unmatchedElements(expected, received);
31
+ return {
32
+ message: () => `expected [${received.join(', ')}] to contain exactly [${expected.join(', ')}] in any order\n` +
33
+ `${renderLines('missing', missing.map(String))}\n${renderLines('extra', extra.map(String))}`,
34
+ pass: extra.length === 0 && missing.length === 0,
35
+ };
36
+ },
37
+ toContainNoDuplicates: (received) => {
38
+ const duplicates = received.filter((item, index) => received.findIndex(candidate => isDeepStrictEqual(candidate, item)) !== index);
39
+ return {
40
+ message: () => `expected [${received.join(', ')}] to contain no duplicates\n${renderLines('duplicates', duplicates.map(String))}`,
41
+ pass: duplicates.length === 0,
42
+ };
43
+ },
44
+ toHaveErrored: ({ errorLines }, line) => lineListResult(errorLines, 'console.error lines', line),
45
+ toHaveLogged: ({ logLines }, line) => lineListResult(logLines, 'console.log lines', line),
46
+ toHaveNumberOfElements: (received, expected) => ({
47
+ message: () => `expected [${received.join(', ')}] (${received.length} elements) to have ${expected} elements`,
48
+ pass: received.length === expected,
49
+ }),
50
+ toHaveRun: ({ commands }, command) => ({
51
+ message: () => `the shell ${commands.includes(command) ? 'ran' : 'never ran'} ${command}\n${renderLines('commands', commands)}`,
52
+ pass: commands.includes(command),
53
+ }),
54
+ toHaveRunMatching: (shell, pattern) => {
55
+ const matched = shell.commandsMatching(pattern);
56
+ return {
57
+ message: () => `${matched.length} shell commands matched ${String(pattern)}\n${renderLines('commands', shell.commands)}`,
58
+ pass: matched.length > 0,
59
+ };
60
+ },
61
+ toHaveWarned: ({ warnLines }, line) => lineListResult(warnLines, 'console.warn lines', line),
62
+ });
package/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "@zyplux/spectra",
3
+ "version": "0.3.0",
4
+ "description": "Shared Vitest fixtures, matchers, polling, and journal reporting.",
5
+ "keywords": [
6
+ "test",
7
+ "fixtures",
8
+ "mock",
9
+ "shell",
10
+ "vitest"
11
+ ],
12
+ "license": "MIT",
13
+ "author": "Sergiy Yeskov <sergiy.yeskov@gmail.com>",
14
+ "homepage": "https://github.com/zyplux/zyplux",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/zyplux/zyplux.git",
18
+ "directory": "packages/spectra"
19
+ },
20
+ "type": "module",
21
+ "exports": {
22
+ "./fetch-fake": {
23
+ "types": "./dist/fakes/fetch-fake.d.ts",
24
+ "default": "./dist/fakes/fetch-fake.js"
25
+ },
26
+ "./prompt-fake": {
27
+ "types": "./dist/fakes/prompt-fake.d.ts",
28
+ "default": "./dist/fakes/prompt-fake.js"
29
+ },
30
+ "./shell-fake": {
31
+ "types": "./dist/fakes/shell-fake.d.ts",
32
+ "default": "./dist/fakes/shell-fake.js"
33
+ },
34
+ "./cli-runner": {
35
+ "types": "./dist/helpers/cli-runner.d.ts",
36
+ "default": "./dist/helpers/cli-runner.js"
37
+ },
38
+ "./poll-until": {
39
+ "types": "./dist/helpers/poll-until.d.ts",
40
+ "default": "./dist/helpers/poll-until.js"
41
+ },
42
+ "./temp-directory": {
43
+ "types": "./dist/helpers/temp-directory.d.ts",
44
+ "default": "./dist/helpers/temp-directory.js"
45
+ },
46
+ "./library-test-api": {
47
+ "types": "./dist/library-test-api.d.ts",
48
+ "default": "./dist/library-test-api.js"
49
+ },
50
+ "./console-capture": {
51
+ "types": "./dist/reporters/console-capture.d.ts",
52
+ "default": "./dist/reporters/console-capture.js"
53
+ },
54
+ "./test-matchers": {
55
+ "types": "./dist/test-matchers.d.ts",
56
+ "default": "./dist/test-matchers.js"
57
+ },
58
+ "./journald-reporter": {
59
+ "types": "./dist/reporters/journald-reporter.d.ts",
60
+ "default": "./dist/reporters/journald-reporter.js"
61
+ },
62
+ "./package.json": "./package.json"
63
+ },
64
+ "files": [
65
+ "README.md",
66
+ "dist"
67
+ ],
68
+ "publishConfig": {
69
+ "access": "public"
70
+ },
71
+ "peerDependencies": {
72
+ "vitest": "^5.0.3"
73
+ },
74
+ "devDependencies": {
75
+ "@types/node": "^26.6.3",
76
+ "@zyplux/tsconfig": "0.3.1",
77
+ "publint": "^0.3.25",
78
+ "vitest": "^5.0.3"
79
+ },
80
+ "imports": {
81
+ "#pattern-match": "./dist/helpers/pattern-match.js",
82
+ "#require-mocked-module": "./dist/helpers/require-mocked-module.js"
83
+ },
84
+ "sideEffects": [
85
+ "./dist/library-test-api.js",
86
+ "./dist/test-matchers.js"
87
+ ],
88
+ "scripts": {}
89
+ }