1/** @jsxImportSource @alchemy.run/sigil */
2import { PassThrough } from "node:stream";
3import { stripVTControlCharacters } from "node:util";
4import {
5 NonInteractiveTerminal,
6 Application,
7 Screen,
8 TerminalCancelled,
9 CliKit,
10 layer as cliKitLayer,
11} from "@/Cli/CliKit/index.ts";
12import {
13 AnsweredPrompt,
14 Alert,
15 Box,
16 ChoiceGroup,
17 DescriptionList,
18 Heading,
19 KeyBar,
20 LiveStore,
21 PromptFrame,
22 ProgressGroup,
23 ProgressBar,
24 SectionHeading,
25 Status,
26 Toast,
27 Tabs,
28 TaskRow,
29 Text,
30 TextField,
31 useLiveStore,
32} from "@/Cli/components/ui/index.ts";
33import { Menu } from "@/Cli/components/ui/Interactive.tsx";
34import { tabsWindow } from "@/Cli/components/ui/Layout.tsx";
35import { makeRuntime } from "@/Cli/components/view/Runtime.tsx";
36import { sigilCli } from "@/Cli/components/view/SigilCli.tsx";
37import { Cli, Progress } from "@/Report.ts";
38import {
39 NukeProgress,
40 NukeProgressStore,
41 nukePlan,
42 reviewNuke,
43 renderNukeDelete,
44 renderNukeScan,
45} from "@/Cli/components/view/Nuke.tsx";
46import { renderApply } from "@/Cli/commands/render.ts";
47import { isInProgress } from "@/Cli/components/view/statusStyle.ts";
48import { spinnerFramesFor } from "@/Util/Theme.ts";
49import {
50 makeResourceLogger,
51 makeResourceOutput,
52} from "@/Util/ResourceOutput.ts";
53import { stackOutputsView } from "@/Cli/components/view/StackOutputs.tsx";
54import { Plan, PlanTree } from "@/Cli/components/view/PlanView.tsx";
55import { ApprovePlan } from "@/Cli/components/view/ApprovePlan.tsx";
56import {
57 ProfileDetailsBody,
58 providerBlockHeight,
59 type ProfileProviderDisplay,
60} from "@/Cli/components/view/Profile.tsx";
61import {
62 Dashboard,
63 DashStore,
64 runProfileDashboardSession,
65} from "@/Cli/components/view/ProfileDashboard.tsx";
66import {
67 buildStageNodes,
68 stateExplorerScreen,
69 StateExplorerStore,
70 type StateExplorerSource,
71} from "@/Cli/components/view/StateExplorer.tsx";
72import * as Effect from "effect/Effect";
73import * as Context from "effect/Context";
74import * as NukeRoute from "@/Alchemist/routes/nuke.ts";
75import type { ProviderService } from "@/Provider.ts";
76import * as Exit from "effect/Exit";
77import * as Fiber from "effect/Fiber";
78import * as Layer from "effect/Layer";
79import * as Logger from "effect/Logger";
80import { expect, it } from "alchemy-test";
81import { deleteNode, noopNode, planWith, updateNode } from "./PlanTestNodes.ts";
82
83class CaptureStream extends PassThrough {
84 readonly columns = 80;
85 readonly rows = 24;
86 output = "";
87
88 constructor(readonly isTTY = false) {
89 super();
90 this.on("data", (chunk) => {
91 this.output += chunk.toString();
92 });
93 }
94
95 waitFor(text: string): Promise<void> {
96 if (this.output.includes(text)) return Promise.resolve();
97 return new Promise((resolve) => {
98 const onData = () => {
99 if (!this.output.includes(text)) return;
100 this.off("data", onData);
101 resolve();
102 };
103 this.on("data", onData);
104 });
105 }
106}
107
108class InputStream extends PassThrough {
109 readonly isTTY = true;
110 isRaw = false;
111 private resolveReady: (() => void) | undefined;
112 readonly ready = new Promise<void>((resolve) => {
113 this.resolveReady = resolve;
114 });
115
116 setRawMode(mode: boolean) {
117 this.isRaw = mode;
118 if (mode) {
119 // Size-only consumers no longer query capabilities. Input is ready once
120 // the raw-mode reader attaches; a query may temporarily detach it.
121 setImmediate(() => {
122 if (this.listenerCount("readable") > 0) this.resolveReady?.();
123 });
124 }
125 return this;
126 }
127
128 connect(stdout: CaptureStream) {
129 stdout.on("data", (chunk) => {
130 if (!chunk.toString().includes("\x1b[c")) return;
131 // Raw mode starts before Sigil finishes querying the terminal. Answer
132 // its device query, then allow input handlers to attach before typing.
133 setImmediate(() => {
134 this.write("\x1b[?1;2c");
135 setImmediate(() => this.resolveReady?.());
136 });
137 });
138 }
139
140 ref() {
141 return this;
142 }
143
144 unref() {
145 return this;
146 }
147}
148
149const makeStatic = () => {
150 const stdout = new CaptureStream();
151 const runtime = makeRuntime(
152 {
153 input: false,
154 // SAFETY: CaptureStream implements the writable stream surface consumed by Sigil.
155 stdout: stdout as unknown as NodeJS.WriteStream,
156 captureConsole: false,
157 },
158 {
159 input: false,
160 columns: stdout.columns,
161 rows: stdout.rows,
162 colors: false,
163 unicode: true,
164 alternateScreen: false,
165 },
166 );
167 return { ...runtime, stdout };
168};
169
170it.effect(
171 "reports terminal-native progress on TTY output",
172 () =>
173 Effect.gen(function* () {
174 const { service, stdout } = yield* makeLive();
175 yield* service.nativeProgress.set("indeterminate");
176 yield* service.nativeProgress.set("normal", 50);
177 yield* service.nativeProgress.set("inactive");
178
179 expect(stdout.output).toContain("\u001B]9;4;3\u001B\\");
180 expect(stdout.output).toContain("\u001B]9;4;1;50\u001B\\");
181 expect(stdout.output).toContain("\u001B]9;4;0\u001B\\");
182 }),
183 { tags: ["unit", "local"] },
184);
185
186const makeExplorerSource = () => {
187 const calls = { stacks: 0, stages: 0, resources: 0, files: 0 };
188 const source: StateExplorerSource = {
189 backend: "local",
190 listStacks: Effect.sync(() => {
191 calls.stacks++;
192 return ["app"];
193 }),
194 listStages: () =>
195 Effect.sync(() => {
196 calls.stages++;
197 return ["prod"];
198 }),
199 listResources: () =>
200 Effect.sync(() => {
201 calls.resources++;
202 return ["Api/Worker"];
203 }),
204 readFile: () =>
205 Effect.sync(() => {
206 calls.files++;
207 return { status: "created", url: "https://workers.dev" };
208 }),
209 deleteNodes: () => Effect.void,
210 };
211 return { calls, source };
212};
213
214const explorerSource = makeExplorerSource().source;
215const flushEffects = () =>
216 new Promise<void>((resolve) => setImmediate(resolve));
217
218it(
219 "builds Finder-style namespace columns from FQN names",
220 () => {
221 const nodes = buildStageNodes("app", "prod", ["Api/Worker"]);
222 expect(nodes.map((node) => node.name)).toEqual(["Api", "output"]);
223 const api = nodes[0];
224 expect(api?.kind).toBe("namespace");
225 if (api?.kind === "namespace") {
226 expect(api.children.map((node) => node.name)).toEqual(["Worker"]);
227 }
228 },
229 { tags: ["unit", "local"] },
230);
231
232it(
233 "loads state columns and file contents only when selected",
234 async () => {
235 const { calls, source } = makeExplorerSource();
236 const store = new StateExplorerStore(source);
237 store.loadRoot();
238 await flushEffects();
239 expect(calls).toEqual({ stacks: 1, stages: 0, resources: 0, files: 0 });
240
241 const root = store.snapshot().root;
242 expect(root.status).toBe("ready");
243 if (root.status !== "ready") return;
244 const stack = root.value[0]!;
245 store.loadChildren(stack);
246 await flushEffects();
247 expect(calls).toEqual({ stacks: 1, stages: 1, resources: 0, files: 0 });
248
249 const stages = store.snapshot().children.get(stack.id);
250 if (stages?.status !== "ready") return;
251 const stage = stages.value[0]!;
252 store.loadChildren(stage);
253 await flushEffects();
254 expect(calls).toEqual({ stacks: 1, stages: 1, resources: 1, files: 0 });
255
256 const state = store.snapshot().children.get(stage.id);
257 if (state?.status !== "ready") return;
258 const namespace = state.value.find((node) => node.kind === "namespace");
259 if (namespace?.kind !== "namespace") return;
260 store.loadFile(namespace.children[0]!);
261 await flushEffects();
262 expect(calls).toEqual({ stacks: 1, stages: 1, resources: 1, files: 1 });
263 },
264 { tags: ["unit", "local"] },
265);
266
267it(
268 "ignores state explorer responses from before a refresh",
269 async () => {
270 let resolveStale: ((stacks: ReadonlyArray<string>) => void) | undefined;
271 let request = 0;
272 const source: StateExplorerSource = {
273 ...explorerSource,
274 listStacks: Effect.suspend(() => {
275 request++;
276 if (request === 1) {
277 return Effect.promise(
278 () =>
279 new Promise((resolve) => {
280 resolveStale = resolve;
281 }),
282 );
283 }
284 return Effect.succeed(["fresh"]);
285 }),
286 };
287 const store = new StateExplorerStore(source);
288 store.loadRoot();
289 store.refresh();
290 await flushEffects();
291
292 resolveStale?.(["stale"]);
293 await flushEffects();
294
295 const root = store.snapshot().root;
296 expect(root.status).toBe("ready");
297 if (root.status === "ready") {
298 expect(root.value.map((node) => node.name)).toEqual(["fresh"]);
299 }
300 },
301 { tags: ["unit", "local"] },
302);
303
304it(
305 "prints stack outputs using inspect without wrapping long lines",
306 () => {
307 const { service } = makeStatic();
308 const apiUrl =
309 "https://cloudflareworkerexample-api-clxp5k3fbtqacxdev7mx7uuxmw.testing-2b2.workers.dev";
310 // Narrow width: `wrap="none"` output lines must overflow, not break.
311 const output = service.output.format(
312 stackOutputsView({
313 apiUrl,
314 metadata: { region: "us-east-1", replicas: 2 },
315 }),
316 { columns: 40 },
317 );
318
319 expect(output).toBe(
320 `{\n apiUrl: '${apiUrl}',\n metadata: { region: 'us-east-1', replicas: 2 }\n}`,
321 );
322 },
323 { tags: ["unit", "local"] },
324);
325
326it(
327 "renders detailed plans as nested YAML",
328 () => {
329 const { service } = makeStatic();
330 const tree = new PlanTree(
331 planWith([
332 updateNode({ config: { retries: 2 } }, { config: { retries: 3 } }),
333 ]),
334 { detailed: true, viewport: "full" },
335 );
336 const output = service.output.format(<Plan tree={tree} />, { columns: 80 });
337
338 expect(output).toContain("properties:");
339 expect(output).toContain("config:");
340 expect(output).toContain("│ - retries: 2");
341 expect(output).toContain("│ + retries: 3");
342 expect(output).toContain("(Test.Resource)");
343 },
344 { tags: ["unit", "local"] },
345);
346
347it(
348 "keeps the plan summary visible and updates it during apply",
349 () => {
350 const { service } = makeStatic();
351 const plan = {
352 ...planWith([
353 updateNode({ version: 1 }, { version: 2 }),
354 noopNode({ version: 1 }, "Stable"),
355 ]),
356 defaultMode: "local" as const,
357 };
358 const reviewTree = new PlanTree(plan, { viewport: "full" });
359 const reviewOutput = service.output.format(<Plan tree={reviewTree} />, {
360 columns: 80,
361 });
362 const tree = new PlanTree(plan, {
363 mode: "apply",
364 label: "Starting dev stack",
365 busy: true,
366 });
367 const output = service.output.format(<Plan tree={tree} collapsible />, {
368 columns: 160,
369 });
370
371 expect(reviewOutput).toContain("1 to update");
372 expect(reviewOutput).toContain("1 no change");
373 expect(output.indexOf("─")).toBeLessThan(
374 output.indexOf("Starting dev stack"),
375 );
376 expect(output.indexOf("Plan")).toBeLessThan(output.indexOf("Worker"));
377 expect(output.indexOf("Starting dev stack")).toBeGreaterThan(
378 output.indexOf("Worker"),
379 );
380 expect(output).toContain("1 pending");
381 expect(output).toContain("1 no change");
382 expect(
383 spinnerFramesFor(true).some((frame) =>
384 output.includes(`${frame} Starting dev stack`),
385 ),
386 ).toBe(true);
387 const keybarLine = output
388 .split("\n")
389 .find((line) => line.includes("p hide widget"));
390 expect(keybarLine).toContain("p hide widget");
391 expect(keybarLine).toContain("Ctrl+C exit");
392 expect(keybarLine).toContain("↑/↓ scroll plan");
393 expect(keybarLine).toContain("Starting dev stack");
394
395 tree.emit({
396 _tag: "apply.resource.status",
397 fqn: "Worker",
398 id: "Worker",
399 type: "Test.Resource",
400 status: "updating",
401 });
402 tree.setBusy(false);
403 tree.setViewport("full");
404 const updatingOutput = service.output.format(<Plan tree={tree} />, {
405 columns: 80,
406 });
407 expect(updatingOutput).toContain("1 updating");
408 expect(updatingOutput).not.toContain("1 pending");
409 expect(updatingOutput).toContain("1 no change");
410 expect(
411 spinnerFramesFor(true).some((frame) =>
412 updatingOutput.includes(`${frame} Starting dev stack`),
413 ),
414 ).toBe(false);
415 },
416 { tags: ["unit", "local"] },
417);
418
419it(
420 "keeps a large destroy summary and confirmation visible",
421 () => {
422 const { service } = makeStatic();
423 const deletions = Array.from({ length: 40 }, (_, index) =>
424 deleteNode({}, `Resource${index}`),
425 );
426 const plan = { ...planWith([], deletions), destroy: true };
427 const output = service.output.format(
428 <ApprovePlan
429 plan={plan}
430 controller={{ submit: () => undefined, cancel: () => undefined }}
431 />,
432 { columns: 80 },
433 );
434
435 expect(output).toContain("Destroy");
436 expect(output).toContain("40 to delete");
437 expect(output).toContain("Destroy?");
438 expect(output).toContain("more lines");
439 expect(output).toContain("↑/↓ scroll plan");
440 },
441 { tags: ["unit", "local"] },
442);
443
444it(
445 "keeps apply totals on top and destroy progress on the last row",
446 () => {
447 const { service } = makeStatic();
448 const plan = {
449 ...planWith(
450 [],
451 Array.from({ length: 10 }, (_, index) =>
452 deleteNode({}, `Resource${index}`),
453 ),
454 ),
455 destroy: true,
456 };
457 const tree = new PlanTree(plan, {
458 mode: "apply",
459 label: "Destroying stack",
460 busy: true,
461 });
462 tree.emit({
463 _tag: "apply.resource.status",
464 fqn: "Resource0",
465 id: "Resource0",
466 type: "Test.Resource",
467 status: "deleted",
468 });
469
470 const output = service.output.format(<Plan tree={tree} />, {
471 columns: 120,
472 });
473 const lines = output.split("\n");
474 const summaryLine = lines.find((line) => line.includes("Plan ·"));
475 const progressLine = lines.find((line) =>
476 line.includes("Destroying stack"),
477 );
478
479 expect(summaryLine).toContain("1 deleted");
480 expect(summaryLine).toContain("9 pending");
481 expect(progressLine).toContain("Destroying stack (1/10)");
482 expect(
483 spinnerFramesFor(true).some((frame) => progressLine?.includes(frame)),
484 ).toBe(true);
485 expect(output.indexOf("Plan ·")).toBeLessThan(output.indexOf("Resource0"));
486 expect(output.indexOf("Destroying stack")).toBeGreaterThan(
487 output.indexOf("Resource9"),
488 );
489 },
490 { tags: ["unit", "local"] },
491);
492
493it(
494 "binding rows mirror their host resource and stay out of totals",
495 () => {
496 const { service } = makeStatic();
497 const worker = {
498 ...updateNode({ a: 1 }, { a: 2 }, "Worker"),
499 bindings: [
500 { sid: "BUCKET", action: "update" as const, data: {} },
501 { sid: "OLD", action: "delete" as const, data: {} },
502 { sid: "STABLE", action: "noop" as const, data: {} },
503 ],
504 };
505 const tree = new PlanTree(planWith([worker]), {
506 mode: "apply",
507 label: "Deploying stack",
508 busy: true,
509 });
510 const render = () =>
511 service.output.format(<Plan tree={tree} />, { columns: 80 });
512
513 // Three bindings never inflate the total: only the host is work.
514 expect(tree.progress()).toEqual({ completed: 0, failures: 0, total: 1 });
515 let output = render();
516 expect(output).toContain("Deploying stack (0/1)");
517 expect(output).toContain("unbind");
518 expect(output).toContain("no change");
519
520 tree.emit({
521 _tag: "apply.resource.status",
522 fqn: "Worker",
523 id: "Worker",
524 type: "Test.Resource",
525 status: "updating",
526 });
527 output = render();
528 expect(output).toContain("unbinding");
529 expect(output).toContain("Deploying stack (0/1)");
530
531 tree.emit({
532 _tag: "apply.resource.status",
533 fqn: "Worker",
534 id: "Worker",
535 type: "Test.Resource",
536 status: "updated",
537 });
538 expect(tree.progress()).toEqual({ completed: 1, failures: 0, total: 1 });
539 output = render();
540 expect(output).toContain("Deploying stack (1/1)");
541 expect(output).toContain("unbound");
542 expect(output).toContain("1 updated");
543 expect(output).not.toContain("pending");
544 },
545 { tags: ["unit", "local"] },
546);
547
548it(
549 "binding rows follow a failed host",
550 () => {
551 const { service } = makeStatic();
552 const worker = {
553 ...updateNode({ a: 1 }, { a: 2 }, "Worker"),
554 bindings: [{ sid: "BUCKET", action: "update" as const, data: {} }],
555 };
556 const tree = new PlanTree(planWith([worker]), {
557 mode: "apply",
558 label: "Deploying stack",
559 busy: true,
560 });
561 tree.emit({
562 _tag: "apply.resource.status",
563 fqn: "Worker",
564 id: "Worker",
565 type: "Test.Resource",
566 status: "fail",
567 message: "worker failed",
568 });
569
570 const output = service.output.format(<Plan tree={tree} />, { columns: 80 });
571 expect(tree.progress()).toEqual({ completed: 1, failures: 1, total: 1 });
572 expect(output).toContain("1 fail");
573 expect(output).toContain("Deploying stack (1/1)");
574 expect(output).toContain("worker failed");
575 expect(output).not.toContain("pending");
576 },
577 { tags: ["unit", "local"] },
578);
579
580it.effect(
581 "keeps native progress active until the apply outcome settles",
582 () =>
583 Effect.gen(function* () {
584 const { service, stdout } = yield* makeLive();
585 const tree = new PlanTree(
586 planWith([updateNode({ version: 1 }, { version: 2 })]),
587 {
588 mode: "apply",
589 label: "Deploying stack",
590 busy: true,
591 },
592 );
593 tree.emit({
594 _tag: "apply.resource.status",
595 fqn: "Worker",
596 id: "Worker",
597 type: "Test.Resource",
598 status: "updated",
599 });
600 const live = yield* service.live.open(<Plan tree={tree} />);
601 yield* Effect.promise(flushEffects);
602 expect(stdout.output).toContain("\u001B]9;4;1;100\u001B\\");
603
604 const settledAt = stdout.output.length;
605 tree.finish("failure", "Deploy failed");
606 yield* Effect.promise(flushEffects);
607 expect(stdout.output.slice(settledAt)).toContain(
608 "\u001B]9;4;2;100\u001B\\",
609 );
610 yield* live.close;
611 }),
612 { tags: ["unit", "local"] },
613);
614
615it.effect(
616 "renders completed dev plans as a hideable output view",
617 () =>
618 Effect.gen(function* () {
619 const { service, stdout } = yield* makeLive();
620 const plan = {
621 ...planWith([updateNode({ version: 1 }, { version: 2 })]),
622 defaultMode: "local" as const,
623 };
624 const tree = new PlanTree(plan, {
625 mode: "apply",
626 label: "Starting dev stack",
627 });
628 const live = yield* service.live.open(<Plan tree={tree} collapsible />);
629
630 yield* Effect.promise(() => stdout.waitFor("p hide widget"));
631 yield* Effect.sync(() => {
632 tree.setOutput({ endpoint: "http://localhost:3000" });
633 tree.finish("success", "Dev stack ready", "output");
634 });
635 yield* Effect.promise(() => stdout.waitFor("http://localhost:3000"));
636 expect(stdout.output).toContain("←/→ show plan");
637
638 tree.setView("plan");
639 expect(tree.snapshot().view).toBe("plan");
640
641 tree.setExpanded(false);
642 yield* Effect.promise(() => stdout.waitFor("p show plan/output"));
643
644 const { service: staticService } = makeStatic();
645 const hiddenOutput = staticService.output.format(
646 <Plan tree={tree} collapsible />,
647 { columns: 80 },
648 );
649 expect(hiddenOutput).not.toContain("Plan ·");
650 expect(hiddenOutput).not.toContain("Worker");
651 expect(hiddenOutput).not.toContain("http://localhost:3000");
652 expect(hiddenOutput).toContain("Dev stack ready (0/1)");
653 expect(hiddenOutput).toContain("p show plan/output");
654 yield* live.close;
655 }),
656 { tags: ["unit", "local"] },
657);
658
659// A dev generation keeps its widget mounted after apply settles and parks
660// until a reload (or Ctrl+C) closes its scope. Closing that scope must remove
661// the widget: leaving it behind stacks one stale "Dev stack ready" bar per
662// reload under the live one.
663it.effect(
664 "removes the dev apply widget when its generation scope closes",
665 () =>
666 Effect.gen(function* () {
667 const { service, stdout } = yield* makeLive();
668 const plan = {
669 ...planWith([updateNode({ version: 1 }, { version: 2 })]),
670 defaultMode: "local" as const,
671 };
672 const cli = sigilCli().pipe(
673 Layer.provide(Layer.succeed(CliKit, service)),
674 );
675 let settledAt = 0;
676 yield* Effect.scoped(
677 Effect.gen(function* () {
678 yield* Effect.succeed({ endpoint: "http://localhost:3000" }).pipe(
679 renderApply(plan, { dev: true }),
680 );
681 yield* Effect.promise(() => stdout.waitFor("Dev stack ready"));
682 settledAt = stdout.output.length;
683 }),
684 ).pipe(Effect.provide(cli));
685
686 // Nothing else is live once the generation is gone, so the renderer
687 // unmounts and restores the cursor.
688 expect(stdout.output.slice(settledAt)).toContain("[?25h");
689 }),
690 { tags: ["unit", "local"] },
691);
692
693// `p` hides the dev widget; a hot reload opens a fresh session and must not
694// bring it back — the user's choice outlives the generation that made it.
695it.effect(
696 "keeps the dev plan widget hidden across hot reloads",
697 () =>
698 Effect.gen(function* () {
699 const stdin = new InputStream();
700 const { service, stdout } = yield* makeLive({ stdin });
701 const plan = {
702 ...planWith([updateNode({ version: 1 }, { version: 2 })]),
703 defaultMode: "local" as const,
704 };
705 const cli = sigilCli().pipe(
706 Layer.provide(Layer.succeed(CliKit, service)),
707 );
708 yield* Effect.gen(function* () {
709 const { startApplySession } = yield* Cli;
710 const first = yield* startApplySession(plan, { dev: true });
711 yield* first.done("success");
712 yield* Effect.promise(() => stdout.waitFor("p hide widget"));
713 yield* Effect.promise(() => stdin.ready);
714 yield* Effect.sync(() => stdin.write("p"));
715 yield* Effect.promise(() => stdout.waitFor("p show plan/output"));
716 yield* first.close!;
717
718 const reloadedAt = stdout.output.length;
719 const second = yield* startApplySession(plan, { dev: true });
720 yield* second.done("success");
721 yield* Effect.promise(() => stdout.waitFor("Dev stack ready"));
722 const reloaded = stdout.output.slice(reloadedAt);
723 expect(reloaded).toContain("p show plan/output");
724 expect(reloaded).not.toContain("p hide widget");
725 yield* second.close!;
726 }).pipe(Effect.provide(cli));
727 }),
728 { tags: ["unit", "local"] },
729);
730
731it(
732 "virtualizes dev stack output",
733 () => {
734 const { service } = makeStatic();
735 const tree = new PlanTree(planWith([updateNode({}, {})]), {
736 mode: "apply",
737 label: "Dev stack ready",
738 });
739 tree.setOutput(
740 Object.fromEntries(
741 Array.from({ length: 40 }, (_, index) => [`output${index}`, index]),
742 ),
743 );
744 tree.setView("output");
745
746 const output = service.output.format(<Plan tree={tree} collapsible />, {
747 columns: 80,
748 });
749 expect(output).toContain("Output");
750 expect(output).toContain("earlier lines");
751 expect(output).not.toContain("Worker");
752 expect(output).toContain("←/→ show plan");
753 expect(output).toContain("↑/↓ scroll output");
754 expect(output.split("\n").length).toBeLessThanOrEqual(21);
755 },
756 { tags: ["unit", "local"] },
757);
758
759it(
760 "renders drift details without detailed mode",
761 () => {
762 const { service } = makeStatic();
763 const node = updateNode({ value: "declared" }, { value: "declared" });
764 node.drift = {
765 expected: { value: "declared" },
766 actual: { value: "changed-out-of-band" },
767 };
768 const tree = new PlanTree(planWith([node]), { viewport: "full" });
769 const output = service.output.format(<Plan tree={tree} />, {
770 columns: 80,
771 });
772
773 expect(output).not.toContain("drift:");
774 expect(output).toContain("│ - value: declared");
775 expect(output).toContain("│ + value: changed-out-of-band");
776 },
777 { tags: ["unit", "local"] },
778);
779
780it(
781 "replaces provider details with refresh progress in place",
782 () => {
783 const { service } = makeStatic();
784 const output = service.output.format(
785 <ProfileDetailsBody
786 providers={[
787 {
788 name: "Cloudflare",
789 method: "oauth",
790 status: "ready",
791 lines: ["accessToken: cfoa****", "expires: in 59m"],
792 },
793 {
794 name: "GitHub",
795 method: "gh-cli",
796 status: "ready",
797 lines: ["token: gho_cZ****"],
798 },
799 ]}
800 refreshingProvider="Cloudflare"
801 />,
802 { columns: 80 },
803 );
804
805 expect(output).toContain("Cloudflare");
806 expect(output).toContain("refreshing OAuth credentials…");
807 expect(output).not.toContain("cfoa****");
808 expect(output).not.toContain("in 59m");
809 expect(output).toContain("GitHub");
810 // `key: value` detail lines render as an aligned key column + value
811 expect(output).toContain("token gho_cZ****");
812 },
813 { tags: ["unit", "local"] },
814);
815
816// Eight providers of varying height: 49 rows in total, twice a 24-row terminal.
817const dashboardProviders: ReadonlyArray<ProfileProviderDisplay> = [
818 {
819 name: "AWS",
820 method: "sso",
821 status: "ready",
822 lines: [
823 "accessKeyId: ASIA****",
824 "secretAccessKey: I7hs****",
825 "sessionToken: IQoJ****",
826 "region: us-east-2",
827 "source: sso - default",
828 ],
829 },
830 {
831 name: "Cloudflare",
832 method: "stored",
833 status: "configured",
834 lines: [
835 "apiKey: cfk_****",
836 "email: blan****",
837 "accountId: 2b29****",
838 "source: stored",
839 ],
840 },
841 {
842 name: "Fly",
843 method: "stored",
844 status: "configured",
845 lines: ["apiKey: FlyV****"],
846 },
847 {
848 name: "GitHub",
849 method: "gh-cli",
850 status: "configured",
851 lines: ["token: gho_cZ****", "source: gh-cli"],
852 },
853 {
854 name: "Hetzner",
855 method: "stored",
856 status: "configured",
857 lines: ["token: 50Kt****"],
858 },
859 {
860 name: "Prisma",
861 method: "stored",
862 status: "configured",
863 lines: ["serviceToken: eyJr****"],
864 },
865 {
866 name: "Railway",
867 method: "oauth",
868 status: "configured",
869 lines: [
870 "token: rw_Fe2****",
871 "tokenKind: account",
872 "apiBaseUrl: https://backboard.railway.com",
873 "source: oauth",
874 ],
875 },
876 {
877 name: "Vercel",
878 method: "stored",
879 status: "reauth",
880 lines: ["token: vc_****"],
881 },
882];
883
884const dashboardEntries = [{ name: "default", isActive: true, isDefault: true }];
885
886// The dashboard windows providers by `providerBlockHeight`, so the number must
887// match what a block actually renders.
888it(
889 "sizes provider blocks by providerBlockHeight",
890 () => {
891 const { service } = makeStatic();
892 const output = service.output.format(
893 <ProfileDetailsBody providers={dashboardProviders} focusColumn />,
894 { columns: 80 },
895 );
896 const expected = dashboardProviders.reduce(
897 (rows, provider, index) =>
898 rows + providerBlockHeight(provider, index === 0),
899 0,
900 );
901 expect(output.split("\n").length).toBe(expected);
902 },
903 { tags: ["unit", "local"] },
904);
905
906// Providers are a table separated by blank rows, not rules; the focused
907// provider is marked by the cursor glyph in a reserved column, never a rail.
908it(
909 "marks the focused provider with the pointer and no rails",
910 () => {
911 const { service } = makeStatic();
912 const output = service.output.format(
913 <ProfileDetailsBody
914 providers={dashboardProviders}
915 focusColumn
916 focusedProvider="Cloudflare"
917 reauthHint="press r to re-login"
918 />,
919 { columns: 80 },
920 );
921 const lines = output.split("\n");
922 expect(lines.some((line) => /[│─]/.test(line))).toBe(false);
923 const pointed = lines.filter((line) => line.includes("❯"));
924 expect(pointed).toHaveLength(1);
925 expect(pointed[0]).toMatch(/^ ❯ Cloudflare\s+stored\s+! configured$/);
926 expect(lines.some((line) => /^ AWS\s+sso\s+✓ ready$/.test(line))).toBe(
927 true,
928 );
929 // detail keys render as an aligned column: key, gap, value
930 expect(output).toContain("apiKey cfk_****");
931 expect(output).toContain("accountId 2b29****");
932 // the re-login hint follows the focus cursor, so it is hidden here
933 expect(output).not.toContain("press r to re-login");
934 },
935 { tags: ["unit", "local"] },
936);
937
938it(
939 "windows the profile dashboard's providers to the terminal height",
940 () => {
941 const { service, stdout } = makeStatic();
942 const store = new DashStore(dashboardEntries);
943 store.setDetails("default", {
944 state: "ready",
945 providers: dashboardProviders,
946 available: [],
947 });
948 const output = service.output.format(
949 <Dashboard store={store} initialSelected={0} />,
950 { columns: 80 },
951 );
952
953 expect(output.split("\n").length).toBeLessThanOrEqual(stdout.rows);
954 expect(output).toContain("AWS");
955 expect(output).toContain("Cloudflare");
956 expect(output).toContain("switch profile");
957 expect(output).not.toContain("Vercel");
958 // The profile row holds the initial focus slot: the pointer sits on it and
959 // the provider rows keep a blank cursor column so the table stays aligned.
960 expect(output).toContain("\n ❯ default");
961 expect(output).toContain("\n AWS ");
962 },
963 { tags: ["unit", "local"] },
964);
965
966// The session sleeps on a real clock (loader delay, notice auto-dismiss).
967it.live(
968 "scrolls the profile dashboard to the focused provider",
969 () =>
970 Effect.gen(function* () {
971 const stdin = new InputStream();
972 const { service, stdout } = yield* makeLive({ stdin });
973 const session = yield* runProfileDashboardSession({
974 entries: dashboardEntries,
975 selected: "default",
976 loadDetails: () =>
977 Effect.succeed({ providers: dashboardProviders, available: [] }),
978 execute: () =>
979 Effect.succeed({ ok: true, message: "", entries: dashboardEntries }),
980 runFlow: () => Effect.succeed({ ok: true, message: "" }),
981 reloadEntries: Effect.succeed(dashboardEntries),
982 }).pipe(Effect.provideService(CliKit, service), Effect.forkChild);
983
984 // "focus provider" joins the key bar once the providers have resolved;
985 // arrow keys are ignored while the pane still shows the loading spinner.
986 yield* Effect.promise(() => stdout.waitFor("focus provider"));
987 yield* Effect.promise(() => stdin.ready);
988 expect(stdout.output).not.toContain("Vercel");
989
990 // Walk the focus cursor down to the last provider; the list follows it.
991 for (const _ of dashboardProviders) {
992 yield* Effect.sync(() => stdin.write("\x1b[B"));
993 }
994 yield* Effect.promise(() => stdout.waitFor("Vercel"));
995
996 yield* Effect.sync(() => stdin.write("q"));
997 yield* Fiber.join(session);
998 }),
999 { tags: ["unit", "local"] },
1000);
1001
1002it(
1003 "renders input frames inline by default and keeps a stacked variant",
1004 () => {
1005 const { service } = makeStatic();
1006 const inline = service.output.format(
1007 <PromptFrame
1008 message="Profile name"
1009 description="Used to select stored credentials."
1010 layout="inline"
1011 >
1012 <Text>production</Text>
1013 </PromptFrame>,
1014 );
1015 const stacked = service.output.format(
1016 <PromptFrame message="Profile name" layout="stacked">
1017 <Text>production</Text>
1018 </PromptFrame>,
1019 );
1020
1021 expect(inline).toContain("Profile name: production");
1022 expect(inline).toContain("Used to select stored credentials.");
1023 expect(stacked).toContain("Profile name\n");
1024 expect(stacked).toContain("production");
1025 },
1026 { tags: ["unit", "local"] },
1027);
1028
1029it(
1030 "renders compact informational toasts as a bare status line",
1031 () => {
1032 const { service } = makeStatic();
1033 const output = service.output.format(
1034 <Toast variant="info">Credentials refreshed.</Toast>,
1035 );
1036
1037 expect(output).toBe("• Credentials refreshed.");
1038 },
1039 { tags: ["unit", "local"] },
1040);
1041
1042it(
1043 "renders prompt frames with an indented body and no rail",
1044 () => {
1045 const { service } = makeStatic();
1046 const output = service.output.format(
1047 <PromptFrame message="Cloudflare authentication method">
1048 <Menu
1049 cursor={0}
1050 choices={[
1051 { value: "oauth", label: "OAuth", description: "recommended" },
1052 {
1053 value: "token",
1054 label: "API Token or API Key",
1055 description: "use an API token",
1056 },
1057 ]}
1058 />
1059 </PromptFrame>,
1060 { columns: 100 },
1061 );
1062
1063 const lines = output.split("\n");
1064 expect(lines[0]).toBe("◆ Cloudflare authentication method");
1065 // options sit directly under the heading; ❯ is the only chevron on screen
1066 expect(lines[1]).toMatch(/^ ❯ OAuth\s+recommended$/);
1067 expect(lines[2]).toMatch(/^ API Token or API Key\s+use an API token$/);
1068 // descriptions share one column across rows
1069 expect(lines[1]!.indexOf("recommended")).toBe(
1070 lines[2]!.indexOf("use an API token"),
1071 );
1072 expect(output).not.toContain("│");
1073 expect(output).not.toContain("›");
1074 },
1075 { tags: ["unit", "local"] },
1076);
1077
1078/**
1079 * Scoped variant of `makeStatic` for tests that mount a persistent renderer.
1080 * Disposal runs as a finalizer, so a failing test never leaks a Sigil
1081 * instance.
1082 */
1083const makeLive = (
1084 overrides: {
1085 readonly stdin?: InputStream;
1086 readonly captureConsole?: boolean;
1087 readonly input?: boolean;
1088 readonly unicode?: boolean;
1089 readonly colors?: boolean;
1090 } = {},
1091) => {
1092 const input = overrides.input ?? true;
1093 return Effect.acquireRelease(
1094 Effect.sync(() => {
1095 const stdout = new CaptureStream(input);
1096 const stderr = new CaptureStream(input);
1097 // Interactive runtimes need a raw-mode-capable stdin: with a real
1098 // process.stdin pipe Sigil's useInput throws during commit, which now
1099 // surfaces as a renderer error instead of being silently swallowed.
1100 const stdin = overrides.stdin ?? (input ? new InputStream() : undefined);
1101 stdin?.connect(stdout);
1102 const runtime = makeRuntime(
1103 {
1104 input,
1105 // SAFETY: InputStream implements the raw readable stream surface consumed by Sigil.
1106 stdin: stdin as unknown as NodeJS.ReadStream | undefined,
1107 // SAFETY: CaptureStream implements the writable stream surface consumed by Sigil.
1108 stdout: stdout as unknown as NodeJS.WriteStream,
1109 // SAFETY: CaptureStream implements the writable stream surface consumed by Sigil.
1110 stderr: stderr as unknown as NodeJS.WriteStream,
1111 captureConsole: overrides.captureConsole ?? false,
1112 },
1113 {
1114 input,
1115 columns: stdout.columns,
1116 rows: stdout.rows,
1117 colors: overrides.colors ?? false,
1118 unicode: overrides.unicode ?? true,
1119 alternateScreen: input,
1120 },
1121 );
1122 return { ...runtime, stdout, stderr };
1123 }),
1124 ({ dispose }) => Effect.promise(dispose),
1125 );
1126};
1127
1128it(
1129 "honors explicit color settings for static output",
1130 () => {
1131 const { service } = makeStatic();
1132 const view = <Text color="green">ready</Text>;
1133 const styled = service.output.format(view, { colors: true });
1134 expect(styled).toContain("\u001B[");
1135 expect(stripVTControlCharacters(styled)).toBe("ready");
1136 expect(service.output.format(view, { colors: false })).toBe("ready");
1137 },
1138 { tags: ["unit", "local"] },
1139);
1140
1141it.effect(
1142 "keeps stack output URLs reachable",
1143 () =>
1144 Effect.gen(function* () {
1145 const { service } = yield* makeLive({ colors: true });
1146 const url = "https://example.com/deploy";
1147 const output = service.output.format(stackOutputsView({ url }), {
1148 colors: true,
1149 });
1150 expect(stripVTControlCharacters(output)).toContain(url);
1151 }),
1152 { tags: ["unit", "local"] },
1153);
1154
1155it.effect(
1156 "keeps the state explorer active while searching and quits cleanly",
1157 () =>
1158 Effect.gen(function* () {
1159 const stdin = new InputStream();
1160 const { service, stdout } = yield* makeLive({ stdin });
1161 const fiber = yield* service
1162 .application(service.prompt.custom(stateExplorerScreen(explorerSource)))
1163 .pipe(Application.alternate)
1164 .pipe(Effect.forkChild);
1165 yield* Effect.promise(() => stdin.ready);
1166 yield* Effect.promise(() => stdout.waitFor("Loading"));
1167
1168 yield* Effect.sync(() => stdin.write("/"));
1169 yield* settleInput;
1170 yield* Effect.sync(() => stdin.write("workers.dev"));
1171 yield* settleInput;
1172 yield* Effect.sync(() => stdin.write("\r"));
1173 yield* settleInput;
1174 expect(fiber.pollUnsafe()).toBeUndefined();
1175
1176 yield* Effect.sync(() => stdin.write("q"));
1177 yield* Fiber.join(fiber);
1178 }),
1179 { tags: ["unit", "local"] },
1180);
1181
1182it.effect(
1183 "renders the built-in layout components without writing",
1184 () =>
1185 Effect.gen(function* () {
1186 const { service, stdout } = makeStatic();
1187 const rendered = yield* service.output.render(
1188 <>
1189 <SectionHeading annotation="active">Profile</SectionHeading>
1190 <DescriptionList
1191 items={[
1192 { label: "Name", value: "production" },
1193 { label: "Status", value: "ready" },
1194 ]}
1195 />
1196 </>,
1197 );
1198
1199 expect(rendered).toContain("Profile");
1200 expect(rendered).toContain("production");
1201 expect(rendered).toContain("ready");
1202 expect(stdout.output).toBe("");
1203 }),
1204 { tags: ["unit", "local"] },
1205);
1206
1207it.effect(
1208 "renders confirmations as a compact segmented choice",
1209 () =>
1210 Effect.gen(function* () {
1211 const { service } = makeStatic();
1212
1213 expect(
1214 (yield* service.output.render(
1215 <ChoiceGroup
1216 value
1217 choices={[
1218 { value: true, label: "Yes" },
1219 { value: false, label: "No" },
1220 ]}
1221 />,
1222 )).trim(),
1223 ).toBe("Yes No");
1224 expect(
1225 (yield* service.output.render(
1226 <ChoiceGroup
1227 value={false}
1228 choices={[
1229 { value: true, label: "Yes" },
1230 { value: false, label: "No" },
1231 ]}
1232 />,
1233 )).trim(),
1234 ).toBe("Yes No");
1235 expect(
1236 (yield* service.output.render(
1237 <ChoiceGroup
1238 value
1239 choices={[
1240 { value: true, label: "Destroy" },
1241 { value: false, label: "Cancel" },
1242 ]}
1243 />,
1244 )).trim(),
1245 ).toBe("Destroy Cancel");
1246 expect(
1247 (yield* service.output.render(
1248 <ChoiceGroup
1249 orientation="vertical"
1250 value="repair"
1251 choices={[
1252 {
1253 value: "repair",
1254 label: "Repair",
1255 description: "restore declared state",
1256 },
1257 { value: "cancel", label: "Cancel" },
1258 ]}
1259 />,
1260 )).trim(),
1261 ).toBe("Repair · restore declared state\n Cancel");
1262 }),
1263 { tags: ["unit", "local"] },
1264);
1265
1266it.effect(
1267 "preserves zero in primitive-array views",
1268 () =>
1269 Effect.gen(function* () {
1270 const { service } = makeStatic();
1271 const rendered = yield* service.output.render([
1272 "count: ",
1273 0,
1274 false,
1275 null,
1276 ]);
1277
1278 expect(rendered).toBe("count: 0");
1279 }),
1280 { tags: ["unit", "local"] },
1281);
1282
1283it(
1284 "does not animate work before it starts",
1285 () => {
1286 expect(isInProgress("pending")).toBe(false);
1287 expect(isInProgress("creating")).toBe(true);
1288 expect(isInProgress("updating")).toBe(true);
1289 expect(isInProgress("deleting")).toBe(true);
1290 expect(isInProgress("running")).toBe(true);
1291 },
1292 { tags: ["unit", "local"] },
1293);
1294
1295it.effect(
1296 "prints headings and data views through one service",
1297 () =>
1298 Effect.gen(function* () {
1299 const { service, stdout } = makeStatic();
1300 yield* service.output.print(<Heading>Deployments</Heading>);
1301 yield* service.output.print(
1302 <DescriptionList
1303 items={[
1304 { label: "api", value: "ready" },
1305 { label: "worker", value: "updating" },
1306 ]}
1307 />,
1308 );
1309
1310 expect(stdout.output).toContain("Deployments");
1311 expect(stdout.output).toContain("worker");
1312 expect(stdout.output).toContain("updating");
1313 }),
1314 { tags: ["unit", "local"] },
1315);
1316
1317it.effect(
1318 "fails input operations when no terminal input is available",
1319 () =>
1320 Effect.gen(function* () {
1321 const { service } = makeStatic();
1322 const failure = yield* service.prompt
1323 .select({
1324 message: "Choose",
1325 options: [{ label: "One", value: 1 }],
1326 })
1327 .pipe(Effect.flip);
1328
1329 expect(failure).toBeInstanceOf(NonInteractiveTerminal);
1330 if (failure instanceof NonInteractiveTerminal) {
1331 expect(failure.operation).toBe("selection");
1332 }
1333
1334 const cycleFailure = yield* service.prompt
1335 .cycle({ message: "Change", options: [] })
1336 .pipe(Effect.flip);
1337 const externalFailure = yield* service.prompt
1338 .awaitExternal({
1339 message: "Authorize",
1340 waitingLabel: "Waiting",
1341 inputLabel: "Enter code",
1342 })
1343 .pipe(Effect.flip);
1344 expect(cycleFailure).toBeInstanceOf(NonInteractiveTerminal);
1345 if (cycleFailure instanceof NonInteractiveTerminal) {
1346 expect(cycleFailure.operation).toBe("cycle selection");
1347 }
1348 expect(externalFailure).toBeInstanceOf(NonInteractiveTerminal);
1349 if (externalFailure instanceof NonInteractiveTerminal) {
1350 expect(externalFailure.operation).toBe("external authorization");
1351 }
1352 }),
1353 { tags: ["unit", "local"] },
1354);
1355
1356it.effect(
1357 "progress handles are updateable and settle only once",
1358 () =>
1359 Effect.gen(function* () {
1360 const { service, stdout } = makeStatic();
1361 const progress = yield* service.live.progress({ label: "Deploying" });
1362 yield* progress.update({ label: "Uploading", detail: "2/3" });
1363 yield* progress.succeed("Deployed");
1364 yield* progress.fail("must not print");
1365
1366 expect(stdout.output).toContain("Deploying");
1367 expect(stdout.output).toContain("Deployed");
1368 expect(stdout.output).not.toContain("must not print");
1369 }),
1370 { tags: ["unit", "local"] },
1371);
1372
1373/** Live views are immutable; dynamic content flows through caller stores. */
1374const LiveLabel = ({ store }: { readonly store: LiveStore<string> }) => (
1375 <Text>{useLiveStore(store)}</Text>
1376);
1377
1378it.effect(
1379 "tears down store-driven live views idempotently",
1380 () =>
1381 Effect.gen(function* () {
1382 const { service, stdout } = yield* makeLive();
1383
1384 const store = new LiveStore("Scanning");
1385 const live = yield* service.live.open(<LiveLabel store={store} />);
1386 yield* Effect.sync(() => store.set("Deleting"));
1387 yield* live.close;
1388 yield* live.close;
1389
1390 expect(stdout.output).toContain("\u001B[?25h");
1391 }),
1392 { tags: ["unit", "local"] },
1393);
1394
1395it.effect(
1396 "does not let one closing view tear down a newer live view",
1397 () =>
1398 Effect.gen(function* () {
1399 const { service, stdout } = yield* makeLive();
1400
1401 const first = yield* service.live.open(<Text>first</Text>);
1402 const closing = yield* first.close.pipe(Effect.forkChild);
1403 const store = new LiveStore("second");
1404 const second = yield* service.live.open(<LiveLabel store={store} />, {
1405 persistOnClose: true,
1406 });
1407 yield* Fiber.join(closing);
1408 yield* Effect.sync(() => store.set("second updated"));
1409 yield* second.close;
1410
1411 expect(stdout.output).toContain("second updated");
1412 }),
1413 { tags: ["unit", "local"] },
1414);
1415
1416interface OrderingCase {
1417 readonly name: string;
1418 readonly captureConsole: boolean;
1419 readonly emit: (service: CliKit["Service"]) => Effect.Effect<void>;
1420 readonly verify: (output: string) => void;
1421}
1422
1423const orderingCases: ReadonlyArray<OrderingCase> = [
1424 {
1425 name: "commits persistent live views to the static transcript",
1426 captureConsole: false,
1427 emit: () => Effect.void,
1428 verify: (output) => {
1429 expect(output).toContain("Deployed");
1430 expect(output.slice(output.lastIndexOf("Deployed"))).not.toContain(
1431 "\u001B[2K",
1432 );
1433 expect(output).toContain("\u001B[?25h");
1434 },
1435 },
1436 {
1437 name: "keeps styled captured logs static and ordered before completed live views",
1438 captureConsole: true,
1439 emit: () =>
1440 Effect.sync(() => console.log("\u001B[32mruntime ready\u001B[0m")),
1441 verify: (output) => {
1442 expect(output.match(/runtime ready/g)?.length).toBe(1);
1443 expect(output).toContain("\u001B[32m");
1444 expect(output.indexOf("runtime ready")).toBeLessThan(
1445 output.lastIndexOf("Deployed"),
1446 );
1447 },
1448 },
1449 {
1450 name: "orders semantic output through the active renderer",
1451 captureConsole: false,
1452 emit: (service) => service.output.info("runtime ready"),
1453 verify: (output) => {
1454 expect(output.indexOf("runtime ready")).toBeLessThan(
1455 output.lastIndexOf("Deployed"),
1456 );
1457 },
1458 },
1459];
1460
1461it.effect.each(orderingCases)(
1462 "$name",
1463 ({ captureConsole, emit, verify }) =>
1464 Effect.gen(function* () {
1465 const { service, stdout } = yield* makeLive({
1466 captureConsole,
1467 colors: captureConsole,
1468 });
1469
1470 const store = new LiveStore("Deploying");
1471 const live = yield* service.live.open(<LiveLabel store={store} />, {
1472 persistOnClose: true,
1473 });
1474 yield* emit(service);
1475 yield* Effect.sync(() => store.set("Deployed"));
1476 yield* live.close;
1477
1478 verify(stdout.output);
1479 }),
1480 { tags: ["unit", "local"] },
1481);
1482
1483it.effect(
1484 "commits stdio above active Sigil views",
1485 () =>
1486 Effect.gen(function* () {
1487 const { service, stdout, stderr } = yield* makeLive({
1488 captureConsole: true,
1489 });
1490 const store = new LiveStore("Resolving credentials");
1491 const live = yield* service.live.open(<LiveLabel store={store} />, {
1492 persistOnClose: true,
1493 });
1494
1495 yield* Effect.sync(() => {
1496 stdout.write("floci stdout\n");
1497 stderr.write("node warning\n");
1498 store.set("Credentials resolved");
1499 });
1500 yield* live.close;
1501
1502 expect(stdout.output).toContain("floci stdout");
1503 expect(stdout.output).toContain("node warning");
1504 expect(stderr.output).not.toContain("node warning");
1505 expect(stdout.output).toContain("Credentials resolved");
1506 expect(stdout.output.lastIndexOf("node warning")).toBeLessThan(
1507 stdout.output.lastIndexOf("Credentials resolved"),
1508 );
1509 }),
1510 { tags: ["unit", "local"] },
1511);
1512
1513it.effect(
1514 "flushes a captured partial line without waiting for unmount",
1515 () =>
1516 Effect.gen(function* () {
1517 const { service, stdout, stderr } = yield* makeLive({
1518 captureConsole: true,
1519 });
1520 const store = new LiveStore("Deploying");
1521 const live = yield* service.live.open(<LiveLabel store={store} />);
1522 // A writer whose final chunk has no trailing newline (e.g. an error
1523 // block written raw to stderr): its tail must render within the
1524 // partial-flush deadline — before this, it sat buffered until the
1525 // renderer unmounted at process exit, so the last line of an error
1526 // only ever appeared on shutdown.
1527 yield* Effect.sync(() => {
1528 stderr.write("stack tail without newline");
1529 });
1530 yield* Effect.promise(() => stdout.waitFor("stack tail without newline"));
1531 yield* live.close;
1532 }),
1533 { tags: ["unit", "local"] },
1534);
1535
1536it.effect(
1537 "progress settles into success and failure status output",
1538 () =>
1539 Effect.gen(function* () {
1540 const { service, stdout } = makeStatic();
1541 yield* Effect.scoped(
1542 Effect.gen(function* () {
1543 const first = yield* service.live.progress({
1544 label: "Resolve credentials",
1545 });
1546 yield* first.succeed();
1547 const second = yield* service.live.progress({
1548 label: "Apply resource",
1549 });
1550 yield* second.fail();
1551 }),
1552 );
1553
1554 expect(stdout.output).toContain("Resolve credentials");
1555 expect(stdout.output).toContain("Apply resource");
1556 expect(stdout.output).toContain("✓");
1557 expect(stdout.output).toContain("×");
1558 }),
1559 { tags: ["unit", "local"] },
1560);
1561
1562it.effect(
1563 "task collapses success and failure into status output",
1564 () =>
1565 Effect.gen(function* () {
1566 const { service, stdout } = makeStatic();
1567 yield* service.task(
1568 { label: "Resolve credentials" },
1569 Effect.succeed("credentials"),
1570 );
1571 yield* service
1572 .task({ label: "Apply resource" }, Effect.fail("nope"))
1573 .pipe(Effect.ignore);
1574
1575 expect(stdout.output).toContain("Resolve credentials");
1576 expect(stdout.output).toContain("Apply resource");
1577 expect(stdout.output).toContain("✓");
1578 expect(stdout.output).toContain("×");
1579 }),
1580 { tags: ["unit", "local"] },
1581);
1582
1583it.effect(
1584 "status output composes as a normal view",
1585 () =>
1586 Effect.gen(function* () {
1587 const { service } = makeStatic();
1588 const rendered = yield* service.output.render(
1589 <Status variant="warning" detail="retrying">
1590 API unavailable
1591 </Status>,
1592 );
1593 expect(rendered).toContain("API unavailable");
1594 expect(rendered).toContain("retrying");
1595 }),
1596 { tags: ["unit", "local"] },
1597);
1598
1599it.effect(
1600 "uses ASCII fallbacks when Unicode is unavailable",
1601 () =>
1602 Effect.gen(function* () {
1603 const { service } = yield* makeLive({ input: false, unicode: false });
1604 const rendered = yield* service.output.render(
1605 <>
1606 <Heading>Deploy</Heading>
1607 <Status variant="success">Complete</Status>
1608 </>,
1609 );
1610 expect(rendered).toContain("@ Deploy");
1611 expect(rendered).toContain("+ Complete");
1612 expect(rendered).not.toContain("✓");
1613 }),
1614 { tags: ["unit", "local"] },
1615);
1616
1617it(
1618 "uses one resource-prefixed pipeline for chunked stdout and stderr",
1619 () => {
1620 const lines: string[] = [];
1621 const output = makeResourceOutput("www", {
1622 log: (...args) => lines.push(args.join(" ")),
1623 });
1624
1625 output.stdout.push("first\nsec");
1626 output.stdout.push("ond\r");
1627 output.stderr.push("failed");
1628 output.stdout.flush();
1629 output.stderr.flush();
1630
1631 const prefix = "[www]";
1632 expect(lines.map(stripVTControlCharacters)).toEqual([
1633 `${prefix} first`,
1634 `${prefix} second`,
1635 `${prefix} failed`,
1636 ]);
1637 },
1638 { tags: ["unit", "local"] },
1639);
1640
1641it.effect(
1642 "routes effectful resource output through the configured logger",
1643 () => {
1644 const entries: Array<{ level: string; message: unknown }> = [];
1645 const logger = Logger.make<unknown, void>(({ logLevel, message }) => {
1646 entries.push({ level: logLevel, message });
1647 });
1648 const output = makeResourceLogger("www");
1649
1650 return Effect.gen(function* () {
1651 yield* output("stdout", "[MDX] generated files");
1652 yield* output("stderr", "vite diagnostic");
1653 expect(entries).toEqual([
1654 { level: "Info", message: ["[www] [MDX] generated files"] },
1655 { level: "Info", message: ["[www] vite diagnostic"] },
1656 ]);
1657 }).pipe(Effect.provide(Logger.layer([logger])));
1658 },
1659 { tags: ["unit", "local"] },
1660);
1661
1662it.effect(
1663 "does not decorate resource stderr as a semantic failure",
1664 () =>
1665 Effect.gen(function* () {
1666 const { service, stdout } = yield* makeLive({ captureConsole: true });
1667 const live = yield* service.live.open(<Text>Building</Text>);
1668
1669 const output = makeResourceOutput("www", globalThis.console);
1670 output.stderr.push("[FILE_NAME_CONFLICT] warning\n");
1671 output.stderr.flush();
1672 yield* live.close;
1673
1674 const rendered = stripVTControlCharacters(stdout.output);
1675 expect(rendered).toContain(`[www] [FILE_NAME_CONFLICT] warning`);
1676 expect(rendered).not.toContain("×");
1677 }),
1678 { tags: ["unit", "local"] },
1679);
1680
1681it.effect(
1682 "runs interactive components inside the owned session",
1683 () =>
1684 Effect.gen(function* () {
1685 const { service, stdout } = yield* makeLive();
1686
1687 const result = yield* service.prompt.custom(
1688 Screen.make("test screen", ({ submit }) => {
1689 queueMicrotask(() => submit("completed", <Status>Done</Status>));
1690 return <Status>Working</Status>;
1691 }),
1692 );
1693 expect(result).toBe("completed");
1694 expect(stdout.output).toContain("Done");
1695 }),
1696 { tags: ["unit", "local"] },
1697);
1698
1699it.effect(
1700 "restores the terminal after an alternate-screen interaction",
1701 () =>
1702 Effect.gen(function* () {
1703 const { service, stdout } = yield* makeLive();
1704
1705 yield* service
1706 .application(
1707 service.prompt.custom(
1708 Screen.make("full screen", ({ submit }) => {
1709 queueMicrotask(() => submit(undefined));
1710 return <Status>Browsing</Status>;
1711 }),
1712 ),
1713 )
1714 .pipe(Application.alternate);
1715
1716 expect(stdout.output).toContain("\u001B[?1049h");
1717 expect(stdout.output).toContain("\u001B[?1049l");
1718 expect(stdout.output.indexOf("\u001B[?1049h")).toBeLessThan(
1719 stdout.output.indexOf("\u001B[?1049l"),
1720 );
1721 }),
1722 { tags: ["unit", "local"] },
1723);
1724
1725it.effect(
1726 "treats the terminal DEL byte as text-field backspace",
1727 () =>
1728 Effect.gen(function* () {
1729 const stdin = new InputStream();
1730 const { service } = yield* makeLive({ stdin });
1731
1732 const fiber = yield* service.prompt
1733 .custom(
1734 Screen.make("backspace", ({ submit }) => (
1735 <TextField initialValue="abc" onChange={submit} onSubmit={submit} />
1736 )),
1737 )
1738 .pipe(Effect.forkChild);
1739 yield* Effect.promise(() => stdin.ready);
1740 yield* Effect.sync(() => stdin.write("\x7f"));
1741 const result = yield* Fiber.join(fiber);
1742
1743 expect(result).toBe("ab");
1744 }),
1745 { tags: ["unit", "local"] },
1746);
1747
1748/** Let a written stdin chunk flow through Sigil's input pipeline. */
1749const settleInput = Effect.yieldNow.pipe(Effect.repeat({ times: 2 }));
1750
1751it.effect(
1752 "strips control characters from pasted text-field input",
1753 () =>
1754 Effect.gen(function* () {
1755 const stdin = new InputStream();
1756 const { service } = yield* makeLive({ stdin });
1757
1758 const fiber = yield* service.prompt
1759 .custom(
1760 Screen.make("paste", ({ submit }) => <TextField onSubmit={submit} />),
1761 )
1762 .pipe(Effect.forkChild);
1763 yield* Effect.promise(() => stdin.ready);
1764 // A paste arrives as one chunk; embedded newlines/tabs must not survive.
1765 yield* Effect.sync(() => stdin.write("to\tken\r\n123"));
1766 yield* settleInput;
1767 yield* Effect.sync(() => stdin.write("\r"));
1768 const result = yield* Fiber.join(fiber);
1769
1770 expect(result).toBe("token123");
1771 }),
1772 { tags: ["unit", "local"] },
1773);
1774
1775it.effect(
1776 "shows the whole typed value while width remains available",
1777 () =>
1778 Effect.gen(function* () {
1779 const stdin = new InputStream();
1780 const { service, stdout } = yield* makeLive({ stdin });
1781
1782 const fiber = yield* service.prompt
1783 .text({ message: "New profile name" })
1784 .pipe(Effect.forkChild);
1785 yield* Effect.promise(() => stdin.ready);
1786 // Regression: the field's box used to shrink to its content, so the
1787 // measured "available" width collapsed to the minimum and the value
1788 // scrolled after ~4 characters despite a mostly-empty row.
1789 yield* Effect.sync(() => stdin.write("my-longer-profile-name"));
1790 yield* Effect.promise(() => stdout.waitFor("my-longer-profile-name"));
1791 expect(stdout.output).toContain("my-longer-profile-name");
1792 yield* Effect.sync(() => stdin.write("\r"));
1793 const result = yield* Fiber.join(fiber);
1794
1795 expect(result).toBe("my-longer-profile-name");
1796 }),
1797 { tags: ["unit", "local"] },
1798);
1799
1800it.effect(
1801 "shows a text prompt default before it is accepted",
1802 () =>
1803 Effect.gen(function* () {
1804 const stdin = new InputStream();
1805 const { service, stdout } = yield* makeLive({ stdin });
1806
1807 const fiber = yield* service.prompt
1808 .text({
1809 message: "Emulator endpoint",
1810 defaultValue: "http://localhost:4566",
1811 })
1812 .pipe(Effect.forkChild);
1813 yield* Effect.promise(() => stdin.ready);
1814 yield* Effect.promise(() => stdout.waitFor("http://localhost:4566"));
1815 expect(stripVTControlCharacters(stdout.output)).toContain(
1816 "Emulator endpoint: http://localhost:4566",
1817 );
1818 yield* Effect.sync(() => stdin.write("\r"));
1819
1820 expect(yield* Fiber.join(fiber)).toBe("http://localhost:4566");
1821 }),
1822 { tags: ["unit", "local"] },
1823);
1824
1825it.effect(
1826 "deletes a whole emoji grapheme on backspace",
1827 () =>
1828 Effect.gen(function* () {
1829 const stdin = new InputStream();
1830 const { service } = yield* makeLive({ stdin });
1831
1832 const fiber = yield* service.prompt
1833 .custom(
1834 Screen.make("grapheme", ({ submit }) => (
1835 <TextField initialValue="a👍" onChange={submit} onSubmit={submit} />
1836 )),
1837 )
1838 .pipe(Effect.forkChild);
1839 yield* Effect.promise(() => stdin.ready);
1840 yield* Effect.sync(() => stdin.write("\x7f"));
1841 const result = yield* Fiber.join(fiber);
1842
1843 expect(result).toBe("a");
1844 }),
1845 { tags: ["unit", "local"] },
1846);
1847
1848it.effect(
1849 "erases the multi-select filter with the terminal DEL byte",
1850 () =>
1851 Effect.gen(function* () {
1852 const stdin = new InputStream();
1853 const { service } = yield* makeLive({ stdin });
1854
1855 const fiber = yield* service.prompt
1856 .multiSelect({
1857 message: "pick",
1858 options: [
1859 { value: "alpha", label: "alpha" },
1860 { value: "beta", label: "beta" },
1861 ],
1862 })
1863 .pipe(Effect.forkChild);
1864 yield* Effect.promise(() => stdin.ready);
1865 // Filter to nothing, erase the filter with DEL, then toggle + confirm.
1866 yield* Effect.sync(() => stdin.write("z"));
1867 yield* settleInput;
1868 yield* Effect.sync(() => stdin.write("\x7f"));
1869 yield* settleInput;
1870 yield* Effect.sync(() => stdin.write(" "));
1871 yield* settleInput;
1872 yield* Effect.sync(() => stdin.write("\r"));
1873 const result = yield* Fiber.join(fiber);
1874
1875 expect(result).toEqual(["alpha"]);
1876 }),
1877 { tags: ["unit", "local"] },
1878);
1879
1880it.effect(
1881 "filters searchable selects without stealing Enter",
1882 () =>
1883 Effect.gen(function* () {
1884 const stdin = new InputStream();
1885 const { service } = yield* makeLive({ stdin });
1886
1887 const fiber = yield* service.prompt
1888 .select({
1889 message: "pick",
1890 searchable: true,
1891 options: [
1892 { value: "alpha", label: "alpha" },
1893 { value: "beta", label: "beta" },
1894 ],
1895 })
1896 .pipe(Effect.forkChild);
1897 yield* Effect.promise(() => stdin.ready);
1898 yield* Effect.sync(() => stdin.write("bet"));
1899 yield* settleInput;
1900 yield* Effect.sync(() => stdin.write("\r"));
1901 const result = yield* Fiber.join(fiber);
1902
1903 expect(result).toBe("beta");
1904 }),
1905 { tags: ["unit", "local"] },
1906);
1907
1908it.effect(
1909 "keeps required cycle edits open until something changes",
1910 () =>
1911 Effect.gen(function* () {
1912 const stdin = new InputStream();
1913 const { service } = yield* makeLive({ stdin });
1914
1915 const fiber = yield* service.prompt
1916 .cycle({
1917 message: "Manage accounts",
1918 requireChange: true,
1919 options: [
1920 {
1921 label: "Cloudflare",
1922 states: [
1923 { value: "keep", label: "keep" },
1924 { value: "remove", label: "remove" },
1925 ],
1926 },
1927 ],
1928 })
1929 .pipe(Effect.forkChild);
1930 yield* Effect.promise(() => stdin.ready);
1931 yield* Effect.sync(() => stdin.write("\r"));
1932 yield* settleInput;
1933 expect(fiber.pollUnsafe()).toBe(undefined);
1934
1935 yield* Effect.sync(() => stdin.write(" "));
1936 yield* settleInput;
1937 yield* Effect.sync(() => stdin.write("\r"));
1938 const result = yield* Fiber.join(fiber);
1939 expect(result).toEqual(["remove"]);
1940 }),
1941 { tags: ["unit", "local"] },
1942);
1943
1944it.effect(
1945 "reopens browser authorization from the waiting screen",
1946 () =>
1947 Effect.gen(function* () {
1948 const stdin = new InputStream();
1949 const { service } = yield* makeLive({ stdin });
1950 let opened = 0;
1951
1952 const fiber = yield* service.prompt
1953 .awaitExternal({
1954 message: "Authorize",
1955 waitingLabel: "Waiting",
1956 url: "https://example.com/authorize",
1957 inputLabel: "Enter code",
1958 onOpen: async () => {
1959 opened += 1;
1960 },
1961 })
1962 .pipe(Effect.forkChild);
1963 yield* Effect.promise(() => stdin.ready);
1964 yield* Effect.sync(() => stdin.write("o"));
1965 yield* settleInput;
1966 expect(opened).toBe(1);
1967
1968 yield* Effect.sync(() => stdin.write("\r"));
1969 yield* settleInput;
1970 yield* Effect.sync(() => stdin.write("code"));
1971 yield* settleInput;
1972 yield* Effect.sync(() => stdin.write("\r"));
1973 const result = yield* Fiber.join(fiber);
1974 expect(result).toBe("code");
1975 }),
1976 { tags: ["unit", "local"] },
1977);
1978
1979it.effect(
1980 "keeps browser-only authorization cancellable",
1981 () =>
1982 Effect.gen(function* () {
1983 const stdin = new InputStream();
1984 const { service, stdout } = yield* makeLive({ stdin });
1985
1986 const fiber = yield* service.prompt
1987 .awaitExternal({
1988 message: "AWS authorization",
1989 waitingLabel: "Waiting",
1990 url: "https://example.com/authorize",
1991 allowManualInput: false,
1992 })
1993 .pipe(Effect.flip, Effect.forkChild);
1994 yield* Effect.promise(() => stdin.ready);
1995 yield* Effect.promise(() => stdout.waitFor("AWS authorization"));
1996 expect(stripVTControlCharacters(stdout.output)).not.toContain(
1997 "enter code manually",
1998 );
1999
2000 // Enter is intentionally inert for a browser-only flow.
2001 yield* Effect.sync(() => stdin.write("\r"));
2002 yield* settleInput;
2003 expect(fiber.pollUnsafe()).toBeUndefined();
2004
2005 yield* Effect.sync(() => stdin.write("\x1b"));
2006 const failure = yield* Fiber.join(fiber);
2007 expect(failure).toBeInstanceOf(TerminalCancelled);
2008 }),
2009 { tags: ["unit", "local"] },
2010);
2011
2012it.effect(
2013 "shows a device authorization code",
2014 () =>
2015 Effect.gen(function* () {
2016 const stdin = new InputStream();
2017 const { service, stdout } = yield* makeLive({ stdin });
2018
2019 const fiber = yield* service.prompt
2020 .awaitExternal({
2021 message: "AWS authorization",
2022 waitingLabel: "Waiting for device authorization…",
2023 url: "https://device.sso.example.com/",
2024 code: "ABCD-EFGH",
2025 allowManualInput: false,
2026 })
2027 .pipe(Effect.flip, Effect.forkChild);
2028 yield* Effect.promise(() => stdin.ready);
2029 yield* Effect.promise(() => stdout.waitFor("ABCD-EFGH"));
2030
2031 const output = stripVTControlCharacters(stdout.output);
2032 expect(output).toContain("Code ABCD-EFGH");
2033 expect(output).toContain("copy code");
2034
2035 yield* Effect.sync(() => stdin.write("\x1b"));
2036 const failure = yield* Fiber.join(fiber);
2037 expect(failure).toBeInstanceOf(TerminalCancelled);
2038 }),
2039 { tags: ["unit", "local"] },
2040);
2041
2042it.effect(
2043 "toggles every visible multi-select choice on ctrl+a",
2044 () =>
2045 Effect.gen(function* () {
2046 const stdin = new InputStream();
2047 const { service } = yield* makeLive({ stdin });
2048
2049 const fiber = yield* service.prompt
2050 .multiSelect({
2051 message: "pick",
2052 options: [
2053 { value: "alpha", label: "alpha" },
2054 { value: "beta", label: "beta" },
2055 { value: "gamma", label: "gamma", disabled: true },
2056 ],
2057 })
2058 .pipe(Effect.forkChild);
2059 yield* Effect.promise(() => stdin.ready);
2060 yield* Effect.sync(() => stdin.write("\x01"));
2061 yield* settleInput;
2062 yield* Effect.sync(() => stdin.write("\r"));
2063 const result = yield* Fiber.join(fiber);
2064
2065 expect(result).toEqual(["alpha", "beta"]);
2066 }),
2067 { tags: ["unit", "local"] },
2068);
2069
2070it.effect(
2071 "returns an explicit undefined menu back target on Escape",
2072 () =>
2073 Effect.gen(function* () {
2074 const stdin = new InputStream();
2075 const { service } = yield* makeLive({ stdin });
2076
2077 const fiber = yield* service.prompt
2078 .menu<string | undefined>({
2079 message: "pick",
2080 back: undefined,
2081 options: [{ value: "alpha", label: "alpha" }],
2082 })
2083 .pipe(Effect.forkChild);
2084 yield* Effect.promise(() => stdin.ready);
2085 yield* Effect.sync(() => stdin.write("\x1b"));
2086 const result = yield* Fiber.join(fiber);
2087
2088 expect(result).toBe(undefined);
2089 }),
2090 { tags: ["unit", "local"] },
2091);
2092
2093it.effect(
2094 "cleans up a cancelled standalone prompt",
2095 () =>
2096 Effect.gen(function* () {
2097 const { service, stdout } = yield* makeLive();
2098
2099 const failure = yield* service.prompt
2100 .custom(
2101 Screen.make("cancel test", ({ cancel }) => {
2102 queueMicrotask(cancel);
2103 return <Status>Waiting</Status>;
2104 }),
2105 )
2106 .pipe(Effect.flip);
2107
2108 expect(failure).toBeInstanceOf(TerminalCancelled);
2109 expect(stdout.output).toContain("cancel test cancelled");
2110 }),
2111 { tags: ["unit", "local"] },
2112);
2113
2114it.effect(
2115 "releases the renderer when a running prompt is interrupted",
2116 () =>
2117 Effect.gen(function* () {
2118 const stdin = new InputStream();
2119 const { service } = yield* makeLive({ stdin });
2120
2121 const fiber = yield* service.prompt
2122 .custom(Screen.make("interrupted", () => <Status>Waiting</Status>))
2123 .pipe(Effect.forkChild);
2124 yield* Effect.promise(() => stdin.ready);
2125 yield* Fiber.interrupt(fiber);
2126
2127 // The interrupted prompt must have released the renderer and the prompt
2128 // gate for the next interaction.
2129 const result = yield* service.prompt.custom(
2130 Screen.make("after interruption", ({ submit }) => {
2131 queueMicrotask(() => submit("ok"));
2132 return <Status>After</Status>;
2133 }),
2134 );
2135 expect(result).toBe("ok");
2136 }),
2137 { tags: ["unit", "local"] },
2138);
2139
2140it.effect(
2141 "closes leaked live handles when their scope closes",
2142 () =>
2143 Effect.gen(function* () {
2144 const { service, stdout } = yield* makeLive();
2145
2146 yield* Effect.scoped(
2147 Effect.gen(function* () {
2148 yield* service.live.open(<Text>Leaky</Text>);
2149 // Deliberately no close — the enclosing scope must release the row
2150 // and unmount the renderer.
2151 }),
2152 );
2153
2154 expect(stdout.output).toContain("[?25h");
2155 }),
2156 { tags: ["unit", "local"] },
2157);
2158
2159it.effect(
2160 "fails the active prompt when a screen throws during render",
2161 () =>
2162 Effect.gen(function* () {
2163 const { service } = yield* makeLive();
2164 const Broken = (): never => {
2165 throw new Error("screen render boom");
2166 };
2167
2168 // Without exit observation this would hang forever: the screen's
2169 // submit/cancel can never run once the renderer has crashed.
2170 const exit = yield* service.prompt
2171 .custom(Screen.make("broken screen", () => <Broken />))
2172 .pipe(Effect.exit);
2173
2174 expect(Exit.isFailure(exit)).toBe(true);
2175 }),
2176 { tags: ["unit", "local"] },
2177);
2178
2179it.effect(
2180 "cancels a screen with no cancel wiring on ctrl+c",
2181 () =>
2182 Effect.gen(function* () {
2183 const stdin = new InputStream();
2184 const { service, stdout } = yield* makeLive({ stdin });
2185
2186 // The screen never touches the controller — the centralized handler in
2187 // the runtime must still turn Ctrl+C into a cancellation.
2188 const fiber = yield* service.prompt
2189 .custom(Screen.make("no cancel wiring", () => <Status>Waiting</Status>))
2190 .pipe(Effect.flip, Effect.forkChild);
2191 yield* Effect.promise(() => stdin.ready);
2192 yield* Effect.sync(() => stdin.write("\x03"));
2193 const failure = yield* Fiber.join(fiber);
2194
2195 expect(failure).toBeInstanceOf(TerminalCancelled);
2196 expect(stdout.output).toContain("no cancel wiring cancelled");
2197 }),
2198 { tags: ["unit", "local"] },
2199);
2200
2201it.effect(
2202 "keeps one renderer alive for an Effect-driven application",
2203 () =>
2204 Effect.gen(function* () {
2205 const { service, stdout } = yield* makeLive();
2206
2207 const result = yield* service.application(
2208 Effect.gen(function* () {
2209 const action = yield* service.prompt.custom(
2210 Screen.make("main menu", ({ submit }) => {
2211 queueMicrotask(() => submit("add"));
2212 return <Status>Main menu</Status>;
2213 }),
2214 );
2215 const name = yield* service.wizard(
2216 service.prompt.custom(
2217 Screen.make("auth flow", ({ submit }) => {
2218 queueMicrotask(() =>
2219 submit("cloudflare", <Status>Profile name</Status>),
2220 );
2221 return <Status>Cloudflare auth</Status>;
2222 }),
2223 ),
2224 );
2225 const done = yield* service.prompt.custom(
2226 Screen.make("returned menu", ({ submit }) => {
2227 queueMicrotask(() => submit(true));
2228 return <Status>Returned menu</Status>;
2229 }),
2230 );
2231 return { action, name, done };
2232 }),
2233 );
2234
2235 expect(result).toEqual({
2236 action: "add",
2237 name: "cloudflare",
2238 done: true,
2239 });
2240 // Clearing an inline application first renders an empty frame. Its final
2241 // row must be reclaimed before teardown or the next shell prompt starts
2242 // one line too low.
2243 expect(
2244 stdout.output.slice(stdout.output.lastIndexOf("\n") + 1),
2245 ).toContain("\u001B[1A");
2246 }),
2247 { tags: ["unit", "local"] },
2248);
2249
2250it.effect(
2251 "provides CliKit once as a scoped injectable service",
2252 () => {
2253 const stdout = new CaptureStream();
2254 return Effect.gen(function* () {
2255 const capabilities = yield* CliKit.useSync((cli) => cli.terminal);
2256 const cli = yield* CliKit;
2257 yield* cli.output.print("Injected");
2258
2259 expect(capabilities.input).toBe(false);
2260 expect(capabilities.colors).toBe(false);
2261 expect(stdout.output).toContain("Injected");
2262 }).pipe(
2263 Effect.provide(
2264 cliKitLayer({
2265 input: false,
2266 stdout: stdout as unknown as NodeJS.WriteStream,
2267 captureConsole: false,
2268 }),
2269 ),
2270 );
2271 },
2272 { tags: ["unit", "local"] },
2273);
2274
2275it.effect(
2276 "uses append-only progress when input is disabled on a TTY",
2277 () => {
2278 const stdout = new CaptureStream(true);
2279 return Effect.gen(function* () {
2280 const cli = yield* CliKit;
2281 const progress = yield* cli.live.progress({ label: "Deploying" });
2282 yield* progress.update({ label: "Uploading" });
2283 yield* progress.succeed("Deployed");
2284
2285 expect(cli.terminal.input).toBe(false);
2286 expect(stdout.output).toContain("Deploying\n");
2287 expect(stdout.output).toContain("Deployed\n");
2288 expect(stdout.output).not.toContain("\u001B[");
2289 }).pipe(
2290 Effect.provide(
2291 cliKitLayer({
2292 input: false,
2293 stdout: stdout as unknown as NodeJS.WriteStream,
2294 captureConsole: false,
2295 }),
2296 ),
2297 );
2298 },
2299 { tags: ["unit", "local"] },
2300);
2301
2302it.effect(
2303 "renders the same semantic and component output without terminal input",
2304 () =>
2305 Effect.gen(function* () {
2306 const { service, stdout } = yield* makeLive({
2307 input: false,
2308 unicode: false,
2309 });
2310
2311 yield* service.output.info("Resolving credentials");
2312 yield* service.output.success({
2313 message: "Authenticated",
2314 detail: "cloudflare",
2315 });
2316 yield* service.output.warning("Token expires soon");
2317 yield* service.output.error("Authentication failed");
2318 yield* service.output.print(
2319 <Alert variant="warning" title="Attention">
2320 Manual action required
2321 </Alert>,
2322 );
2323 yield* service.output.print(<Status>React output</Status>);
2324
2325 expect(stdout.output).toContain("Resolving credentials\n");
2326 expect(stdout.output).toContain("Authenticated");
2327 expect(stdout.output).toContain("cloudflare");
2328 expect(stdout.output).toContain("Token expires soon");
2329 expect(stdout.output).toContain("Authentication failed");
2330 expect(stdout.output).toContain("Attention");
2331 expect(stdout.output).toContain("Manual action required");
2332 expect(stdout.output).toContain("React output");
2333 }),
2334 { tags: ["unit", "local"] },
2335);
2336
2337it.effect(
2338 "renders application, transcript, live-work, and data primitives together",
2339 () =>
2340 Effect.gen(function* () {
2341 const { service } = makeStatic();
2342 const rendered = yield* service.output.render(
2343 <>
2344 <Tabs
2345 tabs={[
2346 { id: "dev", label: "dev" },
2347 { id: "prod", label: "prod", marked: true },
2348 ]}
2349 active="prod"
2350 />
2351 <AnsweredPrompt message="Account" answer="production" />
2352 <TaskRow spinning label="stack" />
2353 <TaskRow icon="+" label="worker" depth={1} />
2354 <ProgressGroup
2355 rows={[
2356 {
2357 id: "providers",
2358 label: "providers",
2359 completed: 2,
2360 total: 4,
2361 },
2362 ]}
2363 />
2364 <Status>q quit</Status>
2365 </>,
2366 );
2367
2368 expect(rendered).toContain("prod");
2369 expect(rendered).toContain("production");
2370 expect(rendered).toContain("worker");
2371 expect(rendered).toContain("2/4");
2372 }),
2373 { tags: ["unit", "local"] },
2374);
2375
2376it.effect(
2377 "composes widgets before and after divided key hints",
2378 () =>
2379 Effect.gen(function* () {
2380 const { service } = makeStatic();
2381 const rendered = yield* service.output.render(
2382 <KeyBar
2383 inline
2384 marginTop={0}
2385 divider
2386 before={<Text>Plan summary</Text>}
2387 keys={[["p", "show plan"]]}
2388 after={<Text>ready</Text>}
2389 />,
2390 );
2391
2392 expect(rendered.trim().split("\n")).toHaveLength(1);
2393 expect(rendered).toContain("Plan summary");
2394 expect(rendered.match(/│/g)).toHaveLength(2);
2395 expect(rendered).toContain("p show plan");
2396 expect(rendered).toContain("ready");
2397 }),
2398 { tags: ["unit", "local"] },
2399);
2400
2401it(
2402 "wraps individual key hints in narrow terminals",
2403 () => {
2404 const { service } = makeStatic();
2405 const rendered = service.output.format(
2406 <KeyBar
2407 inline
2408 marginTop={0}
2409 divider
2410 before={<Text>Ready</Text>}
2411 keys={[
2412 ["p", "show plan"],
2413 ["Ctrl+C", "exit"],
2414 ]}
2415 />,
2416 { columns: 20 },
2417 );
2418
2419 expect(rendered).toContain("p show plan");
2420 expect(rendered).toContain("Ctrl+C exit");
2421 expect(rendered.trimEnd().split("\n").length).toBeGreaterThan(1);
2422 },
2423 { tags: ["unit", "local"] },
2424);
2425
2426it.effect(
2427 "tabs scroll horizontally instead of wrapping when overflowing",
2428 () =>
2429 Effect.gen(function* () {
2430 const { service } = makeStatic();
2431 const tabs = Array.from({ length: 20 }, (_, i) => {
2432 const label = `profile-${String(i + 1).padStart(2, "0")}`;
2433 return { id: label, label, marked: i === 0 };
2434 });
2435
2436 // Active tab in the middle: window centers on it, arrows on both sides.
2437 const middle = yield* service.output.render(
2438 <Tabs tabs={tabs} active="profile-10" />,
2439 );
2440 expect(middle).toContain("profile-10");
2441 expect(middle).toContain("‹");
2442 expect(middle).toContain("›");
2443 expect(middle).not.toContain("profile-01");
2444 expect(middle).not.toContain("profile-20");
2445 // every rendered tab stays on the single tab row — no wrapped chips
2446 expect(middle.trimEnd()).not.toContain("\n");
2447
2448 // Active tab at the start: no left arrow, right arrow only.
2449 const first = yield* service.output.render(
2450 <Tabs tabs={tabs} active="profile-01" />,
2451 );
2452 expect(first).toContain("profile-01");
2453 expect(first).not.toContain("‹");
2454 expect(first).toContain("›");
2455
2456 // Active tab at the end: left arrow only.
2457 const last = yield* service.output.render(
2458 <Tabs tabs={tabs} active="profile-20" />,
2459 );
2460 expect(last).toContain("profile-20");
2461 expect(last).toContain("‹");
2462 expect(last).not.toContain("›");
2463
2464 // Everything fits: no arrows at all.
2465 const fitting = yield* service.output.render(
2466 <Tabs tabs={tabs.slice(0, 3)} active="profile-02" />,
2467 );
2468 expect(fitting).toContain("profile-01");
2469 expect(fitting).toContain("profile-03");
2470 expect(fitting).not.toContain("‹");
2471 expect(fitting).not.toContain("›");
2472 }),
2473 { tags: ["unit", "local"] },
2474);
2475
2476it(
2477 "tabsWindow keeps the active tab visible within the available width",
2478 () => {
2479 // 5 chips of width 12, gap 1, 74 columns → 5 fit exactly around the middle
2480 const widths = Array.from({ length: 20 }, () => 12);
2481 expect(tabsWindow(widths, 9, 74)).toEqual({ start: 7, end: 12 });
2482 // at the edges the window pins to the boundary
2483 expect(tabsWindow(widths, 0, 74)).toEqual({ start: 0, end: 5 });
2484 expect(tabsWindow(widths, 19, 74)).toEqual({ start: 15, end: 20 });
2485 // active always inside, even when nothing else fits
2486 expect(tabsWindow(widths, 3, 12)).toEqual({ start: 3, end: 4 });
2487 expect(tabsWindow(widths, 3, 5)).toEqual({ start: 3, end: 4 });
2488 // out-of-range active clamps; empty input yields an empty window
2489 expect(tabsWindow(widths, 99, 74)).toEqual({ start: 15, end: 20 });
2490 expect(tabsWindow([], 0, 74)).toEqual({ start: 0, end: 0 });
2491 },
2492 { tags: ["unit", "local"] },
2493);
2494
2495it.live(
2496 "state explorer confirms deletes inline and keeps its position afterwards",
2497 () =>
2498 Effect.gen(function* () {
2499 const { calls, source } = makeExplorerSource();
2500 let resources = ["Api/Worker", "Api/Queue", "Db"];
2501 const deleted: string[] = [];
2502 const mutable: StateExplorerSource = {
2503 ...source,
2504 listResources: () =>
2505 Effect.sync(() => {
2506 calls.resources++;
2507 return resources;
2508 }),
2509 deleteNodes: (nodes) =>
2510 Effect.sync(() => {
2511 for (const node of nodes) {
2512 deleted.push(node.path);
2513 const prefix = `app/prod/`;
2514 const fqn = node.path.slice(prefix.length);
2515 resources = resources.filter(
2516 (r) => r !== fqn && !r.startsWith(`${fqn}/`),
2517 );
2518 }
2519 }),
2520 };
2521 const stdin = new InputStream();
2522 const { service, stdout } = yield* makeLive({ stdin });
2523 const fiber = yield* service
2524 .application(service.prompt.custom(stateExplorerScreen(mutable)))
2525 .pipe(Application.alternate)
2526 .pipe(Effect.forkChild);
2527 yield* Effect.promise(() => stdin.ready);
2528 const press = (key: string) =>
2529 Effect.sync(() => stdin.write(key)).pipe(Effect.andThen(settleInput));
2530 // Bounded waits that dump the last frame on expiry — a silent hang here
2531 // is otherwise undebuggable.
2532 const frame = () => stripVTControlCharacters(stdout.output.slice(-6000));
2533 const waitFrame = (text: string) =>
2534 Effect.promise(() => stdout.waitFor(text)).pipe(
2535 Effect.timeoutOrElse({
2536 duration: "3 seconds",
2537 orElse: () =>
2538 Effect.sync(() => {
2539 console.log(`=== TIMEOUT waiting for ${text} ===\n${frame()}`);
2540 throw new Error(`timeout waiting for ${text}`);
2541 }),
2542 }),
2543 );
2544 yield* waitFrame("app");
2545 // root → stages → state → into Api → Worker (select a row, then enter it)
2546 const enter = Effect.gen(function* () {
2547 yield* press("j");
2548 yield* press("\x1B[C");
2549 });
2550 yield* enter;
2551 yield* waitFrame("prod");
2552 yield* enter;
2553 yield* waitFrame("Api");
2554 yield* enter;
2555 yield* waitFrame("Worker");
2556 yield* press("j");
2557 yield* waitFrame("PREVIEW");
2558 yield* press("d");
2559 yield* waitFrame("Delete state records?");
2560 // The panel must not narrow the layout: its top border spans the
2561 // terminal, exactly like the header rule above the columns.
2562 const confirmFrame = frame();
2563 const rule = confirmFrame
2564 .split("\n")
2565 .filter((line) => /^─+$/.test(line.trim()));
2566 expect(rule.at(-1)?.trim().length).toBe(stdout.columns);
2567 const before = stdout.output.length;
2568 yield* press("y");
2569 yield* waitFrame("Deleted state at");
2570 yield* settleInput;
2571 yield* Effect.promise(flushEffects);
2572 yield* settleInput;
2573 const after = stripVTControlCharacters(stdout.output.slice(before));
2574 expect(deleted).toEqual(["app/prod/Api/Queue"]);
2575 // stage listing re-read once; stacks/stages untouched
2576 expect(calls.stacks).toBe(1);
2577 expect(calls.stages).toBe(1);
2578 expect(calls.resources).toBe(2);
2579 // cursor moved to the surviving sibling in the same column
2580 expect(after).toContain("app/prod/Api/Worker");
2581 yield* press("q");
2582 yield* Fiber.join(fiber);
2583 }),
2584 { tags: ["unit", "local"] },
2585);
2586
2587it.effect(
2588 "renders nuke scan counts and outstanding providers",
2589 () =>
2590 Effect.gen(function* () {
2591 const { service } = makeStatic();
2592 const store = new NukeProgressStore();
2593 store.emit({ _tag: "nuke.scan.started", total: 2 });
2594 store.emit({ _tag: "nuke.scan.provider.started", provider: "Test.Fast" });
2595 store.emit({ _tag: "nuke.scan.provider.started", provider: "Test.Slow" });
2596 store.emit({
2597 _tag: "nuke.scan.provider.completed",
2598 provider: "Test.Fast",
2599 resources: 3,
2600 });
2601 const output = yield* service.output.render(
2602 <NukeProgress store={store} />,
2603 );
2604 expect(output).toContain("1/2");
2605 expect(output).toContain("3 resources found");
2606 expect(output).toContain("Scanning Test.Slow");
2607 const lines = output.split("\n");
2608 const header = lines.findIndex((line) => line.includes("1/2"));
2609 expect(lines[header]).toMatch(/^ \[[⣿⡇ ]+\] {2}\[1\/2\]\s*$/);
2610 expect(lines[header + 1]?.trim()).toBe("");
2611 expect(lines[header + 2]).toContain("Scanning providers");
2612 expect(output).not.toContain("Scanning Test.Fast");
2613 }),
2614 { tags: ["unit", "local"] },
2615);
2616
2617it.effect(
2618 "updates individual nuke plan rows during deletion",
2619 () =>
2620 Effect.gen(function* () {
2621 const { service } = makeStatic();
2622 const tree = new PlanTree(
2623 nukePlan(
2624 [
2625 { providerId: "Test.B", displayName: "same-name" },
2626 { providerId: "Test.A", displayName: "same-name" },
2627 { providerId: "Test.A", displayName: "same-name" },
2628 ],
2629 { mode: "live" },
2630 ),
2631 { mode: "apply", viewport: "full", busy: true },
2632 );
2633 tree.setLabel("Deleting resources · pass 2");
2634 tree.emit({
2635 _tag: "apply.resource.status",
2636 fqn: "nuke/2",
2637 id: "Test.A",
2638 type: "Test.A",
2639 status: "deleted",
2640 });
2641 tree.emit({
2642 _tag: "apply.resource.status",
2643 fqn: "nuke/1",
2644 id: "Test.A",
2645 type: "Test.A",
2646 status: "fail",
2647 message: "blocked",
2648 });
2649 tree.emit({
2650 _tag: "apply.resource.status",
2651 fqn: "nuke/0",
2652 id: "Test.B",
2653 type: "Test.B",
2654 status: "deleting",
2655 });
2656 expect(tree.snapshot().tasks.get("nuke/2")?.status).toBe("deleted");
2657 expect(tree.snapshot().tasks.get("nuke/1")?.status).toBe("fail");
2658 expect(tree.progress()).toEqual({ completed: 2, total: 3, failures: 1 });
2659 const output = yield* service.output.render(<Plan tree={tree} />);
2660 expect(output).toContain("pass 2");
2661 expect(output).toContain("blocked");
2662 expect(output.match(/same-name/g)).toHaveLength(3);
2663 expect(output).not.toContain("(Test.A)");
2664 }),
2665 { tags: ["unit", "local"] },
2666);
2667
2668it.effect(
2669 "renders nuke execution outcomes in the shared plan",
2670 () =>
2671 Effect.gen(function* () {
2672 const { service, stdout } = yield* makeLive();
2673 const provider: ProviderService = {
2674 nuke: { dependsOn: ["Test.Parent"] },
2675 list: () => Effect.succeed([]),
2676 reconcile: () => Effect.succeed({}),
2677 delete: () => Effect.void,
2678 };
2679 const resources = [
2680 {
2681 providerId: "Test.Parent",
2682 displayName: "parent",
2683 attributes: {},
2684 provider: { ...provider, nuke: undefined },
2685 },
2686 {
2687 providerId: "Test.Child",
2688 displayName: "same-name",
2689 attributes: {},
2690 provider: {
2691 ...provider,
2692 delete: () => Effect.fail("cannot delete child"),
2693 },
2694 },
2695 {
2696 providerId: "Test.Child",
2697 displayName: "same-name",
2698 attributes: {},
2699 provider,
2700 },
2701 ];
2702 const result = yield* NukeRoute.execute({
2703 scan: {
2704 resources,
2705 mode: "live",
2706 context: Context.empty(),
2707 failures: [],
2708 },
2709 resources,
2710 strategy: { _tag: "coordinated" },
2711 }).pipe(
2712 renderNukeDelete(resources, "live"),
2713 Effect.provideService(CliKit, service),
2714 );
2715 expect(result.deleted).toHaveLength(1);
2716 expect(result.failed).toHaveLength(1);
2717 expect(result.held).toHaveLength(1);
2718 expect(stdout.output).toContain("Nuke incomplete");
2719 expect(stdout.output).toContain("Held back by Test.Child");
2720 expect(stdout.output).toContain("cannot delete child");
2721 expect(stdout.output).toContain("same-name");
2722 expect(stdout.output).not.toContain("Test.Child 1/2");
2723 }),
2724 { tags: ["unit", "local"] },
2725);
2726
2727it.effect(
2728 "closes nuke live progress when the operation fails",
2729 () =>
2730 Effect.gen(function* () {
2731 const { service, stdout } = yield* makeLive();
2732 yield* Effect.gen(function* () {
2733 const report = yield* Progress;
2734 yield* report({ _tag: "nuke.scan.started", total: 2 });
2735 yield* Effect.fail("scan failed");
2736 }).pipe(
2737 renderNukeScan(),
2738 Effect.provideService(CliKit, service),
2739 Effect.result,
2740 );
2741 expect(stdout.output).toContain("\u001B[?25h");
2742 yield* service.output.info("After nuke");
2743 expect(stdout.output).toContain("After nuke");
2744 }),
2745 { tags: ["unit", "local"] },
2746);
2747
2748it.effect(
2749 "renders fixed-width Braille progress bars with partial cells",
2750 () =>
2751 Effect.gen(function* () {
2752 const { service } = makeStatic();
2753 for (const [value, expected] of [
2754 [-1, "[ ]"],
2755 [0, "[ ]"],
2756 [0.0625, "[ ]"],
2757 [0.25, "[⡇ ]"],
2758 [0.5, "[⣿ ]"],
2759 [0.75, "[⣿⡇]"],
2760 [1, "[⣿⣿]"],
2761 [2, "[⣿⣿]"],
2762 ] as const) {
2763 const output = yield* service.output.render(
2764 <ProgressBar value={value} width={2} showPercent={false} />,
2765 );
2766 expect(output.trim()).toBe(expected);
2767 }
2768 const labeled = yield* service.output.render(
2769 <ProgressBar
2770 value={0.5}
2771 width={2}
2772 label="Uploading"
2773 detail="2 of 4 files"
2774 />,
2775 );
2776 expect(labeled).toContain("[⣿ ]");
2777 expect(labeled).toContain("50%");
2778 expect(labeled).toContain("Uploading");
2779 expect(labeled).toContain("2 of 4 files");
2780 }),
2781 { tags: ["unit", "local"] },
2782);
2783
2784it.effect(
2785 "falls back to ASCII for progress bars without Unicode",
2786 () =>
2787 Effect.gen(function* () {
2788 const { service } = yield* makeLive({ input: false, unicode: false });
2789 const output = yield* service.output.render(
2790 <ProgressBar value={0.5} width={4} showPercent={false} />,
2791 );
2792 expect(output.trim()).toBe("[##..]");
2793 }),
2794 { tags: ["unit", "local"] },
2795);
2796
2797it.effect(
2798 "renders nuke inventory with resource types as names and distinct row keys",
2799 () =>
2800 Effect.gen(function* () {
2801 const { service } = makeStatic();
2802 const targets = [
2803 { providerId: "Cloudflare.R2.Bucket", displayName: "same/name" },
2804 { providerId: "Cloudflare.R2.Bucket", displayName: "same/name" },
2805 { providerId: "AWS.S3.Bucket", displayName: "" },
2806 ];
2807 const plan = nukePlan(targets, { mode: "live" });
2808 const tree = new PlanTree(plan, { viewport: "full" });
2809 expect(tree.progressRows).toHaveLength(3);
2810 expect(new Set(tree.rows.map((row) => row.key)).size).toBe(3);
2811 expect(tree.rows.map((row) => row.id)).toEqual([
2812 "AWS.S3.Bucket",
2813 "Cloudflare.R2.Bucket",
2814 "Cloudflare.R2.Bucket",
2815 ]);
2816 expect(
2817 tree.rows.every((row) => row.type === "resource" && row.depth === 0),
2818 ).toBe(true);
2819 const output = yield* service.output.render(<Plan tree={tree} />);
2820 expect(output).toContain("3 to delete");
2821 expect(output.match(/Cloudflare.R2.Bucket/g)).toHaveLength(2);
2822 expect(output).not.toContain("(Cloudflare.R2.Bucket)");
2823 expect(output.match(/same\/name/g)).toHaveLength(2);
2824
2825 const compact = yield* service.output.render(
2826 <Plan
2827 tree={
2828 new PlanTree(nukePlan(targets, { mode: "live" }), {
2829 viewport: "full",
2830 })
2831 }
2832 />,
2833 );
2834 expect(compact).toContain("3 to delete");
2835 expect(compact.match(/same\/name/g)).toHaveLength(2);
2836 expect(compact.match(/Cloudflare.R2.Bucket/g)).toHaveLength(2);
2837 expect(compact).not.toContain("(x2)");
2838 }),
2839 { tags: ["unit", "local"] },
2840);
2841
2842it.effect(
2843 "prints the shared nuke plan for dry-run and yes without prompting",
2844 () =>
2845 Effect.gen(function* () {
2846 for (const options of [
2847 { yes: false, dryRun: true },
2848 { yes: true, dryRun: false },
2849 ]) {
2850 const { service, stdout } = makeStatic();
2851 const result = yield* reviewNuke(
2852 [{ providerId: "AWS.S3.Bucket", displayName: "bucket" }],
2853 { ...options, mode: "local" },
2854 ).pipe(Effect.provideService(CliKit, service));
2855 expect(result).toBe(true);
2856 expect(stdout.output).toContain("1 to delete");
2857 expect(stdout.output).toContain("AWS.S3.Bucket");
2858 expect(stdout.output).toContain("bucket");
2859 }
2860 }),
2861 { tags: ["unit", "local"] },
2862);
2863
2864it.live(
2865 "defaults nuke plan approval to Cancel",
2866 () =>
2867 Effect.gen(function* () {
2868 const stdin = new InputStream();
2869 const { service, stdout } = yield* makeLive({ stdin });
2870 const review = yield* reviewNuke(
2871 [{ providerId: "AWS.S3.Bucket", displayName: "bucket" }],
2872 { mode: "local", yes: false, dryRun: false },
2873 ).pipe(Effect.provideService(CliKit, service), Effect.forkChild);
2874 yield* Effect.promise(flushEffects);
2875 yield* Effect.raceFirst(
2876 Effect.promise(() => stdout.waitFor("Cancel")),
2877 Fiber.join(review).pipe(
2878 Effect.flatMap(() => Effect.die("review ended before prompt")),
2879 ),
2880 );
2881 yield* Effect.promise(() => stdin.ready);
2882 expect(stdout.output).toContain("1 to delete");
2883 expect(stdout.output).toContain("locally emulated");
2884 yield* Effect.sync(() => stdin.write("\r"));
2885 yield* Effect.promise(flushEffects);
2886 expect(yield* Fiber.join(review)).toBe(false);
2887 }),
2888 { tags: ["unit", "local"] },
2889);
2890
2891it.effect(
2892 "keeps unnamed and duplicate nuke targets as individual rows",
2893 () =>
2894 Effect.gen(function* () {
2895 const { service } = makeStatic();
2896 const targets = [
2897 {
2898 providerId: "AWS.ApiGateway.GatewayResponse",
2899 displayName: "api-123",
2900 },
2901 {
2902 providerId: "AWS.ApiGateway.GatewayResponse",
2903 displayName: "api-123",
2904 },
2905 { providerId: "AWS.S3.Bucket", displayName: "photos" },
2906 { providerId: "AWS.S3.Bucket", displayName: "backups" },
2907 { providerId: "Test.Unknown", displayName: "unknown" },
2908 { providerId: "Test.Unknown", displayName: "" },
2909 ];
2910 const plan = nukePlan(targets, { mode: "live" });
2911 expect(plan.rows).toHaveLength(6);
2912 const output = yield* service.output.render(
2913 <Plan tree={new PlanTree(plan, { viewport: "full" })} />,
2914 );
2915 expect(output).toContain("6 to delete");
2916 expect(output.match(/api-123/g)).toHaveLength(2);
2917 expect(output.match(/Test.Unknown/g)).toHaveLength(2);
2918 expect(output).not.toContain("(x2)");
2919 expect(output).toContain("photos");
2920 expect(output).toContain("backups");
2921 expect(output).not.toContain("· unknown");
2922 }),
2923 { tags: ["unit", "local"] },
2924);
2925
2926it.effect(
2927 "logs nuke errors immediately through the configured Effect logger",
2928 () =>
2929 Effect.gen(function* () {
2930 for (const input of [true, false]) {
2931 const { service } = yield* makeLive({ input });
2932 const entries: Array<{ level: string; message: unknown }> = [];
2933 const logger = Logger.make<unknown, void>(({ logLevel, message }) => {
2934 entries.push({ level: logLevel, message });
2935 });
2936 yield* Effect.gen(function* () {
2937 const report = yield* Progress;
2938 yield* report({ _tag: "nuke.scan.started", total: 2 });
2939 yield* report({
2940 _tag: "nuke.scan.provider.completed",
2941 provider: "Test.Failed",
2942 resources: 0,
2943 error: "denied during scan",
2944 });
2945 // Check before the operation returns: errors must not wait for completion.
2946 expect(entries).toEqual([
2947 { level: "Warn", message: ["Test.Failed: denied during scan"] },
2948 ]);
2949 yield* report({
2950 _tag: "nuke.resource.failed",
2951 provider: "Test.Resource",
2952 resource: "example",
2953 message: "denied during delete",
2954 });
2955 expect(entries[1]).toEqual({
2956 level: "Warn",
2957 message: ["Test.Resource example: denied during delete"],
2958 });
2959 yield* report({
2960 _tag: "nuke.scan.provider.completed",
2961 provider: "Test.Ready",
2962 resources: 1,
2963 });
2964 yield* report({
2965 _tag: "nuke.resource.deleted",
2966 provider: "Test.Ready",
2967 resource: "gone",
2968 });
2969 }).pipe(
2970 renderNukeScan(),
2971 Effect.provideService(CliKit, service),
2972 Effect.provide(Logger.layer([logger])),
2973 );
2974 expect(entries).toHaveLength(input ? 2 : 4);
2975 if (!input) {
2976 expect(entries.slice(2)).toEqual([
2977 { level: "Info", message: ["scanned Test.Ready (1)"] },
2978 { level: "Info", message: ["deleted Test.Ready gone"] },
2979 ]);
2980 }
2981 }
2982 }),
2983 { tags: ["unit", "local"] },
2984);