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
73 changes: 73 additions & 0 deletions src/commands/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,79 @@ 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('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('GETs /projects/{id} and prints the §6.1 fields in text mode', async () => {
const { credentialsPath } = makeCreds();
Expand Down
28 changes: 13 additions & 15 deletions src/commands/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,14 @@ import type { FetchImpl, HttpClient } from '../lib/http.js';
import { globalShutdown, type ShutdownHandle } from '../lib/interrupt.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/project-response-schema.js';
import { assertNotLocal } from '../lib/target-url.js';
import { renderTextTable, resolveTextColumns, type TextTableColumn } from '../lib/text-table.js';
import { assertIdempotencyKey } from '../lib/validate.js';
import {
fetchSinglePage,
paginate,
validatePaginationFlags,
type FetchPageArgs,
type Page,
type PaginationFlags,
} from '../lib/pagination.js';
Expand Down Expand Up @@ -155,22 +156,17 @@ 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,
);
page = await fetchPage({ pageSize: paginationFlags.pageSize!, cursor: opts.startingToken });
} 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 @@ -188,7 +184,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
100 changes: 99 additions & 1 deletion src/commands/test.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
} from 'node:fs';
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 @@ -1547,6 +1547,104 @@ 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');
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 });
expect(readFileSync(target, 'utf8')).toBe('keep the original');
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('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');
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
43 changes: 36 additions & 7 deletions src/commands/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
import { REQUEST_TIMEOUT_DEFAULT_MS, REQUEST_TIMEOUT_MAX_MS } from '../lib/http.js';
import type { FetchImpl } from '../lib/http.js';
import type { HttpClient } from '../lib/http.js';
import { CLI_TEST_CODE_SCHEMA } from '../lib/response-schemas.js';
import { VERSION } from '../version.js';
import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '../lib/output.js';
import {
Expand Down Expand Up @@ -253,6 +254,13 @@ export interface CliTestCode {
etag?: string | null;
}

/** The standalone code endpoint also serves draft, legacy, and newer server values. */
export type CliTestCodeRead = Omit<CliTestCode, 'language' | 'framework' | 'code'> & {
language: string;
framework?: string;
code: string | null;
};

/** §6.4 TestStep wire shape. `null` is "not known", not "absent". */
export interface CliTestStep {
testId: string;
Expand Down Expand Up @@ -4607,7 +4615,10 @@ interface CodeGetOptions extends CommonOptions {
* temp file is discarded and the user's pre-existing `--out` file, if
* any, is left untouched.
*/
export async function runCodeGet(opts: CodeGetOptions, deps: TestDeps = {}): Promise<CliTestCode> {
export async function runCodeGet(
opts: CodeGetOptions,
deps: TestDeps = {},
): Promise<CliTestCodeRead> {
// Dry-run: no fetch, no fs. Print the canned shape to stdout and, if
// the user passed `--out`, log on stderr what would have been written.
// We deliberately do NOT validate the `--out` path here in dry-run —
Expand All @@ -4617,15 +4628,23 @@ export async function runCodeGet(opts: CodeGetOptions, deps: TestDeps = {}): Pro
const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`));
const out = makeOutput(opts.output, deps);
const client = makeClient(opts, deps);
const code = await client.get<CliTestCode>(`/tests/${encodeURIComponent(opts.testId)}/code`);
const code = await client.get<CliTestCodeRead>(
`/tests/${encodeURIComponent(opts.testId)}/code`,
{
schema: CLI_TEST_CODE_SCHEMA,
},
);
if (opts.out !== undefined) {
const bytes = isPresignedCodeUrl(code.code) ? '<presigned-stream>' : `${code.code.length}`;
const bytes =
code.code !== null && isPresignedCodeUrl(code.code)
? '<presigned-stream>'
: `${code.code?.length ?? 0}`;
stderr(`[dry-run] would write code body (${bytes} bytes) to ${opts.out}`);
}
if (opts.output === 'json') {
out.print(code);
} else {
await out.writeChunk(code.code);
await out.writeChunk(code.code ?? '');
}
return code;
}
Expand All @@ -4635,13 +4654,18 @@ export async function runCodeGet(opts: CodeGetOptions, deps: TestDeps = {}): Pro
const client = makeClient(opts, deps);

try {
const code = await client.get<CliTestCode>(`/tests/${encodeURIComponent(opts.testId)}/code`);
const code = await client.get<CliTestCodeRead>(
`/tests/${encodeURIComponent(opts.testId)}/code`,
{
schema: CLI_TEST_CODE_SCHEMA,
},
);
let wroteContent = false;

if (opts.output === 'json') {
out.print(code);
wroteContent = true;
} else if (isPresignedCodeUrl(code.code)) {
} else if (code.code !== null && isPresignedCodeUrl(code.code)) {
// Text mode: dump the source body. JSON consumers want the wire
// shape; humans (and agents shelling out via `> file.ts`) want
// ready-to-edit code. Stream chunk-wise so a multi-MB generated
Expand Down Expand Up @@ -4817,7 +4841,12 @@ export async function runCodePut(
requireNonEmpty('expected-version', opts.expectedVersion);
ifMatch = opts.expectedVersion;
} else {
const fetched = await client.get<CliTestCode>(`/tests/${encodeURIComponent(opts.testId)}/code`);
const fetched = await client.get<CliTestCodeRead>(
`/tests/${encodeURIComponent(opts.testId)}/code`,
{
schema: CLI_TEST_CODE_SCHEMA,
},
);
const cv = fetched.codeVersion;
if (cv === null || cv === undefined) {
// Server hasn't stamped a codeVersion yet (legacy row). Send `*`
Expand Down
27 changes: 27 additions & 0 deletions src/lib/project-response-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as v from 'valibot';
import type { CliProject } from '../commands/project.js';
import type { Page } from './pagination.js';

/**
* Project read fixtures in commands/project.test.ts and test/mock-backend/fixtures.ts
* establish these core fields. Optional fields must stay absent when omitted:
* in particular, an absent targetUrl means no answer, whereas null means unset.
* Follow response-schemas.ts: preserve added fields and accept future enum strings.
*/
export const CLI_PROJECT_SCHEMA: v.GenericSchema<unknown, CliProject> = v.looseObject({
id: v.string(),
name: v.string(),
type: v.custom<CliProject['type']>(value => typeof value === 'string'),
createdFrom: v.custom<CliProject['createdFrom']>(value => typeof value === 'string'),
createdAt: v.string(),
updatedAt: v.string(),
orgId: v.optional(v.string()),
orgName: v.optional(v.string()),
targetUrl: v.optional(v.nullable(v.string())),
testIdAttributes: v.optional(v.nullable(v.array(v.string()))),
});

export const CLI_PROJECT_LIST_SCHEMA: v.GenericSchema<unknown, Page<CliProject>> = v.looseObject({
items: v.array(CLI_PROJECT_SCHEMA),
nextToken: v.nullable(v.string()),
});
Loading
Loading