Files
PaperClipAI/cli/src/__tests__/env-lab.test.ts
T
3d6afde104 build(deps): bump @clack/prompts from 0.11.0 to 1.7.0 (#11724)
Bumps
[@clack/prompts](https://github.com/bombshell-dev/clack/tree/HEAD/packages/prompts)
from 0.11.0 to 1.7.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/bombshell-dev/clack/releases">@​clack/prompts's
releases</a>.</em></p>
<blockquote>
<h2><code>@​clack/prompts</code><a
href="https://github.com/1"><code>@​1</code></a>.7.0</h2>
<h3>Minor Changes</h3>
<ul>
<li><a
href="https://redirect.github.com/bombshell-dev/clack/pull/574">#574</a>
<a
href="https://github.com/bombshell-dev/clack/commit/8f1c380683cfb5fb7a094610342046ae1603e28e"><code>8f1c380</code></a>
Thanks <a
href="https://github.com/dreyfus92"><code>@​dreyfus92</code></a>! - Add
<code>showInstructions</code> option to <code>select</code>,
<code>multiselect</code>, and <code>groupMultiselect</code>. Keyboard
hints remain shown by default; pass <code>showInstructions: false</code>
to hide them.</li>
</ul>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/bombshell-dev/clack/pull/582">#582</a>
<a
href="https://github.com/bombshell-dev/clack/commit/4b249539e28eecc16c13b3a1bd0ba447e9f38d06"><code>4b24953</code></a>
Thanks <a href="https://github.com/43081j"><code>@​43081j</code></a>! -
Handle empty arrays in various prompts and utilities.</p>
</li>
<li>
<p>Updated dependencies [<a
href="https://github.com/bombshell-dev/clack/commit/4b249539e28eecc16c13b3a1bd0ba447e9f38d06"><code>4b24953</code></a>]:</p>
<ul>
<li><code>@​clack/core</code><a
href="https://github.com/1"><code>@​1</code></a>.4.3</li>
</ul>
</li>
</ul>
<h2><code>@​clack/prompts</code><a
href="https://github.com/1"><code>@​1</code></a>.6.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/bombshell-dev/clack/pull/568">#568</a>
<a
href="https://github.com/bombshell-dev/clack/commit/f87933fb7b3f4c401b9e51a152b95cb8e7200fe5"><code>f87933f</code></a>
Thanks <a
href="https://github.com/florian-lefebvre"><code>@​florian-lefebvre</code></a>!
- Updates default formatter of <code>note()</code> to note dim lines
anymore</p>
<p>If you want the old behavior, provide a <code>format()</code>
function:</p>
<pre lang="diff"><code>import { note } from '@clack/prompts';
+import { styleText } from 'node:util';
<p>note(
'You can edit the file src/index.jsx',
'Next steps.'</p>
<ul>
<li>{ format: (text) =&gt; styleText('dim', text) }
);
</code></pre></li>
</ul>
</li>
<li>
<p><a
href="https://redirect.github.com/bombshell-dev/clack/pull/567">#567</a>
<a
href="https://github.com/bombshell-dev/clack/commit/cc6aab50186cff8a02dc98e9cfd3897c29a33b15"><code>cc6aab5</code></a>
Thanks <a
href="https://github.com/dreyfus92"><code>@​dreyfus92</code></a>! - Add
keyboard instruction footers to <code>select</code>,
<code>multiselect</code>, and <code>groupMultiselect</code> in the
active state, matching autocomplete. No option — always shown.</p>
</li>
</ul>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [<a
href="https://github.com/bombshell-dev/clack/commit/2f2b52f77cbfa9af618c6d929249ab8395fc37a1"><code>2f2b52f</code></a>,
<a
href="https://github.com/bombshell-dev/clack/commit/e1b6ee71a76e17a3c33ba7ee6e5fb34e886233bb"><code>e1b6ee7</code></a>]:
<ul>
<li><code>@​clack/core</code><a
href="https://github.com/1"><code>@​1</code></a>.4.2</li>
</ul>
</li>
</ul>
<h2><code>@​clack/prompts</code><a
href="https://github.com/1"><code>@​1</code></a>.5.1</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/bombshell-dev/clack/pull/548">#548</a>
<a
href="https://github.com/bombshell-dev/clack/commit/2356e97c1f46007ead55133c3a26910404ef1cfb"><code>2356e97</code></a>
Thanks <a href="https://github.com/43081j"><code>@​43081j</code></a>! -
Remove sourcemaps and enable pretty-ish build output.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/bombshell-dev/clack/pull/546">#546</a>
<a
href="https://github.com/bombshell-dev/clack/commit/56e9d6707715bc858d9c2dbc444230b02813e809"><code>56e9d67</code></a>
Thanks <a
href="https://github.com/ghostdevv"><code>@​ghostdevv</code></a>! -
docs: add jsdoc for <code>date</code>, <code>limit-options</code>, and
<code>messages</code></p>
</li>
<li>
<p>Updated dependencies [<a
href="https://github.com/bombshell-dev/clack/commit/2356e97c1f46007ead55133c3a26910404ef1cfb"><code>2356e97</code></a>]:</p>
<ul>
<li><code>@​clack/core</code><a
href="https://github.com/1"><code>@​1</code></a>.4.1</li>
</ul>
</li>
</ul>
<h2><code>@​clack/prompts</code><a
href="https://github.com/1"><code>@​1</code></a>.5.0</h2>
<h3>Minor Changes</h3>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/bombshell-dev/clack/blob/main/packages/prompts/CHANGELOG.md">@​clack/prompts's
changelog</a>.</em></p>
<blockquote>
<h2>1.7.0</h2>
<h3>Minor Changes</h3>
<ul>
<li><a
href="https://redirect.github.com/bombshell-dev/clack/pull/574">#574</a>
<a
href="https://github.com/bombshell-dev/clack/commit/8f1c380683cfb5fb7a094610342046ae1603e28e"><code>8f1c380</code></a>
Thanks <a
href="https://github.com/dreyfus92"><code>@​dreyfus92</code></a>! - Add
<code>showInstructions</code> option to <code>select</code>,
<code>multiselect</code>, and <code>groupMultiselect</code>. Keyboard
hints remain shown by default; pass <code>showInstructions: false</code>
to hide them.</li>
</ul>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/bombshell-dev/clack/pull/582">#582</a>
<a
href="https://github.com/bombshell-dev/clack/commit/4b249539e28eecc16c13b3a1bd0ba447e9f38d06"><code>4b24953</code></a>
Thanks <a href="https://github.com/43081j"><code>@​43081j</code></a>! -
Handle empty arrays in various prompts and utilities.</p>
</li>
<li>
<p>Updated dependencies [<a
href="https://github.com/bombshell-dev/clack/commit/4b249539e28eecc16c13b3a1bd0ba447e9f38d06"><code>4b24953</code></a>]:</p>
<ul>
<li><code>@​clack/core</code><a
href="https://github.com/1"><code>@​1</code></a>.4.3</li>
</ul>
</li>
</ul>
<h2>1.6.0</h2>
<h3>Minor Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/bombshell-dev/clack/pull/568">#568</a>
<a
href="https://github.com/bombshell-dev/clack/commit/f87933fb7b3f4c401b9e51a152b95cb8e7200fe5"><code>f87933f</code></a>
Thanks <a
href="https://github.com/florian-lefebvre"><code>@​florian-lefebvre</code></a>!
- Updates default formatter of <code>note()</code> to note dim lines
anymore</p>
<p>If you want the old behavior, provide a <code>format()</code>
function:</p>
<pre lang="diff"><code>import { note } from '@clack/prompts';
+import { styleText } from 'node:util';
<p>note(
'You can edit the file src/index.jsx',
'Next steps.'</p>
<ul>
<li>{ format: (text) =&gt; styleText('dim', text) }
);
</code></pre></li>
</ul>
</li>
<li>
<p><a
href="https://redirect.github.com/bombshell-dev/clack/pull/567">#567</a>
<a
href="https://github.com/bombshell-dev/clack/commit/cc6aab50186cff8a02dc98e9cfd3897c29a33b15"><code>cc6aab5</code></a>
Thanks <a
href="https://github.com/dreyfus92"><code>@​dreyfus92</code></a>! - Add
keyboard instruction footers to <code>select</code>,
<code>multiselect</code>, and <code>groupMultiselect</code> in the
active state, matching autocomplete. No option — always shown.</p>
</li>
</ul>
<h3>Patch Changes</h3>
<ul>
<li>Updated dependencies [<a
href="https://github.com/bombshell-dev/clack/commit/2f2b52f77cbfa9af618c6d929249ab8395fc37a1"><code>2f2b52f</code></a>,
<a
href="https://github.com/bombshell-dev/clack/commit/e1b6ee71a76e17a3c33ba7ee6e5fb34e886233bb"><code>e1b6ee7</code></a>]:
<ul>
<li><code>@​clack/core</code><a
href="https://github.com/1"><code>@​1</code></a>.4.2</li>
</ul>
</li>
</ul>
<h2>1.5.1</h2>
<h3>Patch Changes</h3>
<ul>
<li>
<p><a
href="https://redirect.github.com/bombshell-dev/clack/pull/548">#548</a>
<a
href="https://github.com/bombshell-dev/clack/commit/2356e97c1f46007ead55133c3a26910404ef1cfb"><code>2356e97</code></a>
Thanks <a href="https://github.com/43081j"><code>@​43081j</code></a>! -
Remove sourcemaps and enable pretty-ish build output.</p>
</li>
<li>
<p><a
href="https://redirect.github.com/bombshell-dev/clack/pull/546">#546</a>
<a
href="https://github.com/bombshell-dev/clack/commit/56e9d6707715bc858d9c2dbc444230b02813e809"><code>56e9d67</code></a>
Thanks <a
href="https://github.com/ghostdevv"><code>@​ghostdevv</code></a>! -
docs: add jsdoc for <code>date</code>, <code>limit-options</code>, and
<code>messages</code></p>
</li>
<li>
<p>Updated dependencies [<a
href="https://github.com/bombshell-dev/clack/commit/2356e97c1f46007ead55133c3a26910404ef1cfb"><code>2356e97</code></a>]:</p>
<ul>
<li><code>@​clack/core</code><a
href="https://github.com/1"><code>@​1</code></a>.4.1</li>
</ul>
</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/bombshell-dev/clack/commit/dc5bce8aae84a57b5863124adfaa839c1db1fa23"><code>dc5bce8</code></a>
[ci] release (<a
href="https://github.com/bombshell-dev/clack/tree/HEAD/packages/prompts/issues/575">#575</a>)</li>
<li><a
href="https://github.com/bombshell-dev/clack/commit/4b249539e28eecc16c13b3a1bd0ba447e9f38d06"><code>4b24953</code></a>
chore: enable strict index checks (<a
href="https://github.com/bombshell-dev/clack/tree/HEAD/packages/prompts/issues/582">#582</a>)</li>
<li><a
href="https://github.com/bombshell-dev/clack/commit/8f1c380683cfb5fb7a094610342046ae1603e28e"><code>8f1c380</code></a>
feat(prompts): add showInstructions opt-out (<a
href="https://github.com/bombshell-dev/clack/tree/HEAD/packages/prompts/issues/574">#574</a>)</li>
<li><a
href="https://github.com/bombshell-dev/clack/commit/06c16c7b65b69dee44301e7e6c8f1110c6da4149"><code>06c16c7</code></a>
chore: update license copyright to Bombshell contributors (<a
href="https://github.com/bombshell-dev/clack/tree/HEAD/packages/prompts/issues/572">#572</a>)</li>
<li><a
href="https://github.com/bombshell-dev/clack/commit/0e700562dfb54bd693d98c5b7e28d34213fcb226"><code>0e70056</code></a>
[ci] release (<a
href="https://github.com/bombshell-dev/clack/tree/HEAD/packages/prompts/issues/562">#562</a>)</li>
<li><a
href="https://github.com/bombshell-dev/clack/commit/f87933fb7b3f4c401b9e51a152b95cb8e7200fe5"><code>f87933f</code></a>
fix(prompts): do not dim note contents (<a
href="https://github.com/bombshell-dev/clack/tree/HEAD/packages/prompts/issues/568">#568</a>)</li>
<li><a
href="https://github.com/bombshell-dev/clack/commit/cc6aab50186cff8a02dc98e9cfd3897c29a33b15"><code>cc6aab5</code></a>
feat(prompts): add instructions footer for <code>select</code>,
<code>multi-select</code> &amp; `group-...</li>
<li><a
href="https://github.com/bombshell-dev/clack/commit/02ae19193ed1155a57fb3dfd830ecc356a1cc11a"><code>02ae191</code></a>
[ci] release (<a
href="https://github.com/bombshell-dev/clack/tree/HEAD/packages/prompts/issues/549">#549</a>)</li>
<li><a
href="https://github.com/bombshell-dev/clack/commit/56e9d6707715bc858d9c2dbc444230b02813e809"><code>56e9d67</code></a>
docs: add jsdoc for <code>date</code>, <code>limit-options</code>, and
<code>messages</code> (<a
href="https://github.com/bombshell-dev/clack/tree/HEAD/packages/prompts/issues/546">#546</a>)</li>
<li><a
href="https://github.com/bombshell-dev/clack/commit/030ba4d776e5985050c0e2712da7008bd93b8847"><code>030ba4d</code></a>
[ci] release (<a
href="https://github.com/bombshell-dev/clack/tree/HEAD/packages/prompts/issues/539">#539</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/bombshell-dev/clack/commits/@clack/prompts@1.7.0/packages/prompts">compare
view</a></li>
</ul>
</details>
<details>
<summary>Maintainer changes</summary>
<p>This version was pushed to npm by <a
href="https://www.npmjs.com/~GitHub%20Actions">GitHub Actions</a>, a new
releaser for <code>@​clack/prompts</code> since your current
version.</p>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=@clack/prompts&package-manager=npm_and_yarn&previous-version=0.11.0&new-version=1.7.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Priya Raman <priya@paperclip.ing>
Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-08-20 15:57:20 -07:00

288 lines
11 KiB
TypeScript

import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import * as p from "@clack/prompts";
import {
buildEnvLabCleanupCommand,
collectEnvLabDoctorStatus,
envLabDoctorCommand,
resolveEnvLabCliInvocation,
resolveEnvLabSshStatePath,
} from "../commands/env-lab.js";
describe("env-lab command", () => {
it("resolves the default SSH fixture state path under the instance root", () => {
const statePath = resolveEnvLabSshStatePath("fixture-test");
expect(statePath).toContain(
path.join("instances", "fixture-test", "env-lab", "ssh-fixture", "state.json"),
);
});
it("reports doctor status for an instance without a running fixture", async () => {
const status = await collectEnvLabDoctorStatus({ instance: "fixture-test-missing" });
expect(status.statePath).toContain(
path.join("instances", "fixture-test-missing", "env-lab", "ssh-fixture", "state.json"),
);
expect(typeof status.ssh.supported).toBe("boolean");
expect(status.ssh.running).toBe(false);
expect(status.ssh.environment).toBeNull();
});
});
describe("env-lab cleanup command hint", () => {
const originalCwd = process.cwd();
afterEach(() => {
process.chdir(originalCwd);
});
// Resolve a source-checkout invocation for a fabricated checkout root. A source
// checkout runs this module from `<root>/src/commands/env-lab.ts`, so the
// resolver reads that layout and returns the tsx runner and source entry.
function sourceInvocation(root: string) {
return resolveEnvLabCliInvocation(path.join(root, "src", "commands", "env-lab.ts"));
}
// Resolve a bundled-build invocation for a fabricated package root. The bundled
// build runs this module from `<root>/dist/index.js`, so the resolver returns
// that file as the entry with no tsx runner.
function bundledInvocation(root: string) {
return resolveEnvLabCliInvocation(path.join(root, "dist", "index.js"));
}
// Split a command that uses POSIX single-quoting into its argument tokens. The
// parser reads a single-quoted span verbatim and reads `\'` outside a span as a
// literal single quote. This is the same rule a POSIX shell obeys, so a token
// list proves the shell reads the exact paths and runs no embedded command.
function tokenizePosix(command: string): string[] {
const tokens: string[] = [];
let current = "";
let started = false;
let inQuotes = false;
for (let index = 0; index < command.length; index += 1) {
const character = command[index];
if (inQuotes) {
if (character === "'") {
inQuotes = false;
} else {
current += character;
}
} else if (character === "'") {
inQuotes = true;
started = true;
} else if (character === "\\") {
index += 1;
current += command[index];
started = true;
} else if (character === " ") {
if (started) {
tokens.push(current);
current = "";
started = false;
}
} else {
current += character;
started = true;
}
}
if (started) {
tokens.push(current);
}
return tokens;
}
// Return the two path arguments from the `node` command.
function extractPaths(command: string): string[] {
const tokens = tokenizePosix(command);
return tokens.slice(1, 3);
}
it("resolves both CLI paths to absolute paths", () => {
const command = buildEnvLabCleanupCommand();
const paths = extractPaths(command);
expect(paths).toHaveLength(2);
for (const resolved of paths) {
expect(path.isAbsolute(resolved)).toBe(true);
}
expect(command.endsWith("env-lab down")).toBe(true);
});
it("points at the checked-out tsx runner and cli source entry", () => {
const [tsxBin, entry] = extractPaths(buildEnvLabCleanupCommand());
expect(tsxBin).toContain(
path.join("cli", "node_modules", "tsx", "dist", "cli.mjs"),
);
expect(entry).toContain(path.join("cli", "src", "index.ts"));
});
it("returns the same command from a checkout subdirectory", () => {
const fromRoot = buildEnvLabCleanupCommand();
// Simulate a contributor who runs `env-lab doctor` from a subdirectory of
// the checkout. A relative path would change with the working directory, so
// this asserts the command stays constant.
process.chdir(path.dirname(originalCwd));
const fromParent = buildEnvLabCleanupCommand();
process.chdir(originalCwd);
expect(fromParent).toBe(fromRoot);
});
it("never restores the unsafe pnpm invocation forms", () => {
const command = buildEnvLabCleanupCommand();
// The bare `pnpm paperclipai` script form is unsafe. The `pnpm exec` form
// does not resolve the CLI binary. Keep both out of the hint.
expect(command).not.toContain("pnpm paperclipai");
expect(command).not.toContain("pnpm exec paperclipai");
});
// A checkout path can hold shell metacharacters. A contributor copies the hint
// and pastes it into a shell. The hint must neutralize each metacharacter, so
// the shell reads the exact path and runs no embedded command. Each case below
// is a checkout root with one dangerous construct.
const dangerousRoots = [
{ label: "a dollar sign", root: "/tmp/env$lab/checkout" },
{ label: "command substitution", root: "/tmp/$(touch pwned)/checkout" },
{ label: "backticks", root: "/tmp/`touch pwned`/checkout" },
{ label: "a double quote", root: '/tmp/env"lab/checkout' },
{ label: "a single quote", root: "/tmp/env'lab/checkout" },
];
for (const { label, root } of dangerousRoots) {
it(`keeps a checkout path with ${label} inert in the cleanup hint`, () => {
const command = buildEnvLabCleanupCommand({ invocation: sourceInvocation(root) });
const tokens = tokenizePosix(command);
const tsxBin = path.join(root, "node_modules", "tsx", "dist", "cli.mjs");
const entry = path.join(root, "src", "index.ts");
// The shell reads the exact paths as single argument tokens. It does not
// split the paths or run the embedded construct.
expect(tokens).toEqual(["node", tsxBin, entry, "env-lab", "down"]);
// The old double-quoted form left `$(...)`, a backtick pair, and `$NAME`
// live. Do not restore it.
expect(command).not.toContain(`"${tsxBin}"`);
expect(command).not.toContain(`"${entry}"`);
});
}
it("forwards the inspected instance to the cleanup hint", () => {
const command = buildEnvLabCleanupCommand({
instance: "fixture-test",
invocation: sourceInvocation("/tmp/checkout"),
});
const tokens = tokenizePosix(command);
// The hint ends with `--instance <id>`, so it stops the fixture the doctor
// command diagnosed, not the default instance.
expect(tokens.slice(-2)).toEqual(["--instance", "fixture-test"]);
});
it("omits the instance flag when the doctor command uses the default instance", () => {
const command = buildEnvLabCleanupCommand({ invocation: sourceInvocation("/tmp/checkout") });
// Without a selected instance, `env-lab down` resolves the same default
// instance the doctor command inspected. Do not add an empty flag.
expect(command).not.toContain("--instance");
expect(command.endsWith("env-lab down")).toBe(true);
});
it("keeps an instance id with shell metacharacters inert", () => {
const command = buildEnvLabCleanupCommand({
instance: "$(touch pwned)",
invocation: sourceInvocation("/tmp/checkout"),
});
const tokens = tokenizePosix(command);
// The shell reads the instance id as one literal token and runs no embedded
// command.
expect(tokens.slice(-2)).toEqual(["--instance", "$(touch pwned)"]);
expect(command).not.toContain('"$(touch pwned)"');
});
it("runs the bundled dist entry directly, without the tsx runner", () => {
const command = buildEnvLabCleanupCommand({
invocation: bundledInvocation("/opt/pkg"),
});
const tokens = tokenizePosix(command);
// The published package ships one `dist/index.js` file and no tsx runner, so
// node runs that file directly.
expect(tokens).toEqual(["node", path.join("/opt/pkg", "dist", "index.js"), "env-lab", "down"]);
expect(command).not.toContain("tsx");
expect(command).not.toContain(path.join("src", "index.ts"));
});
it("keeps a bundled package path with shell metacharacters inert", () => {
const root = "/opt/$(touch pwned)/pkg";
const command = buildEnvLabCleanupCommand({ invocation: bundledInvocation(root) });
const tokens = tokenizePosix(command);
const entry = path.join(root, "dist", "index.js");
// The shell reads the exact bundled path as one token and runs no embedded
// command.
expect(tokens).toEqual(["node", entry, "env-lab", "down"]);
expect(command).not.toContain(`"${entry}"`);
});
});
describe("env-lab doctor cleanup hint instance", () => {
const originalInstanceId = process.env.PAPERCLIP_INSTANCE_ID;
afterEach(() => {
if (originalInstanceId === undefined) {
delete process.env.PAPERCLIP_INSTANCE_ID;
} else {
process.env.PAPERCLIP_INSTANCE_ID = originalInstanceId;
}
vi.restoreAllMocks();
});
// Capture the cleanup hint the doctor prints. The doctor reports through
// `p.log`, so the test replaces each channel and reads the captured lines.
function captureDoctorMessages(): string[] {
const messages: string[] = [];
vi.spyOn(p.log, "message").mockImplementation((message?: string | string[]) => {
messages.push(Array.isArray(message) ? message.join("\n") : (message ?? ""));
});
vi.spyOn(p.log, "success").mockImplementation(() => {});
vi.spyOn(p.log, "warn").mockImplementation(() => {});
vi.spyOn(p.log, "info").mockImplementation(() => {});
return messages;
}
it("pins the PAPERCLIP_INSTANCE_ID instance when opts.instance is absent", async () => {
// The doctor diagnoses the instance that `PAPERCLIP_INSTANCE_ID` selects.
// The cleanup hint must target that instance, not the default instance.
process.env.PAPERCLIP_INSTANCE_ID = "env-selected-instance";
const messages = captureDoctorMessages();
await envLabDoctorCommand({ instance: undefined });
const cleanup = messages.find((message) => message.startsWith("Cleanup:"));
expect(cleanup).toBeDefined();
expect(cleanup).toContain("env-lab down");
expect(cleanup).toContain("--instance");
expect(cleanup).toContain("env-selected-instance");
});
it("pins the explicit instance over PAPERCLIP_INSTANCE_ID", async () => {
// An explicit `--instance` flag overrides the environment variable, so the
// hint targets the explicit instance the doctor inspected.
process.env.PAPERCLIP_INSTANCE_ID = "env-selected-instance";
const messages = captureDoctorMessages();
await envLabDoctorCommand({ instance: "explicit-instance" });
const cleanup = messages.find((message) => message.startsWith("Cleanup:"));
expect(cleanup).toBeDefined();
expect(cleanup).toContain("--instance");
expect(cleanup).toContain("explicit-instance");
expect(cleanup).not.toContain("env-selected-instance");
});
});