Skip to content

Commit a42c44c

Browse files
authored
Fix preview browse URL for single-file documents (#14300)
Fix preview browse URL for single-file documents After #13804 made the project variable always non-null via singleFileProjectContext(), two code paths in preview() broke for single-file documents: the browse URL included the output filename (e.g. /hello.html instead of /), and GET / returned 404 because the project handler expected index.html as the default file. Guard both the URL path computation and the handler selection with !project.isSingleFile so single-file previews use the correct handler and root URL. Extract URL path logic into a testable previewInitialPath() function. - Fix initialPath using relative path instead of empty string - Fix handler selection using projectHtmlFileRequestHandler with wrong default file instead of htmlFileRequestHandler - Add unit tests for previewInitialPath() (single-file, project, project-subdir, undefined-project cases) - Add manual preview test entries T17-T19 Fixes #14298
1 parent 3fa22f9 commit a42c44c

7 files changed

Lines changed: 161 additions & 16 deletions

File tree

.claude/commands/quarto-preview-test/SKILL.md

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,9 +100,38 @@ See `llm-docs/preview-architecture.md` for the full architecture.
100100
- Testing render output only (no live preview needed) — use `quarto render`
101101
- CI environments without browser access
102102

103-
## Test Fixtures and Cases
103+
## Test Matrix
104104

105-
Test fixtures live in `tests/docs/manual/preview/`. The full test matrix is in `tests/docs/manual/preview/README.md`.
105+
The full test matrix lives in `tests/docs/manual/preview/README.md`. Test fixtures live alongside it in `tests/docs/manual/preview/`.
106+
107+
### Running specific tests by ID
108+
109+
When invoked with test IDs (e.g., `/quarto-preview-test T17 T18`):
110+
111+
1. Read `tests/docs/manual/preview/README.md`
112+
2. Find each requested test by its ID (e.g., `#### T17:`)
113+
3. Parse the **Setup**, **Steps**, and **Expected** fields
114+
4. Execute each test following the steps, using the fixtures in `tests/docs/manual/preview/`
115+
5. Report PASS/FAIL for each test with the actual vs expected result
116+
117+
### Running tests by topic
118+
119+
When invoked with a topic description instead of IDs (e.g., `/quarto-preview-test root URL` or "run preview tests for single-file"):
120+
121+
1. Read `tests/docs/manual/preview/README.md`
122+
2. Search test titles and descriptions for matches (keywords, issue numbers, feature area)
123+
3. Present the matched tests to the user for confirmation before running:
124+
```
125+
Found these matching tests:
126+
- T17: Single-file preview — root URL accessible (#14298)
127+
- T18: Single-file preview — named output URL also accessible
128+
Run these? [Y/n]
129+
```
130+
4. Only execute after user confirms
131+
132+
### Running without arguments
133+
134+
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.
106135

107136
## Baseline Comparison
108137

news/changelog-1.10.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ All changes included in 1.10:
44

55
- ([#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.
66
- ([#14281](https://github.com/quarto-dev/quarto-cli/issues/14281)): Fix transient `.quarto_ipynb` files accumulating during `quarto preview` with Jupyter engine.
7+
- ([#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.
78

89
## Formats
910

src/command/preview/preview.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,21 @@ interface PreviewOptions {
143143
presentation: boolean;
144144
}
145145

146+
export function previewInitialPath(
147+
outputFile: string,
148+
project: ProjectContext | undefined,
149+
): string {
150+
if (isPdfContent(outputFile)) {
151+
return kPdfJsInitialPath;
152+
}
153+
if (project && !project.isSingleFile) {
154+
return pathWithForwardSlashes(
155+
relative(projectOutputDir(project), outputFile),
156+
);
157+
}
158+
return "";
159+
}
160+
146161
export async function preview(
147162
file: string,
148163
flags: RenderFlags,
@@ -233,7 +248,7 @@ export async function preview(
233248
changeHandler.render,
234249
project,
235250
)
236-
: project
251+
: project && !project.isSingleFile
237252
? projectHtmlFileRequestHandler(
238253
project,
239254
normalizePath(file),
@@ -253,13 +268,7 @@ export async function preview(
253268
);
254269

255270
// open browser if this is a browseable format
256-
const initialPath = isPdfContent(result.outputFile)
257-
? kPdfJsInitialPath
258-
: project
259-
? pathWithForwardSlashes(
260-
relative(projectOutputDir(project), result.outputFile),
261-
)
262-
: "";
271+
const initialPath = previewInitialPath(result.outputFile, project);
263272
if (
264273
options.browser &&
265274
!isServerSession() &&

tests/docs/manual/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Manual Tests
2+
3+
Tests that require interactive sessions, external services, or browser access that cannot run in automated CI.
4+
5+
## Test Suites
6+
7+
| Directory / File | Area | Skill | Description |
8+
|-----------------|------|-------|-------------|
9+
| `preview/` | `quarto preview` | `/quarto-preview-test` | Live preview server behavior: URL routing, file watching, live reload, transient file cleanup |
10+
| `publish-connect-cloud/` | `quarto publish` || Posit Connect Cloud publishing with OAuth flow |
11+
| `mermaid-svg-pdf-tooling.qmd` | `quarto render` || Mermaid SVG rendering to PDF with external tooling (rsvg-convert) |
12+
13+
## Running Tests
14+
15+
Each suite has its own README with test matrix and execution instructions. Test fixtures live alongside the README in each directory.
16+
17+
For preview tests, use the `/quarto-preview-test` skill which automates the start-verify-cleanup cycle.

tests/docs/manual/preview/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,35 @@ For tests without Jupyter execution (T9, T10, T11), verify no `.quarto_ipynb` fi
132132
- **Steps:** Preview project, edit index, navigate to about, edit about
133133
- **Expected:** At most one `.quarto_ipynb` per Jupyter-using file. No accumulation.
134134

135+
## Test Matrix: Single-file Preview Root URL (#14298)
136+
137+
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.
138+
139+
### P1: Critical
140+
141+
#### T17: Single-file preview — root URL accessible
142+
143+
- **Setup:** `plain.qmd` with only markdown content (no code cells)
144+
- **Steps:** `quarto preview plain.qmd --port XXXX --no-browser`, then `curl -s -o /dev/null -w "%{http_code}" http://localhost:XXXX/`
145+
- **Expected:** HTTP 200. Browse URL prints `http://localhost:XXXX/` (no filename appended).
146+
- **Catches:** `projectHtmlFileRequestHandler` used for single files (defaultFile=`index.html` instead of output filename), or `previewInitialPath` returning filename instead of `""`
147+
148+
#### T18: Single-file preview — named output URL also accessible
149+
150+
- **Setup:** Same `plain.qmd`
151+
- **Steps:** `quarto preview plain.qmd --port XXXX --no-browser`, then `curl -s -o /dev/null -w "%{http_code}" http://localhost:XXXX/plain.html`
152+
- **Expected:** HTTP 200. The output filename path also serves the rendered content.
153+
- **Catches:** Handler regression where only root or only named path works
154+
155+
### P2: Important
156+
157+
#### T19: Project preview — non-index file URL correct
158+
159+
- **Setup:** Website project with `_quarto.yml`, `index.qmd`, and `about.qmd`
160+
- **Steps:** `quarto preview --port XXXX --no-browser`, navigate to `http://localhost:XXXX/about.html`
161+
- **Expected:** HTTP 200. Browse URL may include path for non-index files in project context.
162+
- **Catches:** `isSingleFile` guard accidentally excluding real project files from path computation
163+
135164
## Test File Templates
136165

137166
**Minimal Python .qmd:**
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/*
2+
* preview-initial-path.test.ts
3+
*
4+
* Tests that previewInitialPath computes the correct browse URL path
5+
* for different project types. Regression test for #14298.
6+
*
7+
* Copyright (C) 2026 Posit Software, PBC
8+
*/
9+
10+
import { unitTest } from "../test.ts";
11+
import { assertEquals } from "testing/asserts";
12+
import { join } from "../../src/deno_ral/path.ts";
13+
import { previewInitialPath } from "../../src/command/preview/preview.ts";
14+
import { createMockProjectContext } from "./project/utils.ts";
15+
16+
// deno-lint-ignore require-await
17+
unitTest("previewInitialPath - single file returns empty path (#14298)", async () => {
18+
const project = createMockProjectContext({ isSingleFile: true });
19+
const outputFile = join(project.dir, "hello.html");
20+
21+
const result = previewInitialPath(outputFile, project);
22+
assertEquals(result, "", "Single-file preview should use root path, not filename");
23+
24+
project.cleanup();
25+
});
26+
27+
// deno-lint-ignore require-await
28+
unitTest("previewInitialPath - project file returns relative path", async () => {
29+
const project = createMockProjectContext();
30+
const outputFile = join(project.dir, "chapter.html");
31+
32+
const result = previewInitialPath(outputFile, project);
33+
assertEquals(result, "chapter.html", "Project preview should include relative path");
34+
35+
project.cleanup();
36+
});
37+
38+
// deno-lint-ignore require-await
39+
unitTest("previewInitialPath - project subdir returns relative path", async () => {
40+
const project = createMockProjectContext();
41+
const outputFile = join(project.dir, "pages", "about.html");
42+
43+
const result = previewInitialPath(outputFile, project);
44+
assertEquals(result, "pages/about.html", "Project preview should include subdirectory path");
45+
46+
project.cleanup();
47+
});
48+
49+
// deno-lint-ignore require-await
50+
unitTest("previewInitialPath - undefined project returns empty path", async () => {
51+
const result = previewInitialPath("/tmp/hello.html", undefined);
52+
assertEquals(result, "", "No project should use root path");
53+
});

tests/unit/project/utils.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,28 @@ import { FileInformationCacheMap } from "../../../src/project/project-shared.ts"
1111

1212
/**
1313
* Create a minimal mock ProjectContext for testing.
14-
* Only provides the essential properties needed for cache-related tests.
1514
*
16-
* @param dir - The project directory (defaults to a temp directory)
15+
* @param options.dir - The project directory (defaults to a temp directory)
16+
* @param options.isSingleFile - Whether this is a single-file project (defaults to false)
17+
* @param options.config - Project config (defaults to { project: {} })
1718
* @returns A mock ProjectContext suitable for unit testing
1819
*/
1920
export function createMockProjectContext(
20-
dir?: string,
21+
options?: {
22+
dir?: string;
23+
isSingleFile?: boolean;
24+
config?: ProjectContext["config"];
25+
},
2126
): ProjectContext {
22-
const projectDir = dir ?? Deno.makeTempDirSync({ prefix: "quarto-test" });
23-
const ownsDir = dir === undefined;
27+
const projectDir = options?.dir ??
28+
Deno.makeTempDirSync({ prefix: "quarto-test" });
29+
const ownsDir = options?.dir === undefined;
2430

2531
return {
2632
dir: projectDir,
2733
engines: [],
2834
files: { input: [] },
35+
config: options?.config ?? { project: {} },
2936
notebookContext: {} as ProjectContext["notebookContext"],
3037
fileInformationCache: new FileInformationCacheMap(),
3138
resolveBrand: () => Promise.resolve(undefined),
@@ -37,7 +44,7 @@ export function createMockProjectContext(
3744
clone: function () {
3845
return this;
3946
},
40-
isSingleFile: false,
47+
isSingleFile: options?.isSingleFile ?? false,
4148
diskCache: {} as ProjectContext["diskCache"],
4249
temp: {} as ProjectContext["temp"],
4350
cleanup: () => {

0 commit comments

Comments
 (0)