runspec-node 0.11.0 → 0.12.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.
@@ -145,7 +145,7 @@ test('normalises [config.logging] with defaults', () => {
145
145
  description = "hi"
146
146
  `);
147
147
  const raw = loadRaw(file);
148
- expect(raw.config.logging).toEqual({ rotate: 'midnight', keep: 7 });
148
+ expect(raw.config.logging).toEqual({ rotate: 'midnight', keep: 7, summary: true });
149
149
  });
150
150
 
151
151
  test('normalises [config.logging] all fields', () => {
@@ -153,14 +153,28 @@ test('normalises [config.logging] all fields', () => {
153
153
  const file = path.join(dir, 'runspec.toml');
154
154
  fs.writeFileSync(file, `
155
155
  [config.logging]
156
- rotate = "10 MB"
157
- keep = 3
156
+ rotate = "10 MB"
157
+ keep = 3
158
+ summary = false
158
159
 
159
160
  [greet]
160
161
  description = "hi"
161
162
  `);
162
163
  const raw = loadRaw(file);
163
- expect(raw.config.logging).toEqual({ rotate: '10 MB', keep: 3 });
164
+ expect(raw.config.logging).toEqual({ rotate: '10 MB', keep: 3, summary: false });
165
+ });
166
+
167
+ test('[config.logging] summary defaults to true when omitted', () => {
168
+ const dir = tmpDir();
169
+ const file = path.join(dir, 'runspec.toml');
170
+ fs.writeFileSync(file, `
171
+ [config.logging]
172
+ rotate = "midnight"
173
+
174
+ [greet]
175
+ description = "hi"
176
+ `);
177
+ expect(loadRaw(file).config.logging?.summary).toBe(true);
164
178
  });
165
179
 
166
180
  test('logging is undefined when section absent', () => {
@@ -10,13 +10,20 @@ import {
10
10
  } from '../src/logging_setup';
11
11
 
12
12
  function tmpDir(): string {
13
- return fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-log-test-'));
13
+ // Each test dir is its own project root so resolveLogDir lands inside it.
14
+ // Without a package.json the walk-up would escape to a real project root
15
+ // and write logs to a shared location — drop a marker file to anchor it.
16
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-log-test-'));
17
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"test","version":"0.0.0"}');
18
+ return d;
14
19
  }
15
20
 
16
21
  function makeCfg(dir: string, overrides: Record<string, unknown> = {}): Parameters<typeof configureLogging>[0] {
17
22
  const { debug, ...logOverrides } = overrides as { debug?: boolean } & Record<string, unknown>;
18
23
  return {
19
- logCfg: { rotate: 'midnight', keep: 7, ...logOverrides },
24
+ // summary defaults off in tests so we don't litter atexit emissions across
25
+ // unrelated assertions; the run-summary suite enables it explicitly.
26
+ logCfg: { rotate: 'midnight', keep: 7, summary: false, ...logOverrides },
20
27
  runnableName: 'myscript',
21
28
  configPath: path.join(dir, 'runspec.toml'),
22
29
  debug,
@@ -89,18 +96,30 @@ test('log file contains JSON lines', () => {
89
96
  expect(typeof parsed.ts).toBe('string');
90
97
  });
91
98
 
92
- test('file handler captures DEBUG even when console floor is INFO', () => {
99
+ test('file handler omits DEBUG without the debug flag', () => {
93
100
  const dir = tmpDir();
94
101
  configureLogging(makeCfg(dir));
102
+ const log = getLogger('test');
103
+ log.debug('low level detail');
104
+ log.info('info detail');
105
+ const lines = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim().split('\n');
106
+ expect(lines.some(l => JSON.parse(l).level === 'DEBUG')).toBe(false);
107
+ expect(lines.some(l => JSON.parse(l).level === 'INFO')).toBe(true);
108
+ });
109
+
110
+ test('file handler captures DEBUG when debug flag is set', () => {
111
+ const dir = tmpDir();
112
+ configureLogging(makeCfg(dir, { debug: true }));
95
113
  getLogger('test').debug('low level detail');
96
114
  const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim();
97
115
  const parsed = JSON.parse(content);
98
116
  expect(parsed.level).toBe('DEBUG');
117
+ expect(parsed.message).toBe('low level detail');
99
118
  });
100
119
 
101
- test('log file captures all levels', () => {
120
+ test('log file captures all levels when debug flag is set', () => {
102
121
  const dir = tmpDir();
103
- configureLogging(makeCfg(dir));
122
+ configureLogging(makeCfg(dir, { debug: true }));
104
123
  const log = getLogger('test');
105
124
  log.debug('a');
106
125
  log.info('b');
@@ -113,6 +132,21 @@ test('log file captures all levels', () => {
113
132
  expect(JSON.parse(lines[4]).level).toBe('CRITICAL');
114
133
  });
115
134
 
135
+ test('log file captures INFO and above without debug flag', () => {
136
+ const dir = tmpDir();
137
+ configureLogging(makeCfg(dir));
138
+ const log = getLogger('test');
139
+ log.debug('a');
140
+ log.info('b');
141
+ log.warning('c');
142
+ log.error('d');
143
+ log.critical('e');
144
+ const lines = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8').trim().split('\n');
145
+ expect(lines).toHaveLength(4);
146
+ expect(JSON.parse(lines[0]).level).toBe('INFO');
147
+ expect(JSON.parse(lines[3]).level).toBe('CRITICAL');
148
+ });
149
+
116
150
  test('error field included when Error passed', () => {
117
151
  const dir = tmpDir();
118
152
  configureLogging(makeCfg(dir));
@@ -338,7 +372,7 @@ test('_periodForDate weekly: adjacent weeks differ', () => {
338
372
 
339
373
  // ── log dir fallback ──────────────────────────────────────────────────────────
340
374
 
341
- test('falls back to ~/logs when package dir is not writable', () => {
375
+ test('falls back to ~/logs when the project root is not writable', () => {
342
376
  const dir = tmpDir();
343
377
  const logsDir = path.join(dir, 'logs');
344
378
  // Create logs dir as unwritable
@@ -0,0 +1,204 @@
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
+ emitRunSummary,
8
+ _resetForTest,
9
+ RUN_SUMMARY_LOGGER,
10
+ } from '../src/logging_setup';
11
+
12
+ function tmpDir(): string {
13
+ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-summary-test-'));
14
+ fs.writeFileSync(path.join(d, 'package.json'), '{"name":"test","version":"0.0.0"}');
15
+ return d;
16
+ }
17
+
18
+ function makeCfg(dir: string, overrides: Record<string, unknown> = {}): Parameters<typeof configureLogging>[0] {
19
+ const { debug, noSummary, autonomy, agent, commandPath, ...logOverrides } = overrides as {
20
+ debug?: boolean; noSummary?: boolean; autonomy?: string; agent?: boolean; commandPath?: string[];
21
+ } & Record<string, unknown>;
22
+ return {
23
+ logCfg: { rotate: 'midnight', keep: 7, summary: true, ...logOverrides },
24
+ runnableName: 'myscript',
25
+ configPath: path.join(dir, 'runspec.toml'),
26
+ debug,
27
+ noSummary,
28
+ autonomy,
29
+ agent,
30
+ commandPath,
31
+ };
32
+ }
33
+
34
+ function captureStderr() {
35
+ const lines: string[] = [];
36
+ const spy = jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => {
37
+ lines.push(String(chunk));
38
+ return true;
39
+ });
40
+ return { lines, restore() { spy.mockRestore(); } };
41
+ }
42
+
43
+ beforeEach(() => {
44
+ _resetForTest();
45
+ delete process.env['RUNSPEC_NO_SUMMARY'];
46
+ });
47
+
48
+ afterAll(() => {
49
+ // Final cleanup so jest's own process-exit doesn't trigger a summary
50
+ // emit against a stale state pointing at a tmp dir.
51
+ _resetForTest();
52
+ });
53
+
54
+ // ── counter ──────────────────────────────────────────────────────────────────
55
+
56
+ test('counter increments per level', () => {
57
+ const dir = tmpDir();
58
+ configureLogging(makeCfg(dir));
59
+ const log = getLogger('test.counter');
60
+ log.info('one');
61
+ log.info('two');
62
+ log.warning('careful');
63
+ log.error('broke');
64
+ const cap = captureStderr();
65
+ emitRunSummary();
66
+ cap.restore();
67
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8');
68
+ const summary = content.trim().split('\n').map(l => JSON.parse(l)).find(o => o.logger === RUN_SUMMARY_LOGGER);
69
+ expect(summary.extra.events).toEqual({ DEBUG: 0, INFO: 2, WARNING: 1, ERROR: 1, CRITICAL: 0 });
70
+ });
71
+
72
+ test('counter ignores the summary logger itself', () => {
73
+ const dir = tmpDir();
74
+ configureLogging(makeCfg(dir));
75
+ // Calling the summary logger directly must not inflate counts.
76
+ getLogger(RUN_SUMMARY_LOGGER).info('should not count');
77
+ const cap = captureStderr();
78
+ emitRunSummary();
79
+ cap.restore();
80
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8');
81
+ const lines = content.trim().split('\n').map(l => JSON.parse(l));
82
+ const summary = lines.find(o => o.logger === RUN_SUMMARY_LOGGER && o.extra?.event === 'run_summary');
83
+ expect(summary.extra.events).toEqual({ DEBUG: 0, INFO: 0, WARNING: 0, ERROR: 0, CRITICAL: 0 });
84
+ });
85
+
86
+ // ── emit ─────────────────────────────────────────────────────────────────────
87
+
88
+ test('summary writes one record to the audit file', () => {
89
+ const dir = tmpDir();
90
+ configureLogging(makeCfg(dir, { autonomy: 'confirm', agent: false }));
91
+ getLogger('test.emit').info('did work');
92
+ getLogger('test.emit').warning('a warning');
93
+ const cap = captureStderr();
94
+ emitRunSummary();
95
+ cap.restore();
96
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8');
97
+ const summaries = content.trim().split('\n').map(l => JSON.parse(l)).filter(o => o.logger === RUN_SUMMARY_LOGGER);
98
+ expect(summaries).toHaveLength(1);
99
+ const s = summaries[0];
100
+ expect(s.message).toBe('run completed');
101
+ expect(s.extra.event).toBe('run_summary');
102
+ expect(s.extra.runnable).toBe('myscript');
103
+ expect(s.extra.events.INFO).toBe(1);
104
+ expect(s.extra.events.WARNING).toBe(1);
105
+ expect(s.extra.exit_code).toBe(0);
106
+ expect(s.extra.exception).toBeNull();
107
+ expect(typeof s.extra.duration_ms).toBe('number');
108
+ });
109
+
110
+ test('summary writes a one-line summary to stderr', () => {
111
+ const dir = tmpDir();
112
+ configureLogging(makeCfg(dir));
113
+ getLogger('test.stderr').info('ran');
114
+ const cap = captureStderr();
115
+ emitRunSummary();
116
+ cap.restore();
117
+ const joined = cap.lines.join('');
118
+ expect(joined).toContain('runspec: myscript completed');
119
+ expect(joined).toContain('events');
120
+ });
121
+
122
+ test('summary is not echoed to stdout or stderr via console handlers', () => {
123
+ const dir = tmpDir();
124
+ const stdoutLines: string[] = [];
125
+ const stderrLines: string[] = [];
126
+ const o = jest.spyOn(process.stdout, 'write').mockImplementation((chunk) => { stdoutLines.push(String(chunk)); return true; });
127
+ const e = jest.spyOn(process.stderr, 'write').mockImplementation((chunk) => { stderrLines.push(String(chunk)); return true; });
128
+ configureLogging(makeCfg(dir));
129
+ getLogger('test.console').info('hi');
130
+ emitRunSummary();
131
+ o.mockRestore();
132
+ e.mockRestore();
133
+ // The JSON form of the summary record must not appear on either stream.
134
+ expect(stdoutLines.join('')).not.toContain('"logger":"runspec.runsummary"');
135
+ expect(stderrLines.join('').match(/"logger":"runspec.runsummary"/)).toBeNull();
136
+ });
137
+
138
+ test('summary emit is idempotent', () => {
139
+ const dir = tmpDir();
140
+ configureLogging(makeCfg(dir));
141
+ const cap = captureStderr();
142
+ emitRunSummary();
143
+ emitRunSummary();
144
+ cap.restore();
145
+ const content = fs.readFileSync(path.join(dir, 'logs', 'myscript.log'), 'utf-8');
146
+ const summaries = content.trim().split('\n').map(l => JSON.parse(l)).filter(o => o.logger === RUN_SUMMARY_LOGGER);
147
+ expect(summaries).toHaveLength(1);
148
+ });
149
+
150
+ // ── disable switches ─────────────────────────────────────────────────────────
151
+
152
+ test('noSummary option disables summary', () => {
153
+ const dir = tmpDir();
154
+ configureLogging(makeCfg(dir, { noSummary: true }));
155
+ const cap = captureStderr();
156
+ emitRunSummary(); // no-op because state is null
157
+ cap.restore();
158
+ expect(cap.lines.join('')).not.toContain('runspec: ');
159
+ expect(fs.existsSync(path.join(dir, 'logs', 'myscript.log'))).toBe(false);
160
+ });
161
+
162
+ test('summary=false in config disables summary', () => {
163
+ const dir = tmpDir();
164
+ configureLogging(makeCfg(dir, { summary: false }));
165
+ const cap = captureStderr();
166
+ emitRunSummary();
167
+ cap.restore();
168
+ expect(cap.lines.join('')).not.toContain('runspec: ');
169
+ });
170
+
171
+ test('RUNSPEC_NO_SUMMARY=1 disables summary', () => {
172
+ process.env['RUNSPEC_NO_SUMMARY'] = '1';
173
+ const dir = tmpDir();
174
+ configureLogging(makeCfg(dir));
175
+ const cap = captureStderr();
176
+ emitRunSummary();
177
+ cap.restore();
178
+ expect(cap.lines.join('')).not.toContain('runspec: ');
179
+ });
180
+
181
+ // ── stderr line shape ────────────────────────────────────────────────────────
182
+
183
+ test('success line uses singular "warning" for 1 and plural "errors" for 0', () => {
184
+ const dir = tmpDir();
185
+ configureLogging(makeCfg(dir));
186
+ getLogger('t').warning('one');
187
+ const cap = captureStderr();
188
+ emitRunSummary();
189
+ cap.restore();
190
+ const joined = cap.lines.join('');
191
+ expect(joined).toContain('1 warning,');
192
+ expect(joined).toContain('0 errors)');
193
+ });
194
+
195
+ test('success line uses plural for 2 warnings', () => {
196
+ const dir = tmpDir();
197
+ configureLogging(makeCfg(dir));
198
+ getLogger('t').warning('a');
199
+ getLogger('t').warning('b');
200
+ const cap = captureStderr();
201
+ emitRunSummary();
202
+ cap.restore();
203
+ expect(cap.lines.join('')).toContain('2 warnings,');
204
+ });