Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/tests/verify.ts
3544 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 { canonicalizeSnapshot, checkSnapshot } from "./verify-snapshot.ts";
24
25
export const withDocxContent = async <T>(
26
file: string,
27
k: (xml: string) => Promise<T>
28
) => {
29
const [_dir, stem] = dirAndStem(file);
30
const temp = await Deno.makeTempDir();
31
try {
32
// Move the docx to a temp dir and unzip it
33
const zipFile = join(temp, stem + ".zip");
34
await Deno.copyFile(file, zipFile);
35
await unzip(zipFile);
36
37
// Open the core xml document and match the matches
38
const docXml = join(temp, "word", "document.xml");
39
const xml = await Deno.readTextFile(docXml);
40
const result = await k(xml);
41
return result;
42
} finally {
43
await Deno.remove(temp, { recursive: true });
44
}
45
};
46
47
export const withEpubDirectory = async <T>(
48
file: string,
49
k: (path: string) => Promise<T>
50
) => {
51
const [_dir, stem] = dirAndStem(file);
52
const temp = await Deno.makeTempDir();
53
try {
54
// Move the docx to a temp dir and unzip it
55
const zipFile = join(temp, stem + ".zip");
56
await Deno.copyFile(file, zipFile);
57
await unzip(zipFile);
58
59
// Open the core xml document and match the matches
60
const result = await k(temp);
61
return result;
62
} finally {
63
await Deno.remove(temp, { recursive: true });
64
}
65
};
66
67
export const withPptxContent = async <T>(
68
file: string,
69
slideNumber: number,
70
rels: boolean,
71
// takes the parsed XML and the XML file path
72
k: (xml: string, xmlFile: string) => Promise<T>,
73
isSlideMax: boolean = false,
74
) => {
75
const [_dir, stem] = dirAndStem(file);
76
const temp = await Deno.makeTempDir();
77
try {
78
// Move the pptx to a temp dir and unzip it
79
const zipFile = join(temp, stem + ".zip");
80
await Deno.copyFile(file, zipFile);
81
await unzip(zipFile);
82
83
// Open the core xml document and match the matches
84
const slidePath = join(temp, "ppt", "slides");
85
let slideFile = join(slidePath, rels ? join("_rels", `slide${slideNumber}.xml.rels`) : `slide${slideNumber}.xml`);
86
assert(
87
existsSync(slideFile),
88
`Slide number ${slideNumber} is not in the Pptx`,
89
);
90
if (isSlideMax) {
91
assert(
92
!existsSync(join(slidePath, `slide${slideNumber + 1}.xml`)),
93
`Pptx has more than ${slideNumber} slides.`,
94
);
95
return Promise.resolve();
96
} else {
97
const xml = await Deno.readTextFile(slideFile);
98
const result = await k(xml, slideFile);
99
return result;
100
}
101
} finally {
102
await Deno.remove(temp, { recursive: true });
103
}
104
};
105
106
const checkErrors = (outputs: ExecuteOutput[]): { errors: boolean, messages: string | undefined } => {
107
const isError = (output: ExecuteOutput) => {
108
return output.levelName.toLowerCase() === "error";
109
};
110
const errors = outputs.some(isError);
111
112
const messages = errors ? outputs.filter(isError).map((outputs) => outputs.msg).join("\n") : undefined
113
114
return({
115
errors,
116
messages
117
})
118
}
119
120
export const noErrors: Verify = {
121
name: "No Errors",
122
verify: (outputs: ExecuteOutput[]) => {
123
124
const { errors, messages } = checkErrors(outputs);
125
126
assert(
127
!errors,
128
`Errors During Execution\n|${messages}|`,
129
);
130
131
return Promise.resolve();
132
},
133
};
134
135
export const shouldError: Verify = {
136
name: "Should Error",
137
verify: (outputs: ExecuteOutput[]) => {
138
139
const { errors } = checkErrors(outputs);
140
141
assert(
142
errors,
143
`No errors during execution while rendering was expected to fail.`,
144
);
145
146
return Promise.resolve();
147
},
148
};
149
150
export const noErrorsOrWarnings: Verify = {
151
name: "No Errors or Warnings",
152
verify: (outputs: ExecuteOutput[]) => {
153
const isErrorOrWarning = (output: ExecuteOutput) => {
154
return output.levelName.toLowerCase() === "warn" ||
155
output.levelName.toLowerCase() === "error";
156
// I'd like to do this but many many of our tests
157
// would fail right now because we're assuming noErrorsOrWarnings
158
// doesn't include warnings from the lua subsystem
159
// ||
160
// output.msg.startsWith("(W)"); // this is a warning from quarto.log.warning()
161
};
162
163
const errorsOrWarnings = outputs.some(isErrorOrWarning);
164
165
// Output an error or warning if it exists
166
if (errorsOrWarnings) {
167
const messages = outputs.filter(isErrorOrWarning).map((outputs) =>
168
outputs.msg
169
).join("\n");
170
171
assert(
172
!errorsOrWarnings,
173
`Error or Warnings During Execution\n|${messages}|`,
174
);
175
}
176
177
return Promise.resolve();
178
},
179
};
180
181
export const printsMessage = (options: {
182
level: "DEBUG" | "INFO" | "WARN" | "ERROR";
183
regex: string | RegExp;
184
negate?: boolean;
185
}): Verify => {
186
const { level, regex: regexPattern, negate = false } = options; // Set default here
187
return {
188
name: `${level} matches ${String(regexPattern)}`,
189
verify: (outputs: ExecuteOutput[]) => {
190
const regex = typeof regexPattern === "string"
191
? new RegExp(regexPattern)
192
: regexPattern;
193
194
const printedMessage = outputs.some((output) => {
195
return output.levelName === level && output.msg.match(regex);
196
});
197
assert(
198
negate ? !printedMessage : printedMessage,
199
`${negate ? "Found" : "Missing"} ${level} ${String(regex)}`
200
);
201
return Promise.resolve();
202
},
203
};
204
};
205
206
export const printsJson = {
207
name: "Prints JSON Output",
208
verify: (outputs: ExecuteOutput[]) => {
209
outputs.filter((out) => out.msg !== "" && out.levelName === "INFO").forEach(
210
(out) => {
211
let json = undefined;
212
try {
213
json = JSON.parse(out.msg);
214
} catch {
215
assert(false, "Error parsing JSON returned by quarto meta");
216
}
217
assert(
218
Object.keys(json).length > 0,
219
"JSON returned by quarto meta seems invalid",
220
);
221
},
222
);
223
return Promise.resolve();
224
},
225
};
226
227
export const fileExists = (file: string): Verify => {
228
return {
229
name: `File ${file} exists`,
230
verify: (_output: ExecuteOutput[]) => {
231
verifyPath(file);
232
return Promise.resolve();
233
},
234
};
235
};
236
237
export const pathDoNotExists = (path: string): Verify => {
238
return {
239
name: `path ${path} exists`,
240
verify: (_output: ExecuteOutput[]) => {
241
verifyNoPath(path);
242
return Promise.resolve();
243
},
244
};
245
};
246
247
export const directoryContainsOnlyAllowedPaths = (dir: string, paths: string[]): Verify => {
248
return {
249
name: `Ensure only has ${paths.length} paths in folder`,
250
verify: (_output: ExecuteOutput[]) => {
251
252
for (const walk of walkSync(dir)) {
253
const path = relative(dir, walk.path);
254
if (path !== "") {
255
assert(paths.includes(path), `Unexpected path ${path} encountered.`);
256
257
}
258
}
259
return Promise.resolve();
260
},
261
};
262
}
263
264
export const folderExists = (path: string): Verify => {
265
return {
266
name: `Folder ${path} exists`,
267
verify: (_output: ExecuteOutput[]) => {
268
verifyPath(path);
269
assert(Deno.statSync(path).isDirectory, `Path ${path} isn't a folder`);
270
return Promise.resolve();
271
},
272
};
273
}
274
275
export const validJsonFileExists = (file: string): Verify => {
276
return {
277
name: `Valid Json ${file} exists`,
278
verify: (_output: ExecuteOutput[]) => {
279
const jsonStr = Deno.readTextFileSync(file);
280
JSON.parse(jsonStr);
281
return Promise.resolve();
282
}
283
}
284
}
285
286
export const validJsonWithFields = (file: string, fields: Record<string, unknown>) => {
287
return {
288
name: `Valid Json ${file} exists`,
289
verify: (_output: ExecuteOutput[]) => {
290
const jsonStr = Deno.readTextFileSync(file);
291
const json = JSON.parse(jsonStr);
292
for (const key of Object.keys(fields)) {
293
294
const value = json[key];
295
assert(ld.isEqual(value, fields[key]), `Key ${key} has invalid value in json.`);
296
}
297
298
299
return Promise.resolve();
300
}
301
}
302
}
303
304
export const outputCreated = (
305
input: string,
306
to: string,
307
projectOutDir?: string,
308
): Verify => {
309
return {
310
name: "Output Created",
311
verify: (outputs: ExecuteOutput[]) => {
312
// Check for output created message
313
const outputCreatedMsg = outputs.find((outMsg) =>
314
outMsg.msg.startsWith("Output created:")
315
);
316
assert(outputCreatedMsg !== undefined, "No output created message");
317
318
// Check for existence of the output
319
const outputFile = outputForInput(input, to, projectOutDir);
320
verifyPath(outputFile.outputPath);
321
return Promise.resolve();
322
},
323
};
324
};
325
326
export const directoryEmptyButFor = (
327
dir: string,
328
allowedFiles: string[],
329
): Verify => {
330
return {
331
name: "Directory is empty",
332
verify: (_outputs: ExecuteOutput[]) => {
333
for (const item of Deno.readDirSync(dir)) {
334
if (!allowedFiles.some((file) => item.name === file)) {
335
assert(false, `Unexpected content ${item.name} in ${dir}`);
336
}
337
}
338
return Promise.resolve();
339
},
340
};
341
};
342
343
export const ensureHtmlElements = (
344
file: string,
345
selectors: string[],
346
noMatchSelectors?: string[],
347
): Verify => {
348
return {
349
name: `Inspecting HTML for Selectors in ${file}`,
350
verify: async (_output: ExecuteOutput[]) => {
351
const htmlInput = await Deno.readTextFile(file);
352
const doc = new DOMParser().parseFromString(htmlInput, "text/html")!;
353
selectors.forEach((sel) => {
354
assert(
355
doc.querySelector(sel) !== null,
356
`Required DOM Element ${sel} is missing in ${file}.`,
357
);
358
});
359
360
if (noMatchSelectors) {
361
noMatchSelectors.forEach((sel) => {
362
assert(
363
doc.querySelector(sel) === null,
364
`Illegal DOM Element ${sel} is present in ${file}.`,
365
);
366
});
367
}
368
},
369
};
370
};
371
372
export const ensureHtmlElementContents = (
373
file: string,
374
options : {
375
selectors: string[],
376
matches: (string | RegExp)[],
377
noMatches?: (string | RegExp)[]
378
}
379
) => {
380
return {
381
name: "Inspecting HTML for Selector Contents",
382
verify: async (_output: ExecuteOutput[]) => {
383
const htmlInput = await Deno.readTextFile(file);
384
const doc = new DOMParser().parseFromString(htmlInput, "text/html")!;
385
options.selectors.forEach((sel) => {
386
const el = doc.querySelector(sel);
387
if (el !== null) {
388
const contents = el.innerText;
389
options.matches.forEach((regex) => {
390
assert(
391
asRegexp(regex).test(contents),
392
`Required match ${String(regex)} is missing from selector ${sel} content: ${contents}.`,
393
);
394
});
395
396
options.noMatches?.forEach((regex) => {
397
assert(
398
!asRegexp(regex).test(contents),
399
`Unexpected match ${String(regex)} is present from selector ${sel} content: ${contents}.`,
400
);
401
});
402
403
}
404
});
405
},
406
};
407
408
}
409
410
export const ensureHtmlElementCount = (
411
file: string,
412
options: {
413
selectors: string[] | string,
414
counts: number[] | number
415
}
416
): Verify => {
417
return {
418
name: "Verify number of elements for selectors",
419
verify: async (_output: ExecuteOutput[]) => {
420
const htmlInput = await Deno.readTextFile(file);
421
const doc = new DOMParser().parseFromString(htmlInput, "text/html")!;
422
423
// Convert single values to arrays for unified processing
424
const selectorsArray = Array.isArray(options.selectors) ? options.selectors : [options.selectors];
425
const countsArray = Array.isArray(options.counts) ? options.counts : [options.counts];
426
427
if (selectorsArray.length !== countsArray.length) {
428
throw new Error("Selectors and counts arrays must have the same length");
429
}
430
431
selectorsArray.forEach((selector, index) => {
432
const expectedCount = countsArray[index];
433
const elements = doc.querySelectorAll(selector);
434
assert(
435
elements.length === expectedCount,
436
`Selector '${selector}' matched ${elements.length} elements, expected ${expectedCount}.`
437
);
438
});
439
}
440
};
441
};
442
443
export const ensureSnapshotMatches = (
444
file: string,
445
): Verify => {
446
return {
447
name: "Inspecting Snapshot",
448
verify: async (_output: ExecuteOutput[]) => {
449
const good = await checkSnapshot(file);
450
if (!good) {
451
console.log("output:");
452
console.log(await canonicalizeSnapshot(file));
453
console.log("snapshot:");
454
console.log(await canonicalizeSnapshot(file + ".snapshot"));
455
}
456
assert(
457
good,
458
`Snapshot ${file}.snapshot doesn't match output`,
459
);
460
},
461
};
462
}
463
464
const regexChecker = async function(file: string, matches: RegExp[], noMatches: RegExp[] | undefined) {
465
const content = await Deno.readTextFile(file);
466
matches.forEach((regex) => {
467
assert(
468
regex.test(content),
469
`Required match ${String(regex)} is missing from file ${file}.`,
470
);
471
});
472
473
if (noMatches) {
474
noMatches.forEach((regex) => {
475
assert(
476
!regex.test(content),
477
`Illegal match ${String(regex)} was found in file ${file}.`,
478
);
479
});
480
}
481
}
482
483
export const verifyFileRegexMatches = (
484
callback: (file: string, matches: RegExp[], noMatches: RegExp[] | undefined) => Promise<void>,
485
name?: string,
486
): (file: string, matchesUntyped: (string | RegExp)[], noMatchesUntyped?: (string | RegExp)[]) => Verify => {
487
return (file: string, matchesUntyped: (string | RegExp)[], noMatchesUntyped?: (string | RegExp)[]) => {
488
// Use mutliline flag for regexes so that ^ and $ can be used
489
const asRegexp = (m: string | RegExp) => {
490
if (typeof m === "string") {
491
return new RegExp(m, "m");
492
} else {
493
return m;
494
}
495
};
496
const matches = matchesUntyped.map(asRegexp);
497
const noMatches = noMatchesUntyped?.map(asRegexp);
498
return {
499
name: name ?? `Inspecting ${file} for Regex matches`,
500
verify: async (_output: ExecuteOutput[]) => {
501
const tex = await Deno.readTextFile(file);
502
await callback(file, matches, noMatches);
503
}
504
};
505
}
506
}
507
508
// Use this function to Regex match text in the output file
509
export const ensureFileRegexMatches = (
510
file: string,
511
matchesUntyped: (string | RegExp)[],
512
noMatchesUntyped?: (string | RegExp)[],
513
): Verify => {
514
return(verifyFileRegexMatches(regexChecker)(file, matchesUntyped, noMatchesUntyped));
515
};
516
517
// Use this function to Regex match text in the intermediate kept file
518
// FIXME: do this properly without resorting on file having keep-*
519
export const verifyKeepFileRegexMatches = (
520
toExt: string,
521
keepExt: string,
522
): (file: string, matchesUntyped: (string | RegExp)[], noMatchesUntyped?: (string | RegExp)[]) => Verify => {
523
return (file: string, matchesUntyped: (string | RegExp)[], noMatchesUntyped?: (string | RegExp)[]) => {
524
const keptFile = file.replace(`.${toExt}`, `.${keepExt}`);
525
const keptFileChecker = async (file: string, matches: RegExp[], noMatches: RegExp[] | undefined) => {
526
try {
527
await regexChecker(file, matches, noMatches);
528
} finally {
529
await safeRemoveSync(file);
530
}
531
}
532
return verifyFileRegexMatches(keptFileChecker, `Inspecting intermediate ${keptFile} for Regex matches`)(keptFile, matchesUntyped, noMatchesUntyped);
533
}
534
};
535
536
// FIXME: do this properly without resorting on file having keep-typ
537
export const ensureTypstFileRegexMatches = (
538
file: string,
539
matchesUntyped: (string | RegExp)[],
540
noMatchesUntyped?: (string | RegExp)[],
541
): Verify => {
542
return(verifyKeepFileRegexMatches("pdf", "typ")(file, matchesUntyped, noMatchesUntyped));
543
};
544
545
// FIXME: do this properly without resorting on file having keep-tex
546
export const ensureLatexFileRegexMatches = (
547
file: string,
548
matchesUntyped: (string | RegExp)[],
549
noMatchesUntyped?: (string | RegExp)[],
550
): Verify => {
551
return(verifyKeepFileRegexMatches("pdf", "tex")(file, matchesUntyped, noMatchesUntyped));
552
};
553
554
// Use this function to Regex match text in a rendered PDF file
555
// This requires pdftotext to be available on PATH
556
export const ensurePdfRegexMatches = (
557
file: string,
558
matchesUntyped: (string | RegExp)[],
559
noMatchesUntyped?: (string | RegExp)[],
560
): Verify => {
561
const matches = matchesUntyped.map(asRegexp);
562
const noMatches = noMatchesUntyped?.map(asRegexp);
563
return {
564
name: `Inspecting ${file} for Regex matches`,
565
verify: async (_output: ExecuteOutput[]) => {
566
const cmd = new Deno.Command("pdftotext", {
567
args: [file, "-"],
568
stdout: "piped",
569
})
570
const output = await cmd.output();
571
assert(output.success, `Failed to extract text from ${file}.`)
572
const text = new TextDecoder().decode(output.stdout);
573
574
matches.forEach((regex) => {
575
assert(
576
regex.test(text),
577
`Required match ${String(regex)} is missing from file ${file}.`,
578
);
579
});
580
581
if (noMatches) {
582
noMatches.forEach((regex) => {
583
assert(
584
!regex.test(text),
585
`Illegal match ${String(regex)} was found in file ${file}.`,
586
);
587
});
588
}
589
},
590
};
591
}
592
593
export const verifyJatsDocument = (
594
callback: (doc: string) => Promise<void>,
595
name?: string,
596
): (file: string) => Verify => {
597
return (file: string) => ({
598
name: name ?? "Inspecting Jats",
599
verify: async (_output: ExecuteOutput[]) => {
600
const xml = await Deno.readTextFile(file);
601
await callback(xml);
602
},
603
});
604
};
605
606
export const verifyOdtDocument = (
607
callback: (doc: string) => Promise<void>,
608
name?: string,
609
): (file: string) => Verify => {
610
return (file: string) => ({
611
name: name ?? "Inspecting Odt",
612
verify: async (_output: ExecuteOutput[]) => {
613
return await withDocxContent(file, callback);
614
},
615
});
616
};
617
618
export const verifyDocXDocument = (
619
callback: (doc: string) => Promise<void>,
620
name?: string,
621
): (file: string) => Verify => {
622
return (file: string) => ({
623
name: name ?? "Inspecting Docx",
624
verify: async (_output: ExecuteOutput[]) => {
625
return await withDocxContent(file, callback);
626
},
627
});
628
};
629
630
export const verifyEpubDocument = (
631
callback: (path: string) => Promise<void>,
632
name?: string,
633
): (file: string) => Verify => {
634
return (file: string) => ({
635
name: name ?? "Inspecting Epub",
636
verify: async (_output: ExecuteOutput[]) => {
637
return await withEpubDirectory(file, callback);
638
},
639
});
640
}
641
642
export const verifyPptxDocument = (
643
callback: (doc: string, docFile: string) => Promise<void>,
644
name?: string,
645
): (file: string, slideNumber: number, rels?: boolean, isSlideMax?: boolean) => Verify => {
646
return (file: string, slideNumber: number, rels: boolean = false, isSlideMax: boolean = false) => ({
647
name: name ?? "Inspecting Pptx",
648
verify: async (_output: ExecuteOutput[]) => {
649
return await withPptxContent(file, slideNumber, rels, callback, isSlideMax);
650
},
651
});
652
};
653
654
const xmlChecker = (
655
selectors: string[],
656
noMatchSelectors?: string[],
657
): (xmlText: string) => Promise<void> => {
658
return (xmlText: string) => {
659
const xmlDoc = parseXmlDocument(xmlText);
660
for (const selector of selectors) {
661
const xpathResult = xpath.evaluateXPath(selector, xmlDoc);
662
const passes = (!Array.isArray(xpathResult) && xpathResult !== null) ||
663
(Array.isArray(xpathResult) && xpathResult.length > 0);
664
assert(
665
passes,
666
`Required XPath selector ${selector} returned empty array. Failing document follows:\n\n${xmlText}}`,
667
);
668
}
669
for (const falseSelector of noMatchSelectors ?? []) {
670
const xpathResult = xpath.evaluateXPath(falseSelector, xmlDoc);
671
const passes = (!Array.isArray(xpathResult) && xpathResult !== null) ||
672
(Array.isArray(xpathResult) && xpathResult.length > 0);
673
assert(
674
!passes,
675
`Illegal XPath selector ${falseSelector} returned non-empty array. Failing document follows:\n\n${xmlText}}`,
676
);
677
}
678
return Promise.resolve();
679
};
680
};
681
682
const pptxLayoutChecker = (layoutName: string): (xmlText: string, xmlFile: string) => Promise<void> => {
683
return async (xmlText: string, xmlFile: string) => {
684
// Parse the XML from slide#.xml.rels
685
const xmlDoc = parseXmlDocument(xmlText);
686
687
// Select the Relationship element with the correct Type attribute
688
const relationshipSelector = "/Relationships/Relationship[substring(@Type, string-length(@Type) - string-length('relationships/slideLayout') + 1) = 'relationships/slideLayout']/@Target";
689
const slideLayoutFile = xpath.evaluateXPathToString(relationshipSelector, xmlDoc);
690
691
assert(
692
slideLayoutFile,
693
`Required XPath selector ${relationshipSelector} returned empty string. Failing document ${basename(xmlFile)} follows:\n\n${xmlText}}`,
694
);
695
696
// Construct the full path to the slide layout file
697
// slideLayoutFile is a relative path from the slide xm document, that the `_rels` equivalent was about
698
const layoutFilePath = resolve(dirname(dirname(xmlFile)), slideLayoutFile);
699
700
// Now we need to check the slide layout file
701
const layoutXml = Deno.readTextFileSync(layoutFilePath);
702
703
// Parse the XML from slideLayout#.xml
704
const layoutDoc = parseXmlDocument(layoutXml);
705
706
// Select the p:cSld element with the correct name attribute
707
const layoutSelector = '//p:cSld/@name';
708
const layout = xpath.evaluateXPathToString(layoutSelector, layoutDoc);
709
assert(
710
layout === layoutName,
711
`Slides is not using "${layoutName}" layout - Current value: "${layout}". Failing document ${basename(layoutFilePath)} follows:\n\n${layoutXml}}`,
712
);
713
714
return Promise.resolve();
715
};
716
};
717
718
export const ensureJatsXpath = (
719
file: string,
720
selectors: string[],
721
noMatchSelectors?: string[],
722
): Verify => {
723
return verifyJatsDocument(
724
xmlChecker(selectors, noMatchSelectors),
725
"Inspecting Jats for XPath selectors",
726
)(file);
727
};
728
729
export const ensureOdtXpath = (
730
file: string,
731
selectors: string[],
732
noMatchSelectors?: string[],
733
): Verify => {
734
return verifyOdtDocument(
735
xmlChecker(selectors, noMatchSelectors),
736
"Inspecting Odt for XPath selectors",
737
)(file);
738
};
739
740
export const ensureDocxXpath = (
741
file: string,
742
selectors: string[],
743
noMatchSelectors?: string[],
744
): Verify => {
745
return verifyDocXDocument(
746
xmlChecker(selectors, noMatchSelectors),
747
"Inspecting Docx for XPath selectors",
748
)(file);
749
};
750
751
export const ensurePptxXpath = (
752
file: string,
753
slideNumber: number,
754
selectors: string[],
755
noMatchSelectors?: string[],
756
): Verify => {
757
return verifyPptxDocument(
758
xmlChecker(selectors, noMatchSelectors),
759
`Inspecting Pptx for XPath selectors on slide ${slideNumber}`,
760
)(file, slideNumber);
761
};
762
763
export const ensurePptxLayout = (
764
file: string,
765
slideNumber: number,
766
layoutName: string,
767
): Verify => {
768
return verifyPptxDocument(
769
pptxLayoutChecker(layoutName),
770
`Inspecting Pptx for slide ${slideNumber} having layout ${layoutName}.`,
771
)(file, slideNumber, true);
772
};
773
774
export const ensurePptxMaxSlides = (
775
file: string,
776
slideNumberMax: number,
777
): Verify => {
778
return verifyPptxDocument(
779
// callback won't be used here
780
() => Promise.resolve(),
781
`Checking Pptx for maximum ${slideNumberMax} slides`,
782
)(file, slideNumberMax, true);
783
};
784
785
export const ensureDocxRegexMatches = (
786
file: string,
787
regexes: (string | RegExp)[],
788
): Verify => {
789
return verifyDocXDocument((xml) => {
790
regexes.forEach((regex) => {
791
if (typeof regex === "string") {
792
regex = new RegExp(regex);
793
}
794
assert(
795
regex.test(xml),
796
`Required DocX Element ${String(regex)} is missing.`,
797
);
798
});
799
return Promise.resolve();
800
}, "Inspecting Docx for Regex matches")(file);
801
};
802
803
export const ensureEpubFileRegexMatches = (
804
epubFile: string,
805
pathsAndRegexes: {
806
path: string;
807
regexes: (string | RegExp)[][];
808
}[]
809
): Verify => {
810
return verifyEpubDocument(async (epubDir) => {
811
for (const { path, regexes } of pathsAndRegexes) {
812
const file = join(epubDir, path);
813
assert(
814
existsSync(file),
815
`File ${file} doesn't exist in Epub`,
816
);
817
const content = await Deno.readTextFile(file);
818
const mustMatch: (RegExp | string)[] = [];
819
const mustNotMatch: (RegExp | string)[] = [];
820
if (regexes.length) {
821
mustMatch.push(...regexes[0]);
822
}
823
if (regexes.length > 1) {
824
mustNotMatch.push(...regexes[1]);
825
}
826
827
mustMatch.forEach((regex) => {
828
if (typeof regex === "string") {
829
regex = new RegExp(regex);
830
}
831
assert(
832
regex.test(content),
833
`Required match ${String(regex)} is missing from file ${file}.`,
834
);
835
});
836
mustNotMatch.forEach((regex) => {
837
if (typeof regex === "string") {
838
regex = new RegExp(regex);
839
}
840
assert(
841
!regex.test(content),
842
`Illegal match ${String(regex)} was found in file ${file}.`,
843
);
844
});
845
}
846
}, "Inspecting Epub for Regex matches")(epubFile);
847
}
848
849
// export const ensureDocxRegexMatches = (
850
// file: string,
851
// regexes: (string | RegExp)[],
852
// ): Verify => {
853
// return {
854
// name: "Inspecting Docx for Regex matches",
855
// verify: async (_output: ExecuteOutput[]) => {
856
// const [_dir, stem] = dirAndStem(file);
857
// const temp = await Deno.makeTempDir();
858
// try {
859
// // Move the docx to a temp dir and unzip it
860
// const zipFile = join(temp, stem + ".zip");
861
// await Deno.rename(file, zipFile);
862
// await unzip(zipFile);
863
864
// // Open the core xml document and match the matches
865
// const docXml = join(temp, "word", "document.xml");
866
// const xml = await Deno.readTextFile(docXml);
867
// regexes.forEach((regex) => {
868
// if (typeof regex === "string") {
869
// regex = new RegExp(regex);
870
// }
871
// assert(
872
// regex.test(xml),
873
// `Required DocX Element ${String(regex)} is missing.`,
874
// );
875
// });
876
// } finally {
877
// await Deno.remove(temp, { recursive: true });
878
// }
879
// },
880
// };
881
// };
882
883
export const ensurePptxRegexMatches = (
884
file: string,
885
regexes: (string | RegExp)[],
886
slideNumber: number,
887
): Verify => {
888
return {
889
name: "Inspecting Pptx for Regex matches",
890
verify: async (_output: ExecuteOutput[]) => {
891
const [_dir, stem] = dirAndStem(file);
892
const temp = await Deno.makeTempDir();
893
try {
894
// Move the docx to a temp dir and unzip it
895
const zipFile = join(temp, stem + ".zip");
896
await Deno.rename(file, zipFile);
897
await unzip(zipFile);
898
899
// Open the core xml document and match the matches
900
const slidePath = join(temp, "ppt", "slides");
901
const slideFile = join(slidePath, `slide${slideNumber}.xml`);
902
assert(
903
existsSync(slideFile),
904
`Slide number ${slideNumber} is not in the Pptx`,
905
);
906
const xml = await Deno.readTextFile(slideFile);
907
regexes.forEach((regex) => {
908
if (typeof regex === "string") {
909
regex = new RegExp(regex);
910
}
911
assert(
912
regex.test(xml),
913
`Required Pptx Element ${String(regex)} is missing.`,
914
);
915
});
916
} finally {
917
await Deno.remove(temp, { recursive: true });
918
}
919
},
920
};
921
};
922
923
export function requireLatexPackage(pkg: string, opts?: string): RegExp {
924
if (opts) {
925
return RegExp(`\\\\usepackage\\[${opts}\\]{${pkg}}`, "g");
926
} else {
927
return RegExp(`\\\\usepackage{${pkg}}`, "g");
928
}
929
}
930
931
export const noSupportingFiles = (
932
input: string,
933
to: string,
934
projectOutDir?: string,
935
): Verify => {
936
return {
937
name: "Verify No Supporting Files Dir",
938
verify: (_output: ExecuteOutput[]) => {
939
const outputFile = outputForInput(input, to, projectOutDir);
940
verifyNoPath(outputFile.supportPath);
941
return Promise.resolve();
942
},
943
};
944
};
945
946
export const hasSupportingFiles = (
947
input: string,
948
to: string,
949
projectOutDir?: string,
950
): Verify => {
951
return {
952
name: "Has Supporting Files Dir",
953
verify: (_output: ExecuteOutput[]) => {
954
const outputFile = outputForInput(input, to, projectOutDir);
955
verifyPath(outputFile.supportPath);
956
return Promise.resolve();
957
},
958
};
959
};
960
961
export const verifyYamlFile = (
962
file: string,
963
func: (yaml: unknown) => boolean,
964
): Verify => {
965
return {
966
name: "Project Yaml is Valid",
967
verify: async (_output: ExecuteOutput[]) => {
968
if (existsSync(file)) {
969
const raw = await Deno.readTextFile(file);
970
if (raw) {
971
const yaml = readYamlFromString(raw);
972
const isValid = func(yaml);
973
assert(isValid, "Project Metadata isn't valid");
974
}
975
}
976
},
977
};
978
};
979
980
export function verifyPath(path: string) {
981
const pathExists = existsSync(path);
982
assert(pathExists, `Path ${path} doesn't exist`);
983
}
984
985
export function verifyNoPath(path: string) {
986
const pathExists = existsSync(path);
987
assert(!pathExists, `Unexpected path: ${path}`);
988
}
989
990
export const ensureHtmlSelectorSatisfies = (
991
file: string,
992
selector: string,
993
predicate: (list: NodeList) => boolean,
994
): Verify => {
995
return {
996
name: "Inspecting HTML for Selectors",
997
verify: async (_output: ExecuteOutput[]) => {
998
const htmlInput = await Deno.readTextFile(file);
999
const doc = new DOMParser().parseFromString(htmlInput, "text/html")!;
1000
// quirk: deno claims the result of this is "NodeListPublic", which is not an exported type in deno-dom.
1001
// so we cast.
1002
const nodeList = doc.querySelectorAll(selector) as NodeList;
1003
assert(
1004
predicate(nodeList),
1005
`Selector ${selector} didn't satisfy predicate`,
1006
);
1007
},
1008
};
1009
};
1010
1011
export const ensureXmlValidatesWithXsd = (
1012
file: string,
1013
xsdPath: string,
1014
): Verify => {
1015
return {
1016
name: "Validating XML",
1017
verify: async (_output: ExecuteOutput[]) => {
1018
if (!isWindows) {
1019
const args = ["--noout", "--valid", file, "--path", xsdPath];
1020
const runOptions: ExecProcessOptions = {
1021
cmd: "xmllint",
1022
args,
1023
stderr: "piped",
1024
stdout: "piped",
1025
};
1026
const result = await execProcess(runOptions);
1027
assert(
1028
result.success,
1029
`Failed XSD Validation for file ${file}\n${result.stderr}`,
1030
);
1031
}
1032
},
1033
};
1034
};
1035
1036
export const ensureMECAValidates = (
1037
mecaFile: string,
1038
): Verify => {
1039
return {
1040
name: "Validating MECA Archive",
1041
verify: async (_output: ExecuteOutput[]) => {
1042
if (!isWindows) {
1043
const hasNpm = await which("npm");
1044
if (hasNpm) {
1045
const hasMeca = await which("meca");
1046
if (hasMeca) {
1047
const result = await execProcess({
1048
cmd: "meca",
1049
args: ["validate", mecaFile],
1050
stderr: "piped",
1051
stdout: "piped",
1052
});
1053
assert(
1054
result.success,
1055
`Failed MECA Validation\n${result.stderr}`,
1056
);
1057
} else {
1058
console.log("meca not present, skipping MECA validation");
1059
}
1060
} else {
1061
console.log("npm not present, skipping MECA validation");
1062
}
1063
}
1064
},
1065
};
1066
};
1067
1068
1069
const asRegexp = (m: string | RegExp) => {
1070
if (typeof m === "string") {
1071
return new RegExp(m);
1072
} else {
1073
return m;
1074
}
1075
};
1076
1077