Skip to content
Merged
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
33 changes: 31 additions & 2 deletions .claude/commands/quarto-preview-test/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions news/changelog-1.10.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
25 changes: 17 additions & 8 deletions src/command/preview/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -233,7 +248,7 @@ export async function preview(
changeHandler.render,
project,
)
: project
: project && !project.isSingleFile
? projectHtmlFileRequestHandler(
project,
normalizePath(file),
Expand All @@ -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() &&
Expand Down
17 changes: 17 additions & 0 deletions tests/docs/manual/README.md
Original file line number Diff line number Diff line change
@@ -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.
29 changes: 29 additions & 0 deletions tests/docs/manual/preview/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
53 changes: 53 additions & 0 deletions tests/unit/preview-initial-path.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
19 changes: 13 additions & 6 deletions tests/unit/project/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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: () => {
Expand Down
Loading