diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index fd8a654..a3e7528 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -75,6 +75,8 @@ testsprite auth status Credentials are normally stored at `~/.testsprite/credentials` (INI-style, mode `0600`). With `setup --from-env`, an unwritable or read-only HOME (`EACCES`, `EPERM`, or `EROFS` while saving credentials) produces a stderr warning and setup continues using `TESTSPRITE_API_KEY` for this session. Its JSON summary includes `credentials: { persisted: false, source: "env" }`; successful authentication does not mean the key was saved. Keep `TESTSPRITE_API_KEY` available in every shell/process that invokes the CLI. Agent installation still needs a writable destination; use `--no-agent` when only session authentication is needed. Other setup errors still fail. See [Configuration](#configuration) for profiles, environment overrides, and scopes. +`testsprite setup --debug` reports display-only identity/profile lookup failures and ignored non-JSON agent-install output on stderr. These diagnostics leave setup's existing fallback behavior and JSON summary intact; without `--debug`, these fallbacks remain silent. + For an org-scoped API key, `auth status` additionally prints an `orgs:` line (every organization your account belongs to) and an `org binding:` line (the specific organization this key is bound to). Both are omitted for a personal key or an older backend that doesn't report them. ### 2. Run your first test diff --git a/src/commands/init.debug.spec.ts b/src/commands/init.debug.spec.ts new file mode 100644 index 0000000..512b5c5 --- /dev/null +++ b/src/commands/init.debug.spec.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { readProfile } from '../lib/credentials.js'; +import { runConfigure, runWhoami } from './auth.js'; +import { runInstall } from './agent.js'; +import { runInit } from './init.js'; + +vi.mock('../lib/credentials.js', () => ({ readProfile: vi.fn() })); +vi.mock('./auth.js', () => ({ runConfigure: vi.fn(), runWhoami: vi.fn() })); +vi.mock('./agent.js', () => ({ runInstall: vi.fn() })); + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(readProfile).mockReturnValue(undefined); + vi.mocked(runConfigure).mockResolvedValue({ + persisted: true, + source: 'prompt', + }); + vi.mocked(runWhoami).mockResolvedValue({ + userId: 'u1', + keyId: 'k1', + scopes: [], + env: 'production', + }); + vi.mocked(runInstall).mockImplementation(async (_opts, deps) => { + deps?.stdout?.('[{"action":"written","skills":["testsprite-verify"]}]'); + }); +}); + +type Scenario = 'profile' | 'install' | 'identity'; +const contexts = { + profile: 'setup summary profile lookup failed; using endpoint fallback', + install: 'setup ignored non-JSON agent install output', + identity: 'setup identity lookup failed after configure', +}; + +/** Exercise the setup orchestrator while its existing primitives inject fallback failures. */ +async function setup( + scenario: Scenario, + debug: boolean, + output: 'json' | 'text', + brokenSink = false, +) { + if (scenario === 'profile') { + // The initial credential guard succeeds; only the display-only reread fails. + vi.mocked(readProfile) + .mockReturnValueOnce(undefined) + .mockImplementationOnce(() => { + throw new Error('profile read failed'); + }); + } else if (scenario === 'install') { + vi.mocked(runInstall).mockImplementation(async (_opts, deps) => { + deps?.stdout?.('private-install-content-not-json'); + deps?.stdout?.('[{"action":"written","skills":["testsprite-verify"]}]'); + }); + } else { + vi.mocked(runWhoami).mockRejectedValue(new Error('identity unavailable')); + } + const stdout: string[] = []; + const stderr: string[] = []; + await runInit( + { + profile: 'default', + output, + debug, + fromEnv: true, + agent: 'claude', + noAgent: false, + force: false, + yes: true, + }, + { + env: { TESTSPRITE_API_KEY: 'sk-user-test-only' }, + isTTY: false, + stdout: line => stdout.push(line), + stderr: line => { + stderr.push(line); + if (brokenSink && line.startsWith('[debug]')) throw new Error('broken diagnostic sink'); + }, + }, + ); + return { stdout: stdout.join('\n'), stderr: stderr.join('\n') }; +} + +describe.each(['profile', 'install', 'identity'] as const)( + 'setup %s fallback diagnostics', + scenario => { + it.each(['json', 'text'] as const)( + 'preserves %s output and emits only under debug', + async output => { + const normal = await setup(scenario, false, output); + const debug = await setup(scenario, true, output); + expect(debug.stdout).toBe(normal.stdout); + expect(normal.stderr).toBe(''); + expect(debug.stderr).toContain(`[debug] ${contexts[scenario]}`); + expect(debug.stderr).not.toContain('private-install-content'); + if (output === 'json') { + const summary = JSON.parse(debug.stdout); + expect(summary.status).toBe('initialized'); + expect(summary.apiUrl).toBe('https://api.testsprite.com'); + expect(summary.agent.action).toBe('installed'); + expect(summary.agent.skills).toEqual(['testsprite-verify']); + } + }, + ); + + it('preserves a successful setup when the diagnostic sink throws', async () => { + const result = await setup(scenario, true, 'json', true); + expect(JSON.parse(result.stdout).status).toBe('initialized'); + }); + }, +); diff --git a/src/commands/init.ts b/src/commands/init.ts index 5f5dbe1..f517cda 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -54,12 +54,31 @@ function resolveReportedEndpoint(opts: InitOptions, deps: InitDeps): string { let existing: string | undefined; try { existing = readProfile(opts.profile, { path: deps.credentialsPath })?.apiUrl; - } catch { + } catch (error) { + emitSetupDebug( + opts, + deps, + 'setup summary profile lookup failed; using endpoint fallback', + error, + ); existing = undefined; } return opts.endpointUrl ?? envApiUrl ?? existing ?? DEFAULT_API_URL; } +/** Report a display-only fallback without letting diagnostics interrupt setup. */ +function emitSetupDebug(opts: InitOptions, deps: InitDeps, context: string, error?: unknown): void { + if (!opts.debug) return; + try { + const reason = + error === undefined ? '' : `: ${error instanceof Error ? error.message : String(error)}`; + const write = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); + write(`[debug] ${context}${reason}`); + } catch { + // Setup already recovered; a broken diagnostic sink must not undo that. + } +} + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -558,10 +577,7 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise