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
148export function removeUndefinedKeys(obj: unknown): unknown {
149 if (typeof obj !== "object" || !obj || Array.isArray(obj)) return obj;
150 return Object.fromEntries(
151 Object.getOwnPropertyNames(obj)
152 .map((k) => [k, (obj as any)[k]])
153 .filter(([, v]) => v !== undefined),
154 );
155}
156
157interface WriteCall {
158 kind: "interactive" | "output";
159 stack: stack.Frame[];
160 content: string;
161}
162
163/** implements a log.HeadlessWidgetHost that acts as a fake screen. */
164export class MockScreen implements Disposable, log.WidgetHost {
165 columns = 80;
166 rows = 33;
167
168 stdout: string = "";
169 stderr: string = "";
170 out: string = "";
171 writeCalls: WriteCall[] = [];
172
173 timers: FakeTimers = new FakeTimers();
174
175 writeOutput: log.WidgetHost["writeOutput"];
176 writeError: log.WidgetHost["writeError"];
177 getDrawLock: log.WidgetHost["getDrawLock"];
178 startWidget: log.WidgetHost["startWidget"];
179 delay: log.WidgetHost["delay"];
180 now: log.WidgetHost["now"];
181
182 hasTerminalLock: null | "locked" | "temporary-unlock" = null;
183 #sizeObservers = new Set<(size: { columns: number; rows: number }) => void>();
184
185 static sync(text: string[]): string {
186 return ansi.syncStart + text.join("") + ansi.syncEnd;
187 }
188
189 constructor({ temporaryUnlocking, outputSharesScreen }: {
190 temporaryUnlocking?: boolean;
191 outputSharesScreen?: boolean;
192 } = {}) {
193 const callerFile = UNWRAP(stack.capture()[0]);
194 const host = log.createTerminalWidgetHost({
195 lockTerminal: () => {
196 ASSERT(!this.hasTerminalLock);
197 this.hasTerminalLock = "locked";
198 return {
199 writeInteractive: (content) => {
200 this.stderr += content;
201 this.out += content;
202 const frames = stack.capture().filter((x) => x.file !== import.meta.filename);
203 const cutoff = frames.findIndex((f) => f.file === callerFile.file && f.fn === callerFile.fn);
204 this.writeCalls.push({
205 kind: "interactive",
206 stack: cutoff === -1 ? frames : frames.slice(0, cutoff),
207 content,
208 });
209 },
210 writeOutput: (content) => {
211 this.stdout += content;
212 this.out += content;
213 const frames = stack.capture().filter((x) => x.file !== import.meta.filename);
214 const cutoff = frames.findIndex((f) => f.file === callerFile.file && f.fn === callerFile.fn);
215 this.writeCalls.push({
216 kind: "output",
217 stack: cutoff === -1 ? frames : frames.slice(0, cutoff),
218 content,
219 });
220 },
221 observeSize: (callback) => {
222 callback({ columns: this.columns, rows: this.rows });
223 this.#sizeObservers.add(callback);
224 return () => void this.#sizeObservers.delete(callback);
225 },
226 temporaryUnlock: temporaryUnlocking
227 ? () => {
228 ASSERT(this.hasTerminalLock === "locked");
229 this.hasTerminalLock = "temporary-unlock";
230 return () => {
231 this.hasTerminalLock = "locked";
232 };
233 }
234 : undefined,
235 close: () => {
236 ASSERT(this.hasTerminalLock === "locked");
237 this.hasTerminalLock = null;
238 },
239 };
240 },
241 now: this.timers.now,
242 delay: this.timers.delay,
243 color: true,
244 outputSharesScreen,
245 });
246 this.writeOutput = host.writeOutput;
247 this.writeError = host.writeError;
248 this.getDrawLock = host.getDrawLock;
249 this.startWidget = host.startWidget;
250 this.delay = host.delay;
251 this.now = host.now;
252 }
253
254 /** simulate a terminal resize (SIGWINCH) */
255 resize(columns: number, rows: number = this.rows) {
256 this.columns = columns;
257 this.rows = rows;
258 this.#sizeObservers.forEach((cb) => cb({ columns, rows }));
259 }
260
261 expectNone() {
262 ASSERT(this.timers.entries.length === 0);
263 }
264
265 expectWithoutConsume(ms: number): FakeTimerEntry {
266 const wait = UNWRAP(
267 this.timers.next(),
268 () => this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o",
269 );
270 const duration = wait.deadline - this.timers.time;
271 ASSERT(
272 ms === duration,
273 `expected ${ms}ms to pass, got ${duration}, from:\n${
274 wait.src.map((frame) => stack.formatFrame(frame, true)).join("\n")
275 }`,
276 );
277 return wait;
278 }
279
280 expectFrame(ms: number | null, { stdout, stderr, merged: out }: {
281 stdout?: string;
282 stderr?: string;
283 merged?: string;
284 }) {
285 if (ms != null) {
286 const wait = this.expectWithoutConsume(ms);
287 this.timers.entries.splice(this.timers.entries.indexOf(wait), 1);
288 this.timers.time = wait.deadline;
289 wait.resolve();
290 } else {
291 ASSERT(this.timers.entries.length === 0);
292 }
293
294 ASSERT(
295 out == null || this.out === out,
296 () =>
297 `merged out does not match\n`
298 + `expected: ${ansi.debugAnsi(out ?? "")}\n`
299 + `actual: ${ansi.debugAnsi(this.out)}\n`
300 + this.fmtWriteCalls(),
301 );
302 ASSERT(
303 stdout == null || this.stdout === stdout,
304 () =>
305 `standard out does not match\n`
306 + `expected: ${ansi.debugAnsi(stdout ?? "")}\n`
307 + `actual: ${ansi.debugAnsi(this.stdout)}\n`
308 + this.fmtWriteCalls(),
309 );
310 ASSERT(
311 stderr == null || this.stderr === stderr,
312 () =>
313 `interactive out does not match\n`
314 + `expected: ${ansi.debugAnsi(stderr ?? "")}\n`
315 + `actual: ${ansi.debugAnsi(this.stderr)}\n`
316 + this.fmtWriteCalls(),
317 );
318 this.stdout = "";
319 this.stderr = "";
320 this.out = "";
321 this.writeCalls = [];
322 }
323
324 fmtWriteCalls(): string {
325 return `\n${ansi.reset}breakdown of calls that rendered this frame:\n`
326 + this.writeCalls.map((call, i) =>
327 `${i + 1}. [${call.kind}] ${ansi.debugAnsi(call.content)}\n`
328 + call.stack.map((frame) => ansi.reset + stack.formatFrame(frame, true))
329 .join("\n")
330 + `\n`
331 ).join("\n").replaceAll(ansi.fgReset, ansi.reset);
332 }
333
334 [Symbol.dispose]() {
335 this.cancel();
336 }
337
338 capabilities = ["widget", "color"] as const;
339
340 cancel() {
341 ASSERT(this.timers.entries.length === 0, "there is a pending write!");
342 ASSERT(!this.stdout, "unread standard out: " + this.stdout);
343 ASSERT(!this.stderr, "unread interactive out: " + this.stderr);
344 }
345}
346
347import { ASSERT, UNWRAP } from "./assert.ts";
348import * as async from "./async.ts";
349import * as log from "./log.ts";
350import * as stack from "./log/stack.ts";
351import * as ansi from "./string/ansi.ts";