@effected/cli 0.9.0 → 0.11.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 (90) hide show
  1. package/Cancelled.js +44 -0
  2. package/CliAudience.js +178 -0
  3. package/CliColor.js +14 -20
  4. package/CliEnv.js +89 -0
  5. package/CliExit.js +1 -1
  6. package/CliFailure.js +253 -0
  7. package/CliInteractive.js +71 -0
  8. package/CliLinks.js +154 -0
  9. package/CliLog.js +294 -0
  10. package/CliLogger.js +34 -33
  11. package/CliMessage.js +83 -0
  12. package/CliPrompt.js +104 -0
  13. package/CliRuntime.js +104 -54
  14. package/CliTest.js +18 -2
  15. package/CliTheme.js +128 -0
  16. package/ConfigIssueRenderer.js +14 -33
  17. package/Doc.js +512 -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 +129 -131
  23. package/Render.js +254 -0
  24. package/SchemaIssueRenderer.js +7 -10
  25. package/Status.js +163 -0
  26. package/TestTerminal.js +80 -0
  27. package/Token.js +69 -0
  28. package/index.d.ts +2923 -171
  29. package/index.js +19 -1
  30. package/internal/HelpRouting.js +1 -1
  31. package/internal/ansi.js +230 -0
  32. package/internal/autoFormat.js +34 -0
  33. package/internal/canPrompt.js +15 -0
  34. package/internal/counts.js +69 -0
  35. package/internal/diagnostics.js +32 -0
  36. package/internal/displayWidth.js +35 -0
  37. package/internal/failureTarget.js +156 -0
  38. package/internal/fallbackAnswer.js +18 -0
  39. package/internal/fileSink.js +62 -0
  40. package/internal/format.js +62 -7
  41. package/internal/layout.js +250 -0
  42. package/internal/linkScheme.js +30 -0
  43. package/internal/linkTarget.js +50 -0
  44. package/internal/logSafety.js +46 -0
  45. package/internal/renderAnsi.js +52 -0
  46. package/internal/renderDoc.js +319 -0
  47. package/internal/renderGithubLog.js +46 -0
  48. package/internal/renderMarkdown.js +367 -0
  49. package/internal/renderPlain.js +50 -0
  50. package/internal/scanAudience.js +106 -0
  51. package/internal/splitFrame.js +56 -0
  52. package/internal/wizardGate.js +18 -0
  53. package/package.json +35 -5
  54. package/testing.d.ts +90 -4
  55. package/testing.js +2 -1
  56. package/ui/CliUi.js +348 -0
  57. package/ui/CliUiLive.js +399 -0
  58. package/ui/Confirm.js +245 -0
  59. package/ui/DocView.js +74 -0
  60. package/ui/KeyHelp.js +62 -0
  61. package/ui/KeyTable.js +199 -0
  62. package/ui/MultiSelect.js +260 -0
  63. package/ui/Select.js +226 -0
  64. package/ui/Tabs.js +202 -0
  65. package/ui/TextInput.js +250 -0
  66. package/ui/Toggle.js +32 -0
  67. package/ui/UiKey.js +44 -0
  68. package/ui/UiProvider.js +60 -0
  69. package/ui/UiStreams.js +18 -0
  70. package/ui/UiTheme.js +119 -0
  71. package/ui/Viewport.js +204 -0
  72. package/ui/internal/ErrorBoundary.js +30 -0
  73. package/ui/internal/Holder.js +74 -0
  74. package/ui/internal/ScreenContext.js +52 -0
  75. package/ui/internal/UiProviders.js +21 -0
  76. package/ui/internal/ink.js +122 -0
  77. package/ui/internal/inkChalk.js +58 -0
  78. package/ui/internal/inkConsole.js +146 -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 +735 -0
  85. package/ui/testing/fakeStreams.js +76 -0
  86. package/ui/testing/terminalModel.js +59 -0
  87. package/ui-testing.d.ts +446 -0
  88. package/ui-testing.js +3 -0
  89. package/ui.d.ts +1648 -0
  90. package/ui.js +17 -0
@@ -0,0 +1,735 @@
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
+ * In `"debug"` mode (screens) Ink writes each frame whole and the capture keeps it as written. In `"production"` mode
216
+ * (the live view) Ink runs as it does for real: each render writes its erase moves and the new frame in one write, so
217
+ * the capture keeps that write without its moves; a write of moves alone is Ink clearing the frame for a log line, not
218
+ * a frame. stderr is the same stream as stdout there, as on a terminal, so the transcript holds both.
219
+ */
220
+ const makeTerminal = (options, mode = "debug") => {
221
+ const columns = options.columns ?? 80;
222
+ const rows = options.rows ?? 24;
223
+ const color = options.color ?? "truecolor";
224
+ const captures = [];
225
+ let lastWrite = 0;
226
+ let frameDue = false;
227
+ const fake = makeFakeStreams({
228
+ columns,
229
+ rows,
230
+ onStdoutWrite: (chunk) => {
231
+ lastWrite = Date.now();
232
+ const current = captures.at(-1);
233
+ if (!frameDue || current === void 0 || current.ended) return;
234
+ if (mode === "debug") {
235
+ frameDue = false;
236
+ current.raws.push(chunk);
237
+ return;
238
+ }
239
+ const text = chunk.replace(FRAME_BRACKETS, "");
240
+ if (text === "") return;
241
+ frameDue = false;
242
+ if (CONTROLS_ONLY.test(text)) return;
243
+ current.raws.push(text.replace(LEADING_MOVES, "").replace(/\n+$/, ""));
244
+ }
245
+ });
246
+ const streams = mode === "production" ? {
247
+ ...fake.streams,
248
+ stderr: fake.streams.stdout
249
+ } : fake.streams;
250
+ const stream = {
251
+ isTerminal: true,
252
+ color,
253
+ hyperlinks: false,
254
+ columns: Option.some(columns)
255
+ };
256
+ const terminal = TerminalEnv.layerTest({
257
+ stdinIsTerminal: true,
258
+ stdout: stream,
259
+ stderr: stream
260
+ });
261
+ const layer = Layer.mergeAll(CliTheme.layer({
262
+ tokens: MARKER_STYLES,
263
+ glyphs: options.glyphs ?? "unicode"
264
+ }).pipe(Layer.provide(terminal)), CliInteractive.layerTest(options.interactive ?? true), Layer.succeed(UiStreams, streams), Layer.succeed(UiRenderOptions, {
265
+ ...mode === "debug" ? { debug: true } : {},
266
+ onRender: () => {
267
+ frameDue = true;
268
+ },
269
+ onMount: () => {
270
+ captures.push({
271
+ raws: [],
272
+ ended: false,
273
+ crash: void 0
274
+ });
275
+ },
276
+ onUnmount: (crash) => {
277
+ frameDue = false;
278
+ const current = captures.at(-1);
279
+ if (current === void 0) return;
280
+ current.ended = true;
281
+ current.crash = crash;
282
+ }
283
+ }));
284
+ /**
285
+ * Wait until a frame after `before` has been followed by a short quiet, so a reaction that renders twice is read
286
+ * whole; or, with no new frame, until quiet since the later of `since` and the last write. Never longer than
287
+ * `limitMs` from the first new frame once one has come, so a screen that never stops drawing still lets the wait end;
288
+ * counted from that frame rather than from `since`, so a loaded machine that was slow to draw it still gets the
289
+ * time to see the reaction that follows it.
290
+ */
291
+ const settle = (raws, ended, before, since, limitMs = QUIET_MS) => Effect.suspend(() => {
292
+ let firstFrameAt;
293
+ const pastLimit = (now) => firstFrameAt !== void 0 && now - firstFrameAt >= limitMs;
294
+ const quiet = realTime(() => {
295
+ if (ended()) return true;
296
+ const now = Date.now();
297
+ if (raws().length > before) {
298
+ firstFrameAt ??= now;
299
+ return now - lastWrite >= TRAILING_QUIET_MS || pastLimit(now);
300
+ }
301
+ return now - Math.max(since, lastWrite) >= QUIET_MS && now - since >= QUIET_MS;
302
+ });
303
+ const confirmed = Effect.flatMap(quiet, () => {
304
+ const seen = lastWrite;
305
+ return Effect.flatMap(afterDueTimers, () => lastWrite === seen || ended() || pastLimit(Date.now()) ? Effect.void : confirmed);
306
+ });
307
+ return confirmed;
308
+ });
309
+ /**
310
+ * Drive and read the screen whose capture `capture` returns (none yet: no frames), ended when `ended` says. A crash
311
+ * is never swallowed: once `failed` (by default, the capture's own crash) holds a cause, every read and send dies
312
+ * with it, so a screen that drew nothing is never read as an empty one.
313
+ */
314
+ const screen = (capture, ended, failed = () => {
315
+ const crash = capture()?.crash;
316
+ return crash === void 0 ? void 0 : Cause.die(crash.defect);
317
+ }) => {
318
+ const surfaced = (effect) => Effect.suspend(() => {
319
+ const cause = failed();
320
+ return cause === void 0 ? effect : Effect.die(Cause.squash(cause));
321
+ });
322
+ const raws = () => capture()?.raws ?? [];
323
+ const after = (before, since) => settle(raws, ended, before, since);
324
+ const send = (bytes, flushMs = 0) => Effect.suspend(() => {
325
+ if (ended()) return Effect.die(/* @__PURE__ */ new Error(SCREEN_ENDED));
326
+ const before = raws().length;
327
+ fake.input(bytes);
328
+ const sent = Date.now();
329
+ const flushed = flushMs === 0 ? Effect.void : realTime(() => Date.now() - sent >= flushMs);
330
+ return Effect.andThen(flushed, after(before, Date.now()));
331
+ });
332
+ return {
333
+ handle: {
334
+ press: (...keys) => Effect.forEach(keys, (key) => surfaced(Effect.flatMap(bytesOf(key, "press"), (bytes) => send(bytes, key === "escape" ? ESCAPE_FLUSH_MS : 0))), { discard: true }),
335
+ type: (text) => Effect.forEach([...text], (character) => surfaced(send(character)), { discard: true }),
336
+ chunk: (...keys) => surfaced(Effect.flatMap(Effect.forEach(keys, (key) => bytesOf(key, "chunk")), (bytes) => send(bytes.join(""), keys.at(-1) === "escape" ? ESCAPE_FLUSH_MS : 0))),
337
+ resize: (nextColumns, nextRows) => surfaced(Effect.suspend(() => {
338
+ const before = raws().length;
339
+ const since = Date.now();
340
+ fake.resize(nextColumns, nextRows);
341
+ return after(before, since);
342
+ })),
343
+ frame: surfaced(Effect.sync(() => trimLines(styled(raws().at(-1) ?? "")))),
344
+ rawFrame: surfaced(Effect.sync(() => raws().at(-1) ?? "")),
345
+ plainFrame: surfaced(Effect.sync(() => trimLines((raws().at(-1) ?? "").replace(ESCAPES, "")))),
346
+ frames: surfaced(Effect.sync(() => raws().map((raw) => trimLines(styled(raw)))))
347
+ },
348
+ raws,
349
+ after,
350
+ surfaced
351
+ };
352
+ };
353
+ return {
354
+ fake,
355
+ layer,
356
+ captures,
357
+ screen,
358
+ settle
359
+ };
360
+ };
361
+ /**
362
+ * Mount `screen` on a fresh terminal for the enclosing scope, held in a swappable holder so a rerender changes only its
363
+ * subtree under the same control: what `render` and `view` share. Ready once the first frame is drawn, the screen has
364
+ * ended, or 2 s have passed. A crash surfaces on every read and send, as on a session's screen; with `refusal`, so does
365
+ * a run refused as not interactive, for a view, which has no `result` to carry it.
366
+ */
367
+ const mount = (screen, options, refusal) => Effect.gen(function* () {
368
+ const terminal = makeTerminal(options);
369
+ let ended = false;
370
+ let failure;
371
+ const slot = holderSlot();
372
+ let control;
373
+ const held = async (given) => {
374
+ control = given;
375
+ const initial = await screen(given);
376
+ return inkModules().react.createElement(holder(), {
377
+ initial,
378
+ bind: slot.bind
379
+ });
380
+ };
381
+ const fiber = yield* Effect.forkScoped(CliUi.run(held).pipe(Effect.provide(terminal.layer), Effect.onExit((exit) => Effect.sync(() => {
382
+ ended = true;
383
+ if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause) && (exit.cause.reasons.some(Cause.isDieReason) || cancelledReason(Cause.squash(exit.cause)) === void 0)) failure = exit.cause;
384
+ }))));
385
+ const { handle, raws, after, surfaced } = terminal.screen(() => terminal.captures[0], () => ended, refusal ? () => failure : void 0);
386
+ const mountedBy = Date.now() + MOUNT_LIMIT_MS;
387
+ yield* realTime(() => raws().length > 0 || ended || Date.now() >= mountedBy);
388
+ yield* after(0, Date.now());
389
+ const swapTo = (next) => Effect.gen(function* () {
390
+ const mountedBy = Date.now() + MOUNT_LIMIT_MS;
391
+ yield* realTime(() => slot.isBound() || ended || Date.now() >= mountedBy);
392
+ if (ended) return yield* Effect.die(/* @__PURE__ */ new Error(RERENDER_AFTER_END));
393
+ if (!slot.isBound() || control === void 0) return yield* Effect.die(/* @__PURE__ */ new Error(RERENDER_BEFORE_MOUNT));
394
+ const given = control;
395
+ const element = yield* Effect.promise(async () => next(given));
396
+ const before = raws().length;
397
+ const since = Date.now();
398
+ if (ended || !slot.swap(element)) {
399
+ const endedBy = Date.now() + MOUNT_LIMIT_MS;
400
+ yield* realTime(() => ended || Date.now() >= endedBy);
401
+ return yield* surfaced(Effect.die(/* @__PURE__ */ new Error(RERENDER_AFTER_END)));
402
+ }
403
+ yield* after(before, since);
404
+ });
405
+ const rerender = (next) => surfaced(Effect.andThen(swapTo(next), surfaced(Effect.void)));
406
+ return {
407
+ handle,
408
+ rerender,
409
+ fiber,
410
+ surfaced
411
+ };
412
+ });
413
+ /** A `Console` that keeps what is written: `log`, `info` and `debug` as stdout, `error`, `warn` and `trace` as stderr. */
414
+ const capturingConsole = (ambient) => {
415
+ const out = [];
416
+ const err = [];
417
+ const line = (sink) => (...args) => {
418
+ sink.push(`${args.map((arg) => Inspectable.toStringUnknown(arg, 0)).join(" ")}\n`);
419
+ };
420
+ return {
421
+ writer: Object.assign(Object.create(ambient), {
422
+ log: line(out),
423
+ info: line(out),
424
+ debug: line(out),
425
+ error: line(err),
426
+ warn: line(err),
427
+ trace: line(err)
428
+ }),
429
+ out,
430
+ err
431
+ };
432
+ };
433
+ /**
434
+ * Drive and read Ink screens in tests: mount a screen on in-memory streams, press keys, and read its frames as token
435
+ * markup.
436
+ *
437
+ * @example
438
+ * ```ts
439
+ * import { assert, it } from "@effect/vitest"
440
+ * import { Select } from "@effected/cli/ui"
441
+ * import { CliUiTest } from "@effected/cli/ui/testing"
442
+ * import { Effect } from "effect"
443
+ *
444
+ * it.effect("chooses the second option", () =>
445
+ * Effect.gen(function* () {
446
+ * const choices = [
447
+ * { label: "a", value: "a" },
448
+ * { label: "b", value: "b" },
449
+ * ]
450
+ * const handle = yield* CliUiTest.render(Select.screen({ message: "Pick one", choices }))
451
+ * yield* handle.press("down", "enter")
452
+ * assert.strictEqual(yield* handle.result, "b")
453
+ * }).pipe(Effect.scoped),
454
+ * )
455
+ * ```
456
+ *
457
+ * @public
458
+ */
459
+ var CliUiTest = class {
460
+ constructor() {}
461
+ /**
462
+ * Mount `screen` on in-memory terminal streams for the enclosing scope.
463
+ *
464
+ * @remarks
465
+ * The screen runs under a fixed environment: a `TerminalEnv` test layer with the given columns and colour, a
466
+ * `CliTheme` whose every token paints in its own marker colour (so frames do not depend on the real palette), and
467
+ * `CliInteractive` set from `interactive`. Ink renders in debug mode, writing every frame in full; the frames
468
+ * are taken from those writes. Closing the scope unmounts the screen. The returned handle is ready once the first
469
+ * frame is drawn or the screen has ended.
470
+ *
471
+ * Waiting is on real time, through native timers a `TestClock` cannot hold, so the harness's own waits work under
472
+ * `it.effect` and `it.live` alike, and never sleep longer than 50 ms past the last write (30 ms first, for Esc). A
473
+ * screen test that itself sleeps, times out or retries on a schedule needs `it.live` (or real timers): under
474
+ * `it.effect` those run on the `TestClock`, which nothing advances while a screen waits on real time.
475
+ *
476
+ * The first frame is awaited for at most 2 s: a screen that draws nothing for longer gives a handle whose `frames`
477
+ * is `[]`. Screens run one at a time process-wide (`CliUi.run`), so a second handle opened while another is
478
+ * still mounted waits for that mount: it returns at the 2 s cap with no frames, and its keys queue in its input until
479
+ * it mounts.
480
+ *
481
+ * Debug frames bypass Ink's erase-and-redraw path, so a harness frame says nothing about what Ink writes between
482
+ * frames on a real terminal (a screen clear, for instance), nor about the final frame left in the scrollback once
483
+ * the screen ends (answered screens stay); a test of that needs the production render path.
484
+ * For the same reason `CliUi.run`'s `clear` has no visible effect on a harness frame, since Ink's `clear` does
485
+ * nothing in debug mode: test `clear` on the production render path, as the kit's own tests do.
486
+ * Unmounting is the scope's close; to draw a different screen, render it in a new scope.
487
+ *
488
+ * A crash is never swallowed. A screen thunk that throws (a classic-JSX `React is not defined` included) or a
489
+ * component that throws ends the run with a defect: `result` dies with it, and so does the next frame read, key,
490
+ * resize or rerender, so a crashed screen is never read as one that drew nothing. As with `view`, after a crash
491
+ * the frames drawn before it cannot be read. A run refused as not interactive is `result`'s `NotInteractive`, and
492
+ * its frames read as `[]`.
493
+ *
494
+ * @param screen - the screen to mount
495
+ * @param options - the terminal's size, colour and glyphs, and whether the run is interactive
496
+ */
497
+ static render = (screen, options = {}) => Effect.map(mount(screen, options, false), ({ handle, rerender, fiber }) => ({
498
+ ...handle,
499
+ rerender,
500
+ result: Fiber.join(fiber)
501
+ }));
502
+ /**
503
+ * Mount a display-only element on in-memory terminal streams for the enclosing scope: a status line, a live view,
504
+ * a component a consumer mounts in an Ink tree of its own.
505
+ *
506
+ * @remarks
507
+ * The same harness as {@link CliUiTest.render}, with the same options: the marker-palette theme, the fake streams
508
+ * and the debug frames, and the element is drawn inside the kit's providers, so `useTheme`, `useGlyphs`,
509
+ * `useTerminalSize` and `Styled` work in it. The handle reads frames and sends keys as a rendered screen's does, and
510
+ * `rerender` swaps in another element, but it has no `result`: a display-only element never ends on its own, so a
511
+ * `render` of one would leave `result` waiting forever. Closing the scope unmounts it.
512
+ *
513
+ * 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
514
+ * is a defect.
515
+ *
516
+ * An element that crashes, or a run that is refused (`interactive: false` ends it with `NotInteractive`), is never
517
+ * swallowed: `view` dies with that error when it happens before the first frame, and otherwise the next frame read,
518
+ * key, resize or rerender does. After a crash every read dies with it, `frames` included, so the frames drawn before
519
+ * the crash cannot be read: the crash is the signal a test needs. A deliberate end (Esc or Ctrl-C) is not a crash:
520
+ * the frames stay readable, and only a key, resize or rerender after it dies, saying the screen has ended.
521
+ *
522
+ * @param element - the element to mount
523
+ * @param options - the terminal's size, colour and glyphs, and whether the run is interactive
524
+ */
525
+ static view = (element, options = {}) => Effect.flatMap(mount(() => element, options, true), ({ handle, rerender, surfaced }) => {
526
+ const view = {
527
+ ...handle,
528
+ rerender: (next) => rerender(() => next)
529
+ };
530
+ return surfaced(Effect.succeed(view));
531
+ });
532
+ /**
533
+ * A terminal for a whole program that runs screens of its own (a wizard, a handler calling `CliUi.prompt` several
534
+ * times): provide its `layer` around the program, then take each screen as it mounts with `next`.
535
+ *
536
+ * @remarks
537
+ * The same environment and the same waiting as {@link CliUiTest.render}, with the same options. The session also
538
+ * keeps what the program writes through `Console`, its own output beside the screens, as `stdout` and `stderr`.
539
+ *
540
+ * Run the program forked (`Effect.forkScoped`) and drive it from the test: `next` returns each screen once it has
541
+ * mounted and drawn, `press` and `type` settle as they do on a rendered screen, and joining the program's fiber
542
+ * gives its exit. Screens still run one at a time, process-wide, so `next` sees them in the order they mount.
543
+ *
544
+ * As with `render`, a screen run with `clear` leaves its frames unchanged here, and the final scrollback is not
545
+ * captured: Ink renders in debug mode, where its `clear` does nothing, so test `clear` on the production render path.
546
+ * The waits are real time: a session test that itself sleeps or times out needs `it.live`. To assert that no screen
547
+ * mounted (a non-interactive run, a flag that skips a prompt), check that `mounts` is `0` once the program has
548
+ * finished.
549
+ *
550
+ * A screen that crashes (its thunk or a component throws) is never swallowed: `next` dies with the crash when it has
551
+ * happened by then, whatever `contains` waited for, and otherwise the screen's next frame read, key or resize does.
552
+ * The program's own fiber dies with it too.
553
+ *
554
+ * Driving a whole `Command` handler: provide `layer`, a fresh `CliExit.layer` if the handler records a code, a
555
+ * `ConfigProvider` that sandboxes what the handler reads (`HOME`, the XDG directories), and the platform core's
556
+ * runner needs, then fork the program and take each screen with `next`:
557
+ *
558
+ * ```ts
559
+ * const session = yield* CliUiTest.session()
560
+ * const program = Effect.gen(function* () {
561
+ * yield* Command.runWith(root, { version })(["init"])
562
+ * return MutableRef.get((yield* CliExit).code)
563
+ * }).pipe(
564
+ * Effect.provide(session.layer),
565
+ * Effect.provide(CliExit.layer),
566
+ * Effect.provide(NodeServices.layer),
567
+ * Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromUnknown({ HOME: "/sandbox/home" })),
568
+ * )
569
+ * const fiber = yield* Effect.forkScoped(program)
570
+ * yield* (yield* session.next({ contains: "Profile" })).press("enter")
571
+ * const code = yield* Fiber.join(fiber)
572
+ * ```
573
+ *
574
+ * To exercise `CliRuntime.main` as well (its failure report and exit code), run the program through it with the
575
+ * session's layer provided around it instead; `main` provides its own `CliExit`.
576
+ *
577
+ * @param options - the terminal's size, colour and glyphs, and whether the run is interactive
578
+ */
579
+ static session = (options = {}) => Effect.map(Console.Console, (ambient) => {
580
+ const terminal = makeTerminal(options);
581
+ const output = capturingConsole(ambient);
582
+ let taken = 0;
583
+ const next = (nextOptions = {}) => Effect.gen(function* () {
584
+ const index = taken++;
585
+ const { contains } = nextOptions;
586
+ const capture = () => terminal.captures[index];
587
+ const { handle, raws, after, surfaced } = terminal.screen(capture, () => capture()?.ended ?? false);
588
+ const shows = () => contains === void 0 ? raws().length > 0 : raws().some((raw) => raw.replace(ESCAPES, "").includes(contains));
589
+ const by = Date.now() + MOUNT_LIMIT_MS;
590
+ yield* realTime(() => shows() || capture()?.ended === true || Date.now() >= by);
591
+ yield* surfaced(Effect.void);
592
+ if (!shows()) {
593
+ const why = capture() === void 0 ? "none mounted within 2 s" : capture()?.ended === true ? "it unmounted first" : "it did not within 2 s";
594
+ return yield* Effect.die(new Error(NEXT_DIED(index, contains, terminal.captures.length, why)));
595
+ }
596
+ yield* after(0, Date.now());
597
+ return handle;
598
+ });
599
+ return {
600
+ layer: Layer.merge(terminal.layer, Layer.succeed(Console.Console, output.writer)),
601
+ next,
602
+ mounts: Effect.sync(() => terminal.captures.length),
603
+ stdout: Effect.sync(() => output.out.join("")),
604
+ stderr: Effect.sync(() => output.err.join(""))
605
+ };
606
+ });
607
+ /**
608
+ * Mount a live view (`CliUi.live`) on a fresh in-memory terminal for the enclosing scope, with an event stream the
609
+ * test publishes to, and read what it draws on the production render path.
610
+ *
611
+ * @remarks
612
+ * The options are `CliUi.live`'s without `events`, which the harness supplies, and the terminal's own (`columns`,
613
+ * `rows`, `color`, `glyphs`, `interactive`). The view runs as it does for real: Ink is interactive and not in debug
614
+ * mode, so frames are what Ink actually writes, committed frames stay on the terminal, and `transcript` shows what is
615
+ * left there, scrollback included, through a small terminal model (it does not wrap a line wider than the terminal).
616
+ * stderr is the same stream as stdout, as on a terminal, so a line logged through `handle.logConsole` lands in the
617
+ * transcript too.
618
+ *
619
+ * Write live tests with `it.effect`: the view's tick runs on the `TestClock`, so `advance` (or `TestClock.adjust`)
620
+ * drives it frame by frame, and the frame index is `floor(now / tickMillis)` from the clock's epoch. The waits after
621
+ * `publish`, `advance` and `resize` are real time, which the `TestClock` does not hold. Without `@effect/vitest`'s
622
+ * `it.effect`, provide the clock yourself: `Effect.provide(test, TestClock.layer())` (from `effect/testing`).
623
+ *
624
+ * @example
625
+ * ```ts
626
+ * import { assert, it } from "@effect/vitest"
627
+ * import { CliUiTest } from "@effected/cli/ui/testing"
628
+ * import { Effect } from "effect"
629
+ * import { Text } from "ink"
630
+ * import { createElement } from "react"
631
+ *
632
+ * type Event = { readonly _tag: "RunStarted" } | { readonly _tag: "RunEnded" }
633
+ *
634
+ * it.effect("turns the spinner on the tick", () =>
635
+ * Effect.gen(function* () {
636
+ * const view = yield* CliUiTest.live({
637
+ * initial: 0,
638
+ * reduce: (count: number, _event: Event) => count + 1,
639
+ * render: (_count, frame) => createElement(Text, null, `frame ${frame}`),
640
+ * isStart: (event) => event._tag === "RunStarted",
641
+ * isTerminal: (event) => event._tag === "RunEnded",
642
+ * })
643
+ * yield* view.publish({ _tag: "RunStarted" })
644
+ * // The clock starts at 0 and the tick is 80 ms, so 160 ms on is frame 2.
645
+ * yield* view.advance("160 millis")
646
+ * assert.include(yield* view.plainFrame, "frame 2")
647
+ * }).pipe(Effect.scoped),
648
+ * )
649
+ * ```
650
+ *
651
+ * @param options - the live view's options without `events`, and the terminal's size, colour, glyphs and
652
+ * interactivity
653
+ */
654
+ static live = (options) => Effect.gen(function* () {
655
+ const { columns, rows, color, glyphs, interactive, ...view } = options;
656
+ const terminal = makeTerminal({
657
+ ...columns === void 0 ? {} : { columns },
658
+ ...rows === void 0 ? {} : { rows },
659
+ ...color === void 0 ? {} : { color },
660
+ ...glyphs === void 0 ? {} : { glyphs },
661
+ ...interactive === void 0 ? {} : { interactive }
662
+ }, "production");
663
+ const queue = yield* Queue.unbounded();
664
+ const handle = yield* CliUi.live({
665
+ ...view,
666
+ events: Stream.fromQueue(queue)
667
+ }).pipe(Effect.provide(terminal.layer));
668
+ const raws = () => terminal.captures.flatMap((capture) => capture.raws);
669
+ const settled = (effect) => Effect.suspend(() => {
670
+ const before = raws().length;
671
+ const since = Date.now();
672
+ return Effect.andThen(effect, terminal.settle(raws, () => false, before, since));
673
+ });
674
+ const last = () => raws().at(-1) ?? "";
675
+ return {
676
+ publish: (event) => settled(Queue.offer(queue, event)),
677
+ end: Effect.andThen(Queue.end(queue), handle.done),
678
+ advance: (duration) => settled(TestClock.adjust(duration)),
679
+ resize: (nextColumns, nextRows) => settled(Effect.sync(() => terminal.fake.resize(nextColumns, nextRows))),
680
+ frame: Effect.sync(() => trimLines(styled(last()))),
681
+ rawFrame: Effect.sync(last),
682
+ plainFrame: Effect.sync(() => trimLines(last().replace(ESCAPES, ""))),
683
+ frames: Effect.sync(() => raws().map((raw) => trimLines(styled(raw)))),
684
+ transcript: Effect.sync(() => screenAfter(terminal.fake.stdout(), terminal.fake.streams.stdout.rows).join("\n")),
685
+ written: Effect.sync(() => terminal.fake.stdout()),
686
+ handle
687
+ };
688
+ });
689
+ /**
690
+ * Why a screen or a program was cancelled, read from its `Exit` or `Cause`: `"escape"` or `"interrupt"` when it
691
+ * carries a `Cancelled`, as a typed failure or as a defect, else `None`.
692
+ *
693
+ * @remarks
694
+ * Pure. A screen's `result` fails with `Cancelled` in the typed channel; a prompt cancelled where the program
695
+ * declares no such error carries it as a defect. Either way a test asks this instead of walking `cause.reasons`:
696
+ *
697
+ * ```ts
698
+ * const exit = yield* Fiber.await(program)
699
+ * assert.deepStrictEqual(CliUiTest.cancelReason(exit), Option.some("escape"))
700
+ * ```
701
+ *
702
+ * @param exitOrCause - the exit of a screen's `result` or of a program, or a bare cause
703
+ */
704
+ static cancelReason = (exitOrCause) => {
705
+ const cause = Exit.isExit(exitOrCause) ? Exit.isFailure(exitOrCause) ? exitOrCause.cause : void 0 : exitOrCause;
706
+ for (const reason of cause?.reasons ?? []) {
707
+ const found = cancelledReason(reason._tag === "Fail" ? reason.error : reason._tag === "Die" ? reason.defect : void 0);
708
+ if (found !== void 0) return Option.some(found);
709
+ }
710
+ return Option.none();
711
+ };
712
+ /**
713
+ * Decode ANSI back to markup: a marker colour to its token (`[success]…[/success]`), any other foreground to
714
+ * `[fg:red]` or `[fg:#ff0000]` (backgrounds likewise as `[bg:…]`), bold to `[b]`, dim to `[dim]`, italic, underline,
715
+ * inverse and strikethrough to `[i]`, `[u]`, `[inverse]` and `[s]`. A reset closes everything open; every other
716
+ * escape (cursor, erase, hyperlinks) is dropped.
717
+ *
718
+ * @param ansi - text with escapes
719
+ */
720
+ static styled = styled;
721
+ /**
722
+ * A Vitest snapshot serializer. It claims a string carrying escapes, a token tag opened and closed, or a colour
723
+ * tag (a raw or styled frame, or a `Render.ansi` string), but not a log line's lone `[info]` prefix, nor one whose
724
+ * only brackets are style tags like `[b]`, which unrelated data uses too. It prints the string as token markup with
725
+ * each line's trailing spaces trimmed, so a snapshot reads without escapes and does not churn with the palette.
726
+ * Register it with `expect.addSnapshotSerializer`.
727
+ */
728
+ static serializer = {
729
+ test: (value) => typeof value === "string" && (value.includes("\x1B") || MARKUP.test(value)),
730
+ serialize: (value) => trimLines(styled(String(value)))
731
+ };
732
+ };
733
+
734
+ //#endregion
735
+ export { CliUiTest };