Files
PaperClipAI/cli/src/__tests__/test-drive.test.ts
T
DottaandPaperclip 3da58b185e fix(cli): restore test-drive credential inputs (#12898)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The CLI provides a test-drive command for a ready local test
instance.
> - That command must accept a provider credential before it creates the
first agent.
> - The command did not accept a literal key and could lose an exported
key during server startup.
> - This pull request accepts both credential paths and captures the CLI
environment before startup.
> - The benefit is a reliable one-command test drive from an existing
shell.

## Linked Issues or Issue Description

Refs #12894

**What happened?**

`paperclipai test-drive --api-key <value>` failed because the option did
not exist. An exported canonical provider variable could also become
unavailable before the post-listen bootstrap read it.

**Expected behavior**

The command must accept a literal key when the operator requests it. The
command must also use a provider variable that was present when the CLI
started.

**Steps to reproduce**

1. Export `ANTHROPIC_API_KEY` in the shell.
2. Run `pnpm paperclipai test-drive`.
3. Observe that bootstrap can report that no credential exists.
4. Run `pnpm paperclipai test-drive --api-key test-value`.
5. Observe that Commander reports an unknown option on the prior
implementation.

**Paperclip version or commit**

The problem exists on `master` after #12894.

**Deployment mode**

Local development with `pnpm` and the embedded database.

## What Changed

- Add the `--api-key <value>` test-drive option.
- Keep `--api-key` and `--api-key-env` mutually exclusive.
- Capture provider variables before in-process server startup changes
the process environment.
- Redact literal and environment-backed credentials from Paperclip
errors, including custom environment-variable names with surrounding
whitespace.
- Scrub split and joined literal-key forms from the JavaScript
`process.argv` view before telemetry, diagnostics, API work, or server
startup.
- Warn about process argument and shell history exposure without
printing the key.
- Add tests for literal keys, option conflicts, environment snapshots,
argv handling, and error redaction.
- Update the CLI and development documentation, including the remaining
external argv exposure tradeoff.

## Verification

- `pnpm exec vitest run cli/src/__tests__/test-drive.test.ts` passed 32
tests.
- `pnpm -r typecheck` passed.
- `pnpm build` passed.
- A live literal-key smoke test created one company and one CEO agent
without a provider call.
- A live exported-variable smoke test created the same clean instance
without credential flags.
- `pnpm test:run` completed locally with 5,870 passing tests and 19
host-dependent failures in six unrelated suites. The failures came from
macOS `/tmp` aliases, exhausted test ports, and existing
workspace-runtime fixture assumptions.
- GitHub CI passed the full build, typecheck, canary dry run, general
tests, serialized server tests, and e2e matrix on clean Linux runners.
- Greptile rated the exact latest head 5/5, Superagent passed, and all
review threads are resolved.

## Risks

- A raw value passed through `--api-key` can appear in operating-system
process listings, shell history, or parent-wrapper output before
Paperclip can scrub its own JavaScript argv view. The command warns
about this risk and Paperclip does not print the value.
- The environment snapshot contains the process environment only in
memory for the life of the foreground command.
- This change adds no schema migration and no REST endpoint.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex with GPT-5. The exact deployment version and context window
are not exposed to the agent. Extended reasoning, tool use, code
execution, and GitHub access were enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-05 10:48:17 -05:00

603 lines
22 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Agent, Company, InstanceExperimentalSettings } from "@paperclipai/shared";
import {
assertTestDriveDatabaseIsolation,
bootstrapTestDrive,
prepareTestDriveEnvironment,
reconcileTestDriveWorktreeExecution,
redactTestDriveArgv,
redactTestDriveText,
resolveTestDriveBootstrap,
resolveTestDriveDataDir,
testDriveCommand,
type TestDriveApi,
type TestDriveHarness,
} from "../commands/test-drive.js";
import type { RunOptions, StartedServer } from "../commands/run.js";
import type { PaperclipConfig } from "../config/schema.js";
const ORIGINAL_ENV = { ...process.env };
const cleanupDirectories: string[] = [];
function company(id = "company-1", name = "Test Company"): Company {
return { id, name } as Company;
}
function agent(overrides: Partial<Agent> = {}): Agent {
return {
id: "agent-1",
companyId: "company-1",
name: "CEO",
role: "ceo",
adapterType: "claude_local",
adapterConfig: {},
...overrides,
} as Agent;
}
function settings(overrides: Partial<InstanceExperimentalSettings> = {}): InstanceExperimentalSettings {
return {
enableWorktreeRunExecution: false,
worktreeRunExecutionActivatedAt: null,
worktreeRunExecutionActivationInstanceId: null,
...overrides,
} as InstanceExperimentalSettings;
}
function freshBootstrapApi(input?: { failAgent?: boolean }) {
const calls: Array<{ method: string; path: string; body?: unknown }> = [];
const api = {
get: vi.fn(async <T>(requestPath: string) => {
calls.push({ method: "GET", path: requestPath });
return [] as T;
}),
post: vi.fn(async <T>(requestPath: string, body?: unknown) => {
calls.push({ method: "POST", path: requestPath, body });
if (requestPath === "/api/companies") return company() as T;
if (requestPath.endsWith("/agents")) {
if (input?.failAgent) throw new Error("agent setup failed");
const payload = body as { name: string; adapterType: Agent["adapterType"]; adapterConfig: Record<string, unknown> };
return agent({
name: payload.name,
adapterType: payload.adapterType,
adapterConfig: payload.adapterConfig,
}) as T;
}
return { ok: true } as T;
}),
patch: vi.fn(async <T>() => ({ ok: true }) as T),
delete: vi.fn(async <T>(requestPath: string) => {
calls.push({ method: "DELETE", path: requestPath });
return { ok: true } as T;
}),
} as TestDriveApi;
return { api, calls };
}
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
while (cleanupDirectories.length > 0) {
fs.rmSync(cleanupDirectories.pop()!, { recursive: true, force: true });
}
vi.restoreAllMocks();
});
describe("test-drive data isolation", () => {
it("creates unique retained OS temporary directories and reports absolute paths", () => {
const first = resolveTestDriveDataDir();
const second = resolveTestDriveDataDir();
cleanupDirectories.push(first, second);
expect(path.isAbsolute(first)).toBe(true);
expect(path.dirname(first)).toBe(os.tmpdir());
expect(first).not.toBe(second);
expect(fs.existsSync(first)).toBe(true);
expect(fs.existsSync(second)).toBe(true);
});
it("resolves an explicit reusable directory without resetting it", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-test-drive-explicit-"));
cleanupDirectories.push(root);
const marker = path.join(root, "keep.txt");
fs.writeFileSync(marker, "keep");
expect(resolveTestDriveDataDir(root)).toBe(path.resolve(root));
expect(fs.readFileSync(marker, "utf8")).toBe("keep");
});
it("discards inherited Paperclip routing while preserving a custom key source", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-test-drive-env-"));
cleanupDirectories.push(root);
process.env.PAPERCLIP_HOME = "/normal/home";
process.env.PAPERCLIP_CONFIG = "/normal/config.json";
process.env.PAPERCLIP_IN_WORKTREE = "true";
process.env.PAPERCLIP_TEST_PROVIDER_KEY = "secret-value";
process.env.DATABASE_URL = "postgres://normal-instance";
const prepared = await prepareTestDriveEnvironment(
{ dataDir: root, apiKeyEnv: "PAPERCLIP_TEST_PROVIDER_KEY" },
os.tmpdir(),
);
expect(prepared.dataDir).toBe(path.resolve(root));
expect(prepared.linkedWorktree).toBe(false);
expect(process.env.PAPERCLIP_HOME).toBe(path.resolve(root));
expect(process.env.PAPERCLIP_CONFIG).toBe(
path.join(path.resolve(root), "instances", "default", "config.json"),
);
expect(process.env.PAPERCLIP_IN_WORKTREE).toBe("false");
expect(process.env.PAPERCLIP_DISABLE_CWD_ENV_FILE).toBe("true");
expect(process.env.PAPERCLIP_DEPLOYMENT_MODE).toBe("local_trusted");
expect(process.env.PAPERCLIP_DEPLOYMENT_EXPOSURE).toBe("private");
expect(process.env.PAPERCLIP_BIND).toBe("loopback");
expect(process.env.HOST).toBe("127.0.0.1");
expect(process.env.PAPERCLIP_TEST_PROVIDER_KEY).toBe("secret-value");
expect(process.env.DATABASE_URL).toBeUndefined();
expect(Number(process.env.PORT)).toBeGreaterThanOrEqual(3100);
});
it.each(["DATABASE_URL", "DATABASE_MIGRATION_URL"])(
"rejects %s loaded from the isolated directory",
(variable) => {
const readConfigFile = vi.fn(() => null);
expect(() => assertTestDriveDatabaseIsolation(
undefined,
{ [variable]: "postgres://external-database" },
readConfigFile,
)).toThrow(/requires its isolated embedded database/);
expect(readConfigFile).not.toHaveBeenCalled();
},
);
it("rejects an explicitly reused PostgreSQL configuration", () => {
const externalConfig = {
database: { mode: "postgres" },
} as PaperclipConfig;
expect(() => assertTestDriveDatabaseIsolation(
"/tmp/reused/config.json",
{},
() => externalConfig,
)).toThrow(/cannot reuse.*external PostgreSQL database/);
});
it("accepts an embedded configuration", () => {
const embeddedConfig = {
database: { mode: "embedded-postgres" },
} as PaperclipConfig;
expect(() => assertTestDriveDatabaseIsolation(
"/tmp/reused/config.json",
{},
() => embeddedConfig,
)).not.toThrow();
});
});
describe("test-drive bootstrap validation", () => {
it("uses Claude defaults and the canonical environment credential", () => {
const resolved = resolveTestDriveBootstrap({}, { ANTHROPIC_API_KEY: "anthropic-secret" });
expect(resolved).toMatchObject({
companyName: "Test Company",
agentName: "CEO",
adapterType: "claude_local",
credentialTarget: "ANTHROPIC_API_KEY",
credential: "anthropic-secret",
});
expect(resolved.model).toBeUndefined();
});
it("maps all harnesses and leaves Claude/Codex models optional", () => {
const cases: Array<[TestDriveHarness, string, string]> = [
["claude", "claude_local", "ANTHROPIC_API_KEY"],
["codex", "codex_local", "OPENAI_API_KEY"],
["opencode", "opencode_local", "OPENROUTER_API_KEY"],
];
for (const [harness, adapterType, credentialTarget] of cases) {
const resolved = resolveTestDriveBootstrap(
{
harness,
...(harness === "opencode" ? { model: "openrouter/anthropic/claude-sonnet-4.5" } : {}),
},
{ [credentialTarget]: "provider-secret" },
);
expect(resolved.adapterType).toBe(adapterType);
expect(resolved.credentialTarget).toBe(credentialTarget);
}
});
it("requires an OpenRouter OpenCode model and preserves every model path segment", () => {
expect(() => resolveTestDriveBootstrap(
{ harness: "opencode" },
{ OPENROUTER_API_KEY: "secret" },
)).toThrow(/require --model openrouter/);
for (const model of ["anthropic/claude", "openrouter/", "openrouter//claude", "openrouter/a/"]) {
expect(() => resolveTestDriveBootstrap(
{ harness: "opencode", model },
{ OPENROUTER_API_KEY: "secret" },
)).toThrow(/require --model openrouter/);
}
const model = "openrouter/publisher/family/model";
expect(resolveTestDriveBootstrap(
{ harness: "opencode", model },
{ OPENROUTER_API_KEY: "secret" },
).model).toBe(model);
});
it("supports custom source variables while retaining the canonical target", () => {
const resolved = resolveTestDriveBootstrap(
{
harness: "opencode",
model: "openrouter/anthropic/claude-sonnet-4.5",
apiKeyEnv: "MY_OPENROUTER_KEY",
},
{ MY_OPENROUTER_KEY: "custom-secret" },
);
expect(resolved.credential).toBe("custom-secret");
expect(resolved.credentialSource).toBe("MY_OPENROUTER_KEY");
expect(resolved.credentialTarget).toBe("OPENROUTER_API_KEY");
});
it("accepts a literal key and gives it precedence over canonical environment lookup", () => {
const resolved = resolveTestDriveBootstrap(
{ apiKey: "literal-secret" },
{ ANTHROPIC_API_KEY: "environment-secret" },
);
expect(resolved.credential).toBe("literal-secret");
expect(resolved.credentialSource).toBe("--api-key");
});
it("rejects mutually exclusive key inputs", () => {
expect(() => resolveTestDriveBootstrap({
apiKey: "literal-secret",
apiKeyEnv: "ANTHROPIC_API_KEY",
}, { ANTHROPIC_API_KEY: "environment-secret" })).toThrow(/mutually exclusive/);
});
it("rejects invalid key variable names and redacts credentials", () => {
expect(() => resolveTestDriveBootstrap({
apiKeyEnv: "NOT-A-VALID-NAME",
}, { ANTHROPIC_API_KEY: "env-secret" })).toThrow(/valid environment variable/);
expect(redactTestDriveText(
"literal-secret, custom-secret, and env-secret must never appear",
["literal-secret", "custom-secret", "env-secret"],
)).toBe("[REDACTED], [REDACTED], and [REDACTED] must never appear");
});
it("removes literal keys from the JavaScript argv view", () => {
const splitArgv = ["node", "paperclipai", "test-drive", "--api-key", "literal-secret"];
const joinedArgv = ["node", "paperclipai", "test-drive", "--api-key=literal-secret"];
redactTestDriveArgv("literal-secret", splitArgv);
redactTestDriveArgv("literal-secret", joinedArgv);
expect(splitArgv).toEqual(["node", "paperclipai", "test-drive", "--api-key", "[REDACTED]"]);
expect(joinedArgv).toEqual(["node", "paperclipai", "test-drive", "--api-key=[REDACTED]"]);
});
});
describe("test-drive API bootstrap", () => {
it.each([
["claude", "claude_local", "ANTHROPIC_API_KEY", undefined],
["codex", "codex_local", "OPENAI_API_KEY", undefined],
["opencode", "opencode_local", "OPENROUTER_API_KEY", "openrouter/anthropic/claude-sonnet-4.5"],
] as const)("creates exactly one company and one CEO for %s", async (
harness,
adapterType,
credentialTarget,
model,
) => {
const { api, calls } = freshBootstrapApi();
const result = await bootstrapTestDrive({
api,
options: { harness, ...(model ? { model } : {}) },
linkedWorktree: false,
instanceId: "default",
env: { [credentialTarget]: "secret" },
});
expect(result.reused).toBe(false);
expect(result.agent?.role).toBe("ceo");
expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual([
"GET /api/companies",
"POST /api/companies",
"POST /api/companies/company-1/user-secret-definitions",
"POST /api/companies/company-1/me/user-secrets",
"POST /api/companies/company-1/agents",
]);
const secretValueCall = calls.find((call) => call.path.endsWith("/me/user-secrets"));
expect(secretValueCall?.body).toEqual({ definitionKey: credentialTarget, value: "secret" });
const agentCall = calls.find((call) => call.path.endsWith("/agents"));
expect(agentCall?.body).toEqual({
name: "CEO",
role: "ceo",
adapterType,
adapterConfig: {
...(model ? { model } : {}),
env: {
[credentialTarget]: {
type: "user_secret_ref",
key: credentialTarget,
version: "latest",
required: true,
},
},
},
});
expect(calls.some((call) => /issues|projects|goals|tasks|heartbeat/.test(call.path))).toBe(false);
});
it("rejects invalid OpenCode configuration before creating a company", async () => {
const { api, calls } = freshBootstrapApi();
await expect(bootstrapTestDrive({
api,
options: { harness: "opencode" },
linkedWorktree: false,
instanceId: "default",
env: { OPENROUTER_API_KEY: "secret" },
})).rejects.toThrow(/require --model openrouter/);
expect(calls).toEqual([{ method: "GET", path: "/api/companies" }]);
});
it("preserves seeded data and ignores every bootstrap flag", async () => {
const get = vi.fn(async <T>(requestPath: string) => {
if (requestPath === "/api/companies") return [company("existing", "Existing Company")] as T;
throw new Error(`Unexpected GET ${requestPath}`);
});
const api = {
get,
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
} as unknown as TestDriveApi;
const result = await bootstrapTestDrive({
api,
options: { harness: "opencode", companyName: "Ignored" },
linkedWorktree: false,
instanceId: "default",
env: {},
});
expect(result).toMatchObject({ reused: true, company: { id: "existing" }, agent: null });
expect(api.post).not.toHaveBeenCalled();
expect(api.patch).not.toHaveBeenCalled();
expect(api.delete).not.toHaveBeenCalled();
});
it("deletes only the newly-created company when fresh bootstrap fails", async () => {
const { api, calls } = freshBootstrapApi({ failAgent: true });
await expect(bootstrapTestDrive({
api,
options: {},
linkedWorktree: false,
instanceId: "default",
env: { ANTHROPIC_API_KEY: "secret" },
})).rejects.toThrow("agent setup failed");
expect(calls.at(-1)).toEqual({ method: "DELETE", path: "/api/companies/company-1" });
});
});
describe("test-drive worktree setting reconciliation", () => {
function worktreeApi(initial: InstanceExperimentalSettings, instanceId = "test-instance") {
let current = initial;
const patchBodies: unknown[] = [];
const api = {
get: vi.fn(async <T>() => current as T),
patch: vi.fn(async <T>(_path: string, body?: unknown) => {
patchBodies.push(body);
const enabled = (body as { enableWorktreeRunExecution: boolean }).enableWorktreeRunExecution;
current = settings({
...current,
enableWorktreeRunExecution: enabled,
worktreeRunExecutionActivatedAt: enabled ? "2026-09-05T12:00:00.000Z" : null,
worktreeRunExecutionActivationInstanceId: enabled ? instanceId : null,
});
return current as T;
}),
post: vi.fn(),
delete: vi.fn(),
} as unknown as TestDriveApi;
return { api, patchBodies };
}
it("enables a disabled setting", async () => {
const { api, patchBodies } = worktreeApi(settings());
await reconcileTestDriveWorktreeExecution(api, "test-instance");
expect(patchBodies).toEqual([{ enableWorktreeRunExecution: true }]);
});
it("leaves a correctly armed setting unchanged", async () => {
const { api, patchBodies } = worktreeApi(settings({
enableWorktreeRunExecution: true,
worktreeRunExecutionActivatedAt: "2026-09-05T11:00:00.000Z",
worktreeRunExecutionActivationInstanceId: "test-instance",
}));
await reconcileTestDriveWorktreeExecution(api, "test-instance");
expect(patchBodies).toEqual([]);
});
it.each([
settings({ enableWorktreeRunExecution: true }),
settings({
enableWorktreeRunExecution: true,
worktreeRunExecutionActivatedAt: "2026-09-05T11:00:00.000Z",
worktreeRunExecutionActivationInstanceId: "another-instance",
}),
])("rearms missing or mismatched activation metadata", async (initial) => {
const { api, patchBodies } = worktreeApi(initial);
await reconcileTestDriveWorktreeExecution(api, "test-instance");
expect(patchBodies).toEqual([
{ enableWorktreeRunExecution: false },
{ enableWorktreeRunExecution: true },
]);
});
it("fails when the setting cannot be armed for this instance", async () => {
const api = {
get: vi.fn(async <T>() => settings() as T),
patch: vi.fn(async <T>() => settings() as T),
post: vi.fn(),
delete: vi.fn(),
} as unknown as TestDriveApi;
await expect(reconcileTestDriveWorktreeExecution(api, "test-instance"))
.rejects.toThrow(/Could not arm/);
});
});
describe("test-drive foreground lifecycle", () => {
const server: StartedServer = {
apiUrl: "http://127.0.0.1:3100/api",
databaseUrl: "postgres://embedded",
host: "127.0.0.1",
listenPort: 3100,
};
it("skips service-manager integration for an auto-created directory and opens after initialization", async () => {
process.env.PAPERCLIP_HOME = "/tmp/test-drive-lifecycle";
process.env.PAPERCLIP_INSTANCE_ID = "default";
process.env.PAPERCLIP_IN_WORKTREE = "false";
process.env.ANTHROPIC_API_KEY = "secret";
const events: string[] = [];
let runOptions: RunOptions | undefined;
const api = {
get: vi.fn(async <T>() => {
events.push("initialized");
return [company("existing", "Existing")] as T;
}),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
} as unknown as TestDriveApi;
await testDriveCommand({}, {
run: async (options) => {
runOptions = options;
events.push("listening");
await options.afterStart?.(server);
},
createApi: () => api,
openBrowser: async () => {
events.push("browser");
return true;
},
});
expect(runOptions).toMatchObject({
yes: true,
bind: "loopback",
installService: false,
skipServiceManagerCheck: true,
introLabel: "paperclipai test-drive",
});
expect(events).toEqual(["listening", "initialized", "browser"]);
});
it("retains the managed-instance collision guard for an explicitly reused directory", async () => {
process.env.PAPERCLIP_HOME = "/tmp/test-drive-reused";
process.env.PAPERCLIP_INSTANCE_ID = "default";
process.env.PAPERCLIP_IN_WORKTREE = "false";
let runOptions: RunOptions | undefined;
const api = {
get: vi.fn(async <T>() => [company("existing", "Existing")] as T),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
} as unknown as TestDriveApi;
await testDriveCommand({ dataDir: "/tmp/test-drive-reused", browser: false }, {
run: async (options) => {
runOptions = options;
await options.afterStart?.(server);
},
createApi: () => api,
openBrowser: vi.fn(async () => true),
});
expect(runOptions?.skipServiceManagerCheck).toBe(false);
});
it("uses the credential snapshot captured before downstream server initialization", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-test-drive-credential-"));
cleanupDirectories.push(root);
process.env.PAPERCLIP_HOME = root;
process.env.PAPERCLIP_INSTANCE_ID = "default";
process.env.PAPERCLIP_IN_WORKTREE = "false";
process.env.ANTHROPIC_API_KEY = "upstream-secret";
const { api, calls } = freshBootstrapApi();
await testDriveCommand({ browser: false }, {
run: async (options) => {
delete process.env.ANTHROPIC_API_KEY;
await options.afterStart?.(server);
},
createApi: () => api,
openBrowser: vi.fn(async () => true),
});
const secretValueCall = calls.find((call) => call.path.endsWith("/me/user-secrets"));
expect(secretValueCall?.body).toEqual({
definitionKey: "ANTHROPIC_API_KEY",
value: "upstream-secret",
});
});
it("redacts a literal key from downstream errors", async () => {
process.env.PAPERCLIP_HOME = "/tmp/test-drive-redaction";
process.env.PAPERCLIP_INSTANCE_ID = "default";
process.env.PAPERCLIP_IN_WORKTREE = "false";
await expect(testDriveCommand({
apiKey: "literal-secret",
browser: false,
}, {
run: async () => {
throw new Error("downstream rejected literal-secret");
},
createApi: () => freshBootstrapApi().api,
openBrowser: vi.fn(async () => true),
})).rejects.toThrow("downstream rejected [REDACTED]");
});
it("redacts a custom environment key when its option name has whitespace", async () => {
process.env.PAPERCLIP_HOME = "/tmp/test-drive-custom-env-redaction";
process.env.PAPERCLIP_INSTANCE_ID = "default";
process.env.PAPERCLIP_IN_WORKTREE = "false";
process.env.CUSTOM_TEST_DRIVE_KEY = "custom-secret";
await expect(testDriveCommand({
apiKeyEnv: " CUSTOM_TEST_DRIVE_KEY ",
browser: false,
}, {
run: async () => {
throw new Error("downstream rejected custom-secret");
},
createApi: () => freshBootstrapApi().api,
openBrowser: vi.fn(async () => true),
})).rejects.toThrow("downstream rejected [REDACTED]");
});
it("honors --no-browser after successful initialization", async () => {
process.env.PAPERCLIP_HOME = "/tmp/test-drive-no-browser";
process.env.PAPERCLIP_INSTANCE_ID = "default";
process.env.PAPERCLIP_IN_WORKTREE = "false";
const api = {
get: vi.fn(async <T>() => [company("existing", "Existing")] as T),
post: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
} as unknown as TestDriveApi;
const openBrowser = vi.fn(async () => true);
await testDriveCommand({ browser: false }, {
run: async (options) => options.afterStart?.(server),
createApi: () => api,
openBrowser,
});
expect(openBrowser).not.toHaveBeenCalled();
});
});