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/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 diff --git a/src/command/preview/preview.ts b/src/command/preview/preview.ts index f65825add2..e307948d03 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, @@ -233,7 +248,7 @@ export async function preview( changeHandler.render, project, ) - : project + : project && !project.isSingleFile ? projectHtmlFileRequestHandler( project, normalizePath(file), @@ -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/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. 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:** diff --git a/tests/unit/preview-initial-path.test.ts b/tests/unit/preview-initial-path.test.ts new file mode 100644 index 0000000000..7fbd1b3ed4 --- /dev/null +++ b/tests/unit/preview-initial-path.test.ts @@ -0,0 +1,53 @@ +/* + * 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 { 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 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"); + + project.cleanup(); +}); + +// deno-lint-ignore require-await +unitTest("previewInitialPath - project file returns relative path", async () => { + 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"); + + project.cleanup(); +}); + +// deno-lint-ignore require-await +unitTest("previewInitialPath - project subdir returns relative path", async () => { + 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"); + + project.cleanup(); +}); + +// 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"); +}); 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: () => {