@effected/cli 0.10.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 (89) 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 +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 +103 -53
  14. package/CliTest.js +16 -0
  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/ansi.js +230 -0
  31. package/internal/autoFormat.js +34 -0
  32. package/internal/canPrompt.js +15 -0
  33. package/internal/counts.js +69 -0
  34. package/internal/diagnostics.js +32 -0
  35. package/internal/displayWidth.js +35 -0
  36. package/internal/failureTarget.js +156 -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 +319 -0
  46. package/internal/renderGithubLog.js +46 -0
  47. package/internal/renderMarkdown.js +367 -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 +35 -5
  53. package/testing.d.ts +88 -2
  54. package/testing.js +2 -1
  55. package/ui/CliUi.js +348 -0
  56. package/ui/CliUiLive.js +399 -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 +226 -0
  63. package/ui/Tabs.js +202 -0
  64. package/ui/TextInput.js +250 -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/lineText.js +19 -0
  79. package/ui/internal/mountPermit.js +16 -0
  80. package/ui/internal/perfDrain.js +33 -0
  81. package/ui/internal/processStreams.js +19 -0
  82. package/ui/internal/renderOptions.js +13 -0
  83. package/ui/testing/CliUiTest.js +735 -0
  84. package/ui/testing/fakeStreams.js +76 -0
  85. package/ui/testing/terminalModel.js +59 -0
  86. package/ui-testing.d.ts +446 -0
  87. package/ui-testing.js +3 -0
  88. package/ui.d.ts +1648 -0
  89. package/ui.js +17 -0
@@ -0,0 +1,76 @@
1
+ import { PassThrough, Writable } from "node:stream";
2
+
3
+ //#region src/ui/testing/fakeStreams.ts
4
+ const capture = (columns, rows, onWrite) => {
5
+ const chunks = [];
6
+ const stream = new Writable({ write(chunk, _encoding, callback) {
7
+ const text = chunk.toString();
8
+ chunks.push(text);
9
+ onWrite?.(text);
10
+ callback();
11
+ } });
12
+ Object.assign(stream, {
13
+ isTTY: true,
14
+ columns,
15
+ rows
16
+ });
17
+ return {
18
+ stream,
19
+ text: () => chunks.join("")
20
+ };
21
+ };
22
+ /**
23
+ * Make in-memory stdin, stdout and stderr that satisfy Ink's stream contract: TTYs with a size, a recorded
24
+ * `setRawMode`, `ref` and `unref`, and captured writes.
25
+ *
26
+ * @remarks
27
+ * The third file licensed to touch Node, testing only: Ink's stream
28
+ * contract is Node's, so the fakes are `node:stream` streams rather than a hand-rolled emitter that would have to
29
+ * reproduce `readable`, `read()`, `setEncoding` and the write-callback barrier Ink waits on at unmount.
30
+ *
31
+ * @internal
32
+ */
33
+ const makeFakeStreams = (options = {}) => {
34
+ const columns = options.columns ?? 80;
35
+ const rows = options.rows ?? 24;
36
+ const rawModes = [];
37
+ const stdin = new PassThrough();
38
+ Object.assign(stdin, {
39
+ isTTY: true,
40
+ setRawMode: (mode) => {
41
+ rawModes.push(mode);
42
+ return stdin;
43
+ },
44
+ ref: () => stdin,
45
+ unref: () => stdin
46
+ });
47
+ const stdout = capture(columns, rows, options.onStdoutWrite);
48
+ const stderr = capture(columns, rows);
49
+ return {
50
+ streams: {
51
+ stdin,
52
+ stdout: stdout.stream,
53
+ stderr: stderr.stream
54
+ },
55
+ rawModes,
56
+ stdout: stdout.text,
57
+ stderr: stderr.text,
58
+ input: (data) => {
59
+ stdin.write(data);
60
+ },
61
+ resize: (nextColumns, nextRows) => {
62
+ Object.assign(stdout.stream, {
63
+ columns: nextColumns,
64
+ rows: nextRows
65
+ });
66
+ Object.assign(stderr.stream, {
67
+ columns: nextColumns,
68
+ rows: nextRows
69
+ });
70
+ stdout.stream.emit("resize");
71
+ }
72
+ };
73
+ };
74
+
75
+ //#endregion
76
+ export { makeFakeStreams };
@@ -0,0 +1,59 @@
1
+ //#region src/ui/testing/terminalModel.ts
2
+ const ESC = String.fromCharCode(27);
3
+ /**
4
+ * A small terminal model for the production render path: what a terminal shows after `written`, as its non-empty
5
+ * lines.
6
+ *
7
+ * @remarks
8
+ * It applies printable text, line feeds, the erase and cursor moves Ink's log-update writes (erase line, cursor up,
9
+ * cursor to column one), and the clears of Ink's clear-terminal frame: `ESC[2J` blanks the visible screen, `ESC[3J`
10
+ * drops the scrollback above it, `ESC[H` homes the cursor to the screen's top left. The visible screen is the last
11
+ * `rows` lines; with `rows` unknown, the whole buffer counts as the screen, so a clear takes everything. Every other
12
+ * escape is ignored. It does not wrap a line wider than the terminal. Shared by `CliUiTest.live`'s transcript and the
13
+ * kit's own production-path tests.
14
+ *
15
+ * @param written - every byte written to the terminal
16
+ * @param rows - the terminal's height, which decides how much of the buffer a clear takes as the screen
17
+ *
18
+ * @internal
19
+ */
20
+ const screenAfter = (written, rows) => {
21
+ const lines = [""];
22
+ let row = 0;
23
+ let column = 0;
24
+ const screenTop = () => rows === void 0 ? 0 : Math.max(0, lines.length - rows);
25
+ const sequence = new RegExp(`${ESC}\\[([0-9;?]*)([A-Za-z])|${ESC}\\][^\\u0007]*\\u0007|([\\s\\S])`, "g");
26
+ for (const match of written.matchAll(sequence)) {
27
+ const [, params, command, character] = match;
28
+ if (character !== void 0) {
29
+ if (character === "\n") {
30
+ row++;
31
+ column = 0;
32
+ while (lines.length <= row) lines.push("");
33
+ } else if (character === "\r") column = 0;
34
+ else if (character >= " ") {
35
+ const line = lines[row] ?? "";
36
+ lines[row] = `${line.slice(0, column).padEnd(column)}${character}${line.slice(column + 1)}`;
37
+ column++;
38
+ }
39
+ } else if (command === "K") lines[row] = "";
40
+ else if (command === "A") row = Math.max(0, row - Number(params === "" ? 1 : params));
41
+ else if (command === "G") column = 0;
42
+ else if (command === "J" && params === "2") for (let index = screenTop(); index < lines.length; index++) lines[index] = "";
43
+ else if (command === "J" && params === "3") {
44
+ const top = screenTop();
45
+ lines.splice(0, top);
46
+ row = Math.max(0, row - top);
47
+ if (lines.length === 0) lines.push("");
48
+ } else if (command === "H") {
49
+ const [line, col] = params === "" ? [1, 1] : params.split(";").map((part) => Number(part === "" ? 1 : part));
50
+ row = screenTop() + (line ?? 1) - 1;
51
+ column = (col ?? 1) - 1;
52
+ while (lines.length <= row) lines.push("");
53
+ }
54
+ }
55
+ return lines.map((line) => line.trimEnd()).filter((line) => line !== "");
56
+ };
57
+
58
+ //#endregion
59
+ export { screenAfter };
@@ -0,0 +1,446 @@
1
+ import * as Cli from "@effected/cli";
2
+ import { KeyName, LiveHandle, LiveOptions, Screen } from "@effected/cli/ui";
3
+ import { ColorLevel } from "@effected/env";
4
+ import { Cause, Duration, Effect, Exit, Layer, Option, Scope } from "effect";
5
+ import { ReactElement } from "react";
6
+ //#region src/ui/testing/CliUiTest.d.ts
7
+ /**
8
+ * Options for {@link CliUiTest.render}, {@link CliUiTest.view} and {@link CliUiTest.session}, and the terminal's
9
+ * half of {@link CliUiTest.live}'s.
10
+ *
11
+ * @public
12
+ */
13
+ interface CliUiTestOptions {
14
+ /** The terminal width the screen lays out at; 80 by default. */
15
+ readonly columns?: number;
16
+ /** The terminal height; 24 by default. */
17
+ readonly rows?: number;
18
+ /**
19
+ * The colour level of both streams; `"truecolor"` by default, so frames carry the marker palette that
20
+ * {@link CliUiTest.styled} decodes to token markup. `"none"` gives escape-free frames.
21
+ *
22
+ * @remarks
23
+ * Only truecolor keeps tokens apart. Below it, chalk maps every marker `#0000NN` to the same colour
24
+ * (`ansi256(16)` at `"256"`, black at `"basic"`), so token identity is lost; do not snapshot tokens from such a run.
25
+ */
26
+ readonly color?: ColorLevel;
27
+ /** The glyph set; Unicode by default. */
28
+ readonly glyphs?: "unicode" | "ascii";
29
+ /**
30
+ * Whether the run is interactive; `true` by default. `false` makes a screen fail with `NotInteractive`, and makes a
31
+ * live view print each run's final frame as a string instead of mounting it.
32
+ */
33
+ readonly interactive?: boolean;
34
+ }
35
+ /**
36
+ * A screen under test: drive it with keys and read its frames.
37
+ *
38
+ * @public
39
+ */
40
+ interface CliUiTestScreen {
41
+ /**
42
+ * Press named keys, one after another. Each waits until the screen draws its next frame or 50 ms pass with
43
+ * nothing written; `"escape"` first waits a real 30 ms, because Ink holds a lone ESC for 20 ms before reporting
44
+ * it. Pressing, typing or chunking on a screen that has ended is a defect, not a key for whatever screen is mounted
45
+ * now.
46
+ *
47
+ * @remarks
48
+ * A `{ char }` item sends its text as typed, as `chunk` does, settling like a key: `press({ char: "n" }, "enter")`.
49
+ * A bare string that is not a key name (`press("n")`) is a defect naming `type("n")` and `{ char: "n" }`, since a
50
+ * letter is not a `KeyName`.
51
+ */
52
+ readonly press: (...keys: ReadonlyArray<KeyName | {
53
+ readonly char: string;
54
+ }>) => Effect.Effect<void>;
55
+ /** Type text, one character at a time, each settling like a key. */
56
+ readonly type: (text: string) => Effect.Effect<void>;
57
+ /**
58
+ * Press named keys together: all their bytes in ONE stdin write, then one settle, as a fast typist, a held key or
59
+ * a batching terminal delivers them.
60
+ *
61
+ * @remarks
62
+ * Ink dispatches every key of one read before React re-renders, so a handler that steps from state its render
63
+ * captured repeats the first key's move; {@link CliUiTestScreen.press}, which writes and settles key by key, can
64
+ * never show that. Use `chunk` to test a key handler against it. An `"escape"` inside a chunk joins the bytes after
65
+ * it, as on a real terminal; only a trailing one waits out Ink's ESC hold. A `{ char }` writes its text as typed:
66
+ * Ink hands text read in one go to `useInput` as one string, `"yy"` or `"y\r"`, which `useKeys` splits into keys.
67
+ */
68
+ readonly chunk: (...keys: ReadonlyArray<KeyName | {
69
+ readonly char: string;
70
+ }>) => Effect.Effect<void>;
71
+ /** Resize the terminal and emit `resize`, as a real one does, settling like a key. */
72
+ readonly resize: (columns: number, rows: number) => Effect.Effect<void>;
73
+ /** The latest frame as token markup ({@link CliUiTest.styled}), each line's trailing spaces trimmed. */
74
+ readonly frame: Effect.Effect<string>;
75
+ /** The latest frame as Ink wrote it, escapes included. */
76
+ readonly rawFrame: Effect.Effect<string>;
77
+ /** The latest frame as plain text: no escapes and no markup, each line's trailing spaces trimmed. */
78
+ readonly plainFrame: Effect.Effect<string>;
79
+ /** Every frame of this screen so far, oldest first, as token markup like {@link CliUiTestScreen.frame}. */
80
+ readonly frames: Effect.Effect<ReadonlyArray<string>>;
81
+ }
82
+ /**
83
+ * A screen mounted by {@link CliUiTest.render}: a {@link CliUiTestScreen} that can also be swapped and awaited.
84
+ *
85
+ * @public
86
+ */
87
+ interface CliUiTestHandle<A> extends CliUiTestScreen {
88
+ /**
89
+ * Show another screen in place of the current one, settling like a key.
90
+ *
91
+ * @remarks
92
+ * `screen` is called with the same `ScreenControl` the first screen got, so `result` still resolves through it,
93
+ * and only the screen's own subtree is swapped: the error boundary, root keys and colour hold stay mounted. Ink's
94
+ * own `rerender` is never used. A rerender after the screen has ended is a defect, not a no-op, because a test
95
+ * that does it has lost track of the screen; so is one on a screen that has not mounted within 2 s, such as a
96
+ * handle queued behind another mounted screen.
97
+ */
98
+ readonly rerender: (screen: Screen<A>) => Effect.Effect<void>;
99
+ /** How the screen ended: its value, or `Cancelled` or `NotInteractive`. Waits for it to end. */
100
+ readonly result: Effect.Effect<A, Cli.Cancelled | Cli.NotInteractive>;
101
+ }
102
+ /**
103
+ * A display-only element mounted by {@link CliUiTest.view}: a {@link CliUiTestScreen} that can be swapped for another
104
+ * element, with no result to wait for.
105
+ *
106
+ * @public
107
+ */
108
+ interface CliUiTestView extends CliUiTestScreen {
109
+ /**
110
+ * Show another element in place of the current one, settling like a key.
111
+ *
112
+ * @remarks
113
+ * Only the element's subtree is swapped: the kit's providers stay mounted. A rerender after the view has ended
114
+ * (Esc or Ctrl-C ends it, as on every screen) is a defect, not a no-op.
115
+ */
116
+ readonly rerender: (element: ReactElement) => Effect.Effect<void>;
117
+ }
118
+ /**
119
+ * Options for {@link CliUiTestSession.next}.
120
+ *
121
+ * @public
122
+ */
123
+ interface CliUiTestNextOptions {
124
+ /**
125
+ * Text the screen must have shown, in plain text, before `next` returns it: in any frame since its mount, not
126
+ * necessarily the latest one. Read `plainFrame` to check what it shows now.
127
+ */
128
+ readonly contains?: string;
129
+ }
130
+ /**
131
+ * A terminal a whole program runs its screens on, from {@link CliUiTest.session}.
132
+ *
133
+ * @public
134
+ */
135
+ interface CliUiTestSession {
136
+ /**
137
+ * Provide it around the program: in-memory terminal streams, the marker-palette `CliTheme`, `CliInteractive`
138
+ * from the session's options, frame capture, and a `Console` whose writes the session keeps.
139
+ *
140
+ * @remarks
141
+ * Anything the program provides closer to the screens wins: under `CliRuntime.main` with `env`, `CliEnv.layer`
142
+ * supplies the theme and decides interactivity, as it does for real, and the session keeps only the streams, the
143
+ * capture and the console.
144
+ */
145
+ readonly layer: Layer.Layer<Cli.CliTheme>;
146
+ /**
147
+ * Wait for the next screen to mount and draw (and, with `contains`, to show that text), and return it.
148
+ *
149
+ * @remarks
150
+ * Each call takes the next mount in order, counting every mount since the session began, so a screen that mounted
151
+ * before the call is not missed. It waits at most 2 s, then dies naming the screen's number, the text it waited
152
+ * for and how many screens had mounted. The returned screen's frames start at its own mount. A screen that crashed
153
+ * makes `next` die with the crash instead.
154
+ */
155
+ readonly next: (options?: CliUiTestNextOptions) => Effect.Effect<CliUiTestScreen>;
156
+ /**
157
+ * How many screens have mounted so far: every run that started mounting, one whose thunk threw before Ink drew
158
+ * included.
159
+ */
160
+ readonly mounts: Effect.Effect<number>;
161
+ /** What the program wrote to stdout through `Console` (`log`, `info`, `debug`), one line per call. */
162
+ readonly stdout: Effect.Effect<string>;
163
+ /** What the program wrote to stderr through `Console` (`error`, `warn`, `trace`), one line per call. */
164
+ readonly stderr: Effect.Effect<string>;
165
+ }
166
+ /**
167
+ * A live view mounted by {@link CliUiTest.live}: its event stream to publish to, and its output on the production
168
+ * render path.
169
+ *
170
+ * @public
171
+ */
172
+ interface CliUiTestLive<E, S> {
173
+ /**
174
+ * Publish one event to the view's stream, then wait as a key press does: until the view draws its next frame and a
175
+ * short quiet follows, or 50 ms pass with nothing drawn.
176
+ */
177
+ readonly publish: (event: E) => Effect.Effect<void>;
178
+ /** End the event stream, then wait for the view to finish (`handle.done`): it dies with what the view died of. */
179
+ readonly end: Effect.Effect<void>;
180
+ /**
181
+ * Move the `TestClock` on by `duration`, which fires the view's tick, then wait as `publish` does. The test runs
182
+ * under `it.effect`, whose clock is a `TestClock`; under `it.live` there is no `TestClock` to move and `advance`
183
+ * dies, while the view's own tick runs on real time.
184
+ */
185
+ readonly advance: (duration: Duration.Input) => Effect.Effect<void>;
186
+ /** Resize the terminal, then wait as `publish` does. */
187
+ readonly resize: (columns: number, rows: number) => Effect.Effect<void>;
188
+ /**
189
+ * The last frame drawn, as token markup (see {@link CliUiTest.styled}); empty before the first.
190
+ *
191
+ * @remarks
192
+ * Frames are best-effort: each is the write Ink makes after a render, so a render whose output is unchanged, or
193
+ * empty, adds none, and a frame printed as a string (not interactive, or a degraded run) is not one. `transcript`
194
+ * and `written` are the authority on what reached the terminal.
195
+ */
196
+ readonly frame: Effect.Effect<string>;
197
+ /** The last frame drawn, as written: with its escape sequences. */
198
+ readonly rawFrame: Effect.Effect<string>;
199
+ /** The last frame drawn, as plain text. */
200
+ readonly plainFrame: Effect.Effect<string>;
201
+ /**
202
+ * The frames drawn, across every run, as token markup, oldest first: best-effort, as `frame` says (an unchanged or
203
+ * empty render adds none, and a printed string is not a frame); `transcript` and `written` are the authority.
204
+ */
205
+ readonly frames: Effect.Effect<ReadonlyArray<string>>;
206
+ /**
207
+ * What the terminal shows now, scrollback included, as plain text: every committed frame, every line logged above a
208
+ * frame, every frame printed as a string, and the frame drawn now, with Ink's erases and clears applied (a
209
+ * scrollback wipe shows as the loss of what was above the frame), each line's trailing spaces trimmed and blank
210
+ * lines left out. For assertions about what stays on the terminal. With `interactive: false`, the frames printed
211
+ * as strings show here and in `written`, and nowhere else.
212
+ */
213
+ readonly transcript: Effect.Effect<string>;
214
+ /**
215
+ * Every byte written to the terminal, escapes included: what to assert a sequence on, such as no `ESC[3J`
216
+ * (a scrollback wipe) anywhere in a run.
217
+ */
218
+ readonly written: Effect.Effect<string>;
219
+ /** The view's own handle: its `state`, its `logConsole`, `done` and `close`. */
220
+ readonly handle: LiveHandle<S>;
221
+ }
222
+ /**
223
+ * Drive and read Ink screens in tests: mount a screen on in-memory streams, press keys, and read its frames as token
224
+ * markup.
225
+ *
226
+ * @example
227
+ * ```ts
228
+ * import { assert, it } from "@effect/vitest"
229
+ * import { Select } from "@effected/cli/ui"
230
+ * import { CliUiTest } from "@effected/cli/ui/testing"
231
+ * import { Effect } from "effect"
232
+ *
233
+ * it.effect("chooses the second option", () =>
234
+ * Effect.gen(function* () {
235
+ * const choices = [
236
+ * { label: "a", value: "a" },
237
+ * { label: "b", value: "b" },
238
+ * ]
239
+ * const handle = yield* CliUiTest.render(Select.screen({ message: "Pick one", choices }))
240
+ * yield* handle.press("down", "enter")
241
+ * assert.strictEqual(yield* handle.result, "b")
242
+ * }).pipe(Effect.scoped),
243
+ * )
244
+ * ```
245
+ *
246
+ * @public
247
+ */
248
+ export declare class CliUiTest {
249
+ private constructor();
250
+ /**
251
+ * Mount `screen` on in-memory terminal streams for the enclosing scope.
252
+ *
253
+ * @remarks
254
+ * The screen runs under a fixed environment: a `TerminalEnv` test layer with the given columns and colour, a
255
+ * `CliTheme` whose every token paints in its own marker colour (so frames do not depend on the real palette), and
256
+ * `CliInteractive` set from `interactive`. Ink renders in debug mode, writing every frame in full; the frames
257
+ * are taken from those writes. Closing the scope unmounts the screen. The returned handle is ready once the first
258
+ * frame is drawn or the screen has ended.
259
+ *
260
+ * Waiting is on real time, through native timers a `TestClock` cannot hold, so the harness's own waits work under
261
+ * `it.effect` and `it.live` alike, and never sleep longer than 50 ms past the last write (30 ms first, for Esc). A
262
+ * screen test that itself sleeps, times out or retries on a schedule needs `it.live` (or real timers): under
263
+ * `it.effect` those run on the `TestClock`, which nothing advances while a screen waits on real time.
264
+ *
265
+ * The first frame is awaited for at most 2 s: a screen that draws nothing for longer gives a handle whose `frames`
266
+ * is `[]`. Screens run one at a time process-wide (`CliUi.run`), so a second handle opened while another is
267
+ * still mounted waits for that mount: it returns at the 2 s cap with no frames, and its keys queue in its input until
268
+ * it mounts.
269
+ *
270
+ * Debug frames bypass Ink's erase-and-redraw path, so a harness frame says nothing about what Ink writes between
271
+ * frames on a real terminal (a screen clear, for instance), nor about the final frame left in the scrollback once
272
+ * the screen ends (answered screens stay); a test of that needs the production render path.
273
+ * For the same reason `CliUi.run`'s `clear` has no visible effect on a harness frame, since Ink's `clear` does
274
+ * nothing in debug mode: test `clear` on the production render path, as the kit's own tests do.
275
+ * Unmounting is the scope's close; to draw a different screen, render it in a new scope.
276
+ *
277
+ * A crash is never swallowed. A screen thunk that throws (a classic-JSX `React is not defined` included) or a
278
+ * component that throws ends the run with a defect: `result` dies with it, and so does the next frame read, key,
279
+ * resize or rerender, so a crashed screen is never read as one that drew nothing. As with `view`, after a crash
280
+ * the frames drawn before it cannot be read. A run refused as not interactive is `result`'s `NotInteractive`, and
281
+ * its frames read as `[]`.
282
+ *
283
+ * @param screen - the screen to mount
284
+ * @param options - the terminal's size, colour and glyphs, and whether the run is interactive
285
+ */
286
+ static readonly render: <A>(screen: Screen<A>, options?: CliUiTestOptions) => Effect.Effect<CliUiTestHandle<A>, never, Scope.Scope>;
287
+ /**
288
+ * Mount a display-only element on in-memory terminal streams for the enclosing scope: a status line, a live view,
289
+ * a component a consumer mounts in an Ink tree of its own.
290
+ *
291
+ * @remarks
292
+ * The same harness as {@link CliUiTest.render}, with the same options: the marker-palette theme, the fake streams
293
+ * and the debug frames, and the element is drawn inside the kit's providers, so `useTheme`, `useGlyphs`,
294
+ * `useTerminalSize` and `Styled` work in it. The handle reads frames and sends keys as a rendered screen's does, and
295
+ * `rerender` swaps in another element, but it has no `result`: a display-only element never ends on its own, so a
296
+ * `render` of one would leave `result` waiting forever. Closing the scope unmounts it.
297
+ *
298
+ * 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
299
+ * is a defect.
300
+ *
301
+ * An element that crashes, or a run that is refused (`interactive: false` ends it with `NotInteractive`), is never
302
+ * swallowed: `view` dies with that error when it happens before the first frame, and otherwise the next frame read,
303
+ * key, resize or rerender does. After a crash every read dies with it, `frames` included, so the frames drawn before
304
+ * the crash cannot be read: the crash is the signal a test needs. A deliberate end (Esc or Ctrl-C) is not a crash:
305
+ * the frames stay readable, and only a key, resize or rerender after it dies, saying the screen has ended.
306
+ *
307
+ * @param element - the element to mount
308
+ * @param options - the terminal's size, colour and glyphs, and whether the run is interactive
309
+ */
310
+ static readonly view: (element: ReactElement, options?: CliUiTestOptions) => Effect.Effect<CliUiTestView, never, Scope.Scope>;
311
+ /**
312
+ * A terminal for a whole program that runs screens of its own (a wizard, a handler calling `CliUi.prompt` several
313
+ * times): provide its `layer` around the program, then take each screen as it mounts with `next`.
314
+ *
315
+ * @remarks
316
+ * The same environment and the same waiting as {@link CliUiTest.render}, with the same options. The session also
317
+ * keeps what the program writes through `Console`, its own output beside the screens, as `stdout` and `stderr`.
318
+ *
319
+ * Run the program forked (`Effect.forkScoped`) and drive it from the test: `next` returns each screen once it has
320
+ * mounted and drawn, `press` and `type` settle as they do on a rendered screen, and joining the program's fiber
321
+ * gives its exit. Screens still run one at a time, process-wide, so `next` sees them in the order they mount.
322
+ *
323
+ * As with `render`, a screen run with `clear` leaves its frames unchanged here, and the final scrollback is not
324
+ * captured: Ink renders in debug mode, where its `clear` does nothing, so test `clear` on the production render path.
325
+ * The waits are real time: a session test that itself sleeps or times out needs `it.live`. To assert that no screen
326
+ * mounted (a non-interactive run, a flag that skips a prompt), check that `mounts` is `0` once the program has
327
+ * finished.
328
+ *
329
+ * A screen that crashes (its thunk or a component throws) is never swallowed: `next` dies with the crash when it has
330
+ * happened by then, whatever `contains` waited for, and otherwise the screen's next frame read, key or resize does.
331
+ * The program's own fiber dies with it too.
332
+ *
333
+ * Driving a whole `Command` handler: provide `layer`, a fresh `CliExit.layer` if the handler records a code, a
334
+ * `ConfigProvider` that sandboxes what the handler reads (`HOME`, the XDG directories), and the platform core's
335
+ * runner needs, then fork the program and take each screen with `next`:
336
+ *
337
+ * ```ts
338
+ * const session = yield* CliUiTest.session()
339
+ * const program = Effect.gen(function* () {
340
+ * yield* Command.runWith(root, { version })(["init"])
341
+ * return MutableRef.get((yield* CliExit).code)
342
+ * }).pipe(
343
+ * Effect.provide(session.layer),
344
+ * Effect.provide(CliExit.layer),
345
+ * Effect.provide(NodeServices.layer),
346
+ * Effect.provideService(ConfigProvider.ConfigProvider, ConfigProvider.fromUnknown({ HOME: "/sandbox/home" })),
347
+ * )
348
+ * const fiber = yield* Effect.forkScoped(program)
349
+ * yield* (yield* session.next({ contains: "Profile" })).press("enter")
350
+ * const code = yield* Fiber.join(fiber)
351
+ * ```
352
+ *
353
+ * To exercise `CliRuntime.main` as well (its failure report and exit code), run the program through it with the
354
+ * session's layer provided around it instead; `main` provides its own `CliExit`.
355
+ *
356
+ * @param options - the terminal's size, colour and glyphs, and whether the run is interactive
357
+ */
358
+ static readonly session: (options?: CliUiTestOptions) => Effect.Effect<CliUiTestSession, never, Scope.Scope>;
359
+ /**
360
+ * Mount a live view (`CliUi.live`) on a fresh in-memory terminal for the enclosing scope, with an event stream the
361
+ * test publishes to, and read what it draws on the production render path.
362
+ *
363
+ * @remarks
364
+ * The options are `CliUi.live`'s without `events`, which the harness supplies, and the terminal's own (`columns`,
365
+ * `rows`, `color`, `glyphs`, `interactive`). The view runs as it does for real: Ink is interactive and not in debug
366
+ * mode, so frames are what Ink actually writes, committed frames stay on the terminal, and `transcript` shows what is
367
+ * left there, scrollback included, through a small terminal model (it does not wrap a line wider than the terminal).
368
+ * stderr is the same stream as stdout, as on a terminal, so a line logged through `handle.logConsole` lands in the
369
+ * transcript too.
370
+ *
371
+ * Write live tests with `it.effect`: the view's tick runs on the `TestClock`, so `advance` (or `TestClock.adjust`)
372
+ * drives it frame by frame, and the frame index is `floor(now / tickMillis)` from the clock's epoch. The waits after
373
+ * `publish`, `advance` and `resize` are real time, which the `TestClock` does not hold. Without `@effect/vitest`'s
374
+ * `it.effect`, provide the clock yourself: `Effect.provide(test, TestClock.layer())` (from `effect/testing`).
375
+ *
376
+ * @example
377
+ * ```ts
378
+ * import { assert, it } from "@effect/vitest"
379
+ * import { CliUiTest } from "@effected/cli/ui/testing"
380
+ * import { Effect } from "effect"
381
+ * import { Text } from "ink"
382
+ * import { createElement } from "react"
383
+ *
384
+ * type Event = { readonly _tag: "RunStarted" } | { readonly _tag: "RunEnded" }
385
+ *
386
+ * it.effect("turns the spinner on the tick", () =>
387
+ * Effect.gen(function* () {
388
+ * const view = yield* CliUiTest.live({
389
+ * initial: 0,
390
+ * reduce: (count: number, _event: Event) => count + 1,
391
+ * render: (_count, frame) => createElement(Text, null, `frame ${frame}`),
392
+ * isStart: (event) => event._tag === "RunStarted",
393
+ * isTerminal: (event) => event._tag === "RunEnded",
394
+ * })
395
+ * yield* view.publish({ _tag: "RunStarted" })
396
+ * // The clock starts at 0 and the tick is 80 ms, so 160 ms on is frame 2.
397
+ * yield* view.advance("160 millis")
398
+ * assert.include(yield* view.plainFrame, "frame 2")
399
+ * }).pipe(Effect.scoped),
400
+ * )
401
+ * ```
402
+ *
403
+ * @param options - the live view's options without `events`, and the terminal's size, colour, glyphs and
404
+ * interactivity
405
+ */
406
+ static readonly live: <E, S>(options: Omit<LiveOptions<E, S>, "events"> & CliUiTestOptions) => Effect.Effect<CliUiTestLive<E, S>, never, Scope.Scope>;
407
+ /**
408
+ * Why a screen or a program was cancelled, read from its `Exit` or `Cause`: `"escape"` or `"interrupt"` when it
409
+ * carries a `Cancelled`, as a typed failure or as a defect, else `None`.
410
+ *
411
+ * @remarks
412
+ * Pure. A screen's `result` fails with `Cancelled` in the typed channel; a prompt cancelled where the program
413
+ * declares no such error carries it as a defect. Either way a test asks this instead of walking `cause.reasons`:
414
+ *
415
+ * ```ts
416
+ * const exit = yield* Fiber.await(program)
417
+ * assert.deepStrictEqual(CliUiTest.cancelReason(exit), Option.some("escape"))
418
+ * ```
419
+ *
420
+ * @param exitOrCause - the exit of a screen's `result` or of a program, or a bare cause
421
+ */
422
+ static readonly cancelReason: (exitOrCause: Exit.Exit<unknown, unknown> | Cause.Cause<unknown>) => Option.Option<"escape" | "interrupt">;
423
+ /**
424
+ * Decode ANSI back to markup: a marker colour to its token (`[success]…[/success]`), any other foreground to
425
+ * `[fg:red]` or `[fg:#ff0000]` (backgrounds likewise as `[bg:…]`), bold to `[b]`, dim to `[dim]`, italic, underline,
426
+ * inverse and strikethrough to `[i]`, `[u]`, `[inverse]` and `[s]`. A reset closes everything open; every other
427
+ * escape (cursor, erase, hyperlinks) is dropped.
428
+ *
429
+ * @param ansi - text with escapes
430
+ */
431
+ static readonly styled: (ansi: string) => string;
432
+ /**
433
+ * A Vitest snapshot serializer. It claims a string carrying escapes, a token tag opened and closed, or a colour
434
+ * tag (a raw or styled frame, or a `Render.ansi` string), but not a log line's lone `[info]` prefix, nor one whose
435
+ * only brackets are style tags like `[b]`, which unrelated data uses too. It prints the string as token markup with
436
+ * each line's trailing spaces trimmed, so a snapshot reads without escapes and does not churn with the palette.
437
+ * Register it with `expect.addSnapshotSerializer`.
438
+ */
439
+ static readonly serializer: {
440
+ readonly test: (value: unknown) => boolean;
441
+ readonly serialize: (value: unknown) => string;
442
+ };
443
+ }
444
+ //#endregion
445
+ export type { CliUiTestHandle, CliUiTestLive, CliUiTestNextOptions, CliUiTestOptions, CliUiTestScreen, CliUiTestSession, CliUiTestView };
446
+ //# sourceMappingURL=ui-testing.d.ts.map
package/ui-testing.js ADDED
@@ -0,0 +1,3 @@
1
+ import { CliUiTest } from "./ui/testing/CliUiTest.js";
2
+
3
+ export { CliUiTest };