Skip to content

Commit ebe6f93

Browse files
Copilotmrlubos
authored andcommitted
fix: surface output.postProcess execution errors instead of swallowing them
Agent-Logs-Url: https://github.com/hey-api/openapi-ts/sessions/d85588ef-7ad9-4ab7-a857-fbbe719b294f
1 parent b395f94 commit ebe6f93

2 files changed

Lines changed: 158 additions & 1 deletion

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { sync } from 'cross-spawn';
2+
import { vi } from 'vitest';
3+
4+
import { postprocessOutput } from '../postprocess';
5+
6+
vi.mock('cross-spawn');
7+
8+
const mockSync = vi.mocked(sync);
9+
10+
const baseConfig = {
11+
path: '/output',
12+
postProcess: [],
13+
};
14+
15+
const noopPostProcessors = {};
16+
17+
describe('postprocessOutput', () => {
18+
beforeEach(() => {
19+
vi.clearAllMocks();
20+
});
21+
22+
it('should not call sync when postProcess is empty', () => {
23+
postprocessOutput(baseConfig, noopPostProcessors, '');
24+
expect(mockSync).not.toHaveBeenCalled();
25+
});
26+
27+
it('should call sync with command and resolved args', () => {
28+
mockSync.mockReturnValue({ error: undefined, status: 0 } as any);
29+
30+
postprocessOutput(
31+
{ ...baseConfig, postProcess: [{ args: ['fmt', '{{path}}'], command: 'dprint' }] },
32+
noopPostProcessors,
33+
'',
34+
);
35+
36+
expect(mockSync).toHaveBeenCalledWith('dprint', ['fmt', '/output']);
37+
});
38+
39+
it('should replace {{path}} placeholder in args', () => {
40+
mockSync.mockReturnValue({ error: undefined, status: 0 } as any);
41+
42+
postprocessOutput(
43+
{ path: '/my/output', postProcess: [{ args: ['{{path}}', '--write'], command: 'prettier' }] },
44+
noopPostProcessors,
45+
'',
46+
);
47+
48+
expect(mockSync).toHaveBeenCalledWith('prettier', ['/my/output', '--write']);
49+
});
50+
51+
it('should throw when the process fails to spawn (e.g., ENOENT)', () => {
52+
const spawnError = new Error('spawnSync oxfmt ENOENT');
53+
mockSync.mockReturnValue({ error: spawnError, status: null } as any);
54+
55+
expect(() =>
56+
postprocessOutput(
57+
{ ...baseConfig, postProcess: [{ args: ['{{path}}'], command: 'oxfmt' }] },
58+
noopPostProcessors,
59+
'',
60+
),
61+
).toThrow('Post-processor "oxfmt" failed to run: spawnSync oxfmt ENOENT');
62+
});
63+
64+
it('should throw with a custom name when the process fails to spawn', () => {
65+
const spawnError = new Error('spawnSync my-formatter ENOENT');
66+
mockSync.mockReturnValue({ error: spawnError, status: null } as any);
67+
68+
expect(() =>
69+
postprocessOutput(
70+
{
71+
...baseConfig,
72+
postProcess: [{ args: ['{{path}}'], command: 'my-formatter', name: 'My Formatter' }],
73+
},
74+
noopPostProcessors,
75+
'',
76+
),
77+
).toThrow('Post-processor "My Formatter" failed to run: spawnSync my-formatter ENOENT');
78+
});
79+
80+
it('should throw when the process exits with a non-zero status code', () => {
81+
mockSync.mockReturnValue({ error: undefined, status: 1, stderr: Buffer.from('') } as any);
82+
83+
expect(() =>
84+
postprocessOutput(
85+
{ ...baseConfig, postProcess: [{ args: ['{{path}}'], command: 'prettier' }] },
86+
noopPostProcessors,
87+
'',
88+
),
89+
).toThrow('Post-processor "prettier" exited with code 1');
90+
});
91+
92+
it('should include stderr output in error message when process fails', () => {
93+
mockSync.mockReturnValue({
94+
error: undefined,
95+
status: 2,
96+
stderr: Buffer.from('error: file not found'),
97+
} as any);
98+
99+
expect(() =>
100+
postprocessOutput(
101+
{ ...baseConfig, postProcess: [{ args: ['{{path}}'], command: 'biome' }] },
102+
noopPostProcessors,
103+
'',
104+
),
105+
).toThrow('Post-processor "biome" exited with code 2:\nerror: file not found');
106+
});
107+
108+
it('should skip unknown string preset processors', () => {
109+
postprocessOutput({ ...baseConfig, postProcess: ['unknown-preset'] }, noopPostProcessors, '');
110+
expect(mockSync).not.toHaveBeenCalled();
111+
});
112+
113+
it('should resolve and run string preset processors', () => {
114+
mockSync.mockReturnValue({ error: undefined, status: 0 } as any);
115+
116+
const processors = {
117+
prettier: { args: ['--write', '{{path}}'], command: 'prettier', name: 'Prettier' },
118+
};
119+
120+
postprocessOutput({ ...baseConfig, postProcess: ['prettier'] }, processors, '');
121+
122+
expect(mockSync).toHaveBeenCalledWith('prettier', ['--write', '/output']);
123+
});
124+
125+
it('should stop processing and throw on first failure', () => {
126+
const spawnError = new Error('ENOENT');
127+
mockSync.mockReturnValue({ error: spawnError, status: null } as any);
128+
129+
expect(() =>
130+
postprocessOutput(
131+
{
132+
...baseConfig,
133+
postProcess: [
134+
{ args: ['{{path}}'], command: 'first' },
135+
{ args: ['{{path}}'], command: 'second' },
136+
],
137+
},
138+
noopPostProcessors,
139+
'',
140+
),
141+
).toThrow('Post-processor "first" failed to run: ENOENT');
142+
143+
expect(mockSync).toHaveBeenCalledTimes(1);
144+
});
145+
});

packages/shared/src/config/output/postprocess.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@ export function postprocessOutput(
6060
const args = resolved.args.map((arg) => arg.replace('{{path}}', config.path));
6161

6262
console.log(`${jobPrefix}🧹 Running ${colors.cyanBright(name)}`);
63-
sync(resolved.command, args);
63+
const result = sync(resolved.command, args);
64+
65+
if (result.error) {
66+
throw new Error(`Post-processor "${name}" failed to run: ${result.error.message}`);
67+
}
68+
69+
if (result.status !== 0) {
70+
const stderr = result.stderr?.toString().trim();
71+
const message = stderr
72+
? `Post-processor "${name}" exited with code ${result.status}:\n${stderr}`
73+
: `Post-processor "${name}" exited with code ${result.status}`;
74+
throw new Error(message);
75+
}
6476
}
6577
}

0 commit comments

Comments
 (0)