From 1a3d03bd529ddda287ca767037bc330cad422afb Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 2 Apr 2026 11:09:03 +0200 Subject: [PATCH 1/6] Fix preview browse URL including output filename for single files PR #13804 made project always non-null via singleFileProjectContext(), causing the initialPath computation to always take the project branch. This produced URLs like http://localhost:PORT/hello.html instead of http://localhost:PORT/ for standalone files, breaking Posit Workbench proxy access. Guard with project.isSingleFile so single-file previews use root path. Extract computation into previewInitialPath() for testability. Fixes #14298 --- src/command/preview/preview.ts | 23 +++++--- tests/unit/preview-initial-path.test.ts | 73 +++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 tests/unit/preview-initial-path.test.ts diff --git a/src/command/preview/preview.ts b/src/command/preview/preview.ts index f65825add2..669749a2c9 100644 --- a/src/command/preview/preview.ts +++ b/src/command/preview/preview.ts @@ -143,6 +143,21 @@ interface PreviewOptions { presentation: boolean; } +export function previewInitialPath( + outputFile: string, + project: ProjectContext | undefined, +): string { + if (isPdfContent(outputFile)) { + return kPdfJsInitialPath; + } + if (project && !project.isSingleFile) { + return pathWithForwardSlashes( + relative(projectOutputDir(project), outputFile), + ); + } + return ""; +} + export async function preview( file: string, flags: RenderFlags, @@ -253,13 +268,7 @@ export async function preview( ); // open browser if this is a browseable format - const initialPath = isPdfContent(result.outputFile) - ? kPdfJsInitialPath - : project - ? pathWithForwardSlashes( - relative(projectOutputDir(project), result.outputFile), - ) - : ""; + const initialPath = previewInitialPath(result.outputFile, project); if ( options.browser && !isServerSession() && diff --git a/tests/unit/preview-initial-path.test.ts b/tests/unit/preview-initial-path.test.ts new file mode 100644 index 0000000000..bbbeabd0ec --- /dev/null +++ b/tests/unit/preview-initial-path.test.ts @@ -0,0 +1,73 @@ +/* + * preview-initial-path.test.ts + * + * Tests that previewInitialPath computes the correct browse URL path + * for different project types. Regression test for #14298. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../test.ts"; +import { assertEquals } from "testing/asserts"; +import { join } from "../../src/deno_ral/path.ts"; +import { normalizePath } from "../../src/core/path.ts"; +import { previewInitialPath } from "../../src/command/preview/preview.ts"; +import { ProjectContext } from "../../src/project/types.ts"; + +function mockProjectContext( + dir: string, + isSingleFile: boolean, +): ProjectContext { + return { + dir: normalizePath(dir), + isSingleFile, + engines: [], + files: { input: [], resources: [], config: [], configResources: [] }, + config: { project: {} }, + notebookContext: () => ({ resolve: () => undefined, get: () => undefined }), + resolveFullMarkdownForFile: () => Promise.resolve(undefined), + cleanup: () => {}, + } as unknown as ProjectContext; +} + +// deno-lint-ignore require-await +unitTest("previewInitialPath - single file returns empty path (#14298)", async () => { + const dir = normalizePath(Deno.makeTempDirSync({ prefix: "quarto-test" })); + const outputFile = join(dir, "hello.html"); + const project = mockProjectContext(dir, true); + + const result = previewInitialPath(outputFile, project); + assertEquals(result, "", "Single-file preview should use root path, not filename"); + + Deno.removeSync(dir, { recursive: true }); +}); + +// deno-lint-ignore require-await +unitTest("previewInitialPath - project file returns relative path", async () => { + const dir = normalizePath(Deno.makeTempDirSync({ prefix: "quarto-test" })); + const outputFile = join(dir, "chapter.html"); + const project = mockProjectContext(dir, false); + + const result = previewInitialPath(outputFile, project); + assertEquals(result, "chapter.html", "Project preview should include relative path"); + + Deno.removeSync(dir, { recursive: true }); +}); + +// deno-lint-ignore require-await +unitTest("previewInitialPath - project subdir returns relative path", async () => { + const dir = normalizePath(Deno.makeTempDirSync({ prefix: "quarto-test" })); + const outputFile = join(dir, "pages", "about.html"); + const project = mockProjectContext(dir, false); + + const result = previewInitialPath(outputFile, project); + assertEquals(result, "pages/about.html", "Project preview should include subdirectory path"); + + Deno.removeSync(dir, { recursive: true }); +}); + +// deno-lint-ignore require-await +unitTest("previewInitialPath - undefined project returns empty path", async () => { + const result = previewInitialPath("/tmp/hello.html", undefined); + assertEquals(result, "", "No project should use root path"); +}); From ba848d6ac0ca8d7aec53433af931d56e0849e42c Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 2 Apr 2026 11:20:25 +0200 Subject: [PATCH 2/6] changelog: Add #14298 regression fix entry --- news/changelog-1.10.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index d43a69482c..6f85f67d76 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -4,6 +4,7 @@ All changes included in 1.10: - ([#14267](https://github.com/quarto-dev/quarto-cli/issues/14267)): Fix Windows paths with accented characters (e.g., `C:\Users\Sébastien\`) breaking dart-sass compilation. - ([#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. ## Formats From 3411d6714ac9add6344a034e645943097966ff03 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 2 Apr 2026 12:13:17 +0200 Subject: [PATCH 3/6] Reuse shared createMockProjectContext in preview URL tests Extend createMockProjectContext() with options object supporting isSingleFile and config parameters. Use it in preview-initial-path tests instead of a duplicate mock function. --- tests/unit/preview-initial-path.test.ts | 30 ++++++------------------- tests/unit/project/utils.ts | 19 +++++++++++----- 2 files changed, 20 insertions(+), 29 deletions(-) diff --git a/tests/unit/preview-initial-path.test.ts b/tests/unit/preview-initial-path.test.ts index bbbeabd0ec..dc00bf360c 100644 --- a/tests/unit/preview-initial-path.test.ts +++ b/tests/unit/preview-initial-path.test.ts @@ -12,58 +12,42 @@ import { assertEquals } from "testing/asserts"; import { join } from "../../src/deno_ral/path.ts"; import { normalizePath } from "../../src/core/path.ts"; import { previewInitialPath } from "../../src/command/preview/preview.ts"; -import { ProjectContext } from "../../src/project/types.ts"; - -function mockProjectContext( - dir: string, - isSingleFile: boolean, -): ProjectContext { - return { - dir: normalizePath(dir), - isSingleFile, - engines: [], - files: { input: [], resources: [], config: [], configResources: [] }, - config: { project: {} }, - notebookContext: () => ({ resolve: () => undefined, get: () => undefined }), - resolveFullMarkdownForFile: () => Promise.resolve(undefined), - cleanup: () => {}, - } as unknown as ProjectContext; -} +import { createMockProjectContext } from "./project/utils.ts"; // deno-lint-ignore require-await unitTest("previewInitialPath - single file returns empty path (#14298)", async () => { const dir = normalizePath(Deno.makeTempDirSync({ prefix: "quarto-test" })); const outputFile = join(dir, "hello.html"); - const project = mockProjectContext(dir, true); + const project = createMockProjectContext({ dir, isSingleFile: true }); const result = previewInitialPath(outputFile, project); assertEquals(result, "", "Single-file preview should use root path, not filename"); - Deno.removeSync(dir, { recursive: true }); + project.cleanup(); }); // deno-lint-ignore require-await unitTest("previewInitialPath - project file returns relative path", async () => { const dir = normalizePath(Deno.makeTempDirSync({ prefix: "quarto-test" })); const outputFile = join(dir, "chapter.html"); - const project = mockProjectContext(dir, false); + const project = createMockProjectContext({ dir }); const result = previewInitialPath(outputFile, project); assertEquals(result, "chapter.html", "Project preview should include relative path"); - Deno.removeSync(dir, { recursive: true }); + project.cleanup(); }); // deno-lint-ignore require-await unitTest("previewInitialPath - project subdir returns relative path", async () => { const dir = normalizePath(Deno.makeTempDirSync({ prefix: "quarto-test" })); const outputFile = join(dir, "pages", "about.html"); - const project = mockProjectContext(dir, false); + const project = createMockProjectContext({ dir }); const result = previewInitialPath(outputFile, project); assertEquals(result, "pages/about.html", "Project preview should include subdirectory path"); - Deno.removeSync(dir, { recursive: true }); + project.cleanup(); }); // deno-lint-ignore require-await diff --git a/tests/unit/project/utils.ts b/tests/unit/project/utils.ts index e7b6c375e2..810847678b 100644 --- a/tests/unit/project/utils.ts +++ b/tests/unit/project/utils.ts @@ -11,21 +11,28 @@ import { FileInformationCacheMap } from "../../../src/project/project-shared.ts" /** * Create a minimal mock ProjectContext for testing. - * Only provides the essential properties needed for cache-related tests. * - * @param dir - The project directory (defaults to a temp directory) + * @param options.dir - The project directory (defaults to a temp directory) + * @param options.isSingleFile - Whether this is a single-file project (defaults to false) + * @param options.config - Project config (defaults to { project: {} }) * @returns A mock ProjectContext suitable for unit testing */ export function createMockProjectContext( - dir?: string, + options?: { + dir?: string; + isSingleFile?: boolean; + config?: ProjectContext["config"]; + }, ): ProjectContext { - const projectDir = dir ?? Deno.makeTempDirSync({ prefix: "quarto-test" }); - const ownsDir = dir === undefined; + const projectDir = options?.dir ?? + Deno.makeTempDirSync({ prefix: "quarto-test" }); + const ownsDir = options?.dir === undefined; return { dir: projectDir, engines: [], files: { input: [] }, + config: options?.config ?? { project: {} }, notebookContext: {} as ProjectContext["notebookContext"], fileInformationCache: new FileInformationCacheMap(), resolveBrand: () => Promise.resolve(undefined), @@ -37,7 +44,7 @@ export function createMockProjectContext( clone: function () { return this; }, - isSingleFile: false, + isSingleFile: options?.isSingleFile ?? false, diskCache: {} as ProjectContext["diskCache"], temp: {} as ProjectContext["temp"], cleanup: () => { From 3e4308b7027fee1a6a7770bc602133c646d415c2 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 2 Apr 2026 15:01:23 +0200 Subject: [PATCH 4/6] Fix single-file preview handler using project handler with wrong default The handler selection at line 251 had the same project-truthiness bug as the URL path computation: single-file projects used projectHtmlFileRequestHandler (defaultFile="index.html") instead of htmlFileRequestHandler (defaultFile=basename of output). This caused GET / to return 404 for single-file previews since v1.9. Add isSingleFile guard to handler selection. Also fix test cleanup by letting createMockProjectContext own temp dir creation. --- src/command/preview/preview.ts | 2 +- tests/unit/preview-initial-path.test.ts | 16 ++++++---------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/command/preview/preview.ts b/src/command/preview/preview.ts index 669749a2c9..e307948d03 100644 --- a/src/command/preview/preview.ts +++ b/src/command/preview/preview.ts @@ -248,7 +248,7 @@ export async function preview( changeHandler.render, project, ) - : project + : project && !project.isSingleFile ? projectHtmlFileRequestHandler( project, normalizePath(file), diff --git a/tests/unit/preview-initial-path.test.ts b/tests/unit/preview-initial-path.test.ts index dc00bf360c..7fbd1b3ed4 100644 --- a/tests/unit/preview-initial-path.test.ts +++ b/tests/unit/preview-initial-path.test.ts @@ -10,15 +10,13 @@ import { unitTest } from "../test.ts"; import { assertEquals } from "testing/asserts"; import { join } from "../../src/deno_ral/path.ts"; -import { normalizePath } from "../../src/core/path.ts"; import { previewInitialPath } from "../../src/command/preview/preview.ts"; import { createMockProjectContext } from "./project/utils.ts"; // deno-lint-ignore require-await unitTest("previewInitialPath - single file returns empty path (#14298)", async () => { - const dir = normalizePath(Deno.makeTempDirSync({ prefix: "quarto-test" })); - const outputFile = join(dir, "hello.html"); - const project = createMockProjectContext({ dir, isSingleFile: true }); + const project = createMockProjectContext({ isSingleFile: true }); + const outputFile = join(project.dir, "hello.html"); const result = previewInitialPath(outputFile, project); assertEquals(result, "", "Single-file preview should use root path, not filename"); @@ -28,9 +26,8 @@ unitTest("previewInitialPath - single file returns empty path (#14298)", async ( // deno-lint-ignore require-await unitTest("previewInitialPath - project file returns relative path", async () => { - const dir = normalizePath(Deno.makeTempDirSync({ prefix: "quarto-test" })); - const outputFile = join(dir, "chapter.html"); - const project = createMockProjectContext({ dir }); + const project = createMockProjectContext(); + const outputFile = join(project.dir, "chapter.html"); const result = previewInitialPath(outputFile, project); assertEquals(result, "chapter.html", "Project preview should include relative path"); @@ -40,9 +37,8 @@ unitTest("previewInitialPath - project file returns relative path", async () => // deno-lint-ignore require-await unitTest("previewInitialPath - project subdir returns relative path", async () => { - const dir = normalizePath(Deno.makeTempDirSync({ prefix: "quarto-test" })); - const outputFile = join(dir, "pages", "about.html"); - const project = createMockProjectContext({ dir }); + const project = createMockProjectContext(); + const outputFile = join(project.dir, "pages", "about.html"); const result = previewInitialPath(outputFile, project); assertEquals(result, "pages/about.html", "Project preview should include subdirectory path"); From 15f5888bfe6f423cce0b5a91414289d89019d8a2 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 2 Apr 2026 15:04:55 +0200 Subject: [PATCH 5/6] Add manual preview tests for single-file root URL accessibility T17: Verify GET / returns 200 for single-file preview T18: Verify GET /filename.html also works T19: Verify project preview paths still work with isSingleFile guard --- tests/docs/manual/preview/README.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/docs/manual/preview/README.md b/tests/docs/manual/preview/README.md index 55b1d71d83..6575b7c547 100644 --- a/tests/docs/manual/preview/README.md +++ b/tests/docs/manual/preview/README.md @@ -132,6 +132,35 @@ For tests without Jupyter execution (T9, T10, T11), verify no `.quarto_ipynb` fi - **Steps:** Preview project, edit index, navigate to about, edit about - **Expected:** At most one `.quarto_ipynb` per Jupyter-using file. No accumulation. +## Test Matrix: Single-file Preview Root URL (#14298) + +After every change to preview URL or handler logic, verify that single-file previews serve content at the root URL and print the correct Browse URL. + +### P1: Critical + +#### T17: Single-file preview — root URL accessible + +- **Setup:** `plain.qmd` with only markdown content (no code cells) +- **Steps:** `quarto preview plain.qmd --port XXXX --no-browser`, then `curl -s -o /dev/null -w "%{http_code}" http://localhost:XXXX/` +- **Expected:** HTTP 200. Browse URL prints `http://localhost:XXXX/` (no filename appended). +- **Catches:** `projectHtmlFileRequestHandler` used for single files (defaultFile=`index.html` instead of output filename), or `previewInitialPath` returning filename instead of `""` + +#### T18: Single-file preview — named output URL also accessible + +- **Setup:** Same `plain.qmd` +- **Steps:** `quarto preview plain.qmd --port XXXX --no-browser`, then `curl -s -o /dev/null -w "%{http_code}" http://localhost:XXXX/plain.html` +- **Expected:** HTTP 200. The output filename path also serves the rendered content. +- **Catches:** Handler regression where only root or only named path works + +### P2: Important + +#### T19: Project preview — non-index file URL correct + +- **Setup:** Website project with `_quarto.yml`, `index.qmd`, and `about.qmd` +- **Steps:** `quarto preview --port XXXX --no-browser`, navigate to `http://localhost:XXXX/about.html` +- **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 + ## Test File Templates **Minimal Python .qmd:** From 3425a5d92c9b7e7d1f2ab00ee0a5a80abe0cfce3 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 2 Apr 2026 15:47:28 +0200 Subject: [PATCH 6/6] Update quarto-preview-test skill to support test matrix lookup The skill now supports three invocation modes: - By test ID: /quarto-preview-test T17 T18 - By topic: /quarto-preview-test root URL (fuzzy match + confirm) - No args: ad-hoc preview testing workflow Also add tests/docs/manual/README.md documenting all manual test suites. --- .claude/commands/quarto-preview-test/SKILL.md | 33 +++++++++++++++++-- tests/docs/manual/README.md | 17 ++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 tests/docs/manual/README.md diff --git a/.claude/commands/quarto-preview-test/SKILL.md b/.claude/commands/quarto-preview-test/SKILL.md index 11f00b8694..b902225e21 100644 --- a/.claude/commands/quarto-preview-test/SKILL.md +++ b/.claude/commands/quarto-preview-test/SKILL.md @@ -100,9 +100,38 @@ See `llm-docs/preview-architecture.md` for the full architecture. - Testing render output only (no live preview needed) — use `quarto render` - CI environments without browser access -## Test Fixtures and Cases +## Test Matrix -Test fixtures live in `tests/docs/manual/preview/`. The full test matrix is in `tests/docs/manual/preview/README.md`. +The full test matrix lives in `tests/docs/manual/preview/README.md`. Test fixtures live alongside it in `tests/docs/manual/preview/`. + +### Running specific tests by ID + +When invoked with test IDs (e.g., `/quarto-preview-test T17 T18`): + +1. Read `tests/docs/manual/preview/README.md` +2. Find each requested test by its ID (e.g., `#### T17:`) +3. Parse the **Setup**, **Steps**, and **Expected** fields +4. Execute each test following the steps, using the fixtures in `tests/docs/manual/preview/` +5. Report PASS/FAIL for each test with the actual vs expected result + +### Running tests by topic + +When invoked with a topic description instead of IDs (e.g., `/quarto-preview-test root URL` or "run preview tests for single-file"): + +1. Read `tests/docs/manual/preview/README.md` +2. Search test titles and descriptions for matches (keywords, issue numbers, feature area) +3. Present the matched tests to the user for confirmation before running: + ``` + Found these matching tests: + - T17: Single-file preview — root URL accessible (#14298) + - T18: Single-file preview — named output URL also accessible + Run these? [Y/n] + ``` +4. Only execute after user confirms + +### Running without arguments + +When invoked without test IDs or topic (e.g., `/quarto-preview-test`), use the general Edit-Verify Cycle workflow described above for ad-hoc preview testing. The test matrix is for targeted regression testing. ## Baseline Comparison diff --git a/tests/docs/manual/README.md b/tests/docs/manual/README.md new file mode 100644 index 0000000000..df15820e31 --- /dev/null +++ b/tests/docs/manual/README.md @@ -0,0 +1,17 @@ +# Manual Tests + +Tests that require interactive sessions, external services, or browser access that cannot run in automated CI. + +## Test Suites + +| Directory / File | Area | Skill | Description | +|-----------------|------|-------|-------------| +| `preview/` | `quarto preview` | `/quarto-preview-test` | Live preview server behavior: URL routing, file watching, live reload, transient file cleanup | +| `publish-connect-cloud/` | `quarto publish` | — | Posit Connect Cloud publishing with OAuth flow | +| `mermaid-svg-pdf-tooling.qmd` | `quarto render` | — | Mermaid SVG rendering to PDF with external tooling (rsvg-convert) | + +## Running Tests + +Each suite has its own README with test matrix and execution instructions. Test fixtures live alongside the README in each directory. + +For preview tests, use the `/quarto-preview-test` skill which automates the start-verify-cleanup cycle.