diff --git a/llm-docs/testing-patterns.md b/llm-docs/testing-patterns.md index aa961a4fced..3d0c65a084b 100644 --- a/llm-docs/testing-patterns.md +++ b/llm-docs/testing-patterns.md @@ -88,6 +88,62 @@ testQuartoCmd( - Use absolute paths with `join()` for file verification - Clean up output directories in teardown +### Pre/Post Render Script Tests + +For testing pre-render or post-render scripts that run during project rendering: + +```typescript +import { docs } from "../../utils.ts"; +import { join } from "../../../src/deno_ral/path.ts"; +import { existsSync } from "../../../src/deno_ral/fs.ts"; +import { testQuartoCmd } from "../../test.ts"; +import { noErrors, validJsonWithFields } from "../../verify.ts"; +import { safeRemoveIfExists } from "../../../src/core/path.ts"; + +const projectDir = docs("project/prepost/my-test"); +const projectDirAbs = join(Deno.cwd(), projectDir); +const dumpPath = join(projectDirAbs, "output.json"); +const outDir = join(projectDirAbs, "_site"); + +testQuartoCmd( + "render", + [projectDir], + [ + noErrors, + validJsonWithFields(dumpPath, { + expected: "value", + }), + ], + { + teardown: async () => { + safeRemoveIfExists(dumpPath); + if (existsSync(outDir)) { + await Deno.remove(outDir, { recursive: true }); + } + }, + }, +); +``` + +**Fixture structure:** + +``` +tests/docs/project/prepost/my-test/ +├── _quarto.yml # project config with pre-render/post-render scripts +├── index.qmd # minimal page (website needs at least one) +├── check-env.ts # pre/post-render script (Deno TypeScript) +└── .gitignore # exclude .quarto/ and *.quarto_ipynb +``` + +**Key points:** +- Pre/post-render scripts run as subprocesses with `cwd` set to the project directory +- Scripts access environment variables via `Deno.env.get()` and can write files for verification +- Use `validJsonWithFields` for JSON file verification (parses and compares field values exactly) +- Use `ensureFileRegexMatches` for non-JSON files or when regex matching is needed +- The file dump pattern (script writes JSON, test reads it) is useful for verifying env vars and other runtime state +- Clean up both the dump file and the output directory in teardown +- Existing fixtures: `tests/docs/project/prepost/` (mutate-render-list, invalid-mutate, extension, issue-10828, script-env-vars) + ### Extension Template Tests For testing `quarto use template`: @@ -164,6 +220,43 @@ folderExists(path: string) directoryEmptyButFor(dir: string, allowedFiles: string[]) ``` +### Content Verifiers + +```typescript +// Regex match on file contents (matches required, noMatches must be absent) +ensureFileRegexMatches(file: string, matches: (string | RegExp)[], noMatches?: (string | RegExp)[]) + +// Regex match on CSS files linked from HTML +ensureCssRegexMatches(file: string, matches: (string | RegExp)[], noMatches?: (string | RegExp)[]) + +// Check HTML elements exist or don't exist (CSS selectors) +ensureHtmlElements(file: string, noElements: string[], elements: string[]) + +// Verify JSON structure has expected fields (parses JSON, compares values with deep equality) +validJsonWithFields(file: string, fields: Record) + +// Check output message at specific log level +printsMessage(options: { level: string, regex: RegExp }) +``` + +### Assertion Helpers + +```typescript +// Assert path exists (throws if missing) +verifyPath(path: string) + +// Assert path does NOT exist (throws if present) +verifyNoPath(path: string) +``` + +### Cleanup Helpers + +```typescript +// Safe file removal (no error if missing) - from src/core/path.ts +import { safeRemoveIfExists } from "../../../src/core/path.ts"; +safeRemoveIfExists(path: string) +``` + ### Path Helpers ```typescript diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index ee4e744b22c..8c1e5fafb84 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -109,6 +109,7 @@ All changes included in 1.9: ## Projects +- ([#12444](https://github.com/quarto-dev/quarto-cli/pull/12444)): Improve pre/post render script logging with `Running script` prefix and add `QUARTO_PROJECT_SCRIPT_PROGRESS` and `QUARTO_PROJECT_SCRIPT_QUIET` environment variables so scripts can adapt their output. - ([#13892](https://github.com/quarto-dev/quarto-cli/issues/13892)): Fix `output-dir: ./` deleting entire project directory. `output-dir` must be a subdirectory of the project directory and check is now better to avoid deleting the project itself when it revolves to the same path. ### `website` diff --git a/src/command/render/project.ts b/src/command/render/project.ts index cf16d99b5da..9093275babf 100644 --- a/src/command/render/project.ts +++ b/src/command/render/project.ts @@ -78,6 +78,7 @@ import { Format } from "../../config/types.ts"; import { fileExecutionEngine } from "../../execute/engine.ts"; import { projectContextForDirectory } from "../../project/project-context.ts"; import { ProjectType } from "../../project/types/types.ts"; +import { RunHandlerOptions } from "../../core/run/types.ts"; const noMutationValidations = ( projType: ProjectType, @@ -937,52 +938,61 @@ async function runScripts( quiet: boolean, env?: { [key: string]: string }, ) { + // initialize the environment if needed + if (env) { + env = { + ...env, + }; + } else { + env = {}; + } + if (!env) throw new Error("should never get here"); + + // Pass some argument as environment + env["QUARTO_PROJECT_SCRIPT_PROGRESS"] = progress ? "1" : "0"; + env["QUARTO_PROJECT_SCRIPT_QUIET"] = quiet ? "1" : "0"; + for (let i = 0; i < scripts.length; i++) { const args = parseShellRunCommand(scripts[i]); const script = args[0]; if (progress && !quiet) { - info(colors.bold(colors.blue(`${script}`))); + info(colors.bold(colors.blue(`Running script '${script}'`))); } - const handler = handlerForScript(script); - if (handler) { - if (env) { - env = { - ...env, - }; - } else { - env = {}; - } - if (!env) throw new Error("should never get here"); - const input = Deno.env.get("QUARTO_USE_FILE_FOR_PROJECT_INPUT_FILES"); - const output = Deno.env.get("QUARTO_USE_FILE_FOR_PROJECT_OUTPUT_FILES"); - if (input) { - env["QUARTO_USE_FILE_FOR_PROJECT_INPUT_FILES"] = input; - } - if (output) { - env["QUARTO_USE_FILE_FOR_PROJECT_OUTPUT_FILES"] = output; - } + const handler = handlerForScript(script) ?? { + run: async ( + script: string, + args: string[], + _stdin?: string, + options?: RunHandlerOptions, + ) => { + return await execProcess({ + cmd: script, + args: args, + cwd: options?.cwd, + stdout: options?.stdout, + env: options?.env, + }); + }, + }; - const result = await handler.run(script, args.splice(1), undefined, { - cwd: projDir, - stdout: quiet ? "piped" : "inherit", - env, - }); - if (!result.success) { - throw new Error(); - } - } else { - const result = await execProcess({ - cmd: args[0], - args: args.slice(1), - cwd: projDir, - stdout: quiet ? "piped" : "inherit", - env, - }); - if (!result.success) { - throw new Error(); - } + const input = Deno.env.get("QUARTO_USE_FILE_FOR_PROJECT_INPUT_FILES"); + const output = Deno.env.get("QUARTO_USE_FILE_FOR_PROJECT_OUTPUT_FILES"); + if (input) { + env["QUARTO_USE_FILE_FOR_PROJECT_INPUT_FILES"] = input; + } + if (output) { + env["QUARTO_USE_FILE_FOR_PROJECT_OUTPUT_FILES"] = output; + } + + const result = await handler.run(script, args.slice(1), undefined, { + cwd: projDir, + stdout: quiet ? "piped" : "inherit", + env, + }); + if (!result.success) { + throw new Error(); } } if (scripts.length > 0) { diff --git a/tests/docs/project/prepost/extension/.gitignore b/tests/docs/project/prepost/extension/.gitignore new file mode 100644 index 00000000000..ad293093b07 --- /dev/null +++ b/tests/docs/project/prepost/extension/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +**/*.quarto_ipynb diff --git a/tests/docs/project/prepost/invalid-mutate/.gitignore b/tests/docs/project/prepost/invalid-mutate/.gitignore index 075b2542afb..0e3521a7d0f 100644 --- a/tests/docs/project/prepost/invalid-mutate/.gitignore +++ b/tests/docs/project/prepost/invalid-mutate/.gitignore @@ -1 +1,3 @@ /.quarto/ + +**/*.quarto_ipynb diff --git a/tests/docs/project/prepost/issue-10828/.gitignore b/tests/docs/project/prepost/issue-10828/.gitignore index 075b2542afb..0e3521a7d0f 100644 --- a/tests/docs/project/prepost/issue-10828/.gitignore +++ b/tests/docs/project/prepost/issue-10828/.gitignore @@ -1 +1,3 @@ /.quarto/ + +**/*.quarto_ipynb diff --git a/tests/docs/project/prepost/mutate-render-list/.gitignore b/tests/docs/project/prepost/mutate-render-list/.gitignore index 075b2542afb..0e3521a7d0f 100644 --- a/tests/docs/project/prepost/mutate-render-list/.gitignore +++ b/tests/docs/project/prepost/mutate-render-list/.gitignore @@ -1 +1,3 @@ /.quarto/ + +**/*.quarto_ipynb diff --git a/tests/docs/project/prepost/script-env-vars/.gitignore b/tests/docs/project/prepost/script-env-vars/.gitignore new file mode 100644 index 00000000000..ad293093b07 --- /dev/null +++ b/tests/docs/project/prepost/script-env-vars/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +**/*.quarto_ipynb diff --git a/tests/docs/project/prepost/script-env-vars/_quarto.yml b/tests/docs/project/prepost/script-env-vars/_quarto.yml new file mode 100644 index 00000000000..99d98604174 --- /dev/null +++ b/tests/docs/project/prepost/script-env-vars/_quarto.yml @@ -0,0 +1,10 @@ +project: + type: website + pre-render: check-env.ts + +website: + title: "script-env-vars" + +format: + html: + theme: cosmo diff --git a/tests/docs/project/prepost/script-env-vars/about.qmd b/tests/docs/project/prepost/script-env-vars/about.qmd new file mode 100644 index 00000000000..29d83a0e116 --- /dev/null +++ b/tests/docs/project/prepost/script-env-vars/about.qmd @@ -0,0 +1,5 @@ +--- +title: About +--- + +About page diff --git a/tests/docs/project/prepost/script-env-vars/check-env.ts b/tests/docs/project/prepost/script-env-vars/check-env.ts new file mode 100644 index 00000000000..2753be45e0d --- /dev/null +++ b/tests/docs/project/prepost/script-env-vars/check-env.ts @@ -0,0 +1,6 @@ +const env = { + progress: Deno.env.get("QUARTO_PROJECT_SCRIPT_PROGRESS") ?? null, + quiet: Deno.env.get("QUARTO_PROJECT_SCRIPT_QUIET") ?? null, +}; + +Deno.writeTextFileSync("env-dump.json", JSON.stringify(env)); diff --git a/tests/docs/project/prepost/script-env-vars/index.qmd b/tests/docs/project/prepost/script-env-vars/index.qmd new file mode 100644 index 00000000000..2af2fce5ba3 --- /dev/null +++ b/tests/docs/project/prepost/script-env-vars/index.qmd @@ -0,0 +1,5 @@ +--- +title: Test +--- + +Hello diff --git a/tests/smoke/project/project-prepost.test.ts b/tests/smoke/project/project-prepost.test.ts index 3caf6d72419..b7895f4a5f1 100644 --- a/tests/smoke/project/project-prepost.test.ts +++ b/tests/smoke/project/project-prepost.test.ts @@ -9,7 +9,7 @@ import { docs } from "../../utils.ts"; import { join } from "../../../src/deno_ral/path.ts"; import { existsSync } from "../../../src/deno_ral/fs.ts"; import { testQuartoCmd } from "../../test.ts"; -import { fileExists, noErrors, printsMessage, verifyNoPath, verifyPath } from "../../verify.ts"; +import { fileExists, noErrors, printsMessage, validJsonWithFields, verifyNoPath, verifyPath } from "../../verify.ts"; import { normalizePath, safeRemoveIfExists } from "../../../src/core/path.ts"; const renderDir = docs("project/prepost/mutate-render-list"); @@ -75,6 +75,10 @@ testQuartoCmd( const path = join(docs("project/prepost/extension"), "i-was-created.txt"); verifyPath(path); safeRemoveIfExists(path); + const siteDir = join(docs("project/prepost/extension"), "_site"); + if (existsSync(siteDir)) { + await Deno.remove(siteDir, { recursive: true }); + } } }); @@ -94,6 +98,37 @@ testQuartoCmd( safeRemoveIfExists(inputPath); verifyPath(outputPath); safeRemoveIfExists(outputPath); + const siteDir = join(docs("project/prepost/issue-10828"), "_site"); + if (existsSync(siteDir)) { + await Deno.remove(siteDir, { recursive: true }); + } } } - ) \ No newline at end of file + ) + +// Verify that pre-render scripts receive QUARTO_PROJECT_SCRIPT_PROGRESS +// and QUARTO_PROJECT_SCRIPT_QUIET environment variables +const scriptEnvDir = docs("project/prepost/script-env-vars"); +const scriptEnvDirAbs = join(Deno.cwd(), scriptEnvDir); +const envDumpPath = join(scriptEnvDirAbs, "env-dump.json"); +const scriptEnvOutDir = join(scriptEnvDirAbs, "_site"); + +testQuartoCmd( + "render", + [scriptEnvDir], + [ + noErrors, + validJsonWithFields(envDumpPath, { + progress: "1", + quiet: "0", + }), + ], + { + teardown: async () => { + safeRemoveIfExists(envDumpPath); + if (existsSync(scriptEnvOutDir)) { + await Deno.remove(scriptEnvOutDir, { recursive: true }); + } + }, + }, +);