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
2 changes: 2 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 111 additions & 0 deletions src/commands/init.debug.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
},
);
29 changes: 23 additions & 6 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -558,10 +577,7 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
} catch (err) {
// Whoami is display-only. If it fails after a successful configure,
// continue with a minimal placeholder so the summary still prints.
if (opts.debug) {
const reason = err instanceof Error ? err.message : String(err);
stderrFn(`[debug] setup identity lookup failed after configure: ${reason}`);
}
emitSetupDebug(opts, deps, 'setup identity lookup failed after configure', err);
me = { userId: '', keyId: '', scopes: [], env: 'production' };
}

Expand All @@ -586,7 +602,8 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
capturedInstallResults = parsed;
}
} catch {
// ignore non-JSON lines (shouldn't happen in json mode, but be safe)
// JSON parser errors can quote the input; do not echo captured install output.
emitSetupDebug(opts, deps, 'setup ignored non-JSON agent install output');
}
};

Expand Down
Loading