diff --git a/.claude/commands/quarto-preview-test/SKILL.md b/.claude/commands/quarto-preview-test/SKILL.md new file mode 100644 index 00000000000..b902225e21f --- /dev/null +++ b/.claude/commands/quarto-preview-test/SKILL.md @@ -0,0 +1,145 @@ +--- +name: quarto-preview-test +description: Use when testing preview functionality, verifying live reload, or validating preview fixes. Covers starting preview with port/logging, browser verification via /agent-browser, and checking logs/filesystem for artifacts. +--- + +# Quarto Preview Test + +Interactive testing of `quarto preview` with automated browser verification. + +## Tools + +| Tool | When to use | +|------|-------------| +| `/agent-browser` | **Preferred.** Token-efficient browser automation. Navigate, verify content, screenshot. | +| Chrome DevTools MCP | Deep debugging: console messages, network requests, DOM inspection. | +| `jq` / `grep` | Parse debug log output. | + +## Prerequisites + +- Quarto dev version built (`./configure.sh` or `./configure.cmd`) +- Test environment configured (`tests/configure-test-env.sh` or `.ps1`) +- `/agent-browser` CLI installed (preferred), OR Chrome + Chrome DevTools MCP connected + +## Starting Preview + +Preview needs the test venv for Jupyter tests. Activate it first (`tests/.venv`), matching how `run-tests.sh` / `run-tests.ps1` do it. + +```bash +# Linux/macOS +source tests/.venv/bin/activate +./package/dist/bin/quarto preview --no-browser --port 4444 + +# Windows (Git Bash) +source tests/.venv/Scripts/activate +./package/dist/bin/quarto.cmd preview --no-browser --port 4444 +``` + +Use `--no-browser` to control browser connection. Use `--port` for a predictable URL. + +### With debug logging + +```bash +./package/dist/bin/quarto preview --no-browser --port 4444 --log-level debug 2>&1 | tee preview.log +``` + +### In background + +```bash +# Linux/macOS (after venv activation) +./package/dist/bin/quarto preview --no-browser --port 4444 & +PREVIEW_PID=$! +# ... run verification ... +kill $PREVIEW_PID + +# Windows (Git Bash, after venv activation) +./package/dist/bin/quarto.cmd preview --no-browser --port 4444 & +PREVIEW_PID=$! +# ... run verification ... +kill $PREVIEW_PID +``` + +## Edit-Verify Cycle + +The core test pattern: + +1. Start preview with `--no-browser --port 4444` +2. Use `/agent-browser` to navigate to `http://localhost:4444/` and verify content +3. Edit source file, wait 3-5 seconds for re-render +4. Verify content updated in browser +5. Check filesystem for unexpected artifacts +6. Stop preview, verify cleanup + +## What to Verify + +**In browser** (via `/agent-browser`): Page loads, content matches source, updates reflect edits. + +**In terminal/logs**: No `BadResource` errors, no crashes, preview stays responsive. + +**On filesystem**: No orphaned temp files, cleanup happens on exit. + +## Windows Limitations + +On Windows, `kill` from Git Bash does not trigger Quarto's `onCleanup` handler (SIGINT doesn't propagate to Windows processes the same way). Cleanup-on-exit verification requires an interactive terminal with Ctrl+C. For automated testing, verify artifacts *during* preview instead. + +## Context Types + +Preview behaves differently depending on input: + +| Input | Code path | +|-------|-----------| +| Single file (no project) | `preview()` -> `renderForPreview()` | +| File within a project | May redirect to project preview via `serveProject()` | +| Project directory | `serveProject()` -> `watchProject()` | + +See `llm-docs/preview-architecture.md` for the full architecture. + +## When NOT to Use + +- Automated smoke tests — use `tests/smoke/` instead +- Testing render output only (no live preview needed) — use `quarto render` +- CI environments without browser access + +## Test Matrix + +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 + +Compare dev build against installed release to distinguish regressions: + +```bash +quarto --version # installed +./package/dist/bin/quarto --version # dev +``` + +If both show the same issue, it's pre-existing. diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 46c821a13fc..5ab9145c937 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -4,6 +4,7 @@ - ([#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. ## In previous releases diff --git a/src/command/preview/preview.ts b/src/command/preview/preview.ts index edb2ac0b75c..3141913418a 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, @@ -230,7 +245,7 @@ export async function preview( changeHandler.render, project, ) - : project + : project && !project.isSingleFile ? projectHtmlFileRequestHandler( project, normalizePath(file), @@ -250,13 +265,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 00000000000..df15820e310 --- /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 new file mode 100644 index 00000000000..6575b7c5479 --- /dev/null +++ b/tests/docs/manual/preview/README.md @@ -0,0 +1,196 @@ +# Manual Preview Tests + +Tests for `quarto preview` behavior that require an interactive session with file-save events. These cannot run in automated smoke tests. + +## Automation + +Use the `/quarto-preview-test` command for the general workflow of starting preview, verifying with browser automation, and checking logs/filesystem. It documents the tools and patterns. + +For browser interaction, `/agent-browser` is preferred over Chrome DevTools MCP (more token-efficient). See the command for details. + +## Test Matrix: quarto_ipynb Accumulation (#14281) + +After every test involving Jupyter execution (Python/Julia cells), verify: +1. `ls *.quarto_ipynb*` — at most one `{name}.quarto_ipynb` (no `_1`, `_2` variants) +2. After Ctrl+C exit — no `.quarto_ipynb` files remain (unless `keep-ipynb: true`) + +For tests without Jupyter execution (T9, T10, T11), verify no `.quarto_ipynb` files are created at all. + +### P1: Critical + +#### T1: Single .qmd with Python — re-render accumulation + +- **Setup:** `test.qmd` with Python code cell +- **Steps:** `quarto preview test.qmd`, save 5 times, check files, Ctrl+C +- **Expected:** At most one `.quarto_ipynb` at any time. Zero after exit. +- **Catches:** `invalidateForFile()` not deleting transient file before cache eviction + +#### T2: Single .qmd with Python — startup duplicate + +- **Setup:** Same `test.qmd` +- **Steps:** `quarto preview test.qmd`, check files immediately after first render (before any saves), Ctrl+C +- **Expected:** Exactly one `.quarto_ipynb` during render. Zero after exit. +- **Catches:** `cmd.ts` not passing ProjectContext to `preview()` + +#### T3: .qmd in project — project-level preview + +- **Setup:** Website project (`_quarto.yml` with `type: website`), `index.qmd` with Python cell +- **Steps:** `quarto preview` (project dir), save `index.qmd` 3 times, check files, Ctrl+C +- **Expected:** At most one `index.quarto_ipynb`. Zero after exit. +- **Catches:** Fix works when `projectContext()` finds a real project + +#### T4: .qmd in project — single file preview + +- **Setup:** Same project as T3 +- **Steps:** `quarto preview index.qmd`, save 3 times, check files, Ctrl+C +- **Expected:** Same as T3. May redirect to project preview (expected behavior). +- **Catches:** Context passing works for files inside serveable projects + +### P2: Important + +#### T5: .qmd with Julia code cells + +- **Setup:** `julia-test.qmd` with Julia cell +- **Steps:** Same as T1 +- **Expected:** Same as T1. Julia uses the same Jupyter engine path. + +#### T6: Rapid successive saves + +- **Setup:** Same `test.qmd` as T1 +- **Steps:** Save 5 times within 2-3 seconds (faster than render completes) +- **Expected:** At most one `.quarto_ipynb`. Debounce/queue coalesces saves. +- **Catches:** Race condition in invalidation during in-progress render + +#### T7: `keep-ipynb: true` + +- **Setup:** `test.qmd` with `keep-ipynb: true` in YAML +- **Steps:** Preview, save 3 times, Ctrl+C, check files +- **Expected:** `test.quarto_ipynb` persists after exit (not cleaned up). No `_1` variants during preview. +- **Catches:** `invalidateForFile()` respects the `transient = false` flag set by `cleanupNotebook()` + +#### T8: `--to pdf` format + +- **Setup:** Same `test.qmd` (requires TinyTeX) +- **Steps:** `quarto preview test.qmd --to pdf`, save 3 times +- **Expected:** Same as T1. Transient notebook logic is format-independent. + +#### T9: Plain .qmd — no code cells (regression) + +- **Setup:** `plain.qmd` with only markdown content +- **Steps:** Preview, save 3 times, check for `.quarto_ipynb` files +- **Expected:** No `.quarto_ipynb` files ever created. +- **Catches:** Fix is a no-op when no Jupyter engine is involved + +#### T10: .qmd with R/knitr engine (regression) + +- **Setup:** `r-test.qmd` with R code cell and `engine: knitr` +- **Steps:** Preview, save 3 times, check for `.quarto_ipynb` files +- **Expected:** No `.quarto_ipynb` files. Knitr doesn't use Jupyter intermediate. + +#### T10b: File excluded from project inputs (regression) + +- **Setup:** Website project with `_quarto.yml`. Create `_excluded.qmd` with a Python cell (files starting with `_` are excluded from project inputs by default) +- **Steps:** `quarto preview _excluded.qmd`, save 3 times, check files, Ctrl+C +- **Expected:** Falls back to single-file preview (not project preview). At most one `.quarto_ipynb`. +- **Catches:** Context reuse from cmd.ts incorrectly applying project semantics to excluded files + +### P3: Nice-to-Have + +#### T11: Native .ipynb file + +- **Setup:** `notebook.ipynb` (native Jupyter notebook) +- **Steps:** Preview, save 3 times +- **Expected:** No transient `.quarto_ipynb` — the `.ipynb` is the source, not transient. + +#### T12: File with spaces in name + +- **Setup:** `my document.qmd` with Python cell +- **Steps:** `quarto preview "my document.qmd"`, save 3 times +- **Expected:** At most one `my document.quarto_ipynb`. Path normalization handles spaces. + +#### T13: File in subdirectory + +- **Setup:** `subdir/deep/test.qmd` with Python cell +- **Steps:** Preview from parent dir, save 3 times +- **Expected:** At most one transient notebook in `subdir/deep/`. + +#### T14: Change code cell content + +- **Setup:** `test.qmd` with `x = 1; print(x)` +- **Steps:** Change to `x = 2`, save; change to `x = 3`, save +- **Expected:** At most one `.quarto_ipynb`. Code changes trigger re-execution but file is cleaned. + +#### T15: Change YAML metadata + +- **Setup:** `test.qmd` with `title: "Test"` +- **Steps:** Change title, save; add `theme: cosmo`, save +- **Expected:** Same as T1. Metadata changes go through the same render/invalidation path. + +#### T16: Multiple .qmd files in project + +- **Setup:** Website with `index.qmd` (Python), `about.qmd` (Python), `plain.qmd` (no code) +- **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:** +```yaml +--- +title: "Preview Test" +--- +``` + +```` +```{python} +print("Hello from Python") +``` +```` + +``` +Edit this line to trigger re-renders. +``` + +**Minimal website project (`_quarto.yml`):** +```yaml +project: + type: website +``` + +**keep-ipynb variant:** +```yaml +--- +title: "Keep ipynb Test" +execute: + keep-ipynb: true +--- +``` diff --git a/tests/docs/manual/preview/plain.qmd b/tests/docs/manual/preview/plain.qmd new file mode 100644 index 00000000000..27dd844ed46 --- /dev/null +++ b/tests/docs/manual/preview/plain.qmd @@ -0,0 +1,9 @@ +--- +title: "Plain QMD Test" +--- + +## T9: No code cells + +No .quarto_ipynb should ever be created. + +Edit counter: 0 diff --git a/tests/unit/preview-initial-path.test.ts b/tests/unit/preview-initial-path.test.ts new file mode 100644 index 00000000000..7fbd1b3ed42 --- /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 e7b6c375e29..810847678b4 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: () => {