Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/tests/test.ts
3544 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
56
prereq?: () => Promise<boolean>;
57
58
// Cleans up the test
59
teardown?: () => Promise<void>;
60
61
// Sets up the test
62
setup?: () => Promise<void>;
63
64
// Request that the test be run from another working directory
65
cwd?: () => string;
66
67
// Control of underlying sanitizer
68
sanitize?: { resources?: boolean; ops?: boolean; exit?: boolean };
69
70
// control if test is ran or skipped
71
ignore?: boolean;
72
73
// environment to pass to downstream processes
74
env?: Record<string, string>;
75
}
76
77
// Allow to merge test contexts in Tests helpers
78
export function mergeTestContexts(baseContext: TestContext, additionalContext?: TestContext): TestContext {
79
if (!additionalContext) {
80
return baseContext;
81
}
82
83
return {
84
// override name if provided
85
name: additionalContext.name || baseContext.name,
86
// combine prereq conditions
87
prereq: async () => {
88
const baseResult = !baseContext.prereq || await baseContext.prereq();
89
const additionalResult = !additionalContext.prereq || await additionalContext.prereq();
90
return baseResult && additionalResult;
91
},
92
// run teardowns in reverse order
93
teardown: async () => {
94
if (baseContext.teardown) await baseContext.teardown();
95
if (additionalContext.teardown) await additionalContext.teardown();
96
},
97
// run setups in order
98
setup: async () => {
99
if (additionalContext.setup) await additionalContext.setup();
100
if (baseContext.setup) await baseContext.setup();
101
},
102
// override cwd if provided
103
cwd: additionalContext.cwd || baseContext.cwd,
104
// merge sanitize options
105
sanitize: {
106
resources: additionalContext.sanitize?.resources ?? baseContext.sanitize?.resources,
107
ops: additionalContext.sanitize?.ops ?? baseContext.sanitize?.ops,
108
exit: additionalContext.sanitize?.exit ?? baseContext.sanitize?.exit,
109
},
110
// override ignore if provided
111
ignore: additionalContext.ignore ?? baseContext.ignore,
112
// merge env with additional context taking precedence
113
env: { ...baseContext.env, ...additionalContext.env },
114
};
115
}
116
117
export function testQuartoCmd(
118
cmd: string,
119
args: string[],
120
verify: Verify[],
121
context?: TestContext,
122
name?: string,
123
logConfig?: TestLogConfig,
124
) {
125
if (name === undefined) {
126
name = `quarto ${cmd} ${args.join(" ")}`;
127
}
128
test({
129
name,
130
execute: async () => {
131
const timeout = new Promise((_resolve, reject) => {
132
setTimeout(reject, 600000, "timed out after 10 minutes");
133
});
134
await Promise.race([
135
quarto([cmd, ...args], undefined, context?.env),
136
timeout,
137
]);
138
},
139
verify,
140
context: context || {},
141
type: "smoke",
142
logConfig, // Pass log config to test
143
});
144
}
145
146
export interface Verify {
147
name: string;
148
verify: (outputs: ExecuteOutput[]) => Promise<void>;
149
}
150
151
export interface ExecuteOutput {
152
msg: string;
153
level: number;
154
levelName: string;
155
}
156
157
export function unitTest(
158
name: string,
159
ver: () => Promise<unknown>, // VoidFunction,
160
context?: TestContext,
161
) {
162
test({
163
name,
164
type: "unit",
165
context: context || {},
166
execute: () => {
167
return Promise.resolve();
168
},
169
verify: [
170
{
171
name: `${name}`,
172
verify: async (_outputs: ExecuteOutput[]) => {
173
const timeout = new Promise((_resolve, reject) => {
174
setTimeout(() => reject(new AssertionError(`timed out after 2 minutes. Something may be wrong with verify function in the test '${name}'.`)), 120000);
175
});
176
await Promise.race([ver(), timeout]);
177
},
178
},
179
],
180
});
181
}
182
183
export function test(test: TestDescriptor) {
184
const testName = test.context.name
185
? `[${test.type}] > ${test.name} (${test.context.name})`
186
: `[${test.type}] > ${test.name}`;
187
188
const sanitizeResources = test.context.sanitize?.resources;
189
const sanitizeOps = test.context.sanitize?.ops;
190
const sanitizeExit = test.context.sanitize?.exit;
191
const ignore = test.context.ignore;
192
const userSession = !runningInCI();
193
194
const args: Deno.TestDefinition = {
195
name: testName,
196
async fn(context) {
197
await initDenoDom();
198
const runTest = !test.context.prereq || await test.context.prereq();
199
if (runTest) {
200
const wd = Deno.cwd();
201
if (test.context?.cwd) {
202
Deno.chdir(test.context.cwd());
203
}
204
205
if (test.context.setup) {
206
await test.context.setup();
207
}
208
209
let cleanedup = false;
210
const cleanupLogOnce = async () => {
211
if (!cleanedup) {
212
await cleanupLogger();
213
cleanedup = true;
214
}
215
};
216
217
// Capture the output
218
const log = Deno.makeTempFileSync({ suffix: ".json" });
219
const handlers = await initializeLogger({
220
log: test.logConfig?.log || log,
221
level: test.logConfig?.level || "INFO",
222
format: test.logConfig?.format || "json-stream",
223
quiet: true,
224
});
225
226
const logOutput = (path: string) => {
227
if (existsSync(path)) {
228
return readExecuteOutput(path);
229
} else {
230
return undefined;
231
}
232
};
233
let lastVerify;
234
try {
235
236
try {
237
await test.execute();
238
} catch (e) {
239
logError(e);
240
}
241
242
// Cleanup the output logging
243
await cleanupLogOnce();
244
245
flushLoggers(handlers);
246
247
// Read the output
248
const testOutput = logOutput(log);
249
if (testOutput) {
250
for (const ver of test.verify) {
251
lastVerify = ver;
252
if (userSession) {
253
const verifyMsg = "[verify] > " + ver.name;
254
console.log(userSession ? colors.dim(verifyMsg) : verifyMsg);
255
}
256
await ver.verify(testOutput);
257
}
258
}
259
} catch (ex) {
260
if (!(ex instanceof Error)) throw ex;
261
const border = "-".repeat(80);
262
const coloredName = userSession
263
? colors.brightGreen(colors.italic(testName))
264
: testName;
265
266
// Compute an inset based upon the testName
267
const offset = testName.indexOf(">");
268
269
// Form the test runner command
270
const absPath = isWindows
271
? fromFileUrl(context.origin)
272
: (new URL(context.origin)).pathname;
273
274
const quartoRoot = join(quartoConfig.binPath(), "..", "..", "..");
275
const relPath = relative(
276
join(quartoRoot, "tests"),
277
absPath,
278
);
279
const command = isWindows
280
? "run-tests.ps1"
281
: "./run-tests.sh";
282
const testCommand = `${
283
offset > 0 ? " ".repeat(offset + 2) : ""
284
}${command} ${relPath}`;
285
const coloredTestCommand = userSession
286
? colors.brightGreen(testCommand)
287
: testCommand;
288
289
const verifyFailed = `[verify] > ${
290
lastVerify ? lastVerify.name : "unknown"
291
}`;
292
const coloredVerify = userSession
293
? colors.brightGreen(verifyFailed)
294
: verifyFailed;
295
296
const logMessages = logOutput(log);
297
const output: string[] = [
298
"",
299
"",
300
border,
301
coloredName,
302
coloredTestCommand,
303
"",
304
coloredVerify,
305
"",
306
ex.message,
307
ex.stack ?? "",
308
"",
309
];
310
311
if (logMessages && logMessages.length > 0) {
312
output.push("OUTPUT:");
313
logMessages.forEach((out) => {
314
const parts = out.msg.split("\n");
315
parts.forEach((part) => {
316
output.push(" " + part);
317
});
318
});
319
}
320
fail(output.join("\n"));
321
} finally {
322
safeRemoveSync(log);
323
await cleanupLogOnce();
324
if (test.context.teardown) {
325
await test.context.teardown();
326
}
327
328
if (test.context?.cwd) {
329
Deno.chdir(wd);
330
}
331
}
332
} else {
333
warning(`Skipped - ${test.name}`);
334
}
335
},
336
ignore,
337
sanitizeExit,
338
sanitizeOps,
339
sanitizeResources,
340
};
341
342
// work around 1.32.5 bug: https://github.com/denoland/deno/issues/18784
343
if (args.ignore === undefined) {
344
delete args.ignore;
345
}
346
Deno.test(args);
347
}
348
349
export function readExecuteOutput(log: string) {
350
const jsonStream = Deno.readTextFileSync(log);
351
const lines = jsonStream.split("\n").filter((line) => !!line);
352
return lines.map((line) => {
353
return JSON.parse(line) as ExecuteOutput;
354
});
355
}
356
357