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. ``), 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();
+ },
+ },
+);