Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/tests/smoke/smoke-all.test.ts
6434 views
1
/*
2
* smoke-all.test.ts
3
*
4
* Copyright (C) 2022 Posit Software, PBC
5
*/
6
7
import { expandGlobSync } from "../../src/core/deno/expand-glob.ts";
8
import { testQuartoCmd, Verify } from "../test.ts";
9
import { initYamlIntelligenceResourcesFromFilesystem } from "../../src/core/schema/utils.ts";
10
import {
11
initState,
12
setInitializer,
13
} from "../../src/core/lib/yaml-validation/state.ts";
14
import { os } from "../../src/deno_ral/platform.ts";
15
import { asArray } from "../../src/core/array.ts";
16
17
import { breakQuartoMd } from "../../src/core/lib/break-quarto-md.ts";
18
import { parse } from "../../src/core/yaml.ts";
19
import { cleanoutput } from "./render/render.ts";
20
import {
21
ensureEpubFileRegexMatches,
22
ensureDocxRegexMatches,
23
ensureDocxXpath,
24
ensureFileRegexMatches,
25
ensureHtmlElements,
26
ensureIpynbCellMatches,
27
ensurePdfRegexMatches,
28
ensurePdfTextPositions,
29
ensurePdfMetadata,
30
ensureJatsXpath,
31
ensureOdtXpath,
32
ensurePptxRegexMatches,
33
ensureTypstFileRegexMatches,
34
ensureSnapshotMatches,
35
fileExists,
36
noErrors,
37
noErrorsOrWarnings,
38
ensurePptxXpath,
39
ensurePptxLayout,
40
ensurePptxMaxSlides,
41
ensureLatexFileRegexMatches,
42
printsMessage,
43
shouldError,
44
ensureHtmlElementContents,
45
ensureHtmlElementCount,
46
} from "../verify.ts";
47
import { readYamlFromMarkdown } from "../../src/core/yaml.ts";
48
import { findProjectDir, findProjectOutputDir, outputForInput } from "../utils.ts";
49
import { jupyterNotebookToMarkdown } from "../../src/command/convert/jupyter.ts";
50
import { basename, dirname, join, relative } from "../../src/deno_ral/path.ts";
51
import { WalkEntry } from "../../src/deno_ral/fs.ts";
52
import { quarto } from "../../src/quarto.ts";
53
import { safeExistsSync, safeRemoveSync } from "../../src/core/path.ts";
54
import { runningInCI } from "../../src/core/ci-info.ts";
55
56
async function fullInit() {
57
await initYamlIntelligenceResourcesFromFilesystem();
58
}
59
60
async function guessFormat(fileName: string): Promise<string[]> {
61
const { cells } = await breakQuartoMd(Deno.readTextFileSync(fileName));
62
63
const formats: Set<string> = new Set();
64
65
for (const cell of cells) {
66
if (cell.cell_type === "raw") {
67
const src = cell.source.value.replaceAll(/^---$/mg, "");
68
let yaml;
69
try {
70
yaml = parse(src);
71
} catch (e) {
72
if (!(e instanceof Error)) throw e;
73
if (e.message.includes("unknown tag")) {
74
// assume it's not necessary to guess the format
75
continue;
76
}
77
}
78
if (yaml && typeof yaml === "object") {
79
// deno-lint-ignore no-explicit-any
80
const format = (yaml as Record<string, any>).format;
81
if (typeof format === "object") {
82
for (
83
const [k, _] of Object.entries(
84
// deno-lint-ignore no-explicit-any
85
(yaml as Record<string, any>).format || {},
86
)
87
) {
88
formats.add(k);
89
}
90
} else if (typeof format === "string") {
91
formats.add(format);
92
}
93
}
94
}
95
}
96
return Array.from(formats);
97
}
98
99
function skipTest(metadata: Record<string, any>): string | undefined {
100
// deno-lint-ignore no-explicit-any
101
const quartoMeta = metadata["_quarto"] as any;
102
const runConfig = quartoMeta?.tests?.run;
103
104
// No run config means run everywhere
105
if (!runConfig) {
106
return undefined;
107
}
108
109
// Check explicit skip with message
110
if (runConfig.skip) {
111
return typeof runConfig.skip === "string" ? runConfig.skip : "tests.run.skip is true";
112
}
113
114
// Check CI
115
if (runningInCI() && runConfig.ci === false) {
116
return "tests.run.ci is false";
117
}
118
119
// Check OS blacklist (not_os)
120
const notOs = runConfig.not_os;
121
if (notOs !== undefined && asArray(notOs).includes(os)) {
122
return `tests.run.not_os includes ${os}`;
123
}
124
125
// Check OS whitelist (os) - if specified, must match
126
const onlyOs = runConfig.os;
127
if (onlyOs !== undefined && !asArray(onlyOs).includes(os)) {
128
return `tests.run.os does not include ${os}`;
129
}
130
131
return undefined;
132
}
133
134
//deno-lint-ignore no-explicit-any
135
function hasTestSpecs(metadata: any, input: string): boolean {
136
const tests = metadata?.["_quarto"]?.["tests"];
137
if (!tests && metadata?.["_quarto"]?.["test"] != undefined) {
138
throw new Error(`Test is ${input} is using 'test' in metadata instead of 'tests'. This is probably a typo.`);
139
}
140
// Check if tests has any format specs (keys other than 'run')
141
if (tests && typeof tests === "object") {
142
const formatKeys = Object.keys(tests).filter(key => key !== "run");
143
return formatKeys.length > 0;
144
}
145
return false;
146
}
147
148
interface QuartoInlineTestSpec {
149
format: string;
150
verifyFns: Verify[];
151
}
152
153
// Functions to cleanup leftover testing
154
const postRenderCleanupFiles: string[] = [];
155
function registerPostRenderCleanupFile(file: string): void {
156
postRenderCleanupFiles.push(file);
157
}
158
const postRenderCleanup = () => {
159
if (Deno.env.get("QUARTO_TEST_KEEP_OUTPUTS")) {
160
return;
161
}
162
for (const file of postRenderCleanupFiles) {
163
console.log(`Cleaning up ${file} in ${Deno.cwd()}`);
164
if (safeExistsSync(file)) {
165
Deno.removeSync(file);
166
}
167
}
168
}
169
170
function resolveTestSpecs(
171
input: string,
172
// deno-lint-ignore no-explicit-any
173
metadata: Record<string, any>,
174
): QuartoInlineTestSpec[] {
175
const specs = metadata["_quarto"]["tests"];
176
177
const result = [];
178
// deno-lint-ignore no-explicit-any
179
const verifyMap: Record<string, any> = {
180
ensureEpubFileRegexMatches,
181
ensureHtmlElements,
182
ensureHtmlElementContents,
183
ensureHtmlElementCount,
184
ensureFileRegexMatches,
185
ensureIpynbCellMatches,
186
ensureLatexFileRegexMatches,
187
ensureTypstFileRegexMatches,
188
ensureDocxRegexMatches,
189
ensureDocxXpath,
190
ensureOdtXpath,
191
ensureJatsXpath,
192
ensurePdfRegexMatches,
193
ensurePdfTextPositions,
194
ensurePdfMetadata,
195
ensurePptxRegexMatches,
196
ensurePptxXpath,
197
ensurePptxLayout,
198
ensurePptxMaxSlides,
199
ensureSnapshotMatches,
200
printsMessage
201
};
202
203
for (const [format, testObj] of Object.entries(specs)) {
204
// Skip the 'run' key - it's not a format
205
if (format === "run") {
206
continue;
207
}
208
let checkWarnings = true;
209
const verifyFns: Verify[] = [];
210
if (testObj && typeof testObj === "object") {
211
for (
212
// deno-lint-ignore no-explicit-any
213
const [key, value] of Object.entries(testObj as Record<string, any>)
214
) {
215
if (key == "postRenderCleanup") {
216
// This is a special key to register cleanup operations
217
// each entry is a file to cleanup relative to the input file
218
for (let file of value) {
219
// if value has `${input_stem}` in the string, replace by input_stem value (input file name without extension)
220
if (file.includes("${input_stem}")) {
221
const extension = input.endsWith('.qmd') ? '.qmd' : '.ipynb';
222
const inputStem = basename(input, extension);
223
file = file.replace("${input_stem}", inputStem);
224
}
225
// file is registered for cleanup in testQuartoCmd teardown step
226
registerPostRenderCleanupFile(join(dirname(input), file));
227
}
228
} else if (key == "shouldError") {
229
checkWarnings = false;
230
verifyFns.push(shouldError);
231
} else if (key === "noErrors") {
232
checkWarnings = false;
233
verifyFns.push(noErrors);
234
} else if (key === "noErrorsOrWarnings") {
235
checkWarnings = false;
236
verifyFns.push(noErrorsOrWarnings);
237
} else {
238
// See if there is a project and grab it's type
239
const projectPath = findRootTestsProjectDir(input)
240
const projectOutDir = findProjectOutputDir(projectPath);
241
const outputFile = outputForInput(input, format, projectOutDir, projectPath, metadata);
242
if (key === "fileExists") {
243
for (
244
const [path, file] of Object.entries(
245
value as Record<string, string>,
246
)
247
) {
248
if (path === "outputPath") {
249
verifyFns.push(
250
fileExists(join(dirname(outputFile.outputPath), file)),
251
);
252
} else if (path === "supportPath") {
253
verifyFns.push(
254
fileExists(join(outputFile.supportPath, file)),
255
);
256
}
257
}
258
} else if (["ensurePptxLayout", "ensurePptxXpath"].includes(key)) {
259
if (Array.isArray(value) && Array.isArray(value[0])) {
260
// several slides to check
261
value.forEach((slide: any) => {
262
verifyFns.push(verifyMap[key](outputFile.outputPath, ...slide));
263
});
264
} else {
265
verifyFns.push(verifyMap[key](outputFile.outputPath, ...value));
266
}
267
} else if (key === "printsMessage") {
268
verifyFns.push(verifyMap[key](value));
269
} else if (key === "ensureEpubFileRegexMatches") {
270
// this ensure function is special because it takes an array of path + regex specifiers,
271
// so we should never use the spread operator
272
verifyFns.push(verifyMap[key](outputFile.outputPath, value));
273
} else if (verifyMap[key]) {
274
// FIXME: We should find another way that having this requirement of keep-* in the metadata
275
if (key === "ensureTypstFileRegexMatches") {
276
if (!metadata.format?.typst?.['keep-typ'] && !metadata['keep-typ'] && metadata.format?.typst?.['output-ext'] !== 'typ' && metadata['output-ext'] !== 'typ') {
277
throw new Error(`Using ensureTypstFileRegexMatches requires setting 'keep-typ: true' in file ${input}`);
278
}
279
} else if (key === "ensureLatexFileRegexMatches") {
280
if (!metadata.format?.pdf?.['keep-tex'] && !metadata['keep-tex']) {
281
throw new Error(`Using ensureLatexFileRegexMatches requires setting 'keep-tex: true' in file ${input}`);
282
}
283
}
284
285
// keep-typ/keep-tex files are alongside source, so pass input path
286
// But output-ext: typ puts files in output directory, so don't pass input path
287
const usesKeepTyp = key === "ensureTypstFileRegexMatches" &&
288
(metadata.format?.typst?.['keep-typ'] || metadata['keep-typ']) &&
289
!(metadata.format?.typst?.['output-ext'] === 'typ' || metadata['output-ext'] === 'typ');
290
const usesKeepTex = key === "ensureLatexFileRegexMatches" &&
291
(metadata.format?.pdf?.['keep-tex'] || metadata['keep-tex']);
292
const needsInputPath = usesKeepTyp || usesKeepTex;
293
294
// For book projects, use intermediateTypstPath (index.typ at project root)
295
// instead of the output path (which would be _book/BookTitle.typ)
296
let targetPath = outputFile.outputPath;
297
if (key === "ensureTypstFileRegexMatches" && outputFile.intermediateTypstPath) {
298
targetPath = outputFile.intermediateTypstPath;
299
}
300
301
if (typeof value === "object" && Array.isArray(value)) {
302
// value is [matches, noMatches?] - ensure inputFile goes in the right position
303
const matches = value[0];
304
const noMatches = value[1];
305
const inputFile = needsInputPath ? input : undefined;
306
verifyFns.push(verifyMap[key](targetPath, matches, noMatches, inputFile));
307
} else {
308
verifyFns.push(verifyMap[key](targetPath, value, undefined, needsInputPath ? input : undefined));
309
}
310
} else {
311
throw new Error(`Unknown verify function used: ${key} in file ${input} for format ${format}`) ;
312
}
313
}
314
}
315
}
316
if (checkWarnings) {
317
verifyFns.push(noErrorsOrWarnings);
318
}
319
320
result.push({
321
format,
322
verifyFns,
323
});
324
}
325
return result;
326
}
327
328
await initYamlIntelligenceResourcesFromFilesystem();
329
330
// Ideally we'd just walk the one single glob here,
331
// but because smoke-all.test.ts ends up being called
332
// from a number of different places (including different shell
333
// scripts run under a variety of shells), it's
334
// actually non-trivial to guarantee that we'll see a single
335
// unexpanded glob pattern. So we assume that a pattern
336
// might have already been expanded here, and we also
337
// accommodate cases where it hasn't been expanded.
338
//
339
// (Do note that this means that files that don't exist will
340
// be silently ignored.)
341
const files: WalkEntry[] = [];
342
if (Deno.args.length === 0) {
343
// ignore file starting with `_`
344
files.push(...[...expandGlobSync("docs/smoke-all/**/*.{md,qmd,ipynb}")].filter((entry) => /^[^_]/.test(basename(entry.path))));
345
} else {
346
for (const arg of Deno.args) {
347
files.push(...expandGlobSync(arg));
348
}
349
}
350
351
// To store project path we render before testing file testSpecs
352
const renderedProjects: Set<string> = new Set();
353
// To store information of all the project we render so that we can cleanup after testing
354
const testedProjects: Set<string> = new Set();
355
356
// Create an array to hold all the promises for the tests of files
357
let testFilesPromises = [];
358
359
for (const { path: fileName } of files) {
360
const input = relative(Deno.cwd(), fileName);
361
362
const metadata = input.endsWith("md") // qmd or md
363
? readYamlFromMarkdown(Deno.readTextFileSync(input))
364
: readYamlFromMarkdown(await jupyterNotebookToMarkdown(input, false));
365
366
const skipReason = skipTest(metadata);
367
if (skipReason !== undefined) {
368
console.log(`Skipping tests for ${input}: ${skipReason}`);
369
continue;
370
}
371
372
const testSpecs: QuartoInlineTestSpec[] = [];
373
374
if (hasTestSpecs(metadata, input)) {
375
testSpecs.push(...resolveTestSpecs(input, metadata));
376
} else {
377
const formats = await guessFormat(input);
378
379
if (formats.length == 0) {
380
formats.push("html");
381
}
382
for (const format of formats) {
383
testSpecs.push({ format: format, verifyFns: [noErrorsOrWarnings] });
384
}
385
}
386
387
// Get project path for this input and store it if this is a project (used for cleaning)
388
const projectPath = findRootTestsProjectDir(input);
389
if (projectPath) testedProjects.add(projectPath);
390
391
// Render project before testing individual document if required
392
if (
393
(metadata["_quarto"] as any)?.["render-project"] &&
394
projectPath &&
395
!renderedProjects.has(projectPath)
396
) {
397
await quarto(["render", projectPath]);
398
renderedProjects.add(projectPath);
399
}
400
401
testFilesPromises.push(new Promise<void>(async (resolve, reject) => {
402
try {
403
404
// Create an array to hold all the promises for the testSpecs
405
let testSpecPromises = [];
406
407
for (const testSpec of testSpecs) {
408
const {
409
format,
410
verifyFns,
411
//deno-lint-ignore no-explicit-any
412
} = testSpec as any;
413
testSpecPromises.push(new Promise<void>((testSpecResolve, testSpecReject) => {
414
try {
415
if (format === "editor-support-crossref") {
416
const tempFile = Deno.makeTempFileSync();
417
testQuartoCmd("editor-support", ["crossref", "--input", input, "--output", tempFile], verifyFns, {
418
teardown: () => {
419
Deno.removeSync(tempFile);
420
testSpecResolve(); // Resolve the promise for the testSpec
421
return Promise.resolve();
422
}
423
}, `quarto editor-support crossref < ${input}`);
424
} else {
425
testQuartoCmd("render", [input, "--to", format], verifyFns, {
426
prereq: async () => {
427
setInitializer(fullInit);
428
await initState();
429
return Promise.resolve(true);
430
},
431
teardown: () => {
432
cleanoutput(input, format, undefined, undefined, metadata);
433
postRenderCleanup()
434
testSpecResolve(); // Resolve the promise for the testSpec
435
return Promise.resolve();
436
},
437
});
438
}
439
} catch (error) {
440
testSpecReject(error);
441
}
442
}));
443
444
}
445
446
// Wait for all the promises to resolve
447
await Promise.all(testSpecPromises);
448
449
// Resolve the promise for the file
450
resolve();
451
452
} catch (error) {
453
reject(error);
454
}
455
}));
456
}
457
458
// Wait for all the promises to resolve
459
// Meaning all the files have been tested and we can clean
460
Promise.all(testFilesPromises).then(() => {
461
if (Deno.env.get("QUARTO_TEST_KEEP_OUTPUTS")) {
462
return;
463
}
464
// Clean up any projects that were tested
465
for (const project of testedProjects) {
466
// Clean project output directory
467
const projectOutDir = join(project, findProjectOutputDir(project));
468
if (projectOutDir !== project && safeExistsSync(projectOutDir)) {
469
safeRemoveSync(projectOutDir, { recursive: true });
470
}
471
// Clean hidden .quarto directory
472
const hiddenQuarto = join(project, ".quarto");
473
if (safeExistsSync(hiddenQuarto)) {
474
safeRemoveSync(hiddenQuarto, { recursive: true });
475
}
476
}
477
}).catch((_error) => {});
478
479
function findRootTestsProjectDir(input: string) {
480
const smokeAllRootDir = 'smoke-all$'
481
const ffMatrixRootDir = 'feature-format-matrix[/]qmd-files$'
482
483
const RootTestsRegex = new RegExp(`${smokeAllRootDir}|${ffMatrixRootDir}`);
484
485
return findProjectDir(input, RootTestsRegex);
486
}
487