runspec-node 0.11.1 → 0.13.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 +16 -1
- package/dist/loader.js.map +1 -1
- package/dist/logging_setup.d.ts +21 -3
- package/dist/logging_setup.d.ts.map +1 -1
- package/dist/logging_setup.js +210 -12
- package/dist/logging_setup.js.map +1 -1
- package/dist/models.d.ts +2 -1
- package/dist/models.d.ts.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +41 -4
- package/dist/parser.js.map +1 -1
- package/dist/serve.js +19 -3
- package/dist/serve.js.map +1 -1
- package/package.json +5 -5
- package/src/loader.ts +14 -1
- package/src/logging_setup.ts +240 -12
- package/src/models.ts +2 -1
- package/src/parser.ts +42 -4
- package/src/serve.ts +20 -3
- package/tests/test_integration.test.ts +1 -1
- package/tests/test_loader.test.ts +18 -4
- package/tests/test_logging_setup.test.ts +10 -3
- package/tests/test_parser.test.ts +67 -0
- package/tests/test_run_summary.test.ts +204 -0
- package/tsconfig.json +2 -1
package/src/serve.ts
CHANGED
|
@@ -139,13 +139,26 @@ function handleToolsCall(
|
|
|
139
139
|
const runspecEnv = argsToRunspecEnv(args, toolArgSpecs);
|
|
140
140
|
const env = { ...process.env, RUNSPEC_AGENT: '1', ...runspecEnv };
|
|
141
141
|
|
|
142
|
+
const start = process.hrtime.bigint();
|
|
142
143
|
const result = spawnSync(cmd, argv, { encoding: 'utf-8', env });
|
|
144
|
+
const durationMs = Number((process.hrtime.bigint() - start) / 1_000_000n);
|
|
145
|
+
|
|
146
|
+
// _meta is the MCP-standard extension point; clients that don't understand
|
|
147
|
+
// it ignore the block. Same envelope on success and failure so callers can
|
|
148
|
+
// rely on it being present.
|
|
149
|
+
const meta = {
|
|
150
|
+
runspec: { tool: name, duration_ms: durationMs, exit_code: result.status ?? null },
|
|
151
|
+
};
|
|
143
152
|
|
|
144
153
|
if (result.status === 0) {
|
|
145
154
|
return {
|
|
146
155
|
jsonrpc: '2.0',
|
|
147
156
|
id: reqId,
|
|
148
|
-
result: {
|
|
157
|
+
result: {
|
|
158
|
+
content: [{ type: 'text', text: result.stdout ?? '' }],
|
|
159
|
+
isError: false,
|
|
160
|
+
_meta: meta,
|
|
161
|
+
},
|
|
149
162
|
};
|
|
150
163
|
}
|
|
151
164
|
|
|
@@ -156,7 +169,11 @@ function handleToolsCall(
|
|
|
156
169
|
return {
|
|
157
170
|
jsonrpc: '2.0',
|
|
158
171
|
id: reqId,
|
|
159
|
-
result: {
|
|
172
|
+
result: {
|
|
173
|
+
content: [{ type: 'text', text: parts.join('\n') }],
|
|
174
|
+
isError: true,
|
|
175
|
+
_meta: meta,
|
|
176
|
+
},
|
|
160
177
|
};
|
|
161
178
|
}
|
|
162
179
|
|
|
@@ -192,7 +209,7 @@ function argsToRunspecEnv(args: Record<string, unknown>, argSpecs: Record<string
|
|
|
192
209
|
if (value === null || value === undefined) value = s['default'];
|
|
193
210
|
if (value === null || value === undefined) continue;
|
|
194
211
|
|
|
195
|
-
const envKey = '
|
|
212
|
+
const envKey = 'RUNSPEC_ARG_' + argName.toUpperCase().replace(/-/g, '_').replace(/\./g, '_');
|
|
196
213
|
const argType = (s['type'] as string) ?? 'str';
|
|
197
214
|
|
|
198
215
|
if (argType === 'flag' || argType === 'bool') {
|
|
@@ -123,7 +123,7 @@ describe('complex.toml', () => {
|
|
|
123
123
|
const raw = loadRaw(COMPLEX);
|
|
124
124
|
const inferred = inferScript(raw.runnables['pipeline'], raw.config.autonomyDefault);
|
|
125
125
|
const run = inferred.commands['run'];
|
|
126
|
-
expect(run.args['api-key'].env).
|
|
126
|
+
expect(run.args['api-key'].env).toEqual(['PIPELINE_API_KEY']);
|
|
127
127
|
expect(run.args['api-key'].autonomy).toBe('manual');
|
|
128
128
|
});
|
|
129
129
|
|
|
@@ -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,
|
|
@@ -365,7 +372,7 @@ test('_periodForDate weekly: adjacent weeks differ', () => {
|
|
|
365
372
|
|
|
366
373
|
// ── log dir fallback ──────────────────────────────────────────────────────────
|
|
367
374
|
|
|
368
|
-
test('falls back to ~/logs when
|
|
375
|
+
test('falls back to ~/logs when the project root is not writable', () => {
|
|
369
376
|
const dir = tmpDir();
|
|
370
377
|
const logsDir = path.join(dir, 'logs');
|
|
371
378
|
// Create logs dir as unwritable
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { parse } from '../src/parser';
|
|
5
|
+
|
|
6
|
+
function makeTmpConfig(toml: string): string {
|
|
7
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'runspec-parser-test-'));
|
|
8
|
+
const configPath = path.join(dir, 'runspec.toml');
|
|
9
|
+
fs.writeFileSync(configPath, toml);
|
|
10
|
+
return configPath;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const QUALITY_TOML = `
|
|
14
|
+
[compress]
|
|
15
|
+
[compress.args]
|
|
16
|
+
quality = {default = 85, range = [1, 100]}
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
const QUALITY_ALIAS_TOML = `
|
|
20
|
+
[compress]
|
|
21
|
+
[compress.args]
|
|
22
|
+
quality = {default = 85, range = [1, 100], env = "CI_QUALITY"}
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
// ── env resolution tier ───────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
describe('env resolution', () => {
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
delete process.env['RUNSPEC_ARG_QUALITY'];
|
|
30
|
+
delete process.env['CI_QUALITY'];
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('RUNSPEC_ARG_* provides auto default when no CLI arg', () => {
|
|
34
|
+
const configPath = makeTmpConfig(QUALITY_TOML);
|
|
35
|
+
process.env['RUNSPEC_ARG_QUALITY'] = '95';
|
|
36
|
+
const args = parse({ scriptName: 'compress', argv: [], configPath });
|
|
37
|
+
expect(args['quality']).toBe(95);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('CLI arg wins over RUNSPEC_ARG_*', () => {
|
|
41
|
+
const configPath = makeTmpConfig(QUALITY_TOML);
|
|
42
|
+
process.env['RUNSPEC_ARG_QUALITY'] = '95';
|
|
43
|
+
const args = parse({ scriptName: 'compress', argv: ['--quality', '80'], configPath });
|
|
44
|
+
expect(args['quality']).toBe(80);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('developer alias fallback when RUNSPEC_ARG_* not set', () => {
|
|
48
|
+
const configPath = makeTmpConfig(QUALITY_ALIAS_TOML);
|
|
49
|
+
process.env['CI_QUALITY'] = '70';
|
|
50
|
+
const args = parse({ scriptName: 'compress', argv: [], configPath });
|
|
51
|
+
expect(args['quality']).toBe(70);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('RUNSPEC_ARG_* wins over developer alias', () => {
|
|
55
|
+
const configPath = makeTmpConfig(QUALITY_ALIAS_TOML);
|
|
56
|
+
process.env['RUNSPEC_ARG_QUALITY'] = '95';
|
|
57
|
+
process.env['CI_QUALITY'] = '70';
|
|
58
|
+
const args = parse({ scriptName: 'compress', argv: [], configPath });
|
|
59
|
+
expect(args['quality']).toBe(95);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('no env vars set falls back to spec default', () => {
|
|
63
|
+
const configPath = makeTmpConfig(QUALITY_TOML);
|
|
64
|
+
const args = parse({ scriptName: 'compress', argv: [], configPath });
|
|
65
|
+
expect(args['quality']).toBe(85);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -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_ARG_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_ARG_NO_SUMMARY=1 disables summary', () => {
|
|
172
|
+
process.env['RUNSPEC_ARG_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
|
+
});
|
package/tsconfig.json
CHANGED