Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/tests/verify.ts
12923 views
1
/*
2
* verify.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { existsSync, walkSync } from "../src/deno_ral/fs.ts";
8
import { DOMParser, Element, NodeList } from "../src/core/deno-dom.ts";
9
import { assert } from "testing/asserts";
10
import { basename, dirname, join, relative, resolve } from "../src/deno_ral/path.ts";
11
import { parseXmlDocument } from "slimdom";
12
import xpath from "fontoxpath";
13
import * as ld from "../src/core/lodash.ts";
14
15
import { readYamlFromString } from "../src/core/yaml.ts";
16
17
import { ExecuteOutput, Verify } from "./test.ts";
18
import { outputForInput } from "./utils.ts";
19
import { unzip } from "../src/core/zip.ts";
20
import { dirAndStem, safeRemoveSync, which } from "../src/core/path.ts";
21
import { isWindows } from "../src/deno_ral/platform.ts";
22
import { execProcess, ExecProcessOptions } from "../src/core/process.ts";
23
import { checkSnapshot, generateSnapshotDiff, generateInlineDiff, WordDiffPart } from "./verify-snapshot.ts";
24
import * as colors from "fmt/colors";
25
26
export const withDocxContent = async <T>(
27
file: string,
28
k: (xml: string) => Promise<T>
29
) => {
30
const [_dir, stem] = dirAndStem(file);
31
const temp = await Deno.makeTempDir();
32
try {
33
// Move the docx to a temp dir and unzip it
34
const zipFile = join(temp, stem + ".zip");
35
await Deno.copyFile(file, zipFile);
36
await unzip(zipFile);
37
38
// Open the core xml document and match the matches
39
const docXml = join(temp, "word", "document.xml");
40
const xml = await Deno.readTextFile(docXml);
41
const result = await k(xml);
42
return result;
43
} finally {
44
await Deno.remove(temp, { recursive: true });
45
}
46
};
47
48
export const withEpubDirectory = async <T>(
49
file: string,
50
k: (path: string) => Promise<T>
51
) => {
52
const [_dir, stem] = dirAndStem(file);
53
const temp = await Deno.makeTempDir();
54
try {
55
// Move the docx to a temp dir and unzip it
56
const zipFile = join(temp, stem + ".zip");
57
await Deno.copyFile(file, zipFile);
58
await unzip(zipFile);
59
60
// Open the core xml document and match the matches
61
const result = await k(temp);
62
return result;
63
} finally {
64
await Deno.remove(temp, { recursive: true });
65
}
66
};
67
68
export const withPptxContent = async <T>(
69
file: string,
70
slideNumber: number,
71
rels: boolean,
72
// takes the parsed XML and the XML file path
73
k: (xml: string, xmlFile: string) => Promise<T>,
74
isSlideMax: boolean = false,
75
) => {
76
const [_dir, stem] = dirAndStem(file);
77
const temp = await Deno.makeTempDir();
78
try {
79
// Move the pptx to a temp dir and unzip it
80
const zipFile = join(temp, stem + ".zip");
81
await Deno.copyFile(file, zipFile);
82
await unzip(zipFile);
83
84
// Open the core xml document and match the matches
85
const slidePath = join(temp, "ppt", "slides");
86
let slideFile = join(slidePath, rels ? join("_rels", `slide${slideNumber}.xml.rels`) : `slide${slideNumber}.xml`);
87
assert(
88
existsSync(slideFile),
89
`Slide number ${slideNumber} is not in the Pptx`,
90
);
91
if (isSlideMax) {
92
assert(
93
!existsSync(join(slidePath, `slide${slideNumber + 1}.xml`)),
94
`Pptx has more than ${slideNumber} slides.`,
95
);
96
return Promise.resolve();
97
} else {
98
const xml = await Deno.readTextFile(slideFile);
99
const result = await k(xml, slideFile);
100
return result;
101
}
102
} finally {
103
await Deno.remove(temp, { recursive: true });
104
}
105
};
106
107
const checkErrors = (outputs: ExecuteOutput[]): { errors: boolean, messages: string | undefined } => {
108
const isError = (output: ExecuteOutput) => {
109
return output.levelName.toLowerCase() === "error";
110
};
111
const errors = outputs.some(isError);
112
113
const messages = errors ? outputs.filter(isError).map((outputs) => outputs.msg).join("\n") : undefined
114
115
return({
116
errors,
117
messages
118
})
119
}
120
121
export const noErrors: Verify = {
122
name: "No Errors",
123
verify: (outputs: ExecuteOutput[]) => {
124
125
const { errors, messages } = checkErrors(outputs);
126
127
assert(
128
!errors,
129
`Errors During Execution\n|${messages}|`,
130
);
131
132
return Promise.resolve();
133
},
134
};
135
136
export const shouldError: Verify = {
137
name: "Should Error",
138
verify: (outputs: ExecuteOutput[]) => {
139
140
const { errors } = checkErrors(outputs);
141
142
assert(
143
errors,
144
`No errors during execution while rendering was expected to fail.`,
145
);
146
147
return Promise.resolve();
148
},
149
};
150
151
export const noErrorsOrWarnings: Verify = {
152
name: "No Errors or Warnings",
153
verify: (outputs: ExecuteOutput[]) => {
154
const isErrorOrWarning = (output: ExecuteOutput) => {
155
return output.levelName.toLowerCase() === "warn" ||
156
output.levelName.toLowerCase() === "error";
157
// I'd like to do this but many many of our tests
158
// would fail right now because we're assuming noErrorsOrWarnings
159
// doesn't include warnings from the lua subsystem
160
// ||
161
// output.msg.startsWith("(W)"); // this is a warning from quarto.log.warning()
162
};
163
164
const errorsOrWarnings = outputs.some(isErrorOrWarning);
165
166
// Output an error or warning if it exists
167
if (errorsOrWarnings) {
168
const messages = outputs.filter(isErrorOrWarning).map((outputs) =>
169
outputs.msg
170
).join("\n");
171
172
assert(
173
!errorsOrWarnings,
174
`Error or Warnings During Execution\n|${messages}|`,
175
);
176
}
177
178
return Promise.resolve();
179
},
180
};
181
182
export const printsMessage = (options: {
183
level: "DEBUG" | "INFO" | "WARN" | "ERROR";
184
regex: string | RegExp;
185
negate?: boolean;
186
}): Verify => {
187
const { level, regex: regexPattern, negate = false } = options; // Set default here
188
return {
189
name: `${level} matches ${String(regexPattern)}`,
190
verify: (outputs: ExecuteOutput[]) => {
191
const regex = typeof regexPattern === "string"
192
? new RegExp(regexPattern)
193
: regexPattern;
194
195
const printedMessage = outputs.some((output) => {
196
return output.levelName === level && output.msg.match(regex);
197
});
198
assert(
199
negate ? !printedMessage : printedMessage,
200
`${negate ? "Found" : "Missing"} ${level} ${String(regex)}`
201
);
202
return Promise.resolve();
203
},
204
};
205
};
206
207
export const printsJson = {
208
name: "Prints JSON Output",
209
verify: (outputs: ExecuteOutput[]) => {
210
outputs.filter((out) => out.msg !== "" && out.levelName === "INFO").forEach(
211
(out) => {
212
let json = undefined;
213
try {
214
json = JSON.parse(out.msg);
215
} catch {
216
assert(false, "Error parsing JSON returned by quarto meta");
217
}
218
assert(
219
Object.keys(json).length > 0,
220
"JSON returned by quarto meta seems invalid",
221
);
222
},
223
);
224
return Promise.resolve();
225
},
226
};
227
228
export const fileExists = (file: string): Verify => {
229
return {
230
name: `File ${file} exists`,
231
verify: (_output: ExecuteOutput[]) => {
232
verifyPath(file);
233
return Promise.resolve();
234
},
235
};
236
};
237
238
export const pathDoNotExists = (path: string): Verify => {
239
return {
240
name: `path ${path} do not exists`,
241
verify: (_output: ExecuteOutput[]) => {
242
verifyNoPath(path);
243
return Promise.resolve();
244
},
245
};
246
};
247
248
export const directoryContainsOnlyAllowedPaths = (dir: string, paths: string[]): Verify => {
249
return {
250
name: `Ensure only has ${paths.length} paths in folder`,
251
verify: (_output: ExecuteOutput[]) => {
252
253
for (const walk of walkSync(dir)) {
254
const path = relative(dir, walk.path);
255
if (path !== "") {
256
assert(paths.includes(path), `Unexpected path ${path} encountered.`);
257
258
}
259
}
260
return Promise.resolve();
261
},
262
};
263
}
264
265
export const folderExists = (path: string): Verify => {
266
return {
267
name: `Folder ${path} exists`,
268
verify: (_output: ExecuteOutput[]) => {
269
verifyPath(path);
270
assert(Deno.statSync(path).isDirectory, `Path ${path} isn't a folder`);
271
return Promise.resolve();
272
},
273
};
274
}
275
276
export const validJsonFileExists = (file: string): Verify => {
277
return {
278
name: `Valid Json ${file} exists`,
279
verify: (_output: ExecuteOutput[]) => {
280
const jsonStr = Deno.readTextFileSync(file);
281
JSON.parse(jsonStr);
282
return Promise.resolve();
283
}
284
}
285
}
286
287
export const validJsonWithFields = (file: string, fields: Record<string, unknown>) => {
288
return {
289
name: `Valid Json ${file} exists`,
290
verify: (_output: ExecuteOutput[]) => {
291
const jsonStr = Deno.readTextFileSync(file);
292
const json = JSON.parse(jsonStr);
293
for (const key of Object.keys(fields)) {
294
295
const value = json[key];
296
assert(ld.isEqual(value, fields[key]), `Key ${key} has invalid value in json.`);
297
}
298
299
300
return Promise.resolve();
301
}
302
}
303
}
304
305
export const ensureIpynbCellMatches = (
306
file: string,
307
options: {
308
cellType: "code" | "markdown";
309
matches?: (string | RegExp)[];
310
noMatches?: (string | RegExp)[];
311
}
312
): Verify => {
313
const { cellType, matches = [], noMatches = [] } = options;
314
return {
315
name: `IPYNB ${file} has ${cellType} cells matching patterns`,
316
verify: async (_output: ExecuteOutput[]) => {
317
const jsonStr = Deno.readTextFileSync(file);
318
const notebook = JSON.parse(jsonStr);
319
// deno-lint-ignore no-explicit-any
320
const cells = notebook.cells.filter((c: any) => c.cell_type === cellType);
321
// deno-lint-ignore no-explicit-any
322
const content = cells.map((c: any) =>
323
Array.isArray(c.source) ? c.source.join("") : c.source
324
).join("\n");
325
326
for (const m of matches) {
327
const regex = typeof m === "string" ? new RegExp(m) : m;
328
assert(regex.test(content), `Pattern ${m} not found in ${cellType} cells of ${file}`);
329
}
330
for (const m of noMatches) {
331
const regex = typeof m === "string" ? new RegExp(m) : m;
332
assert(!regex.test(content), `Pattern ${m} should not be in ${cellType} cells of ${file}`);
333
}
334
return Promise.resolve();
335
}
336
};
337
};
338
339
export const outputCreated = (
340
input: string,
341
to: string,
342
projectOutDir?: string,
343
): Verify => {
344
return {
345
name: "Output Created",
346
verify: (outputs: ExecuteOutput[]) => {
347
// Check for output created message
348
const outputCreatedMsg = outputs.find((outMsg) =>
349
outMsg.msg.startsWith("Output created:")
350
);
351
assert(outputCreatedMsg !== undefined, "No output created message");
352
353
// Check for existence of the output
354
const outputFile = outputForInput(input, to, projectOutDir);
355
verifyPath(outputFile.outputPath);
356
return Promise.resolve();
357
},
358
};
359
};
360
361
export const directoryEmptyButFor = (
362
dir: string,
363
allowedFiles: string[],
364
): Verify => {
365
return {
366
name: "Directory is empty",
367
verify: (_outputs: ExecuteOutput[]) => {
368
for (const item of Deno.readDirSync(dir)) {
369
if (!allowedFiles.some((file) => item.name === file)) {
370
assert(false, `Unexpected content ${item.name} in ${dir}`);
371
}
372
}
373
return Promise.resolve();
374
},
375
};
376
};
377
378
export const ensureHtmlElements = (
379
file: string,
380
selectors: string[],
381
noMatchSelectors?: string[],
382
): Verify => {
383
return {
384
name: `Inspecting HTML for Selectors in ${file}`,
385
verify: async (_output: ExecuteOutput[]) => {
386
const htmlInput = await Deno.readTextFile(file);
387
const doc = new DOMParser().parseFromString(htmlInput, "text/html")!;
388
selectors.forEach((sel) => {
389
assert(
390
doc.querySelector(sel) !== null,
391
`Required DOM Element ${sel} is missing in ${file}.`,
392
);
393
});
394
395
if (noMatchSelectors) {
396
noMatchSelectors.forEach((sel) => {
397
assert(
398
doc.querySelector(sel) === null,
399
`Illegal DOM Element ${sel} is present in ${file}.`,
400
);
401
});
402
}
403
},
404
};
405
};
406
407
export const ensureHtmlElementContents = (
408
file: string,
409
options : {
410
selectors: string[],
411
matches: (string | RegExp)[],
412
noMatches?: (string | RegExp)[]
413
}
414
) => {
415
return {
416
name: "Inspecting HTML for Selector Contents",
417
verify: async (_output: ExecuteOutput[]) => {
418
const htmlInput = await Deno.readTextFile(file);
419
const doc = new DOMParser().parseFromString(htmlInput, "text/html")!;
420
options.selectors.forEach((sel) => {
421
const el = doc.querySelector(sel);
422
if (el !== null) {
423
const contents = el.innerText;
424
options.matches.forEach((regex) => {
425
assert(
426
asRegexp(regex).test(contents),
427
`Required match ${String(regex)} is missing from selector ${sel} content: ${contents}.`,
428
);
429
});
430
431
options.noMatches?.forEach((regex) => {
432
assert(
433
!asRegexp(regex).test(contents),
434
`Unexpected match ${String(regex)} is present from selector ${sel} content: ${contents}.`,
435
);
436
});
437
438
}
439
});
440
},
441
};
442
443
}
444
445
export const ensureHtmlElementCount = (
446
file: string,
447
options: {
448
selectors: string[] | string,
449
counts: number[] | number
450
}
451
): Verify => {
452
return {
453
name: "Verify number of elements for selectors",
454
verify: async (_output: ExecuteOutput[]) => {
455
const htmlInput = await Deno.readTextFile(file);
456
const doc = new DOMParser().parseFromString(htmlInput, "text/html")!;
457
458
// Convert single values to arrays for unified processing
459
const selectorsArray = Array.isArray(options.selectors) ? options.selectors : [options.selectors];
460
const countsArray = Array.isArray(options.counts) ? options.counts : [options.counts];
461
462
if (selectorsArray.length !== countsArray.length) {
463
throw new Error("Selectors and counts arrays must have the same length");
464
}
465
466
selectorsArray.forEach((selector, index) => {
467
const expectedCount = countsArray[index];
468
const elements = doc.querySelectorAll(selector);
469
assert(
470
elements.length === expectedCount,
471
`Selector '${selector}' matched ${elements.length} elements, expected ${expectedCount}.`
472
);
473
});
474
}
475
};
476
};
477
478
export const verifyOjsDefine = (
479
callback: (contents: Array<{name: string, value: any}>) => Promise<void>,
480
name?: string,
481
): (file: string) => Verify => {
482
return (file: string) => ({
483
name: name ?? "Inspecting OJS Define",
484
verify: async (_output: ExecuteOutput[]) => {
485
const htmlContent = await Deno.readTextFile(file);
486
const doc = new DOMParser().parseFromString(htmlContent, "text/html")!;
487
const scriptElement = doc.querySelector('script[type="ojs-define"]');
488
assert(
489
scriptElement,
490
"Should find ojs-define script element in rendered HTML"
491
);
492
const jsonContent = scriptElement.textContent.trim();
493
const ojsData = JSON.parse(jsonContent);
494
assert(
495
ojsData.contents && Array.isArray(ojsData.contents),
496
"ojs-define should have contents array"
497
);
498
await callback(ojsData.contents);
499
},
500
});
501
};
502
503
const printColoredDiff = (diff: string) => {
504
for (const line of diff.split("\n")) {
505
if (line.startsWith("+") && !line.startsWith("+++")) {
506
console.log(colors.green(line));
507
} else if (line.startsWith("-") && !line.startsWith("---")) {
508
console.log(colors.red(line));
509
} else if (line.startsWith("@@")) {
510
console.log(colors.dim(line));
511
} else {
512
console.log(line);
513
}
514
}
515
};
516
517
const escapeWhitespace = (s: string): string => {
518
return s.replace(/\n/g, "⏎\\n").replace(/\t/g, "→\\t").replace(/ /g, "·");
519
};
520
521
const printCompactInlineDiff = (parts: WordDiffPart[]) => {
522
const chunks: string[] = [];
523
let currentChunk = "";
524
let hasChanges = false;
525
526
for (let i = 0; i < parts.length; i++) {
527
const part = parts[i];
528
if (part.added || part.removed) {
529
hasChanges = true;
530
const displayValue = /^\s+$/.test(part.value) ? escapeWhitespace(part.value) : part.value;
531
if (part.added) {
532
currentChunk += colors.bgGreen(colors.black(displayValue));
533
} else {
534
currentChunk += colors.bgRed(colors.white(displayValue));
535
}
536
} else {
537
if (hasChanges) {
538
const contextBefore = part.value.slice(0, 40);
539
chunks.push(currentChunk + colors.dim(contextBefore + (part.value.length > 40 ? "..." : "")));
540
currentChunk = "";
541
hasChanges = false;
542
}
543
const nextHasChange = parts.slice(i + 1).some(p => p.added || p.removed);
544
if (nextHasChange) {
545
const contextAfter = part.value.slice(-40);
546
currentChunk = colors.dim((part.value.length > 40 ? "..." : "") + contextAfter);
547
}
548
}
549
}
550
if (currentChunk) {
551
chunks.push(currentChunk);
552
}
553
554
for (const chunk of chunks) {
555
console.log(chunk);
556
console.log("");
557
}
558
};
559
560
export const ensureSnapshotMatches = (
561
file: string,
562
): Verify => {
563
return {
564
name: "Inspecting Snapshot",
565
verify: async (_output: ExecuteOutput[]) => {
566
const good = await checkSnapshot(file);
567
const diffFile = file + ".diff";
568
if (!good) {
569
const diff = await generateSnapshotDiff(file);
570
const inlineParts = await generateInlineDiff(file);
571
572
await Deno.writeTextFile(diffFile, diff);
573
console.log(`\nDiff saved to: ${diffFile}`);
574
575
console.log("\n--- Unified Diff ---");
576
printColoredDiff(diff);
577
console.log("--- End Unified Diff ---\n");
578
579
console.log("--- Word-level Changes (with context) ---");
580
printCompactInlineDiff(inlineParts);
581
console.log("--- End Word-level Changes ---\n");
582
} else {
583
safeRemoveSync(diffFile);
584
}
585
assert(
586
good,
587
`Snapshot ${file}.snapshot doesn't match output`,
588
);
589
},
590
};
591
}
592
593
const regexChecker = async function(file: string, matches: RegExp[], noMatches: RegExp[] | undefined) {
594
const content = await Deno.readTextFile(file);
595
matches.forEach((regex) => {
596
assert(
597
regex.test(content),
598
`Required match ${String(regex)} is missing from file ${file}.`,
599
);
600
});
601
602
if (noMatches) {
603
noMatches.forEach((regex) => {
604
assert(
605
!regex.test(content),
606
`Illegal match ${String(regex)} was found in file ${file}.`,
607
);
608
});
609
}
610
}
611
612
export const verifyFileRegexMatches = (
613
callback: (file: string, matches: RegExp[], noMatches: RegExp[] | undefined) => Promise<void>,
614
name?: string,
615
): (file: string, matchesUntyped: (string | RegExp)[], noMatchesUntyped?: (string | RegExp)[]) => Verify => {
616
return (file: string, matchesUntyped: (string | RegExp)[], noMatchesUntyped?: (string | RegExp)[]) => {
617
// Use mutliline flag for regexes so that ^ and $ can be used
618
const asRegexp = (m: string | RegExp) => {
619
if (typeof m === "string") {
620
return new RegExp(m, "m");
621
} else {
622
return m;
623
}
624
};
625
const matches = matchesUntyped.map(asRegexp);
626
const noMatches = noMatchesUntyped?.map(asRegexp);
627
return {
628
name: name ?? `Inspecting ${file} for Regex matches`,
629
verify: async (_output: ExecuteOutput[]) => {
630
const tex = await Deno.readTextFile(file);
631
await callback(file, matches, noMatches);
632
}
633
};
634
}
635
}
636
637
// Use this function to Regex match text in the output file
638
export const ensureFileRegexMatches = (
639
file: string,
640
matchesUntyped: (string | RegExp)[],
641
noMatchesUntyped?: (string | RegExp)[],
642
): Verify => {
643
return(verifyFileRegexMatches(regexChecker)(file, matchesUntyped, noMatchesUntyped));
644
};
645
646
// Use this function to Regex match text in CSS files linked from the HTML document
647
export const ensureCssRegexMatches = (
648
file: string,
649
matchesUntyped: (string | RegExp)[],
650
noMatchesUntyped?: (string | RegExp)[],
651
): Verify => {
652
const asRegexp = (m: string | RegExp) => {
653
if (typeof m === "string") {
654
return new RegExp(m, "m");
655
}
656
return m;
657
};
658
const matches = matchesUntyped.map(asRegexp);
659
const noMatches = noMatchesUntyped?.map(asRegexp);
660
661
return {
662
name: `Inspecting CSS files for Regex matches`,
663
verify: async (_output: ExecuteOutput[]) => {
664
// Parse the HTML file to find linked CSS files
665
const htmlContent = await Deno.readTextFile(file);
666
const doc = new DOMParser().parseFromString(htmlContent, "text/html")!;
667
const [dir] = dirAndStem(file);
668
669
// Find all stylesheet links and read their content
670
let combinedContent = "";
671
const links = doc.querySelectorAll('link[rel="stylesheet"]');
672
for (const link of links) {
673
const href = (link as Element).getAttribute("href");
674
if (href && !href.startsWith("http://") && !href.startsWith("https://")) {
675
const cssPath = join(dir, href);
676
try {
677
combinedContent += await Deno.readTextFile(cssPath) + "\n";
678
} catch {
679
// Skip files that don't exist (e.g., external URLs we couldn't parse)
680
}
681
}
682
}
683
684
matches.forEach((regex) => {
685
assert(
686
regex.test(combinedContent),
687
`Required CSS match ${String(regex)} is missing.`,
688
);
689
});
690
691
if (noMatches) {
692
noMatches.forEach((regex) => {
693
assert(
694
!regex.test(combinedContent),
695
`Illegal CSS match ${String(regex)} was found.`,
696
);
697
});
698
}
699
},
700
};
701
};
702
703
// Verify the .llms.md companion file for an HTML output.
704
// Used when testing websites with llms-txt: true enabled.
705
export const ensureLlmsMdRegexMatches = (
706
htmlFile: string,
707
matchesUntyped: (string | RegExp)[],
708
noMatchesUntyped?: (string | RegExp)[],
709
): Verify => {
710
const llmsFile = htmlFile.replace(/\.html$/, ".llms.md");
711
return verifyFileRegexMatches(regexChecker, `Inspecting ${llmsFile} for Regex matches`)(llmsFile, matchesUntyped, noMatchesUntyped);
712
};
713
714
// Verify the .llms.md companion file exists for an HTML output.
715
export const ensureLlmsMdExists = (htmlFile: string): Verify => {
716
const llmsFile = htmlFile.replace(/\.html$/, ".llms.md");
717
return {
718
name: `File ${llmsFile} exists`,
719
verify: (_output: ExecuteOutput[]) => {
720
verifyPath(llmsFile);
721
return Promise.resolve();
722
},
723
};
724
};
725
726
// Verify the .llms.md companion file does NOT exist for an HTML output.
727
export const ensureLlmsMdDoesNotExist = (htmlFile: string): Verify => {
728
const llmsFile = htmlFile.replace(/\.html$/, ".llms.md");
729
return {
730
name: `File ${llmsFile} does not exist`,
731
verify: (_output: ExecuteOutput[]) => {
732
verifyNoPath(llmsFile);
733
return Promise.resolve();
734
},
735
};
736
};
737
738
// Verify the llms.txt index file in a website output directory.
739
// Takes the HTML file path and looks for llms.txt in the same directory.
740
export const ensureLlmsTxtRegexMatches = (
741
htmlFile: string,
742
matchesUntyped: (string | RegExp)[],
743
noMatchesUntyped?: (string | RegExp)[],
744
): Verify => {
745
const llmsTxtPath = join(dirname(htmlFile), "llms.txt");
746
return verifyFileRegexMatches(regexChecker, `Inspecting ${llmsTxtPath} for Regex matches`)(llmsTxtPath, matchesUntyped, noMatchesUntyped);
747
};
748
749
// Verify the llms.txt file exists in a website output directory.
750
// Takes the HTML file path and looks for llms.txt in the same directory.
751
export const ensureLlmsTxtExists = (htmlFile: string): Verify => {
752
const llmsTxtPath = join(dirname(htmlFile), "llms.txt");
753
return {
754
name: `File ${llmsTxtPath} exists`,
755
verify: (_output: ExecuteOutput[]) => {
756
verifyPath(llmsTxtPath);
757
return Promise.resolve();
758
},
759
};
760
};
761
762
// Verify the llms.txt file does NOT exist in a website output directory.
763
// Takes the HTML file path and looks for llms.txt in the same directory.
764
export const ensureLlmsTxtDoesNotExist = (htmlFile: string): Verify => {
765
const llmsTxtPath = join(dirname(htmlFile), "llms.txt");
766
return {
767
name: `File ${llmsTxtPath} does not exist`,
768
verify: (_output: ExecuteOutput[]) => {
769
verifyNoPath(llmsTxtPath);
770
return Promise.resolve();
771
},
772
};
773
};
774
775
// Use this function to Regex match text in the intermediate kept file
776
// FIXME: do this properly without resorting on file having keep-*
777
// Note: keep-typ/keep-tex places files alongside source, not in output dir
778
export const verifyKeepFileRegexMatches = (
779
toExt: string,
780
keepExt: string,
781
): (file: string, matchesUntyped: (string | RegExp)[], noMatchesUntyped?: (string | RegExp)[], inputFile?: string) => Verify => {
782
return (file: string, matchesUntyped: (string | RegExp)[], noMatchesUntyped?: (string | RegExp)[], inputFile?: string) => {
783
// Kept files are alongside source, so derive from inputFile if provided
784
const keptFile = inputFile
785
? join(dirname(inputFile), basename(file).replace(`.${toExt}`, `.${keepExt}`))
786
: file.replace(`.${toExt}`, `.${keepExt}`);
787
const keptFileChecker = async (file: string, matches: RegExp[], noMatches: RegExp[] | undefined) => {
788
try {
789
await regexChecker(file, matches, noMatches);
790
} finally {
791
if (!Deno.env.get("QUARTO_TEST_KEEP_OUTPUTS")) {
792
await safeRemoveSync(file);
793
}
794
}
795
}
796
return verifyFileRegexMatches(keptFileChecker, `Inspecting intermediate ${keptFile} for Regex matches`)(keptFile, matchesUntyped, noMatchesUntyped);
797
}
798
};
799
800
// FIXME: do this properly without resorting on file having keep-typ
801
export const ensureTypstFileRegexMatches = (
802
file: string,
803
matchesUntyped: (string | RegExp)[],
804
noMatchesUntyped?: (string | RegExp)[],
805
inputFile?: string,
806
): Verify => {
807
return(verifyKeepFileRegexMatches("pdf", "typ")(file, matchesUntyped, noMatchesUntyped, inputFile));
808
};
809
810
// FIXME: do this properly without resorting on file having keep-tex
811
export const ensureLatexFileRegexMatches = (
812
file: string,
813
matchesUntyped: (string | RegExp)[],
814
noMatchesUntyped?: (string | RegExp)[],
815
inputFile?: string,
816
): Verify => {
817
return(verifyKeepFileRegexMatches("pdf", "tex")(file, matchesUntyped, noMatchesUntyped, inputFile));
818
};
819
820
// Use this function to Regex match text in a rendered PDF file
821
// This requires pdftotext to be available on PATH
822
export const ensurePdfRegexMatches = (
823
file: string,
824
matchesUntyped: (string | RegExp)[],
825
noMatchesUntyped?: (string | RegExp)[],
826
): Verify => {
827
const matches = matchesUntyped.map(asRegexp);
828
const noMatches = noMatchesUntyped?.map(asRegexp);
829
return {
830
name: `Inspecting ${file} for Regex matches`,
831
verify: async (_output: ExecuteOutput[]) => {
832
const cmd = new Deno.Command("pdftotext", {
833
args: [file, "-"],
834
stdout: "piped",
835
})
836
const output = await cmd.output();
837
assert(output.success, `Failed to extract text from ${file}.`)
838
const text = new TextDecoder().decode(output.stdout);
839
840
// Collect all failures instead of failing on first mismatch
841
const failures: string[] = [];
842
843
matches.forEach((regex) => {
844
if (!regex.test(text)) {
845
failures.push(`Required match ${String(regex)} is missing`);
846
}
847
});
848
849
if (noMatches) {
850
noMatches.forEach((regex) => {
851
if (regex.test(text)) {
852
failures.push(`Illegal match ${String(regex)} was found`);
853
}
854
});
855
}
856
857
assert(
858
failures.length === 0,
859
`${failures.length} regex mismatch(es) in ${file}:\n - ${failures.join('\n - ')}`,
860
);
861
},
862
};
863
}
864
865
export const verifyJatsDocument = (
866
callback: (doc: string) => Promise<void>,
867
name?: string,
868
): (file: string) => Verify => {
869
return (file: string) => ({
870
name: name ?? "Inspecting Jats",
871
verify: async (_output: ExecuteOutput[]) => {
872
const xml = await Deno.readTextFile(file);
873
await callback(xml);
874
},
875
});
876
};
877
878
export const verifyOdtDocument = (
879
callback: (doc: string) => Promise<void>,
880
name?: string,
881
): (file: string) => Verify => {
882
return (file: string) => ({
883
name: name ?? "Inspecting Odt",
884
verify: async (_output: ExecuteOutput[]) => {
885
return await withDocxContent(file, callback);
886
},
887
});
888
};
889
890
export const verifyDocXDocument = (
891
callback: (doc: string) => Promise<void>,
892
name?: string,
893
): (file: string) => Verify => {
894
return (file: string) => ({
895
name: name ?? "Inspecting Docx",
896
verify: async (_output: ExecuteOutput[]) => {
897
return await withDocxContent(file, callback);
898
},
899
});
900
};
901
902
export const verifyEpubDocument = (
903
callback: (path: string) => Promise<void>,
904
name?: string,
905
): (file: string) => Verify => {
906
return (file: string) => ({
907
name: name ?? "Inspecting Epub",
908
verify: async (_output: ExecuteOutput[]) => {
909
return await withEpubDirectory(file, callback);
910
},
911
});
912
}
913
914
export const verifyPptxDocument = (
915
callback: (doc: string, docFile: string) => Promise<void>,
916
name?: string,
917
): (file: string, slideNumber: number, rels?: boolean, isSlideMax?: boolean) => Verify => {
918
return (file: string, slideNumber: number, rels: boolean = false, isSlideMax: boolean = false) => ({
919
name: name ?? "Inspecting Pptx",
920
verify: async (_output: ExecuteOutput[]) => {
921
return await withPptxContent(file, slideNumber, rels, callback, isSlideMax);
922
},
923
});
924
};
925
926
const xmlChecker = (
927
selectors: string[],
928
noMatchSelectors?: string[],
929
): (xmlText: string) => Promise<void> => {
930
return (xmlText: string) => {
931
const xmlDoc = parseXmlDocument(xmlText);
932
for (const selector of selectors) {
933
const xpathResult = xpath.evaluateXPath(selector, xmlDoc);
934
const passes = (!Array.isArray(xpathResult) && xpathResult !== null) ||
935
(Array.isArray(xpathResult) && xpathResult.length > 0);
936
assert(
937
passes,
938
`Required XPath selector ${selector} returned empty array. Failing document follows:\n\n${xmlText}}`,
939
);
940
}
941
for (const falseSelector of noMatchSelectors ?? []) {
942
const xpathResult = xpath.evaluateXPath(falseSelector, xmlDoc);
943
const passes = (!Array.isArray(xpathResult) && xpathResult !== null) ||
944
(Array.isArray(xpathResult) && xpathResult.length > 0);
945
assert(
946
!passes,
947
`Illegal XPath selector ${falseSelector} returned non-empty array. Failing document follows:\n\n${xmlText}}`,
948
);
949
}
950
return Promise.resolve();
951
};
952
};
953
954
const pptxLayoutChecker = (layoutName: string): (xmlText: string, xmlFile: string) => Promise<void> => {
955
return async (xmlText: string, xmlFile: string) => {
956
// Parse the XML from slide#.xml.rels
957
const xmlDoc = parseXmlDocument(xmlText);
958
959
// Select the Relationship element with the correct Type attribute
960
const relationshipSelector = "/Relationships/Relationship[substring(@Type, string-length(@Type) - string-length('relationships/slideLayout') + 1) = 'relationships/slideLayout']/@Target";
961
const slideLayoutFile = xpath.evaluateXPathToString(relationshipSelector, xmlDoc);
962
963
assert(
964
slideLayoutFile,
965
`Required XPath selector ${relationshipSelector} returned empty string. Failing document ${basename(xmlFile)} follows:\n\n${xmlText}}`,
966
);
967
968
// Construct the full path to the slide layout file
969
// slideLayoutFile is a relative path from the slide xm document, that the `_rels` equivalent was about
970
const layoutFilePath = resolve(dirname(dirname(xmlFile)), slideLayoutFile);
971
972
// Now we need to check the slide layout file
973
const layoutXml = Deno.readTextFileSync(layoutFilePath);
974
975
// Parse the XML from slideLayout#.xml
976
const layoutDoc = parseXmlDocument(layoutXml);
977
978
// Select the p:cSld element with the correct name attribute
979
const layoutSelector = '//p:cSld/@name';
980
const layout = xpath.evaluateXPathToString(layoutSelector, layoutDoc);
981
assert(
982
layout === layoutName,
983
`Slides is not using "${layoutName}" layout - Current value: "${layout}". Failing document ${basename(layoutFilePath)} follows:\n\n${layoutXml}}`,
984
);
985
986
return Promise.resolve();
987
};
988
};
989
990
export const ensureJatsXpath = (
991
file: string,
992
selectors: string[],
993
noMatchSelectors?: string[],
994
): Verify => {
995
return verifyJatsDocument(
996
xmlChecker(selectors, noMatchSelectors),
997
"Inspecting Jats for XPath selectors",
998
)(file);
999
};
1000
1001
export const ensureOdtXpath = (
1002
file: string,
1003
selectors: string[],
1004
noMatchSelectors?: string[],
1005
): Verify => {
1006
return verifyOdtDocument(
1007
xmlChecker(selectors, noMatchSelectors),
1008
"Inspecting Odt for XPath selectors",
1009
)(file);
1010
};
1011
1012
export const ensureDocxXpath = (
1013
file: string,
1014
selectors: string[],
1015
noMatchSelectors?: string[],
1016
): Verify => {
1017
return verifyDocXDocument(
1018
xmlChecker(selectors, noMatchSelectors),
1019
"Inspecting Docx for XPath selectors",
1020
)(file);
1021
};
1022
1023
export const ensurePptxXpath = (
1024
file: string,
1025
slideNumber: number,
1026
selectors: string[],
1027
noMatchSelectors?: string[],
1028
): Verify => {
1029
return verifyPptxDocument(
1030
xmlChecker(selectors, noMatchSelectors),
1031
`Inspecting Pptx for XPath selectors on slide ${slideNumber}`,
1032
)(file, slideNumber);
1033
};
1034
1035
export const ensurePptxLayout = (
1036
file: string,
1037
slideNumber: number,
1038
layoutName: string,
1039
): Verify => {
1040
return verifyPptxDocument(
1041
pptxLayoutChecker(layoutName),
1042
`Inspecting Pptx for slide ${slideNumber} having layout ${layoutName}.`,
1043
)(file, slideNumber, true);
1044
};
1045
1046
export const ensurePptxMaxSlides = (
1047
file: string,
1048
slideNumberMax: number,
1049
): Verify => {
1050
return verifyPptxDocument(
1051
// callback won't be used here
1052
() => Promise.resolve(),
1053
`Checking Pptx for maximum ${slideNumberMax} slides`,
1054
)(file, slideNumberMax, true);
1055
};
1056
1057
export const ensureDocxRegexMatches = (
1058
file: string,
1059
regexes: (string | RegExp)[],
1060
): Verify => {
1061
return verifyDocXDocument((xml) => {
1062
regexes.forEach((regex) => {
1063
if (typeof regex === "string") {
1064
regex = new RegExp(regex);
1065
}
1066
assert(
1067
regex.test(xml),
1068
`Required DocX Element ${String(regex)} is missing.`,
1069
);
1070
});
1071
return Promise.resolve();
1072
}, "Inspecting Docx for Regex matches")(file);
1073
};
1074
1075
export const ensureEpubFileRegexMatches = (
1076
epubFile: string,
1077
pathsAndRegexes: {
1078
path: string;
1079
regexes: (string | RegExp)[][];
1080
}[]
1081
): Verify => {
1082
return verifyEpubDocument(async (epubDir) => {
1083
for (const { path, regexes } of pathsAndRegexes) {
1084
const file = join(epubDir, path);
1085
assert(
1086
existsSync(file),
1087
`File ${file} doesn't exist in Epub`,
1088
);
1089
const content = await Deno.readTextFile(file);
1090
const mustMatch: (RegExp | string)[] = [];
1091
const mustNotMatch: (RegExp | string)[] = [];
1092
if (regexes.length) {
1093
mustMatch.push(...regexes[0]);
1094
}
1095
if (regexes.length > 1) {
1096
mustNotMatch.push(...regexes[1]);
1097
}
1098
1099
mustMatch.forEach((regex) => {
1100
if (typeof regex === "string") {
1101
regex = new RegExp(regex);
1102
}
1103
assert(
1104
regex.test(content),
1105
`Required match ${String(regex)} is missing from file ${file}.`,
1106
);
1107
});
1108
mustNotMatch.forEach((regex) => {
1109
if (typeof regex === "string") {
1110
regex = new RegExp(regex);
1111
}
1112
assert(
1113
!regex.test(content),
1114
`Illegal match ${String(regex)} was found in file ${file}.`,
1115
);
1116
});
1117
}
1118
}, "Inspecting Epub for Regex matches")(epubFile);
1119
}
1120
1121
// export const ensureDocxRegexMatches = (
1122
// file: string,
1123
// regexes: (string | RegExp)[],
1124
// ): Verify => {
1125
// return {
1126
// name: "Inspecting Docx for Regex matches",
1127
// verify: async (_output: ExecuteOutput[]) => {
1128
// const [_dir, stem] = dirAndStem(file);
1129
// const temp = await Deno.makeTempDir();
1130
// try {
1131
// // Move the docx to a temp dir and unzip it
1132
// const zipFile = join(temp, stem + ".zip");
1133
// await Deno.rename(file, zipFile);
1134
// await unzip(zipFile);
1135
1136
// // Open the core xml document and match the matches
1137
// const docXml = join(temp, "word", "document.xml");
1138
// const xml = await Deno.readTextFile(docXml);
1139
// regexes.forEach((regex) => {
1140
// if (typeof regex === "string") {
1141
// regex = new RegExp(regex);
1142
// }
1143
// assert(
1144
// regex.test(xml),
1145
// `Required DocX Element ${String(regex)} is missing.`,
1146
// );
1147
// });
1148
// } finally {
1149
// await Deno.remove(temp, { recursive: true });
1150
// }
1151
// },
1152
// };
1153
// };
1154
1155
export const ensurePptxRegexMatches = (
1156
file: string,
1157
regexes: (string | RegExp)[],
1158
slideNumber: number,
1159
): Verify => {
1160
return {
1161
name: "Inspecting Pptx for Regex matches",
1162
verify: async (_output: ExecuteOutput[]) => {
1163
const [_dir, stem] = dirAndStem(file);
1164
const temp = await Deno.makeTempDir();
1165
try {
1166
// Move the docx to a temp dir and unzip it
1167
const zipFile = join(temp, stem + ".zip");
1168
await Deno.rename(file, zipFile);
1169
await unzip(zipFile);
1170
1171
// Open the core xml document and match the matches
1172
const slidePath = join(temp, "ppt", "slides");
1173
const slideFile = join(slidePath, `slide${slideNumber}.xml`);
1174
assert(
1175
existsSync(slideFile),
1176
`Slide number ${slideNumber} is not in the Pptx`,
1177
);
1178
const xml = await Deno.readTextFile(slideFile);
1179
regexes.forEach((regex) => {
1180
if (typeof regex === "string") {
1181
regex = new RegExp(regex);
1182
}
1183
assert(
1184
regex.test(xml),
1185
`Required Pptx Element ${String(regex)} is missing.`,
1186
);
1187
});
1188
} finally {
1189
await Deno.remove(temp, { recursive: true });
1190
}
1191
},
1192
};
1193
};
1194
1195
export function requireLatexPackage(pkg: string, opts?: string): RegExp {
1196
if (opts) {
1197
return RegExp(`\\\\usepackage\\[${opts}\\]{${pkg}}`, "g");
1198
} else {
1199
return RegExp(`\\\\usepackage{${pkg}}`, "g");
1200
}
1201
}
1202
1203
export const noSupportingFiles = (
1204
input: string,
1205
to: string,
1206
projectOutDir?: string,
1207
): Verify => {
1208
return {
1209
name: "Verify No Supporting Files Dir",
1210
verify: (_output: ExecuteOutput[]) => {
1211
const outputFile = outputForInput(input, to, projectOutDir);
1212
verifyNoPath(outputFile.supportPath);
1213
return Promise.resolve();
1214
},
1215
};
1216
};
1217
1218
export const hasSupportingFiles = (
1219
input: string,
1220
to: string,
1221
projectOutDir?: string,
1222
): Verify => {
1223
return {
1224
name: "Has Supporting Files Dir",
1225
verify: (_output: ExecuteOutput[]) => {
1226
const outputFile = outputForInput(input, to, projectOutDir);
1227
verifyPath(outputFile.supportPath);
1228
return Promise.resolve();
1229
},
1230
};
1231
};
1232
1233
export const verifyYamlFile = (
1234
file: string,
1235
func: (yaml: unknown) => boolean,
1236
): Verify => {
1237
return {
1238
name: "Project Yaml is Valid",
1239
verify: async (_output: ExecuteOutput[]) => {
1240
if (existsSync(file)) {
1241
const raw = await Deno.readTextFile(file);
1242
if (raw) {
1243
const yaml = readYamlFromString(raw);
1244
const isValid = func(yaml);
1245
assert(isValid, "Project Metadata isn't valid");
1246
}
1247
}
1248
},
1249
};
1250
};
1251
1252
export function verifyPath(path: string) {
1253
const pathExists = existsSync(path);
1254
assert(pathExists, `Path ${path} doesn't exist`);
1255
}
1256
1257
export function verifyNoPath(path: string) {
1258
const pathExists = existsSync(path);
1259
assert(!pathExists, `Unexpected path: ${path}`);
1260
}
1261
1262
export const ensureHtmlSelectorSatisfies = (
1263
file: string,
1264
selector: string,
1265
predicate: (list: NodeList) => boolean,
1266
): Verify => {
1267
return {
1268
name: "Inspecting HTML for Selectors",
1269
verify: async (_output: ExecuteOutput[]) => {
1270
const htmlInput = await Deno.readTextFile(file);
1271
const doc = new DOMParser().parseFromString(htmlInput, "text/html")!;
1272
// quirk: deno claims the result of this is "NodeListPublic", which is not an exported type in deno-dom.
1273
// so we cast.
1274
const nodeList = doc.querySelectorAll(selector) as NodeList;
1275
assert(
1276
predicate(nodeList),
1277
`Selector ${selector} didn't satisfy predicate`,
1278
);
1279
},
1280
};
1281
};
1282
1283
export const ensureXmlValidatesWithXsd = (
1284
file: string,
1285
xsdPath: string,
1286
): Verify => {
1287
return {
1288
name: "Validating XML",
1289
verify: async (_output: ExecuteOutput[]) => {
1290
if (!isWindows) {
1291
const args = ["--noout", "--valid", file, "--path", xsdPath];
1292
const runOptions: ExecProcessOptions = {
1293
cmd: "xmllint",
1294
args,
1295
stderr: "piped",
1296
stdout: "piped",
1297
};
1298
const result = await execProcess(runOptions);
1299
assert(
1300
result.success,
1301
`Failed XSD Validation for file ${file}\n${result.stderr}`,
1302
);
1303
}
1304
},
1305
};
1306
};
1307
1308
export const ensureMECAValidates = (
1309
mecaFile: string,
1310
): Verify => {
1311
return {
1312
name: "Validating MECA Archive",
1313
verify: async (_output: ExecuteOutput[]) => {
1314
if (!isWindows) {
1315
const hasNpm = await which("npm");
1316
if (hasNpm) {
1317
const hasMeca = await which("meca");
1318
if (hasMeca) {
1319
const result = await execProcess({
1320
cmd: "meca",
1321
args: ["validate", mecaFile],
1322
stderr: "piped",
1323
stdout: "piped",
1324
});
1325
assert(
1326
result.success,
1327
`Failed MECA Validation\n${result.stderr}`,
1328
);
1329
} else {
1330
console.log("meca not present, skipping MECA validation");
1331
}
1332
} else {
1333
console.log("npm not present, skipping MECA validation");
1334
}
1335
}
1336
},
1337
};
1338
};
1339
1340
1341
const asRegexp = (m: string | RegExp) => {
1342
if (typeof m === "string") {
1343
return new RegExp(m);
1344
} else {
1345
return m;
1346
}
1347
};
1348
1349
// Re-export ensurePdfTextPositions from dedicated module
1350
export { ensurePdfTextPositions } from "./verify-pdf-text-position.ts";
1351
1352
// Re-export ensurePdfMetadata from dedicated module
1353
export { ensurePdfMetadata } from "./verify-pdf-metadata.ts";
1354
1355