@effected/cli 0.11.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.
- package/CliFailure.js +57 -8
- package/CliLog.js +53 -1
- package/CliMessage.js +3 -6
- package/CliRuntime.js +16 -10
- package/CliTheme.js +28 -15
- package/Doc.js +31 -7
- package/README.md +22 -6
- package/Render.js +6 -5
- package/Status.js +5 -2
- package/index.d.ts +191 -23
- package/internal/counts.js +17 -2
- package/internal/failureTarget.js +53 -14
- package/internal/renderDoc.js +4 -3
- package/internal/renderMarkdown.js +5 -4
- package/package.json +7 -2
- package/ui/CliUi.js +91 -7
- package/ui/CliUiLive.js +63 -16
- package/ui/Select.js +5 -1
- package/ui/TextInput.js +51 -11
- package/ui/internal/lazyView.js +74 -0
- package/ui/testing/CliUiTest.js +47 -22
- package/ui/testing/fakeStreams.js +6 -3
- package/ui-testing-serializer.d.ts +14 -0
- package/ui-testing-serializer.js +33 -0
- package/ui-testing.d.ts +93 -12
- package/ui.d.ts +154 -12
package/ui/testing/CliUiTest.js
CHANGED
|
@@ -212,12 +212,14 @@ const LEADING_MOVES = /^(?:\u001b\[[0-9;?]*[A-Za-ln-z])+/;
|
|
|
212
212
|
* with the settle-and-send machinery that drives a screen.
|
|
213
213
|
*
|
|
214
214
|
* @remarks
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
* a
|
|
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.
|
|
219
221
|
*/
|
|
220
|
-
const makeTerminal = (options,
|
|
222
|
+
const makeTerminal = (options, settings = { screens: "debug" }) => {
|
|
221
223
|
const columns = options.columns ?? 80;
|
|
222
224
|
const rows = options.rows ?? 24;
|
|
223
225
|
const color = options.color ?? "truecolor";
|
|
@@ -231,7 +233,7 @@ const makeTerminal = (options, mode = "debug") => {
|
|
|
231
233
|
lastWrite = Date.now();
|
|
232
234
|
const current = captures.at(-1);
|
|
233
235
|
if (!frameDue || current === void 0 || current.ended) return;
|
|
234
|
-
if (mode === "debug") {
|
|
236
|
+
if (current.mode === "debug") {
|
|
235
237
|
frameDue = false;
|
|
236
238
|
current.raws.push(chunk);
|
|
237
239
|
return;
|
|
@@ -243,10 +245,7 @@ const makeTerminal = (options, mode = "debug") => {
|
|
|
243
245
|
current.raws.push(text.replace(LEADING_MOVES, "").replace(/\n+$/, ""));
|
|
244
246
|
}
|
|
245
247
|
});
|
|
246
|
-
const streams =
|
|
247
|
-
...fake.streams,
|
|
248
|
-
stderr: fake.streams.stdout
|
|
249
|
-
} : fake.streams;
|
|
248
|
+
const streams = fake.streams;
|
|
250
249
|
const stream = {
|
|
251
250
|
isTerminal: true,
|
|
252
251
|
color,
|
|
@@ -262,13 +261,16 @@ const makeTerminal = (options, mode = "debug") => {
|
|
|
262
261
|
tokens: MARKER_STYLES,
|
|
263
262
|
glyphs: options.glyphs ?? "unicode"
|
|
264
263
|
}).pipe(Layer.provide(terminal)), CliInteractive.layerTest(options.interactive ?? true), Layer.succeed(UiStreams, streams), Layer.succeed(UiRenderOptions, {
|
|
265
|
-
...
|
|
264
|
+
...settings.screens === "debug" ? { debug: true } : {},
|
|
265
|
+
maxFps: 1e3,
|
|
266
266
|
onRender: () => {
|
|
267
267
|
frameDue = true;
|
|
268
268
|
},
|
|
269
|
-
onMount: () => {
|
|
269
|
+
onMount: (kind) => {
|
|
270
|
+
const mode = kind === "live" ? "production" : settings.screens;
|
|
270
271
|
captures.push({
|
|
271
272
|
raws: [],
|
|
273
|
+
mode,
|
|
272
274
|
ended: false,
|
|
273
275
|
crash: void 0
|
|
274
276
|
});
|
|
@@ -482,7 +484,7 @@ var CliUiTest = class {
|
|
|
482
484
|
* frames on a real terminal (a screen clear, for instance), nor about the final frame left in the scrollback once
|
|
483
485
|
* the screen ends (answered screens stay); a test of that needs the production render path.
|
|
484
486
|
* 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`
|
|
487
|
+
* nothing in debug mode: test `clear` with `session({ renderPath: "production" })` and its `transcript`.
|
|
486
488
|
* Unmounting is the scope's close; to draw a different screen, render it in a new scope.
|
|
487
489
|
*
|
|
488
490
|
* A crash is never swallowed. A screen thunk that throws (a classic-JSX `React is not defined` included) or a
|
|
@@ -541,8 +543,11 @@ var CliUiTest = class {
|
|
|
541
543
|
* mounted and drawn, `press` and `type` settle as they do on a rendered screen, and joining the program's fiber
|
|
542
544
|
* gives its exit. Screens still run one at a time, process-wide, so `next` sees them in the order they mount.
|
|
543
545
|
*
|
|
544
|
-
*
|
|
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`.
|
|
546
551
|
* The waits are real time: a session test that itself sleeps or times out needs `it.live`. To assert that no screen
|
|
547
552
|
* mounted (a non-interactive run, a flag that skips a prompt), check that `mounts` is `0` once the program has
|
|
548
553
|
* finished.
|
|
@@ -574,10 +579,11 @@ var CliUiTest = class {
|
|
|
574
579
|
* To exercise `CliRuntime.main` as well (its failure report and exit code), run the program through it with the
|
|
575
580
|
* session's layer provided around it instead; `main` provides its own `CliExit`.
|
|
576
581
|
*
|
|
577
|
-
* @param options - the terminal's size, colour and glyphs,
|
|
582
|
+
* @param options - the terminal's size, colour and glyphs, whether the run is interactive, and the screens' render path
|
|
578
583
|
*/
|
|
579
584
|
static session = (options = {}) => Effect.map(Console.Console, (ambient) => {
|
|
580
|
-
const
|
|
585
|
+
const { renderPath, ...terminalOptions } = options;
|
|
586
|
+
const terminal = makeTerminal(terminalOptions, { screens: renderPath ?? "debug" });
|
|
581
587
|
const output = capturingConsole(ambient);
|
|
582
588
|
let taken = 0;
|
|
583
589
|
const next = (nextOptions = {}) => Effect.gen(function* () {
|
|
@@ -601,7 +607,13 @@ var CliUiTest = class {
|
|
|
601
607
|
next,
|
|
602
608
|
mounts: Effect.sync(() => terminal.captures.length),
|
|
603
609
|
stdout: Effect.sync(() => output.out.join("")),
|
|
604
|
-
stderr: Effect.sync(() => output.err.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"))
|
|
605
617
|
};
|
|
606
618
|
});
|
|
607
619
|
/**
|
|
@@ -659,7 +671,7 @@ var CliUiTest = class {
|
|
|
659
671
|
...color === void 0 ? {} : { color },
|
|
660
672
|
...glyphs === void 0 ? {} : { glyphs },
|
|
661
673
|
...interactive === void 0 ? {} : { interactive }
|
|
662
|
-
}, "production");
|
|
674
|
+
}, { screens: "production" });
|
|
663
675
|
const queue = yield* Queue.unbounded();
|
|
664
676
|
const handle = yield* CliUi.live({
|
|
665
677
|
...view,
|
|
@@ -681,8 +693,8 @@ var CliUiTest = class {
|
|
|
681
693
|
rawFrame: Effect.sync(last),
|
|
682
694
|
plainFrame: Effect.sync(() => trimLines(last().replace(ESCAPES, ""))),
|
|
683
695
|
frames: Effect.sync(() => raws().map((raw) => trimLines(styled(raw)))),
|
|
684
|
-
transcript: Effect.sync(() => screenAfter(terminal.fake.
|
|
685
|
-
written: Effect.sync(() => terminal.fake.
|
|
696
|
+
transcript: Effect.sync(() => screenAfter(terminal.fake.written(), terminal.fake.streams.stdout.rows).join("\n")),
|
|
697
|
+
written: Effect.sync(() => terminal.fake.written()),
|
|
686
698
|
handle
|
|
687
699
|
};
|
|
688
700
|
});
|
|
@@ -723,7 +735,20 @@ var CliUiTest = class {
|
|
|
723
735
|
* tag (a raw or styled frame, or a `Render.ansi` string), but not a log line's lone `[info]` prefix, nor one whose
|
|
724
736
|
* only brackets are style tags like `[b]`, which unrelated data uses too. It prints the string as token markup with
|
|
725
737
|
* each line's trailing spaces trimmed, so a snapshot reads without escapes and does not churn with the palette.
|
|
726
|
-
*
|
|
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.
|
|
727
752
|
*/
|
|
728
753
|
static serializer = {
|
|
729
754
|
test: (value) => typeof value === "string" && (value.includes("\x1B") || MARKUP.test(value)),
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { PassThrough, Writable } from "node:stream";
|
|
2
2
|
|
|
3
3
|
//#region src/ui/testing/fakeStreams.ts
|
|
4
|
-
const capture = (columns, rows, onWrite) => {
|
|
4
|
+
const capture = (columns, rows, both, onWrite) => {
|
|
5
5
|
const chunks = [];
|
|
6
6
|
const stream = new Writable({ write(chunk, _encoding, callback) {
|
|
7
7
|
const text = chunk.toString();
|
|
8
8
|
chunks.push(text);
|
|
9
|
+
both.push(text);
|
|
9
10
|
onWrite?.(text);
|
|
10
11
|
callback();
|
|
11
12
|
} });
|
|
@@ -44,8 +45,9 @@ const makeFakeStreams = (options = {}) => {
|
|
|
44
45
|
ref: () => stdin,
|
|
45
46
|
unref: () => stdin
|
|
46
47
|
});
|
|
47
|
-
const
|
|
48
|
-
const
|
|
48
|
+
const both = [];
|
|
49
|
+
const stdout = capture(columns, rows, both, options.onStdoutWrite);
|
|
50
|
+
const stderr = capture(columns, rows, both);
|
|
49
51
|
return {
|
|
50
52
|
streams: {
|
|
51
53
|
stdin,
|
|
@@ -55,6 +57,7 @@ const makeFakeStreams = (options = {}) => {
|
|
|
55
57
|
rawModes,
|
|
56
58
|
stdout: stdout.text,
|
|
57
59
|
stderr: stderr.text,
|
|
60
|
+
written: () => both.join(""),
|
|
58
61
|
input: (data) => {
|
|
59
62
|
stdin.write(data);
|
|
60
63
|
},
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
//#region src/ui-testing-serializer.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* `CliUiTest.serializer`: it claims a string carrying escapes or token markup and prints it as token markup with each
|
|
4
|
+
* line's trailing spaces trimmed. See `CliUiTest.serializer` for what it claims.
|
|
5
|
+
*
|
|
6
|
+
* @public
|
|
7
|
+
*/
|
|
8
|
+
declare const serializer: {
|
|
9
|
+
readonly test: (value: unknown) => boolean;
|
|
10
|
+
readonly serialize: (value: unknown) => string;
|
|
11
|
+
};
|
|
12
|
+
//#endregion
|
|
13
|
+
export { serializer as default };
|
|
14
|
+
//# sourceMappingURL=ui-testing-serializer.d.ts.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { CliUiTest } from "./ui/testing/CliUiTest.js";
|
|
2
|
+
|
|
3
|
+
//#region src/ui-testing-serializer.ts
|
|
4
|
+
/**
|
|
5
|
+
* `CliUiTest.serializer` as a module's default export, for Vitest's `snapshotSerializers` config.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Vitest's `test.snapshotSerializers` takes module paths whose default export is a serializer, so registering this
|
|
9
|
+
* one needs no `expect.addSnapshotSerializer` call and no shim file of your own:
|
|
10
|
+
*
|
|
11
|
+
* ```ts
|
|
12
|
+
* // vitest.config.ts
|
|
13
|
+
* import { defineConfig } from "vitest/config"
|
|
14
|
+
*
|
|
15
|
+
* export default defineConfig({
|
|
16
|
+
* test: { snapshotSerializers: ["@effected/cli/ui/testing/serializer"] },
|
|
17
|
+
* })
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* A separate entrypoint so a program's runtime import graph never loads test code.
|
|
21
|
+
*
|
|
22
|
+
* @packageDocumentation
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* `CliUiTest.serializer`: it claims a string carrying escapes or token markup and prints it as token markup with each
|
|
26
|
+
* line's trailing spaces trimmed. See `CliUiTest.serializer` for what it claims.
|
|
27
|
+
*
|
|
28
|
+
* @public
|
|
29
|
+
*/
|
|
30
|
+
const serializer = CliUiTest.serializer;
|
|
31
|
+
|
|
32
|
+
//#endregion
|
|
33
|
+
export { serializer as default };
|
package/ui-testing.d.ts
CHANGED
|
@@ -32,6 +32,21 @@ interface CliUiTestOptions {
|
|
|
32
32
|
*/
|
|
33
33
|
readonly interactive?: boolean;
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Options for {@link CliUiTest.session}: the terminal's, and the render path its screens mount on.
|
|
37
|
+
*
|
|
38
|
+
* @public
|
|
39
|
+
*/
|
|
40
|
+
interface CliUiTestSessionOptions extends CliUiTestOptions {
|
|
41
|
+
/**
|
|
42
|
+
* The path the session's screens render on. `"debug"`, the default, is `render`'s: Ink writes every frame whole and
|
|
43
|
+
* unthrottled, which is what `next`, `frame` and `frames` read best, and where `clear` does nothing. `"production"`
|
|
44
|
+
* renders screens as a real terminal does, erase moves and all, so `CliUi.run`'s `clear: true` is observable in
|
|
45
|
+
* {@link CliUiTestSession.transcript}: the cleared frame is gone from it. A live view mounted in a session always
|
|
46
|
+
* renders on the production path, whichever this is.
|
|
47
|
+
*/
|
|
48
|
+
readonly renderPath?: "debug" | "production";
|
|
49
|
+
}
|
|
35
50
|
/**
|
|
36
51
|
* A screen under test: drive it with keys and read its frames.
|
|
37
52
|
*
|
|
@@ -134,13 +149,26 @@ interface CliUiTestNextOptions {
|
|
|
134
149
|
*/
|
|
135
150
|
interface CliUiTestSession {
|
|
136
151
|
/**
|
|
137
|
-
* Provide it around the program: in-memory terminal streams, the marker-palette `CliTheme`,
|
|
138
|
-
* from the session's options, frame capture, and a `Console` whose writes the session keeps.
|
|
152
|
+
* Provide it around the program: in-memory terminal streams (`UiStreams`), the marker-palette `CliTheme`,
|
|
153
|
+
* `CliInteractive` from the session's options, frame capture, and a `Console` whose writes the session keeps. Its
|
|
154
|
+
* type names `CliTheme` alone because that is the only one of them a program can require: `UiStreams`,
|
|
155
|
+
* `CliInteractive`, the capture and `Console` are `Context.Reference`s with defaults, which never appear in a
|
|
156
|
+
* requirement, so a type cannot say that the layer supplies them. Their defaults are the real process streams, a
|
|
157
|
+
* run that is not interactive, and the real console, so a session provided where it is shadowed fails quietly
|
|
158
|
+
* rather than by a type error. Hence the ordering below.
|
|
139
159
|
*
|
|
140
160
|
* @remarks
|
|
141
|
-
* Anything the program provides closer to the screens wins:
|
|
142
|
-
*
|
|
143
|
-
*
|
|
161
|
+
* **Where you provide it decides what it supplies.** Anything the program provides closer to the screens wins, so:
|
|
162
|
+
*
|
|
163
|
+
* - Provided **outside** a layer that supplies the same services, it is shadowed there. Under `CliRuntime.main`
|
|
164
|
+
* with `env`, `CliEnv.layer` (provided inside `main`) supplies the theme and decides interactivity, as it does for
|
|
165
|
+
* real, and the session keeps only the streams, the capture and the console. That is the intended way to test the
|
|
166
|
+
* production wiring.
|
|
167
|
+
* - To have the session's own theme and `interactive` option win, provide it **inside** any presentation layer of
|
|
168
|
+
* your own (closest to the program), or drop that layer from the test.
|
|
169
|
+
*
|
|
170
|
+
* The streams, the capture and the console are the session's wherever it is provided, since nothing else in the kit
|
|
171
|
+
* provides them.
|
|
144
172
|
*/
|
|
145
173
|
readonly layer: Layer.Layer<Cli.CliTheme>;
|
|
146
174
|
/**
|
|
@@ -162,6 +190,43 @@ interface CliUiTestSession {
|
|
|
162
190
|
readonly stdout: Effect.Effect<string>;
|
|
163
191
|
/** What the program wrote to stderr through `Console` (`error`, `warn`, `trace`), one line per call. */
|
|
164
192
|
readonly stderr: Effect.Effect<string>;
|
|
193
|
+
/**
|
|
194
|
+
* What the terminal shows now, scrollback included, as plain text: what the session's screens and live views wrote to
|
|
195
|
+
* the terminal streams (`UiStreams`), with Ink's erases and clears applied, each line's trailing spaces trimmed and
|
|
196
|
+
* blank lines left out, as {@link CliUiTestLive.transcript} reads it.
|
|
197
|
+
*
|
|
198
|
+
* @remarks
|
|
199
|
+
* It holds the lines a live view writes above its frame through `handle.logConsole` (stdout's and stderr's, merged in the order
|
|
200
|
+
* written, as one terminal shows them; `stdoutWritten` and `stderrWritten` keep them apart), each run's committed frame, a frame printed as a string, and, with
|
|
201
|
+
* `renderPath: "production"`, each screen's frames as they stay on the terminal, so a screen run with `clear: true`
|
|
202
|
+
* leaves nothing. On the default debug path each screen frame is written whole, one after another, so the
|
|
203
|
+
* transcript shows every frame a screen drew rather than what a terminal would keep. What the program writes through
|
|
204
|
+
* `Console` is not here: that is `stdout` and `stderr`.
|
|
205
|
+
*/
|
|
206
|
+
readonly transcript: Effect.Effect<string>;
|
|
207
|
+
/** Every byte written to the terminal streams, both of them in the order written, escapes included. */
|
|
208
|
+
readonly written: Effect.Effect<string>;
|
|
209
|
+
/**
|
|
210
|
+
* Every byte written to the terminal's stdout (`UiStreams.stdout`) alone, escapes included: with
|
|
211
|
+
* {@link CliUiTestSession.stderrWritten}, what tells a line on the wrong stream from one on the right, which the
|
|
212
|
+
* merged `transcript` and `written` cannot.
|
|
213
|
+
*/
|
|
214
|
+
readonly stdoutWritten: Effect.Effect<string>;
|
|
215
|
+
/** Every byte written to the terminal's stderr (`UiStreams.stderr`) alone, escapes included. */
|
|
216
|
+
readonly stderrWritten: Effect.Effect<string>;
|
|
217
|
+
/**
|
|
218
|
+
* What the terminal's stdout alone shows now, as plain text: {@link CliUiTestSession.stdoutWritten} run through the
|
|
219
|
+
* same pipeline as {@link CliUiTestSession.transcript} (Ink's erases and clears applied, escapes stripped, trailing
|
|
220
|
+
* spaces trimmed, blank lines left out).
|
|
221
|
+
*
|
|
222
|
+
* @remarks
|
|
223
|
+
* Assert text per stream here rather than on the raw bytes, which split a painted line wherever the paint breaks
|
|
224
|
+
* (a counter's `Dry run` from its `0/2 repos`). A frame cleared on the production render path is absent from it
|
|
225
|
+
* and present in `stdoutWritten`.
|
|
226
|
+
*/
|
|
227
|
+
readonly stdoutTranscript: Effect.Effect<string>;
|
|
228
|
+
/** What the terminal's stderr alone shows now, as plain text: {@link CliUiTestSession.stderrWritten} read as `stdoutTranscript` reads stdout. */
|
|
229
|
+
readonly stderrTranscript: Effect.Effect<string>;
|
|
165
230
|
}
|
|
166
231
|
/**
|
|
167
232
|
* A live view mounted by {@link CliUiTest.live}: its event stream to publish to, and its output on the production
|
|
@@ -271,7 +336,7 @@ export declare class CliUiTest {
|
|
|
271
336
|
* frames on a real terminal (a screen clear, for instance), nor about the final frame left in the scrollback once
|
|
272
337
|
* the screen ends (answered screens stay); a test of that needs the production render path.
|
|
273
338
|
* 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`
|
|
339
|
+
* nothing in debug mode: test `clear` with `session({ renderPath: "production" })` and its `transcript`.
|
|
275
340
|
* Unmounting is the scope's close; to draw a different screen, render it in a new scope.
|
|
276
341
|
*
|
|
277
342
|
* A crash is never swallowed. A screen thunk that throws (a classic-JSX `React is not defined` included) or a
|
|
@@ -320,8 +385,11 @@ export declare class CliUiTest {
|
|
|
320
385
|
* mounted and drawn, `press` and `type` settle as they do on a rendered screen, and joining the program's fiber
|
|
321
386
|
* gives its exit. Screens still run one at a time, process-wide, so `next` sees them in the order they mount.
|
|
322
387
|
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
388
|
+
* By default, as with `render`, a screen run with `clear` leaves its frames unchanged here: Ink renders screens in
|
|
389
|
+
* debug mode, where its `clear` does nothing. Pass `renderPath: "production"` to render them as a terminal does, and
|
|
390
|
+
* read `transcript` to see what stays on it: a cleared screen leaves nothing there. A live view a handler mounts
|
|
391
|
+
* (`CliUi.live`) always renders on the production path, and the lines it writes above its frame through
|
|
392
|
+
* `handle.logConsole` are in `transcript` and `written`.
|
|
325
393
|
* The waits are real time: a session test that itself sleeps or times out needs `it.live`. To assert that no screen
|
|
326
394
|
* mounted (a non-interactive run, a flag that skips a prompt), check that `mounts` is `0` once the program has
|
|
327
395
|
* finished.
|
|
@@ -353,9 +421,9 @@ export declare class CliUiTest {
|
|
|
353
421
|
* To exercise `CliRuntime.main` as well (its failure report and exit code), run the program through it with the
|
|
354
422
|
* session's layer provided around it instead; `main` provides its own `CliExit`.
|
|
355
423
|
*
|
|
356
|
-
* @param options - the terminal's size, colour and glyphs,
|
|
424
|
+
* @param options - the terminal's size, colour and glyphs, whether the run is interactive, and the screens' render path
|
|
357
425
|
*/
|
|
358
|
-
static readonly session: (options?:
|
|
426
|
+
static readonly session: (options?: CliUiTestSessionOptions) => Effect.Effect<CliUiTestSession, never, Scope.Scope>;
|
|
359
427
|
/**
|
|
360
428
|
* Mount a live view (`CliUi.live`) on a fresh in-memory terminal for the enclosing scope, with an event stream the
|
|
361
429
|
* test publishes to, and read what it draws on the production render path.
|
|
@@ -434,7 +502,20 @@ export declare class CliUiTest {
|
|
|
434
502
|
* tag (a raw or styled frame, or a `Render.ansi` string), but not a log line's lone `[info]` prefix, nor one whose
|
|
435
503
|
* only brackets are style tags like `[b]`, which unrelated data uses too. It prints the string as token markup with
|
|
436
504
|
* each line's trailing spaces trimmed, so a snapshot reads without escapes and does not churn with the palette.
|
|
437
|
-
*
|
|
505
|
+
*
|
|
506
|
+
* @remarks
|
|
507
|
+
* Register it in either of two ways. Through the Vitest config, by the module whose default export it is, which
|
|
508
|
+
* needs no code in a test file:
|
|
509
|
+
*
|
|
510
|
+
* ```ts
|
|
511
|
+
* // vitest.config.ts
|
|
512
|
+
* export default defineConfig({ test: { snapshotSerializers: ["@effected/cli/ui/testing/serializer"] } })
|
|
513
|
+
* ```
|
|
514
|
+
*
|
|
515
|
+
* Or in a test file, with `expect.addSnapshotSerializer(CliUiTest.serializer)`.
|
|
516
|
+
*
|
|
517
|
+
* Snapshots are the one place a test needs `expect`: `assert` has no snapshot form, so a suite that asserts with
|
|
518
|
+
* `assert.*` everywhere else still writes `expect(frame).toMatchInlineSnapshot(...)` for a snapshot.
|
|
438
519
|
*/
|
|
439
520
|
static readonly serializer: {
|
|
440
521
|
readonly test: (value: unknown) => boolean;
|
|
@@ -442,5 +523,5 @@ export declare class CliUiTest {
|
|
|
442
523
|
};
|
|
443
524
|
}
|
|
444
525
|
//#endregion
|
|
445
|
-
export type { CliUiTestHandle, CliUiTestLive, CliUiTestNextOptions, CliUiTestOptions, CliUiTestScreen, CliUiTestSession, CliUiTestView };
|
|
526
|
+
export type { CliUiTestHandle, CliUiTestLive, CliUiTestNextOptions, CliUiTestOptions, CliUiTestScreen, CliUiTestSession, CliUiTestSessionOptions, CliUiTestView };
|
|
446
527
|
//# sourceMappingURL=ui-testing.d.ts.map
|
package/ui.d.ts
CHANGED
|
@@ -39,6 +39,10 @@ interface LiveOptions<E, S> {
|
|
|
39
39
|
* `useTerminalSize` work in it. When the final frame is printed as a string (not interactive, or a run that degraded
|
|
40
40
|
* before it painted), `useTerminalSize().rows` is `Infinity`, since that frame has no height to fit: a render must
|
|
41
41
|
* not allocate per row.
|
|
42
|
+
*
|
|
43
|
+
* To keep the view's module (its JSX, and so React) off every run that never draws it, pass
|
|
44
|
+
* `CliUi.lazyView(() => import("./view.js"))`: the module is loaded only when a run first mounts or prints its frame
|
|
45
|
+
* with Ink, so `--help`, a usage error, and, with `final`, a run that is not interactive, never load it.
|
|
42
46
|
*/
|
|
43
47
|
readonly render: (state: S, frame: number) => ReactElement;
|
|
44
48
|
/** Whether an event starts a run: by default, the only event that begins one (see `begins`). */
|
|
@@ -61,6 +65,28 @@ interface LiveOptions<E, S> {
|
|
|
61
65
|
* string; hosted writes nothing, its host having its own output.
|
|
62
66
|
*/
|
|
63
67
|
readonly mode?: "owned" | "hosted";
|
|
68
|
+
/**
|
|
69
|
+
* The final frame of a run that is not interactive (an agent, CI, a pipe, `TERM=dumb`), as a document: given, an
|
|
70
|
+
* `owned` view prints each run's `final(state)` at the run's end instead of rendering `render` to a string with Ink,
|
|
71
|
+
* so such a run loads neither Ink nor React, nor a `CliUi.lazyView` module.
|
|
72
|
+
*
|
|
73
|
+
* @remarks
|
|
74
|
+
* It is called once per run, at the run's terminal event (or when the events end mid-run), with the state then, and
|
|
75
|
+
* replaces the string that run would have printed: never both. It is rendered as `Doc.print` renders a document:
|
|
76
|
+
* `Render.context("stdout")` when the environment `CliRuntime.main` builds is there (`TerminalEnv`, `Audience` and
|
|
77
|
+
* `CliLinks`; otherwise the stdout theme, the `Audience` if any, and no width limit), the renderer the audience gets
|
|
78
|
+
* (`ansi` for a person, `plain` for an agent, `githubLog` under GitHub Actions), and an agent gets no escape. It is
|
|
79
|
+
* written where the view writes, to `UiStreams` stdout, so a host's capture of the view's output holds it. A document
|
|
80
|
+
* that renders to nothing prints nothing. A `final` that throws is the run degrading: one warning, nothing printed.
|
|
81
|
+
*
|
|
82
|
+
* An interactive run never calls it, and a `hosted` view never prints either way. It need not match the Ink frame:
|
|
83
|
+
* it is what a reader with no terminal gets.
|
|
84
|
+
*
|
|
85
|
+
* With `final` set, `render` is never called on a run that is not interactive, not even to build a string that would
|
|
86
|
+
* go unused: the run's output is `final(state)` alone. (Pinned by `CliUi.live.final.test.ts`, whose watch-mode test
|
|
87
|
+
* counts zero `render` calls over three runs.)
|
|
88
|
+
*/
|
|
89
|
+
readonly final?: ((state: S) => Cli.Document) | undefined;
|
|
64
90
|
/**
|
|
65
91
|
* The frame tick, in milliseconds; 80 by default. While a run is drawn the view redraws on every tick, so a spinner
|
|
66
92
|
* turns without events. Anything but a positive, finite number is a defect.
|
|
@@ -343,7 +369,7 @@ export declare class CliUi {
|
|
|
343
369
|
* what is published before.
|
|
344
370
|
*
|
|
345
371
|
* `live` returns its handle at once, before Ink has loaded: it loads Ink when a run first mounts (or, when not
|
|
346
|
-
* interactive, when an owned run prints its final frame), and it waits on nothing asynchronous before returning, so
|
|
372
|
+
* interactive, when an owned run without a `final` prints its final frame), and it waits on nothing asynchronous before returning, so
|
|
347
373
|
* a host outside Effect can take the handle with `Effect.runSync`. The handle works from the start: a `close`
|
|
348
374
|
* before any run has mounted folds what is queued and ends the view as the events ending would, waiting for a mount
|
|
349
375
|
* already under way, and one with no run to end loads nothing.
|
|
@@ -387,8 +413,12 @@ export declare class CliUi {
|
|
|
387
413
|
* When the run is not interactive, nothing is mounted and Ink is loaded only when a string is due. In the `owned`
|
|
388
414
|
* mode (the default) each run's final frame is written once to stdout, as a string laid out at stdout's width (80
|
|
389
415
|
* when it reports none) with no height to fit, at its terminal event or when the stream ends. It is escape-free at
|
|
390
|
-
* colour `none`, and for an agent audience (`Audience`, when provided) whatever the terminal could do.
|
|
391
|
-
* `
|
|
416
|
+
* colour `none`, and for an agent audience (`Audience`, when provided) whatever the terminal could do. With a
|
|
417
|
+
* `final` document, that document is printed instead, once per run, rendered as `Doc.print` renders it, and Ink,
|
|
418
|
+
* React and a `CliUi.lazyView` module are never loaded: an agent, CI or piped run of a command with a live view pays
|
|
419
|
+
* for none of them. In the `hosted` mode nothing is written.
|
|
420
|
+
*
|
|
421
|
+
* Keep React off the runs that never draw (`--help`, a usage error) with `render: CliUi.lazyView(() => import(...))`.
|
|
392
422
|
*
|
|
393
423
|
* No input is mounted: the view reads no keys and never enters raw mode, so Ctrl-C stays the platform's SIGINT,
|
|
394
424
|
* which interrupts the program and so closes the scope. Each run holds the process-wide mount permit from its
|
|
@@ -414,6 +444,10 @@ export declare class CliUi {
|
|
|
414
444
|
* naming the peers, never a silent `otherwise`. Screens in sequence make a wizard: discover the defaults first,
|
|
415
445
|
* pass each as an `otherwise`, and a non-interactive run returns exactly them.
|
|
416
446
|
*
|
|
447
|
+
* Without `otherwise`, `NotInteractive` stays in the error type even after the caller checked `CliInteractive`, since
|
|
448
|
+
* the type cannot know. Catch the tag and fail with `CliError.UserError` to exit as a usage error (`64` under
|
|
449
|
+
* `CliRuntime.main`).
|
|
450
|
+
*
|
|
417
451
|
* As with `CliUi.run`, do not log while the screen is mounted: a line written to the terminal from elsewhere tears
|
|
418
452
|
* the frame.
|
|
419
453
|
*
|
|
@@ -466,6 +500,79 @@ export declare class CliUi {
|
|
|
466
500
|
static readonly lazy: <A>(load: () => Promise<{
|
|
467
501
|
readonly default: Screen<A>;
|
|
468
502
|
}>) => Screen<A>;
|
|
503
|
+
/**
|
|
504
|
+
* A live view's `render` whose module is loaded only when a run first draws it, so importing the command that uses
|
|
505
|
+
* the view loads neither the view's own code nor React: `CliUi.lazy` for `CliUi.live`.
|
|
506
|
+
*
|
|
507
|
+
* @remarks
|
|
508
|
+
* `load` resolves to the view, `(state, frame) => ReactElement`, exactly what `render` takes, so the frame index a
|
|
509
|
+
* spinner needs reaches it: either a module whose default export is the view (`() => import("./view.js")`), or the
|
|
510
|
+
* view itself (`() => import("./views.js").then((module) => module.syncView)`, for a named export).
|
|
511
|
+
*
|
|
512
|
+
* It is optional: `render` still takes the view directly, and needs no dynamic import. A view passed directly
|
|
513
|
+
* loads with the module that imports it, so it costs React on every run that loads that module; `lazyView` is
|
|
514
|
+
* how a command keeps React off the runs that never draw. `CliUi.live` loads the module before a run mounts with Ink, or before it
|
|
515
|
+
* prints a run's final frame as a string; a run that is not interactive and has a `final` document never loads it,
|
|
516
|
+
* nor Ink, nor React. An import that fails degrades the run, as a render that throws does: one warning, and the next
|
|
517
|
+
* run tries the import again. A load that resolves to no view (neither a function nor a module whose `default` is
|
|
518
|
+
* one) is a programming error, and deterministic, so it is kept for the view's life: every run degrades without
|
|
519
|
+
* loading again, and the view warns once, saying what it received and what is expected, rather than once per run. A
|
|
520
|
+
* view function that happens to carry a `default` property is the view.
|
|
521
|
+
*
|
|
522
|
+
* The returned function is for `CliUi.live`'s `render` alone: called before its module has loaded, it throws.
|
|
523
|
+
*
|
|
524
|
+
* Keep the `LiveOptions` (the state, the events and the `lazyView` call) in a module the view does not import. The
|
|
525
|
+
* view module usually imports the state's types or its fold from somewhere; if that somewhere is the module that
|
|
526
|
+
* holds the `import("./view.js")`, the dynamic import closes a cycle, which Biome's `noImportCycles` reports even
|
|
527
|
+
* though it is lazy. A layout that stays acyclic: the model (state, events, fold) in one module, the view importing
|
|
528
|
+
* the model, and the options (with `lazyView`) in a third that imports the model and loads the view.
|
|
529
|
+
*
|
|
530
|
+
* ```ts
|
|
531
|
+
* // commands/sync.ts: no JSX, no React
|
|
532
|
+
* const view = yield* CliUi.live({
|
|
533
|
+
* events,
|
|
534
|
+
* initial,
|
|
535
|
+
* reduce,
|
|
536
|
+
* render: CliUi.lazyView(() => import("./sync-view.js")),
|
|
537
|
+
* final: (state) => [Doc.paragraph(`${state.done} synced`)],
|
|
538
|
+
* isStart,
|
|
539
|
+
* isTerminal,
|
|
540
|
+
* })
|
|
541
|
+
* ```
|
|
542
|
+
*
|
|
543
|
+
* @param load - resolves to the view, or to a module whose default export is the view
|
|
544
|
+
*/
|
|
545
|
+
static readonly lazyView: <S>(load: () => Promise<((state: S, frame: number) => ReactElement) | {
|
|
546
|
+
readonly default: (state: S, frame: number) => ReactElement;
|
|
547
|
+
}>) => (state: S, frame: number) => ReactElement;
|
|
548
|
+
/**
|
|
549
|
+
* A screen whose answer is `f` of `screen`'s: it mounts `screen` and resolves with `f(value)` when `screen` resolves
|
|
550
|
+
* with `value`.
|
|
551
|
+
*
|
|
552
|
+
* @remarks
|
|
553
|
+
* Only the resolve is mapped. A cancel passes through unchanged, as the same `Cancelled`, and so does everything
|
|
554
|
+
* else about the screen: what it draws, its keys, a lazy load. `f` runs when the screen resolves; what it throws is
|
|
555
|
+
* thrown from the screen's resolve, so it is a defect of the run, as any other throw in a key handler is.
|
|
556
|
+
*
|
|
557
|
+
* The mapped screen is a `Screen` like any other, so it goes wherever a screen goes: `CliUi.run`, `CliUi.prompt`,
|
|
558
|
+
* `CliUi.fallback`, or around a `CliUi.lazy` one. The commonest use is a `Confirm` behind a boolean flag, where the
|
|
559
|
+
* fallback needs a `Screen<boolean>` and `Confirm` answers a whole `ConfirmResult`:
|
|
560
|
+
*
|
|
561
|
+
* ```ts
|
|
562
|
+
* const yes = Flag.Boolean("yes").pipe(
|
|
563
|
+
* Flag.withFallbackPrompt(
|
|
564
|
+
* CliUi.fallback(
|
|
565
|
+
* CliUi.map(Confirm.screen({ message: "Publish?" }), (result) => result.confirmed),
|
|
566
|
+
* { flag: "yes", otherwise: false },
|
|
567
|
+
* ),
|
|
568
|
+
* ),
|
|
569
|
+
* )
|
|
570
|
+
* ```
|
|
571
|
+
*
|
|
572
|
+
* @param screen - the screen to show
|
|
573
|
+
* @param f - turns its answer into the mapped screen's
|
|
574
|
+
*/
|
|
575
|
+
static readonly map: <A, B>(screen: Screen<A>, f: (value: A) => B) => Screen<B>;
|
|
469
576
|
}
|
|
470
577
|
//#endregion
|
|
471
578
|
//#region src/ui/UiKey.d.ts
|
|
@@ -1243,7 +1350,11 @@ export declare class Select {
|
|
|
1243
1350
|
* Draw the select: the message, the list (the highlighted row in the accent token with the arrow glyph, disabled
|
|
1244
1351
|
* rows muted, and at colour `none` ending in ` (disabled)` instead, every row cut to the width with the glyph set's
|
|
1245
1352
|
* ellipsis), the highlighted choice's detail, and the
|
|
1246
|
-
* key help.
|
|
1353
|
+
* key help.
|
|
1354
|
+
*
|
|
1355
|
+
* @remarks
|
|
1356
|
+
* A choice's `detail` is drawn only while that choice is highlighted, as one muted line under the list, so the others'
|
|
1357
|
+
* details are not on screen until the cursor reaches them. Enter calls `onSubmit` with the value; `q` cancels the screen with `"escape"`.
|
|
1247
1358
|
*
|
|
1248
1359
|
* Single-shot: the choices and the starting choice are read once, when the view mounts, and later changes to them
|
|
1249
1360
|
* are ignored; after a submit it stays as it is. Render a new view (a new screen) to ask again.
|
|
@@ -1358,9 +1469,9 @@ interface TextInputState {
|
|
|
1358
1469
|
/** The text. */
|
|
1359
1470
|
readonly value: string;
|
|
1360
1471
|
/**
|
|
1361
|
-
* The insertion point, from 0 to the value's length, in UTF-16 code units,
|
|
1362
|
-
*
|
|
1363
|
-
*
|
|
1472
|
+
* The insertion point, from 0 to the value's length, in UTF-16 code units. Left, right, backspace and delete move
|
|
1473
|
+
* and delete by grapheme, so a character built from several code points (an emoji, a flag, a letter with a
|
|
1474
|
+
* combining accent) is crossed and deleted whole, and the cursor never stops inside one it moved over.
|
|
1364
1475
|
*/
|
|
1365
1476
|
readonly cursor: number;
|
|
1366
1477
|
/** Whether enter was pressed; the view submits only when the value also validates. */
|
|
@@ -1387,8 +1498,30 @@ interface TextInputScreenOptions {
|
|
|
1387
1498
|
readonly initial?: string;
|
|
1388
1499
|
/** Shown, muted, while the value is empty. */
|
|
1389
1500
|
readonly placeholder?: string;
|
|
1390
|
-
/** Returns a message when the value cannot be submitted, or `undefined` when it can. */
|
|
1501
|
+
/** Returns a message when the value cannot be submitted, or `undefined` when it can; given the real text. */
|
|
1391
1502
|
readonly validate?: (value: string) => string | undefined;
|
|
1503
|
+
/**
|
|
1504
|
+
* Draw the value masked, so a secret typed or pasted into it is never drawn: one mask per grapheme, so an emoji, a
|
|
1505
|
+
* flag or a letter with a combining accent is one mask, not one per code unit. `true` masks with `•` (`*` under ASCII
|
|
1506
|
+
* glyphs); a string masks with that string, its controls removed. A predicate, `(value) => boolean`, is asked with
|
|
1507
|
+
* the real value and masks with `•` from the first render it answers `true`, and then LATCHES: the value stays masked
|
|
1508
|
+
* through every edit after (deleting a pasted token's first character never redraws the rest in clear) until the
|
|
1509
|
+
* value is cleared to empty. So a field that holds an address (an `op://` reference) stays readable while typed and
|
|
1510
|
+
* hides a value the moment it looks like a token. Match a giveaway ANYWHERE in the value, never as a prefix:
|
|
1511
|
+
* `(value) => /gh[pousr]_|github_pat_/.test(value)` masks `op://v/` followed by a pasted token, which a prefix
|
|
1512
|
+
* match never would. Frames drawn before it first answered `true` showed the text typed so far; a paste arrives
|
|
1513
|
+
* whole, so a pasted token is masked from its first frame. Unmasked by default.
|
|
1514
|
+
*
|
|
1515
|
+
* @remarks
|
|
1516
|
+
* Only the drawing changes: `validate` and the resolved value get the real text, the cursor moves through it as
|
|
1517
|
+
* before, and the placeholder still shows while the value is empty. A masked frame never holds the text, so neither
|
|
1518
|
+
* does the scrollback; erase the frame as well with `clear: true` on the run when even the mask's length should not
|
|
1519
|
+
* stay behind.
|
|
1520
|
+
*
|
|
1521
|
+
* The message `validate` returns is drawn as it is, unmasked: a message that echoes the value (`"ghp_abc is a
|
|
1522
|
+
* token"`) draws the secret in the frame. Say what is wrong without quoting the value.
|
|
1523
|
+
*/
|
|
1524
|
+
readonly mask?: string | true | ((value: string) => boolean);
|
|
1392
1525
|
}
|
|
1393
1526
|
/**
|
|
1394
1527
|
* Props of {@link TextInput.View}.
|
|
@@ -1435,8 +1568,8 @@ export declare class TextInput {
|
|
|
1435
1568
|
static readonly init: (options?: TextInputInitOptions) => TextInputState;
|
|
1436
1569
|
/**
|
|
1437
1570
|
* Apply a key: a typed character (any, `q` included) or space is inserted at the cursor; backspace and delete
|
|
1438
|
-
* remove
|
|
1439
|
-
* key changes nothing.
|
|
1571
|
+
* remove the grapheme before or after it; left and right move it a grapheme, home and end to either end, clamped
|
|
1572
|
+
* to the text; enter marks it submitted. Every other key changes nothing.
|
|
1440
1573
|
*
|
|
1441
1574
|
* @param state - where the input is
|
|
1442
1575
|
* @param key - the key pressed
|
|
@@ -1444,7 +1577,8 @@ export declare class TextInput {
|
|
|
1444
1577
|
static readonly step: (state: TextInputState, key: UiKey) => TextInputState;
|
|
1445
1578
|
/**
|
|
1446
1579
|
* Draw the input: the message, the value with the cursor shown as `▏` (`|` under ASCII glyphs, so it stays visible
|
|
1447
|
-
* without colour),
|
|
1580
|
+
* without colour), or one mask per grapheme in its place with `mask`, the placeholder while empty, a validation
|
|
1581
|
+
* message in the error token, and the key help. Enter
|
|
1448
1582
|
* submits when `validate` passes; otherwise its message is shown until the next key other than enter, or a paste.
|
|
1449
1583
|
*
|
|
1450
1584
|
* @remarks
|
|
@@ -1459,7 +1593,15 @@ export declare class TextInput {
|
|
|
1459
1593
|
/**
|
|
1460
1594
|
* A ready-made screen for `CliUi.run`: the input, resolving with the submitted text.
|
|
1461
1595
|
*
|
|
1462
|
-
* @
|
|
1596
|
+
* @remarks
|
|
1597
|
+
* With `mask`, a secret is drawn as one mask per grapheme and never as itself, while the screen still resolves with
|
|
1598
|
+
* the real text:
|
|
1599
|
+
*
|
|
1600
|
+
* ```ts
|
|
1601
|
+
* const token = CliUi.run(TextInput.screen({ message: "Token reference?", mask: true }), { clear: true })
|
|
1602
|
+
* ```
|
|
1603
|
+
*
|
|
1604
|
+
* @param options - the message, the starting text, the placeholder, the validator and the mask
|
|
1463
1605
|
*/
|
|
1464
1606
|
static readonly screen: (options: TextInputScreenOptions) => Screen<string>;
|
|
1465
1607
|
}
|