runspec-node 0.8.0 → 0.10.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,388 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import {
5
+ configureLogging,
6
+ getLogger,
7
+ Logger,
8
+ _resetForTest,
9
+ _periodForDate,
10
+ } from '../src/logging_setup';
11
+
12
+ function tmpDir(): string {
13
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-log-test-'));
14
+ }
15
+
16
+ function makeCfg(dir: string, overrides: Record<string, unknown> = {}): Parameters<typeof configureLogging>[0] {
17
+ return {
18
+ logCfg: { level: 'info', rotate: 'midnight', keep: 7, ...overrides },
19
+ agent: false,
20
+ runnableName: 'myscript',
21
+ configPath: path.join(dir, 'runspec.toml'),
22
+ };
23
+ }
24
+
25
+ beforeEach(() => {
26
+ _resetForTest();
27
+ });
28
+
29
+ // ── getLogger ─────────────────────────────────────────────────────────────────
30
+
31
+ test('getLogger returns Logger instance', () => {
32
+ expect(getLogger('myapp')).toBeInstanceOf(Logger);
33
+ });
34
+
35
+ test('getLogger returns same instance for same name', () => {
36
+ expect(getLogger('myapp')).toBe(getLogger('myapp'));
37
+ });
38
+
39
+ test('getLogger returns different instances for different names', () => {
40
+ expect(getLogger('a')).not.toBe(getLogger('b'));
41
+ });
42
+
43
+ // ── configureLogging — no-op cases ────────────────────────────────────────────
44
+
45
+ test('no-op when logCfg is undefined', () => {
46
+ const dir = tmpDir();
47
+ configureLogging({ logCfg: undefined, agent: false, runnableName: 'x', configPath: path.join(dir, 'runspec.toml') });
48
+ // no log file created
49
+ expect(fs.existsSync(path.join(dir, 'logs', 'x.log'))).toBe(false);
50
+ });
51
+
52
+ test('idempotent — second call is silently ignored', () => {
53
+ const dir = tmpDir();
54
+ const cfg = makeCfg(dir);
55
+ configureLogging(cfg);
56
+ _resetForTest(); // reset state but don't re-configure — simulate second call by calling again after a fresh configure
57
+ configureLogging(cfg);
58
+ configureLogging(cfg); // third call — should be no-op after second
59
+ // Just ensure no error thrown
60
+ });
61
+
62
+ test('idempotent — configured flag prevents double setup', () => {
63
+ const dir = tmpDir();
64
+ const cfg = makeCfg(dir);
65
+ configureLogging(cfg);
66
+ // second call should be silently ignored
67
+ expect(() => configureLogging(cfg)).not.toThrow();
68
+ });
69
+
70
+ // ── file handler ──────────────────────────────────────────────────────────────
71
+
72
+ test('creates log file in logs/ subdir', () => {
73
+ const dir = tmpDir();
74
+ configureLogging(makeCfg(dir));
75
+ getLogger('test').info('hello');
76
+ expect(fs.existsSync(path.join(dir, 'logs', 'myscript.log'))).toBe(true);
77
+ });
78
+
79
+ test('log file contains JSON lines', () => {
80
+ const dir = tmpDir();
81
+ configureLogging(makeCfg(dir));
82
+ getLogger('test').info('hello world');
83
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
84
+ const parsed = JSON.parse(content);
85
+ expect(parsed.level).toBe('INFO');
86
+ expect(parsed.message).toBe('hello world');
87
+ expect(parsed.logger).toBe('test');
88
+ expect(typeof parsed.ts).toBe('string');
89
+ });
90
+
91
+ test('file handler captures DEBUG even when console is INFO', () => {
92
+ const dir = tmpDir();
93
+ configureLogging(makeCfg(dir, { level: 'info' }));
94
+ getLogger('test').debug('low level detail');
95
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
96
+ const parsed = JSON.parse(content);
97
+ expect(parsed.level).toBe('DEBUG');
98
+ });
99
+
100
+ test('log file captures all levels', () => {
101
+ const dir = tmpDir();
102
+ configureLogging(makeCfg(dir));
103
+ const log = getLogger('test');
104
+ log.debug('a');
105
+ log.info('b');
106
+ log.warning('c');
107
+ log.error('d');
108
+ log.critical('e');
109
+ const lines = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim().split('\n');
110
+ expect(lines).toHaveLength(5);
111
+ expect(JSON.parse(lines[0]).level).toBe('DEBUG');
112
+ expect(JSON.parse(lines[4]).level).toBe('CRITICAL');
113
+ });
114
+
115
+ test('error field included when Error passed', () => {
116
+ const dir = tmpDir();
117
+ configureLogging(makeCfg(dir));
118
+ getLogger('test').error('boom', { error: new Error('something broke') });
119
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
120
+ const parsed = JSON.parse(content);
121
+ expect(typeof parsed.exc).toBe('string');
122
+ expect(parsed.exc).toContain('something broke');
123
+ });
124
+
125
+ // ── agent mode ────────────────────────────────────────────────────────────────
126
+
127
+ test('agent mode: no console output to stderr', () => {
128
+ const dir = tmpDir();
129
+ const stderrWrite = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
130
+ configureLogging({ ...makeCfg(dir), agent: true });
131
+ getLogger('test').info('silent');
132
+ expect(stderrWrite).not.toHaveBeenCalled();
133
+ stderrWrite.mockRestore();
134
+ });
135
+
136
+ test('non-agent mode: writes to stderr', () => {
137
+ const dir = tmpDir();
138
+ const lines: string[] = [];
139
+ const stderrWrite = jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
140
+ lines.push(String(chunk));
141
+ return true;
142
+ });
143
+ configureLogging(makeCfg(dir, { level: 'info' }));
144
+ getLogger('test').info('visible');
145
+ expect(lines.some(l => l.includes('visible'))).toBe(true);
146
+ stderrWrite.mockRestore();
147
+ });
148
+
149
+ test('console output not written for messages below configured level', () => {
150
+ const dir = tmpDir();
151
+ const lines: string[] = [];
152
+ const stderrWrite = jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
153
+ lines.push(String(chunk));
154
+ return true;
155
+ });
156
+ configureLogging(makeCfg(dir, { level: 'warning' }));
157
+ getLogger('test').debug('below threshold');
158
+ getLogger('test').info('also below');
159
+ expect(lines).toHaveLength(0);
160
+ stderrWrite.mockRestore();
161
+ });
162
+
163
+ // ── log level override ────────────────────────────────────────────────────────
164
+
165
+ test('logLevelOverride sets console level', () => {
166
+ const dir = tmpDir();
167
+ const lines: string[] = [];
168
+ const stderrWrite = jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
169
+ lines.push(String(chunk));
170
+ return true;
171
+ });
172
+ configureLogging({ ...makeCfg(dir, { level: 'warning' }), logLevelOverride: 'debug' });
173
+ getLogger('test').debug('now visible');
174
+ expect(lines.some(l => l.includes('now visible'))).toBe(true);
175
+ stderrWrite.mockRestore();
176
+ });
177
+
178
+ // ── sensitive data redaction ──────────────────────────────────────────────────
179
+
180
+ test('redacts password= in log message', () => {
181
+ const dir = tmpDir();
182
+ configureLogging(makeCfg(dir));
183
+ getLogger('test').info('connecting with password=hunter2');
184
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
185
+ expect(content).not.toContain('hunter2');
186
+ expect(content).toContain('[REDACTED]');
187
+ });
188
+
189
+ test('redacts token= in log message', () => {
190
+ const dir = tmpDir();
191
+ configureLogging(makeCfg(dir));
192
+ getLogger('test').info('using token=abc123secret');
193
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
194
+ expect(content).not.toContain('abc123secret');
195
+ expect(content).toContain('[REDACTED]');
196
+ });
197
+
198
+ test('redacts Bearer token in Authorization header', () => {
199
+ const dir = tmpDir();
200
+ configureLogging(makeCfg(dir));
201
+ getLogger('test').info('Authorization: Bearer supersecrettoken');
202
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
203
+ expect(content).not.toContain('supersecrettoken');
204
+ expect(content).toContain('[REDACTED]');
205
+ });
206
+
207
+ test('redacts URL credentials', () => {
208
+ const dir = tmpDir();
209
+ configureLogging(makeCfg(dir));
210
+ getLogger('test').info('connecting to https://user:p4ssw0rd@db.example.com');
211
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
212
+ expect(content).not.toContain('p4ssw0rd');
213
+ expect(content).toContain('[REDACTED]');
214
+ });
215
+
216
+ test('redacts JSON password field', () => {
217
+ const dir = tmpDir();
218
+ configureLogging(makeCfg(dir));
219
+ getLogger('test').info('payload: {"password": "mysecret"}');
220
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
221
+ expect(content).not.toContain('mysecret');
222
+ expect(content).toContain('[REDACTED]');
223
+ });
224
+
225
+ test('redaction errors do not suppress the log record', () => {
226
+ const dir = tmpDir();
227
+ configureLogging(makeCfg(dir));
228
+ // Normal message — no redaction error expected, but confirm log still written
229
+ getLogger('test').info('safe message');
230
+ expect(fs.existsSync(path.join(dir, 'logs', 'myscript.log'))).toBe(true);
231
+ });
232
+
233
+ // ── size-based rotation ───────────────────────────────────────────────────────
234
+
235
+ test('size rotation: creates .1 backup when file exceeds maxBytes', () => {
236
+ const dir = tmpDir();
237
+ configureLogging(makeCfg(dir, { rotate: '1 KB', keep: 3 }));
238
+ const logPath = path.join(dir, 'logs', 'myscript.log');
239
+ const log = getLogger('rot');
240
+ // Write enough to exceed 1 KB
241
+ for (let i = 0; i < 30; i++) log.info('a'.repeat(50));
242
+ expect(fs.existsSync(`${logPath}.1`)).toBe(true);
243
+ });
244
+
245
+ test('size rotation: keeps at most N backups', () => {
246
+ const dir = tmpDir();
247
+ configureLogging(makeCfg(dir, { rotate: '1 KB', keep: 2 }));
248
+ const logPath = path.join(dir, 'logs', 'myscript.log');
249
+ const log = getLogger('rot');
250
+ for (let i = 0; i < 120; i++) log.info('a'.repeat(50));
251
+ expect(fs.existsSync(`${logPath}.1`)).toBe(true);
252
+ expect(fs.existsSync(`${logPath}.2`)).toBe(true);
253
+ expect(fs.existsSync(`${logPath}.3`)).toBe(false);
254
+ });
255
+
256
+ // ── _periodForDate ────────────────────────────────────────────────────────────
257
+
258
+ test('_periodForDate daily returns YYYY-MM-DD', () => {
259
+ const d = new Date('2024-03-15T12:00:00Z');
260
+ expect(_periodForDate(d, 'daily')).toBe('2024-03-15');
261
+ });
262
+
263
+ test('_periodForDate midnight returns YYYY-MM-DD', () => {
264
+ const d = new Date('2024-03-15T23:59:00Z');
265
+ expect(_periodForDate(d, 'midnight')).toBe('2024-03-15');
266
+ });
267
+
268
+ test('_periodForDate weekly returns ISO week string', () => {
269
+ const d = new Date('2024-03-15T12:00:00Z'); // ISO week 11 of 2024
270
+ const result = _periodForDate(d, 'weekly');
271
+ expect(result).toMatch(/^\d{4}-W\d+$/);
272
+ });
273
+
274
+ test('_periodForDate weekly: same week returns same string', () => {
275
+ const mon = new Date('2024-03-11T00:00:00Z');
276
+ const sun = new Date('2024-03-17T00:00:00Z');
277
+ expect(_periodForDate(mon, 'weekly')).toBe(_periodForDate(sun, 'weekly'));
278
+ });
279
+
280
+ test('_periodForDate weekly: adjacent weeks differ', () => {
281
+ const sun = new Date('2024-03-17T00:00:00Z');
282
+ const nextMon = new Date('2024-03-18T00:00:00Z');
283
+ expect(_periodForDate(sun, 'weekly')).not.toBe(_periodForDate(nextMon, 'weekly'));
284
+ });
285
+
286
+ // ── log dir fallback ──────────────────────────────────────────────────────────
287
+
288
+ test('falls back to ~/logs when package dir is not writable', () => {
289
+ const dir = tmpDir();
290
+ const logsDir = path.join(dir, 'logs');
291
+ // Create logs dir as unwritable
292
+ fs.mkdirSync(logsDir, { recursive: true });
293
+ try {
294
+ fs.chmodSync(logsDir, 0o444);
295
+ } catch {
296
+ // Skip on systems that don't support chmod (e.g. Windows CI)
297
+ return;
298
+ }
299
+ configureLogging(makeCfg(dir));
300
+ getLogger('test').info('fallback test');
301
+ const homeLog = path.join(os.homedir(), 'logs', 'myscript.log');
302
+ expect(fs.existsSync(homeLog)).toBe(true);
303
+ // Cleanup
304
+ try { fs.chmodSync(logsDir, 0o755); } catch {}
305
+ try { fs.unlinkSync(homeLog); } catch {}
306
+ });
307
+
308
+ // ── invalid rotate value ──────────────────────────────────────────────────────
309
+
310
+ test('throws on unrecognised rotate value', () => {
311
+ const dir = tmpDir();
312
+ expect(() => configureLogging(makeCfg(dir, { rotate: 'hourly' }))).toThrow('[config.logging]');
313
+ });
314
+
315
+ // ── extra fields ──────────────────────────────────────────────────────────────
316
+
317
+ test('extra fields appear under "extra" key in JSON', () => {
318
+ const dir = tmpDir();
319
+ configureLogging(makeCfg(dir));
320
+ getLogger('test').info('connected', { user_id: '42', region: 'eu-west' });
321
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
322
+ const record = JSON.parse(content);
323
+ expect(record.extra).toEqual({ user_id: '42', region: 'eu-west' });
324
+ expect(record.message).toBe('connected');
325
+ });
326
+
327
+ test('no "extra" key when no extra fields', () => {
328
+ const dir = tmpDir();
329
+ configureLogging(makeCfg(dir));
330
+ getLogger('test').info('plain message');
331
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
332
+ const record = JSON.parse(content);
333
+ expect(record.extra).toBeUndefined();
334
+ });
335
+
336
+ test('error key extracted from fields, not placed in extra', () => {
337
+ const dir = tmpDir();
338
+ configureLogging(makeCfg(dir));
339
+ getLogger('test').error('boom', { error: new Error('oops'), user_id: '42' });
340
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
341
+ const record = JSON.parse(content);
342
+ expect(typeof record.exc).toBe('string');
343
+ expect(record.exc).toContain('oops');
344
+ expect(record.extra).toEqual({ user_id: '42' });
345
+ expect(record.extra?.error).toBeUndefined();
346
+ });
347
+
348
+ test('error-only fields: no extra key', () => {
349
+ const dir = tmpDir();
350
+ configureLogging(makeCfg(dir));
351
+ getLogger('test').error('boom', { error: new Error('oops') });
352
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
353
+ const record = JSON.parse(content);
354
+ expect(record.extra).toBeUndefined();
355
+ expect(typeof record.exc).toBe('string');
356
+ });
357
+
358
+ test('extra string values are redacted', () => {
359
+ const dir = tmpDir();
360
+ configureLogging(makeCfg(dir));
361
+ getLogger('test').info('auth', { token: 'secret123', user: 'alice' });
362
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
363
+ expect(content).not.toContain('secret123');
364
+ expect(content).toContain('[REDACTED]');
365
+ expect(content).toContain('alice'); // non-sensitive field untouched
366
+ });
367
+
368
+ test('extra integer fields pass through unredacted', () => {
369
+ const dir = tmpDir();
370
+ configureLogging(makeCfg(dir));
371
+ getLogger('test').info('counts', { items: 99 });
372
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
373
+ const record = JSON.parse(content);
374
+ expect(record.extra?.items).toBe(99);
375
+ });
376
+
377
+ test('extra fields appear in console output', () => {
378
+ const dir = tmpDir();
379
+ const lines: string[] = [];
380
+ const stderrWrite = jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
381
+ lines.push(String(chunk));
382
+ return true;
383
+ });
384
+ configureLogging(makeCfg(dir, { level: 'info' }));
385
+ getLogger('test').info('connected', { user_id: '42' });
386
+ expect(lines.some(l => l.includes('user_id=42'))).toBe(true);
387
+ stderrWrite.mockRestore();
388
+ });