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