Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/tests/verify.ts
6450 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, 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 the intermediate kept file
647
// FIXME: do this properly without resorting on file having keep-*
648
// Note: keep-typ/keep-tex places files alongside source, not in output dir
649
export const verifyKeepFileRegexMatches = (
650
toExt: string,
651
keepExt: string,
652
): (file: string, matchesUntyped: (string | RegExp)[], noMatchesUntyped?: (string | RegExp)[], inputFile?: string) => Verify => {
653
return (file: string, matchesUntyped: (string | RegExp)[], noMatchesUntyped?: (string | RegExp)[], inputFile?: string) => {
654
// Kept files are alongside source, so derive from inputFile if provided
655
const keptFile = inputFile
656
? join(dirname(inputFile), basename(file).replace(`.${toExt}`, `.${keepExt}`))
657
: file.replace(`.${toExt}`, `.${keepExt}`);
658
const keptFileChecker = async (file: string, matches: RegExp[], noMatches: RegExp[] | undefined) => {
659
try {
660
await regexChecker(file, matches, noMatches);
661
} finally {
662
if (!Deno.env.get("QUARTO_TEST_KEEP_OUTPUTS")) {
663
await safeRemoveSync(file);
664
}
665
}
666
}
667
return verifyFileRegexMatches(keptFileChecker, `Inspecting intermediate ${keptFile} for Regex matches`)(keptFile, matchesUntyped, noMatchesUntyped);
668
}
669
};
670
671
// FIXME: do this properly without resorting on file having keep-typ
672
export const ensureTypstFileRegexMatches = (
673
file: string,
674
matchesUntyped: (string | RegExp)[],
675
noMatchesUntyped?: (string | RegExp)[],
676
inputFile?: string,
677
): Verify => {
678
return(verifyKeepFileRegexMatches("pdf", "typ")(file, matchesUntyped, noMatchesUntyped, inputFile));
679
};
680
681
// FIXME: do this properly without resorting on file having keep-tex
682
export const ensureLatexFileRegexMatches = (
683
file: string,
684
matchesUntyped: (string | RegExp)[],
685
noMatchesUntyped?: (string | RegExp)[],
686
inputFile?: string,
687
): Verify => {
688
return(verifyKeepFileRegexMatches("pdf", "tex")(file, matchesUntyped, noMatchesUntyped, inputFile));
689
};
690
691
// Use this function to Regex match text in a rendered PDF file
692
// This requires pdftotext to be available on PATH
693
export const ensurePdfRegexMatches = (
694
file: string,
695
matchesUntyped: (string | RegExp)[],
696
noMatchesUntyped?: (string | RegExp)[],
697
): Verify => {
698
const matches = matchesUntyped.map(asRegexp);
699
const noMatches = noMatchesUntyped?.map(asRegexp);
700
return {
701
name: `Inspecting ${file} for Regex matches`,
702
verify: async (_output: ExecuteOutput[]) => {
703
const cmd = new Deno.Command("pdftotext", {
704
args: [file, "-"],
705
stdout: "piped",
706
})
707
const output = await cmd.output();
708
assert(output.success, `Failed to extract text from ${file}.`)
709
const text = new TextDecoder().decode(output.stdout);
710
711
// Collect all failures instead of failing on first mismatch
712
const failures: string[] = [];
713
714
matches.forEach((regex) => {
715
if (!regex.test(text)) {
716
failures.push(`Required match ${String(regex)} is missing`);
717
}
718
});
719
720
if (noMatches) {
721
noMatches.forEach((regex) => {
722
if (regex.test(text)) {
723
failures.push(`Illegal match ${String(regex)} was found`);
724
}
725
});
726
}
727
728
assert(
729
failures.length === 0,
730
`${failures.length} regex mismatch(es) in ${file}:\n - ${failures.join('\n - ')}`,
731
);
732
},
733
};
734
}
735
736
export const verifyJatsDocument = (
737
callback: (doc: string) => Promise<void>,
738
name?: string,
739
): (file: string) => Verify => {
740
return (file: string) => ({
741
name: name ?? "Inspecting Jats",
742
verify: async (_output: ExecuteOutput[]) => {
743
const xml = await Deno.readTextFile(file);
744
await callback(xml);
745
},
746
});
747
};
748
749
export const verifyOdtDocument = (
750
callback: (doc: string) => Promise<void>,
751
name?: string,
752
): (file: string) => Verify => {
753
return (file: string) => ({
754
name: name ?? "Inspecting Odt",
755
verify: async (_output: ExecuteOutput[]) => {
756
return await withDocxContent(file, callback);
757
},
758
});
759
};
760
761
export const verifyDocXDocument = (
762
callback: (doc: string) => Promise<void>,
763
name?: string,
764
): (file: string) => Verify => {
765
return (file: string) => ({
766
name: name ?? "Inspecting Docx",
767
verify: async (_output: ExecuteOutput[]) => {
768
return await withDocxContent(file, callback);
769
},
770
});
771
};
772
773
export const verifyEpubDocument = (
774
callback: (path: string) => Promise<void>,
775
name?: string,
776
): (file: string) => Verify => {
777
return (file: string) => ({
778
name: name ?? "Inspecting Epub",
779
verify: async (_output: ExecuteOutput[]) => {
780
return await withEpubDirectory(file, callback);
781
},
782
});
783
}
784
785
export const verifyPptxDocument = (
786
callback: (doc: string, docFile: string) => Promise<void>,
787
name?: string,
788
): (file: string, slideNumber: number, rels?: boolean, isSlideMax?: boolean) => Verify => {
789
return (file: string, slideNumber: number, rels: boolean = false, isSlideMax: boolean = false) => ({
790
name: name ?? "Inspecting Pptx",
791
verify: async (_output: ExecuteOutput[]) => {
792
return await withPptxContent(file, slideNumber, rels, callback, isSlideMax);
793
},
794
});
795
};
796
797
const xmlChecker = (
798
selectors: string[],
799
noMatchSelectors?: string[],
800
): (xmlText: string) => Promise<void> => {
801
return (xmlText: string) => {
802
const xmlDoc = parseXmlDocument(xmlText);
803
for (const selector of selectors) {
804
const xpathResult = xpath.evaluateXPath(selector, xmlDoc);
805
const passes = (!Array.isArray(xpathResult) && xpathResult !== null) ||
806
(Array.isArray(xpathResult) && xpathResult.length > 0);
807
assert(
808
passes,
809
`Required XPath selector ${selector} returned empty array. Failing document follows:\n\n${xmlText}}`,
810
);
811
}
812
for (const falseSelector of noMatchSelectors ?? []) {
813
const xpathResult = xpath.evaluateXPath(falseSelector, xmlDoc);
814
const passes = (!Array.isArray(xpathResult) && xpathResult !== null) ||
815
(Array.isArray(xpathResult) && xpathResult.length > 0);
816
assert(
817
!passes,
818
`Illegal XPath selector ${falseSelector} returned non-empty array. Failing document follows:\n\n${xmlText}}`,
819
);
820
}
821
return Promise.resolve();
822
};
823
};
824
825
const pptxLayoutChecker = (layoutName: string): (xmlText: string, xmlFile: string) => Promise<void> => {
826
return async (xmlText: string, xmlFile: string) => {
827
// Parse the XML from slide#.xml.rels
828
const xmlDoc = parseXmlDocument(xmlText);
829
830
// Select the Relationship element with the correct Type attribute
831
const relationshipSelector = "/Relationships/Relationship[substring(@Type, string-length(@Type) - string-length('relationships/slideLayout') + 1) = 'relationships/slideLayout']/@Target";
832
const slideLayoutFile = xpath.evaluateXPathToString(relationshipSelector, xmlDoc);
833
834
assert(
835
slideLayoutFile,
836
`Required XPath selector ${relationshipSelector} returned empty string. Failing document ${basename(xmlFile)} follows:\n\n${xmlText}}`,
837
);
838
839
// Construct the full path to the slide layout file
840
// slideLayoutFile is a relative path from the slide xm document, that the `_rels` equivalent was about
841
const layoutFilePath = resolve(dirname(dirname(xmlFile)), slideLayoutFile);
842
843
// Now we need to check the slide layout file
844
const layoutXml = Deno.readTextFileSync(layoutFilePath);
845
846
// Parse the XML from slideLayout#.xml
847
const layoutDoc = parseXmlDocument(layoutXml);
848
849
// Select the p:cSld element with the correct name attribute
850
const layoutSelector = '//p:cSld/@name';
851
const layout = xpath.evaluateXPathToString(layoutSelector, layoutDoc);
852
assert(
853
layout === layoutName,
854
`Slides is not using "${layoutName}" layout - Current value: "${layout}". Failing document ${basename(layoutFilePath)} follows:\n\n${layoutXml}}`,
855
);
856
857
return Promise.resolve();
858
};
859
};
860
861
export const ensureJatsXpath = (
862
file: string,
863
selectors: string[],
864
noMatchSelectors?: string[],
865
): Verify => {
866
return verifyJatsDocument(
867
xmlChecker(selectors, noMatchSelectors),
868
"Inspecting Jats for XPath selectors",
869
)(file);
870
};
871
872
export const ensureOdtXpath = (
873
file: string,
874
selectors: string[],
875
noMatchSelectors?: string[],
876
): Verify => {
877
return verifyOdtDocument(
878
xmlChecker(selectors, noMatchSelectors),
879
"Inspecting Odt for XPath selectors",
880
)(file);
881
};
882
883
export const ensureDocxXpath = (
884
file: string,
885
selectors: string[],
886
noMatchSelectors?: string[],
887
): Verify => {
888
return verifyDocXDocument(
889
xmlChecker(selectors, noMatchSelectors),
890
"Inspecting Docx for XPath selectors",
891
)(file);
892
};
893
894
export const ensurePptxXpath = (
895
file: string,
896
slideNumber: number,
897
selectors: string[],
898
noMatchSelectors?: string[],
899
): Verify => {
900
return verifyPptxDocument(
901
xmlChecker(selectors, noMatchSelectors),
902
`Inspecting Pptx for XPath selectors on slide ${slideNumber}`,
903
)(file, slideNumber);
904
};
905
906
export const ensurePptxLayout = (
907
file: string,
908
slideNumber: number,
909
layoutName: string,
910
): Verify => {
911
return verifyPptxDocument(
912
pptxLayoutChecker(layoutName),
913
`Inspecting Pptx for slide ${slideNumber} having layout ${layoutName}.`,
914
)(file, slideNumber, true);
915
};
916
917
export const ensurePptxMaxSlides = (
918
file: string,
919
slideNumberMax: number,
920
): Verify => {
921
return verifyPptxDocument(
922
// callback won't be used here
923
() => Promise.resolve(),
924
`Checking Pptx for maximum ${slideNumberMax} slides`,
925
)(file, slideNumberMax, true);
926
};
927
928
export const ensureDocxRegexMatches = (
929
file: string,
930
regexes: (string | RegExp)[],
931
): Verify => {
932
return verifyDocXDocument((xml) => {
933
regexes.forEach((regex) => {
934
if (typeof regex === "string") {
935
regex = new RegExp(regex);
936
}
937
assert(
938
regex.test(xml),
939
`Required DocX Element ${String(regex)} is missing.`,
940
);
941
});
942
return Promise.resolve();
943
}, "Inspecting Docx for Regex matches")(file);
944
};
945
946
export const ensureEpubFileRegexMatches = (
947
epubFile: string,
948
pathsAndRegexes: {
949
path: string;
950
regexes: (string | RegExp)[][];
951
}[]
952
): Verify => {
953
return verifyEpubDocument(async (epubDir) => {
954
for (const { path, regexes } of pathsAndRegexes) {
955
const file = join(epubDir, path);
956
assert(
957
existsSync(file),
958
`File ${file} doesn't exist in Epub`,
959
);
960
const content = await Deno.readTextFile(file);
961
const mustMatch: (RegExp | string)[] = [];
962
const mustNotMatch: (RegExp | string)[] = [];
963
if (regexes.length) {
964
mustMatch.push(...regexes[0]);
965
}
966
if (regexes.length > 1) {
967
mustNotMatch.push(...regexes[1]);
968
}
969
970
mustMatch.forEach((regex) => {
971
if (typeof regex === "string") {
972
regex = new RegExp(regex);
973
}
974
assert(
975
regex.test(content),
976
`Required match ${String(regex)} is missing from file ${file}.`,
977
);
978
});
979
mustNotMatch.forEach((regex) => {
980
if (typeof regex === "string") {
981
regex = new RegExp(regex);
982
}
983
assert(
984
!regex.test(content),
985
`Illegal match ${String(regex)} was found in file ${file}.`,
986
);
987
});
988
}
989
}, "Inspecting Epub for Regex matches")(epubFile);
990
}
991
992
// export const ensureDocxRegexMatches = (
993
// file: string,
994
// regexes: (string | RegExp)[],
995
// ): Verify => {
996
// return {
997
// name: "Inspecting Docx for Regex matches",
998
// verify: async (_output: ExecuteOutput[]) => {
999
// const [_dir, stem] = dirAndStem(file);
1000
// const temp = await Deno.makeTempDir();
1001
// try {
1002
// // Move the docx to a temp dir and unzip it
1003
// const zipFile = join(temp, stem + ".zip");
1004
// await Deno.rename(file, zipFile);
1005
// await unzip(zipFile);
1006
1007
// // Open the core xml document and match the matches
1008
// const docXml = join(temp, "word", "document.xml");
1009
// const xml = await Deno.readTextFile(docXml);
1010
// regexes.forEach((regex) => {
1011
// if (typeof regex === "string") {
1012
// regex = new RegExp(regex);
1013
// }
1014
// assert(
1015
// regex.test(xml),
1016
// `Required DocX Element ${String(regex)} is missing.`,
1017
// );
1018
// });
1019
// } finally {
1020
// await Deno.remove(temp, { recursive: true });
1021
// }
1022
// },
1023
// };
1024
// };
1025
1026
export const ensurePptxRegexMatches = (
1027
file: string,
1028
regexes: (string | RegExp)[],
1029
slideNumber: number,
1030
): Verify => {
1031
return {
1032
name: "Inspecting Pptx for Regex matches",
1033
verify: async (_output: ExecuteOutput[]) => {
1034
const [_dir, stem] = dirAndStem(file);
1035
const temp = await Deno.makeTempDir();
1036
try {
1037
// Move the docx to a temp dir and unzip it
1038
const zipFile = join(temp, stem + ".zip");
1039
await Deno.rename(file, zipFile);
1040
await unzip(zipFile);
1041
1042
// Open the core xml document and match the matches
1043
const slidePath = join(temp, "ppt", "slides");
1044
const slideFile = join(slidePath, `slide${slideNumber}.xml`);
1045
assert(
1046
existsSync(slideFile),
1047
`Slide number ${slideNumber} is not in the Pptx`,
1048
);
1049
const xml = await Deno.readTextFile(slideFile);
1050
regexes.forEach((regex) => {
1051
if (typeof regex === "string") {
1052
regex = new RegExp(regex);
1053
}
1054
assert(
1055
regex.test(xml),
1056
`Required Pptx Element ${String(regex)} is missing.`,
1057
);
1058
});
1059
} finally {
1060
await Deno.remove(temp, { recursive: true });
1061
}
1062
},
1063
};
1064
};
1065
1066
export function requireLatexPackage(pkg: string, opts?: string): RegExp {
1067
if (opts) {
1068
return RegExp(`\\\\usepackage\\[${opts}\\]{${pkg}}`, "g");
1069
} else {
1070
return RegExp(`\\\\usepackage{${pkg}}`, "g");
1071
}
1072
}
1073
1074
export const noSupportingFiles = (
1075
input: string,
1076
to: string,
1077
projectOutDir?: string,
1078
): Verify => {
1079
return {
1080
name: "Verify No Supporting Files Dir",
1081
verify: (_output: ExecuteOutput[]) => {
1082
const outputFile = outputForInput(input, to, projectOutDir);
1083
verifyNoPath(outputFile.supportPath);
1084
return Promise.resolve();
1085
},
1086
};
1087
};
1088
1089
export const hasSupportingFiles = (
1090
input: string,
1091
to: string,
1092
projectOutDir?: string,
1093
): Verify => {
1094
return {
1095
name: "Has Supporting Files Dir",
1096
verify: (_output: ExecuteOutput[]) => {
1097
const outputFile = outputForInput(input, to, projectOutDir);
1098
verifyPath(outputFile.supportPath);
1099
return Promise.resolve();
1100
},
1101
};
1102
};
1103
1104
export const verifyYamlFile = (
1105
file: string,
1106
func: (yaml: unknown) => boolean,
1107
): Verify => {
1108
return {
1109
name: "Project Yaml is Valid",
1110
verify: async (_output: ExecuteOutput[]) => {
1111
if (existsSync(file)) {
1112
const raw = await Deno.readTextFile(file);
1113
if (raw) {
1114
const yaml = readYamlFromString(raw);
1115
const isValid = func(yaml);
1116
assert(isValid, "Project Metadata isn't valid");
1117
}
1118
}
1119
},
1120
};
1121
};
1122
1123
export function verifyPath(path: string) {
1124
const pathExists = existsSync(path);
1125
assert(pathExists, `Path ${path} doesn't exist`);
1126
}
1127
1128
export function verifyNoPath(path: string) {
1129
const pathExists = existsSync(path);
1130
assert(!pathExists, `Unexpected path: ${path}`);
1131
}
1132
1133
export const ensureHtmlSelectorSatisfies = (
1134
file: string,
1135
selector: string,
1136
predicate: (list: NodeList) => boolean,
1137
): Verify => {
1138
return {
1139
name: "Inspecting HTML for Selectors",
1140
verify: async (_output: ExecuteOutput[]) => {
1141
const htmlInput = await Deno.readTextFile(file);
1142
const doc = new DOMParser().parseFromString(htmlInput, "text/html")!;
1143
// quirk: deno claims the result of this is "NodeListPublic", which is not an exported type in deno-dom.
1144
// so we cast.
1145
const nodeList = doc.querySelectorAll(selector) as NodeList;
1146
assert(
1147
predicate(nodeList),
1148
`Selector ${selector} didn't satisfy predicate`,
1149
);
1150
},
1151
};
1152
};
1153
1154
export const ensureXmlValidatesWithXsd = (
1155
file: string,
1156
xsdPath: string,
1157
): Verify => {
1158
return {
1159
name: "Validating XML",
1160
verify: async (_output: ExecuteOutput[]) => {
1161
if (!isWindows) {
1162
const args = ["--noout", "--valid", file, "--path", xsdPath];
1163
const runOptions: ExecProcessOptions = {
1164
cmd: "xmllint",
1165
args,
1166
stderr: "piped",
1167
stdout: "piped",
1168
};
1169
const result = await execProcess(runOptions);
1170
assert(
1171
result.success,
1172
`Failed XSD Validation for file ${file}\n${result.stderr}`,
1173
);
1174
}
1175
},
1176
};
1177
};
1178
1179
export const ensureMECAValidates = (
1180
mecaFile: string,
1181
): Verify => {
1182
return {
1183
name: "Validating MECA Archive",
1184
verify: async (_output: ExecuteOutput[]) => {
1185
if (!isWindows) {
1186
const hasNpm = await which("npm");
1187
if (hasNpm) {
1188
const hasMeca = await which("meca");
1189
if (hasMeca) {
1190
const result = await execProcess({
1191
cmd: "meca",
1192
args: ["validate", mecaFile],
1193
stderr: "piped",
1194
stdout: "piped",
1195
});
1196
assert(
1197
result.success,
1198
`Failed MECA Validation\n${result.stderr}`,
1199
);
1200
} else {
1201
console.log("meca not present, skipping MECA validation");
1202
}
1203
} else {
1204
console.log("npm not present, skipping MECA validation");
1205
}
1206
}
1207
},
1208
};
1209
};
1210
1211
1212
const asRegexp = (m: string | RegExp) => {
1213
if (typeof m === "string") {
1214
return new RegExp(m);
1215
} else {
1216
return m;
1217
}
1218
};
1219
1220
// Re-export ensurePdfTextPositions from dedicated module
1221
export { ensurePdfTextPositions } from "./verify-pdf-text-position.ts";
1222
1223
// Re-export ensurePdfMetadata from dedicated module
1224
export { ensurePdfMetadata } from "./verify-pdf-metadata.ts";
1225
1226