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.
- package/dist/loader.js +6 -0
- package/dist/loader.js.map +1 -1
- package/dist/logging_setup.d.ts +26 -5
- package/dist/logging_setup.d.ts.map +1 -1
- package/dist/logging_setup.js +227 -24
- package/dist/logging_setup.js.map +1 -1
- package/dist/models.d.ts +1 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +26 -0
- package/dist/parser.js.map +1 -1
- package/dist/serve.js +18 -2
- package/dist/serve.js.map +1 -1
- package/package.json +4 -4
- package/src/loader.ts +6 -0
- package/src/logging_setup.ts +253 -24
- package/src/models.ts +1 -0
- package/src/parser.ts +27 -0
- package/src/serve.ts +19 -2
- package/tests/test_loader.test.ts +18 -4
- package/tests/test_logging_setup.test.ts +40 -6
- package/tests/test_run_summary.test.ts +204 -0
|
@@ -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
|
|
157
|
-
keep
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
});
|