Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/tests/test.ts
6429 views
1
/*
2
* test.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*
6
*/
7
import { existsSync, safeRemoveSync } from "../src/deno_ral/fs.ts";
8
import { AssertionError, fail } from "testing/asserts";
9
import { warning } from "../src/deno_ral/log.ts";
10
import { initDenoDom } from "../src/core/deno-dom.ts";
11
12
import { cleanupLogger, initializeLogger, flushLoggers, logError, LogLevel, LogFormat } from "../src/core/log.ts";
13
import { quarto } from "../src/quarto.ts";
14
import { join } from "../src/deno_ral/path.ts";
15
import * as colors from "fmt/colors";
16
import { runningInCI } from "../src/core/ci-info.ts";
17
import { relative, fromFileUrl } from "../src/deno_ral/path.ts";
18
import { quartoConfig } from "../src/core/quarto.ts";
19
import { isWindows } from "../src/deno_ral/platform.ts";
20
21
22
export interface TestLogConfig {
23
// Path to log file
24
log?: string;
25
26
// Log level
27
level?: LogLevel;
28
29
// Log format
30
format?: LogFormat;
31
}
32
export interface TestDescriptor {
33
// The name of the test
34
name: string;
35
36
// Sets up the test
37
context: TestContext;
38
39
// Executes the test
40
execute: () => Promise<void>;
41
42
// Used to verify the outcome of the test
43
verify: Verify[];
44
45
// type of test
46
type: "smoke" | "unit";
47
48
// Optional logging configuration
49
logConfig?: TestLogConfig;
50
}
51
52
export interface TestContext {
53
name?: string;
54
55
// Checks that prereqs for the test are met (async conditional skip)
56
// - Returns false: Test is SKIPPED with warning message (not failed)
57
// - Throws/rejects: Test is SKIPPED (initialization failed gracefully)
58
// Use cases:
59
// - Tool availability checks (e.g., which("rsvg-convert"))
60
// - Initialization that might fail (e.g., schema loading)
61
// Difference from ignore: Can be async, runs inside test, handles exceptions
62
prereq?: () => Promise<boolean>;
63
64
// Cleans up the test
65
teardown?: () => Promise<void>;
66
67
// Sets up the test
68
setup?: () => Promise<void>;
69
70
// Request that the test be run from another working directory
71
cwd?: () => string;
72
73
// Control of underlying sanitizer
74
sanitize?: { resources?: boolean; ops?: boolean; exit?: boolean };
75
76
// Control if test is ran or skipped (static boolean only)
77
// - true: Test is completely IGNORED by Deno (not run, not counted)
78
// - false: Test runs normally
79
// Use cases:
80
// - Static platform checks (e.g., isWindows)
81
// - Static configuration flags
82
// Limitation: Must be a simple boolean value computed at registration time
83
// For dynamic/async conditional skip (e.g., tool availability), use prereq instead
84
ignore?: boolean;
85
86
// environment to pass to downstream processes
87
env?: Record<string, string>;
88
}
89
90
// Allow to merge test contexts in Tests helpers
91
export function mergeTestContexts(baseContext: TestContext, additionalContext?: TestContext): TestContext {
92
if (!additionalContext) {
93
return baseContext;
94
}
95
96
return {
97
// override name if provided
98
name: additionalContext.name || baseContext.name,
99
// combine prereq conditions
100
prereq: async () => {
101
const baseResult = !baseContext.prereq || await baseContext.prereq();
102
const additionalResult = !additionalContext.prereq || await additionalContext.prereq();
103
return baseResult && additionalResult;
104
},
105
// run teardowns in reverse order
106
teardown: async () => {
107
if (baseContext.teardown) await baseContext.teardown();
108
if (additionalContext.teardown) await additionalContext.teardown();
109
},
110
// run setups in order
111
setup: async () => {
112
if (additionalContext.setup) await additionalContext.setup();
113
if (baseContext.setup) await baseContext.setup();
114
},
115
// override cwd if provided
116
cwd: additionalContext.cwd || baseContext.cwd,
117
// merge sanitize options
118
sanitize: {
119
resources: additionalContext.sanitize?.resources ?? baseContext.sanitize?.resources,
120
ops: additionalContext.sanitize?.ops ?? baseContext.sanitize?.ops,
121
exit: additionalContext.sanitize?.exit ?? baseContext.sanitize?.exit,
122
},
123
// override ignore if provided
124
ignore: additionalContext.ignore ?? baseContext.ignore,
125
// merge env with additional context taking precedence
126
env: { ...baseContext.env, ...additionalContext.env },
127
};
128
}
129
130
export function testQuartoCmd(
131
cmd: string,
132
args: string[],
133
verify: Verify[],
134
context?: TestContext,
135
name?: string,
136
logConfig?: TestLogConfig,
137
) {
138
if (name === undefined) {
139
name = `quarto ${cmd} ${args.join(" ")}`;
140
}
141
test({
142
name,
143
execute: async () => {
144
const timeout = new Promise((_resolve, reject) => {
145
setTimeout(reject, 600000, "timed out after 10 minutes");
146
});
147
await Promise.race([
148
quarto([cmd, ...args], undefined, context?.env),
149
timeout,
150
]);
151
},
152
verify,
153
context: context || {},
154
type: "smoke",
155
logConfig, // Pass log config to test
156
});
157
}
158
159
export interface Verify {
160
name: string;
161
verify: (outputs: ExecuteOutput[]) => Promise<void>;
162
}
163
164
export interface ExecuteOutput {
165
msg: string;
166
level: number;
167
levelName: string;
168
}
169
170
export function unitTest(
171
name: string,
172
ver: () => Promise<unknown>, // VoidFunction,
173
context?: TestContext,
174
) {
175
test({
176
name,
177
type: "unit",
178
context: context || {},
179
execute: () => {
180
return Promise.resolve();
181
},
182
verify: [
183
{
184
name: `${name}`,
185
verify: async (_outputs: ExecuteOutput[]) => {
186
const timeout = new Promise((_resolve, reject) => {
187
setTimeout(() => reject(new AssertionError(`timed out after 2 minutes. Something may be wrong with verify function in the test '${name}'.`)), 120000);
188
});
189
await Promise.race([ver(), timeout]);
190
},
191
},
192
],
193
});
194
}
195
196
export function test(test: TestDescriptor) {
197
const testName = test.context.name
198
? `[${test.type}] > ${test.name} (${test.context.name})`
199
: `[${test.type}] > ${test.name}`;
200
201
const sanitizeResources = test.context.sanitize?.resources;
202
const sanitizeOps = test.context.sanitize?.ops;
203
const sanitizeExit = test.context.sanitize?.exit;
204
const ignore = test.context.ignore;
205
const userSession = !runningInCI();
206
207
const args: Deno.TestDefinition = {
208
name: testName,
209
async fn(context) {
210
await initDenoDom();
211
const runTest = !test.context.prereq || await test.context.prereq();
212
if (runTest) {
213
const wd = Deno.cwd();
214
if (test.context?.cwd) {
215
Deno.chdir(test.context.cwd());
216
}
217
218
if (test.context.setup) {
219
await test.context.setup();
220
}
221
222
let cleanedup = false;
223
const cleanupLogOnce = async () => {
224
if (!cleanedup) {
225
await cleanupLogger();
226
cleanedup = true;
227
}
228
};
229
230
// Capture the output
231
const log = Deno.makeTempFileSync({ suffix: ".json" });
232
const handlers = await initializeLogger({
233
log: test.logConfig?.log || log,
234
level: test.logConfig?.level || "INFO",
235
format: test.logConfig?.format || "json-stream",
236
quiet: true,
237
});
238
239
const logOutput = (path: string) => {
240
if (existsSync(path)) {
241
return readExecuteOutput(path);
242
} else {
243
return undefined;
244
}
245
};
246
let lastVerify;
247
248
try {
249
250
try {
251
await test.execute();
252
} catch (e) {
253
logError(e);
254
}
255
256
// Cleanup the output logging
257
await cleanupLogOnce();
258
259
flushLoggers(handlers);
260
261
// Read the output
262
const testOutput = logOutput(log);
263
if (testOutput) {
264
for (const ver of test.verify) {
265
lastVerify = ver;
266
if (userSession) {
267
const verifyMsg = "[verify] > " + ver.name;
268
console.log(userSession ? colors.dim(verifyMsg) : verifyMsg);
269
}
270
await ver.verify(testOutput);
271
}
272
}
273
} catch (ex) {
274
if (!(ex instanceof Error)) throw ex;
275
276
const border = "-".repeat(80);
277
const coloredName = userSession
278
? colors.brightGreen(colors.italic(testName))
279
: testName;
280
281
// Compute an inset based upon the testName
282
const offset = testName.indexOf(">");
283
284
// Form the test runner command
285
const absPath = isWindows
286
? fromFileUrl(context.origin)
287
: (new URL(context.origin)).pathname;
288
289
const quartoRoot = join(quartoConfig.binPath(), "..", "..", "..");
290
const relPath = relative(
291
join(quartoRoot, "tests"),
292
absPath,
293
);
294
const command = isWindows
295
? "run-tests.ps1"
296
: "./run-tests.sh";
297
const testCommand = `${
298
offset > 0 ? " ".repeat(offset + 2) : ""
299
}${command} ${relPath}`;
300
const coloredTestCommand = userSession
301
? colors.brightGreen(testCommand)
302
: testCommand;
303
304
const verifyFailed = `[verify] > ${
305
lastVerify ? lastVerify.name : "unknown"
306
}`;
307
const coloredVerify = userSession
308
? colors.brightGreen(verifyFailed)
309
: verifyFailed;
310
311
const logMessages = logOutput(log);
312
313
// Create distinctive failure marker for easy log navigation
314
// This helps users find the failure when clicking GitHub Actions annotations
315
const failureMarker = `━━━ TEST FAILURE: ${testName}`;
316
const coloredFailureMarker = userSession
317
? colors.red(colors.bold(failureMarker))
318
: failureMarker;
319
320
const output: string[] = [
321
"",
322
"",
323
coloredFailureMarker,
324
border,
325
coloredName,
326
coloredTestCommand,
327
"",
328
coloredVerify,
329
"",
330
ex.message,
331
ex.stack ?? "",
332
"",
333
];
334
335
if (logMessages && logMessages.length > 0) {
336
output.push("OUTPUT:");
337
logMessages.forEach((out) => {
338
const parts = out.msg.split("\n");
339
parts.forEach((part) => {
340
output.push(" " + part);
341
});
342
});
343
}
344
345
fail(output.join("\n"));
346
} finally {
347
safeRemoveSync(log);
348
await cleanupLogOnce();
349
if (test.context.teardown) {
350
await test.context.teardown();
351
}
352
353
if (test.context?.cwd) {
354
Deno.chdir(wd);
355
}
356
}
357
} else {
358
warning(`Skipped - ${test.name}`);
359
}
360
},
361
ignore,
362
sanitizeExit,
363
sanitizeOps,
364
sanitizeResources,
365
};
366
367
// work around 1.32.5 bug: https://github.com/denoland/deno/issues/18784
368
if (args.ignore === undefined) {
369
delete args.ignore;
370
}
371
Deno.test(args);
372
}
373
374
export function readExecuteOutput(log: string) {
375
const jsonStream = Deno.readTextFileSync(log);
376
const lines = jsonStream.split("\n").filter((line) => !!line);
377
return lines.map((line) => {
378
return JSON.parse(line) as ExecuteOutput;
379
});
380
}
381
382