1/**
2 * contains some utilities for writing tests. many are only useful for
3 * testing against other library modules.
4 *
5 * @module testing
6 */
7
8/**
9 * implements `Promise` but reactions are emitted instantly.
10 *
11 * this is used for some tests where the promise interface is used in a
12 * synchronous way. an example of this is the log and progress tests use this
13 * to satisfy the async interface function for `delay` but using code that runs
14 * fully synchronously.
15 */
16export class SyncPromise<T> implements Promise<T> {
17 [Symbol.toStringTag] = "SyncPromise";
18 #status: "pending" | "resolved" | "rejected" = "pending";
19 #value: unknown = null;
20 #resolve: Array<(value: T) => void> = [];
21 #reject: Array<(error: unknown) => void> = [];
22 #finally: Array<() => void> = [];
23
24 constructor(
25 init: (
26 resolve: (value: T) => void,
27 reject: (error: unknown) => void,
28 ) => void,
29 ) {
30 init(
31 (value) => {
32 if (this.#status !== "pending") return;
33 this.#status = "resolved";
34 this.#value = value;
35 this.#resolve.forEach((cb) => cb(value));
36 this.#finally.forEach((cb) => cb());
37 },
38 (error) => {
39 if (this.#status !== "pending") return;
40 this.#status = "rejected";
41 this.#value = error;
42 if (this.#reject.length === 0) throw error;
43 this.#reject.forEach((cb) => cb(error));
44 this.#finally.forEach((cb) => cb());
45 },
46 );
47 }
48
49 then<TResult1 = T, TResult2 = never>(
50 onfulfilled?:
51 | ((value: T) => TResult1 | PromiseLike<TResult1>)
52 | null
53 | undefined,
54 onrejected?:
55 | ((reason: any) => TResult2 | PromiseLike<TResult2>)
56 | null
57 | undefined,
58 ): Promise<TResult1 | TResult2> {
59 if (this.#status === "rejected") throw this.#value;
60 if (this.#status === "resolved") {
61 const result = onfulfilled
62 ? onfulfilled(this.#value as T)
63 : this.#value as T;
64 return new SyncPromise((resolve) => {
65 if (typeof result === "object" && result && "then" in result) {
66 result.then?.(resolve);
67 } else {
68 resolve(result as TResult1);
69 }
70 });
71 }
72 let resolve: (value: TResult1 | TResult2) => void;
73 let reject: (error: unknown) => void;
74 function react<X>(
75 value: X,
76 reactor: (
77 value: X,
78 ) => TResult1 | TResult2 | PromiseLike<TResult1 | TResult2>,
79 ) {
80 try {
81 const result = reactor(value);
82 if (typeof result === "object" && result && "then" in result) {
83 result.then?.(resolve);
84 } else {
85 resolve(result);
86 }
87 } catch (error) {
88 reject(error);
89 }
90 }
91 this.#resolve.push((value) => onfulfilled && react(value, onfulfilled));
92 if (onrejected) this.#reject.push((value) => react(value, onrejected));
93 return new SyncPromise((innerResolve, innerReject) => {
94 resolve = innerResolve;
95 reject = innerReject;
96 });
97 }
98 catch<TResult = never>(
99 onrejected?:
100 | ((reason: any) => TResult | PromiseLike<TResult>)
101 | null
102 | undefined,
103 ): Promise<T | TResult> {
104 return this.then((x) => x, onrejected);
105 }
106 finally(onfinally?: (() => void) | null | undefined): Promise<T> {
107 if (!onfinally) return this;
108 if (this.#status === "pending") this.#finally.push(onfinally);
109 else onfinally();
110 return this;
111 }
112}
113
114export interface FakeTimerEntry {
115 deadline: number;
116 resolve: () => void;
117 src: stack.Frame[];
118}
119
120export class FakeTimers {
121 time = 0;
122 entries: Array<FakeTimerEntry> = [];
123
124 now: () => number = () => {
125 return this.time;
126 };
127 delay: typeof async.delay = (ms) => {
128 const src = stack.capture(2);
129 let entry: (typeof this.entries)[number];
130 return async.makeCancelable(
131 new SyncPromise((resolve) => {
132 entry = { deadline: this.time + ms, resolve, src };
133 this.entries.push(entry);
134 }),
135 () => {
136 const i = this.entries.indexOf(entry);
137 if (i !== -1) this.entries.splice(i, 1);
138 },
139 );
140 };
141
142 /** the pending timer that fires soonest */
143 next(): (typeof this.entries)[number] | undefined {
144 return this.entries.toSorted((a, b) => a.deadline - b.deadline)[0];
145 }
146
147 /** let `ms` pass, firing the timers that come due in order */
148 advance(ms: number): void {
149 const end = this.time + ms;
150 let entry;
151 while ((entry = this.next()) && entry.deadline <= end) {
152 this.entries.splice(this.entries.indexOf(entry), 1);
153 this.time = entry.deadline;
154 entry.resolve();
155 }
156 this.time = end;
157 }
158}
159
160/**
161 * a terminal emulator covering the escape sequences `log.ts` writes, to
162 * assert on what ends up on screen instead of on how it was drawn.
163 */
164export class VirtualTerminal {
165 columns: number;
166 rows: number;
167 /** cells of every line, including those scrolled off the top */
168 #lines: string[][] = [[]];
169 /** the lines that only began because the line before them was full */
170 #wrapped = new Set<string[]>();
171 /** index in `#lines` of the first row on screen */
172 #top = 0;
173 #row = 0;
174 /** equal to `columns` when the cursor is waiting to wrap */
175 #column = 0;
176 /** whether text that reaches the last column continues on the next row */
177 #wraps = true;
178 /**
179 * resize the way xterm.js does, which most terminals inside of editors
180 * are: the cursor's line is cut off instead of wrapped, and the cursor
181 * moves down for every row that is added, even under it.
182 */
183 xtermjs = false;
184 #saved = { row: 0, column: 0 };
185
186 constructor(columns: number, rows: number) {
187 this.columns = columns;
188 this.rows = rows;
189 }
190
191 /** everything written, scrollback included, without trailing blanks */
192 get text(): string {
193 return this.#lines
194 .map((cells) => Array.from(cells, (cell) => cell ?? " ").join("").trimEnd())
195 .join("\n")
196 .trimEnd();
197 }
198
199 /** position in terms of `text`: `line` is a line index, `column` is in cells */
200 get cursor(): { line: number; column: number } {
201 return {
202 line: this.#top + this.#row,
203 column: Math.min(this.#column, this.columns - 1),
204 };
205 }
206
207 write(data: string): void {
208 const sequence = /\x1b(?:\[([0-?]*)[ -\/]*([@-~])|[78])/y;
209 const segmenter = new Intl.Segmenter();
210 for (let i = 0; i < data.length;) {
211 const char = data[i]!;
212 if (char === "\x1b") {
213 sequence.lastIndex = i;
214 const match = UNWRAP(sequence.exec(data), "unknown escape sequence");
215 this.#control(match[0], match[1] ?? "", match[2]);
216 i += match[0].length;
217 } else if (char === "\n") {
218 this.#newline();
219 i += 1;
220 } else if (char === "\r") {
221 this.#column = 0;
222 i += 1;
223 } else {
224 const end = data.slice(i).search(/[\x1b\n\r]/);
225 const text = end === -1 ? data.slice(i) : data.slice(i, i + end);
226 for (const { segment } of segmenter.segment(text)) this.#print(segment);
227 i += text.length;
228 }
229 }
230 }
231
232 #line(row: number): string[] {
233 return this.#lines[this.#top + row] ??= [];
234 }
235
236 /** empty a line, which also ends its being part of the line before it */
237 #clear(row: number) {
238 const line = this.#line(row);
239 line.length = 0;
240 this.#wrapped.delete(line);
241 }
242
243 /**
244 * blank the cursor's line from `column` on. as in tmux, a line that keeps
245 * its start stays as long as it was, which is how long it is when wrapped.
246 */
247 #clearFrom(column: number) {
248 if (column === 0) this.#clear(this.#row);
249 else this.#line(this.#row).fill(" ", column);
250 }
251
252 /**
253 * change the size the way terminals do: lines that were wrapped are
254 * wrapped again to the new width, and the cursor stays with its text.
255 */
256 resize(columns: number, rows: number): void {
257 const cursorLine = this.#line(this.#row);
258 const topLine = this.#line(0);
259 const lines: string[][] = [];
260 let cursor = { line: 0, column: 0 };
261 let top = 0;
262 let passedCursor = false;
263
264 // put together the text of each run of wrapped lines, then wrap it anew
265 for (let i = 0; i < this.#lines.length;) {
266 const run: string[][] = [];
267 const text: string[] = [];
268 let offset = -1;
269 const first = lines.length;
270 do {
271 const line = UNWRAP(this.#lines[i]);
272 if (line === cursorLine) offset = text.length + this.#column;
273 if (line === topLine) top = first;
274 run.push(line);
275 text.push(...Array.from(line, (cell) => cell ?? " "));
276 i += 1;
277 } while (i < this.#lines.length && this.#wrapped.has(UNWRAP(this.#lines[i])));
278
279 if (this.xtermjs && offset !== -1) {
280 for (const line of run) line.length = Math.min(line.length, columns);
281 cursor = { line: first + run.indexOf(cursorLine), column: Math.min(this.#column, columns - 1) };
282 lines.push(...run);
283 passedCursor = true;
284 continue;
285 }
286
287 for (let start = 0; start === 0 || start < text.length;) {
288 // a wide character is not split from the cell that it continues into
289 let end = Math.min(text.length, start + columns);
290 if (text[end] === "" && end > start + 1) end -= 1;
291 if (offset >= start && (offset < end || end === text.length)) {
292 cursor = { line: lines.length, column: Math.min(offset - start, columns) };
293 }
294 const cells = text.slice(start, end);
295 if (start > 0) this.#wrapped.add(cells);
296 lines.push(cells);
297 start = Math.max(end, start + 1);
298 }
299 if (this.xtermjs && passedCursor) cursor.line += lines.length - first - run.length;
300 }
301
302 this.columns = columns;
303 this.rows = rows;
304 this.#lines = lines;
305 // what was on screen stays there, unless the cursor would leave it
306 this.#top = Math.max(top, cursor.line - rows + 1);
307 this.#row = cursor.line - this.#top;
308 this.#column = cursor.column;
309 }
310
311 #newline(wrapped = false) {
312 this.#column = 0;
313 if (this.#row === this.rows - 1) this.#top += 1;
314 else this.#row += 1;
315 const line = this.#line(this.#row);
316 if (wrapped) this.#wrapped.add(line);
317 }
318
319 #print(grapheme: string) {
320 const width = ansi.widthInTerminal(grapheme);
321 if (width === 0) return;
322 if (this.#column + width > this.columns) {
323 if (this.#wraps) this.#newline(true);
324 else this.#column = Math.max(0, this.columns - width);
325 }
326 const line = this.#line(this.#row);
327 line[this.#column] = grapheme;
328 for (let i = 1; i < width; i += 1) line[this.#column + i] = "";
329 this.#column += width;
330 }
331
332 #control(sequence: string, params: string, final: string | undefined) {
333 if (sequence === "\x1b7") {
334 this.#saved = { row: this.#row, column: this.#column };
335 return;
336 }
337 if (sequence === "\x1b8") {
338 this.#row = this.#saved.row;
339 this.#column = this.#saved.column;
340 return;
341 }
342 if (params === "?7") this.#wraps = final === "h";
343 // other private sequences set modes that have no effect on the grid
344 if (/^[<=>?]/.test(params)) return;
345
346 const [first = 0, second = 0] = params.split(";").map(Number);
347 const count = first || 1;
348 const column = Math.min(this.#column, this.columns - 1);
349 const line = this.#line(this.#row);
350 if (final === "A") this.#row = Math.max(0, this.#row - count);
351 else if (final === "B") this.#row = Math.min(this.rows - 1, this.#row + count);
352 else if (final === "C") this.#column = Math.min(this.columns - 1, column + count);
353 else if (final === "D") this.#column = Math.max(0, column - count);
354 else if (final === "H") {
355 this.#row = Math.min(this.rows, first || 1) - 1;
356 this.#column = Math.min(this.columns, second || 1) - 1;
357 } else if (final === "K") {
358 if (first === 0) this.#clearFrom(column);
359 else if (first === 1) line.fill(" ", 0, column + 1);
360 else this.#clear(this.#row);
361 } else if (final === "J") {
362 const from = first === 0 ? this.#row + 1 : 0;
363 if (first === 0) this.#clearFrom(column);
364 for (let row = from; row < this.rows; row += 1) {
365 if (this.#top + row < this.#lines.length) this.#clear(row);
366 }
367 } else if (final !== "m") {
368 throw new Error(`unknown escape sequence: ${JSON.stringify(sequence)}`);
369 }
370 }
371}
372
373export function removeUndefinedKeys(obj: unknown): unknown {
374 if (typeof obj !== "object" || !obj || Array.isArray(obj)) return obj;
375 return Object.fromEntries(
376 Object.getOwnPropertyNames(obj)
377 .map((k) => [k, (obj as any)[k]])
378 .filter(([, v]) => v !== undefined),
379 );
380}
381
382interface WriteCall {
383 kind: "interactive" | "output";
384 stack: stack.Frame[];
385 content: string;
386}
387
388/** implements a log.HeadlessWidgetHost that acts as a fake screen. */
389export class MockScreen implements Disposable, log.WidgetHost {
390 columns = 80;
391 rows = 33;
392
393 stdout: string = "";
394 stderr: string = "";
395 out: string = "";
396 writeCalls: WriteCall[] = [];
397
398 timers: FakeTimers = new FakeTimers();
399 /** receives everything that lands on screen, across frames */
400 terminal: VirtualTerminal = new VirtualTerminal(this.columns, this.rows);
401
402 writeOutput: log.WidgetHost["writeOutput"];
403 writeError: log.WidgetHost["writeError"];
404 getDrawLock: log.WidgetHost["getDrawLock"];
405 startWidget: log.WidgetHost["startWidget"];
406 delay: log.WidgetHost["delay"];
407 now: log.WidgetHost["now"];
408
409 hasTerminalLock: null | "locked" | "temporary-unlock" = null;
410 #sizeObservers = new Set<(size: { columns: number; rows: number }) => void>();
411 #input: ((data: string) => void) | null = null;
412
413 static sync(text: string[]): string {
414 return ansi.syncStart + text.join("") + ansi.syncEnd;
415 }
416
417 constructor({ temporaryUnlocking, outputSharesScreen }: {
418 temporaryUnlocking?: boolean;
419 outputSharesScreen?: boolean;
420 } = {}) {
421 const callerFile = UNWRAP(stack.capture()[0]);
422 const host = log.createTerminalWidgetHost({
423 lockTerminal: () => {
424 ASSERT(!this.hasTerminalLock);
425 this.hasTerminalLock = "locked";
426 return {
427 writeInteractive: (content) => {
428 this.stderr += content;
429 this.out += content;
430 this.terminal.write(content);
431 const frames = stack.capture().filter((x) => x.file !== import.meta.filename);
432 const cutoff = frames.findIndex((f) => f.file === callerFile.file && f.fn === callerFile.fn);
433 this.writeCalls.push({
434 kind: "interactive",
435 stack: cutoff === -1 ? frames : frames.slice(0, cutoff),
436 content,
437 });
438 },
439 writeOutput: (content) => {
440 this.stdout += content;
441 this.out += content;
442 if (outputSharesScreen ?? true) this.terminal.write(content);
443 const frames = stack.capture().filter((x) => x.file !== import.meta.filename);
444 const cutoff = frames.findIndex((f) => f.file === callerFile.file && f.fn === callerFile.fn);
445 this.writeCalls.push({
446 kind: "output",
447 stack: cutoff === -1 ? frames : frames.slice(0, cutoff),
448 content,
449 });
450 },
451 observeSize: (callback) => {
452 callback({ columns: this.columns, rows: this.rows });
453 this.#sizeObservers.add(callback);
454 return () => void this.#sizeObservers.delete(callback);
455 },
456 temporaryUnlock: temporaryUnlocking
457 ? () => {
458 ASSERT(this.hasTerminalLock === "locked");
459 this.hasTerminalLock = "temporary-unlock";
460 return () => {
461 this.hasTerminalLock = "locked";
462 };
463 }
464 : undefined,
465 close: () => {
466 ASSERT(this.hasTerminalLock === "locked");
467 this.hasTerminalLock = null;
468 },
469 };
470 },
471 now: this.timers.now,
472 delay: this.timers.delay,
473 color: true,
474 outputSharesScreen,
475 observeInput: (callback) => {
476 ASSERT(this.hasTerminalLock && !this.#input);
477 this.#input = callback;
478 return () => {
479 this.#input = null;
480 };
481 },
482 });
483 this.writeOutput = host.writeOutput;
484 this.writeError = host.writeError;
485 this.getDrawLock = host.getDrawLock;
486 this.startWidget = host.startWidget;
487 this.delay = host.delay;
488 this.now = host.now;
489 }
490
491 /** simulate a terminal resize (SIGWINCH) */
492 resize(columns: number, rows: number = this.rows) {
493 this.columns = columns;
494 this.rows = rows;
495 this.terminal.resize(columns, rows);
496 this.#sizeObservers.forEach((cb) => cb({ columns, rows }));
497 }
498
499 /** whether the keyboard is being read (the terminal is in raw mode) */
500 get reading(): boolean {
501 return this.#input !== null;
502 }
503
504 /** simulate the terminal sending keyboard input */
505 type(data: string) {
506 UNWRAP(this.#input, "the keyboard is not being read")(data);
507 }
508
509 /**
510 * let `ms` pass, drawing whatever comes due. the writes are dropped
511 * instead of being held for `expectFrame`; observe `terminal` instead.
512 */
513 advance(ms = 0) {
514 this.timers.advance(ms);
515 this.stdout = "";
516 this.stderr = "";
517 this.out = "";
518 this.writeCalls = [];
519 }
520
521 expectNone() {
522 ASSERT(this.timers.entries.length === 0);
523 }
524
525 expectWithoutConsume(ms: number): FakeTimerEntry {
526 const wait = UNWRAP(
527 this.timers.next(),
528 () => this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o",
529 );
530 const duration = wait.deadline - this.timers.time;
531 ASSERT(
532 ms === duration,
533 `expected ${ms}ms to pass, got ${duration}, from:\n${
534 wait.src.map((frame) => stack.formatFrame(frame, true)).join("\n")
535 }`,
536 );
537 return wait;
538 }
539
540 expectFrame(ms: number | null, { stdout, stderr, merged: out }: {
541 stdout?: string;
542 stderr?: string;
543 merged?: string;
544 }) {
545 if (ms != null) {
546 const wait = this.expectWithoutConsume(ms);
547 this.timers.entries.splice(this.timers.entries.indexOf(wait), 1);
548 this.timers.time = wait.deadline;
549 wait.resolve();
550 } else {
551 ASSERT(this.timers.entries.length === 0);
552 }
553
554 ASSERT(
555 out == null || this.out === out,
556 () =>
557 `merged out does not match\n`
558 + `expected: ${ansi.debugAnsi(out ?? "")}\n`
559 + `actual: ${ansi.debugAnsi(this.out)}\n`
560 + this.fmtWriteCalls(),
561 );
562 ASSERT(
563 stdout == null || this.stdout === stdout,
564 () =>
565 `standard out does not match\n`
566 + `expected: ${ansi.debugAnsi(stdout ?? "")}\n`
567 + `actual: ${ansi.debugAnsi(this.stdout)}\n`
568 + this.fmtWriteCalls(),
569 );
570 ASSERT(
571 stderr == null || this.stderr === stderr,
572 () =>
573 `interactive out does not match\n`
574 + `expected: ${ansi.debugAnsi(stderr ?? "")}\n`
575 + `actual: ${ansi.debugAnsi(this.stderr)}\n`
576 + this.fmtWriteCalls(),
577 );
578 this.stdout = "";
579 this.stderr = "";
580 this.out = "";
581 this.writeCalls = [];
582 }
583
584 fmtWriteCalls(): string {
585 return `\n${ansi.reset}breakdown of calls that rendered this frame:\n`
586 + this.writeCalls.map((call, i) =>
587 `${i + 1}. [${call.kind}] ${ansi.debugAnsi(call.content)}\n`
588 + call.stack.map((frame) => ansi.reset + stack.formatFrame(frame, true))
589 .join("\n")
590 + `\n`
591 ).join("\n").replaceAll(ansi.fgReset, ansi.reset);
592 }
593
594 [Symbol.dispose]() {
595 this.cancel();
596 }
597
598 capabilities = ["widget", "color"] as const;
599
600 cancel() {
601 ASSERT(this.timers.entries.length === 0, "there is a pending write!");
602 ASSERT(!this.stdout, "unread standard out: " + this.stdout);
603 ASSERT(!this.stderr, "unread interactive out: " + this.stderr);
604 }
605}
606
607import { ASSERT, UNWRAP } from "./assert.ts";
608import * as async from "./async.ts";
609import * as log from "./log.ts";
610import * as stack from "./log/stack.ts";
611import * as ansi from "./string/ansi.ts";