import { expandGlobSync } from "../../src/core/deno/expand-glob.ts";
import { testQuartoCmd, Verify } from "../test.ts";
import { initYamlIntelligenceResourcesFromFilesystem } from "../../src/core/schema/utils.ts";
import {
initState,
setInitializer,
} from "../../src/core/lib/yaml-validation/state.ts";
import { os } from "../../src/deno_ral/platform.ts";
import { asArray } from "../../src/core/array.ts";
import { breakQuartoMd } from "../../src/core/lib/break-quarto-md.ts";
import { parse } from "../../src/core/yaml.ts";
import { cleanoutput } from "./render/render.ts";
import {
ensureEpubFileRegexMatches,
ensureDocxRegexMatches,
ensureDocxXpath,
ensureFileRegexMatches,
ensureHtmlElements,
ensureIpynbCellMatches,
ensurePdfRegexMatches,
ensurePdfTextPositions,
ensurePdfMetadata,
ensureJatsXpath,
ensureOdtXpath,
ensurePptxRegexMatches,
ensureTypstFileRegexMatches,
ensureSnapshotMatches,
fileExists,
noErrors,
noErrorsOrWarnings,
ensurePptxXpath,
ensurePptxLayout,
ensurePptxMaxSlides,
ensureLatexFileRegexMatches,
printsMessage,
shouldError,
ensureHtmlElementContents,
ensureHtmlElementCount,
} from "../verify.ts";
import { readYamlFromMarkdown } from "../../src/core/yaml.ts";
import { findProjectDir, findProjectOutputDir, outputForInput } from "../utils.ts";
import { jupyterNotebookToMarkdown } from "../../src/command/convert/jupyter.ts";
import { basename, dirname, join, relative } from "../../src/deno_ral/path.ts";
import { WalkEntry } from "../../src/deno_ral/fs.ts";
import { quarto } from "../../src/quarto.ts";
import { safeExistsSync, safeRemoveSync } from "../../src/core/path.ts";
import { runningInCI } from "../../src/core/ci-info.ts";
async function fullInit() {
await initYamlIntelligenceResourcesFromFilesystem();
}
async function guessFormat(fileName: string): Promise<string[]> {
const { cells } = await breakQuartoMd(Deno.readTextFileSync(fileName));
const formats: Set<string> = new Set();
for (const cell of cells) {
if (cell.cell_type === "raw") {
const src = cell.source.value.replaceAll(/^---$/mg, "");
let yaml;
try {
yaml = parse(src);
} catch (e) {
if (!(e instanceof Error)) throw e;
if (e.message.includes("unknown tag")) {
continue;
}
}
if (yaml && typeof yaml === "object") {
const format = (yaml as Record<string, any>).format;
if (typeof format === "object") {
for (
const [k, _] of Object.entries(
(yaml as Record<string, any>).format || {},
)
) {
formats.add(k);
}
} else if (typeof format === "string") {
formats.add(format);
}
}
}
}
return Array.from(formats);
}
function skipTest(metadata: Record<string, any>): string | undefined {
const quartoMeta = metadata["_quarto"] as any;
const runConfig = quartoMeta?.tests?.run;
if (!runConfig) {
return undefined;
}
if (runConfig.skip) {
return typeof runConfig.skip === "string" ? runConfig.skip : "tests.run.skip is true";
}
if (runningInCI() && runConfig.ci === false) {
return "tests.run.ci is false";
}
const notOs = runConfig.not_os;
if (notOs !== undefined && asArray(notOs).includes(os)) {
return `tests.run.not_os includes ${os}`;
}
const onlyOs = runConfig.os;
if (onlyOs !== undefined && !asArray(onlyOs).includes(os)) {
return `tests.run.os does not include ${os}`;
}
return undefined;
}
function hasTestSpecs(metadata: any, input: string): boolean {
const tests = metadata?.["_quarto"]?.["tests"];
if (!tests && metadata?.["_quarto"]?.["test"] != undefined) {
throw new Error(`Test is ${input} is using 'test' in metadata instead of 'tests'. This is probably a typo.`);
}
if (tests && typeof tests === "object") {
const formatKeys = Object.keys(tests).filter(key => key !== "run");
return formatKeys.length > 0;
}
return false;
}
interface QuartoInlineTestSpec {
format: string;
verifyFns: Verify[];
}
const postRenderCleanupFiles: string[] = [];
function registerPostRenderCleanupFile(file: string): void {
postRenderCleanupFiles.push(file);
}
const postRenderCleanup = () => {
if (Deno.env.get("QUARTO_TEST_KEEP_OUTPUTS")) {
return;
}
for (const file of postRenderCleanupFiles) {
console.log(`Cleaning up ${file} in ${Deno.cwd()}`);
if (safeExistsSync(file)) {
Deno.removeSync(file);
}
}
}
function resolveTestSpecs(
input: string,
metadata: Record<string, any>,
): QuartoInlineTestSpec[] {
const specs = metadata["_quarto"]["tests"];
const result = [];
const verifyMap: Record<string, any> = {
ensureEpubFileRegexMatches,
ensureHtmlElements,
ensureHtmlElementContents,
ensureHtmlElementCount,
ensureFileRegexMatches,
ensureIpynbCellMatches,
ensureLatexFileRegexMatches,
ensureTypstFileRegexMatches,
ensureDocxRegexMatches,
ensureDocxXpath,
ensureOdtXpath,
ensureJatsXpath,
ensurePdfRegexMatches,
ensurePdfTextPositions,
ensurePdfMetadata,
ensurePptxRegexMatches,
ensurePptxXpath,
ensurePptxLayout,
ensurePptxMaxSlides,
ensureSnapshotMatches,
printsMessage
};
for (const [format, testObj] of Object.entries(specs)) {
if (format === "run") {
continue;
}
let checkWarnings = true;
const verifyFns: Verify[] = [];
if (testObj && typeof testObj === "object") {
for (
const [key, value] of Object.entries(testObj as Record<string, any>)
) {
if (key == "postRenderCleanup") {
for (let file of value) {
if (file.includes("${input_stem}")) {
const extension = input.endsWith('.qmd') ? '.qmd' : '.ipynb';
const inputStem = basename(input, extension);
file = file.replace("${input_stem}", inputStem);
}
registerPostRenderCleanupFile(join(dirname(input), file));
}
} else if (key == "shouldError") {
checkWarnings = false;
verifyFns.push(shouldError);
} else if (key === "noErrors") {
checkWarnings = false;
verifyFns.push(noErrors);
} else if (key === "noErrorsOrWarnings") {
checkWarnings = false;
verifyFns.push(noErrorsOrWarnings);
} else {
const projectPath = findRootTestsProjectDir(input)
const projectOutDir = findProjectOutputDir(projectPath);
const outputFile = outputForInput(input, format, projectOutDir, projectPath, metadata);
if (key === "fileExists") {
for (
const [path, file] of Object.entries(
value as Record<string, string>,
)
) {
if (path === "outputPath") {
verifyFns.push(
fileExists(join(dirname(outputFile.outputPath), file)),
);
} else if (path === "supportPath") {
verifyFns.push(
fileExists(join(outputFile.supportPath, file)),
);
}
}
} else if (["ensurePptxLayout", "ensurePptxXpath"].includes(key)) {
if (Array.isArray(value) && Array.isArray(value[0])) {
value.forEach((slide: any) => {
verifyFns.push(verifyMap[key](outputFile.outputPath, ...slide));
});
} else {
verifyFns.push(verifyMap[key](outputFile.outputPath, ...value));
}
} else if (key === "printsMessage") {
verifyFns.push(verifyMap[key](value));
} else if (key === "ensureEpubFileRegexMatches") {
verifyFns.push(verifyMap[key](outputFile.outputPath, value));
} else if (verifyMap[key]) {
if (key === "ensureTypstFileRegexMatches") {
if (!metadata.format?.typst?.['keep-typ'] && !metadata['keep-typ'] && metadata.format?.typst?.['output-ext'] !== 'typ' && metadata['output-ext'] !== 'typ') {
throw new Error(`Using ensureTypstFileRegexMatches requires setting 'keep-typ: true' in file ${input}`);
}
} else if (key === "ensureLatexFileRegexMatches") {
if (!metadata.format?.pdf?.['keep-tex'] && !metadata['keep-tex']) {
throw new Error(`Using ensureLatexFileRegexMatches requires setting 'keep-tex: true' in file ${input}`);
}
}
const usesKeepTyp = key === "ensureTypstFileRegexMatches" &&
(metadata.format?.typst?.['keep-typ'] || metadata['keep-typ']) &&
!(metadata.format?.typst?.['output-ext'] === 'typ' || metadata['output-ext'] === 'typ');
const usesKeepTex = key === "ensureLatexFileRegexMatches" &&
(metadata.format?.pdf?.['keep-tex'] || metadata['keep-tex']);
const needsInputPath = usesKeepTyp || usesKeepTex;
let targetPath = outputFile.outputPath;
if (key === "ensureTypstFileRegexMatches" && outputFile.intermediateTypstPath) {
targetPath = outputFile.intermediateTypstPath;
}
if (typeof value === "object" && Array.isArray(value)) {
const matches = value[0];
const noMatches = value[1];
const inputFile = needsInputPath ? input : undefined;
verifyFns.push(verifyMap[key](targetPath, matches, noMatches, inputFile));
} else {
verifyFns.push(verifyMap[key](targetPath, value, undefined, needsInputPath ? input : undefined));
}
} else {
throw new Error(`Unknown verify function used: ${key} in file ${input} for format ${format}`) ;
}
}
}
}
if (checkWarnings) {
verifyFns.push(noErrorsOrWarnings);
}
result.push({
format,
verifyFns,
});
}
return result;
}
await initYamlIntelligenceResourcesFromFilesystem();
const files: WalkEntry[] = [];
if (Deno.args.length === 0) {
files.push(...[...expandGlobSync("docs/smoke-all/**/*.{md,qmd,ipynb}")].filter((entry) => /^[^_]/.test(basename(entry.path))));
} else {
for (const arg of Deno.args) {
files.push(...expandGlobSync(arg));
}
}
const renderedProjects: Set<string> = new Set();
const testedProjects: Set<string> = new Set();
let testFilesPromises = [];
for (const { path: fileName } of files) {
const input = relative(Deno.cwd(), fileName);
const metadata = input.endsWith("md")
? readYamlFromMarkdown(Deno.readTextFileSync(input))
: readYamlFromMarkdown(await jupyterNotebookToMarkdown(input, false));
const skipReason = skipTest(metadata);
if (skipReason !== undefined) {
console.log(`Skipping tests for ${input}: ${skipReason}`);
continue;
}
const testSpecs: QuartoInlineTestSpec[] = [];
if (hasTestSpecs(metadata, input)) {
testSpecs.push(...resolveTestSpecs(input, metadata));
} else {
const formats = await guessFormat(input);
if (formats.length == 0) {
formats.push("html");
}
for (const format of formats) {
testSpecs.push({ format: format, verifyFns: [noErrorsOrWarnings] });
}
}
const projectPath = findRootTestsProjectDir(input);
if (projectPath) testedProjects.add(projectPath);
if (
(metadata["_quarto"] as any)?.["render-project"] &&
projectPath &&
!renderedProjects.has(projectPath)
) {
await quarto(["render", projectPath]);
renderedProjects.add(projectPath);
}
testFilesPromises.push(new Promise<void>(async (resolve, reject) => {
try {
let testSpecPromises = [];
for (const testSpec of testSpecs) {
const {
format,
verifyFns,
} = testSpec as any;
testSpecPromises.push(new Promise<void>((testSpecResolve, testSpecReject) => {
try {
if (format === "editor-support-crossref") {
const tempFile = Deno.makeTempFileSync();
testQuartoCmd("editor-support", ["crossref", "--input", input, "--output", tempFile], verifyFns, {
teardown: () => {
Deno.removeSync(tempFile);
testSpecResolve();
return Promise.resolve();
}
}, `quarto editor-support crossref < ${input}`);
} else {
testQuartoCmd("render", [input, "--to", format], verifyFns, {
prereq: async () => {
setInitializer(fullInit);
await initState();
return Promise.resolve(true);
},
teardown: () => {
cleanoutput(input, format, undefined, undefined, metadata);
postRenderCleanup()
testSpecResolve();
return Promise.resolve();
},
});
}
} catch (error) {
testSpecReject(error);
}
}));
}
await Promise.all(testSpecPromises);
resolve();
} catch (error) {
reject(error);
}
}));
}
Promise.all(testFilesPromises).then(() => {
if (Deno.env.get("QUARTO_TEST_KEEP_OUTPUTS")) {
return;
}
for (const project of testedProjects) {
const projectOutDir = join(project, findProjectOutputDir(project));
if (projectOutDir !== project && safeExistsSync(projectOutDir)) {
safeRemoveSync(projectOutDir, { recursive: true });
}
const hiddenQuarto = join(project, ".quarto");
if (safeExistsSync(hiddenQuarto)) {
safeRemoveSync(hiddenQuarto, { recursive: true });
}
}
}).catch((_error) => {});
function findRootTestsProjectDir(input: string) {
const smokeAllRootDir = 'smoke-all$'
const ffMatrixRootDir = 'feature-format-matrix[/]qmd-files$'
const RootTestsRegex = new RegExp(`${smokeAllRootDir}|${ffMatrixRootDir}`);
return findProjectDir(input, RootTestsRegex);
}