pi-mono/packages/coding-agent/test/auth-terminal.test.ts
Vegard Stikbakke 9b9cb30ae1 feat(coding-agent): add pi auth login and logout
pi auth login <provider> logs in from the shell without starting a
session, like pi mcp login. Account sign-in is the default and takes
the provider's browser method; when the provider has another method,
it prints a hint to rerun with --no-browser. --no-browser skips the
browser method and uses the other one, such as device code, asking
only when there are several. --api-key saves an API key instead and
cannot be combined with --no-browser. After logging in, it fetches the
provider's model catalog, and for Radius it points the Radius MCP
server in the global mcp.json at the login without asking.

pi auth logout <provider> removes the stored credentials like /logout
and succeeds when nothing is stored. For MCP servers that use provider
credentials, pi mcp login and logout now refer to these commands
instead of /login and /logout.

/login shares the check for the Radius MCP server entry and the web
URL helpers with the new command.
2026-11-05 12:07:14 +01:00

63 lines
2.5 KiB
TypeScript

import { PassThrough } from "node:stream";
import { afterEach, describe, expect, test, vi } from "vitest";
import { createProcessLoginTerminal } from "../src/cli/auth-terminal.ts";
const inputs: PassThrough[] = [];
afterEach(() => {
vi.restoreAllMocks();
for (const input of inputs) input.destroy();
inputs.length = 0;
});
function setup() {
const input = new PassThrough();
inputs.push(input);
// Readline only needs the readable stream; the test does not exercise TTY detection or raw mode.
vi.spyOn(process, "stdin", "get").mockReturnValue(input as unknown as typeof process.stdin);
vi.spyOn(process.stdout, "write").mockReturnValue(true);
const controller = new AbortController();
const interrupt = vi.fn(() => controller.abort());
const terminal = createProcessLoginTerminal(interrupt);
return { input, controller, interrupt, terminal };
}
describe("auth terminal questions", () => {
test("rejects and cancels the login when stdin closes before an answer", async () => {
const { input, controller, interrupt, terminal } = setup();
const answer = terminal.question("Enter domain: ", controller.signal);
const rejected = expect(answer).rejects.toMatchObject({ name: "AbortError" });
input.end();
await rejected;
expect(interrupt).toHaveBeenCalledOnce();
expect(controller.signal.aborted).toBe(true);
});
test("normal cleanup does not cancel the login after an answer", async () => {
const { input, controller, interrupt, terminal } = setup();
const answer = terminal.question("Enter domain: ", controller.signal);
input.write("example.com\n");
await expect(answer).resolves.toBe("example.com");
expect(interrupt).not.toHaveBeenCalled();
expect(controller.signal.aborted).toBe(false);
});
test("keeps an answer delivered immediately before EOF", async () => {
const { input, controller, interrupt, terminal } = setup();
const answer = terminal.question("Enter domain: ", controller.signal);
input.end("example.com\n");
await expect(answer).resolves.toBe("example.com");
expect(interrupt).not.toHaveBeenCalled();
});
test("prompt cancellation does not cancel the whole login", async () => {
const { controller, interrupt, terminal } = setup();
const prompt = new AbortController();
const answer = terminal.question("Paste code: ", AbortSignal.any([controller.signal, prompt.signal]));
const rejected = expect(answer).rejects.toMatchObject({ name: "AbortError" });
prompt.abort();
await rejected;
expect(interrupt).not.toHaveBeenCalled();
expect(controller.signal.aborted).toBe(false);
});
});