@effected/cli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/Cancelled.js +44 -0
  2. package/CliAudience.js +178 -0
  3. package/CliColor.js +13 -19
  4. package/CliEnv.js +89 -0
  5. package/CliExit.js +1 -1
  6. package/CliFailure.js +302 -0
  7. package/CliInteractive.js +71 -0
  8. package/CliLinks.js +154 -0
  9. package/CliLog.js +346 -0
  10. package/CliLogger.js +34 -33
  11. package/CliMessage.js +80 -0
  12. package/CliPrompt.js +104 -0
  13. package/CliRuntime.js +110 -54
  14. package/CliTest.js +16 -0
  15. package/CliTheme.js +141 -0
  16. package/ConfigIssueRenderer.js +14 -33
  17. package/Doc.js +536 -0
  18. package/Fmt.js +133 -0
  19. package/GithubAnnotation.js +40 -0
  20. package/Glyphs.js +83 -0
  21. package/NotInteractive.js +42 -0
  22. package/README.md +145 -131
  23. package/Render.js +255 -0
  24. package/SchemaIssueRenderer.js +7 -10
  25. package/Status.js +166 -0
  26. package/TestTerminal.js +80 -0
  27. package/Token.js +69 -0
  28. package/index.d.ts +3089 -169
  29. package/index.js +19 -1
  30. package/internal/ansi.js +230 -0
  31. package/internal/autoFormat.js +34 -0
  32. package/internal/canPrompt.js +15 -0
  33. package/internal/counts.js +84 -0
  34. package/internal/diagnostics.js +32 -0
  35. package/internal/displayWidth.js +35 -0
  36. package/internal/failureTarget.js +195 -0
  37. package/internal/fallbackAnswer.js +18 -0
  38. package/internal/fileSink.js +62 -0
  39. package/internal/format.js +62 -7
  40. package/internal/layout.js +250 -0
  41. package/internal/linkScheme.js +30 -0
  42. package/internal/linkTarget.js +50 -0
  43. package/internal/logSafety.js +46 -0
  44. package/internal/renderAnsi.js +52 -0
  45. package/internal/renderDoc.js +320 -0
  46. package/internal/renderGithubLog.js +46 -0
  47. package/internal/renderMarkdown.js +368 -0
  48. package/internal/renderPlain.js +50 -0
  49. package/internal/scanAudience.js +106 -0
  50. package/internal/splitFrame.js +56 -0
  51. package/internal/wizardGate.js +18 -0
  52. package/package.json +40 -5
  53. package/testing.d.ts +88 -2
  54. package/testing.js +2 -1
  55. package/ui/CliUi.js +432 -0
  56. package/ui/CliUiLive.js +446 -0
  57. package/ui/Confirm.js +245 -0
  58. package/ui/DocView.js +74 -0
  59. package/ui/KeyHelp.js +62 -0
  60. package/ui/KeyTable.js +199 -0
  61. package/ui/MultiSelect.js +260 -0
  62. package/ui/Select.js +230 -0
  63. package/ui/Tabs.js +202 -0
  64. package/ui/TextInput.js +290 -0
  65. package/ui/Toggle.js +32 -0
  66. package/ui/UiKey.js +44 -0
  67. package/ui/UiProvider.js +60 -0
  68. package/ui/UiStreams.js +18 -0
  69. package/ui/UiTheme.js +119 -0
  70. package/ui/Viewport.js +204 -0
  71. package/ui/internal/ErrorBoundary.js +30 -0
  72. package/ui/internal/Holder.js +74 -0
  73. package/ui/internal/ScreenContext.js +52 -0
  74. package/ui/internal/UiProviders.js +21 -0
  75. package/ui/internal/ink.js +122 -0
  76. package/ui/internal/inkChalk.js +58 -0
  77. package/ui/internal/inkConsole.js +146 -0
  78. package/ui/internal/lazyView.js +74 -0
  79. package/ui/internal/lineText.js +19 -0
  80. package/ui/internal/mountPermit.js +16 -0
  81. package/ui/internal/perfDrain.js +33 -0
  82. package/ui/internal/processStreams.js +19 -0
  83. package/ui/internal/renderOptions.js +13 -0
  84. package/ui/testing/CliUiTest.js +760 -0
  85. package/ui/testing/fakeStreams.js +79 -0
  86. package/ui/testing/terminalModel.js +59 -0
  87. package/ui-testing-serializer.d.ts +14 -0
  88. package/ui-testing-serializer.js +33 -0
  89. package/ui-testing.d.ts +527 -0
  90. package/ui-testing.js +3 -0
  91. package/ui.d.ts +1790 -0
  92. package/ui.js +17 -0
@@ -0,0 +1,760 @@
1
+ import { CliInteractive } from "../../CliInteractive.js";
2
+ import { CliTheme } from "../../CliTheme.js";
3
+ import { inkModules } from "../internal/ink.js";
4
+ import { holder, holderSlot } from "../internal/Holder.js";
5
+ import { UiStreams } from "../UiStreams.js";
6
+ import { UiRenderOptions } from "../internal/renderOptions.js";
7
+ import { CliUi } from "../CliUi.js";
8
+ import { makeFakeStreams } from "./fakeStreams.js";
9
+ import { screenAfter } from "./terminalModel.js";
10
+ import { Cause, Console, Effect, Exit, Fiber, Inspectable, Layer, Option, Queue, Stream } from "effect";
11
+ import { TerminalEnv } from "@effected/env";
12
+ import { TestClock } from "effect/testing";
13
+
14
+ //#region src/ui/testing/CliUiTest.ts
15
+ /** The tokens, in the order their marker colours are numbered. */
16
+ const TOKENS = [
17
+ "success",
18
+ "failure",
19
+ "warning",
20
+ "info",
21
+ "error",
22
+ "muted",
23
+ "accent",
24
+ "emphasis"
25
+ ];
26
+ /** The marker palette: token N (1-based) paints in `#0000NN`, so a frame decodes back to the token. */
27
+ const MARKER_STYLES = Object.fromEntries(TOKENS.map((token, index) => [token, { fg: `#0000${(index + 1).toString(16).padStart(2, "0")}` }]));
28
+ const TOKEN_BY_BLUE = new Map(TOKENS.map((token, index) => [index + 1, token]));
29
+ const NAMED = [
30
+ "black",
31
+ "red",
32
+ "green",
33
+ "yellow",
34
+ "blue",
35
+ "magenta",
36
+ "cyan",
37
+ "white"
38
+ ];
39
+ /** The bytes a terminal in raw mode sends for each named key, as Ink's keypress parser reads them. */
40
+ const KEY_BYTES = {
41
+ up: "\x1B[A",
42
+ down: "\x1B[B",
43
+ right: "\x1B[C",
44
+ left: "\x1B[D",
45
+ enter: "\r",
46
+ space: " ",
47
+ tab: " ",
48
+ "shift+tab": "\x1B[Z",
49
+ backspace: "",
50
+ delete: "\x1B[3~",
51
+ escape: "\x1B",
52
+ "ctrl+c": "",
53
+ home: "\x1B[H",
54
+ end: "\x1B[F",
55
+ pageup: "\x1B[5~",
56
+ pagedown: "\x1B[6~"
57
+ };
58
+ const ESCAPES = /\u001b\[([0-9;]*)m|\u001b\[[0-9;?]*[A-Za-z]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
59
+ const hex = (r, g, b) => `#${[
60
+ r,
61
+ g,
62
+ b
63
+ ].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`;
64
+ /** Decode one run of SGR parameters into markup, against the stack of open styles. */
65
+ const decodeSgr = (params, open) => {
66
+ let out = "";
67
+ const close = (kind) => {
68
+ for (let index = open.length - 1; index >= 0; index--) {
69
+ const entry = open[index];
70
+ if (entry?.kind === kind) {
71
+ out += entry.close;
72
+ open.splice(index, 1);
73
+ return;
74
+ }
75
+ }
76
+ };
77
+ const push = (kind, tag, closeTag) => {
78
+ if (kind === "fg" || kind === "bg") close(kind);
79
+ out += tag;
80
+ open.push({
81
+ kind,
82
+ close: closeTag
83
+ });
84
+ };
85
+ const colour = (kind, codes, at) => {
86
+ if (codes[at + 1] === 2) {
87
+ const [r, g, b] = [
88
+ codes[at + 2] ?? 0,
89
+ codes[at + 3] ?? 0,
90
+ codes[at + 4] ?? 0
91
+ ];
92
+ const token = kind === "fg" && r === 0 && g === 0 ? TOKEN_BY_BLUE.get(b) : void 0;
93
+ if (token !== void 0) push(kind, `[${token}]`, `[/${token}]`);
94
+ else push(kind, `[${kind}:${hex(r, g, b)}]`, `[/${kind}]`);
95
+ return at + 4;
96
+ }
97
+ if (codes[at + 1] === 5) {
98
+ push(kind, `[${kind}:ansi256(${codes[at + 2] ?? 0})]`, `[/${kind}]`);
99
+ return at + 2;
100
+ }
101
+ return at;
102
+ };
103
+ const codes = params === "" ? [0] : params.split(";").map(Number);
104
+ for (let at = 0; at < codes.length; at++) {
105
+ const code = codes[at] ?? 0;
106
+ if (code === 0) while (open.length > 0) close(open[open.length - 1]?.kind ?? "fg");
107
+ else if (code === 1) push("b", "[b]", "[/b]");
108
+ else if (code === 2) push("dim", "[dim]", "[/dim]");
109
+ else if (code === 22) {
110
+ close("dim");
111
+ close("b");
112
+ } else if (code === 3) push("i", "[i]", "[/i]");
113
+ else if (code === 23) close("i");
114
+ else if (code === 4) push("u", "[u]", "[/u]");
115
+ else if (code === 24) close("u");
116
+ else if (code === 7) push("inverse", "[inverse]", "[/inverse]");
117
+ else if (code === 27) close("inverse");
118
+ else if (code === 9) push("s", "[s]", "[/s]");
119
+ else if (code === 29) close("s");
120
+ else if (code >= 30 && code <= 37) push("fg", `[fg:${NAMED[code - 30]}]`, "[/fg]");
121
+ else if (code >= 90 && code <= 97) push("fg", `[fg:${NAMED[code - 90]}Bright]`, "[/fg]");
122
+ else if (code === 38) at = colour("fg", codes, at);
123
+ else if (code === 39) close("fg");
124
+ else if (code >= 40 && code <= 47) push("bg", `[bg:${NAMED[code - 40]}]`, "[/bg]");
125
+ else if (code >= 100 && code <= 107) push("bg", `[bg:${NAMED[code - 100]}Bright]`, "[/bg]");
126
+ else if (code === 48) at = colour("bg", codes, at);
127
+ else if (code === 49) close("bg");
128
+ }
129
+ return out;
130
+ };
131
+ const styled = (ansi) => {
132
+ const open = [];
133
+ let out = "";
134
+ let last = 0;
135
+ for (const match of ansi.matchAll(ESCAPES)) {
136
+ out += ansi.slice(last, match.index);
137
+ last = match.index + match[0].length;
138
+ if (match[1] !== void 0) out += decodeSgr(match[1], open);
139
+ }
140
+ out += ansi.slice(last);
141
+ for (let index = open.length - 1; index >= 0; index--) out += open[index]?.close ?? "";
142
+ return out;
143
+ };
144
+ const trimLines = (text) => text.split("\n").map((line) => line.trimEnd()).join("\n");
145
+ /**
146
+ * Markup only this harness writes: a token tag opened and closed (`[info]…[/info]`), or a colour tag. A lone
147
+ * `[info]` is a log prefix, and style-only tags (`[b]`, `[i]`) are too common in unrelated data, so neither is claimed.
148
+ */
149
+ const MARKUP = new RegExp(`\\[(${TOKENS.join("|")})\\][\\s\\S]*?\\[/\\1\\]|\\[(?:fg|bg):[^\\]\\s]+\\]`);
150
+ /** Run `register`'s check on native timers, which a `TestClock` cannot hold, until it says done. */
151
+ const realTime = (poll) => Effect.callback((resume) => {
152
+ if (poll()) return resume(Effect.void);
153
+ const timer = setInterval(() => {
154
+ if (poll()) {
155
+ clearInterval(timer);
156
+ resume(Effect.void);
157
+ }
158
+ }, 2);
159
+ return Effect.sync(() => clearInterval(timer));
160
+ });
161
+ /**
162
+ * Resume once every timer already due by the wall clock has run.
163
+ *
164
+ * @remarks
165
+ * A zero-delay timer set straight away is not enough: during a timers phase the loop's time is the one cached when the
166
+ * phase began, so after a long block a new timer can count as due before an older one the wall clock says is overdue.
167
+ * `setImmediate` runs in the check phase, after the loop has refreshed its time; a zero-delay timer set there comes due
168
+ * after every timer already overdue, so the next timers phase runs them first.
169
+ */
170
+ const afterDueTimers = Effect.callback((resume) => {
171
+ let timer;
172
+ let after;
173
+ const immediate = setImmediate(() => {
174
+ timer = setTimeout(() => {
175
+ after = setImmediate(() => resume(Effect.void));
176
+ }, 0);
177
+ });
178
+ return Effect.sync(() => {
179
+ clearImmediate(immediate);
180
+ if (timer !== void 0) clearTimeout(timer);
181
+ if (after !== void 0) clearImmediate(after);
182
+ });
183
+ });
184
+ const QUIET_MS = 50;
185
+ const TRAILING_QUIET_MS = 8;
186
+ const ESCAPE_FLUSH_MS = 30;
187
+ const MOUNT_LIMIT_MS = 2e3;
188
+ const RERENDER_AFTER_END = "@effected/cli/ui/testing: rerender after the screen ended";
189
+ const RERENDER_BEFORE_MOUNT = "@effected/cli/ui/testing: rerender before the screen mounted (waited 2 s; is another screen still mounted?)";
190
+ const SCREEN_ENDED = "@effected/cli/ui/testing: a key was sent to a screen that has ended; take the screen mounted now with session.next()";
191
+ const NEXT_DIED = (index, contains, mounted, why) => `@effected/cli/ui/testing: next waited for screen ${index + 1} to mount and ${contains === void 0 ? "draw" : `show "${contains}"`}, but ${why}; ${mounted} mounted so far`;
192
+ const NOT_A_KEY = (method, text) => `@effected/cli/ui/testing: ${method}(${JSON.stringify(text)}): ${JSON.stringify(text)} is not a key name; send text with type(${JSON.stringify(text)}) or ${method}({ char: ${JSON.stringify(text)} })`;
193
+ /** The bytes of a named key or a `{ char }`; a bare string that names no key is a defect saying how to send text. */
194
+ const bytesOf = (key, method) => {
195
+ if (typeof key !== "string") return Effect.succeed(key.char);
196
+ return Object.hasOwn(KEY_BYTES, key) ? Effect.succeed(KEY_BYTES[key]) : Effect.die(new Error(NOT_A_KEY(method, key)));
197
+ };
198
+ /** A `Cancelled` from the root entrypoint, matched by shape: this entry may carry its own copy of the class. */
199
+ const cancelledReason = (value) => {
200
+ if (typeof value !== "object" || value === null) return void 0;
201
+ const { _tag, reason } = value;
202
+ return _tag === "Cancelled" && (reason === "escape" || reason === "interrupt") ? reason : void 0;
203
+ };
204
+ /** Synchronized-update brackets and cursor show/hide: written around a frame, never a frame themselves. */
205
+ const FRAME_BRACKETS = /\u001b\[\?(?:2026|25)[hl]/g;
206
+ /** A chunk of nothing but control sequences: Ink erasing its frame to write a log line above it. */
207
+ const CONTROLS_ONLY = /^(?:\u001b\[[0-9;?]*[A-Za-z])*$/;
208
+ /** The erase and cursor moves log-update writes before a frame; colour (SGR, `m`) is part of the frame. */
209
+ const LEADING_MOVES = /^(?:\u001b\[[0-9;?]*[A-Za-ln-z])+/;
210
+ /**
211
+ * The in-memory terminal `render`, `session` and `live` share: fake streams, the environment, and a capture per mount,
212
+ * with the settle-and-send machinery that drives a screen.
213
+ *
214
+ * @remarks
215
+ * `screens` is the render path a screen mounts on; a live view always mounts on the production path. On the debug path
216
+ * Ink writes each frame whole and the capture keeps it as written. On the production path Ink runs as it does for real:
217
+ * each render writes its erase moves and the new frame in one write, so the capture keeps that write without its
218
+ * moves; a write of moves alone is Ink clearing the frame for a log line, not a frame. stdout and stderr are two
219
+ * streams, each read alone, and `fake.written` is both in the order written: one terminal, for `written` and the
220
+ * transcript.
221
+ */
222
+ const makeTerminal = (options, settings = { screens: "debug" }) => {
223
+ const columns = options.columns ?? 80;
224
+ const rows = options.rows ?? 24;
225
+ const color = options.color ?? "truecolor";
226
+ const captures = [];
227
+ let lastWrite = 0;
228
+ let frameDue = false;
229
+ const fake = makeFakeStreams({
230
+ columns,
231
+ rows,
232
+ onStdoutWrite: (chunk) => {
233
+ lastWrite = Date.now();
234
+ const current = captures.at(-1);
235
+ if (!frameDue || current === void 0 || current.ended) return;
236
+ if (current.mode === "debug") {
237
+ frameDue = false;
238
+ current.raws.push(chunk);
239
+ return;
240
+ }
241
+ const text = chunk.replace(FRAME_BRACKETS, "");
242
+ if (text === "") return;
243
+ frameDue = false;
244
+ if (CONTROLS_ONLY.test(text)) return;
245
+ current.raws.push(text.replace(LEADING_MOVES, "").replace(/\n+$/, ""));
246
+ }
247
+ });
248
+ const streams = fake.streams;
249
+ const stream = {
250
+ isTerminal: true,
251
+ color,
252
+ hyperlinks: false,
253
+ columns: Option.some(columns)
254
+ };
255
+ const terminal = TerminalEnv.layerTest({
256
+ stdinIsTerminal: true,
257
+ stdout: stream,
258
+ stderr: stream
259
+ });
260
+ const layer = Layer.mergeAll(CliTheme.layer({
261
+ tokens: MARKER_STYLES,
262
+ glyphs: options.glyphs ?? "unicode"
263
+ }).pipe(Layer.provide(terminal)), CliInteractive.layerTest(options.interactive ?? true), Layer.succeed(UiStreams, streams), Layer.succeed(UiRenderOptions, {
264
+ ...settings.screens === "debug" ? { debug: true } : {},
265
+ maxFps: 1e3,
266
+ onRender: () => {
267
+ frameDue = true;
268
+ },
269
+ onMount: (kind) => {
270
+ const mode = kind === "live" ? "production" : settings.screens;
271
+ captures.push({
272
+ raws: [],
273
+ mode,
274
+ ended: false,
275
+ crash: void 0
276
+ });
277
+ },
278
+ onUnmount: (crash) => {
279
+ frameDue = false;
280
+ const current = captures.at(-1);
281
+ if (current === void 0) return;
282
+ current.ended = true;
283
+ current.crash = crash;
284
+ }
285
+ }));
286
+ /**
287
+ * Wait until a frame after `before` has been followed by a short quiet, so a reaction that renders twice is read
288
+ * whole; or, with no new frame, until quiet since the later of `since` and the last write. Never longer than
289
+ * `limitMs` from the first new frame once one has come, so a screen that never stops drawing still lets the wait end;
290
+ * counted from that frame rather than from `since`, so a loaded machine that was slow to draw it still gets the
291
+ * time to see the reaction that follows it.
292
+ */
293
+ const settle = (raws, ended, before, since, limitMs = QUIET_MS) => Effect.suspend(() => {
294
+ let firstFrameAt;
295
+ const pastLimit = (now) => firstFrameAt !== void 0 && now - firstFrameAt >= limitMs;
296
+ const quiet = realTime(() => {
297
+ if (ended()) return true;
298
+ const now = Date.now();
299
+ if (raws().length > before) {
300
+ firstFrameAt ??= now;
301
+ return now - lastWrite >= TRAILING_QUIET_MS || pastLimit(now);
302
+ }
303
+ return now - Math.max(since, lastWrite) >= QUIET_MS && now - since >= QUIET_MS;
304
+ });
305
+ const confirmed = Effect.flatMap(quiet, () => {
306
+ const seen = lastWrite;
307
+ return Effect.flatMap(afterDueTimers, () => lastWrite === seen || ended() || pastLimit(Date.now()) ? Effect.void : confirmed);
308
+ });
309
+ return confirmed;
310
+ });
311
+ /**
312
+ * Drive and read the screen whose capture `capture` returns (none yet: no frames), ended when `ended` says. A crash
313
+ * is never swallowed: once `failed` (by default, the capture's own crash) holds a cause, every read and send dies
314
+ * with it, so a screen that drew nothing is never read as an empty one.
315
+ */
316
+ const screen = (capture, ended, failed = () => {
317
+ const crash = capture()?.crash;
318
+ return crash === void 0 ? void 0 : Cause.die(crash.defect);
319
+ }) => {
320
+ const surfaced = (effect) => Effect.suspend(() => {
321
+ const cause = failed();
322
+ return cause === void 0 ? effect : Effect.die(Cause.squash(cause));
323
+ });
324
+ const raws = () => capture()?.raws ?? [];
325
+ const after = (before, since) => settle(raws, ended, before, since);
326
+ const send = (bytes, flushMs = 0) => Effect.suspend(() => {
327
+ if (ended()) return Effect.die(/* @__PURE__ */ new Error(SCREEN_ENDED));
328
+ const before = raws().length;
329
+ fake.input(bytes);
330
+ const sent = Date.now();
331
+ const flushed = flushMs === 0 ? Effect.void : realTime(() => Date.now() - sent >= flushMs);
332
+ return Effect.andThen(flushed, after(before, Date.now()));
333
+ });
334
+ return {
335
+ handle: {
336
+ press: (...keys) => Effect.forEach(keys, (key) => surfaced(Effect.flatMap(bytesOf(key, "press"), (bytes) => send(bytes, key === "escape" ? ESCAPE_FLUSH_MS : 0))), { discard: true }),
337
+ type: (text) => Effect.forEach([...text], (character) => surfaced(send(character)), { discard: true }),
338
+ chunk: (...keys) => surfaced(Effect.flatMap(Effect.forEach(keys, (key) => bytesOf(key, "chunk")), (bytes) => send(bytes.join(""), keys.at(-1) === "escape" ? ESCAPE_FLUSH_MS : 0))),
339
+ resize: (nextColumns, nextRows) => surfaced(Effect.suspend(() => {
340
+ const before = raws().length;
341
+ const since = Date.now();
342
+ fake.resize(nextColumns, nextRows);
343
+ return after(before, since);
344
+ })),
345
+ frame: surfaced(Effect.sync(() => trimLines(styled(raws().at(-1) ?? "")))),
346
+ rawFrame: surfaced(Effect.sync(() => raws().at(-1) ?? "")),
347
+ plainFrame: surfaced(Effect.sync(() => trimLines((raws().at(-1) ?? "").replace(ESCAPES, "")))),
348
+ frames: surfaced(Effect.sync(() => raws().map((raw) => trimLines(styled(raw)))))
349
+ },
350
+ raws,
351
+ after,
352
+ surfaced
353
+ };
354
+ };
355
+ return {
356
+ fake,
357
+ layer,
358
+ captures,
359
+ screen,
360
+ settle
361
+ };
362
+ };
363
+ /**
364
+ * Mount `screen` on a fresh terminal for the enclosing scope, held in a swappable holder so a rerender changes only its
365
+ * subtree under the same control: what `render` and `view` share. Ready once the first frame is drawn, the screen has
366
+ * ended, or 2 s have passed. A crash surfaces on every read and send, as on a session's screen; with `refusal`, so does
367
+ * a run refused as not interactive, for a view, which has no `result` to carry it.
368
+ */
369
+ const mount = (screen, options, refusal) => Effect.gen(function* () {
370
+ const terminal = makeTerminal(options);
371
+ let ended = false;
372
+ let failure;
373
+ const slot = holderSlot();
374
+ let control;
375
+ const held = async (given) => {
376
+ control = given;
377
+ const initial = await screen(given);
378
+ return inkModules().react.createElement(holder(), {
379
+ initial,
380
+ bind: slot.bind
381
+ });
382
+ };
383
+ const fiber = yield* Effect.forkScoped(CliUi.run(held).pipe(Effect.provide(terminal.layer), Effect.onExit((exit) => Effect.sync(() => {
384
+ ended = true;
385
+ if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause) && (exit.cause.reasons.some(Cause.isDieReason) || cancelledReason(Cause.squash(exit.cause)) === void 0)) failure = exit.cause;
386
+ }))));
387
+ const { handle, raws, after, surfaced } = terminal.screen(() => terminal.captures[0], () => ended, refusal ? () => failure : void 0);
388
+ const mountedBy = Date.now() + MOUNT_LIMIT_MS;
389
+ yield* realTime(() => raws().length > 0 || ended || Date.now() >= mountedBy);
390
+ yield* after(0, Date.now());
391
+ const swapTo = (next) => Effect.gen(function* () {
392
+ const mountedBy = Date.now() + MOUNT_LIMIT_MS;
393
+ yield* realTime(() => slot.isBound() || ended || Date.now() >= mountedBy);
394
+ if (ended) return yield* Effect.die(/* @__PURE__ */ new Error(RERENDER_AFTER_END));
395
+ if (!slot.isBound() || control === void 0) return yield* Effect.die(/* @__PURE__ */ new Error(RERENDER_BEFORE_MOUNT));
396
+ const given = control;
397
+ const element = yield* Effect.promise(async () => next(given));
398
+ const before = raws().length;
399
+ const since = Date.now();
400
+ if (ended || !slot.swap(element)) {
401
+ const endedBy = Date.now() + MOUNT_LIMIT_MS;
402
+ yield* realTime(() => ended || Date.now() >= endedBy);
403
+ return yield* surfaced(Effect.die(/* @__PURE__ */ new Error(RERENDER_AFTER_END)));
404
+ }
405
+ yield* after(before, since);
406
+ });
407
+ const rerender = (next) => surfaced(Effect.andThen(swapTo(next), surfaced(Effect.void)));
408
+ return {
409
+ handle,
410
+ rerender,
411
+ fiber,
412
+ surfaced
413
+ };
414
+ });
415
+ /** A `Console` that keeps what is written: `log`, `info` and `debug` as stdout, `error`, `warn` and `trace` as stderr. */
416
+ const capturingConsole = (ambient) => {
417
+ const out = [];
418
+ const err = [];
419
+ const line = (sink) => (...args) => {
420
+ sink.push(`${args.map((arg) => Inspectable.toStringUnknown(arg, 0)).join(" ")}\n`);
421
+ };
422
+ return {
423
+ writer: Object.assign(Object.create(ambient), {
424
+ log: line(out),
425
+ info: line(out),
426
+ debug: line(out),
427
+ error: line(err),
428
+ warn: line(err),
429
+ trace: line(err)
430
+ }),
431
+ out,
432
+ err
433
+ };
434
+ };
435
+ /**
436
+ * Drive and read Ink screens in tests: mount a screen on in-memory streams, press keys, and read its frames as token
437
+ * markup.
438
+ *
439
+ * @example
440
+ * ```ts
441
+ * import { assert, it } from "@effect/vitest"
442
+ * import { Select } from "@effected/cli/ui"
443
+ * import { CliUiTest } from "@effected/cli/ui/testing"
444
+ * import { Effect } from "effect"
445
+ *
446
+ * it.effect("chooses the second option", () =>
447
+ * Effect.gen(function* () {
448
+ * const choices = [
449
+ * { label: "a", value: "a" },
450
+ * { label: "b", value: "b" },
451
+ * ]
452
+ * const handle = yield* CliUiTest.render(Select.screen({ message: "Pick one", choices }))
453
+ * yield* handle.press("down", "enter")
454
+ * assert.strictEqual(yield* handle.result, "b")
455
+ * }).pipe(Effect.scoped),
456
+ * )
457
+ * ```
458
+ *
459
+ * @public
460
+ */
461
+ var CliUiTest = class {
462
+ constructor() {}
463
+ /**
464
+ * Mount `screen` on in-memory terminal streams for the enclosing scope.
465
+ *
466
+ * @remarks
467
+ * The screen runs under a fixed environment: a `TerminalEnv` test layer with the given columns and colour, a
468
+ * `CliTheme` whose every token paints in its own marker colour (so frames do not depend on the real palette), and
469
+ * `CliInteractive` set from `interactive`. Ink renders in debug mode, writing every frame in full; the frames
470
+ * are taken from those writes. Closing the scope unmounts the screen. The returned handle is ready once the first
471
+ * frame is drawn or the screen has ended.
472
+ *
473
+ * Waiting is on real time, through native timers a `TestClock` cannot hold, so the harness's own waits work under
474
+ * `it.effect` and `it.live` alike, and never sleep longer than 50 ms past the last write (30 ms first, for Esc). A
475
+ * screen test that itself sleeps, times out or retries on a schedule needs `it.live` (or real timers): under
476
+ * `it.effect` those run on the `TestClock`, which nothing advances while a screen waits on real time.
477
+ *
478
+ * The first frame is awaited for at most 2 s: a screen that draws nothing for longer gives a handle whose `frames`
479
+ * is `[]`. Screens run one at a time process-wide (`CliUi.run`), so a second handle opened while another is
480
+ * still mounted waits for that mount: it returns at the 2 s cap with no frames, and its keys queue in its input until
481
+ * it mounts.
482
+ *
483
+ * Debug frames bypass Ink's erase-and-redraw path, so a harness frame says nothing about what Ink writes between
484
+ * frames on a real terminal (a screen clear, for instance), nor about the final frame left in the scrollback once
485
+ * the screen ends (answered screens stay); a test of that needs the production render path.
486
+ * For the same reason `CliUi.run`'s `clear` has no visible effect on a harness frame, since Ink's `clear` does
487
+ * nothing in debug mode: test `clear` with `session({ renderPath: "production" })` and its `transcript`.
488
+ * Unmounting is the scope's close; to draw a different screen, render it in a new scope.
489
+ *
490
+ * A crash is never swallowed. A screen thunk that throws (a classic-JSX `React is not defined` included) or a
491
+ * component that throws ends the run with a defect: `result` dies with it, and so does the next frame read, key,
492
+ * resize or rerender, so a crashed screen is never read as one that drew nothing. As with `view`, after a crash
493
+ * the frames drawn before it cannot be read. A run refused as not interactive is `result`'s `NotInteractive`, and
494
+ * its frames read as `[]`.
495
+ *
496
+ * @param screen - the screen to mount
497
+ * @param options - the terminal's size, colour and glyphs, and whether the run is interactive
498
+ */
499
+ static render = (screen, options = {}) => Effect.map(mount(screen, options, false), ({ handle, rerender, fiber }) => ({
500
+ ...handle,
501
+ rerender,
502
+ result: Fiber.join(fiber)
503
+ }));
504
+ /**
505
+ * Mount a display-only element on in-memory terminal streams for the enclosing scope: a status line, a live view,
506
+ * a component a consumer mounts in an Ink tree of its own.
507
+ *
508
+ * @remarks
509
+ * The same harness as {@link CliUiTest.render}, with the same options: the marker-palette theme, the fake streams
510
+ * and the debug frames, and the element is drawn inside the kit's providers, so `useTheme`, `useGlyphs`,
511
+ * `useTerminalSize` and `Styled` work in it. The handle reads frames and sends keys as a rendered screen's does, and
512
+ * `rerender` swaps in another element, but it has no `result`: a display-only element never ends on its own, so a
513
+ * `render` of one would leave `result` waiting forever. Closing the scope unmounts it.
514
+ *
515
+ * The kit's root keys stay bound, as on every screen: Esc or Ctrl-C ends the view, after which a key or a rerender
516
+ * is a defect.
517
+ *
518
+ * An element that crashes, or a run that is refused (`interactive: false` ends it with `NotInteractive`), is never
519
+ * swallowed: `view` dies with that error when it happens before the first frame, and otherwise the next frame read,
520
+ * key, resize or rerender does. After a crash every read dies with it, `frames` included, so the frames drawn before
521
+ * the crash cannot be read: the crash is the signal a test needs. A deliberate end (Esc or Ctrl-C) is not a crash:
522
+ * the frames stay readable, and only a key, resize or rerender after it dies, saying the screen has ended.
523
+ *
524
+ * @param element - the element to mount
525
+ * @param options - the terminal's size, colour and glyphs, and whether the run is interactive
526
+ */
527
+ static view = (element, options = {}) => Effect.flatMap(mount(() => element, options, true), ({ handle, rerender, surfaced }) => {
528
+ const view = {
529
+ ...handle,
530
+ rerender: (next) => rerender(() => next)
531
+ };
532
+ return surfaced(Effect.succeed(view));
533
+ });
534
+ /**
535
+ * A terminal for a whole program that runs screens of its own (a wizard, a handler calling `CliUi.prompt` several
536
+ * times): provide its `layer` around the program, then take each screen as it mounts with `next`.
537
+ *
538
+ * @remarks
539
+ * The same environment and the same waiting as {@link CliUiTest.render}, with the same options. The session also
540
+ * keeps what the program writes through `Console`, its own output beside the screens, as `stdout` and `stderr`.
541
+ *
542
+ * Run the program forked (`Effect.forkScoped`) and drive it from the test: `next` returns each screen once it has
543
+ * mounted and drawn, `press` and `type` settle as they do on a rendered screen, and joining the program's fiber
544
+ * gives its exit. Screens still run one at a time, process-wide, so `next` sees them in the order they mount.
545
+ *
546
+ * By default, as with `render`, a screen run with `clear` leaves its frames unchanged here: Ink renders screens in
547
+ * debug mode, where its `clear` does nothing. Pass `renderPath: "production"` to render them as a terminal does, and
548
+ * read `transcript` to see what stays on it: a cleared screen leaves nothing there. A live view a handler mounts
549
+ * (`CliUi.live`) always renders on the production path, and the lines it writes above its frame through
550
+ * `handle.logConsole` are in `transcript` and `written`.
551
+ * The waits are real time: a session test that itself sleeps or times out needs `it.live`. To assert that no screen
552
+ * mounted (a non-interactive run, a flag that skips a prompt), check that `mounts` is `0` once the program has
553
+ * finished.
554
+ *
555
+ * A screen that crashes (its thunk or a component throws) is never swallowed: `next` dies with the crash when it has
556
+ * happened by then, whatever `contains` waited for, and otherwise the screen's next frame read, key or resize does.
557
+ * The program's own fiber dies with it too.
558
+ *
559
+ * Driving a whole `Command` handler: provide `layer`, a fresh `CliExit.layer` if the handler records a code, a
560
+ * `ConfigProvider` that sandboxes what the handler reads (`HOME`, the XDG directories), and the platform core's
561
+ * runner needs, then fork the program and take each screen with `next`:
562
+ *
563
+ * ```ts
564
+ * const session = yield* CliUiTest.session()
565
+ * const program = Effect.gen(function* () {
566
+ * yield* Command.runWith(root, { version })(["init"])
567
+ * return MutableRef.get((yield* CliExit).code)
568
+ * }).pipe(
569
+ * Effect.provide(session.layer),
570
+ * Effect.provide(CliExit.layer),
571
+ * Effect.provide(NodeServices.layer),
572
+ * Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromUnknown({ HOME: "/sandbox/home" })),
573
+ * )
574
+ * const fiber = yield* Effect.forkScoped(program)
575
+ * yield* (yield* session.next({ contains: "Profile" })).press("enter")
576
+ * const code = yield* Fiber.join(fiber)
577
+ * ```
578
+ *
579
+ * To exercise `CliRuntime.main` as well (its failure report and exit code), run the program through it with the
580
+ * session's layer provided around it instead; `main` provides its own `CliExit`.
581
+ *
582
+ * @param options - the terminal's size, colour and glyphs, whether the run is interactive, and the screens' render path
583
+ */
584
+ static session = (options = {}) => Effect.map(Console.Console, (ambient) => {
585
+ const { renderPath, ...terminalOptions } = options;
586
+ const terminal = makeTerminal(terminalOptions, { screens: renderPath ?? "debug" });
587
+ const output = capturingConsole(ambient);
588
+ let taken = 0;
589
+ const next = (nextOptions = {}) => Effect.gen(function* () {
590
+ const index = taken++;
591
+ const { contains } = nextOptions;
592
+ const capture = () => terminal.captures[index];
593
+ const { handle, raws, after, surfaced } = terminal.screen(capture, () => capture()?.ended ?? false);
594
+ const shows = () => contains === void 0 ? raws().length > 0 : raws().some((raw) => raw.replace(ESCAPES, "").includes(contains));
595
+ const by = Date.now() + MOUNT_LIMIT_MS;
596
+ yield* realTime(() => shows() || capture()?.ended === true || Date.now() >= by);
597
+ yield* surfaced(Effect.void);
598
+ if (!shows()) {
599
+ const why = capture() === void 0 ? "none mounted within 2 s" : capture()?.ended === true ? "it unmounted first" : "it did not within 2 s";
600
+ return yield* Effect.die(new Error(NEXT_DIED(index, contains, terminal.captures.length, why)));
601
+ }
602
+ yield* after(0, Date.now());
603
+ return handle;
604
+ });
605
+ return {
606
+ layer: Layer.merge(terminal.layer, Layer.succeed(Console.Console, output.writer)),
607
+ next,
608
+ mounts: Effect.sync(() => terminal.captures.length),
609
+ stdout: Effect.sync(() => output.out.join("")),
610
+ stderr: Effect.sync(() => output.err.join("")),
611
+ transcript: Effect.sync(() => screenAfter(terminal.fake.written(), terminal.fake.streams.stdout.rows).join("\n")),
612
+ written: Effect.sync(() => terminal.fake.written()),
613
+ stdoutWritten: Effect.sync(() => terminal.fake.stdout()),
614
+ stderrWritten: Effect.sync(() => terminal.fake.stderr()),
615
+ stdoutTranscript: Effect.sync(() => screenAfter(terminal.fake.stdout(), terminal.fake.streams.stdout.rows).join("\n")),
616
+ stderrTranscript: Effect.sync(() => screenAfter(terminal.fake.stderr(), terminal.fake.streams.stdout.rows).join("\n"))
617
+ };
618
+ });
619
+ /**
620
+ * Mount a live view (`CliUi.live`) on a fresh in-memory terminal for the enclosing scope, with an event stream the
621
+ * test publishes to, and read what it draws on the production render path.
622
+ *
623
+ * @remarks
624
+ * The options are `CliUi.live`'s without `events`, which the harness supplies, and the terminal's own (`columns`,
625
+ * `rows`, `color`, `glyphs`, `interactive`). The view runs as it does for real: Ink is interactive and not in debug
626
+ * mode, so frames are what Ink actually writes, committed frames stay on the terminal, and `transcript` shows what is
627
+ * left there, scrollback included, through a small terminal model (it does not wrap a line wider than the terminal).
628
+ * stderr is the same stream as stdout, as on a terminal, so a line logged through `handle.logConsole` lands in the
629
+ * transcript too.
630
+ *
631
+ * Write live tests with `it.effect`: the view's tick runs on the `TestClock`, so `advance` (or `TestClock.adjust`)
632
+ * drives it frame by frame, and the frame index is `floor(now / tickMillis)` from the clock's epoch. The waits after
633
+ * `publish`, `advance` and `resize` are real time, which the `TestClock` does not hold. Without `@effect/vitest`'s
634
+ * `it.effect`, provide the clock yourself: `Effect.provide(test, TestClock.layer())` (from `effect/testing`).
635
+ *
636
+ * @example
637
+ * ```ts
638
+ * import { assert, it } from "@effect/vitest"
639
+ * import { CliUiTest } from "@effected/cli/ui/testing"
640
+ * import { Effect } from "effect"
641
+ * import { Text } from "ink"
642
+ * import { createElement } from "react"
643
+ *
644
+ * type Event = { readonly _tag: "RunStarted" } | { readonly _tag: "RunEnded" }
645
+ *
646
+ * it.effect("turns the spinner on the tick", () =>
647
+ * Effect.gen(function* () {
648
+ * const view = yield* CliUiTest.live({
649
+ * initial: 0,
650
+ * reduce: (count: number, _event: Event) => count + 1,
651
+ * render: (_count, frame) => createElement(Text, null, `frame ${frame}`),
652
+ * isStart: (event) => event._tag === "RunStarted",
653
+ * isTerminal: (event) => event._tag === "RunEnded",
654
+ * })
655
+ * yield* view.publish({ _tag: "RunStarted" })
656
+ * // The clock starts at 0 and the tick is 80 ms, so 160 ms on is frame 2.
657
+ * yield* view.advance("160 millis")
658
+ * assert.include(yield* view.plainFrame, "frame 2")
659
+ * }).pipe(Effect.scoped),
660
+ * )
661
+ * ```
662
+ *
663
+ * @param options - the live view's options without `events`, and the terminal's size, colour, glyphs and
664
+ * interactivity
665
+ */
666
+ static live = (options) => Effect.gen(function* () {
667
+ const { columns, rows, color, glyphs, interactive, ...view } = options;
668
+ const terminal = makeTerminal({
669
+ ...columns === void 0 ? {} : { columns },
670
+ ...rows === void 0 ? {} : { rows },
671
+ ...color === void 0 ? {} : { color },
672
+ ...glyphs === void 0 ? {} : { glyphs },
673
+ ...interactive === void 0 ? {} : { interactive }
674
+ }, { screens: "production" });
675
+ const queue = yield* Queue.unbounded();
676
+ const handle = yield* CliUi.live({
677
+ ...view,
678
+ events: Stream.fromQueue(queue)
679
+ }).pipe(Effect.provide(terminal.layer));
680
+ const raws = () => terminal.captures.flatMap((capture) => capture.raws);
681
+ const settled = (effect) => Effect.suspend(() => {
682
+ const before = raws().length;
683
+ const since = Date.now();
684
+ return Effect.andThen(effect, terminal.settle(raws, () => false, before, since));
685
+ });
686
+ const last = () => raws().at(-1) ?? "";
687
+ return {
688
+ publish: (event) => settled(Queue.offer(queue, event)),
689
+ end: Effect.andThen(Queue.end(queue), handle.done),
690
+ advance: (duration) => settled(TestClock.adjust(duration)),
691
+ resize: (nextColumns, nextRows) => settled(Effect.sync(() => terminal.fake.resize(nextColumns, nextRows))),
692
+ frame: Effect.sync(() => trimLines(styled(last()))),
693
+ rawFrame: Effect.sync(last),
694
+ plainFrame: Effect.sync(() => trimLines(last().replace(ESCAPES, ""))),
695
+ frames: Effect.sync(() => raws().map((raw) => trimLines(styled(raw)))),
696
+ transcript: Effect.sync(() => screenAfter(terminal.fake.written(), terminal.fake.streams.stdout.rows).join("\n")),
697
+ written: Effect.sync(() => terminal.fake.written()),
698
+ handle
699
+ };
700
+ });
701
+ /**
702
+ * Why a screen or a program was cancelled, read from its `Exit` or `Cause`: `"escape"` or `"interrupt"` when it
703
+ * carries a `Cancelled`, as a typed failure or as a defect, else `None`.
704
+ *
705
+ * @remarks
706
+ * Pure. A screen's `result` fails with `Cancelled` in the typed channel; a prompt cancelled where the program
707
+ * declares no such error carries it as a defect. Either way a test asks this instead of walking `cause.reasons`:
708
+ *
709
+ * ```ts
710
+ * const exit = yield* Fiber.await(program)
711
+ * assert.deepStrictEqual(CliUiTest.cancelReason(exit), Option.some("escape"))
712
+ * ```
713
+ *
714
+ * @param exitOrCause - the exit of a screen's `result` or of a program, or a bare cause
715
+ */
716
+ static cancelReason = (exitOrCause) => {
717
+ const cause = Exit.isExit(exitOrCause) ? Exit.isFailure(exitOrCause) ? exitOrCause.cause : void 0 : exitOrCause;
718
+ for (const reason of cause?.reasons ?? []) {
719
+ const found = cancelledReason(reason._tag === "Fail" ? reason.error : reason._tag === "Die" ? reason.defect : void 0);
720
+ if (found !== void 0) return Option.some(found);
721
+ }
722
+ return Option.none();
723
+ };
724
+ /**
725
+ * Decode ANSI back to markup: a marker colour to its token (`[success]…[/success]`), any other foreground to
726
+ * `[fg:red]` or `[fg:#ff0000]` (backgrounds likewise as `[bg:…]`), bold to `[b]`, dim to `[dim]`, italic, underline,
727
+ * inverse and strikethrough to `[i]`, `[u]`, `[inverse]` and `[s]`. A reset closes everything open; every other
728
+ * escape (cursor, erase, hyperlinks) is dropped.
729
+ *
730
+ * @param ansi - text with escapes
731
+ */
732
+ static styled = styled;
733
+ /**
734
+ * A Vitest snapshot serializer. It claims a string carrying escapes, a token tag opened and closed, or a colour
735
+ * tag (a raw or styled frame, or a `Render.ansi` string), but not a log line's lone `[info]` prefix, nor one whose
736
+ * only brackets are style tags like `[b]`, which unrelated data uses too. It prints the string as token markup with
737
+ * each line's trailing spaces trimmed, so a snapshot reads without escapes and does not churn with the palette.
738
+ *
739
+ * @remarks
740
+ * Register it in either of two ways. Through the Vitest config, by the module whose default export it is, which
741
+ * needs no code in a test file:
742
+ *
743
+ * ```ts
744
+ * // vitest.config.ts
745
+ * export default defineConfig({ test: { snapshotSerializers: ["@effected/cli/ui/testing/serializer"] } })
746
+ * ```
747
+ *
748
+ * Or in a test file, with `expect.addSnapshotSerializer(CliUiTest.serializer)`.
749
+ *
750
+ * Snapshots are the one place a test needs `expect`: `assert` has no snapshot form, so a suite that asserts with
751
+ * `assert.*` everywhere else still writes `expect(frame).toMatchInlineSnapshot(...)` for a snapshot.
752
+ */
753
+ static serializer = {
754
+ test: (value) => typeof value === "string" && (value.includes("\x1B") || MARKUP.test(value)),
755
+ serialize: (value) => trimLines(styled(String(value)))
756
+ };
757
+ };
758
+
759
+ //#endregion
760
+ export { CliUiTest };