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
116 changes: 116 additions & 0 deletions .claude/commands/quarto-preview-test/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
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 <file-or-dir> --no-browser --port 4444

# Windows (Git Bash)
source tests/.venv/Scripts/activate
./package/dist/bin/quarto.cmd preview <file-or-dir> --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 <file> --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 <file> --no-browser --port 4444 &
PREVIEW_PID=$!
# ... run verification ...
kill $PREVIEW_PID

# Windows (Git Bash, after venv activation)
./package/dist/bin/quarto.cmd preview <file> --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 Fixtures and Cases

Test fixtures live in `tests/docs/manual/preview/`. The full test matrix is in `tests/docs/manual/preview/README.md`.

## 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.
17 changes: 17 additions & 0 deletions .claude/rules/testing/typescript-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,20 @@ const fixtureDir = docs("my-fixture"); // → tests/docs/my-fixture/
- `tests/docs/<feature>/` - Test fixtures

**Details:** `llm-docs/testing-patterns.md` for comprehensive patterns and examples.

## Common Test Utilities

**Constructing `MappedString` values:**
```typescript
import { asMappedString } from "../../../src/core/lib/mapped-text.ts";

// Use asMappedString("") instead of casting or constructing MappedString manually
const markdown = asMappedString("");
const markdownWithContent = asMappedString("# Title\nSome content");
```

**Mock ProjectContext:**
```typescript
import { createMockProjectContext } from "./utils.ts"; // tests/unit/project/utils.ts
const project = createMockProjectContext(); // Creates temp dir + FileInformationCacheMap
```
166 changes: 166 additions & 0 deletions llm-docs/preview-architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
---
main_commit: 94ebb7f79
analyzed_date: 2026-03-31
key_files:
- src/command/preview/cmd.ts
- src/command/preview/preview.ts
- src/project/serve/serve.ts
- src/project/serve/watch.ts
- src/project/project-shared.ts
- src/execute/jupyter/jupyter.ts
- src/execute/engine.ts
---

# Preview Architecture

How `quarto preview` works, from CLI entry through rendering and file watching.

## Entry Points

- `src/command/preview/cmd.ts` — CLI command handler, routing logic
- `src/command/preview/preview.ts` — Single-file preview lifecycle
- `src/project/serve/serve.ts` — Project preview via `serveProject()`
- `src/project/serve/watch.ts` — Project file watcher

## cmd.ts Branching (5 Paths)

The command handler in `cmd.ts` determines which preview mode to use. The key variables `file` and `projectTarget` are mutated as a state machine to route between paths.

```
quarto preview [input]
isFile(input)?
├── YES ──► Create ProjectContext, detect format
│ ├── Shiny? ──► previewShiny() / serve() ──► EXIT (Path C)
│ ├── Serveable project, file NOT in inputs?
│ │ └── .md + external previewer ──► file = project.dir (Path B1)
│ ├── Serveable project, file IN inputs?
│ │ └── HTML/serve output ──► renderProject() then file = project.dir (Path B2)
│ └── None of above ──► file stays as-is (Path A)
└── NO (directory) ──► straight to isDirectory check (Path D)
isDirectory(file)? ← file may have been mutated above
├── YES ──► serveProject(projectTarget, ...) ← projectTarget may be ProjectContext
└── NO ──► preview(file, ..., project) ← single-file preview
```

### Path details

| Path | Input | Condition | `file` mutated? | Terminal action |
|------|-------|-----------|-----------------|-----------------|
| A | file | Not in serveable project | No | `preview()` |
| B1 | file | `.md` not in project inputs + external previewer | `file = project.dir` | `serveProject()` |
| B2 | file | In project inputs, HTML/serve output | `file = project.dir` | `serveProject()` (after pre-render) |
| C | file | Shiny document | N/A (exits early) | `previewShiny()`/`serve()` |
| D | directory | User passed directory or cwd | N/A (isFile skipped) | `serveProject()` |

The `file` mutation pattern (`file = project.dir`) is intentional design by JJ Allaire (2022, commit `5508ace5bd`). It converts a single-file preview into a project preview when the file lives in a serveable project, so the browser gets full project navigation.

`projectTarget` (`string | ProjectContext`) carries the context to `serveProject()`, which accepts both types. When it receives a string, it resolves the project itself.

## Single-File Preview Lifecycle (Path A)

### Context creation

`cmd.ts` creates a `ProjectContext` for format detection (routing decisions). This context is passed to `preview()` via the `pProject` parameter to avoid creating a duplicate.

```typescript
// cmd.ts creates context for routing
project = (await projectContext(dirname(file), nbContext)) ||
(await singleFileProjectContext(file, nbContext));

// preview() reuses it
export async function preview(
file, flags, pandocArgs, options,
pProject?: ProjectContext, // reused from cmd.ts
)
```

This mirrors `render()`'s `pContext` pattern in `render-shared.ts`.

### Startup sequence

1. `preview()` receives or creates `ProjectContext`
2. `previewFormat()` determines output format (calls `renderFormats()` if `--to` not specified)
3. `renderForPreview()` does the initial render
4. `createChangeHandler()` sets up file watchers
5. HTTP dev server starts

### Re-render on file change

When the watched source file changes:

1. `createChangeHandler` triggers the `render` closure
2. `renderForPreview()` is called with the **same** `project` from the closure
3. `invalidateForFile(file)` cleans up the transient notebook and removes the cache entry
4. `render()` runs with the project context, which creates a fresh target/notebook
5. Browser reloads

The project context persists across all re-renders. Only the per-file cache entry is invalidated.

## FileInformationCache and invalidateForFile

`FileInformationCacheMap` stores per-file cached data:

| Field | Content | Cost of re-computation |
|-------|---------|----------------------|
| `fullMarkdown` | Expanded markdown with includes | Re-reads file, re-expands includes |
| `includeMap` | Include source→target mappings | Recomputed with markdown |
| `codeCells` | Parsed code cells | Recomputed from markdown |
| `engine` | Execution engine instance | Re-determined |
| `target` | Execution target (includes `.quarto_ipynb` path) | Re-created by `target()` |
| `metadata` | YAML front matter | Recomputed from markdown |
| `brand` | Resolved `_brand.yml` data | Re-loaded from disk |

### invalidateForFile() (added for #14281)

Before each preview re-render, the cache entry for the changed file must be invalidated so fresh content is picked up. `invalidateForFile()` does two things:

1. Deletes any transient `.quarto_ipynb` file from disk (if the cached target is transient)
2. Removes the cache entry

Without step 1, the Jupyter engine's `target()` function sees the old file on disk and its collision-avoidance loop creates numbered variants (`_1`, `_2`, etc.) that accumulate.

### cleanupFileInformationCache()

Called at project cleanup (preview exit). Delegates to `invalidateForFile()` for each cache entry, removing all transient files and clearing the cache. This is the final cleanup — `invalidateForFile()` handles per-render cleanup for individual files.

## Transient Notebook Lifecycle (.quarto_ipynb)

When rendering a `.qmd` with a Jupyter kernel, the engine creates a transient `.ipynb` notebook:

1. `target()` in `jupyter.ts` generates the path: `{stem}.quarto_ipynb`
2. If the file already exists, a collision-avoidance loop appends `_1`, `_2`, etc.
3. The target is marked `data: { transient: true }`
4. `execute()` runs the notebook through Jupyter
5. `cleanupNotebook()` flips `transient = false` if `keep-ipynb: true`
6. At preview exit, `cleanupFileInformationCache()` deletes files where `transient = true`

## Context Computation Count (Summary)

| Scenario | Startup computations | Per-change |
|----------|---------------------|------------|
| Single file, no project | 1 (cmd.ts, passed to preview) | 0 (cached project reused) |
| Single file in serveable project | 1 (cmd.ts, passed to serveProject) | See project rows |
| Project directory | 1 (serve.ts) | See project rows |
| Project: single input changed | — | 1 (render() without pContext) |
| Project: multiple inputs changed | — | 0 (renderProject reuses cached) |
| Project: config file changed (HTML) | — | 1 (refreshProjectConfig) |

## Key Files

| File | Purpose |
|------|---------|
| `src/command/preview/cmd.ts` | CLI handler, routing state machine |
| `src/command/preview/preview.ts` | Single-file preview lifecycle, `renderForPreview()`, `previewFormat()` |
| `src/project/serve/serve.ts` | `serveProject()` — project preview server |
| `src/project/serve/watch.ts` | `watchProject()` — file watcher, `refreshProjectConfig()` |
| `src/command/render/render-shared.ts` | `render()` — accepts optional `pContext` |
| `src/command/render/render-contexts.ts` | `renderContexts()`, `renderFormats()` — calls `fileExecutionEngineAndTarget()` |
| `src/execute/engine.ts` | `fileExecutionEngineAndTarget()` — caching wrapper |
| `src/execute/jupyter/jupyter.ts` | `target()` — creates `.quarto_ipynb`, collision-avoidance loop |
| `src/project/project-shared.ts` | `FileInformationCacheMap`, `invalidateForFile()`, `cleanupFileInformationCache()` |
| `src/project/types/single-file/single-file.ts` | `singleFileProjectContext()` — creates minimal context |
5 changes: 5 additions & 0 deletions news/changelog-1.10.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ All changes included in 1.10:
## Regression fixes

- ([#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.

## Formats

Expand All @@ -12,6 +13,10 @@ All changes included in 1.10:

## Commands

### `quarto preview`

- ([#14281](https://github.com/quarto-dev/quarto-cli/issues/14281)): Avoid creating a duplicate `.quarto_ipynb` file on preview startup for single-file Jupyter documents.

### `quarto create`

- ([#14250](https://github.com/quarto-dev/quarto-cli/issues/14250)): Fix `quarto create` producing read-only files when Quarto is installed via system packages (e.g., `.deb`). Files copied from installed resources now have user-write permission ensured.
Expand Down
5 changes: 3 additions & 2 deletions src/command/preview/cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,10 +274,11 @@ export const previewCommand = new Command()
// and convert the render to a project one
let touchPath: string | undefined;
let projectTarget: string | ProjectContext = file;
let project: ProjectContext | undefined;
if (Deno.statSync(file).isFile) {
// get project and preview format
const nbContext = notebookContext();
const project = (await projectContext(dirname(file), nbContext)) ||
project = (await projectContext(dirname(file), nbContext)) ||
(await singleFileProjectContext(file, nbContext));
const formats = await (async () => {
const services = renderServices(nbContext);
Expand Down Expand Up @@ -431,6 +432,6 @@ export const previewCommand = new Command()
[kProjectWatchInputs]: options.watchInputs,
timeout: options.timeout,
presentation: options.presentation,
});
}, project);
}
});
14 changes: 9 additions & 5 deletions src/command/preview/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,13 @@ export async function preview(
flags: RenderFlags,
pandocArgs: string[],
options: PreviewOptions,
pProject?: ProjectContext,
) {
const nbContext = notebookContext();
// see if this is project file
const project = (await projectContext(file, nbContext)) ||
// Reuse the project context from cmd.ts if provided, avoiding redundant
// context creation and transient notebook file duplication (#14281).
const nbContext = pProject?.notebookContext ?? notebookContext();
const project = pProject ??
(await projectContext(file, nbContext)) ??
(await singleFileProjectContext(file, nbContext));
onCleanup(() => {
project.cleanup();
Expand Down Expand Up @@ -427,9 +430,10 @@ export async function renderForPreview(
// Invalidate file cache for the file being rendered so changes are picked up.
// The project context persists across re-renders in preview mode, but the
// fileInformationCache contains file content that needs to be refreshed.
// TODO(#13955): Consider adding a dedicated invalidateForFile() method on ProjectContext
// Uses invalidateForFile() to also clean up transient notebook files
// (.quarto_ipynb) from disk before removing the cache entry (#14281).
if (project?.fileInformationCache) {
project.fileInformationCache.delete(file);
project.fileInformationCache.invalidateForFile(file);
}

// render
Expand Down
Loading
Loading