Skip to content

Commit 5e0f7c4

Browse files
fix(test): reject empty code get --out and strip code-file BOM (#34)
* fix(test): reject empty code get --out and strip code-file BOM When test code get --out receives an empty inline body, closeOutputFile left a zero-byte file with exit 0 — scripts and agents treated that as a successful download. Abort the sink, unlink any artifact, and surface VALIDATION_ERROR instead. Plan/steps JSON already strip a leading UTF-8 BOM from PowerShell 5.1 files; apply the same strip to --code-file reads so uploaded test source is not corrupted by an invisible U+FEFF prefix. * fix(test): rebase onto v0.2.0 and harden empty-code --out cleanup - Resolve rebase conflicts: keep atomic temp-file --out writes from main while rejecting empty inline code with VALIDATION_ERROR (exit 5) - abortOutputFile: wait for stream close before unlinking tmpPath - BOM test: use .py code-file (assertPythonCodeFile from v0.2.0) - CI: build before test + fileParallelism false (dist/ race flake)
1 parent 000c196 commit 5e0f7c4

4 files changed

Lines changed: 107 additions & 11 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ jobs:
6363
cache: 'npm'
6464

6565
- run: npm ci
66+
- run: npm run build
6667
- run: npm test
6768
env:
6869
CI: true

src/commands/test.test.ts

Lines changed: 74 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1591,22 +1591,66 @@ describe('runCodeGet', () => {
15911591
expect(leftovers).toEqual([]);
15921592
});
15931593

1594-
// Regression: the "no code generated yet" branch writes nothing but
1595-
// previously still closed (and thus truncated) the opened file.
1594+
it('--out (text mode) rejects empty inline code with VALIDATION_ERROR and leaves no artifact', async () => {
1595+
const { credentialsPath } = makeCreds();
1596+
const emptyCode: CliTestCode = { ...TEST_CODE_INLINE, code: '' };
1597+
const fetchImpl = makeFetch(() => ({ body: emptyCode }));
1598+
const dir = mkdtempSync(join(tmpdir(), 'cli-test-code-empty-out-'));
1599+
const target = join(dir, 'empty.ts');
1600+
await expect(
1601+
runCodeGet(
1602+
{
1603+
profile: 'default',
1604+
output: 'text',
1605+
debug: false,
1606+
testId: 'test_fe',
1607+
out: target,
1608+
},
1609+
{ credentialsPath, fetchImpl },
1610+
),
1611+
).rejects.toMatchObject({
1612+
code: 'VALIDATION_ERROR',
1613+
exitCode: 5,
1614+
details: expect.objectContaining({ field: 'out' }),
1615+
});
1616+
expect(existsSync(target)).toBe(false);
1617+
});
1618+
1619+
// Regression: empty inline code with --out must reject (exit 5) without
1620+
// truncating or replacing a pre-existing destination file.
15961621
it('--out: "no code generated yet" leaves a pre-existing file untouched', async () => {
15971622
const { credentialsPath } = makeCreds();
15981623
const dir = mkdtempSync(join(tmpdir(), 'cli-test-code-out-empty-'));
15991624
const target = join(dir, 'existing.ts');
16001625
writeFileSync(target, 'PRE-EXISTING CONTENT', 'utf8');
16011626
const fetchImpl = makeFetch(() => ({ body: { ...TEST_CODE_INLINE, code: '' } }));
1602-
await runCodeGet(
1603-
{ profile: 'default', output: 'text', debug: false, testId: 'test_fe', out: target },
1604-
{ credentialsPath, fetchImpl, stderr: () => undefined },
1605-
);
1627+
await expect(
1628+
runCodeGet(
1629+
{ profile: 'default', output: 'text', debug: false, testId: 'test_fe', out: target },
1630+
{ credentialsPath, fetchImpl, stderr: () => undefined },
1631+
),
1632+
).rejects.toMatchObject({
1633+
code: 'VALIDATION_ERROR',
1634+
exitCode: 5,
1635+
details: expect.objectContaining({ field: 'out' }),
1636+
});
16061637
expect(readFileSync(target, 'utf-8')).toBe('PRE-EXISTING CONTENT');
16071638
const leftovers = readdirSync(dir).filter(f => f !== 'existing.ts');
16081639
expect(leftovers).toEqual([]);
16091640
});
1641+
1642+
it('text mode without --out still hints on stderr when inline code is empty', async () => {
1643+
const { credentialsPath } = makeCreds();
1644+
const emptyCode: CliTestCode = { ...TEST_CODE_INLINE, code: '' };
1645+
const fetchImpl = makeFetch(() => ({ body: emptyCode }));
1646+
const stderr: string[] = [];
1647+
const got = await runCodeGet(
1648+
{ profile: 'default', output: 'text', debug: false, testId: 'test_fe' },
1649+
{ credentialsPath, fetchImpl, stderr: line => stderr.push(line) },
1650+
);
1651+
expect(got.code).toBe('');
1652+
expect(stderr.join('\n')).toContain('no code generated yet');
1653+
});
16101654
});
16111655

16121656
describe('runCodePut', () => {
@@ -1661,6 +1705,30 @@ describe('runCodePut', () => {
16611705
expect(sent.headers.get('content-type')).toBe('application/json');
16621706
});
16631707

1708+
it('strips a UTF-8 BOM from --code-file before uploading (Windows PowerShell 5.1 default)', async () => {
1709+
const { credentialsPath } = makeCreds();
1710+
const dir = mkdtempSync(join(tmpdir(), 'cli-p4-bom-'));
1711+
const codeFile = join(dir, 'updated.py');
1712+
writeFileSync(codeFile, '\uFEFF' + 'updated body', 'utf8');
1713+
let seenBody: unknown;
1714+
const fetchImpl = makeFetch((_url, init) => {
1715+
seenBody = init.body ? JSON.parse(init.body as string) : undefined;
1716+
return { body: SAMPLE_RESPONSE };
1717+
});
1718+
await runCodePut(
1719+
{
1720+
profile: 'default',
1721+
output: 'json',
1722+
debug: false,
1723+
testId: 'test_alpha',
1724+
codeFile,
1725+
expectedVersion: 'v3',
1726+
},
1727+
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: () => undefined },
1728+
);
1729+
expect(seenBody).toEqual({ code: 'updated body' });
1730+
});
1731+
16641732
it('forwards --language in the body when set', async () => {
16651733
const { credentialsPath } = makeCreds();
16661734
const codeFile = writeCodeFile('print("hi")');

src/commands/test.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -911,7 +911,7 @@ function readCodeFileGuarded(path: string): string {
911911

912912
function readCodeFile(path: string): string {
913913
try {
914-
return readFileSync(resolveAbsolute(path), 'utf8');
914+
return stripBom(readFileSync(resolveAbsolute(path), 'utf8'));
915915
} catch (err) {
916916
const code = (err as NodeJS.ErrnoException).code;
917917
if (code === 'ENOENT') {
@@ -3292,7 +3292,7 @@ export async function runCodeGet(opts: CodeGetOptions, deps: TestDeps = {}): Pro
32923292
return code;
32933293
}
32943294

3295-
const fileSink = opts.out !== undefined ? openOutputFile(opts.out) : null;
3295+
let fileSink = opts.out !== undefined ? openOutputFile(opts.out) : null;
32963296
const out = fileSink ? makeFileOutput(opts.output, fileSink) : makeOutput(opts.output, deps);
32973297
const client = makeClient(opts, deps);
32983298

@@ -3317,9 +3317,20 @@ export async function runCodeGet(opts: CodeGetOptions, deps: TestDeps = {}): Pro
33173317
} else if (code.code === '' || code.code === null) {
33183318
// P2-10: draft test with no code yet — empty body would produce
33193319
// silent empty stdout. Print a friendly hint to stderr instead so
3320-
// the operator knows what happened, and keep exit 0. Nothing was
3321-
// written, so the temp file is discarded below without touching
3322-
// a pre-existing `--out` file.
3320+
// the operator knows what happened, and keep exit 0 when no `--out`.
3321+
//
3322+
// With `--out`, refuse to leave a zero-byte artifact behind: agents
3323+
// and scripts that check file size would otherwise treat exit 0 as
3324+
// a successful download. Discard the temp sink without touching a
3325+
// pre-existing destination file.
3326+
if (fileSink) {
3327+
await abortOutputFile(fileSink);
3328+
fileSink = null;
3329+
throw localValidationError(
3330+
'out',
3331+
'test has no generated code yet — run the test first (refusing to write an empty --out file)',
3332+
);
3333+
}
33233334
const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
33243335
stderrFn('(no code generated yet — run the test first)');
33253336
} else {
@@ -7997,6 +8008,19 @@ async function closeOutputFile(sink: FileSink, commit: boolean): Promise<void> {
79978008
await rename(sink.tmpPath, sink.path);
79988009
}
79998010

8011+
/** Tear down an opened `--out` sink without leaving a zero-byte artifact. */
8012+
async function abortOutputFile(sink: FileSink): Promise<void> {
8013+
await new Promise<void>(resolve => {
8014+
if (sink.stream.destroyed) {
8015+
resolve();
8016+
return;
8017+
}
8018+
sink.stream.once('close', () => resolve());
8019+
sink.stream.destroy();
8020+
});
8021+
await unlink(sink.tmpPath).catch(() => undefined);
8022+
}
8023+
80008024
/** A presigned `code` body is any `https://` URL — never anything else. */
80018025
export function isPresignedCodeUrl(code: string): boolean {
80028026
return code.startsWith('https://');

vitest.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ export default defineConfig({
44
test: {
55
include: ['src/**/*.{test,spec}.ts', 'test/**/*.{test,spec}.ts'],
66
exclude: ['test/dev-e2e/**', 'test/e2e/**', 'node_modules/**', 'dist/**'],
7+
// Subprocess/snapshot suites each run `npm run build` in beforeAll; parallel
8+
// file workers can race on dist/ and produce a stale binary (exit 1 vs 5 flakes).
9+
fileParallelism: false,
710
coverage: {
811
provider: 'v8',
912
reporter: ['text', 'text-summary', 'json-summary', 'html'],

0 commit comments

Comments
 (0)