Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 74 additions & 6 deletions src/commands/test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1564,6 +1564,44 @@ describe('runCodeGet', () => {
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
});

it('--out (text mode) rejects empty inline code with VALIDATION_ERROR and leaves no artifact', async () => {
const { credentialsPath } = makeCreds();
const emptyCode: CliTestCode = { ...TEST_CODE_INLINE, code: '' };
const fetchImpl = makeFetch(() => ({ body: emptyCode }));
const dir = mkdtempSync(join(tmpdir(), 'cli-test-code-empty-out-'));
const target = join(dir, 'empty.ts');
await expect(
runCodeGet(
{
profile: 'default',
output: 'text',
debug: false,
testId: 'test_fe',
out: target,
},
{ credentialsPath, fetchImpl },
),
).rejects.toMatchObject({
code: 'VALIDATION_ERROR',
exitCode: 5,
details: expect.objectContaining({ field: 'out' }),
});
expect(existsSync(target)).toBe(false);
});

it('text mode without --out still hints on stderr when inline code is empty', async () => {
const { credentialsPath } = makeCreds();
const emptyCode: CliTestCode = { ...TEST_CODE_INLINE, code: '' };
const fetchImpl = makeFetch(() => ({ body: emptyCode }));
const stderr: string[] = [];
const got = await runCodeGet(
{ profile: 'default', output: 'text', debug: false, testId: 'test_fe' },
{ credentialsPath, fetchImpl, stderr: line => stderr.push(line) },
);
expect(got.code).toBe('');
expect(stderr.join('\n')).toContain('no code generated yet');
});

// Regression: --out used to open (truncate) the destination file
// before the network request. A failed fetch left a pre-existing
// file emptied. The fix writes to a sibling temp file and renames it
Expand All @@ -1587,18 +1625,24 @@ describe('runCodeGet', () => {
expect(leftovers).toEqual([]);
});

// Regression: the "no code generated yet" branch writes nothing but
// previously still closed (and thus truncated) the opened file.
// Regression: empty inline code with --out must reject (exit 5) without
// truncating a pre-existing destination file.
it('--out: "no code generated yet" leaves a pre-existing file untouched', async () => {
const { credentialsPath } = makeCreds();
const dir = mkdtempSync(join(tmpdir(), 'cli-test-code-out-empty-'));
const target = join(dir, 'existing.ts');
writeFileSync(target, 'PRE-EXISTING CONTENT', 'utf8');
const fetchImpl = makeFetch(() => ({ body: { ...TEST_CODE_INLINE, code: '' } }));
await runCodeGet(
{ profile: 'default', output: 'text', debug: false, testId: 'test_fe', out: target },
{ credentialsPath, fetchImpl, stderr: () => undefined },
);
await expect(
runCodeGet(
{ profile: 'default', output: 'text', debug: false, testId: 'test_fe', out: target },
{ credentialsPath, fetchImpl, stderr: () => undefined },
),
).rejects.toMatchObject({
code: 'VALIDATION_ERROR',
exitCode: 5,
details: expect.objectContaining({ field: 'out' }),
});
expect(readFileSync(target, 'utf-8')).toBe('PRE-EXISTING CONTENT');
const leftovers = readdirSync(dir).filter(f => f !== 'existing.ts');
expect(leftovers).toEqual([]);
Expand Down Expand Up @@ -1657,6 +1701,30 @@ describe('runCodePut', () => {
expect(sent.headers.get('content-type')).toBe('application/json');
});

it('strips a UTF-8 BOM from --code-file before uploading (Windows PowerShell 5.1 default)', async () => {
const { credentialsPath } = makeCreds();
const dir = mkdtempSync(join(tmpdir(), 'cli-p4-bom-'));
const codeFile = join(dir, 'updated.spec.ts');
writeFileSync(codeFile, '\uFEFF' + 'updated body', 'utf8');
let seenBody: unknown;
const fetchImpl = makeFetch((_url, init) => {
seenBody = init.body ? JSON.parse(init.body as string) : undefined;
return { body: SAMPLE_RESPONSE };
});
await runCodePut(
{
profile: 'default',
output: 'json',
debug: false,
testId: 'test_alpha',
codeFile,
expectedVersion: 'v3',
},
{ credentialsPath, fetchImpl, stdout: () => undefined, stderr: () => undefined },
);
expect(seenBody).toEqual({ code: 'updated body' });
});

it('forwards --language in the body when set', async () => {
const { credentialsPath } = makeCreds();
const codeFile = writeCodeFile('print("hi")');
Expand Down
21 changes: 16 additions & 5 deletions src/commands/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -880,7 +880,7 @@ function readCodeFileGuarded(path: string): string {

function readCodeFile(path: string): string {
try {
return readFileSync(resolveAbsolute(path), 'utf8');
return stripBom(readFileSync(resolveAbsolute(path), 'utf8'));
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
Expand Down Expand Up @@ -3261,7 +3261,7 @@ export async function runCodeGet(opts: CodeGetOptions, deps: TestDeps = {}): Pro
return code;
}

const fileSink = opts.out !== undefined ? openOutputFile(opts.out) : null;
let fileSink = opts.out !== undefined ? openOutputFile(opts.out) : null;
const out = fileSink ? makeFileOutput(opts.output, fileSink) : makeOutput(opts.output, deps);
const client = makeClient(opts, deps);

Expand All @@ -3286,9 +3286,20 @@ export async function runCodeGet(opts: CodeGetOptions, deps: TestDeps = {}): Pro
} else if (code.code === '' || code.code === null) {
// P2-10: draft test with no code yet — empty body would produce
// silent empty stdout. Print a friendly hint to stderr instead so
// the operator knows what happened, and keep exit 0. Nothing was
// written, so the temp file is discarded below without touching
// a pre-existing `--out` file.
// the operator knows what happened, and keep exit 0.
//
// With `--out`, refuse to leave a zero-byte artifact behind: agents
// and scripts that check file size would otherwise treat exit 0 as
// a successful download. Discard the temp sink without renaming so
// a pre-existing `--out` file is never touched.
if (fileSink) {
await closeOutputFile(fileSink, false);
fileSink = null;
throw localValidationError(
'out',
'test has no generated code yet — run the test first (refusing to write an empty --out file)',
);
}
const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
stderrFn('(no code generated yet — run the test first)');
} else {
Expand Down
Loading