diff --git a/CHANGELOG.md b/CHANGELOG.md index d2d581b8..fec87204 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//` 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 diff --git a/src/commands/project.test.ts b/src/commands/project.test.ts index da10e51e..f5307e27 100644 --- a/src/commands/project.test.ts +++ b/src/commands/project.test.ts @@ -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(); diff --git a/src/commands/project.ts b/src/commands/project.ts index 12f9b55d..99542e74 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -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'; @@ -32,6 +33,7 @@ import { fetchSinglePage, paginate, validatePaginationFlags, + type FetchPageArgs, type Page, type PaginationFlags, } from '../lib/pagination.js'; @@ -173,6 +175,12 @@ 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>('/projects', { + query: { pageSize, cursor }, + schema: CLI_PROJECT_LIST_SCHEMA, + }); + let page: Page; if (useSinglePage) { page = await fetchSinglePage( @@ -180,15 +188,11 @@ export async function runList( '/projects', paginationFlags.pageSize!, opts.startingToken, + {}, + CLI_PROJECT_LIST_SCHEMA, ); } else { - page = await paginate( - async ({ pageSize, cursor }) => - client.get>('/projects', { - query: { pageSize, cursor }, - }), - paginationFlags, - ); + page = await paginate(fetchPage, paginationFlags); } out.print(page, data => { @@ -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(`/projects/${encodeURIComponent(opts.projectId)}`); + const project = await client.get(`/projects/${encodeURIComponent(opts.projectId)}`, { + schema: CLI_PROJECT_SCHEMA, + }); out.print(project, data => renderProjectText(data as CliProject)); return project; } diff --git a/src/commands/test.test.ts b/src/commands/test.test.ts index 215b7dc6..bfea7800 100644 --- a/src/commands/test.test.ts +++ b/src/commands/test.test.ts @@ -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'; @@ -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 = { ...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(); + expectTypeOf(result.framework).toEqualTypeOf(); + 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[] = []; diff --git a/src/commands/test.ts b/src/commands/test.ts index 49c98946..359a5bd1 100644 --- a/src/commands/test.ts +++ b/src/commands/test.ts @@ -86,6 +86,7 @@ import { } 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 { emitDeprecationEntry, @@ -331,6 +332,18 @@ 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' | 'codeVersion' +> & { + language: string; + framework?: string; + code: string | null; + /** Legacy rows may omit it or send null; both mean "no version stamped yet". */ + codeVersion?: string | null; +}; + /** §6.4 TestStep wire shape. `null` is "not known", not "absent". */ export interface CliTestStep { testId: string; @@ -5584,7 +5597,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 { +export async function runCodeGet( + opts: CodeGetOptions, + deps: TestDeps = {}, +): Promise { // 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 — @@ -5594,15 +5610,26 @@ 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(`/tests/${encodeURIComponent(opts.testId)}/code`); + const code = await client.get( + `/tests/${encodeURIComponent(opts.testId)}/code`, + { + schema: CLI_TEST_CODE_SCHEMA, + }, + ); if (opts.out !== undefined) { - const bytes = isPresignedCodeUrl(code.code) ? '' : `${code.code.length}`; + // Null/empty first: isPresignedCodeUrl dereferences its argument. + const bytes = + code.code === null || code.code === '' + ? '0' + : isPresignedCodeUrl(code.code) + ? '' + : `${code.code.length}`; 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; } @@ -5612,24 +5639,19 @@ export async function runCodeGet(opts: CodeGetOptions, deps: TestDeps = {}): Pro const client = makeClient(opts, deps); try { - const code = await client.get(`/tests/${encodeURIComponent(opts.testId)}/code`); + const code = await client.get( + `/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)) { - // 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 - // suite doesn't sit in memory waiting for the full download. - // `writeChunk` awaits the rawStdout drain promise so a slow - // downstream consumer (a file on a slow disk, an NFS mount, - // or a piped `gzip`) pauses the upstream reader rather than - // letting chunks accumulate in V8's heap. - await streamPresignedBody(code.code, out, deps); - wroteContent = true; } else if (code.code === '' || code.code === null) { + // Checked before isPresignedCodeUrl, which dereferences its argument. // 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 when no `--out`. @@ -5648,6 +5670,17 @@ export async function runCodeGet(opts: CodeGetOptions, deps: TestDeps = {}): Pro } const stderrFn = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); stderrFn('(no code generated yet — run the test first)'); + } else if (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 + // suite doesn't sit in memory waiting for the full download. + // `writeChunk` awaits the rawStdout drain promise so a slow + // downstream consumer (a file on a slow disk, an NFS mount, + // or a piped `gzip`) pauses the upstream reader rather than + // letting chunks accumulate in V8's heap. + await streamPresignedBody(code.code, out, deps); + wroteContent = true; } else { await out.writeChunk(code.code); wroteContent = true; @@ -5794,7 +5827,12 @@ export async function runCodePut( requireNonEmpty('expected-version', opts.expectedVersion); ifMatch = opts.expectedVersion; } else { - const fetched = await client.get(`/tests/${encodeURIComponent(opts.testId)}/code`); + const fetched = await client.get( + `/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 `*` diff --git a/src/lib/pagination.ts b/src/lib/pagination.ts index cae75811..e6b0026b 100644 --- a/src/lib/pagination.ts +++ b/src/lib/pagination.ts @@ -1,3 +1,4 @@ +import type * as v from 'valibot'; import type { HttpClient } from './http.js'; import { ApiError, localValidationError } from './errors.js'; @@ -153,8 +154,10 @@ export async function fetchSinglePage( pageSize: number, cursor: string | undefined, extraQuery: Record = {}, + schema?: v.GenericSchema>, ): Promise> { return client.get>(path, { query: { ...extraQuery, pageSize, cursor }, + ...(schema !== undefined ? { schema } : {}), }); } diff --git a/src/lib/response-schemas.code.test.ts b/src/lib/response-schemas.code.test.ts new file mode 100644 index 00000000..753a820a --- /dev/null +++ b/src/lib/response-schemas.code.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import * as v from 'valibot'; +import { testCodeFixture, testCodeLargeFixture } from '../../test/mock-backend/fixtures.js'; +import { CLI_TEST_CODE_SCHEMA } from './response-schemas.js'; + +describe('CLI_TEST_CODE_SCHEMA', () => { + it.each([testCodeFixture, testCodeLargeFixture])('preserves a complete source fixture', body => { + expect(v.parse(CLI_TEST_CODE_SCHEMA, body)).toEqual(body); + }); + + it('accepts an omitted framework on the code-put legacy auto-fetch shape', () => { + const body = { testId: 'test_alpha', language: 'typescript', code: 'old', codeVersion: null }; + expect(v.parse(CLI_TEST_CODE_SCHEMA, body)).toEqual(body); + }); + + it('keeps an absent legacy codeVersion absent so --output json stays byte-identical', () => { + const body = { testId: 'test_alpha', language: 'python', code: '' }; + const parsed = v.parse(CLI_TEST_CODE_SCHEMA, body); + expect(parsed).toEqual(body); + expect(parsed).not.toHaveProperty('codeVersion'); + expect(parsed).not.toHaveProperty('etag'); + expect(parsed).not.toHaveProperty('framework'); + }); + + it('preserves explicit null etag and code from a draft row', () => { + const body = { ...testCodeFixture, code: null, etag: null }; + expect(v.parse(CLI_TEST_CODE_SCHEMA, body)).toEqual(body); + }); + + it.each([ + null, + [], + { ...testCodeFixture, testId: 7 }, + { ...testCodeFixture, language: false }, + { ...testCodeFixture, framework: [] }, + { ...testCodeFixture, code: undefined }, + { ...testCodeFixture, codeVersion: 7 }, + { ...testCodeFixture, etag: {} }, + ])('rejects an incompatible response shape', body => { + expect(v.safeParse(CLI_TEST_CODE_SCHEMA, body).success).toBe(false); + }); +}); diff --git a/src/lib/response-schemas.ts b/src/lib/response-schemas.ts index ca53ed02..567e62ec 100644 --- a/src/lib/response-schemas.ts +++ b/src/lib/response-schemas.ts @@ -8,8 +8,10 @@ * HttpClient helpers `triggerRun`, `triggerRunWithMeta`, `triggerRerun`, * `triggerBatchRerun`, `triggerBatchRunFresh`, `getRun`, `listTestRuns`, * `cancelRun`, and — for the account surfaces below — the `client.get` call - * sites that own a `/me` or usage read. Every other generic - * `get`/`post`/`put`/`patch`/`delete` caller stays schema-free and opt-in. + * sites that own a `/me` or usage read, plus the `client.get` call sites that + * own the `project get` / `project list` and `test code get` / `code put` + * reads. Every other generic `get`/`post`/`put`/`patch`/`delete` caller stays + * schema-free and opt-in. * * Resilience rules (additive server changes must never hard-fail the CLI): * @@ -63,6 +65,9 @@ import type { TunnelStatusResponse, } from './tunnel.types.js'; import type { ConflictReason } from './conflict-reason.js'; +import type { CliProject } from '../commands/project.js'; +import type { CliTestCodeRead } from '../commands/test.js'; +import type { Page } from './pagination.js'; /** Deployment environment the bound key belongs to; open on the wire (rule 2). */ type AccountEnv = 'development' | 'staging' | 'production'; @@ -79,6 +84,53 @@ function openWireLiteral(): v.GenericSchema(value => typeof value === 'string'); } +/** + * GET /tests/{id}/code. The inline and presigned fixtures in + * test/mock-backend/fixtures.ts contain the required identity/source fields. + * The code-put auto-fetch fixtures in commands/test.test.ts omit framework; + * legacy codeVersion may be null/absent (kept as received so `--output json` + * stays byte-identical; `code put` treats both as the explicit If-Match + * fallback). Keep etag absence distinct from an explicit null. + * A null code body is the draft/no-generated-code branch of runCodeGet. + */ +export const CLI_TEST_CODE_SCHEMA: v.GenericSchema = v.looseObject({ + testId: v.string(), + language: v.string(), + framework: v.optional(v.string()), + code: v.nullable(v.string()), + codeVersion: v.optional(v.nullable(v.string())), + etag: v.optional(v.nullable(v.string())), +}); + +/** + * GET /projects/{id} and the rows of GET /projects. Optional fields stay + * absent when omitted: an absent `targetUrl` means "no answer", whereas + * `null` means "unset" (see `CliProject.targetUrl`). The renderer-consumed + * environment fields are typed so a drifted value cannot throw in the text + * renderer. + */ +export const CLI_PROJECT_SCHEMA: v.GenericSchema = v.looseObject({ + id: v.string(), + name: v.string(), + type: openWireLiteral(), + createdFrom: openWireLiteral(), + createdAt: v.string(), + updatedAt: v.string(), + orgId: v.optional(v.string()), + orgName: v.optional(v.string()), + targetUrl: v.optional(v.nullable(v.string())), + defaultEnvironment: v.optional(v.nullable(v.string())), + environmentCount: v.optional(v.number()), + originMode: v.optional(openWireLiteral>()), + testIdAttributes: v.optional(v.nullable(v.array(v.string()))), +}); + +/** GET /projects page; an omitted `nextToken` normalizes to the terminal `null` cursor. */ +export const CLI_PROJECT_LIST_SCHEMA: v.GenericSchema> = v.looseObject({ + items: v.array(CLI_PROJECT_SCHEMA), + nextToken: v.nullish(v.string(), null), +}); + /** * Mirrors `RunEnvironmentRef` (runs.types.ts): the environment a run resolved to. * Optional + nullable everywhere it appears: absent on an older backend, `null` diff --git a/test/contract/p4-schema.test.ts b/test/contract/p4-schema.test.ts index cedea9ca..dd6a536c 100644 --- a/test/contract/p4-schema.test.ts +++ b/test/contract/p4-schema.test.ts @@ -301,7 +301,7 @@ describe('P4 schema contract — CLI runners return §6.x shapes', () => { { credentialsPath, stdout: () => undefined }, ); validateTestCode(code); - expect(code.code.startsWith('https://')).toBe(false); + expect(code.code?.startsWith('https://')).toBe(false); }); it('runCodeGet (presigned) returns a §6.3 TestCode with https code', async () => { @@ -311,7 +311,7 @@ describe('P4 schema contract — CLI runners return §6.x shapes', () => { { credentialsPath, stdout: () => undefined }, ); validateTestCode(code); - expect(code.code.startsWith('https://')).toBe(true); + expect(code.code?.startsWith('https://')).toBe(true); }); it('runSteps returns a §6.4 TestStepList', async () => {