Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions llm-docs/testing-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down Expand Up @@ -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<string, unknown>)

// 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
Expand Down
1 change: 1 addition & 0 deletions news/changelog-1.9.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
86 changes: 48 additions & 38 deletions src/command/render/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions tests/docs/project/prepost/extension/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/.quarto/
**/*.quarto_ipynb
2 changes: 2 additions & 0 deletions tests/docs/project/prepost/invalid-mutate/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
/.quarto/

**/*.quarto_ipynb
2 changes: 2 additions & 0 deletions tests/docs/project/prepost/issue-10828/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
/.quarto/

**/*.quarto_ipynb
2 changes: 2 additions & 0 deletions tests/docs/project/prepost/mutate-render-list/.gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
/.quarto/

**/*.quarto_ipynb
2 changes: 2 additions & 0 deletions tests/docs/project/prepost/script-env-vars/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/.quarto/
**/*.quarto_ipynb
10 changes: 10 additions & 0 deletions tests/docs/project/prepost/script-env-vars/_quarto.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
project:
type: website
pre-render: check-env.ts

website:
title: "script-env-vars"

format:
html:
theme: cosmo
5 changes: 5 additions & 0 deletions tests/docs/project/prepost/script-env-vars/about.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: About
---

About page
6 changes: 6 additions & 0 deletions tests/docs/project/prepost/script-env-vars/check-env.ts
Original file line number Diff line number Diff line change
@@ -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));
5 changes: 5 additions & 0 deletions tests/docs/project/prepost/script-env-vars/index.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
title: Test
---

Hello
39 changes: 37 additions & 2 deletions tests/smoke/project/project-prepost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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 });
}
}
});

Expand All @@ -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 });
}
}
}
)
)

// 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 });
}
},
},
);
Loading