diff --git a/.claude/commands/quarto-preview-test/SKILL.md b/.claude/commands/quarto-preview-test/SKILL.md new file mode 100644 index 00000000000..11f00b8694b --- /dev/null +++ b/.claude/commands/quarto-preview-test/SKILL.md @@ -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 --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 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. diff --git a/.claude/rules/testing/typescript-tests.md b/.claude/rules/testing/typescript-tests.md index 42870a00e7e..5c301dc5534 100644 --- a/.claude/rules/testing/typescript-tests.md +++ b/.claude/rules/testing/typescript-tests.md @@ -92,3 +92,20 @@ const fixtureDir = docs("my-fixture"); // → tests/docs/my-fixture/ - `tests/docs//` - 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 +``` diff --git a/llm-docs/preview-architecture.md b/llm-docs/preview-architecture.md new file mode 100644 index 00000000000..6d0219f1732 --- /dev/null +++ b/llm-docs/preview-architecture.md @@ -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 | diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 86270480531..ad35e91abd3 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -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 @@ -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. diff --git a/src/command/preview/cmd.ts b/src/command/preview/cmd.ts index b37e8ea4c80..8e34d5549c3 100644 --- a/src/command/preview/cmd.ts +++ b/src/command/preview/cmd.ts @@ -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); @@ -431,6 +432,6 @@ export const previewCommand = new Command() [kProjectWatchInputs]: options.watchInputs, timeout: options.timeout, presentation: options.presentation, - }); + }, project); } }); diff --git a/src/command/preview/preview.ts b/src/command/preview/preview.ts index 7b85cafd0e8..f65825add24 100644 --- a/src/command/preview/preview.ts +++ b/src/command/preview/preview.ts @@ -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(); @@ -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 diff --git a/src/project/project-shared.ts b/src/project/project-shared.ts index cf121d63d88..7f8d2d53015 100644 --- a/src/project/project-shared.ts +++ b/src/project/project-shared.ts @@ -22,6 +22,7 @@ import { readAndValidateYamlFromFile } from "../core/schema/validated-yaml.ts"; import { FileInclusion, FileInformation, + FileInformationCache, kProjectOutputDir, kProjectType, ProjectConfig, @@ -660,7 +661,7 @@ export async function projectResolveBrand( // Implements Cloneable but shares state intentionally - in preview mode, // the project context is reused across renders and cache state must persist. export class FileInformationCacheMap extends Map - implements Cloneable> { + implements FileInformationCache, Cloneable> { override get(key: string): FileInformation | undefined { return super.get(normalizePath(key)); } @@ -681,6 +682,22 @@ export class FileInformationCacheMap extends Map // return normalized keys as stored. Code iterating over the cache sees // normalized paths, which is consistent with how keys are stored. + // Removes a cache entry and cleans up any associated transient files. + // In preview mode, this should be used instead of delete() to ensure + // transient notebooks (.quarto_ipynb) are removed from disk before the + // cache entry is dropped. Without this, the collision-avoidance logic + // in jupyter.ts target() creates numbered variants on each re-render. + invalidateForFile(key: string): void { + const existing = this.get(key); + if (existing?.target?.data) { + const data = existing.target.data as { transient?: boolean }; + if (data.transient && existing.target.input) { + safeRemoveSync(existing.target.input); + } + } + this.delete(key); + } + // Returns this instance (shared reference) rather than a copy. // This is intentional: in preview mode, project context is cloned for // each render but the cache must be shared so invalidations persist. @@ -690,16 +707,9 @@ export class FileInformationCacheMap extends Map } export function cleanupFileInformationCache(project: ProjectContext) { - project.fileInformationCache.forEach((entry) => { - if (entry?.target?.data) { - const data = entry.target.data as { - transient?: boolean; - }; - if (data.transient && entry.target?.input) { - safeRemoveSync(entry.target?.input); - } - } - }); + for (const key of [...project.fileInformationCache.keys()]) { + project.fileInformationCache.invalidateForFile(key); + } } export async function withProjectCleanup( diff --git a/src/project/types.ts b/src/project/types.ts index 2c4948dc182..d36f04f2282 100644 --- a/src/project/types.ts +++ b/src/project/types.ts @@ -68,6 +68,13 @@ export type FileInformation = { brand?: LightDarkBrandDarkFlag; }; +export interface FileInformationCache extends Map { + // Removes a cache entry and cleans up any associated transient files from disk. + // Use this instead of delete() when invalidating entries that may reference + // transient notebooks (.quarto_ipynb) to prevent file accumulation. + invalidateForFile(key: string): void; +} + export interface ProjectContext extends Cloneable { dir: string; engines: string[]; @@ -76,7 +83,7 @@ export interface ProjectContext extends Cloneable { notebookContext: NotebookContext; outputNameIndex?: Map; - fileInformationCache: Map; + fileInformationCache: FileInformationCache; // This is a cache of _brand.yml for a project brandCache?: { brand?: LightDarkBrandDarkFlag }; @@ -182,7 +189,7 @@ export interface EngineProjectContext { * For file information cache management * Used for the transient notebook tracking in Jupyter */ - fileInformationCache: Map; + fileInformationCache: FileInformationCache; /** * Get the output directory for the project diff --git a/tests/docs/manual/preview/14281-quarto-ipynb-accumulation.qmd b/tests/docs/manual/preview/14281-quarto-ipynb-accumulation.qmd new file mode 100644 index 00000000000..e3286110aab --- /dev/null +++ b/tests/docs/manual/preview/14281-quarto-ipynb-accumulation.qmd @@ -0,0 +1,39 @@ +--- +title: "Manual Test: quarto_ipynb accumulation during preview (#14281)" +format: html +execute: + keep-ipynb: false +--- + +## Purpose + +This document tests that `.quarto_ipynb` files do not accumulate during +`quarto preview`. Before the fix, each save during preview created a new +numbered variant (`test.quarto_ipynb_1`, `test.quarto_ipynb_2`, etc.) +that was never cleaned up. + +**Why manual?** Preview re-render behavior requires an interactive session +with file-save events that cannot be triggered in automated smoke tests. + +## Steps + +1. Run: `quarto preview tests/docs/manual/preview/14281-quarto-ipynb-accumulation.qmd` +2. Wait for the preview to open in the browser +3. Edit this file (e.g., change the text below) and save 3-4 times +4. Check this directory for `.quarto_ipynb` files + +## Expected + +- Only one `14281-quarto-ipynb-accumulation.quarto_ipynb` file should exist + at any time during preview (no `_1`, `_2` suffixes) +- After stopping preview (Ctrl+C), the `.quarto_ipynb` file should be cleaned up + +## Code cell + +```{python} +1 + 1 +``` + +Edit the line below to trigger re-renders: + +Current value: 11 diff --git a/tests/docs/manual/preview/README.md b/tests/docs/manual/preview/README.md new file mode 100644 index 00000000000..55b1d71d83e --- /dev/null +++ b/tests/docs/manual/preview/README.md @@ -0,0 +1,167 @@ +# 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 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/keep-ipynb.qmd b/tests/docs/manual/preview/keep-ipynb.qmd new file mode 100644 index 00000000000..64b0e418f0b --- /dev/null +++ b/tests/docs/manual/preview/keep-ipynb.qmd @@ -0,0 +1,15 @@ +--- +title: "Keep ipynb Test" +execute: + keep-ipynb: true +--- + +## T7: keep-ipynb: true + +File should persist after preview exit. No _1 variants during preview. + +```{python} +print("keep-ipynb test") +``` + +Edit counter: 0 diff --git a/tests/docs/manual/preview/knitr.qmd b/tests/docs/manual/preview/knitr.qmd new file mode 100644 index 00000000000..df447000a08 --- /dev/null +++ b/tests/docs/manual/preview/knitr.qmd @@ -0,0 +1,14 @@ +--- +title: "Knitr Test" +engine: knitr +--- + +## T10: R/knitr engine + +No .quarto_ipynb should be created. Knitr does not use Jupyter. + +```{r} +print("Hello from R") +``` + +Edit counter: 0 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/docs/manual/preview/project-preview/.gitignore b/tests/docs/manual/preview/project-preview/.gitignore new file mode 100644 index 00000000000..ad293093b07 --- /dev/null +++ b/tests/docs/manual/preview/project-preview/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +**/*.quarto_ipynb diff --git a/tests/docs/manual/preview/project-preview/_quarto.yml b/tests/docs/manual/preview/project-preview/_quarto.yml new file mode 100644 index 00000000000..cb3cb6e57e8 --- /dev/null +++ b/tests/docs/manual/preview/project-preview/_quarto.yml @@ -0,0 +1,19 @@ +project: + type: website + +website: + title: "Preview Test Project" + navbar: + left: + - href: index.qmd + text: Home + - about.qmd + +format: + html: + theme: cosmo + css: styles.css + toc: true + + + diff --git a/tests/docs/manual/preview/project-preview/about.qmd b/tests/docs/manual/preview/project-preview/about.qmd new file mode 100644 index 00000000000..07c5e7f9d13 --- /dev/null +++ b/tests/docs/manual/preview/project-preview/about.qmd @@ -0,0 +1,5 @@ +--- +title: "About" +--- + +About this site diff --git a/tests/docs/manual/preview/project-preview/index.qmd b/tests/docs/manual/preview/project-preview/index.qmd new file mode 100644 index 00000000000..3f307004cd9 --- /dev/null +++ b/tests/docs/manual/preview/project-preview/index.qmd @@ -0,0 +1,11 @@ +--- +title: "Preview Test Project" +--- + +## T3/T4: Project preview with Python cell + +```{python} +print("Hello from project preview") +``` + +Edit counter: 0 diff --git a/tests/docs/manual/preview/project-preview/styles.css b/tests/docs/manual/preview/project-preview/styles.css new file mode 100644 index 00000000000..2ddf50c7b42 --- /dev/null +++ b/tests/docs/manual/preview/project-preview/styles.css @@ -0,0 +1 @@ +/* css styles */ diff --git a/tests/unit/project/file-information-cache.test.ts b/tests/unit/project/file-information-cache.test.ts index 9f552e9c96b..d883f021d2a 100644 --- a/tests/unit/project/file-information-cache.test.ts +++ b/tests/unit/project/file-information-cache.test.ts @@ -9,6 +9,8 @@ import { unitTest } from "../../test.ts"; import { assert } from "testing/asserts"; +import { asMappedString } from "../../../src/core/lib/mapped-text.ts"; +import { existsSync } from "../../../src/deno_ral/fs.ts"; import { join, relative } from "../../../src/deno_ral/path.ts"; import { ensureFileInformationCache, @@ -130,3 +132,117 @@ unitTest( ); }, ); + +// deno-lint-ignore require-await +unitTest( + "fileInformationCache - invalidateForFile deletes transient notebook file", + async () => { + const project = createMockProjectContext(); + const sourcePath = join(project.dir, "doc.qmd"); + + // Create a real temp file simulating a transient .quarto_ipynb + const notebookPath = join(project.dir, "doc.quarto_ipynb"); + Deno.writeTextFileSync(notebookPath, '{"cells": []}'); + assert(existsSync(notebookPath), "Temp notebook file should exist"); + + // Populate cache entry with a transient target pointing to the file + const entry = ensureFileInformationCache(project, sourcePath); + entry.target = { + source: sourcePath, + input: notebookPath, + markdown: asMappedString(""), + metadata: {}, + data: { transient: true, kernelspec: {} }, + }; + + // Invalidate the cache entry for this file + project.fileInformationCache.invalidateForFile(sourcePath); + + // The transient file should be deleted from disk + assert( + !existsSync(notebookPath), + "Transient notebook file should be deleted on invalidation", + ); + // The cache entry should be removed + assert( + !project.fileInformationCache.has(sourcePath), + "Cache entry should be removed after invalidation", + ); + }, +); + +// deno-lint-ignore require-await +unitTest( + "fileInformationCache - invalidateForFile preserves non-transient files", + async () => { + const project = createMockProjectContext(); + const sourcePath = join(project.dir, "notebook.ipynb"); + + // Create a real file simulating a user's .ipynb (non-transient) + const notebookPath = join(project.dir, "notebook.ipynb"); + Deno.writeTextFileSync(notebookPath, '{"cells": []}'); + + // Populate cache entry with a non-transient target + const entry = ensureFileInformationCache(project, sourcePath); + entry.target = { + source: sourcePath, + input: notebookPath, + markdown: asMappedString(""), + metadata: {}, + data: { transient: false, kernelspec: {} }, + }; + + // Invalidate the cache entry + project.fileInformationCache.invalidateForFile(sourcePath); + + // The non-transient file should NOT be deleted + assert( + existsSync(notebookPath), + "Non-transient file should be preserved on invalidation", + ); + // But the cache entry should still be removed + assert( + !project.fileInformationCache.has(sourcePath), + "Cache entry should be removed after invalidation", + ); + }, +); + +// deno-lint-ignore require-await +unitTest( + "fileInformationCache - invalidateForFile handles entry with no target", + async () => { + const project = createMockProjectContext(); + const sourcePath = join(project.dir, "doc.qmd"); + + // Populate cache entry with metadata only (no target) + const entry = ensureFileInformationCache(project, sourcePath); + entry.metadata = { title: "Test" }; + + // Should not throw + project.fileInformationCache.invalidateForFile(sourcePath); + + assert( + !project.fileInformationCache.has(sourcePath), + "Cache entry should be removed even without a target", + ); + }, +); + +// deno-lint-ignore require-await +unitTest( + "fileInformationCache - invalidateForFile is a no-op for missing keys", + async () => { + const project = createMockProjectContext(); + + // Should not throw on a key that doesn't exist + project.fileInformationCache.invalidateForFile( + join(project.dir, "nonexistent.qmd"), + ); + + assert( + project.fileInformationCache.size === 0, + "Cache should remain empty", + ); + }, +);