t3-code-android-nightly/.repos/alchemy-effect/packages/alchemy/test/Cli/CliKit.test.tsx
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

2984 lines
92 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @jsxImportSource @alchemy.run/sigil */
import { PassThrough } from "node:stream";
import { stripVTControlCharacters } from "node:util";
import {
NonInteractiveTerminal,
Application,
Screen,
TerminalCancelled,
CliKit,
layer as cliKitLayer,
} from "@/Cli/CliKit/index.ts";
import {
AnsweredPrompt,
Alert,
Box,
ChoiceGroup,
DescriptionList,
Heading,
KeyBar,
LiveStore,
PromptFrame,
ProgressGroup,
ProgressBar,
SectionHeading,
Status,
Toast,
Tabs,
TaskRow,
Text,
TextField,
useLiveStore,
} from "@/Cli/components/ui/index.ts";
import { Menu } from "@/Cli/components/ui/Interactive.tsx";
import { tabsWindow } from "@/Cli/components/ui/Layout.tsx";
import { makeRuntime } from "@/Cli/components/view/Runtime.tsx";
import { sigilCli } from "@/Cli/components/view/SigilCli.tsx";
import { Cli, Progress } from "@/Report.ts";
import {
NukeProgress,
NukeProgressStore,
nukePlan,
reviewNuke,
renderNukeDelete,
renderNukeScan,
} from "@/Cli/components/view/Nuke.tsx";
import { renderApply } from "@/Cli/commands/render.ts";
import { isInProgress } from "@/Cli/components/view/statusStyle.ts";
import { spinnerFramesFor } from "@/Util/Theme.ts";
import {
makeResourceLogger,
makeResourceOutput,
} from "@/Util/ResourceOutput.ts";
import { stackOutputsView } from "@/Cli/components/view/StackOutputs.tsx";
import { Plan, PlanTree } from "@/Cli/components/view/PlanView.tsx";
import { ApprovePlan } from "@/Cli/components/view/ApprovePlan.tsx";
import {
ProfileDetailsBody,
providerBlockHeight,
type ProfileProviderDisplay,
} from "@/Cli/components/view/Profile.tsx";
import {
Dashboard,
DashStore,
runProfileDashboardSession,
} from "@/Cli/components/view/ProfileDashboard.tsx";
import {
buildStageNodes,
stateExplorerScreen,
StateExplorerStore,
type StateExplorerSource,
} from "@/Cli/components/view/StateExplorer.tsx";
import * as Effect from "effect/Effect";
import * as Context from "effect/Context";
import * as NukeRoute from "@/Alchemist/routes/nuke.ts";
import type { ProviderService } from "@/Provider.ts";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Logger from "effect/Logger";
import { expect, it } from "alchemy-test";
import { deleteNode, noopNode, planWith, updateNode } from "./PlanTestNodes.ts";
class CaptureStream extends PassThrough {
readonly columns = 80;
readonly rows = 24;
output = "";
constructor(readonly isTTY = false) {
super();
this.on("data", (chunk) => {
this.output += chunk.toString();
});
}
waitFor(text: string): Promise<void> {
if (this.output.includes(text)) return Promise.resolve();
return new Promise((resolve) => {
const onData = () => {
if (!this.output.includes(text)) return;
this.off("data", onData);
resolve();
};
this.on("data", onData);
});
}
}
class InputStream extends PassThrough {
readonly isTTY = true;
isRaw = false;
private resolveReady: (() => void) | undefined;
readonly ready = new Promise<void>((resolve) => {
this.resolveReady = resolve;
});
setRawMode(mode: boolean) {
this.isRaw = mode;
if (mode) {
// Size-only consumers no longer query capabilities. Input is ready once
// the raw-mode reader attaches; a query may temporarily detach it.
setImmediate(() => {
if (this.listenerCount("readable") > 0) this.resolveReady?.();
});
}
return this;
}
connect(stdout: CaptureStream) {
stdout.on("data", (chunk) => {
if (!chunk.toString().includes("\x1b[c")) return;
// Raw mode starts before Sigil finishes querying the terminal. Answer
// its device query, then allow input handlers to attach before typing.
setImmediate(() => {
this.write("\x1b[?1;2c");
setImmediate(() => this.resolveReady?.());
});
});
}
ref() {
return this;
}
unref() {
return this;
}
}
const makeStatic = () => {
const stdout = new CaptureStream();
const runtime = makeRuntime(
{
input: false,
// SAFETY: CaptureStream implements the writable stream surface consumed by Sigil.
stdout: stdout as unknown as NodeJS.WriteStream,
captureConsole: false,
},
{
input: false,
columns: stdout.columns,
rows: stdout.rows,
colors: false,
unicode: true,
alternateScreen: false,
},
);
return { ...runtime, stdout };
};
it.effect(
"reports terminal-native progress on TTY output",
() =>
Effect.gen(function* () {
const { service, stdout } = yield* makeLive();
yield* service.nativeProgress.set("indeterminate");
yield* service.nativeProgress.set("normal", 50);
yield* service.nativeProgress.set("inactive");
expect(stdout.output).toContain("\u001B]9;4;3\u001B\\");
expect(stdout.output).toContain("\u001B]9;4;1;50\u001B\\");
expect(stdout.output).toContain("\u001B]9;4;0\u001B\\");
}),
{ tags: ["unit", "local"] },
);
const makeExplorerSource = () => {
const calls = { stacks: 0, stages: 0, resources: 0, files: 0 };
const source: StateExplorerSource = {
backend: "local",
listStacks: Effect.sync(() => {
calls.stacks++;
return ["app"];
}),
listStages: () =>
Effect.sync(() => {
calls.stages++;
return ["prod"];
}),
listResources: () =>
Effect.sync(() => {
calls.resources++;
return ["Api/Worker"];
}),
readFile: () =>
Effect.sync(() => {
calls.files++;
return { status: "created", url: "https://workers.dev" };
}),
deleteNodes: () => Effect.void,
};
return { calls, source };
};
const explorerSource = makeExplorerSource().source;
const flushEffects = () =>
new Promise<void>((resolve) => setImmediate(resolve));
it(
"builds Finder-style namespace columns from FQN names",
() => {
const nodes = buildStageNodes("app", "prod", ["Api/Worker"]);
expect(nodes.map((node) => node.name)).toEqual(["Api", "output"]);
const api = nodes[0];
expect(api?.kind).toBe("namespace");
if (api?.kind === "namespace") {
expect(api.children.map((node) => node.name)).toEqual(["Worker"]);
}
},
{ tags: ["unit", "local"] },
);
it(
"loads state columns and file contents only when selected",
async () => {
const { calls, source } = makeExplorerSource();
const store = new StateExplorerStore(source);
store.loadRoot();
await flushEffects();
expect(calls).toEqual({ stacks: 1, stages: 0, resources: 0, files: 0 });
const root = store.snapshot().root;
expect(root.status).toBe("ready");
if (root.status !== "ready") return;
const stack = root.value[0]!;
store.loadChildren(stack);
await flushEffects();
expect(calls).toEqual({ stacks: 1, stages: 1, resources: 0, files: 0 });
const stages = store.snapshot().children.get(stack.id);
if (stages?.status !== "ready") return;
const stage = stages.value[0]!;
store.loadChildren(stage);
await flushEffects();
expect(calls).toEqual({ stacks: 1, stages: 1, resources: 1, files: 0 });
const state = store.snapshot().children.get(stage.id);
if (state?.status !== "ready") return;
const namespace = state.value.find((node) => node.kind === "namespace");
if (namespace?.kind !== "namespace") return;
store.loadFile(namespace.children[0]!);
await flushEffects();
expect(calls).toEqual({ stacks: 1, stages: 1, resources: 1, files: 1 });
},
{ tags: ["unit", "local"] },
);
it(
"ignores state explorer responses from before a refresh",
async () => {
let resolveStale: ((stacks: ReadonlyArray<string>) => void) | undefined;
let request = 0;
const source: StateExplorerSource = {
...explorerSource,
listStacks: Effect.suspend(() => {
request++;
if (request === 1) {
return Effect.promise(
() =>
new Promise((resolve) => {
resolveStale = resolve;
}),
);
}
return Effect.succeed(["fresh"]);
}),
};
const store = new StateExplorerStore(source);
store.loadRoot();
store.refresh();
await flushEffects();
resolveStale?.(["stale"]);
await flushEffects();
const root = store.snapshot().root;
expect(root.status).toBe("ready");
if (root.status === "ready") {
expect(root.value.map((node) => node.name)).toEqual(["fresh"]);
}
},
{ tags: ["unit", "local"] },
);
it(
"prints stack outputs using inspect without wrapping long lines",
() => {
const { service } = makeStatic();
const apiUrl =
"https://cloudflareworkerexample-api-clxp5k3fbtqacxdev7mx7uuxmw.testing-2b2.workers.dev";
// Narrow width: `wrap="none"` output lines must overflow, not break.
const output = service.output.format(
stackOutputsView({
apiUrl,
metadata: { region: "us-east-1", replicas: 2 },
}),
{ columns: 40 },
);
expect(output).toBe(
`{\n apiUrl: '${apiUrl}',\n metadata: { region: 'us-east-1', replicas: 2 }\n}`,
);
},
{ tags: ["unit", "local"] },
);
it(
"renders detailed plans as nested YAML",
() => {
const { service } = makeStatic();
const tree = new PlanTree(
planWith([
updateNode({ config: { retries: 2 } }, { config: { retries: 3 } }),
]),
{ detailed: true, viewport: "full" },
);
const output = service.output.format(<Plan tree={tree} />, { columns: 80 });
expect(output).toContain("properties:");
expect(output).toContain("config:");
expect(output).toContain("│ - retries: 2");
expect(output).toContain("│ + retries: 3");
expect(output).toContain("(Test.Resource)");
},
{ tags: ["unit", "local"] },
);
it(
"keeps the plan summary visible and updates it during apply",
() => {
const { service } = makeStatic();
const plan = {
...planWith([
updateNode({ version: 1 }, { version: 2 }),
noopNode({ version: 1 }, "Stable"),
]),
defaultMode: "local" as const,
};
const reviewTree = new PlanTree(plan, { viewport: "full" });
const reviewOutput = service.output.format(<Plan tree={reviewTree} />, {
columns: 80,
});
const tree = new PlanTree(plan, {
mode: "apply",
label: "Starting dev stack",
busy: true,
});
const output = service.output.format(<Plan tree={tree} collapsible />, {
columns: 160,
});
expect(reviewOutput).toContain("1 to update");
expect(reviewOutput).toContain("1 no change");
expect(output.indexOf("─")).toBeLessThan(
output.indexOf("Starting dev stack"),
);
expect(output.indexOf("Plan")).toBeLessThan(output.indexOf("Worker"));
expect(output.indexOf("Starting dev stack")).toBeGreaterThan(
output.indexOf("Worker"),
);
expect(output).toContain("1 pending");
expect(output).toContain("1 no change");
expect(
spinnerFramesFor(true).some((frame) =>
output.includes(`${frame} Starting dev stack`),
),
).toBe(true);
const keybarLine = output
.split("\n")
.find((line) => line.includes("p hide widget"));
expect(keybarLine).toContain("p hide widget");
expect(keybarLine).toContain("Ctrl+C exit");
expect(keybarLine).toContain("↑/↓ scroll plan");
expect(keybarLine).toContain("Starting dev stack");
tree.emit({
_tag: "apply.resource.status",
fqn: "Worker",
id: "Worker",
type: "Test.Resource",
status: "updating",
});
tree.setBusy(false);
tree.setViewport("full");
const updatingOutput = service.output.format(<Plan tree={tree} />, {
columns: 80,
});
expect(updatingOutput).toContain("1 updating");
expect(updatingOutput).not.toContain("1 pending");
expect(updatingOutput).toContain("1 no change");
expect(
spinnerFramesFor(true).some((frame) =>
updatingOutput.includes(`${frame} Starting dev stack`),
),
).toBe(false);
},
{ tags: ["unit", "local"] },
);
it(
"keeps a large destroy summary and confirmation visible",
() => {
const { service } = makeStatic();
const deletions = Array.from({ length: 40 }, (_, index) =>
deleteNode({}, `Resource${index}`),
);
const plan = { ...planWith([], deletions), destroy: true };
const output = service.output.format(
<ApprovePlan
plan={plan}
controller={{ submit: () => undefined, cancel: () => undefined }}
/>,
{ columns: 80 },
);
expect(output).toContain("Destroy");
expect(output).toContain("40 to delete");
expect(output).toContain("Destroy?");
expect(output).toContain("more lines");
expect(output).toContain("↑/↓ scroll plan");
},
{ tags: ["unit", "local"] },
);
it(
"keeps apply totals on top and destroy progress on the last row",
() => {
const { service } = makeStatic();
const plan = {
...planWith(
[],
Array.from({ length: 10 }, (_, index) =>
deleteNode({}, `Resource${index}`),
),
),
destroy: true,
};
const tree = new PlanTree(plan, {
mode: "apply",
label: "Destroying stack",
busy: true,
});
tree.emit({
_tag: "apply.resource.status",
fqn: "Resource0",
id: "Resource0",
type: "Test.Resource",
status: "deleted",
});
const output = service.output.format(<Plan tree={tree} />, {
columns: 120,
});
const lines = output.split("\n");
const summaryLine = lines.find((line) => line.includes("Plan ·"));
const progressLine = lines.find((line) =>
line.includes("Destroying stack"),
);
expect(summaryLine).toContain("1 deleted");
expect(summaryLine).toContain("9 pending");
expect(progressLine).toContain("Destroying stack (1/10)");
expect(
spinnerFramesFor(true).some((frame) => progressLine?.includes(frame)),
).toBe(true);
expect(output.indexOf("Plan ·")).toBeLessThan(output.indexOf("Resource0"));
expect(output.indexOf("Destroying stack")).toBeGreaterThan(
output.indexOf("Resource9"),
);
},
{ tags: ["unit", "local"] },
);
it(
"binding rows mirror their host resource and stay out of totals",
() => {
const { service } = makeStatic();
const worker = {
...updateNode({ a: 1 }, { a: 2 }, "Worker"),
bindings: [
{ sid: "BUCKET", action: "update" as const, data: {} },
{ sid: "OLD", action: "delete" as const, data: {} },
{ sid: "STABLE", action: "noop" as const, data: {} },
],
};
const tree = new PlanTree(planWith([worker]), {
mode: "apply",
label: "Deploying stack",
busy: true,
});
const render = () =>
service.output.format(<Plan tree={tree} />, { columns: 80 });
// Three bindings never inflate the total: only the host is work.
expect(tree.progress()).toEqual({ completed: 0, failures: 0, total: 1 });
let output = render();
expect(output).toContain("Deploying stack (0/1)");
expect(output).toContain("unbind");
expect(output).toContain("no change");
tree.emit({
_tag: "apply.resource.status",
fqn: "Worker",
id: "Worker",
type: "Test.Resource",
status: "updating",
});
output = render();
expect(output).toContain("unbinding");
expect(output).toContain("Deploying stack (0/1)");
tree.emit({
_tag: "apply.resource.status",
fqn: "Worker",
id: "Worker",
type: "Test.Resource",
status: "updated",
});
expect(tree.progress()).toEqual({ completed: 1, failures: 0, total: 1 });
output = render();
expect(output).toContain("Deploying stack (1/1)");
expect(output).toContain("unbound");
expect(output).toContain("1 updated");
expect(output).not.toContain("pending");
},
{ tags: ["unit", "local"] },
);
it(
"binding rows follow a failed host",
() => {
const { service } = makeStatic();
const worker = {
...updateNode({ a: 1 }, { a: 2 }, "Worker"),
bindings: [{ sid: "BUCKET", action: "update" as const, data: {} }],
};
const tree = new PlanTree(planWith([worker]), {
mode: "apply",
label: "Deploying stack",
busy: true,
});
tree.emit({
_tag: "apply.resource.status",
fqn: "Worker",
id: "Worker",
type: "Test.Resource",
status: "fail",
message: "worker failed",
});
const output = service.output.format(<Plan tree={tree} />, { columns: 80 });
expect(tree.progress()).toEqual({ completed: 1, failures: 1, total: 1 });
expect(output).toContain("1 fail");
expect(output).toContain("Deploying stack (1/1)");
expect(output).toContain("worker failed");
expect(output).not.toContain("pending");
},
{ tags: ["unit", "local"] },
);
it.effect(
"keeps native progress active until the apply outcome settles",
() =>
Effect.gen(function* () {
const { service, stdout } = yield* makeLive();
const tree = new PlanTree(
planWith([updateNode({ version: 1 }, { version: 2 })]),
{
mode: "apply",
label: "Deploying stack",
busy: true,
},
);
tree.emit({
_tag: "apply.resource.status",
fqn: "Worker",
id: "Worker",
type: "Test.Resource",
status: "updated",
});
const live = yield* service.live.open(<Plan tree={tree} />);
yield* Effect.promise(flushEffects);
expect(stdout.output).toContain("\u001B]9;4;1;100\u001B\\");
const settledAt = stdout.output.length;
tree.finish("failure", "Deploy failed");
yield* Effect.promise(flushEffects);
expect(stdout.output.slice(settledAt)).toContain(
"\u001B]9;4;2;100\u001B\\",
);
yield* live.close;
}),
{ tags: ["unit", "local"] },
);
it.effect(
"renders completed dev plans as a hideable output view",
() =>
Effect.gen(function* () {
const { service, stdout } = yield* makeLive();
const plan = {
...planWith([updateNode({ version: 1 }, { version: 2 })]),
defaultMode: "local" as const,
};
const tree = new PlanTree(plan, {
mode: "apply",
label: "Starting dev stack",
});
const live = yield* service.live.open(<Plan tree={tree} collapsible />);
yield* Effect.promise(() => stdout.waitFor("p hide widget"));
yield* Effect.sync(() => {
tree.setOutput({ endpoint: "http://localhost:3000" });
tree.finish("success", "Dev stack ready", "output");
});
yield* Effect.promise(() => stdout.waitFor("http://localhost:3000"));
expect(stdout.output).toContain("←/→ show plan");
tree.setView("plan");
expect(tree.snapshot().view).toBe("plan");
tree.setExpanded(false);
yield* Effect.promise(() => stdout.waitFor("p show plan/output"));
const { service: staticService } = makeStatic();
const hiddenOutput = staticService.output.format(
<Plan tree={tree} collapsible />,
{ columns: 80 },
);
expect(hiddenOutput).not.toContain("Plan ·");
expect(hiddenOutput).not.toContain("Worker");
expect(hiddenOutput).not.toContain("http://localhost:3000");
expect(hiddenOutput).toContain("Dev stack ready (0/1)");
expect(hiddenOutput).toContain("p show plan/output");
yield* live.close;
}),
{ tags: ["unit", "local"] },
);
// A dev generation keeps its widget mounted after apply settles and parks
// until a reload (or Ctrl+C) closes its scope. Closing that scope must remove
// the widget: leaving it behind stacks one stale "Dev stack ready" bar per
// reload under the live one.
it.effect(
"removes the dev apply widget when its generation scope closes",
() =>
Effect.gen(function* () {
const { service, stdout } = yield* makeLive();
const plan = {
...planWith([updateNode({ version: 1 }, { version: 2 })]),
defaultMode: "local" as const,
};
const cli = sigilCli().pipe(
Layer.provide(Layer.succeed(CliKit, service)),
);
let settledAt = 0;
yield* Effect.scoped(
Effect.gen(function* () {
yield* Effect.succeed({ endpoint: "http://localhost:3000" }).pipe(
renderApply(plan, { dev: true }),
);
yield* Effect.promise(() => stdout.waitFor("Dev stack ready"));
settledAt = stdout.output.length;
}),
).pipe(Effect.provide(cli));
// Nothing else is live once the generation is gone, so the renderer
// unmounts and restores the cursor.
expect(stdout.output.slice(settledAt)).toContain("[?25h");
}),
{ tags: ["unit", "local"] },
);
// `p` hides the dev widget; a hot reload opens a fresh session and must not
// bring it back — the user's choice outlives the generation that made it.
it.effect(
"keeps the dev plan widget hidden across hot reloads",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service, stdout } = yield* makeLive({ stdin });
const plan = {
...planWith([updateNode({ version: 1 }, { version: 2 })]),
defaultMode: "local" as const,
};
const cli = sigilCli().pipe(
Layer.provide(Layer.succeed(CliKit, service)),
);
yield* Effect.gen(function* () {
const { startApplySession } = yield* Cli;
const first = yield* startApplySession(plan, { dev: true });
yield* first.done("success");
yield* Effect.promise(() => stdout.waitFor("p hide widget"));
yield* Effect.promise(() => stdin.ready);
yield* Effect.sync(() => stdin.write("p"));
yield* Effect.promise(() => stdout.waitFor("p show plan/output"));
yield* first.close!;
const reloadedAt = stdout.output.length;
const second = yield* startApplySession(plan, { dev: true });
yield* second.done("success");
yield* Effect.promise(() => stdout.waitFor("Dev stack ready"));
const reloaded = stdout.output.slice(reloadedAt);
expect(reloaded).toContain("p show plan/output");
expect(reloaded).not.toContain("p hide widget");
yield* second.close!;
}).pipe(Effect.provide(cli));
}),
{ tags: ["unit", "local"] },
);
it(
"virtualizes dev stack output",
() => {
const { service } = makeStatic();
const tree = new PlanTree(planWith([updateNode({}, {})]), {
mode: "apply",
label: "Dev stack ready",
});
tree.setOutput(
Object.fromEntries(
Array.from({ length: 40 }, (_, index) => [`output${index}`, index]),
),
);
tree.setView("output");
const output = service.output.format(<Plan tree={tree} collapsible />, {
columns: 80,
});
expect(output).toContain("Output");
expect(output).toContain("earlier lines");
expect(output).not.toContain("Worker");
expect(output).toContain("←/→ show plan");
expect(output).toContain("↑/↓ scroll output");
expect(output.split("\n").length).toBeLessThanOrEqual(21);
},
{ tags: ["unit", "local"] },
);
it(
"renders drift details without detailed mode",
() => {
const { service } = makeStatic();
const node = updateNode({ value: "declared" }, { value: "declared" });
node.drift = {
expected: { value: "declared" },
actual: { value: "changed-out-of-band" },
};
const tree = new PlanTree(planWith([node]), { viewport: "full" });
const output = service.output.format(<Plan tree={tree} />, {
columns: 80,
});
expect(output).not.toContain("drift:");
expect(output).toContain("│ - value: declared");
expect(output).toContain("│ + value: changed-out-of-band");
},
{ tags: ["unit", "local"] },
);
it(
"replaces provider details with refresh progress in place",
() => {
const { service } = makeStatic();
const output = service.output.format(
<ProfileDetailsBody
providers={[
{
name: "Cloudflare",
method: "oauth",
status: "ready",
lines: ["accessToken: cfoa****", "expires: in 59m"],
},
{
name: "GitHub",
method: "gh-cli",
status: "ready",
lines: ["token: gho_cZ****"],
},
]}
refreshingProvider="Cloudflare"
/>,
{ columns: 80 },
);
expect(output).toContain("Cloudflare");
expect(output).toContain("refreshing OAuth credentials…");
expect(output).not.toContain("cfoa****");
expect(output).not.toContain("in 59m");
expect(output).toContain("GitHub");
// `key: value` detail lines render as an aligned key column + value
expect(output).toContain("token gho_cZ****");
},
{ tags: ["unit", "local"] },
);
// Eight providers of varying height: 49 rows in total, twice a 24-row terminal.
const dashboardProviders: ReadonlyArray<ProfileProviderDisplay> = [
{
name: "AWS",
method: "sso",
status: "ready",
lines: [
"accessKeyId: ASIA****",
"secretAccessKey: I7hs****",
"sessionToken: IQoJ****",
"region: us-east-2",
"source: sso - default",
],
},
{
name: "Cloudflare",
method: "stored",
status: "configured",
lines: [
"apiKey: cfk_****",
"email: blan****",
"accountId: 2b29****",
"source: stored",
],
},
{
name: "Fly",
method: "stored",
status: "configured",
lines: ["apiKey: FlyV****"],
},
{
name: "GitHub",
method: "gh-cli",
status: "configured",
lines: ["token: gho_cZ****", "source: gh-cli"],
},
{
name: "Hetzner",
method: "stored",
status: "configured",
lines: ["token: 50Kt****"],
},
{
name: "Prisma",
method: "stored",
status: "configured",
lines: ["serviceToken: eyJr****"],
},
{
name: "Railway",
method: "oauth",
status: "configured",
lines: [
"token: rw_Fe2****",
"tokenKind: account",
"apiBaseUrl: https://backboard.railway.com",
"source: oauth",
],
},
{
name: "Vercel",
method: "stored",
status: "reauth",
lines: ["token: vc_****"],
},
];
const dashboardEntries = [{ name: "default", isActive: true, isDefault: true }];
// The dashboard windows providers by `providerBlockHeight`, so the number must
// match what a block actually renders.
it(
"sizes provider blocks by providerBlockHeight",
() => {
const { service } = makeStatic();
const output = service.output.format(
<ProfileDetailsBody providers={dashboardProviders} focusColumn />,
{ columns: 80 },
);
const expected = dashboardProviders.reduce(
(rows, provider, index) =>
rows + providerBlockHeight(provider, index === 0),
0,
);
expect(output.split("\n").length).toBe(expected);
},
{ tags: ["unit", "local"] },
);
// Providers are a table separated by blank rows, not rules; the focused
// provider is marked by the cursor glyph in a reserved column, never a rail.
it(
"marks the focused provider with the pointer and no rails",
() => {
const { service } = makeStatic();
const output = service.output.format(
<ProfileDetailsBody
providers={dashboardProviders}
focusColumn
focusedProvider="Cloudflare"
reauthHint="press r to re-login"
/>,
{ columns: 80 },
);
const lines = output.split("\n");
expect(lines.some((line) => /[│─]/.test(line))).toBe(false);
const pointed = lines.filter((line) => line.includes("❯"));
expect(pointed).toHaveLength(1);
expect(pointed[0]).toMatch(/^ ❯ Cloudflare\s+stored\s+! configured$/);
expect(lines.some((line) => /^ AWS\s+sso\s+✓ ready$/.test(line))).toBe(
true,
);
// detail keys render as an aligned column: key, gap, value
expect(output).toContain("apiKey cfk_****");
expect(output).toContain("accountId 2b29****");
// the re-login hint follows the focus cursor, so it is hidden here
expect(output).not.toContain("press r to re-login");
},
{ tags: ["unit", "local"] },
);
it(
"windows the profile dashboard's providers to the terminal height",
() => {
const { service, stdout } = makeStatic();
const store = new DashStore(dashboardEntries);
store.setDetails("default", {
state: "ready",
providers: dashboardProviders,
available: [],
});
const output = service.output.format(
<Dashboard store={store} initialSelected={0} />,
{ columns: 80 },
);
expect(output.split("\n").length).toBeLessThanOrEqual(stdout.rows);
expect(output).toContain("AWS");
expect(output).toContain("Cloudflare");
expect(output).toContain("switch profile");
expect(output).not.toContain("Vercel");
// The profile row holds the initial focus slot: the pointer sits on it and
// the provider rows keep a blank cursor column so the table stays aligned.
expect(output).toContain("\n ❯ default");
expect(output).toContain("\n AWS ");
},
{ tags: ["unit", "local"] },
);
// The session sleeps on a real clock (loader delay, notice auto-dismiss).
it.live(
"scrolls the profile dashboard to the focused provider",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service, stdout } = yield* makeLive({ stdin });
const session = yield* runProfileDashboardSession({
entries: dashboardEntries,
selected: "default",
loadDetails: () =>
Effect.succeed({ providers: dashboardProviders, available: [] }),
execute: () =>
Effect.succeed({ ok: true, message: "", entries: dashboardEntries }),
runFlow: () => Effect.succeed({ ok: true, message: "" }),
reloadEntries: Effect.succeed(dashboardEntries),
}).pipe(Effect.provideService(CliKit, service), Effect.forkChild);
// "focus provider" joins the key bar once the providers have resolved;
// arrow keys are ignored while the pane still shows the loading spinner.
yield* Effect.promise(() => stdout.waitFor("focus provider"));
yield* Effect.promise(() => stdin.ready);
expect(stdout.output).not.toContain("Vercel");
// Walk the focus cursor down to the last provider; the list follows it.
for (const _ of dashboardProviders) {
yield* Effect.sync(() => stdin.write("\x1b[B"));
}
yield* Effect.promise(() => stdout.waitFor("Vercel"));
yield* Effect.sync(() => stdin.write("q"));
yield* Fiber.join(session);
}),
{ tags: ["unit", "local"] },
);
it(
"renders input frames inline by default and keeps a stacked variant",
() => {
const { service } = makeStatic();
const inline = service.output.format(
<PromptFrame
message="Profile name"
description="Used to select stored credentials."
layout="inline"
>
<Text>production</Text>
</PromptFrame>,
);
const stacked = service.output.format(
<PromptFrame message="Profile name" layout="stacked">
<Text>production</Text>
</PromptFrame>,
);
expect(inline).toContain("Profile name: production");
expect(inline).toContain("Used to select stored credentials.");
expect(stacked).toContain("Profile name\n");
expect(stacked).toContain("production");
},
{ tags: ["unit", "local"] },
);
it(
"renders compact informational toasts as a bare status line",
() => {
const { service } = makeStatic();
const output = service.output.format(
<Toast variant="info">Credentials refreshed.</Toast>,
);
expect(output).toBe("• Credentials refreshed.");
},
{ tags: ["unit", "local"] },
);
it(
"renders prompt frames with an indented body and no rail",
() => {
const { service } = makeStatic();
const output = service.output.format(
<PromptFrame message="Cloudflare authentication method">
<Menu
cursor={0}
choices={[
{ value: "oauth", label: "OAuth", description: "recommended" },
{
value: "token",
label: "API Token or API Key",
description: "use an API token",
},
]}
/>
</PromptFrame>,
{ columns: 100 },
);
const lines = output.split("\n");
expect(lines[0]).toBe("◆ Cloudflare authentication method");
// options sit directly under the heading; ❯ is the only chevron on screen
expect(lines[1]).toMatch(/^ ❯ OAuth\s+recommended$/);
expect(lines[2]).toMatch(/^ API Token or API Key\s+use an API token$/);
// descriptions share one column across rows
expect(lines[1]!.indexOf("recommended")).toBe(
lines[2]!.indexOf("use an API token"),
);
expect(output).not.toContain("│");
expect(output).not.toContain("›");
},
{ tags: ["unit", "local"] },
);
/**
* Scoped variant of `makeStatic` for tests that mount a persistent renderer.
* Disposal runs as a finalizer, so a failing test never leaks a Sigil
* instance.
*/
const makeLive = (
overrides: {
readonly stdin?: InputStream;
readonly captureConsole?: boolean;
readonly input?: boolean;
readonly unicode?: boolean;
readonly colors?: boolean;
} = {},
) => {
const input = overrides.input ?? true;
return Effect.acquireRelease(
Effect.sync(() => {
const stdout = new CaptureStream(input);
const stderr = new CaptureStream(input);
// Interactive runtimes need a raw-mode-capable stdin: with a real
// process.stdin pipe Sigil's useInput throws during commit, which now
// surfaces as a renderer error instead of being silently swallowed.
const stdin = overrides.stdin ?? (input ? new InputStream() : undefined);
stdin?.connect(stdout);
const runtime = makeRuntime(
{
input,
// SAFETY: InputStream implements the raw readable stream surface consumed by Sigil.
stdin: stdin as unknown as NodeJS.ReadStream | undefined,
// SAFETY: CaptureStream implements the writable stream surface consumed by Sigil.
stdout: stdout as unknown as NodeJS.WriteStream,
// SAFETY: CaptureStream implements the writable stream surface consumed by Sigil.
stderr: stderr as unknown as NodeJS.WriteStream,
captureConsole: overrides.captureConsole ?? false,
},
{
input,
columns: stdout.columns,
rows: stdout.rows,
colors: overrides.colors ?? false,
unicode: overrides.unicode ?? true,
alternateScreen: input,
},
);
return { ...runtime, stdout, stderr };
}),
({ dispose }) => Effect.promise(dispose),
);
};
it(
"honors explicit color settings for static output",
() => {
const { service } = makeStatic();
const view = <Text color="green">ready</Text>;
const styled = service.output.format(view, { colors: true });
expect(styled).toContain("\u001B[");
expect(stripVTControlCharacters(styled)).toBe("ready");
expect(service.output.format(view, { colors: false })).toBe("ready");
},
{ tags: ["unit", "local"] },
);
it.effect(
"keeps stack output URLs reachable",
() =>
Effect.gen(function* () {
const { service } = yield* makeLive({ colors: true });
const url = "https://example.com/deploy";
const output = service.output.format(stackOutputsView({ url }), {
colors: true,
});
expect(stripVTControlCharacters(output)).toContain(url);
}),
{ tags: ["unit", "local"] },
);
it.effect(
"keeps the state explorer active while searching and quits cleanly",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service, stdout } = yield* makeLive({ stdin });
const fiber = yield* service
.application(service.prompt.custom(stateExplorerScreen(explorerSource)))
.pipe(Application.alternate)
.pipe(Effect.forkChild);
yield* Effect.promise(() => stdin.ready);
yield* Effect.promise(() => stdout.waitFor("Loading"));
yield* Effect.sync(() => stdin.write("/"));
yield* settleInput;
yield* Effect.sync(() => stdin.write("workers.dev"));
yield* settleInput;
yield* Effect.sync(() => stdin.write("\r"));
yield* settleInput;
expect(fiber.pollUnsafe()).toBeUndefined();
yield* Effect.sync(() => stdin.write("q"));
yield* Fiber.join(fiber);
}),
{ tags: ["unit", "local"] },
);
it.effect(
"renders the built-in layout components without writing",
() =>
Effect.gen(function* () {
const { service, stdout } = makeStatic();
const rendered = yield* service.output.render(
<>
<SectionHeading annotation="active">Profile</SectionHeading>
<DescriptionList
items={[
{ label: "Name", value: "production" },
{ label: "Status", value: "ready" },
]}
/>
</>,
);
expect(rendered).toContain("Profile");
expect(rendered).toContain("production");
expect(rendered).toContain("ready");
expect(stdout.output).toBe("");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"renders confirmations as a compact segmented choice",
() =>
Effect.gen(function* () {
const { service } = makeStatic();
expect(
(yield* service.output.render(
<ChoiceGroup
value
choices={[
{ value: true, label: "Yes" },
{ value: false, label: "No" },
]}
/>,
)).trim(),
).toBe("Yes No");
expect(
(yield* service.output.render(
<ChoiceGroup
value={false}
choices={[
{ value: true, label: "Yes" },
{ value: false, label: "No" },
]}
/>,
)).trim(),
).toBe("Yes No");
expect(
(yield* service.output.render(
<ChoiceGroup
value
choices={[
{ value: true, label: "Destroy" },
{ value: false, label: "Cancel" },
]}
/>,
)).trim(),
).toBe("Destroy Cancel");
expect(
(yield* service.output.render(
<ChoiceGroup
orientation="vertical"
value="repair"
choices={[
{
value: "repair",
label: "Repair",
description: "restore declared state",
},
{ value: "cancel", label: "Cancel" },
]}
/>,
)).trim(),
).toBe("Repair · restore declared state\n Cancel");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"preserves zero in primitive-array views",
() =>
Effect.gen(function* () {
const { service } = makeStatic();
const rendered = yield* service.output.render([
"count: ",
0,
false,
null,
]);
expect(rendered).toBe("count: 0");
}),
{ tags: ["unit", "local"] },
);
it(
"does not animate work before it starts",
() => {
expect(isInProgress("pending")).toBe(false);
expect(isInProgress("creating")).toBe(true);
expect(isInProgress("updating")).toBe(true);
expect(isInProgress("deleting")).toBe(true);
expect(isInProgress("running")).toBe(true);
},
{ tags: ["unit", "local"] },
);
it.effect(
"prints headings and data views through one service",
() =>
Effect.gen(function* () {
const { service, stdout } = makeStatic();
yield* service.output.print(<Heading>Deployments</Heading>);
yield* service.output.print(
<DescriptionList
items={[
{ label: "api", value: "ready" },
{ label: "worker", value: "updating" },
]}
/>,
);
expect(stdout.output).toContain("Deployments");
expect(stdout.output).toContain("worker");
expect(stdout.output).toContain("updating");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"fails input operations when no terminal input is available",
() =>
Effect.gen(function* () {
const { service } = makeStatic();
const failure = yield* service.prompt
.select({
message: "Choose",
options: [{ label: "One", value: 1 }],
})
.pipe(Effect.flip);
expect(failure).toBeInstanceOf(NonInteractiveTerminal);
if (failure instanceof NonInteractiveTerminal) {
expect(failure.operation).toBe("selection");
}
const cycleFailure = yield* service.prompt
.cycle({ message: "Change", options: [] })
.pipe(Effect.flip);
const externalFailure = yield* service.prompt
.awaitExternal({
message: "Authorize",
waitingLabel: "Waiting",
inputLabel: "Enter code",
})
.pipe(Effect.flip);
expect(cycleFailure).toBeInstanceOf(NonInteractiveTerminal);
if (cycleFailure instanceof NonInteractiveTerminal) {
expect(cycleFailure.operation).toBe("cycle selection");
}
expect(externalFailure).toBeInstanceOf(NonInteractiveTerminal);
if (externalFailure instanceof NonInteractiveTerminal) {
expect(externalFailure.operation).toBe("external authorization");
}
}),
{ tags: ["unit", "local"] },
);
it.effect(
"progress handles are updateable and settle only once",
() =>
Effect.gen(function* () {
const { service, stdout } = makeStatic();
const progress = yield* service.live.progress({ label: "Deploying" });
yield* progress.update({ label: "Uploading", detail: "2/3" });
yield* progress.succeed("Deployed");
yield* progress.fail("must not print");
expect(stdout.output).toContain("Deploying");
expect(stdout.output).toContain("Deployed");
expect(stdout.output).not.toContain("must not print");
}),
{ tags: ["unit", "local"] },
);
/** Live views are immutable; dynamic content flows through caller stores. */
const LiveLabel = ({ store }: { readonly store: LiveStore<string> }) => (
<Text>{useLiveStore(store)}</Text>
);
it.effect(
"tears down store-driven live views idempotently",
() =>
Effect.gen(function* () {
const { service, stdout } = yield* makeLive();
const store = new LiveStore("Scanning");
const live = yield* service.live.open(<LiveLabel store={store} />);
yield* Effect.sync(() => store.set("Deleting"));
yield* live.close;
yield* live.close;
expect(stdout.output).toContain("\u001B[?25h");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"does not let one closing view tear down a newer live view",
() =>
Effect.gen(function* () {
const { service, stdout } = yield* makeLive();
const first = yield* service.live.open(<Text>first</Text>);
const closing = yield* first.close.pipe(Effect.forkChild);
const store = new LiveStore("second");
const second = yield* service.live.open(<LiveLabel store={store} />, {
persistOnClose: true,
});
yield* Fiber.join(closing);
yield* Effect.sync(() => store.set("second updated"));
yield* second.close;
expect(stdout.output).toContain("second updated");
}),
{ tags: ["unit", "local"] },
);
interface OrderingCase {
readonly name: string;
readonly captureConsole: boolean;
readonly emit: (service: CliKit["Service"]) => Effect.Effect<void>;
readonly verify: (output: string) => void;
}
const orderingCases: ReadonlyArray<OrderingCase> = [
{
name: "commits persistent live views to the static transcript",
captureConsole: false,
emit: () => Effect.void,
verify: (output) => {
expect(output).toContain("Deployed");
expect(output.slice(output.lastIndexOf("Deployed"))).not.toContain(
"\u001B[2K",
);
expect(output).toContain("\u001B[?25h");
},
},
{
name: "keeps styled captured logs static and ordered before completed live views",
captureConsole: true,
emit: () =>
Effect.sync(() => console.log("\u001B[32mruntime ready\u001B[0m")),
verify: (output) => {
expect(output.match(/runtime ready/g)?.length).toBe(1);
expect(output).toContain("\u001B[32m");
expect(output.indexOf("runtime ready")).toBeLessThan(
output.lastIndexOf("Deployed"),
);
},
},
{
name: "orders semantic output through the active renderer",
captureConsole: false,
emit: (service) => service.output.info("runtime ready"),
verify: (output) => {
expect(output.indexOf("runtime ready")).toBeLessThan(
output.lastIndexOf("Deployed"),
);
},
},
];
it.effect.each(orderingCases)(
"$name",
({ captureConsole, emit, verify }) =>
Effect.gen(function* () {
const { service, stdout } = yield* makeLive({
captureConsole,
colors: captureConsole,
});
const store = new LiveStore("Deploying");
const live = yield* service.live.open(<LiveLabel store={store} />, {
persistOnClose: true,
});
yield* emit(service);
yield* Effect.sync(() => store.set("Deployed"));
yield* live.close;
verify(stdout.output);
}),
{ tags: ["unit", "local"] },
);
it.effect(
"commits stdio above active Sigil views",
() =>
Effect.gen(function* () {
const { service, stdout, stderr } = yield* makeLive({
captureConsole: true,
});
const store = new LiveStore("Resolving credentials");
const live = yield* service.live.open(<LiveLabel store={store} />, {
persistOnClose: true,
});
yield* Effect.sync(() => {
stdout.write("floci stdout\n");
stderr.write("node warning\n");
store.set("Credentials resolved");
});
yield* live.close;
expect(stdout.output).toContain("floci stdout");
expect(stdout.output).toContain("node warning");
expect(stderr.output).not.toContain("node warning");
expect(stdout.output).toContain("Credentials resolved");
expect(stdout.output.lastIndexOf("node warning")).toBeLessThan(
stdout.output.lastIndexOf("Credentials resolved"),
);
}),
{ tags: ["unit", "local"] },
);
it.effect(
"flushes a captured partial line without waiting for unmount",
() =>
Effect.gen(function* () {
const { service, stdout, stderr } = yield* makeLive({
captureConsole: true,
});
const store = new LiveStore("Deploying");
const live = yield* service.live.open(<LiveLabel store={store} />);
// A writer whose final chunk has no trailing newline (e.g. an error
// block written raw to stderr): its tail must render within the
// partial-flush deadline — before this, it sat buffered until the
// renderer unmounted at process exit, so the last line of an error
// only ever appeared on shutdown.
yield* Effect.sync(() => {
stderr.write("stack tail without newline");
});
yield* Effect.promise(() => stdout.waitFor("stack tail without newline"));
yield* live.close;
}),
{ tags: ["unit", "local"] },
);
it.effect(
"progress settles into success and failure status output",
() =>
Effect.gen(function* () {
const { service, stdout } = makeStatic();
yield* Effect.scoped(
Effect.gen(function* () {
const first = yield* service.live.progress({
label: "Resolve credentials",
});
yield* first.succeed();
const second = yield* service.live.progress({
label: "Apply resource",
});
yield* second.fail();
}),
);
expect(stdout.output).toContain("Resolve credentials");
expect(stdout.output).toContain("Apply resource");
expect(stdout.output).toContain("✓");
expect(stdout.output).toContain("×");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"task collapses success and failure into status output",
() =>
Effect.gen(function* () {
const { service, stdout } = makeStatic();
yield* service.task(
{ label: "Resolve credentials" },
Effect.succeed("credentials"),
);
yield* service
.task({ label: "Apply resource" }, Effect.fail("nope"))
.pipe(Effect.ignore);
expect(stdout.output).toContain("Resolve credentials");
expect(stdout.output).toContain("Apply resource");
expect(stdout.output).toContain("✓");
expect(stdout.output).toContain("×");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"status output composes as a normal view",
() =>
Effect.gen(function* () {
const { service } = makeStatic();
const rendered = yield* service.output.render(
<Status variant="warning" detail="retrying">
API unavailable
</Status>,
);
expect(rendered).toContain("API unavailable");
expect(rendered).toContain("retrying");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"uses ASCII fallbacks when Unicode is unavailable",
() =>
Effect.gen(function* () {
const { service } = yield* makeLive({ input: false, unicode: false });
const rendered = yield* service.output.render(
<>
<Heading>Deploy</Heading>
<Status variant="success">Complete</Status>
</>,
);
expect(rendered).toContain("@ Deploy");
expect(rendered).toContain("+ Complete");
expect(rendered).not.toContain("✓");
}),
{ tags: ["unit", "local"] },
);
it(
"uses one resource-prefixed pipeline for chunked stdout and stderr",
() => {
const lines: string[] = [];
const output = makeResourceOutput("www", {
log: (...args) => lines.push(args.join(" ")),
});
output.stdout.push("first\nsec");
output.stdout.push("ond\r");
output.stderr.push("failed");
output.stdout.flush();
output.stderr.flush();
const prefix = "[www]";
expect(lines.map(stripVTControlCharacters)).toEqual([
`${prefix} first`,
`${prefix} second`,
`${prefix} failed`,
]);
},
{ tags: ["unit", "local"] },
);
it.effect(
"routes effectful resource output through the configured logger",
() => {
const entries: Array<{ level: string; message: unknown }> = [];
const logger = Logger.make<unknown, void>(({ logLevel, message }) => {
entries.push({ level: logLevel, message });
});
const output = makeResourceLogger("www");
return Effect.gen(function* () {
yield* output("stdout", "[MDX] generated files");
yield* output("stderr", "vite diagnostic");
expect(entries).toEqual([
{ level: "Info", message: ["[www] [MDX] generated files"] },
{ level: "Info", message: ["[www] vite diagnostic"] },
]);
}).pipe(Effect.provide(Logger.layer([logger])));
},
{ tags: ["unit", "local"] },
);
it.effect(
"does not decorate resource stderr as a semantic failure",
() =>
Effect.gen(function* () {
const { service, stdout } = yield* makeLive({ captureConsole: true });
const live = yield* service.live.open(<Text>Building</Text>);
const output = makeResourceOutput("www", globalThis.console);
output.stderr.push("[FILE_NAME_CONFLICT] warning\n");
output.stderr.flush();
yield* live.close;
const rendered = stripVTControlCharacters(stdout.output);
expect(rendered).toContain(`[www] [FILE_NAME_CONFLICT] warning`);
expect(rendered).not.toContain("×");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"runs interactive components inside the owned session",
() =>
Effect.gen(function* () {
const { service, stdout } = yield* makeLive();
const result = yield* service.prompt.custom(
Screen.make("test screen", ({ submit }) => {
queueMicrotask(() => submit("completed", <Status>Done</Status>));
return <Status>Working</Status>;
}),
);
expect(result).toBe("completed");
expect(stdout.output).toContain("Done");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"restores the terminal after an alternate-screen interaction",
() =>
Effect.gen(function* () {
const { service, stdout } = yield* makeLive();
yield* service
.application(
service.prompt.custom(
Screen.make("full screen", ({ submit }) => {
queueMicrotask(() => submit(undefined));
return <Status>Browsing</Status>;
}),
),
)
.pipe(Application.alternate);
expect(stdout.output).toContain("\u001B[?1049h");
expect(stdout.output).toContain("\u001B[?1049l");
expect(stdout.output.indexOf("\u001B[?1049h")).toBeLessThan(
stdout.output.indexOf("\u001B[?1049l"),
);
}),
{ tags: ["unit", "local"] },
);
it.effect(
"treats the terminal DEL byte as text-field backspace",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service } = yield* makeLive({ stdin });
const fiber = yield* service.prompt
.custom(
Screen.make("backspace", ({ submit }) => (
<TextField initialValue="abc" onChange={submit} onSubmit={submit} />
)),
)
.pipe(Effect.forkChild);
yield* Effect.promise(() => stdin.ready);
yield* Effect.sync(() => stdin.write("\x7f"));
const result = yield* Fiber.join(fiber);
expect(result).toBe("ab");
}),
{ tags: ["unit", "local"] },
);
/** Let a written stdin chunk flow through Sigil's input pipeline. */
const settleInput = Effect.yieldNow.pipe(Effect.repeat({ times: 2 }));
it.effect(
"strips control characters from pasted text-field input",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service } = yield* makeLive({ stdin });
const fiber = yield* service.prompt
.custom(
Screen.make("paste", ({ submit }) => <TextField onSubmit={submit} />),
)
.pipe(Effect.forkChild);
yield* Effect.promise(() => stdin.ready);
// A paste arrives as one chunk; embedded newlines/tabs must not survive.
yield* Effect.sync(() => stdin.write("to\tken\r\n123"));
yield* settleInput;
yield* Effect.sync(() => stdin.write("\r"));
const result = yield* Fiber.join(fiber);
expect(result).toBe("token123");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"shows the whole typed value while width remains available",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service, stdout } = yield* makeLive({ stdin });
const fiber = yield* service.prompt
.text({ message: "New profile name" })
.pipe(Effect.forkChild);
yield* Effect.promise(() => stdin.ready);
// Regression: the field's box used to shrink to its content, so the
// measured "available" width collapsed to the minimum and the value
// scrolled after ~4 characters despite a mostly-empty row.
yield* Effect.sync(() => stdin.write("my-longer-profile-name"));
yield* Effect.promise(() => stdout.waitFor("my-longer-profile-name"));
expect(stdout.output).toContain("my-longer-profile-name");
yield* Effect.sync(() => stdin.write("\r"));
const result = yield* Fiber.join(fiber);
expect(result).toBe("my-longer-profile-name");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"shows a text prompt default before it is accepted",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service, stdout } = yield* makeLive({ stdin });
const fiber = yield* service.prompt
.text({
message: "Emulator endpoint",
defaultValue: "http://localhost:4566",
})
.pipe(Effect.forkChild);
yield* Effect.promise(() => stdin.ready);
yield* Effect.promise(() => stdout.waitFor("http://localhost:4566"));
expect(stripVTControlCharacters(stdout.output)).toContain(
"Emulator endpoint: http://localhost:4566",
);
yield* Effect.sync(() => stdin.write("\r"));
expect(yield* Fiber.join(fiber)).toBe("http://localhost:4566");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"deletes a whole emoji grapheme on backspace",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service } = yield* makeLive({ stdin });
const fiber = yield* service.prompt
.custom(
Screen.make("grapheme", ({ submit }) => (
<TextField initialValue="a👍" onChange={submit} onSubmit={submit} />
)),
)
.pipe(Effect.forkChild);
yield* Effect.promise(() => stdin.ready);
yield* Effect.sync(() => stdin.write("\x7f"));
const result = yield* Fiber.join(fiber);
expect(result).toBe("a");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"erases the multi-select filter with the terminal DEL byte",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service } = yield* makeLive({ stdin });
const fiber = yield* service.prompt
.multiSelect({
message: "pick",
options: [
{ value: "alpha", label: "alpha" },
{ value: "beta", label: "beta" },
],
})
.pipe(Effect.forkChild);
yield* Effect.promise(() => stdin.ready);
// Filter to nothing, erase the filter with DEL, then toggle + confirm.
yield* Effect.sync(() => stdin.write("z"));
yield* settleInput;
yield* Effect.sync(() => stdin.write("\x7f"));
yield* settleInput;
yield* Effect.sync(() => stdin.write(" "));
yield* settleInput;
yield* Effect.sync(() => stdin.write("\r"));
const result = yield* Fiber.join(fiber);
expect(result).toEqual(["alpha"]);
}),
{ tags: ["unit", "local"] },
);
it.effect(
"filters searchable selects without stealing Enter",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service } = yield* makeLive({ stdin });
const fiber = yield* service.prompt
.select({
message: "pick",
searchable: true,
options: [
{ value: "alpha", label: "alpha" },
{ value: "beta", label: "beta" },
],
})
.pipe(Effect.forkChild);
yield* Effect.promise(() => stdin.ready);
yield* Effect.sync(() => stdin.write("bet"));
yield* settleInput;
yield* Effect.sync(() => stdin.write("\r"));
const result = yield* Fiber.join(fiber);
expect(result).toBe("beta");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"keeps required cycle edits open until something changes",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service } = yield* makeLive({ stdin });
const fiber = yield* service.prompt
.cycle({
message: "Manage accounts",
requireChange: true,
options: [
{
label: "Cloudflare",
states: [
{ value: "keep", label: "keep" },
{ value: "remove", label: "remove" },
],
},
],
})
.pipe(Effect.forkChild);
yield* Effect.promise(() => stdin.ready);
yield* Effect.sync(() => stdin.write("\r"));
yield* settleInput;
expect(fiber.pollUnsafe()).toBe(undefined);
yield* Effect.sync(() => stdin.write(" "));
yield* settleInput;
yield* Effect.sync(() => stdin.write("\r"));
const result = yield* Fiber.join(fiber);
expect(result).toEqual(["remove"]);
}),
{ tags: ["unit", "local"] },
);
it.effect(
"reopens browser authorization from the waiting screen",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service } = yield* makeLive({ stdin });
let opened = 0;
const fiber = yield* service.prompt
.awaitExternal({
message: "Authorize",
waitingLabel: "Waiting",
url: "https://example.com/authorize",
inputLabel: "Enter code",
onOpen: async () => {
opened += 1;
},
})
.pipe(Effect.forkChild);
yield* Effect.promise(() => stdin.ready);
yield* Effect.sync(() => stdin.write("o"));
yield* settleInput;
expect(opened).toBe(1);
yield* Effect.sync(() => stdin.write("\r"));
yield* settleInput;
yield* Effect.sync(() => stdin.write("code"));
yield* settleInput;
yield* Effect.sync(() => stdin.write("\r"));
const result = yield* Fiber.join(fiber);
expect(result).toBe("code");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"keeps browser-only authorization cancellable",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service, stdout } = yield* makeLive({ stdin });
const fiber = yield* service.prompt
.awaitExternal({
message: "AWS authorization",
waitingLabel: "Waiting",
url: "https://example.com/authorize",
allowManualInput: false,
})
.pipe(Effect.flip, Effect.forkChild);
yield* Effect.promise(() => stdin.ready);
yield* Effect.promise(() => stdout.waitFor("AWS authorization"));
expect(stripVTControlCharacters(stdout.output)).not.toContain(
"enter code manually",
);
// Enter is intentionally inert for a browser-only flow.
yield* Effect.sync(() => stdin.write("\r"));
yield* settleInput;
expect(fiber.pollUnsafe()).toBeUndefined();
yield* Effect.sync(() => stdin.write("\x1b"));
const failure = yield* Fiber.join(fiber);
expect(failure).toBeInstanceOf(TerminalCancelled);
}),
{ tags: ["unit", "local"] },
);
it.effect(
"shows a device authorization code",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service, stdout } = yield* makeLive({ stdin });
const fiber = yield* service.prompt
.awaitExternal({
message: "AWS authorization",
waitingLabel: "Waiting for device authorization…",
url: "https://device.sso.example.com/",
code: "ABCD-EFGH",
allowManualInput: false,
})
.pipe(Effect.flip, Effect.forkChild);
yield* Effect.promise(() => stdin.ready);
yield* Effect.promise(() => stdout.waitFor("ABCD-EFGH"));
const output = stripVTControlCharacters(stdout.output);
expect(output).toContain("Code ABCD-EFGH");
expect(output).toContain("copy code");
yield* Effect.sync(() => stdin.write("\x1b"));
const failure = yield* Fiber.join(fiber);
expect(failure).toBeInstanceOf(TerminalCancelled);
}),
{ tags: ["unit", "local"] },
);
it.effect(
"toggles every visible multi-select choice on ctrl+a",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service } = yield* makeLive({ stdin });
const fiber = yield* service.prompt
.multiSelect({
message: "pick",
options: [
{ value: "alpha", label: "alpha" },
{ value: "beta", label: "beta" },
{ value: "gamma", label: "gamma", disabled: true },
],
})
.pipe(Effect.forkChild);
yield* Effect.promise(() => stdin.ready);
yield* Effect.sync(() => stdin.write("\x01"));
yield* settleInput;
yield* Effect.sync(() => stdin.write("\r"));
const result = yield* Fiber.join(fiber);
expect(result).toEqual(["alpha", "beta"]);
}),
{ tags: ["unit", "local"] },
);
it.effect(
"returns an explicit undefined menu back target on Escape",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service } = yield* makeLive({ stdin });
const fiber = yield* service.prompt
.menu<string | undefined>({
message: "pick",
back: undefined,
options: [{ value: "alpha", label: "alpha" }],
})
.pipe(Effect.forkChild);
yield* Effect.promise(() => stdin.ready);
yield* Effect.sync(() => stdin.write("\x1b"));
const result = yield* Fiber.join(fiber);
expect(result).toBe(undefined);
}),
{ tags: ["unit", "local"] },
);
it.effect(
"cleans up a cancelled standalone prompt",
() =>
Effect.gen(function* () {
const { service, stdout } = yield* makeLive();
const failure = yield* service.prompt
.custom(
Screen.make("cancel test", ({ cancel }) => {
queueMicrotask(cancel);
return <Status>Waiting</Status>;
}),
)
.pipe(Effect.flip);
expect(failure).toBeInstanceOf(TerminalCancelled);
expect(stdout.output).toContain("cancel test cancelled");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"releases the renderer when a running prompt is interrupted",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service } = yield* makeLive({ stdin });
const fiber = yield* service.prompt
.custom(Screen.make("interrupted", () => <Status>Waiting</Status>))
.pipe(Effect.forkChild);
yield* Effect.promise(() => stdin.ready);
yield* Fiber.interrupt(fiber);
// The interrupted prompt must have released the renderer and the prompt
// gate for the next interaction.
const result = yield* service.prompt.custom(
Screen.make("after interruption", ({ submit }) => {
queueMicrotask(() => submit("ok"));
return <Status>After</Status>;
}),
);
expect(result).toBe("ok");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"closes leaked live handles when their scope closes",
() =>
Effect.gen(function* () {
const { service, stdout } = yield* makeLive();
yield* Effect.scoped(
Effect.gen(function* () {
yield* service.live.open(<Text>Leaky</Text>);
// Deliberately no close — the enclosing scope must release the row
// and unmount the renderer.
}),
);
expect(stdout.output).toContain("[?25h");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"fails the active prompt when a screen throws during render",
() =>
Effect.gen(function* () {
const { service } = yield* makeLive();
const Broken = (): never => {
throw new Error("screen render boom");
};
// Without exit observation this would hang forever: the screen's
// submit/cancel can never run once the renderer has crashed.
const exit = yield* service.prompt
.custom(Screen.make("broken screen", () => <Broken />))
.pipe(Effect.exit);
expect(Exit.isFailure(exit)).toBe(true);
}),
{ tags: ["unit", "local"] },
);
it.effect(
"cancels a screen with no cancel wiring on ctrl+c",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service, stdout } = yield* makeLive({ stdin });
// The screen never touches the controller — the centralized handler in
// the runtime must still turn Ctrl+C into a cancellation.
const fiber = yield* service.prompt
.custom(Screen.make("no cancel wiring", () => <Status>Waiting</Status>))
.pipe(Effect.flip, Effect.forkChild);
yield* Effect.promise(() => stdin.ready);
yield* Effect.sync(() => stdin.write("\x03"));
const failure = yield* Fiber.join(fiber);
expect(failure).toBeInstanceOf(TerminalCancelled);
expect(stdout.output).toContain("no cancel wiring cancelled");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"keeps one renderer alive for an Effect-driven application",
() =>
Effect.gen(function* () {
const { service, stdout } = yield* makeLive();
const result = yield* service.application(
Effect.gen(function* () {
const action = yield* service.prompt.custom(
Screen.make("main menu", ({ submit }) => {
queueMicrotask(() => submit("add"));
return <Status>Main menu</Status>;
}),
);
const name = yield* service.wizard(
service.prompt.custom(
Screen.make("auth flow", ({ submit }) => {
queueMicrotask(() =>
submit("cloudflare", <Status>Profile name</Status>),
);
return <Status>Cloudflare auth</Status>;
}),
),
);
const done = yield* service.prompt.custom(
Screen.make("returned menu", ({ submit }) => {
queueMicrotask(() => submit(true));
return <Status>Returned menu</Status>;
}),
);
return { action, name, done };
}),
);
expect(result).toEqual({
action: "add",
name: "cloudflare",
done: true,
});
// Clearing an inline application first renders an empty frame. Its final
// row must be reclaimed before teardown or the next shell prompt starts
// one line too low.
expect(
stdout.output.slice(stdout.output.lastIndexOf("\n") + 1),
).toContain("\u001B[1A");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"provides CliKit once as a scoped injectable service",
() => {
const stdout = new CaptureStream();
return Effect.gen(function* () {
const capabilities = yield* CliKit.useSync((cli) => cli.terminal);
const cli = yield* CliKit;
yield* cli.output.print("Injected");
expect(capabilities.input).toBe(false);
expect(capabilities.colors).toBe(false);
expect(stdout.output).toContain("Injected");
}).pipe(
Effect.provide(
cliKitLayer({
input: false,
stdout: stdout as unknown as NodeJS.WriteStream,
captureConsole: false,
}),
),
);
},
{ tags: ["unit", "local"] },
);
it.effect(
"uses append-only progress when input is disabled on a TTY",
() => {
const stdout = new CaptureStream(true);
return Effect.gen(function* () {
const cli = yield* CliKit;
const progress = yield* cli.live.progress({ label: "Deploying" });
yield* progress.update({ label: "Uploading" });
yield* progress.succeed("Deployed");
expect(cli.terminal.input).toBe(false);
expect(stdout.output).toContain("Deploying\n");
expect(stdout.output).toContain("Deployed\n");
expect(stdout.output).not.toContain("\u001B[");
}).pipe(
Effect.provide(
cliKitLayer({
input: false,
stdout: stdout as unknown as NodeJS.WriteStream,
captureConsole: false,
}),
),
);
},
{ tags: ["unit", "local"] },
);
it.effect(
"renders the same semantic and component output without terminal input",
() =>
Effect.gen(function* () {
const { service, stdout } = yield* makeLive({
input: false,
unicode: false,
});
yield* service.output.info("Resolving credentials");
yield* service.output.success({
message: "Authenticated",
detail: "cloudflare",
});
yield* service.output.warning("Token expires soon");
yield* service.output.error("Authentication failed");
yield* service.output.print(
<Alert variant="warning" title="Attention">
Manual action required
</Alert>,
);
yield* service.output.print(<Status>React output</Status>);
expect(stdout.output).toContain("Resolving credentials\n");
expect(stdout.output).toContain("Authenticated");
expect(stdout.output).toContain("cloudflare");
expect(stdout.output).toContain("Token expires soon");
expect(stdout.output).toContain("Authentication failed");
expect(stdout.output).toContain("Attention");
expect(stdout.output).toContain("Manual action required");
expect(stdout.output).toContain("React output");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"renders application, transcript, live-work, and data primitives together",
() =>
Effect.gen(function* () {
const { service } = makeStatic();
const rendered = yield* service.output.render(
<>
<Tabs
tabs={[
{ id: "dev", label: "dev" },
{ id: "prod", label: "prod", marked: true },
]}
active="prod"
/>
<AnsweredPrompt message="Account" answer="production" />
<TaskRow spinning label="stack" />
<TaskRow icon="+" label="worker" depth={1} />
<ProgressGroup
rows={[
{
id: "providers",
label: "providers",
completed: 2,
total: 4,
},
]}
/>
<Status>q quit</Status>
</>,
);
expect(rendered).toContain("prod");
expect(rendered).toContain("production");
expect(rendered).toContain("worker");
expect(rendered).toContain("2/4");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"composes widgets before and after divided key hints",
() =>
Effect.gen(function* () {
const { service } = makeStatic();
const rendered = yield* service.output.render(
<KeyBar
inline
marginTop={0}
divider
before={<Text>Plan summary</Text>}
keys={[["p", "show plan"]]}
after={<Text>ready</Text>}
/>,
);
expect(rendered.trim().split("\n")).toHaveLength(1);
expect(rendered).toContain("Plan summary");
expect(rendered.match(/│/g)).toHaveLength(2);
expect(rendered).toContain("p show plan");
expect(rendered).toContain("ready");
}),
{ tags: ["unit", "local"] },
);
it(
"wraps individual key hints in narrow terminals",
() => {
const { service } = makeStatic();
const rendered = service.output.format(
<KeyBar
inline
marginTop={0}
divider
before={<Text>Ready</Text>}
keys={[
["p", "show plan"],
["Ctrl+C", "exit"],
]}
/>,
{ columns: 20 },
);
expect(rendered).toContain("p show plan");
expect(rendered).toContain("Ctrl+C exit");
expect(rendered.trimEnd().split("\n").length).toBeGreaterThan(1);
},
{ tags: ["unit", "local"] },
);
it.effect(
"tabs scroll horizontally instead of wrapping when overflowing",
() =>
Effect.gen(function* () {
const { service } = makeStatic();
const tabs = Array.from({ length: 20 }, (_, i) => {
const label = `profile-${String(i + 1).padStart(2, "0")}`;
return { id: label, label, marked: i === 0 };
});
// Active tab in the middle: window centers on it, arrows on both sides.
const middle = yield* service.output.render(
<Tabs tabs={tabs} active="profile-10" />,
);
expect(middle).toContain("profile-10");
expect(middle).toContain("‹");
expect(middle).toContain("›");
expect(middle).not.toContain("profile-01");
expect(middle).not.toContain("profile-20");
// every rendered tab stays on the single tab row — no wrapped chips
expect(middle.trimEnd()).not.toContain("\n");
// Active tab at the start: no left arrow, right arrow only.
const first = yield* service.output.render(
<Tabs tabs={tabs} active="profile-01" />,
);
expect(first).toContain("profile-01");
expect(first).not.toContain("‹");
expect(first).toContain("›");
// Active tab at the end: left arrow only.
const last = yield* service.output.render(
<Tabs tabs={tabs} active="profile-20" />,
);
expect(last).toContain("profile-20");
expect(last).toContain("‹");
expect(last).not.toContain("›");
// Everything fits: no arrows at all.
const fitting = yield* service.output.render(
<Tabs tabs={tabs.slice(0, 3)} active="profile-02" />,
);
expect(fitting).toContain("profile-01");
expect(fitting).toContain("profile-03");
expect(fitting).not.toContain("‹");
expect(fitting).not.toContain("›");
}),
{ tags: ["unit", "local"] },
);
it(
"tabsWindow keeps the active tab visible within the available width",
() => {
// 5 chips of width 12, gap 1, 74 columns → 5 fit exactly around the middle
const widths = Array.from({ length: 20 }, () => 12);
expect(tabsWindow(widths, 9, 74)).toEqual({ start: 7, end: 12 });
// at the edges the window pins to the boundary
expect(tabsWindow(widths, 0, 74)).toEqual({ start: 0, end: 5 });
expect(tabsWindow(widths, 19, 74)).toEqual({ start: 15, end: 20 });
// active always inside, even when nothing else fits
expect(tabsWindow(widths, 3, 12)).toEqual({ start: 3, end: 4 });
expect(tabsWindow(widths, 3, 5)).toEqual({ start: 3, end: 4 });
// out-of-range active clamps; empty input yields an empty window
expect(tabsWindow(widths, 99, 74)).toEqual({ start: 15, end: 20 });
expect(tabsWindow([], 0, 74)).toEqual({ start: 0, end: 0 });
},
{ tags: ["unit", "local"] },
);
it.live(
"state explorer confirms deletes inline and keeps its position afterwards",
() =>
Effect.gen(function* () {
const { calls, source } = makeExplorerSource();
let resources = ["Api/Worker", "Api/Queue", "Db"];
const deleted: string[] = [];
const mutable: StateExplorerSource = {
...source,
listResources: () =>
Effect.sync(() => {
calls.resources++;
return resources;
}),
deleteNodes: (nodes) =>
Effect.sync(() => {
for (const node of nodes) {
deleted.push(node.path);
const prefix = `app/prod/`;
const fqn = node.path.slice(prefix.length);
resources = resources.filter(
(r) => r !== fqn && !r.startsWith(`${fqn}/`),
);
}
}),
};
const stdin = new InputStream();
const { service, stdout } = yield* makeLive({ stdin });
const fiber = yield* service
.application(service.prompt.custom(stateExplorerScreen(mutable)))
.pipe(Application.alternate)
.pipe(Effect.forkChild);
yield* Effect.promise(() => stdin.ready);
const press = (key: string) =>
Effect.sync(() => stdin.write(key)).pipe(Effect.andThen(settleInput));
// Bounded waits that dump the last frame on expiry — a silent hang here
// is otherwise undebuggable.
const frame = () => stripVTControlCharacters(stdout.output.slice(-6000));
const waitFrame = (text: string) =>
Effect.promise(() => stdout.waitFor(text)).pipe(
Effect.timeoutOrElse({
duration: "3 seconds",
orElse: () =>
Effect.sync(() => {
console.log(`=== TIMEOUT waiting for ${text} ===\n${frame()}`);
throw new Error(`timeout waiting for ${text}`);
}),
}),
);
yield* waitFrame("app");
// root → stages → state → into Api → Worker (select a row, then enter it)
const enter = Effect.gen(function* () {
yield* press("j");
yield* press("\x1B[C");
});
yield* enter;
yield* waitFrame("prod");
yield* enter;
yield* waitFrame("Api");
yield* enter;
yield* waitFrame("Worker");
yield* press("j");
yield* waitFrame("PREVIEW");
yield* press("d");
yield* waitFrame("Delete state records?");
// The panel must not narrow the layout: its top border spans the
// terminal, exactly like the header rule above the columns.
const confirmFrame = frame();
const rule = confirmFrame
.split("\n")
.filter((line) => /^─+$/.test(line.trim()));
expect(rule.at(-1)?.trim().length).toBe(stdout.columns);
const before = stdout.output.length;
yield* press("y");
yield* waitFrame("Deleted state at");
yield* settleInput;
yield* Effect.promise(flushEffects);
yield* settleInput;
const after = stripVTControlCharacters(stdout.output.slice(before));
expect(deleted).toEqual(["app/prod/Api/Queue"]);
// stage listing re-read once; stacks/stages untouched
expect(calls.stacks).toBe(1);
expect(calls.stages).toBe(1);
expect(calls.resources).toBe(2);
// cursor moved to the surviving sibling in the same column
expect(after).toContain("app/prod/Api/Worker");
yield* press("q");
yield* Fiber.join(fiber);
}),
{ tags: ["unit", "local"] },
);
it.effect(
"renders nuke scan counts and outstanding providers",
() =>
Effect.gen(function* () {
const { service } = makeStatic();
const store = new NukeProgressStore();
store.emit({ _tag: "nuke.scan.started", total: 2 });
store.emit({ _tag: "nuke.scan.provider.started", provider: "Test.Fast" });
store.emit({ _tag: "nuke.scan.provider.started", provider: "Test.Slow" });
store.emit({
_tag: "nuke.scan.provider.completed",
provider: "Test.Fast",
resources: 3,
});
const output = yield* service.output.render(
<NukeProgress store={store} />,
);
expect(output).toContain("1/2");
expect(output).toContain("3 resources found");
expect(output).toContain("Scanning Test.Slow");
const lines = output.split("\n");
const header = lines.findIndex((line) => line.includes("1/2"));
expect(lines[header]).toMatch(/^ \[[⣿⡇ ]+\] {2}\[1\/2\]\s*$/);
expect(lines[header + 1]?.trim()).toBe("");
expect(lines[header + 2]).toContain("Scanning providers");
expect(output).not.toContain("Scanning Test.Fast");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"updates individual nuke plan rows during deletion",
() =>
Effect.gen(function* () {
const { service } = makeStatic();
const tree = new PlanTree(
nukePlan(
[
{ providerId: "Test.B", displayName: "same-name" },
{ providerId: "Test.A", displayName: "same-name" },
{ providerId: "Test.A", displayName: "same-name" },
],
{ mode: "live" },
),
{ mode: "apply", viewport: "full", busy: true },
);
tree.setLabel("Deleting resources · pass 2");
tree.emit({
_tag: "apply.resource.status",
fqn: "nuke/2",
id: "Test.A",
type: "Test.A",
status: "deleted",
});
tree.emit({
_tag: "apply.resource.status",
fqn: "nuke/1",
id: "Test.A",
type: "Test.A",
status: "fail",
message: "blocked",
});
tree.emit({
_tag: "apply.resource.status",
fqn: "nuke/0",
id: "Test.B",
type: "Test.B",
status: "deleting",
});
expect(tree.snapshot().tasks.get("nuke/2")?.status).toBe("deleted");
expect(tree.snapshot().tasks.get("nuke/1")?.status).toBe("fail");
expect(tree.progress()).toEqual({ completed: 2, total: 3, failures: 1 });
const output = yield* service.output.render(<Plan tree={tree} />);
expect(output).toContain("pass 2");
expect(output).toContain("blocked");
expect(output.match(/same-name/g)).toHaveLength(3);
expect(output).not.toContain("(Test.A)");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"renders nuke execution outcomes in the shared plan",
() =>
Effect.gen(function* () {
const { service, stdout } = yield* makeLive();
const provider: ProviderService = {
nuke: { dependsOn: ["Test.Parent"] },
list: () => Effect.succeed([]),
reconcile: () => Effect.succeed({}),
delete: () => Effect.void,
};
const resources = [
{
providerId: "Test.Parent",
displayName: "parent",
attributes: {},
provider: { ...provider, nuke: undefined },
},
{
providerId: "Test.Child",
displayName: "same-name",
attributes: {},
provider: {
...provider,
delete: () => Effect.fail("cannot delete child"),
},
},
{
providerId: "Test.Child",
displayName: "same-name",
attributes: {},
provider,
},
];
const result = yield* NukeRoute.execute({
scan: {
resources,
mode: "live",
context: Context.empty(),
failures: [],
},
resources,
strategy: { _tag: "coordinated" },
}).pipe(
renderNukeDelete(resources, "live"),
Effect.provideService(CliKit, service),
);
expect(result.deleted).toHaveLength(1);
expect(result.failed).toHaveLength(1);
expect(result.held).toHaveLength(1);
expect(stdout.output).toContain("Nuke incomplete");
expect(stdout.output).toContain("Held back by Test.Child");
expect(stdout.output).toContain("cannot delete child");
expect(stdout.output).toContain("same-name");
expect(stdout.output).not.toContain("Test.Child 1/2");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"closes nuke live progress when the operation fails",
() =>
Effect.gen(function* () {
const { service, stdout } = yield* makeLive();
yield* Effect.gen(function* () {
const report = yield* Progress;
yield* report({ _tag: "nuke.scan.started", total: 2 });
yield* Effect.fail("scan failed");
}).pipe(
renderNukeScan(),
Effect.provideService(CliKit, service),
Effect.result,
);
expect(stdout.output).toContain("\u001B[?25h");
yield* service.output.info("After nuke");
expect(stdout.output).toContain("After nuke");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"renders fixed-width Braille progress bars with partial cells",
() =>
Effect.gen(function* () {
const { service } = makeStatic();
for (const [value, expected] of [
[-1, "[ ]"],
[0, "[ ]"],
[0.0625, "[ ]"],
[0.25, "[⡇ ]"],
[0.5, "[⣿ ]"],
[0.75, "[⣿⡇]"],
[1, "[⣿⣿]"],
[2, "[⣿⣿]"],
] as const) {
const output = yield* service.output.render(
<ProgressBar value={value} width={2} showPercent={false} />,
);
expect(output.trim()).toBe(expected);
}
const labeled = yield* service.output.render(
<ProgressBar
value={0.5}
width={2}
label="Uploading"
detail="2 of 4 files"
/>,
);
expect(labeled).toContain("[⣿ ]");
expect(labeled).toContain("50%");
expect(labeled).toContain("Uploading");
expect(labeled).toContain("2 of 4 files");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"falls back to ASCII for progress bars without Unicode",
() =>
Effect.gen(function* () {
const { service } = yield* makeLive({ input: false, unicode: false });
const output = yield* service.output.render(
<ProgressBar value={0.5} width={4} showPercent={false} />,
);
expect(output.trim()).toBe("[##..]");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"renders nuke inventory with resource types as names and distinct row keys",
() =>
Effect.gen(function* () {
const { service } = makeStatic();
const targets = [
{ providerId: "Cloudflare.R2.Bucket", displayName: "same/name" },
{ providerId: "Cloudflare.R2.Bucket", displayName: "same/name" },
{ providerId: "AWS.S3.Bucket", displayName: "" },
];
const plan = nukePlan(targets, { mode: "live" });
const tree = new PlanTree(plan, { viewport: "full" });
expect(tree.progressRows).toHaveLength(3);
expect(new Set(tree.rows.map((row) => row.key)).size).toBe(3);
expect(tree.rows.map((row) => row.id)).toEqual([
"AWS.S3.Bucket",
"Cloudflare.R2.Bucket",
"Cloudflare.R2.Bucket",
]);
expect(
tree.rows.every((row) => row.type === "resource" && row.depth === 0),
).toBe(true);
const output = yield* service.output.render(<Plan tree={tree} />);
expect(output).toContain("3 to delete");
expect(output.match(/Cloudflare.R2.Bucket/g)).toHaveLength(2);
expect(output).not.toContain("(Cloudflare.R2.Bucket)");
expect(output.match(/same\/name/g)).toHaveLength(2);
const compact = yield* service.output.render(
<Plan
tree={
new PlanTree(nukePlan(targets, { mode: "live" }), {
viewport: "full",
})
}
/>,
);
expect(compact).toContain("3 to delete");
expect(compact.match(/same\/name/g)).toHaveLength(2);
expect(compact.match(/Cloudflare.R2.Bucket/g)).toHaveLength(2);
expect(compact).not.toContain("(x2)");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"prints the shared nuke plan for dry-run and yes without prompting",
() =>
Effect.gen(function* () {
for (const options of [
{ yes: false, dryRun: true },
{ yes: true, dryRun: false },
]) {
const { service, stdout } = makeStatic();
const result = yield* reviewNuke(
[{ providerId: "AWS.S3.Bucket", displayName: "bucket" }],
{ ...options, mode: "local" },
).pipe(Effect.provideService(CliKit, service));
expect(result).toBe(true);
expect(stdout.output).toContain("1 to delete");
expect(stdout.output).toContain("AWS.S3.Bucket");
expect(stdout.output).toContain("bucket");
}
}),
{ tags: ["unit", "local"] },
);
it.live(
"defaults nuke plan approval to Cancel",
() =>
Effect.gen(function* () {
const stdin = new InputStream();
const { service, stdout } = yield* makeLive({ stdin });
const review = yield* reviewNuke(
[{ providerId: "AWS.S3.Bucket", displayName: "bucket" }],
{ mode: "local", yes: false, dryRun: false },
).pipe(Effect.provideService(CliKit, service), Effect.forkChild);
yield* Effect.promise(flushEffects);
yield* Effect.raceFirst(
Effect.promise(() => stdout.waitFor("Cancel")),
Fiber.join(review).pipe(
Effect.flatMap(() => Effect.die("review ended before prompt")),
),
);
yield* Effect.promise(() => stdin.ready);
expect(stdout.output).toContain("1 to delete");
expect(stdout.output).toContain("locally emulated");
yield* Effect.sync(() => stdin.write("\r"));
yield* Effect.promise(flushEffects);
expect(yield* Fiber.join(review)).toBe(false);
}),
{ tags: ["unit", "local"] },
);
it.effect(
"keeps unnamed and duplicate nuke targets as individual rows",
() =>
Effect.gen(function* () {
const { service } = makeStatic();
const targets = [
{
providerId: "AWS.ApiGateway.GatewayResponse",
displayName: "api-123",
},
{
providerId: "AWS.ApiGateway.GatewayResponse",
displayName: "api-123",
},
{ providerId: "AWS.S3.Bucket", displayName: "photos" },
{ providerId: "AWS.S3.Bucket", displayName: "backups" },
{ providerId: "Test.Unknown", displayName: "unknown" },
{ providerId: "Test.Unknown", displayName: "" },
];
const plan = nukePlan(targets, { mode: "live" });
expect(plan.rows).toHaveLength(6);
const output = yield* service.output.render(
<Plan tree={new PlanTree(plan, { viewport: "full" })} />,
);
expect(output).toContain("6 to delete");
expect(output.match(/api-123/g)).toHaveLength(2);
expect(output.match(/Test.Unknown/g)).toHaveLength(2);
expect(output).not.toContain("(x2)");
expect(output).toContain("photos");
expect(output).toContain("backups");
expect(output).not.toContain("· unknown");
}),
{ tags: ["unit", "local"] },
);
it.effect(
"logs nuke errors immediately through the configured Effect logger",
() =>
Effect.gen(function* () {
for (const input of [true, false]) {
const { service } = yield* makeLive({ input });
const entries: Array<{ level: string; message: unknown }> = [];
const logger = Logger.make<unknown, void>(({ logLevel, message }) => {
entries.push({ level: logLevel, message });
});
yield* Effect.gen(function* () {
const report = yield* Progress;
yield* report({ _tag: "nuke.scan.started", total: 2 });
yield* report({
_tag: "nuke.scan.provider.completed",
provider: "Test.Failed",
resources: 0,
error: "denied during scan",
});
// Check before the operation returns: errors must not wait for completion.
expect(entries).toEqual([
{ level: "Warn", message: ["Test.Failed: denied during scan"] },
]);
yield* report({
_tag: "nuke.resource.failed",
provider: "Test.Resource",
resource: "example",
message: "denied during delete",
});
expect(entries[1]).toEqual({
level: "Warn",
message: ["Test.Resource example: denied during delete"],
});
yield* report({
_tag: "nuke.scan.provider.completed",
provider: "Test.Ready",
resources: 1,
});
yield* report({
_tag: "nuke.resource.deleted",
provider: "Test.Ready",
resource: "gone",
});
}).pipe(
renderNukeScan(),
Effect.provideService(CliKit, service),
Effect.provide(Logger.layer([logger])),
);
expect(entries).toHaveLength(input ? 2 : 4);
if (!input) {
expect(entries.slice(2)).toEqual([
{ level: "Info", message: ["scanned Test.Ready (1)"] },
{ level: "Info", message: ["deleted Test.Ready gone"] },
]);
}
}
}),
{ tags: ["unit", "local"] },
);