Skip to content
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ All notable changes to `@testsprite/testsprite-cli` are documented here. The for
- **`test artifact get --out` and `test failure get --out` create missing parent directories.** An explicit `--out` needed an existing parent and exited 5 otherwise, so the `--out ./.testsprite/…` commands in the verify skill, the README and DOCUMENTATION.md failed in a fresh project. Missing parents are now created the way the default `./.testsprite/runs/<run-id>/` is, after the bundle passes its checks, so a failed fetch still leaves nothing on disk; an `--out` at an existing file, or one that runs through a file, still exits 5 (#729, closes #654).
- **`ci run` on `deployment_status` gates only an open pull request whose head is the deployed commit** (#550). A redeploy of a merged or closed pull request's head commit, or an open pull request that merely contains the commit, is now skipped with a `::notice::` and exit 0 (`no_pull_request`) instead of cloning an environment and gating it.
- **The `deployment_status` pull-request lookup honors `--request-timeout` and survives a non-JSON reply** (#551). A GitHub API that never answers is exit 10 within the request budget instead of hanging the job, and a 200 with an HTML body (proxy, captive portal) is exit 10 with a clear message instead of an uncaught exception. 401/403 keep exit 5.
- **`project get`/`project list` and `test code get`/`code put` validate the response shape.** A malformed response now exits 1 with an `INTERNAL` "Response shape mismatch" error (request id and the first mismatched paths, never the body) instead of a raw `TypeError`, a bad body printed as a success, or a bad `If-Match` header on `code put`. Additive server fields and new project type/language/framework values still pass through unchanged, an omitted project `nextToken` counts as the last page, and in text mode a null or empty `code` is reported as "no code generated yet" (with `--out`, no file is written and an existing one is left untouched). `test code get --output json` is unchanged for legacy rows. Thanks @jerrybate-sketch (#340).

## [0.16.0] - 2026-10-30

Expand Down
106 changes: 106 additions & 0 deletions src/commands/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,112 @@ describe('createProjectCommand --page-size option parser', () => {
});
});

describe('project read response validation', () => {
it.each([
['missing name', { ...PROJECT_FIXTURE, name: undefined }],
['invalid attribute list', { ...PROJECT_FIXTURE, testIdAttributes: 'data-testid' }],
['invalid nullable URL', { ...PROJECT_FIXTURE, targetUrl: false }],
])('rejects %s before printing a successful result', async (_label, body) => {
const out: string[] = [];
await expect(
runGet(
{ profile: 'default', output: 'json', debug: false, projectId: PROJECT_FIXTURE.id },
{ ...makeCreds(), fetchImpl: makeFetch(() => ({ body })), stdout: line => out.push(line) },
),
).rejects.toMatchObject({ code: 'INTERNAL', exitCode: 1 });
expect(out).toEqual([]);
});

it.each([false, true])('validates list rows before output (single page: %s)', async single => {
const out: string[] = [];
let calls = 0;
const fetchImpl = makeFetch(() => {
calls += 1;
return {
body:
!single && calls === 1
? { items: [PROJECT_FIXTURE], nextToken: 'second-page' }
: { items: [{ ...PROJECT_FIXTURE, id: null }], nextToken: null },
};
});
await expect(
runList(
{ profile: 'default', output: 'text', debug: false, ...(single ? { pageSize: 1 } : {}) },
{ ...makeCreds(), fetchImpl, stdout: line => out.push(line) },
),
).rejects.toMatchObject({ code: 'INTERNAL', exitCode: 1 });
expect(calls).toBe(single ? 1 : 2);
expect(out).toEqual([]);
});

it('rejects a malformed cursor instead of returning it as a usable next page', async () => {
const out: string[] = [];
await expect(
runList(
{ profile: 'default', output: 'json', debug: false, pageSize: 1 },
{
...makeCreds(),
fetchImpl: makeFetch(() => ({ body: { items: [PROJECT_FIXTURE], nextToken: 17 } })),
stdout: line => out.push(line),
},
),
).rejects.toMatchObject({ code: 'INTERNAL', exitCode: 1 });
expect(out).toEqual([]);
});

it.each([
['defaultEnvironment', { defaultEnvironment: {} }],
['environmentCount', { environmentCount: 'x' }],
['originMode', { originMode: 1 }],
])('rejects a wrong-typed %s before the text renderer runs', async (_field, patch) => {
const out: string[] = [];
await expect(
runGet(
{ profile: 'default', output: 'text', debug: false, projectId: PROJECT_FIXTURE.id },
{
...makeCreds(),
fetchImpl: makeFetch(() => ({ body: { ...PROJECT_FIXTURE, ...patch } })),
stdout: line => out.push(line),
},
),
).rejects.toMatchObject({ code: 'INTERNAL', exitCode: 1 });
expect(out).toEqual([]);
});

it('normalizes an omitted nextToken to the terminal null cursor', async () => {
const out: string[] = [];
const result = await runList(
{ profile: 'default', output: 'json', debug: false, pageSize: 1 },
{
...makeCreds(),
fetchImpl: makeFetch(() => ({ body: { items: [PROJECT_FIXTURE] } })),
stdout: line => out.push(line),
},
);
expect(result.nextToken).toBeNull();
expect(JSON.parse(out[0]!)).toEqual({ items: [PROJECT_FIXTURE], nextToken: null });
});

it('preserves new server fields and enum values without inventing absent optional fields', async () => {
const body = {
...PROJECT_FIXTURE,
type: 'mobile',
createdFrom: 'import',
owner: { name: 'Team' },
};
const out: string[] = [];
const result = await runGet(
{ profile: 'default', output: 'json', debug: false, projectId: PROJECT_FIXTURE.id },
{ ...makeCreds(), fetchImpl: makeFetch(() => ({ body })), stdout: line => out.push(line) },
);
expect(result).toEqual(body);
expect(JSON.parse(out[0]!)).toEqual(body);
expect(result).not.toHaveProperty('targetUrl');
expect(result).not.toHaveProperty('testIdAttributes');
expect(result).not.toHaveProperty('orgName');
});
});

describe('runGet', () => {
it('get shows default environment and count', async () => {
const { credentialsPath } = makeCreds();
Expand Down
22 changes: 14 additions & 8 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
} from '../lib/local-target.js';
import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '../lib/output.js';
import { readSecretFileGuarded } from '../lib/secret-file.js';
import { CLI_PROJECT_LIST_SCHEMA, CLI_PROJECT_SCHEMA } from '../lib/response-schemas.js';
import { terminalWidth } from '../lib/ticker.js';
import { assertNotLocal } from '../lib/target-url.js';
import { renderTextTable, resolveTextColumns, type TextTableColumn } from '../lib/text-table.js';
Expand All @@ -32,6 +33,7 @@ import {
fetchSinglePage,
paginate,
validatePaginationFlags,
type FetchPageArgs,
type Page,
type PaginationFlags,
} from '../lib/pagination.js';
Expand Down Expand Up @@ -173,22 +175,24 @@ export async function runList(
// request — same shape AWS CLI ships. Otherwise auto-page.
const useSinglePage = opts.pageSize !== undefined && opts.maxItems === undefined;

const fetchPage = async ({ pageSize, cursor }: FetchPageArgs) =>
client.get<Page<CliProject>>('/projects', {
query: { pageSize, cursor },
schema: CLI_PROJECT_LIST_SCHEMA,
});

let page: Page<CliProject>;
if (useSinglePage) {
page = await fetchSinglePage<CliProject>(
client,
'/projects',
paginationFlags.pageSize!,
opts.startingToken,
{},
CLI_PROJECT_LIST_SCHEMA,
);
} else {
page = await paginate<CliProject>(
async ({ pageSize, cursor }) =>
client.get<Page<CliProject>>('/projects', {
query: { pageSize, cursor },
}),
paginationFlags,
);
page = await paginate<CliProject>(fetchPage, paginationFlags);
}

out.print(page, data => {
Expand All @@ -206,7 +210,9 @@ export async function runGet(opts: GetOptions, deps: ProjectDeps = {}): Promise<
const out = makeOutput(opts.output, deps);
const client = makeClient(opts, deps);

const project = await client.get<CliProject>(`/projects/${encodeURIComponent(opts.projectId)}`);
const project = await client.get<CliProject>(`/projects/${encodeURIComponent(opts.projectId)}`, {
schema: CLI_PROJECT_SCHEMA,
});
out.print(project, data => renderProjectText(data as CliProject));
return project;
}
Expand Down
149 changes: 148 additions & 1 deletion src/commands/test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
import { execFileSync, spawn } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest';
import type { Command } from 'commander';
import type { RunResponse } from '../lib/runs.types.js';
import { ApiError, InterruptError } from '../lib/errors.js';
Expand Down Expand Up @@ -2041,6 +2041,153 @@ describe('isPresignedCodeUrl', () => {
});

describe('runCodeGet', () => {
it.each(['json', 'text'] as const)(
'%s mode rejects malformed test-code responses before writing output',
async output => {
const { credentialsPath } = makeCreds();
const stdout: string[] = [];
const fetchImpl = makeFetch(() => ({
body: { ...TEST_CODE_INLINE, code: { unexpected: 'do not emit this body' } },
}));
await expect(
runCodeGet(
{ profile: 'default', output, debug: false, testId: 'test_fe' },
{
credentialsPath,
fetchImpl,
stdout: line => stdout.push(line),
rawStdout: chunk => {
stdout.push(chunk);
},
},
),
).rejects.toMatchObject({ code: 'INTERNAL', exitCode: 1 });
expect(stdout).toEqual([]);
},
);

it('rejects a missing code field without replacing an existing --out file', async () => {
const { credentialsPath } = makeCreds();
const dir = mkdtempSync(join(tmpdir(), 'cli-test-code-shape-'));
const target = join(dir, 'existing.json');
// eslint-disable-next-line security/detect-non-literal-fs-filename -- target is inside this test's own mkdtempSync directory, never user input.
writeFileSync(target, 'keep the original');
const withoutCode: Record<string, unknown> = { ...TEST_CODE_INLINE };
delete withoutCode.code;
const fetchImpl = makeFetch(() => ({ body: withoutCode }));
await expect(
runCodeGet(
{ profile: 'default', output: 'json', debug: false, testId: 'test_fe', out: target },
{ credentialsPath, fetchImpl },
),
).rejects.toMatchObject({ code: 'INTERNAL', exitCode: 1 });
// eslint-disable-next-line security/detect-non-literal-fs-filename -- target is the fixture path created inside this test's own temp directory.
expect(readFileSync(target, 'utf8')).toBe('keep the original');
// eslint-disable-next-line security/detect-non-literal-fs-filename -- dir is created by this test's mkdtempSync call, never user input.
expect(readdirSync(dir)).toEqual(['existing.json']);
});

it('preserves additive fields and future code language/framework strings', async () => {
const { credentialsPath } = makeCreds();
const body = {
...TEST_CODE_INLINE,
language: 'ruby',
framework: 'rspec',
provenance: { source: 'future-server' },
};
const lines: string[] = [];
const result = await runCodeGet(
{ profile: 'default', output: 'json', debug: false, testId: 'test_fe' },
{ credentialsPath, fetchImpl: makeFetch(() => ({ body })), stdout: line => lines.push(line) },
);
expectTypeOf(result.language).toEqualTypeOf<string>();
expectTypeOf(result.framework).toEqualTypeOf<string | undefined>();
expect(result).toEqual(body);
expect(JSON.parse(lines.join(''))).toEqual(body);
});

it('routes null code to the no-code response without dereferencing it', async () => {
const { credentialsPath } = makeCreds();
const lines: string[] = [];
await runCodeGet(
{ profile: 'default', output: 'text', debug: false, testId: 'test_fe' },
{
credentialsPath,
fetchImpl: makeFetch(() => ({ body: { ...TEST_CODE_INLINE, code: null } })),
stderr: line => lines.push(line),
rawStdout: () => {
throw new Error('must not write an absent source body');
},
},
);
expect(lines).toContain('(no code generated yet — run the test first)');
});

it('null code with --out refuses to write and leaves an existing file unchanged', async () => {
const { credentialsPath } = makeCreds();
const dir = mkdtempSync(join(tmpdir(), 'cli-test-code-null-'));
const target = join(dir, 'existing.py');
// eslint-disable-next-line security/detect-non-literal-fs-filename -- target is inside this test's own mkdtempSync directory, never user input.
writeFileSync(target, 'keep the original');
await expect(
runCodeGet(
{ profile: 'default', output: 'text', debug: false, testId: 'test_fe', out: target },
{
credentialsPath,
fetchImpl: makeFetch(() => ({ body: { ...TEST_CODE_INLINE, code: null } })),
stderr: () => undefined,
},
),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR', exitCode: 5 });
// eslint-disable-next-line security/detect-non-literal-fs-filename -- target is the fixture path created inside this test's own temp directory.
expect(readFileSync(target, 'utf8')).toBe('keep the original');
// eslint-disable-next-line security/detect-non-literal-fs-filename -- dir is created by this test's mkdtempSync call, never user input.
expect(readdirSync(dir)).toEqual(['existing.py']);
});

it('dry-run with null code and --out reports zero bytes instead of throwing', async () => {
const { credentialsPath } = makeCreds();
const errs: string[] = [];
await runCodeGet(
{
profile: 'default',
output: 'text',
debug: false,
dryRun: true,
testId: 'test_fe',
out: join(tmpdir(), 'cli-dry-run-never-written.py'),
},
{
credentialsPath,
fetchImpl: makeFetch(() => ({ body: { ...TEST_CODE_INLINE, code: null } })),
stderr: line => errs.push(line),
stdout: () => undefined,
rawStdout: () => undefined,
},
);
expect(errs.join('\n')).toContain('would write code body (0 bytes)');
});

it('rejects an invalid auto-fetched codeVersion before sending code put', async () => {
const { credentialsPath } = makeCreds();
const dir = mkdtempSync(join(tmpdir(), 'cli-test-code-version-'));
const codeFile = join(dir, 'replacement.py');
// eslint-disable-next-line security/detect-non-literal-fs-filename -- codeFile is a fixture inside this test's own mkdtempSync directory.
writeFileSync(codeFile, 'print("replacement")');
const methods: string[] = [];
const fetchImpl = makeFetch((_url, init) => {
methods.push(init.method ?? 'GET');
return { body: { ...TEST_CODE_INLINE, codeVersion: { unexpected: true } } };
});
await expect(
runCodePut(
{ profile: 'default', output: 'json', debug: false, testId: 'test_fe', codeFile },
{ credentialsPath, fetchImpl, stderr: () => undefined },
),
).rejects.toMatchObject({ code: 'INTERNAL', exitCode: 1 });
expect(methods).toEqual(['GET']);
});

it('JSON mode prints the §6.3 wire shape verbatim and skips the URL fetch', async () => {
const { credentialsPath } = makeCreds();
const seen: string[] = [];
Expand Down
Loading
Loading