runspec-node 0.24.0 → 0.26.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.
@@ -0,0 +1,209 @@
1
+ import * as fs from 'fs';
2
+ import * as os from 'os';
3
+ import * as path from 'path';
4
+ import * as zlib from 'zlib';
5
+ import { randomUUID } from 'crypto';
6
+ import { parseDuration, parseSize, collectRecords, view, status, inventory, planPrune, prune, compact } from '../src/logs';
7
+
8
+ const DAY = 86_400_000;
9
+
10
+ function tmp(): string {
11
+ return fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'runspec-logs-'));
12
+ }
13
+
14
+ function summary(runId: string, runnable: string, ts: string, user = 'alice'): string {
15
+ return JSON.stringify({ ts, level: 'INFO', logger: 'runspec.runsummary', message: 'run completed', extra: { event: 'run_summary', run_id: runId, runnable, user, exit_code: 0 } });
16
+ }
17
+
18
+ function line(runId: string, message: string, ts: string, level = 'INFO'): string {
19
+ return JSON.stringify({ ts, level, logger: 'app', message, extra: { run_id: runId } });
20
+ }
21
+
22
+ function writeRun(dir: string, runnable: string, tsName: string, lines: string[], ageDays?: number): string {
23
+ // Real uuid token so isManaged() matches the per-run grammar.
24
+ const p = path.join(dir, `${runnable}.${tsName}.${randomUUID()}.log`);
25
+ fs.writeFileSync(p, lines.join('\n') + '\n');
26
+ if (ageDays != null) {
27
+ const t = Date.now() / 1000 - ageDays * 86400;
28
+ fs.utimesSync(p, t, t);
29
+ }
30
+ return p;
31
+ }
32
+
33
+ const dirs: string[] = [];
34
+ afterEach(() => {
35
+ while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true });
36
+ });
37
+ function dir(): string {
38
+ const d = tmp();
39
+ dirs.push(d);
40
+ return d;
41
+ }
42
+
43
+ describe('parsers', () => {
44
+ it('parseDuration', () => {
45
+ expect(parseDuration('30m')).toBe(30 * 60_000);
46
+ expect(parseDuration('7d')).toBe(7 * DAY);
47
+ expect(parseDuration('2w')).toBe(14 * DAY);
48
+ expect(() => parseDuration('soon')).toThrow();
49
+ });
50
+ it('parseSize', () => {
51
+ expect(parseSize('500KB')).toBe(500 * 1024);
52
+ expect(parseSize('10MB')).toBe(10 * 1024 ** 2);
53
+ expect(() => parseSize('huge')).toThrow();
54
+ });
55
+ });
56
+
57
+ describe('view / collectRecords', () => {
58
+ it('merges and sorts by ts across files', () => {
59
+ const d = dir();
60
+ writeRun(d, 'deploy', '20260603T150000Z', [summary('r2', 'deploy', '2026-06-03T15:00:00+00:00')]);
61
+ writeRun(d, 'deploy', '20260601T100000Z', [summary('r1', 'deploy', '2026-06-01T10:00:00+00:00')]);
62
+ const ids = collectRecords([d], 'deploy').map((p) => p.rec.extra.run_id);
63
+ expect(ids).toEqual(['r1', 'r2']);
64
+ });
65
+
66
+ it('user filter covers all of a run\'s lines', () => {
67
+ const d = dir();
68
+ writeRun(d, 'deploy', '20260601T100000Z', [line('r1', 'hello', '2026-06-01T10:00:00+00:00'), summary('r1', 'deploy', '2026-06-01T10:00:01+00:00', 'bob')]);
69
+ writeRun(d, 'deploy', '20260601T110000Z', [summary('r2', 'deploy', '2026-06-01T11:00:00+00:00', 'alice')]);
70
+ const recs = collectRecords([d], 'deploy', { user: 'bob' });
71
+ expect(new Set(recs.map((p) => p.rec.extra.run_id))).toEqual(new Set(['r1']));
72
+ expect(recs.length).toBe(2);
73
+ });
74
+
75
+ it('json passthrough emits the raw line', () => {
76
+ const d = dir();
77
+ const raw = summary('r1', 'deploy', '2026-06-01T10:00:00+00:00');
78
+ writeRun(d, 'deploy', '20260601T100000Z', [raw]);
79
+ let buf = '';
80
+ view('deploy', { dirs: [d], asJson: true, out: (s) => (buf += s) });
81
+ expect(buf.trim()).toBe(raw);
82
+ });
83
+
84
+ it('text line carries run + user columns', () => {
85
+ const d = dir();
86
+ writeRun(d, 'deploy', '20260601T100000Z', [line('r1', 'doing', '2026-06-01T10:00:00+00:00')]);
87
+ let buf = '';
88
+ view('deploy', { dirs: [d], out: (s) => (buf += s) });
89
+ expect(buf).toContain('run=r1');
90
+ expect(buf).toContain('doing');
91
+ });
92
+ });
93
+
94
+ describe('status / inventory', () => {
95
+ it('counts runs and archives, ignores single-mode file', () => {
96
+ const d = dir();
97
+ writeRun(d, 'deploy', '20260601T100000Z', [summary('r1', 'deploy', '2026-06-01T10:00:00+00:00')]);
98
+ writeRun(d, 'deploy', '20260602T100000Z', [summary('r2', 'deploy', '2026-06-02T10:00:00+00:00')]);
99
+ fs.writeFileSync(path.join(d, 'deploy.log'), summary('r0', 'deploy', '2026-06-01T00:00:00+00:00') + '\n'); // single-mode
100
+ const inv = inventory([d]);
101
+ expect(inv['deploy'].per_run_files).toBe(2);
102
+ expect(inv['deploy'].archives).toBe(0);
103
+ });
104
+
105
+ it('json shape', () => {
106
+ const d = dir();
107
+ writeRun(d, 'deploy', '20260601T100000Z', [summary('r1', 'deploy', '2026-06-01T10:00:00+00:00')]);
108
+ let buf = '';
109
+ status(null, { dirs: [d], asJson: true, out: (s) => (buf += s) });
110
+ const payload = JSON.parse(buf);
111
+ expect(payload.total_files).toBe(1);
112
+ expect(payload.runnables[0].runnable).toBe('deploy');
113
+ expect(String(payload.runnables[0].newest)).toMatch(/Z$/);
114
+ });
115
+
116
+ it('text says nothing found when empty', () => {
117
+ const d = dir();
118
+ let buf = '';
119
+ status(null, { dirs: [d], out: (s) => (buf += s) });
120
+ expect(buf).toContain('No per-invocation logs');
121
+ });
122
+ });
123
+
124
+ describe('prune', () => {
125
+ it('older-than selects only old files', () => {
126
+ const d = dir();
127
+ const old = writeRun(d, 'x', '20260101T100000Z', [summary('r1', 'x', '2026-01-01T10:00:00+00:00')], 30);
128
+ const fresh = writeRun(d, 'x', '20260603T100000Z', [summary('r2', 'x', '2026-06-03T10:00:00+00:00')], 1);
129
+ const doomed = planPrune([d], 'x', { olderThan: 7 * DAY });
130
+ expect(doomed).toEqual([old]);
131
+ expect(fs.existsSync(fresh)).toBe(true);
132
+ });
133
+
134
+ it('max-files keeps the newest N', () => {
135
+ const d = dir();
136
+ const files = [1, 2, 3, 4].map((i) => writeRun(d, 'x', `2026060${i}T100000Z`, [summary(`r${i}`, 'x', `2026-06-0${i}T10:00:00+00:00`)], 10 - i));
137
+ const doomed = new Set(planPrune([d], 'x', { maxFiles: 2 }));
138
+ expect(doomed).toEqual(new Set([files[0], files[1]])); // oldest two
139
+ });
140
+
141
+ it('requires a policy', () => {
142
+ const d = dir();
143
+ expect(() => planPrune([d], 'x', {})).toThrow();
144
+ });
145
+
146
+ it('never selects a single-mode {runnable}.log', () => {
147
+ const d = dir();
148
+ const single = path.join(d, 'x.log');
149
+ fs.writeFileSync(single, '{}\n');
150
+ const t = Date.now() / 1000 - 99 * 86400;
151
+ fs.utimesSync(single, t, t);
152
+ expect(planPrune([d], 'x', { olderThan: 7 * DAY })).toEqual([]);
153
+ expect(fs.existsSync(single)).toBe(true);
154
+ });
155
+
156
+ it('dry-run deletes nothing; json reports the plan', () => {
157
+ const d = dir();
158
+ const f = writeRun(d, 'x', '20260101T100000Z', [summary('r1', 'x', '2026-01-01T10:00:00+00:00')], 30);
159
+ let buf = '';
160
+ const { count } = prune('x', { dirs: [d], olderThan: 7 * DAY, dryRun: true, asJson: true, out: (s) => (buf += s) });
161
+ expect(count).toBe(1);
162
+ expect(fs.existsSync(f)).toBe(true);
163
+ const payload = JSON.parse(buf);
164
+ expect(payload.dry_run).toBe(true);
165
+ expect(payload.deleted[0].path).toBe(f);
166
+ });
167
+
168
+ it('executes deletion', () => {
169
+ const d = dir();
170
+ const f = writeRun(d, 'x', '20260101T100000Z', [summary('r1', 'x', '2026-01-01T10:00:00+00:00')], 30);
171
+ prune('x', { dirs: [d], olderThan: 7 * DAY, out: () => {} });
172
+ expect(fs.existsSync(f)).toBe(false);
173
+ });
174
+ });
175
+
176
+ describe('compact', () => {
177
+ it('rolls old runs into a gzip archive and view reads it back', () => {
178
+ const d = dir();
179
+ const old = writeRun(d, 'x', '20260101T100000Z', [summary('r1', 'x', '2026-01-01T10:00:00+00:00')], 30);
180
+ const recent = writeRun(d, 'x', '20260603T100000Z', [summary('r2', 'x', '2026-06-03T10:00:00+00:00')], 1);
181
+ const n = compact('x', 7 * DAY, { dirs: [d], gzip: true, out: () => {} });
182
+ expect(n).toBe(1);
183
+ expect(fs.existsSync(old)).toBe(false);
184
+ expect(fs.existsSync(recent)).toBe(true);
185
+ const archives = fs.readdirSync(d).filter((f) => /x\.archive\.\d{8}\.log\.gz$/.test(f));
186
+ expect(archives.length).toBe(1);
187
+ // archive is readable + view merges it
188
+ expect(zlib.gunzipSync(fs.readFileSync(path.join(d, archives[0]))).toString()).toContain('r1');
189
+ // view merges the archive (r1) with the still-present recent file (r2), ts-sorted
190
+ expect(collectRecords([d], 'x').map((p) => p.rec.extra.run_id)).toEqual(['r1', 'r2']);
191
+ });
192
+
193
+ it('does not re-compact an existing archive; json reports compacted count', () => {
194
+ const d = dir();
195
+ writeRun(d, 'x', '20260101T100000Z', [summary('r1', 'x', '2026-01-01T10:00:00+00:00')], 30);
196
+ let buf = '';
197
+ compact('x', 7 * DAY, { dirs: [d], gzip: true, asJson: true, out: (s) => (buf += s) });
198
+ expect(JSON.parse(buf).compacted).toBe(1);
199
+ expect(compact('x', 7 * DAY, { dirs: [d], gzip: true, out: () => {} })).toBe(0);
200
+ });
201
+
202
+ it('dry-run keeps originals', () => {
203
+ const d = dir();
204
+ const old = writeRun(d, 'x', '20260101T100000Z', [summary('r1', 'x', '2026-01-01T10:00:00+00:00')], 30);
205
+ compact('x', 7 * DAY, { dirs: [d], dryRun: true, out: () => {} });
206
+ expect(fs.existsSync(old)).toBe(true);
207
+ expect(fs.readdirSync(d).some((f) => f.includes('.archive.'))).toBe(false);
208
+ });
209
+ });
@@ -0,0 +1,82 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import { configureLogging, emitRunSummary, getLogger, _resetForTest } from '../src/logging_setup';
5
+
6
+ // NOTE: these tests write via `process.stdout.write` directly rather than
7
+ // `console.log`, because jest hijacks `console.*` to add source annotations —
8
+ // which is what the tee would (correctly) capture under jest. In production
9
+ // `console.log` writes the plain line straight to process.stdout, which is what
10
+ // we exercise here.
11
+
12
+ function tmpDir(): string {
13
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-print-test-'));
14
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"test","version":"0.0.0"}');
15
+ return d;
16
+ }
17
+
18
+ function cfg(dir: string): Parameters<typeof configureLogging>[0] {
19
+ return {
20
+ logCfg: { rotate: 'midnight', keep: 7, summary: true, store: 'per-run' },
21
+ runnableName: 'myscript',
22
+ configPath: path.join(dir, 'runspec.toml'),
23
+ };
24
+ }
25
+
26
+ function readPerRun(dir: string): Array<Record<string, any>> {
27
+ const file = fs.readdirSync(path.join(dir, 'logs')).find((f) => f.startsWith('myscript.'))!;
28
+ return fs
29
+ .readFileSync(path.join(dir, 'logs', file), 'utf-8')
30
+ .trim()
31
+ .split('\n')
32
+ .map((l) => JSON.parse(l));
33
+ }
34
+
35
+ beforeEach(() => _resetForTest());
36
+ afterAll(() => _resetForTest());
37
+
38
+ test('printed output is captured into the audit file as a runspec.print record', () => {
39
+ const dir = tmpDir();
40
+ configureLogging(cfg(dir));
41
+ process.stdout.write('hello from the runnable\n');
42
+ emitRunSummary();
43
+
44
+ const printRec = readPerRun(dir).find((r) => r.logger === 'runspec.print');
45
+ expect(printRec).toBeTruthy();
46
+ expect(printRec!.message).toBe('hello from the runnable');
47
+ expect(printRec!.extra.run_id).toBeTruthy(); // tagged like every file record
48
+ });
49
+
50
+ test('captured output reaches the real stdout exactly once (no double-print)', () => {
51
+ const dir = tmpDir();
52
+ // Spy BEFORE configureLogging so the tee writes through this spy as the raw stream.
53
+ const spy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true);
54
+ configureLogging(cfg(dir));
55
+ process.stdout.write('once please\n');
56
+ const rawCalls = spy.mock.calls.filter((c) => String(c[0]).includes('once please')).length;
57
+ spy.mockRestore();
58
+ expect(rawCalls).toBe(1);
59
+ });
60
+
61
+ test('captured lines are not counted as log events in the run summary', () => {
62
+ const dir = tmpDir();
63
+ configureLogging(cfg(dir));
64
+ process.stdout.write('line one\n');
65
+ process.stdout.write('line two\n');
66
+ getLogger('app').info('a real event'); // this one SHOULD count
67
+ emitRunSummary();
68
+
69
+ const summary = readPerRun(dir).find((r) => r.extra?.event === 'run_summary')!;
70
+ expect(summary.extra.events.INFO).toBe(1); // the two printed lines didn't inflate the count
71
+ });
72
+
73
+ test('a partial (newline-less) trailing write is flushed on the next newline', () => {
74
+ const dir = tmpDir();
75
+ configureLogging(cfg(dir));
76
+ process.stdout.write('partial '); // buffered, not yet a complete line
77
+ process.stdout.write('then rest\n'); // completes the line
78
+ emitRunSummary();
79
+
80
+ const printRec = readPerRun(dir).find((r) => r.logger === 'runspec.print');
81
+ expect(printRec!.message).toBe('partial then rest');
82
+ });