diff --git a/.claude/rules/testing/test-anti-patterns.md b/.claude/rules/testing/test-anti-patterns.md index ab18275a715..e7523f9028e 100644 --- a/.claude/rules/testing/test-anti-patterns.md +++ b/.claude/rules/testing/test-anti-patterns.md @@ -17,3 +17,9 @@ paths: Never create `Project.toml`, `.venv/`, or `renv.lock` in test fixture directories. **Details:** `llm-docs/testing-patterns.md` → "Shared Test Environments" + +## Don't: `Deno.chdir()` inside the test body + +`Deno.chdir()` mutates process-global cwd, so a test that changes it can leak into other tests in the same process. The harness already changes and restores the working directory: return the directory from `TestContext.cwd`, create fixtures in `setup`, clean up in `teardown` (examples: `tests/unit/dotenv-config.test.ts`, `tests/smoke/use/template.test.ts`). For a temp directory you don't need to run *from*, use `withTempDir` (`tests/utils.ts`). A test that only needs a *relative* input can pass a path relative to the current cwd without changing it. + +**Details:** `llm-docs/testing-patterns.md` → "Working-Directory-Sensitive Tests" diff --git a/.claude/rules/testing/unit-test-with-r.md b/.claude/rules/testing/unit-test-with-r.md new file mode 100644 index 00000000000..c826b0e544c --- /dev/null +++ b/.claude/rules/testing/unit-test-with-r.md @@ -0,0 +1,15 @@ +--- +paths: + - "tests/unit/preview-subdir-knitr-cwd.test.ts" +--- + +# R unit test that changes working directory + +This test runs a knitr subprocess with cwd set to a scratch directory +(required to reproduce the bug). R's `.Rprofile` lookup is cwd-exact, so it +won't pick up `tests/renv` activation there — on CI the R subprocess fails +with `there is no package called 'rmarkdown'`. `setup()` writes a +`.Rprofile` into the fixture dir to re-activate renv against the real +`tests/` project. + +**Details:** `llm-docs/testing-patterns.md` → "R Tests That Change Working Directory" diff --git a/llm-docs/testing-patterns.md b/llm-docs/testing-patterns.md index fd36ab1ff6e..5dc9e9d2aae 100644 --- a/llm-docs/testing-patterns.md +++ b/llm-docs/testing-patterns.md @@ -169,6 +169,40 @@ testQuartoCmd( - Clean up entire temp directory including source files - File verification uses relative paths when checking files in `cwd()` +### Working-Directory-Sensitive Tests + +Some tests need to run from a specific directory (e.g. reproducing a bug that +depends on the process cwd). **Do not `Deno.chdir()` inside the test body** — +it mutates process-global cwd and can leak into other tests in the same +process. Use the `TestContext` options instead; the harness changes the cwd +before the test and restores it afterward: + +```typescript +const workingDir = Deno.makeTempDirSync(); + +unitTest("runs from workingDir", async () => { + // cwd is workingDir here (set by the harness) +}, { + setup: () => { Deno.writeTextFileSync(".env.example", "..."); return Promise.resolve(); }, + cwd: () => workingDir, + teardown: () => { try { Deno.removeSync(workingDir, { recursive: true }); } catch { /* best-effort */ } return Promise.resolve(); }, +}); +``` + +**Key points:** + +- The harness calls `cwd()` **before** `setup()`, so the directory must already + exist when `cwd()` runs — create it at module scope, not in `setup`. +- `teardown` runs **before** the harness restores the cwd, so on Windows the + temp dir may still be the cwd and resist removal. Wrap the removal in + try/catch (best-effort) — see `tests/smoke/use/template.test.ts` and + `tests/unit/dotenv-config.test.ts`. +- For a temp directory you don't need to run *from*, prefer `withTempDir` + (`tests/utils.ts`), which creates and recursively removes it in a `finally`. +- A test that only needs a **relative** input (not a specific cwd) can pass a + path relative to the current cwd (`relative(Deno.cwd(), absFile)`) without + changing directories at all. + ## Verification Helpers ### Core Verifiers @@ -363,6 +397,36 @@ Rscript -e "renv::install(); renv::snapshot()" **Note:** While Quarto supports local Project.toml files in document directories for production use, the quarto-cli test infrastructure specifically does NOT support this pattern. All test dependencies must be in the main `tests/` environment. +### R Tests That Change Working Directory + +R resolves `.Rprofile` from the **exact** process cwd (no parent-directory +search). On CI, rmarkdown/knitr live only in `tests/renv`'s project library, +activated when cwd is `tests/` (via `tests/.Rprofile` sourcing +`renv/activate.R`). Most knitr tests never leave `tests/` — they pass paths +relative to the current cwd instead of changing directories — so activation +happens automatically. + +A test that must run with cwd set elsewhere (a scratch temp dir, via +`TestContext.cwd()` — see "Working-Directory-Sensitive Tests" above) loses +that activation: the R subprocess starts outside `tests/`, renv never +activates, and package loads fail with `there is no package called +'rmarkdown'`. This is CI-only — a developer machine with rmarkdown on the +default `.libPaths()` masks it entirely. The render pipeline also tends to +swallow the underlying subprocess error, so the failure can be silent beyond +the bare package-load message. + +**Fix:** in the fixture's cwd, write a `.Rprofile` that re-points renv at the +real project, regardless of where the test's cwd actually is: + +```r +Sys.setenv(RENV_PROJECT = "") +source("/renv/activate.R") +``` + +`renv/activate.R` reads `RENV_PROJECT` to determine the project root if set, +falling back to `getwd()` otherwise — setting it explicitly decouples renv +activation from the test's cwd. + ## Best Practices 1. **Always clean up**: Use teardown to remove generated files diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index c645725178f..3d2eda2e5ab 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -6,6 +6,7 @@ All changes included in 1.10: - ([#14281](https://github.com/quarto-dev/quarto-cli/issues/14281)): Fix transient `.quarto_ipynb` files accumulating during `quarto preview` with Jupyter engine. - ([#14298](https://github.com/quarto-dev/quarto-cli/issues/14298)): Fix `quarto preview` browse URL including output filename (e.g., `hello.html`) for single-file documents, breaking Posit Workbench proxied server access. - ([#14489](https://github.com/quarto-dev/quarto-cli/issues/14489)): Restore `--output-dir` support for `quarto preview` of single files when no `_quarto.yml` is present (e.g. R-package workspaces). Regression introduced in v1.9.18. +- ([#14683](https://github.com/quarto-dev/quarto-cli/issues/14683)): Fix `quarto preview` of a knitr document in a project subdirectory failing when run from that subdirectory with a bare filename (the shape RStudio's Render button uses). Regression introduced in v1.9.18. - ([rstudio/rstudio#17333](https://github.com/rstudio/rstudio/issues/17333)): Fix `quarto inspect` on standalone files emitting project metadata that breaks RStudio's publishing wizard. ## Dependencies @@ -108,6 +109,7 @@ All changes included in 1.10: - ([#6092](https://github.com/quarto-dev/quarto-cli/issues/6092)): Fix the `default-image-extension` being appended to an extensionless URL ending in a slash (e.g. `![](https://example.com/)`), which broke the embed/iframe image syntax. - ([#6651](https://github.com/quarto-dev/quarto-cli/issues/6651)): Fix dart-sass compilation failing in enterprise environments where `.bat` files are blocked by group policy. - ([#11703](https://github.com/quarto-dev/quarto-cli/issues/11703)): Fix SCSS color-variable export (`--quarto-scss-export-*`) being silently skipped when a theme rule contains consecutive semicolons (e.g. `max-height: 100% !important;;`). +- ([#12401](https://github.com/quarto-dev/quarto-cli/issues/12401)): Fix `QUARTO_DOCUMENT_PATH` being a relative path for single-file renders, inconsistent with project renders where it was absolute. Execution target paths are now normalized to absolute at a single point, so single-file and project renders behave the same. - ([#14255](https://github.com/quarto-dev/quarto-cli/issues/14255)): Fix shortcodes inside inline and display math expressions not being resolved. - ([#14342](https://github.com/quarto-dev/quarto-cli/issues/14342)): Work around TOCTOU race in Deno's `expandGlobSync` that can cause unexpected exceptions to be raised while traversing directories during project initialization. - ([#14445](https://github.com/quarto-dev/quarto-cli/issues/14445)): Fix intermittent `Uncaught (in promise) TypeError: Writable stream is closed or errored.` aborting renders on Linux. `execProcess` now awaits and swallows the rejection from `process.stdin.close()` when the child closes its stdin first. The captured stderr is now also surfaced when `typst-gather analyze` falls back to staging all packages, so failures are diagnosable without bypassing `quarto`. diff --git a/src/execute/engine.ts b/src/execute/engine.ts index 0afab0a88f2..18a554921f7 100644 --- a/src/execute/engine.ts +++ b/src/execute/engine.ts @@ -12,7 +12,7 @@ import { partitionYamlFrontMatter, readYamlFromMarkdown, } from "../core/yaml.ts"; -import { dirAndStem } from "../core/path.ts"; +import { dirAndStem, normalizePath } from "../core/path.ts"; import { metadataAsFormat } from "../config/metadata.ts"; import { kBaseFormat, kEngine } from "../config/constants.ts"; @@ -355,6 +355,19 @@ export async function fileExecutionEngineAndTarget( flags: RenderFlags | undefined, project: ProjectContext, ): Promise<{ engine: ExecutionEngineInstance; target: ExecutionTarget }> { + // Establish an "always absolute" contract for the file path at this single + // convergence point for all callers (preview format-resolution, render, and + // project render). fileInformationCache keys are normalized to absolute + // (FileInformationCacheMap), so a caller passing a cwd-relative path — e.g. + // preview seeding the cache with the bare filename RStudio's Render button + // uses from a subdirectory — would otherwise store a target whose + // source/input stay relative under an absolute key, a stale value that a + // later absolute-path lookup reuses. Normalizing here keeps target.source and + // target.input consistent with the key and with project renders (which + // already pass absolute paths). See #14151 (the contract), #12401 + // (QUARTO_DOCUMENT_PATH / cwd inconsistency), #14150 (doubled subdirectory), + // and #14683 (knitr abs_path failure previewing from a subdirectory). + file = normalizePath(file); const cached = ensureFileInformationCache(project, file); if (cached && cached.engine && cached.target) { return { engine: cached.engine, target: cached.target }; diff --git a/tests/docs/manual/preview/README.md b/tests/docs/manual/preview/README.md index 36b0c7d8566..aa17e01dd25 100644 --- a/tests/docs/manual/preview/README.md +++ b/tests/docs/manual/preview/README.md @@ -161,6 +161,13 @@ After every change to preview URL or handler logic, verify that single-file prev - **Expected:** HTTP 200. Browse URL may include path for non-index files in project context. - **Catches:** `isSingleFile` guard accidentally excluding real project files from path computation +#### T33: knitr doc in a subdirectory — preview from that subdirectory (#14683) + +- **Setup:** Fixture `knitr-subdir-14683/` — a website project with a knitr doc at `labs/doc.qmd` (an `{r}` chunk plus a `QUARTO_DOCUMENT_PATH` echo). Requires R + `rmarkdown`/`knitr`. +- **Steps:** From `knitr-subdir-14683/labs/` (the doc's own directory), run `quarto preview doc.qmd --to html --no-watch-inputs --no-browser --port XXXX` — the shape RStudio's Render button uses (bare filename, cwd = the doc's subdirectory). +- **Expected:** Renders without `Error in rmarkdown:::abs_path(input)`; `_site/labs/doc.html` is produced showing `[1] 2` (the R chunk executed). The echoed `QUARTO_DOCUMENT_PATH` is the doc's absolute subdirectory (ends in `labs`), not `.` / the project root (#12401). +- **Catches:** `fileExecutionEngineAndTarget` not normalizing the input to absolute — a cwd-relative `target.input`/`source` in the shared preview cache makes the knitr R subprocess (cwd = project dir) fail `abs_path` on the subdirectory-relative filename. + ## Test Matrix: Format Change Detection (#14533) After every change to `previewRenderRequestIsCompatible` or the diff --git a/tests/docs/manual/preview/knitr-subdir-14683/.gitignore b/tests/docs/manual/preview/knitr-subdir-14683/.gitignore new file mode 100644 index 00000000000..00a02dceb55 --- /dev/null +++ b/tests/docs/manual/preview/knitr-subdir-14683/.gitignore @@ -0,0 +1,4 @@ +/.quarto/ +/_site/ +**/*_files/ +**/*.quarto_ipynb diff --git a/tests/docs/manual/preview/knitr-subdir-14683/_quarto.yml b/tests/docs/manual/preview/knitr-subdir-14683/_quarto.yml new file mode 100644 index 00000000000..3b71b759805 --- /dev/null +++ b/tests/docs/manual/preview/knitr-subdir-14683/_quarto.yml @@ -0,0 +1,5 @@ +project: + type: website + +website: + title: "Knitr Subdir Preview (#14683)" diff --git a/tests/docs/manual/preview/knitr-subdir-14683/index.qmd b/tests/docs/manual/preview/knitr-subdir-14683/index.qmd new file mode 100644 index 00000000000..81cd544d576 --- /dev/null +++ b/tests/docs/manual/preview/knitr-subdir-14683/index.qmd @@ -0,0 +1,6 @@ +--- +title: Home +--- + +Home page. The regression fixture is `labs/doc.qmd` — preview it from the +`labs/` directory with a bare filename (see T33). diff --git a/tests/docs/manual/preview/knitr-subdir-14683/labs/doc.qmd b/tests/docs/manual/preview/knitr-subdir-14683/labs/doc.qmd new file mode 100644 index 00000000000..c9149a48abb --- /dev/null +++ b/tests/docs/manual/preview/knitr-subdir-14683/labs/doc.qmd @@ -0,0 +1,20 @@ +--- +title: Claude Test +engine: knitr +--- + +A knitr document in a project subdirectory. Previewing it from this `labs/` +directory with a bare filename (`quarto preview doc.qmd`) is the shape +RStudio's Render button uses, and is what regressed in #14683. + +```{r} +1 + 1 +``` + +The rendered output must show `[1] 2` above (the R engine executed), and +`QUARTO_DOCUMENT_PATH` below must be this file's absolute directory (ending in +`labs`), not `.` or the project root (#12401). + +```{r} +cat("QUARTO_DOCUMENT_PATH:", Sys.getenv("QUARTO_DOCUMENT_PATH")) +``` diff --git a/tests/unit/preview-subdir-knitr-cwd.test.ts b/tests/unit/preview-subdir-knitr-cwd.test.ts new file mode 100644 index 00000000000..cc249651c15 --- /dev/null +++ b/tests/unit/preview-subdir-knitr-cwd.test.ts @@ -0,0 +1,289 @@ +/* + * preview-subdir-knitr-cwd.test.ts + * + * Regression test for quarto-dev/quarto-cli#14683. + * + * When previewing a knitr-engine document that lives in a project + * subdirectory, with the working directory set to that subdirectory and the + * input passed as a bare filename (the shape RStudio's Render button uses), + * the render failed with: + * + * Error in rmarkdown:::abs_path(input) : + * The file 'claudetest.qmd' does not exist. + * + * Root cause: preview resolves the preview format first (renderFormats / + * previewFormat) using the cwd-relative filename, which seeds the shared + * ProjectContext's fileInformationCache with an ExecutionTarget whose `input` + * is that relative string. The cache is keyed by normalized (absolute) path, so + * the later renderProject() call — which passes the absolute path — hits the + * stale cache entry and inherits the relative `input`. The knitr subprocess + * runs with cwd = project dir, where the subdirectory-relative filename does + * not resolve, so abs_path() throws. + * + * fileExecutionEngineAndTarget now normalizes the path to absolute at that + * single convergence point, so the cached target is absolute regardless of how + * the caller spelled the path. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../test.ts"; +import { assert } from "testing/asserts"; +import { + dirname, + fromFileUrl, + isAbsolute, + join, +} from "../../src/deno_ral/path.ts"; +import { existsSync } from "../../src/deno_ral/fs.ts"; +import { which } from "../../src/core/path.ts"; +import { projectContext } from "../../src/project/project-context.ts"; +import { singleFileProjectContext } from "../../src/project/types/single-file/single-file.ts"; +import { notebookContext } from "../../src/render/notebook/notebook-context.ts"; +import { renderServices } from "../../src/command/render/render-services.ts"; +import { renderFormats } from "../../src/command/render/render-contexts.ts"; +import { previewFormat } from "../../src/command/preview/preview.ts"; +import { renderProject } from "../../src/command/render/project.ts"; +import { initYamlIntelligenceResourcesFromFilesystem } from "../../src/core/schema/utils.ts"; + +// --- End-to-end fixture (RED-1) --- +// The harness applies TestContext.cwd() BEFORE setup(), so the working +// directory must already exist when it chdirs in — create the tree in the +// prereq check itself (the harness runs prereq before cwd()/setup(), and +// skips cwd()/setup()/teardown() together when prereq is false), so nothing +// is left on disk when Rscript is absent and the test is skipped. This test +// keeps the literal RStudio shape: cwd = the doc's subdirectory, input = the +// bare filename. +let e2eBase: string; +let e2eProjDir: string; +let e2eLabsDir: string; + +unitTest( + "preview of a knitr subdir doc from that subdir's cwd renders (#14683)", + async () => { + await initYamlIntelligenceResourcesFromFilesystem(); + + // cwd is e2eLabsDir (set by the harness via TestContext.cwd). + const file = "claudetest.qmd"; + + const nbContext = notebookContext(); + const project = (await projectContext(dirname(file), nbContext)) ?? + (await singleFileProjectContext(file, nbContext)); + + try { + // Mimic preview cmd.ts: resolve the preview format first, using the + // bare filename. This seeds the shared context's fileInformationCache. + const services = renderServices(nbContext); + try { + const formats = await renderFormats(file, services, "all", project); + await previewFormat(file, project, undefined, formats); + } finally { + services.cleanup(); + } + + // Now render as preview does, passing the bare filename. + const renderServices2 = renderServices(nbContext); + try { + const result = await renderProject( + project, + { + services: renderServices2, + progress: false, + useFreezer: false, + flags: { to: "html" }, + pandocArgs: [], + previewServer: true, + }, + [file], + ); + + // The behavioral signal for this bug is whether the knitr render + // actually produced output. With the bug, the R subprocess fails at + // rmarkdown:::abs_path(input) (execute.R) before Pandoc runs, so no + // HTML is written. Surface renderProject's error (if any) first, since + // the missing-file assertion below would otherwise mask it. + const outputHtml = join(e2eProjDir, "_site", "labs", "claudetest.html"); + assert( + existsSync(outputHtml), + "Expected rendered HTML at _site/labs/claudetest.html — knitr render " + + "of the subdir doc failed to produce output (regression #14683). " + + `renderProject error: ${result.error?.message} ${result.error?.stack}`, + ); + + // Mere existence is not enough: prove the knitr chunk actually + // executed. `1 + 1` yields `[1] 2` in the rendered output, confirming + // the R engine ran end-to-end (the exact path the bug broke). + const html = Deno.readTextFileSync(outputHtml); + assert( + /\[1\]\s*2/.test(html), + "Rendered HTML is missing the knitr chunk result ([1] 2) — the R " + + "engine did not execute the code cell (regression #14683)", + ); + + // QUARTO_DOCUMENT_PATH (from target.source) must point at the doc's + // subdirectory (…/testsite/labs), not "." or the project root. A + // relative source would make it wrong here (#12401). + assert( + /testsite[\\/]labs/.test(html), + "QUARTO_DOCUMENT_PATH did not resolve to the doc's subdirectory " + + "(expected a path containing testsite/labs) — regression #14683 / #12401", + ); + + assert( + !result.error, + `renderProject reported an error: ${result.error?.message}`, + ); + } finally { + renderServices2.cleanup(); + } + } finally { + project.cleanup(); + } + }, + { + // Spawns the R/knitr engine; skip (not fail) where R is absent. Creates + // the fixture tree as a side effect only when Rscript is present, so a + // skipped run leaves nothing on disk (teardown never runs otherwise). + prereq: async () => { + if ((await which("Rscript")) === undefined) return false; + e2eBase = Deno.makeTempDirSync({ prefix: "quarto_test_14683_" }); + e2eProjDir = join(e2eBase, "testsite"); + e2eLabsDir = join(e2eProjDir, "labs"); + Deno.mkdirSync(e2eLabsDir, { recursive: true }); + return true; + }, + // Run from the doc's subdirectory so the bare filename is cwd-relative, + // exactly as RStudio's Render button invokes preview. The harness restores + // the original cwd afterward. + cwd: () => e2eLabsDir, + setup: () => { + // Re-activate renv against the real tests/ project, regardless of + // this fixture's cwd — see llm-docs/testing-patterns.md → "R Tests + // That Change Working Directory" for why this is needed. + const testsDir = dirname(dirname(fromFileUrl(import.meta.url))) + .replaceAll("\\", "/"); + Deno.writeTextFileSync( + join(e2eProjDir, ".Rprofile"), + `Sys.setenv(RENV_PROJECT = "${testsDir}")\n` + + `source("${testsDir}/renv/activate.R")\n`, + ); + + Deno.writeTextFileSync( + join(e2eProjDir, "_quarto.yml"), + 'project:\n type: website\n\nwebsite:\n title: "testsite"\n', + ); + Deno.writeTextFileSync( + join(e2eProjDir, "index.qmd"), + "---\ntitle: Index\n---\n\nHome.\n", + ); + // Second chunk echoes QUARTO_DOCUMENT_PATH so the render also proves the + // env var (derived from target.source) resolves to the doc's subdirectory + // rather than "." / the project root — the #12401 inconsistency. + Deno.writeTextFileSync( + join(e2eLabsDir, "claudetest.qmd"), + "---\ntitle: Claude Test\n---\n\n```{r}\n1 + 1\n```\n\n" + + '```{r}\ncat("QDP:", Sys.getenv("QUARTO_DOCUMENT_PATH"))\n```\n', + ); + return Promise.resolve(); + }, + teardown: () => { + // Best-effort: the harness restores cwd after teardown, so on Windows the + // dir may still be the cwd and resist removal — acceptable, matching the + // house pattern (see tests/smoke/use/template.test.ts). + try { + Deno.removeSync(e2eBase, { recursive: true }); + } catch { + // ignore + } + return Promise.resolve(); + }, + }, +); + +// Deterministic, R-free guard for the root cause. Seeding the cache with a +// relative path (as preview's format resolution does) and then looking up by +// the absolute path (as renderProject does) must return one cached target whose +// input/source are absolute — never the stale relative string. This is what the +// knitr R subprocesses (execute, dependencies, postprocess) read, so asserting +// the target is absolute covers every reader without needing R. A fix that only +// absolutized at execute time would leave the cached target relative and fail. +// Uses TestContext.cwd + a bare filename (not relative(Deno.cwd(), absFile)): +// path.relative() falls back to returning the absolute path unchanged when its +// two arguments are on different Windows drives, which would make the "seed +// with a relative path" step silently seed with an absolute one instead and +// pass even without the fix (see .github/workflows/test-smokes.yml's "Fix temp +// dir to use runner one" step, which exists because the default drive for +// %TEMP% and the checkout can differ on GitHub's Windows runners). +const guardBase = Deno.makeTempDirSync({ prefix: "quarto_test_14683b_" }); +const guardProjDir = join(guardBase, "testsite"); +const guardLabsDir = join(guardProjDir, "labs"); +Deno.mkdirSync(guardLabsDir, { recursive: true }); + +unitTest( + "fileExecutionEngineAndTarget yields an absolute target for a relative subdir input (#14683)", + async () => { + await initYamlIntelligenceResourcesFromFilesystem(); + + // cwd is guardLabsDir (set by the harness via TestContext.cwd). + const absFile = join(guardLabsDir, "claudetest.qmd"); + const relFile = "claudetest.qmd"; + + const nbContext = notebookContext(); + const project = (await projectContext(guardLabsDir, nbContext)) ?? + (await singleFileProjectContext(absFile, nbContext)); + + try { + // Seed with the relative path. Building the target reads only + // markdown/YAML (no R execution), so this is deterministic. + const seeded = await project.fileExecutionEngineAndTarget(relFile); + assert( + isAbsolute(seeded.target.input) && isAbsolute(seeded.target.source), + `Seeded target should be absolute; got input=${seeded.target.input} ` + + `source=${seeded.target.source} (#14683)`, + ); + + // Look up by the absolute path (renderProject's spelling). The + // normalized cache key collides with the seeded entry; the returned + // target must still be absolute and be the same cached object. + const resolved = await project.fileExecutionEngineAndTarget(absFile); + assert( + isAbsolute(resolved.target.input), + `Expected absolute target.input after absolute lookup, got ` + + `${resolved.target.input} (#14683)`, + ); + assert( + isAbsolute(resolved.target.source), + `Expected absolute target.source after absolute lookup, got ` + + `${resolved.target.source} (#14683)`, + ); + assert( + resolved.target === seeded.target, + "Relative and absolute lookups must hit the same cached target (#14683)", + ); + } finally { + project.cleanup(); + } + }, + { + cwd: () => guardLabsDir, + setup: () => { + Deno.writeTextFileSync( + join(guardProjDir, "_quarto.yml"), + 'project:\n type: website\n\nwebsite:\n title: "testsite"\n', + ); + Deno.writeTextFileSync( + join(guardLabsDir, "claudetest.qmd"), + "---\ntitle: Claude Test\n---\n\n```{r}\n1 + 1\n```\n", + ); + return Promise.resolve(); + }, + teardown: () => { + try { + Deno.removeSync(guardBase, { recursive: true }); + } catch { + // ignore — best-effort (see the anti-pattern doc's Windows cwd note) + } + return Promise.resolve(); + }, + }, +); diff --git a/tests/unit/single-file-input-abs-12401.test.ts b/tests/unit/single-file-input-abs-12401.test.ts new file mode 100644 index 00000000000..b6858f19dbc --- /dev/null +++ b/tests/unit/single-file-input-abs-12401.test.ts @@ -0,0 +1,89 @@ +/* + * single-file-input-abs-12401.test.ts + * + * Regression test for quarto-dev/quarto-cli#12401. + * + * Single-file (non-project) rendering used to build an ExecutionTarget whose + * `source`/`input` were the caller's relative path, while project rendering + * normalized to an absolute path (via project.ts normalizeFiles). That + * inconsistency leaked into `target.source`-derived values — notably + * QUARTO_DOCUMENT_PATH (src/execute/environment.ts sets it to + * dirname(target.source)) — which was relative for single files but absolute + * inside a project. + * + * fileExecutionEngineAndTarget now normalizes the path to absolute at that + * single convergence point, so single-file and project renders agree: the + * target is always absolute. + * + * Deterministic and engine-free: building the target only reads markdown/YAML. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../test.ts"; +import { assert } from "testing/asserts"; +import { isAbsolute, join } from "../../src/deno_ral/path.ts"; +import { singleFileProjectContext } from "../../src/project/types/single-file/single-file.ts"; +import { notebookContext } from "../../src/render/notebook/notebook-context.ts"; +import { initYamlIntelligenceResourcesFromFilesystem } from "../../src/core/schema/utils.ts"; + +// Uses TestContext.cwd + a bare filename rather than relative(Deno.cwd(), +// absFile): path.relative() falls back to returning the absolute path +// unchanged when its two arguments are on different Windows drives, which +// would make "the single-file render path used to keep this relative" +// silently pass even without the fix on a machine/CI job where the OS temp +// drive differs from the checkout drive. +const fixtureDir = Deno.makeTempDirSync({ prefix: "quarto_test_12401_" }); +const relFile = "doc.qmd"; +const absFile = join(fixtureDir, relFile); + +unitTest( + "single-file render builds an absolute target from a relative arg (#12401)", + async () => { + await initYamlIntelligenceResourcesFromFilesystem(); + + // cwd is fixtureDir (set by the harness via TestContext.cwd). + const nbContext = notebookContext(); + const project = await singleFileProjectContext(relFile, nbContext); + + try { + const { target } = await project.fileExecutionEngineAndTarget(relFile); + + // Before the fix, target.source/input were the relative arg, so + // QUARTO_DOCUMENT_PATH (dirname(target.source)) diverged from project + // renders. They are now absolute in both cases. + assert( + isAbsolute(target.source), + `Expected an absolute target.source for single-file render, got ` + + `${target.source} (regression #12401)`, + ); + assert( + isAbsolute(target.input), + `Expected an absolute target.input for single-file render, got ` + + `${target.input} (regression #12401)`, + ); + assert( + target.source === absFile, + `Expected target.source to resolve to ${absFile}, got ` + + `${target.source} (regression #12401)`, + ); + } finally { + project.cleanup(); + } + }, + { + cwd: () => fixtureDir, + setup: () => { + Deno.writeTextFileSync(absFile, "---\ntitle: Doc\n---\n\nHello.\n"); + return Promise.resolve(); + }, + teardown: () => { + try { + Deno.removeSync(fixtureDir, { recursive: true }); + } catch { + // ignore — best-effort (see the anti-pattern doc's Windows cwd note) + } + return Promise.resolve(); + }, + }, +);