diff --git a/.claude/commands/quarto-preview-test/SKILL.md b/.claude/commands/quarto-preview-test/SKILL.md new file mode 100644 index 00000000000..8f69b72644f --- /dev/null +++ b/.claude/commands/quarto-preview-test/SKILL.md @@ -0,0 +1,143 @@ +--- +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 `tests/configure-test-env.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()` | + +## 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/.github/workflows/actions/quarto-dev/action.yml b/.github/workflows/actions/quarto-dev/action.yml index cc5292af525..42d1aa8884a 100644 --- a/.github/workflows/actions/quarto-dev/action.yml +++ b/.github/workflows/actions/quarto-dev/action.yml @@ -23,9 +23,19 @@ runs: restore-keys: | ${{ runner.os }}-cargo-typst-gather- + - name: Cache Deno development cache + uses: actions/cache@v5 + with: + path: ./package/dist/bin/deno_cache + key: ${{ runner.os }}-${{ runner.arch }}-deno-cache-v1-${{ hashFiles('configuration', 'src/import_map.json', 'src/vendor_deps.ts', 'tests/test-deps.ts', 'package/scripts/deno_std/deno_std.ts') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-deno-cache-v1- + - name: Configure Quarto (.sh) if: runner.os != 'Windows' shell: bash + env: + QUARTO_SKIP_DENO_CACHE_WIPE: "1" run: | # install with symlink in /usr/local/bin which in PATH on CI ./configure.sh @@ -33,6 +43,8 @@ runs: - name: Configure Quarto (.ps1) if: runner.os == 'Windows' shell: pwsh + env: + QUARTO_SKIP_DENO_CACHE_WIPE: "1" run: | ./configure.cmd "$(Get-ChildItem -Path ./package/dist/bin/quarto.cmd | %{ $_.FullName } | Split-Path)" >> $env:GITHUB_PATH diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index f4db596d7bf..12ec4f5a757 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -89,7 +89,7 @@ jobs: run: | echo ${{ steps.read-version.outputs.version_full }} > version.txt - - uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 + - uses: EndBug/add-and-commit@290ea2c423ad77ca9c62ae0f5b224379612c0321 if: ${{ inputs.publish-release }} id: version_commit with: @@ -677,9 +677,7 @@ jobs: - name: Create Release id: create_release - uses: softprops/action-gh-release@4634c16e79c963813287e889244c50009e7f0981 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe with: tag_name: ${{needs.configure.outputs.tag_name}} target_commitish: ${{ needs.configure.outputs.version_commit }} diff --git a/.github/workflows/test-ff-matrix.yml b/.github/workflows/test-ff-matrix.yml index 8214caa5724..29645fcd5bd 100644 --- a/.github/workflows/test-ff-matrix.yml +++ b/.github/workflows/test-ff-matrix.yml @@ -20,6 +20,7 @@ on: - ".github/workflows/stale-needs-repro.yml" - ".github/workflows/test-bundle.yml" - ".github/workflows/test-smokes-parallel.yml" + - ".github/workflows/test-install.yml" - ".github/workflows/test-quarto-latexmk.yml" - ".github/workflows/update-test-timing.yml" pull_request: @@ -31,6 +32,7 @@ on: - ".github/workflows/performance-check.yml" - ".github/workflows/stale-needs-repro.yml" - ".github/workflows/test-bundle.yml" + - ".github/workflows/test-install.yml" - ".github/workflows/test-smokes-parallel.yml" - ".github/workflows/test-quarto-latexmk.yml" - ".github/workflows/update-test-timing.yml" diff --git a/.github/workflows/test-install.yml b/.github/workflows/test-install.yml new file mode 100644 index 00000000000..d4967035f9d --- /dev/null +++ b/.github/workflows/test-install.yml @@ -0,0 +1,134 @@ +# Integration test for `quarto install` on platforms not covered by smoke tests. +# Smoke tests (test-smokes.yml) cover x86_64 Linux and Windows. +# This workflow fills the gap for arm64 Linux and macOS. +name: Test Tool Install +on: + workflow_dispatch: + push: + branches: + - main + - "v1.*" + paths: + - "src/tools/**" + - ".github/workflows/test-install.yml" + pull_request: + paths: + - "src/tools/**" + - ".github/workflows/test-install.yml" + schedule: + # Weekly Monday 9am UTC — detect upstream CDN/API breakage + - cron: "0 9 * * 1" + +permissions: + contents: read + +jobs: + test-install: + name: Install tools (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04-arm, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Repo + uses: actions/checkout@v6 + + - uses: ./.github/workflows/actions/quarto-dev + + - name: Install TinyTeX + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + quarto install tinytex + + - name: Install Chrome Headless Shell + run: | + quarto install chrome-headless-shell --no-prompt + + - name: Verify tools with quarto check + run: | + quarto check install + + test-chromium-deprecation: + name: Chromium deprecation warning (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout Repo + uses: actions/checkout@v6 + + - uses: ./.github/workflows/actions/quarto-dev + + - name: Make quarto available in bash (Windows) + if: runner.os == 'Windows' + shell: bash + run: | + quarto_cmd=$(command -v quarto.cmd) + dir=$(dirname "$quarto_cmd") + printf '#!/bin/bash\nexec "%s" "$@"\n' "$quarto_cmd" > "$dir/quarto" + chmod +x "$dir/quarto" + + - name: Install chromium and capture result + id: install-chromium + shell: bash + run: | + set +e + output=$(quarto install chromium --no-prompt 2>&1) + exit_code=$? + set -e + echo "$output" + if echo "$output" | grep -Fq "is deprecated"; then + echo "deprecation-warning=true" >> "$GITHUB_OUTPUT" + fi + if [ "$exit_code" -eq 0 ]; then + echo "chromium-installed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Assert install deprecation warning was shown + shell: bash + run: | + if [ "${{ steps.install-chromium.outputs.deprecation-warning }}" != "true" ]; then + echo "::error::Deprecation warning missing from quarto install chromium output" + exit 1 + fi + echo "Install deprecation warning found" + + - name: Update chromium and capture result + id: update-chromium + shell: bash + run: | + set +e + output=$(quarto update chromium --no-prompt 2>&1) + set -e + echo "$output" + if echo "$output" | grep -Fq "is deprecated"; then + echo "deprecation-warning=true" >> "$GITHUB_OUTPUT" + fi + + - name: Assert update deprecation warning was shown + shell: bash + run: | + if [ "${{ steps.update-chromium.outputs.deprecation-warning }}" != "true" ]; then + echo "::error::Deprecation warning missing from quarto update chromium output" + exit 1 + fi + echo "Update deprecation warning found" + + - name: Verify quarto check warns about outdated Chromium + shell: bash + run: | + if [ "${{ steps.install-chromium.outputs.chromium-installed }}" != "true" ]; then + echo "Chromium install did not succeed on this platform, skipping check" + exit 0 + fi + output=$(quarto check install 2>&1) + echo "$output" + if ! echo "$output" | grep -Fq "Chromium is outdated"; then + echo "::error::Outdated Chromium warning missing from quarto check" + exit 1 + fi + echo "Outdated Chromium warning found in quarto check" diff --git a/.github/workflows/test-smokes-parallel.yml b/.github/workflows/test-smokes-parallel.yml index 48e02401135..1f4185b4944 100644 --- a/.github/workflows/test-smokes-parallel.yml +++ b/.github/workflows/test-smokes-parallel.yml @@ -29,6 +29,7 @@ on: - ".github/workflows/stale-needs-repro.yml" - ".github/workflows/test-bundle.yml" - ".github/workflows/test-ff-matrix.yml" + - ".github/workflows/test-install.yml" - ".github/workflows/test-quarto-latexmk.yml" - ".github/workflows/update-test-timing.yml" push: diff --git a/.github/workflows/test-smokes.yml b/.github/workflows/test-smokes.yml index bd70ac69648..9195ec07ff7 100644 --- a/.github/workflows/test-smokes.yml +++ b/.github/workflows/test-smokes.yml @@ -217,13 +217,13 @@ jobs: quarto install chrome-headless-shell --no-prompt - name: Setup Julia - uses: julia-actions/setup-julia@v2 + uses: julia-actions/setup-julia@a8c65a2a580b6a5cf30070e825e62f9fc0fee1d7 id: setup-julia with: version: "1.11.7" - name: Load Julia packages from cache - uses: julia-actions/cache@v2 + uses: julia-actions/cache@v3 with: cache-name: julia-cache;workflow=${{ github.workflow }};job=${{ github.job }};julia=${{ steps.setup-julia.outputs.julia-version }} diff --git a/configure.cmd b/configure.cmd index f1eccc9c3ed..6c7d3632940 100644 --- a/configure.cmd +++ b/configure.cmd @@ -15,27 +15,31 @@ if NOT DEFINED QUARTO_VENDOR_BINARIES ( if "%QUARTO_VENDOR_BINARIES%" == "true" ( - REM Windows-specific: Check if deno.exe is running before deleting package/dist - REM Extracted to package/scripts/windows/check-deno-in-use.cmd for maintainability - call package\scripts\windows\check-deno-in-use.cmd "!QUARTO_DIST_PATH!" - if "!ERRORLEVEL!"=="1" exit /B 1 - - echo Removing package/dist/ directory... - RMDIR /S /Q "!QUARTO_DIST_PATH!" 2>NUL - - REM Fallback: Verify deletion succeeded (defense in depth) - if exist "!QUARTO_DIST_PATH!" ( - echo. - echo ============================================================ - echo Error: Could not delete package/dist/ directory - echo This may be due to permissions, antivirus, or another process holding files - echo ============================================================ - echo. - echo Try closing applications and run configure.cmd again - exit /B 1 + REM CI sets QUARTO_SKIP_DENO_CACHE_WIPE=1 so the restored deno_cache survives + REM (matches the gate used by package\scripts\vendoring\vendor.cmd). + IF NOT "!QUARTO_SKIP_DENO_CACHE_WIPE!"=="1" ( + REM Windows-specific: Check if deno.exe is running before deleting package/dist + REM Extracted to package/scripts/windows/check-deno-in-use.cmd for maintainability + call package\scripts\windows\check-deno-in-use.cmd "!QUARTO_DIST_PATH!" + if "!ERRORLEVEL!"=="1" exit /B 1 + + echo Removing package/dist/ directory... + RMDIR /S /Q "!QUARTO_DIST_PATH!" 2>NUL + + REM Fallback: Verify deletion succeeded (defense in depth) + if exist "!QUARTO_DIST_PATH!" ( + echo. + echo ============================================================ + echo Error: Could not delete package/dist/ directory + echo This may be due to permissions, antivirus, or another process holding files + echo ============================================================ + echo. + echo Try closing applications and run configure.cmd again + exit /B 1 + ) ) - MKDIR !QUARTO_BIN_PATH!\tools + IF NOT EXIST "!QUARTO_BIN_PATH!\tools" MKDIR !QUARTO_BIN_PATH!\tools PUSHD !QUARTO_BIN_PATH!\tools ECHO Bootstrapping Deno... diff --git a/configure.sh b/configure.sh index 64f54e790e0..d505fd68dd2 100755 --- a/configure.sh +++ b/configure.sh @@ -37,7 +37,11 @@ if [[ "${QUARTO_VENDOR_BINARIES}" = "true" ]]; then # Ensure directory is there for Deno echo "Bootstrapping Deno..." - rm -rf "$QUARTO_DIST_PATH" + # CI sets QUARTO_SKIP_DENO_CACHE_WIPE=1 so the restored deno_cache survives + # (matches the gate used by package/scripts/vendoring/vendor.sh). + if [ "${QUARTO_SKIP_DENO_CACHE_WIPE}" != "1" ]; then + rm -rf "$QUARTO_DIST_PATH" + fi ## Binary Directory mkdir -p "$QUARTO_BIN_PATH/tools" diff --git a/dev-docs/ci-deno-caching.md b/dev-docs/ci-deno-caching.md new file mode 100644 index 00000000000..b368d127745 --- /dev/null +++ b/dev-docs/ci-deno-caching.md @@ -0,0 +1,107 @@ +# CI Deno cache + +How the Quarto development Deno cache is cached in GitHub Actions, how +invalidation works, and how to force a fresh download. + +## What gets cached + +Two Deno caches are restored by the `quarto-dev` composite action +(`.github/workflows/actions/quarto-dev/action.yml`): + +| Cache | Path | Populated by | +|-------|------|--------------| +| Deno standard library | `./src/resources/deno_std/cache` | `package/scripts/deno_std/deno_std.ts` | +| Deno development cache | `./package/dist/bin/deno_cache` | `package/scripts/vendoring/vendor.sh` / `vendor.cmd` | + +This document covers the second one. The first predates it and is keyed on +`deno_std.lock` + `deno_std.ts`. + +## Cache key + +``` +${{ runner.os }}-${{ runner.arch }}-deno-cache-v1- +``` + +`` is `hashFiles()` over five files: + +| File | Why it's in the key | +|------|---------------------| +| `configuration` | Pins the Deno binary version. | +| `src/import_map.json` | Top-level dep version map — the primary driver of what gets downloaded. | +| `src/vendor_deps.ts` | Explicit "things to vendor" entrypoint. | +| `tests/test-deps.ts` | Test-only deps entrypoint (iterated by vendor.sh / vendor.cmd). | +| `package/scripts/deno_std/deno_std.ts` | deno_std entrypoint iterated by the vendor scripts. | + +`restore-keys` falls back to the same prefix without the hash suffix, so a +partial-match cache from a sibling branch is reused if the exact key misses. + +### Not in the key + +- `quarto.ts` and other `src/**/*.ts` files: these are consumer code, not + dep-resolution inputs. `deno install` is additive on top of an existing + cache, so new imports in consumer code download without needing a key change. + Hashing `src/**` would invalidate on every commit. +- `vendor.sh` / `vendor.cmd`: they control *how* `deno install` runs, not + *what* it resolves. Including them would cross-invalidate (a Unix-only + `vendor.sh` edit would bust the Windows cache too, since `hashFiles()` is + OS-independent). +- `deno.lock` / `tests/deno.lock`: both are gitignored (`.gitignore:26`) and + are generated by `deno install` at run time, so they do not exist on a + fresh checkout and cannot serve as key inputs. + +## Wipe gate + +`vendor.sh` and `vendor.cmd` delete `./package/dist/bin/deno_cache` before +running `deno install`. That default is preserved for local development. In +CI we need the opposite — the cache was just restored by `actions/cache` and +must survive until `deno install` uses it. + +The coordination uses a single environment variable: + +- **Name:** `QUARTO_SKIP_DENO_CACHE_WIPE` +- **Truthy value:** `"1"` (anything else, including unset, means "wipe"). +- **Set by:** both configure steps in `action.yml` (`env: QUARTO_SKIP_DENO_CACHE_WIPE: "1"`). +- **Read by:** `vendor.sh` and `vendor.cmd`. Nothing else. +- **Do not set locally.** The variable only makes sense when paired with a + restore step. Without one, skipping the wipe just leaves a stale cache on + disk. + +## Forcing invalidation + +### When it happens automatically + +Editing any of the five keyed files changes the hash, which produces a new +cache key. Next CI run misses, re-downloads, then saves under the new key. +Old keys age out via GitHub's 7-day LRU. + +### When you have to force it manually + +Only if the cache contents go bad in a way the key can't detect — e.g. a +partial download that wasn't corrected, or a silent shape change from a new +Deno version that still resolves to the same `configuration` string. In that +case, bump the `-v1-` version prefix in both the `key` and `restore-keys` of +the cache step in `action.yml`: + +```diff +- key: ${{ runner.os }}-${{ runner.arch }}-deno-cache-v1-... ++ key: ${{ runner.os }}-${{ runner.arch }}-deno-cache-v2-... + restore-keys: | +- ${{ runner.os }}-${{ runner.arch }}-deno-cache-v1- ++ ${{ runner.os }}-${{ runner.arch }}-deno-cache-v2- +``` + +This avoids editing any of the five hashed files (which may not even be +involved in the regression) and leaves a clear audit trail in git history. + +## Rollback + +- **Cache misbehaves:** delete the "Cache Deno development cache" step in + `action.yml`. `configure.sh` / `configure.cmd` run `vendor.sh` / + `vendor.cmd` as before and redownload from scratch. +- **Wipe gate misbehaves:** remove the `QUARTO_SKIP_DENO_CACHE_WIPE` env + from the configure steps. Vendor scripts fall back to the default wipe. + +## Expected footprint + +~150–200 MB per OS, three OSes → ~600 MB total repo cache usage, well under +GitHub's 10 GB per-repo quota. diff --git a/dev-docs/upgrade-dependencies.md b/dev-docs/upgrade-dependencies.md index fe52814715d..6c888c15c87 100644 --- a/dev-docs/upgrade-dependencies.md +++ b/dev-docs/upgrade-dependencies.md @@ -12,6 +12,8 @@ Contact Carlos so he uploads the binaries to the S3 bucket. - run `./configure.sh`. +Bumping a version in `src/import_map.json` (or any of the other keyed files) automatically invalidates the CI Deno cache on next run. See [ci-deno-caching.md](ci-deno-caching.md) for the key composition and how to force invalidation manually. + ### Upgrade Deno download link for RHEL build from conda-forge - Go to and find the version of Deno required. diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index 1e13acd0afd..8fc84d8a18a 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -1,3 +1,21 @@ +# v1.10 backports + +## In this release + +- ([#14304](https://github.com/quarto-dev/quarto-cli/issues/14304)): Fix `quarto install tinytex` silently ignoring extraction failures. When archive extraction fails (e.g., `.tar.xz` on a system without `xz-utils`), the installer now reports a clear error instead of proceeding and failing with a confusing `NotFound` message. +- ([#14367](https://github.com/quarto-dev/quarto-cli/issues/14367), [#14534](https://github.com/quarto-dev/quarto-cli/issues/14534)): Fix Dart Sass invocation failing on Windows when the user profile path contains `&` (e.g., `C:\Users\Tom & Jerry\`) and on enterprise systems where Group Policy blocks `.bat` execution from `%TEMP%`. Quarto now invokes the bundled `dart.exe` with `sass.snapshot` directly, bypassing `sass.bat` and `cmd.exe` entirely. +- ([#14489](https://github.com/quarto-dev/quarto-cli/issues/14489)): Restore `--output-dir` support for `quarto preview` of single files when no `_quarto.yml` is present (e.g. R-package workspaces). Regression introduced in v1.9.18. + +## In previous releases + +- ([#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. +- ([rstudio/rstudio#17333](https://github.com/rstudio/rstudio/issues/17333)): Fix `quarto inspect` on standalone files emitting project metadata that breaks RStudio's publishing wizard. +- ([#14334](https://github.com/quarto-dev/quarto-cli/pull/14334), [#9710](https://github.com/quarto-dev/quarto-cli/issues/9710)): Add arm64 Linux support for `quarto install chrome-headless-shell` using Playwright CDN as download source. `quarto install chromium` and `quarto update chromium` now show a deprecation warning — use `chrome-headless-shell` instead, which always installs the latest stable Chrome (the legacy `chromium` installer pins an outdated Puppeteer revision that cannot receive security updates). `quarto check install` also warns when legacy Chromium is detected. + +# v1.9 changes + All changes included in 1.9: ## Shortcodes @@ -79,12 +97,13 @@ All changes included in 1.9: - ([#13775](https://github.com/quarto-dev/quarto-cli/issues/13775)): Fix brand fonts not being applied when using `citeproc: true` with Typst format. Format detection now properly handles Pandoc format variants like `typst-citations`. - ([#13868](https://github.com/quarto-dev/quarto-cli/issues/13868)): Add image alt text support for PDF/UA accessibility. Alt text from markdown captions and explicit `alt` attributes is now passed to Typst's `image()` function. (Temporary workaround until [jgm/pandoc#11394](https://github.com/jgm/pandoc/pull/11394) is merged.) - ([#13870](https://github.com/quarto-dev/quarto-cli/issues/13870)): Add support for `alt` attribute on cross-referenced equations for improved accessibility. (author: @mcanouil) +- ([#13878](https://github.com/quarto-dev/quarto-cli/issues/13878)): Typst now uses Pandoc's skylighting for syntax highlighting by default (consistent with other formats). Use `syntax-highlighting: idiomatic` to opt-in to Typst's native syntax highlighting instead. +- ([#13879](https://github.com/quarto-dev/quarto-cli/pull/13879)): Add Tufte-style margin layout for Typst using the marginalia package. Supports `.column-margin` for margin notes, figures, tables, and captions; `.column-screen` and `.column-page` for full-width content; `citation-location: margin`; `reference-location: margin` for sidenotes; `suppress-bibliography`; `cap-location: margin`, `fig-cap-location: margin`, and `tbl-cap-location: margin`; `grid` options (`margin-width`, `body-width`, `gutter-width`); and `margin-geometry` for fine-grained layout control. - ([#13917](https://github.com/quarto-dev/quarto-cli/issues/13917)): Fix brand logo paths not resolving correctly for Typst documents in project subdirectories. Brand logo paths are now converted to project-absolute paths before merging with document metadata, replacing the fragile `projectOffset()` workaround. - ([#13942](https://github.com/quarto-dev/quarto-cli/issues/13942)): Fix Typst compilation errors showing confusing internal stack traces. Users now see only the relevant Typst error message. - ([#13950](https://github.com/quarto-dev/quarto-cli/pull/13950)): Replace ctheorems with theorion package for theorem environments. Add `theorem-appearance` option to control styling: `simple` (default, classic LaTeX style), `fancy` (colored boxes with brand colors), `clouds` (rounded backgrounds), or `rainbow` (colored start border and colored title). - ([#13954](https://github.com/quarto-dev/quarto-cli/issues/13954)): Add support for Typst book projects via format extensions. Quarto now bundles the `orange-book` extension which provides a textbook-style format with chapter numbering, cross-references, and professional styling. Book projects with `format: typst` automatically use this extension. - ([#13978](https://github.com/quarto-dev/quarto-cli/pull/13978)): Keep term and description together in definition lists to avoid breaking across pages. (author: @mcanouil) -- ([#13878](https://github.com/quarto-dev/quarto-cli/issues/13878)): Typst now uses Pandoc's skylighting for syntax highlighting by default (consistent with other formats). Use `syntax-highlighting: idiomatic` to opt-in to Typst's native syntax highlighting instead. - ([#14126](https://github.com/quarto-dev/quarto-cli/issues/14126)): Fix Skylighting code blocks in Typst lacking full-width background, padding, and border radius. A postprocessor patches the Pandoc-generated Skylighting function to add `width: 100%`, `inset: 8pt`, and `radius: 2pt` to the block call, matching the styling of native code blocks. Brand `monospace-block.background-color` also now correctly applies to Skylighting output. This workaround will be removed once the fix is upstreamed to Skylighting. - ([#14202](https://github.com/quarto-dev/quarto-cli/issues/14202)): Fix CSS inlining (`juice`) failing on Windows when Quarto is installed in a path with spaces (e.g., `C:\Program Files\Quarto\`). diff --git a/package/scripts/vendoring/vendor.cmd b/package/scripts/vendoring/vendor.cmd index f9ab84973fa..e43fae3577b 100644 --- a/package/scripts/vendoring/vendor.cmd +++ b/package/scripts/vendoring/vendor.cmd @@ -18,9 +18,12 @@ IF NOT DEFINED DENO_DIR ( ECHO Revendoring quarto dependencies -REM remove deno_cache directory first +REM remove deno_cache directory first, unless explicitly told to preserve +REM (CI sets QUARTO_SKIP_DENO_CACHE_WIPE=1 so a restored cache survives vendor.cmd) IF EXIST "!DENO_DIR!" ( - RMDIR /S /Q "!DENO_DIR!" + IF NOT "!QUARTO_SKIP_DENO_CACHE_WIPE!"=="1" ( + RMDIR /S /Q "!DENO_DIR!" + ) ) PUSHD "!QUARTO_SRC_PATH!" diff --git a/package/scripts/vendoring/vendor.sh b/package/scripts/vendoring/vendor.sh index 04d899a51ed..350917d8237 100755 --- a/package/scripts/vendoring/vendor.sh +++ b/package/scripts/vendoring/vendor.sh @@ -15,15 +15,26 @@ fi echo Revendoring quarto dependencies -# remove deno_cache directory first -if [ -d "$DENO_DIR" ]; then +# remove deno_cache directory first, unless explicitly told to preserve +# (CI sets QUARTO_SKIP_DENO_CACHE_WIPE=1 so a restored cache survives vendor.sh) +if [ -d "$DENO_DIR" ] && [ "${QUARTO_SKIP_DENO_CACHE_WIPE}" != "1" ]; then rm -rf "$DENO_DIR" fi pushd "${QUARTO_SRC_PATH}" set +e +# QUARTO_VENDOR_LOCK / QUARTO_VENDOR_OFFLINE let packagers building from a +# pre-populated, checksummed DENO_DIR (no network in the build sandbox) force +# resolution to fail loudly on any cache miss instead of silently fetching. +DENO_INSTALL_EXTRA_ARGS=() +if [ -n "$QUARTO_VENDOR_LOCK" ]; then + DENO_INSTALL_EXTRA_ARGS+=("--lock=$QUARTO_VENDOR_LOCK" "--frozen") +fi +if [ "$QUARTO_VENDOR_OFFLINE" = "1" ]; then + DENO_INSTALL_EXTRA_ARGS+=("--cached-only") +fi for entrypoint in quarto.ts vendor_deps.ts ../tests/test-deps.ts ../package/scripts/deno_std/deno_std.ts; do - $DENO_BIN_PATH install --allow-all --no-config --entrypoint $entrypoint "--importmap=$QUARTO_SRC_PATH/import_map.json" + $DENO_BIN_PATH install --allow-all --no-config --entrypoint $entrypoint "--importmap=$QUARTO_SRC_PATH/import_map.json" "${DENO_INSTALL_EXTRA_ARGS[@]}" done set -e popd diff --git a/package/src/linux/installer.ts b/package/src/linux/installer.ts index cd90fd65bcb..366edafc296 100644 --- a/package/src/linux/installer.ts +++ b/package/src/linux/installer.ts @@ -92,11 +92,15 @@ async function createNfpmConfig( // Format-specific configuration if (format === 'deb') { config.overrides.deb = { - recommends: ["unzip"], + recommends: ["unzip", "xz-utils"], }; // Add Debian-specific metadata config.section = "user/text"; config.priority = "optional"; + } else if (format === 'rpm') { + config.overrides.rpm = { + recommends: ["xz"], + }; } return config; } diff --git a/src/command/check/check.ts b/src/command/check/check.ts index 46c6ed15728..ab7fda63976 100644 --- a/src/command/check/check.ts +++ b/src/command/check/check.ts @@ -364,6 +364,11 @@ async function checkInstall(conf: CheckConfiguration) { toolsJson[tool.name] = { version, }; + if (tool.name === "Chromium") { + toolsOutput.push( + `${kIndent} (Chromium is outdated. Run "quarto uninstall chromium" then "quarto install chrome-headless-shell")`, + ); + } } for (const tool of tools.notInstalled) { toolsOutput.push(`${kIndent}${tool.name}: (not installed)`); diff --git a/src/command/preview/preview.ts b/src/command/preview/preview.ts index 7b85cafd0e8..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() && @@ -427,9 +436,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/command/render/render-shared.ts b/src/command/render/render-shared.ts index 473c27498fd..06589b6ca1b 100644 --- a/src/command/render/render-shared.ts +++ b/src/command/render/render-shared.ts @@ -49,10 +49,15 @@ export async function render( // determine target context/files let context = pContext || (await projectContext(path, nbContext, options)); - // Create a synthetic project when --output-dir is used without a project file - // This creates a temporary .quarto directory to manage the render, which must - // be fully cleaned up afterward to avoid leaving debris (see #9745) - if (!context && options.flags?.outputDir) { + // Create a synthetic project when --output-dir is used without a real project. + // Triggers in two situations: + // 1. No context yet (render path: no _quarto.yml found above) + // 2. pContext is a single-file context (preview pre-creates one in + // preview.ts before calling render(), which would otherwise bypass + // synthetic-project creation — regression behind #14489) + // The synthetic project provides a temporary .quarto directory so output-dir + // handling works the same as for real projects (see #9745, #13625). + if (options.flags?.outputDir && (!context || context.isSingleFile)) { context = await projectContextForDirectory(path, nbContext, options); // forceClean signals this is a synthetic project that needs full cleanup diff --git a/src/core/dart-sass.ts b/src/core/dart-sass.ts index 0c8e39c9795..1d08e2d68b9 100644 --- a/src/core/dart-sass.ts +++ b/src/core/dart-sass.ts @@ -1,20 +1,18 @@ /* * dart-sass.ts * - * Copyright (C) 2020-2022 Posit Software, PBC + * Copyright (C) 2020-2026 Posit Software, PBC */ import { join } from "../deno_ral/path.ts"; import { architectureToolsPath } from "./resources.ts"; import { execProcess } from "./process.ts"; -import { ProcessResult } from "./process-types.ts"; import { TempContext } from "./temp.ts"; import { lines } from "./text.ts"; import { debug, info } from "../deno_ral/log.ts"; import { existsSync } from "../deno_ral/fs.ts"; import { warnOnce } from "./log.ts"; import { isWindows } from "../deno_ral/platform.ts"; -import { requireQuoting, safeWindowsExec } from "./windows.ts"; export function dartSassInstallDir() { return architectureToolsPath("dart-sass"); @@ -60,17 +58,39 @@ export async function dartCompile( */ export interface DartCommandOptions { /** - * Override the sass executable path. - * Primarily used for testing with spaced paths. + * Override the dart-sass install directory. + * Used for testing with non-standard paths (spaces, accented characters). */ - sassPath?: string; + installDir?: string; } -export async function dartCommand( - args: string[], - options?: DartCommandOptions, -) { - const resolvePath = () => { +/** + * Resolve the dart-sass command and its base arguments. + * + * On Windows, calls dart.exe + sass.snapshot directly instead of going + * through sass.bat. The bundled sass.bat is a thin wrapper generated by + * dart_cli_pkg that just runs: + * "%SCRIPTPATH%\src\dart.exe" "%SCRIPTPATH%\src\sass.snapshot" %arguments% + * + * Template source: + * https://github.com/google/dart_cli_pkg/blob/main/lib/src/templates/standalone/executable.bat.mustache + * Upstream issue to ship standalone .exe instead of .bat + dart.exe: + * https://github.com/google/dart_cli_pkg/issues/67 + * + * Bypassing sass.bat avoids multiple .bat file issues on Windows: + * - Deno quoting bugs with spaced paths (#13997) + * - cmd.exe OEM code page misreading UTF-8 accented paths (#14267) + * - Enterprise group policy blocking .bat execution (#6651) + */ +function resolveSassCommand(options?: DartCommandOptions): { + cmd: string; + baseArgs: string[]; +} { + const installDir = options?.installDir; + if (installDir == null) { + // Only check env var override when no explicit installDir is provided. + // If QUARTO_DART_SASS doesn't exist on disk, fall through to use the + // bundled dart-sass at the default architectureToolsPath. const dartOverrideCmd = Deno.env.get("QUARTO_DART_SASS"); if (dartOverrideCmd) { if (!existsSync(dartOverrideCmd)) { @@ -78,62 +98,51 @@ export async function dartCommand( `Specified QUARTO_DART_SASS does not exist, using built in dart sass.`, ); } else { - return dartOverrideCmd; + return { cmd: dartOverrideCmd, baseArgs: [] }; } } + } - const command = isWindows ? "sass.bat" : "sass"; - return architectureToolsPath(join("dart-sass", command)); - }; - const sass = options?.sassPath ?? resolvePath(); - - // Process result helper (shared by Windows and non-Windows paths) - const processResult = (result: ProcessResult): string | undefined => { - if (result.success) { - if (result.stderr) { - info(result.stderr); - } - return result.stdout; - } else { - debug(`[DART path] : ${sass}`); - debug(`[DART args] : ${args.join(" ")}`); - debug(`[DART stdout] : ${result.stdout}`); - debug(`[DART stderr] : ${result.stderr}`); - - const errLines = lines(result.stderr || ""); - // truncate the last 2 lines (they include a pointer to the temp file containing - // all of the concatenated sass, which is more or less incomprehensible for users. - const errMsg = errLines.slice(0, errLines.length - 2).join("\n"); - throw new Error("Theme file compilation failed:\n\n" + errMsg); - } - }; + const sassDir = installDir ?? architectureToolsPath("dart-sass"); - // On Windows, use safeWindowsExec to handle paths with spaces - // (e.g., when Quarto is installed in C:\Program Files\) - // See https://github.com/quarto-dev/quarto-cli/issues/13997 if (isWindows) { - const quoted = requireQuoting([sass, ...args]); - const result = await safeWindowsExec( - quoted.args[0], - quoted.args.slice(1), - (cmd: string[]) => { - return execProcess({ - cmd: cmd[0], - args: cmd.slice(1), - stdout: "piped", - stderr: "piped", - }); - }, - ); - return processResult(result); + return { + cmd: join(sassDir, "src", "dart.exe"), + baseArgs: [join(sassDir, "src", "sass.snapshot")], + }; } - // Non-Windows: direct execution + return { cmd: join(sassDir, "sass"), baseArgs: [] }; +} + +export async function dartCommand( + args: string[], + options?: DartCommandOptions, +) { + const { cmd, baseArgs } = resolveSassCommand(options); + const result = await execProcess({ - cmd: sass, - args, + cmd, + args: [...baseArgs, ...args], stdout: "piped", stderr: "piped", }); - return processResult(result); + + if (result.success) { + if (result.stderr) { + info(result.stderr); + } + return result.stdout; + } else { + debug(`[DART cmd] : ${cmd}`); + debug(`[DART args] : ${[...baseArgs, ...args].join(" ")}`); + debug(`[DART stdout] : ${result.stdout}`); + debug(`[DART stderr] : ${result.stderr}`); + + const errLines = lines(result.stderr || ""); + // truncate the last 2 lines (they include a pointer to the temp file containing + // all of the concatenated sass, which is more or less incomprehensible for users. + const errMsg = errLines.slice(0, errLines.length - 2).join("\n"); + throw new Error("Theme file compilation failed:\n\n" + errMsg); + } } diff --git a/src/core/platform.ts b/src/core/platform.ts index 85c34070cd0..01f18f374db 100644 --- a/src/core/platform.ts +++ b/src/core/platform.ts @@ -14,7 +14,15 @@ export function isWSL() { return !!Deno.env.get("WSL_DISTRO_NAME"); } +// Test override for isRStudio — avoids Deno.env.set() race conditions +// in parallel tests. See quarto-dev/quarto-cli#14218 and PR #12621. +let _isRStudioOverride: boolean | undefined; +export function _setIsRStudioForTest(value: boolean | undefined) { + _isRStudioOverride = value; +} + export function isRStudio() { + if (_isRStudioOverride !== undefined) return _isRStudioOverride; return !!Deno.env.get("RSTUDIO"); } diff --git a/src/core/windows.ts b/src/core/windows.ts index aed3d21fab8..c2880635c4a 100644 --- a/src/core/windows.ts +++ b/src/core/windows.ts @@ -156,7 +156,7 @@ export async function safeWindowsExec( try { Deno.writeTextFileSync( tempFile, - ["@echo off", [program, ...args].join(" ")].join("\n"), + ["@echo off", "chcp 65001 >nul", [program, ...args].join(" ")].join("\r\n"), ); return await fnExec(["cmd", "/c", tempFile]); } finally { diff --git a/src/inspect/inspect.ts b/src/inspect/inspect.ts index 3e98cc443fb..f7b4c283707 100644 --- a/src/inspect/inspect.ts +++ b/src/inspect/inspect.ts @@ -49,6 +49,7 @@ import { import { validateDocumentFromSource } from "../core/schema/validate-document.ts"; import { error } from "../deno_ral/log.ts"; import { ProjectContext } from "../project/types.ts"; +import { isRStudio } from "../core/platform.ts"; export function isProjectConfig( config: InspectedConfig, @@ -238,7 +239,9 @@ const inspectDocumentConfig = async (path: string) => { }; // if there is a project then add it - if (context?.config) { + // Suppress project for standalone files in RStudio: current releases + // assume project.dir implies _quarto.yml exists (rstudio/rstudio#17333) + if (context?.config && !(context.isSingleFile && isRStudio())) { config.project = await inspectProjectConfig(context); } return config; diff --git a/src/project/project-shared.ts b/src/project/project-shared.ts index cf121d63d88..d9daaf0803c 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. 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/src/resources/lua-types/quarto/doc.lua b/src/resources/lua-types/quarto/doc.lua index 3da0e8e5eae..2642f9617dd 100644 --- a/src/resources/lua-types/quarto/doc.lua +++ b/src/resources/lua-types/quarto/doc.lua @@ -48,7 +48,25 @@ which must be copied into the LaTeX output directory. function quarto.doc.add_format_resource(file) end --[[ -Include text at the specified location (`in-header`, `before-body`, or `after-body`). +Add a resource file to the document. The file will be copied to the same +relative location in the output directory. + +The path should be relative to the Lua script calling this function. +]] +---@param path string Resource file path (relative to Lua script) +function quarto.doc.add_resource(path) end + +--[[ +Add a supporting file to the document. Supporting files are moved to the +output directory and may be cleaned up after rendering. + +The path should be relative to the Lua script calling this function. +]] +---@param path string Supporting file path (relative to Lua script) +function quarto.doc.add_supporting(path) end + +--[[ +Include text at the specified location (`in-header`, `before-body`, or `after-body`). ]] ---@param location 'in-header'|'before-body'|'after-body' Location for include ---@param text string Text to include @@ -101,6 +119,13 @@ Does the current output format include Bootstrap themed HTML ---@return boolean function quarto.doc.has_bootstrap() end +--[[ +Check whether a specific internal Quarto filter is currently active. +]] +---@param filter string Filter name to check +---@return boolean +function quarto.doc.is_filter_active(filter) end + --[[ Provides the project relative path to the current input if this render is in the context of a project (otherwise `nil`) diff --git a/src/resources/lua-types/quarto/utils.lua b/src/resources/lua-types/quarto/utils.lua index cd92c02fbf7..f1ebd4600f5 100644 --- a/src/resources/lua-types/quarto/utils.lua +++ b/src/resources/lua-types/quarto/utils.lua @@ -12,15 +12,60 @@ Note that you should use `quarto.log.output()` instead of this function. function quarto.utils.dump(value) end --[[ -Compute the absolute path to a file that is installed alongside the Lua script. +Extended type function that returns Pandoc types with Quarto custom node awareness. -This is useful for internal resources that your filter needs but should +Returns `"CustomBlock"` for Quarto custom block nodes and `"CustomInline"` for +custom inline nodes, otherwise delegates to `pandoc.utils.type()`. +]] +---@param value any Value to check +---@return string +function quarto.utils.type(value) end + +quarto.utils.table = {} + +--[[ +Return `true` if the table is a plain integer-indexed array. +]] +---@param t table Table to check +---@return boolean +function quarto.utils.table.isarray(t) end + +--[[ +Return `true` if the array-like table contains the given value. +]] +---@param t table Array-like table to search +---@param value any Value to find +---@return boolean +function quarto.utils.table.contains(t, value) end + +--[[ +Iterator that traverses table keys in sorted order. +]] +---@param t table Table to iterate +---@param f? function Optional comparison function for sorting +---@return function Iterator function +function quarto.utils.table.sortedPairs(t, f) end + +--[[ +Compute the absolute path to a file that is installed alongside the Lua script. + +This is useful for internal resources that your filter needs but should not be visible to the user. ]] ---@param path string Path of file relative to the Lua script ---@return string function quarto.utils.resolve_path(path) end +--[[ +Resolve a file path relative to the document's working directory. + +Unlike `resolve_path()` which resolves relative to the Lua script, +this resolves relative to the document being rendered. +]] +---@param path string File path to resolve +---@return string +function quarto.utils.resolve_path_relative_to_document(path) end + --[[ Converts a string to a list of Pandoc Inlines, processing any Quarto custom syntax in the string. @@ -38,6 +83,78 @@ syntax in the string. ---@return pandoc.Blocks function quarto.utils.string_to_blocks(path) end +--[[ +Coerce the given object into a `pandoc.Inlines` list. + +Handles Inline, Inlines, Block, Blocks, List, and table inputs. +More performant than `pandoc.Inlines()` as it avoids full marshal roundtrips. + +Note: The input object may be modified destructively. +]] +---@param obj any Object to coerce +---@return pandoc.Inlines +function quarto.utils.as_inlines(obj) end + +--[[ +Coerce the given object into a `pandoc.Blocks` list. + +Handles Block, Blocks, Inline, Inlines, List, Caption, and table inputs. +More performant than `pandoc.Blocks()` as it avoids full marshal roundtrips. + +Note: The input object may be modified destructively. +]] +---@param obj any Object to coerce +---@return pandoc.Blocks +function quarto.utils.as_blocks(obj) end + +--[[ +Return `true` if the given AST node is empty. + +A node is considered empty if it's an empty list/table, has empty `content`, +has empty `caption`, or has an empty `text` field. +]] +---@param node any AST node to check +---@return boolean +function quarto.utils.is_empty_node(node) end + +--[[ +Walk and render extended/custom AST nodes. + +Processes Quarto custom AST nodes (like Callout, Tabset, etc.) into their +final Pandoc representation. +]] +---@param node pandoc.Node AST node to render +---@return pandoc.Node +function quarto.utils.render(node) end + +--[[ +CSS-selector-like AST matcher for Pandoc nodes. + +Accepts string path selectors separated by `/` with support for: +- Element type selectors (e.g. `"Header"`, `"Para"`, `"Div"`) +- Child traversal via `/` separator +- `*` wildcard to match any child +- `{Type}` capture syntax to return matched nodes +- `:child` to search direct children +- `:descendant` to search all descendants +- `:nth-child(n)` to match a specific child index + +Returns a function that, when called with an AST node, returns the matched +node(s) or `false`/`nil` if no match. +]] +---@param ... string|function Selector path components +---@return function Matcher function +function quarto.utils.match(...) end + +--[[ +Append a Block, Blocks, or Inlines value to an existing Blocks list. + +Inlines are wrapped in a Plain block before appending. +]] +---@param blocks pandoc.Blocks Target Blocks list +---@param block pandoc.Block|pandoc.Blocks|pandoc.Inlines Value to append +function quarto.utils.add_to_blocks(blocks, block) end + --[[ Returns a filter that parses book metadata markers during document traversal. diff --git a/src/tools/impl/chrome-for-testing.ts b/src/tools/impl/chrome-for-testing.ts index ce543913937..4dd3e463775 100644 --- a/src/tools/impl/chrome-for-testing.ts +++ b/src/tools/impl/chrome-for-testing.ts @@ -18,6 +18,7 @@ import { InstallContext } from "../types.ts"; /** CfT platform identifiers matching the Google Chrome for Testing API. */ export type CftPlatform = | "linux64" + | "linux-arm64" | "mac-arm64" | "mac-x64" | "win32" @@ -32,11 +33,12 @@ export interface PlatformInfo { /** * Map os + arch to a CfT platform string. - * Throws on unsupported platforms (e.g., linux aarch64 — to be handled by Playwright CDN). + * Throws on unsupported platforms. */ export function detectCftPlatform(): PlatformInfo { const platformMap: Record = { "linux-x86_64": "linux64", + "linux-aarch64": "linux-arm64", "darwin-aarch64": "mac-arm64", "darwin-x86_64": "mac-x64", "windows-x86_64": "win64", @@ -47,14 +49,8 @@ export function detectCftPlatform(): PlatformInfo { const platform = platformMap[key]; if (!platform) { - if (os === "linux" && arch === "aarch64") { - throw new Error( - "linux-arm64 is not supported by Chrome for Testing. " + - "Use 'quarto install chromium' for arm64 support.", - ); - } throw new Error( - `Unsupported platform for Chrome for Testing: ${os} ${arch}`, + `Unsupported platform for chrome-headless-shell: ${os} ${arch}`, ); } @@ -122,6 +118,79 @@ export async function fetchLatestCftRelease(): Promise { }; } +/** Parsed entry from Playwright's browsers.json for chromium-headless-shell. */ +export interface PlaywrightBrowserEntry { + revision: string; + browserVersion: string; +} + +const kPlaywrightBrowsersJsonUrl = + "https://raw.githubusercontent.com/microsoft/playwright/main/packages/playwright-core/browsers.json"; + +/** Check if the current platform requires Playwright CDN (arm64 Linux). */ +export function isPlaywrightCdnPlatform(info?: PlatformInfo): boolean { + const p = info ?? detectCftPlatform(); + return p.platform === "linux-arm64"; +} + +/** + * Fetch Playwright's browsers.json and extract the chromium-headless-shell entry. + * Used as the version/revision source for arm64 Linux where CfT has no builds. + */ +export async function fetchPlaywrightBrowsersJson(): Promise { + let response: Response; + const fallbackHint = "\nIf this persists, install a system Chrome/Chromium instead " + + "(Quarto will detect it automatically)."; + try { + response = await fetch(kPlaywrightBrowsersJsonUrl); + } catch (e) { + throw new Error( + `Failed to fetch Playwright browsers.json: ${ + e instanceof Error ? e.message : String(e) + }${fallbackHint}`, + ); + } + + if (!response.ok) { + throw new Error( + `Playwright browsers.json returned ${response.status}: ${response.statusText}${fallbackHint}`, + ); + } + + // deno-lint-ignore no-explicit-any + let data: any; + try { + data = await response.json(); + } catch { + throw new Error("Playwright browsers.json returned invalid JSON"); + } + + const browsers = data?.browsers; + if (!Array.isArray(browsers)) { + throw new Error("Playwright browsers.json missing 'browsers' array"); + } + + // deno-lint-ignore no-explicit-any + const entry = browsers.find((b: any) => b.name === "chromium-headless-shell"); + if (!entry || !entry.revision || !entry.browserVersion) { + throw new Error( + "Playwright browsers.json has no 'chromium-headless-shell' entry with revision and browserVersion", + ); + } + + return { + revision: entry.revision, + browserVersion: entry.browserVersion, + }; +} + +/** + * Construct the Playwright CDN download URL for chrome-headless-shell on linux arm64. + */ +export function playwrightCdnDownloadUrl(revision: string): string { + return `https://cdn.playwright.dev/builds/chromium/${revision}/chromium-headless-shell-linux-arm64.zip`; +} + /** * Find a named executable inside an extracted CfT directory. * Handles platform-specific naming (.exe on Windows) and nested directory structures. diff --git a/src/tools/impl/chrome-headless-shell.ts b/src/tools/impl/chrome-headless-shell.ts index 7ac9c038c89..f950856f8a9 100644 --- a/src/tools/impl/chrome-headless-shell.ts +++ b/src/tools/impl/chrome-headless-shell.ts @@ -20,7 +20,10 @@ import { detectCftPlatform, downloadAndExtractCft, fetchLatestCftRelease, + fetchPlaywrightBrowsersJson, findCftExecutable, + isPlaywrightCdnPlatform, + playwrightCdnDownloadUrl, } from "./chrome-for-testing.ts"; const kVersionFileName = "version"; @@ -32,6 +35,18 @@ export function chromeHeadlessShellInstallDir(): string { return quartoDataDir("chrome-headless-shell"); } +/** + * The executable name for chrome-headless-shell on the current platform. + * CfT builds use "chrome-headless-shell", Playwright arm64 builds use "headless_shell". + */ +export function chromeHeadlessShellBinaryName(): string { + try { + return isPlaywrightCdnPlatform() ? "headless_shell" : "chrome-headless-shell"; + } catch { + return "chrome-headless-shell"; + } +} + /** * Find the chrome-headless-shell executable in the install directory. * Returns the absolute path if installed, undefined otherwise. @@ -41,7 +56,7 @@ export function chromeHeadlessShellExecutablePath(): string | undefined { if (!existsSync(dir)) { return undefined; } - return findCftExecutable(dir, "chrome-headless-shell"); + return findCftExecutable(dir, chromeHeadlessShellBinaryName()); } /** Record the installed version as a plain text file. */ @@ -62,7 +77,7 @@ export function readInstalledVersion(dir: string): string | undefined { /** Check if chrome-headless-shell is installed in the given directory. */ export function isInstalled(dir: string): boolean { return existsSync(join(dir, kVersionFileName)) && - findCftExecutable(dir, "chrome-headless-shell") !== undefined; + findCftExecutable(dir, chromeHeadlessShellBinaryName()) !== undefined; } // -- InstallableTool methods -- @@ -84,8 +99,22 @@ async function installedVersion(): Promise { } async function latestRelease(): Promise { + const platformInfo = detectCftPlatform(); + + if (isPlaywrightCdnPlatform(platformInfo)) { + // arm64 Linux: use Playwright CDN + const entry = await fetchPlaywrightBrowsersJson(); + const url = playwrightCdnDownloadUrl(entry.revision); + return { + url, + version: entry.browserVersion, + assets: [{ name: "chrome-headless-shell", url }], + }; + } + + // All other platforms: use CfT API const release = await fetchLatestCftRelease(); - const { platform } = detectCftPlatform(); + const { platform } = platformInfo; const downloads = release.downloads["chrome-headless-shell"]; if (!downloads) { @@ -110,13 +139,15 @@ async function preparePackage(ctx: InstallContext): Promise { const release = await latestRelease(); const workingDir = Deno.makeTempDirSync({ prefix: "quarto-chrome-hs-" }); + const binaryName = chromeHeadlessShellBinaryName(); + try { await downloadAndExtractCft( "Chrome Headless Shell", release.url, workingDir, ctx, - "chrome-headless-shell", + binaryName, ); } catch (e) { safeRemoveSync(workingDir, { recursive: true }); diff --git a/src/tools/impl/tinytex.ts b/src/tools/impl/tinytex.ts index cb07917406b..b6c3be20338 100644 --- a/src/tools/impl/tinytex.ts +++ b/src/tools/impl/tinytex.ts @@ -190,7 +190,15 @@ async function install( await context.withSpinner( { message: `Unzipping ${basename(pkgInfo.filePath)}` }, async () => { - await unzip(pkgInfo.filePath); + const result = await unzip(pkgInfo.filePath); + if (!result.success) { + const hint = pkgInfo.filePath.endsWith(".tar.xz") && isLinux + ? "\nYou may need to install xz-utils (e.g., apt install xz-utils)." + : ""; + throw new Error( + `Failed to extract ${basename(pkgInfo.filePath)}.${hint}\n${result.stderr}`, + ); + } }, ); diff --git a/src/tools/tools-console.ts b/src/tools/tools-console.ts index 5cd9db25809..13356c94a86 100644 --- a/src/tools/tools-console.ts +++ b/src/tools/tools-console.ts @@ -149,6 +149,14 @@ export async function updateOrInstallTool( prompt?: boolean, updatePath?: boolean, ) { + // Deprecation warning for chromium + if (tool.toLowerCase() === "chromium" && action === "update") { + warning( + '"quarto update chromium" is deprecated and will be removed in Quarto 1.10. ' + + 'Use "quarto install chrome-headless-shell" instead.', + ); + } + const summary = await toolSummary(tool); if (action === "update") { diff --git a/src/tools/tools.ts b/src/tools/tools.ts index 0ef3ad02534..c1c714da48c 100644 --- a/src/tools/tools.ts +++ b/src/tools/tools.ts @@ -92,6 +92,12 @@ export async function printToolInfo(name: string) { } export function checkToolRequirement(name: string) { + if (name.toLowerCase() === "chromium") { + warning( + '"quarto install chromium" is deprecated and will be removed in Quarto 1.10. ' + + 'Use "quarto install chrome-headless-shell" instead.', + ); + } if (name.toLowerCase() === "chromium" && isWSL()) { // TODO: Change to a quarto-web url page ? const troubleshootUrl = diff --git a/src/webui/quarto-preview/build.ts b/src/webui/quarto-preview/build.ts index aaa30fb374e..a7929bb32e7 100644 --- a/src/webui/quarto-preview/build.ts +++ b/src/webui/quarto-preview/build.ts @@ -5,6 +5,18 @@ export {}; const args = Deno.args; +// Packagers building from a source tarball (no .git directory) rather than a +// git checkout have no way to satisfy buildFromGit()'s `git ls-files` check, +// which unconditionally triggers a rebuild (and thus a network-dependent +// `npm install`) even though quarto-preview.js is already committed to the +// tree. Let them opt out explicitly and use the shipped build. +if (Deno.env.get("QUARTO_SKIP_PREVIEW_BUILD")) { + console.log( + "QUARTO_SKIP_PREVIEW_BUILD set, skipping quarto-preview.js rebuild", + ); + Deno.exit(0); +} + // establish target js build time const kQuartoPreviewJs = "../../resources/preview/quarto-preview.js"; let jsBuildTime: number; diff --git a/tests/docs/inspect/standalone-hello.qmd b/tests/docs/inspect/standalone-hello.qmd new file mode 100644 index 00000000000..151c07a56fb --- /dev/null +++ b/tests/docs/inspect/standalone-hello.qmd @@ -0,0 +1,6 @@ +--- +title: Hello +format: html +--- + +Hello world. 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/smoke/inspect/inspect-standalone-rstudio.test.ts b/tests/smoke/inspect/inspect-standalone-rstudio.test.ts new file mode 100644 index 00000000000..2f26608179b --- /dev/null +++ b/tests/smoke/inspect/inspect-standalone-rstudio.test.ts @@ -0,0 +1,78 @@ +/* +* inspect-standalone-rstudio.test.ts +* +* Copyright (C) 2020-2026 Posit Software, PBC +* +*/ + +import { existsSync } from "../../../src/deno_ral/fs.ts"; +import { _setIsRStudioForTest } from "../../../src/core/platform.ts"; +import { + ExecuteOutput, + testQuartoCmd, +} from "../../test.ts"; +import { assert, assertEquals } from "testing/asserts"; + +// Test: standalone file inspect with RStudio override should NOT emit project. +// Uses _setIsRStudioForTest to avoid Deno.env.set() race conditions in +// parallel tests (see #14218, PR #12621). +(() => { + const input = "docs/inspect/standalone-hello.qmd"; + const output = "docs/inspect/standalone-hello.json"; + testQuartoCmd( + "inspect", + [input, output], + [ + { + name: "inspect-standalone-no-project-in-rstudio", + verify: async (_outputs: ExecuteOutput[]) => { + assert(existsSync(output)); + const json = JSON.parse(Deno.readTextFileSync(output)); + assertEquals(json.project, undefined, + "Standalone file inspect should not emit 'project' when in RStudio"); + } + } + ], + { + setup: async () => { + _setIsRStudioForTest(true); + }, + teardown: async () => { + _setIsRStudioForTest(undefined); + if (existsSync(output)) { + Deno.removeSync(output); + } + } + }, + ); +})(); + +// Test: standalone file inspect WITHOUT RStudio should still emit project +(() => { + const input = "docs/inspect/standalone-hello.qmd"; + const output = "docs/inspect/standalone-hello-nors.json"; + testQuartoCmd( + "inspect", + [input, output], + [ + { + name: "inspect-standalone-has-project-outside-rstudio", + verify: async (_outputs: ExecuteOutput[]) => { + assert(existsSync(output)); + const json = JSON.parse(Deno.readTextFileSync(output)); + assert(json.project !== undefined, + "Standalone file inspect should emit 'project' when not in RStudio"); + assert(json.project.dir !== undefined, + "project.dir should be set"); + } + } + ], + { + teardown: async () => { + if (existsSync(output)) { + Deno.removeSync(output); + } + } + }, + ); +})(); diff --git a/tests/unit/dart-sass.test.ts b/tests/unit/dart-sass.test.ts index 0e37241353c..6d5d3de86b9 100644 --- a/tests/unit/dart-sass.test.ts +++ b/tests/unit/dart-sass.test.ts @@ -2,7 +2,10 @@ * dart-sass.test.ts * * Tests for dart-sass functionality. - * Validates fix for https://github.com/quarto-dev/quarto-cli/issues/13997 + * Validates fixes for: + * https://github.com/quarto-dev/quarto-cli/issues/13997 (spaced paths) + * https://github.com/quarto-dev/quarto-cli/issues/14267 (accented paths) + * https://github.com/quarto-dev/quarto-cli/issues/6651 (enterprise .bat blocking) * * Copyright (C) 2020-2025 Posit Software, PBC */ @@ -13,46 +16,53 @@ import { isWindows } from "../../src/deno_ral/platform.ts"; import { join } from "../../src/deno_ral/path.ts"; import { dartCommand, dartSassInstallDir } from "../../src/core/dart-sass.ts"; +/** + * Helper: create a junction to the real dart-sass install dir at `targetDir`. + * Returns cleanup function to remove the junction. + */ +async function createDartSassJunction(targetDir: string) { + const sassInstallDir = dartSassInstallDir(); + const result = await new Deno.Command("cmd", { + args: ["/c", "mklink", "/J", targetDir, sassInstallDir], + }).output(); + + if (!result.success) { + const stderr = new TextDecoder().decode(result.stderr); + throw new Error(`Failed to create junction: ${stderr}`); + } + + return async () => { + await new Deno.Command("cmd", { + args: ["/c", "rmdir", targetDir], + }).output(); + }; +} + // Test that dartCommand handles spaced paths on Windows (issue #13997) -// The bug only triggers when BOTH the executable path AND arguments contain spaces. +// dart.exe is called directly, bypassing sass.bat and its quoting issues. unitTest( "dartCommand - handles spaced paths on Windows (issue #13997)", async () => { - // Create directories with spaces for both sass and file arguments const tempBase = Deno.makeTempDirSync({ prefix: "quarto_test_" }); const spacedSassDir = join(tempBase, "Program Files", "dart-sass"); const spacedProjectDir = join(tempBase, "My Project"); - const sassInstallDir = dartSassInstallDir(); + + let removeJunction: (() => Promise) | undefined; try { - // Create directories Deno.mkdirSync(join(tempBase, "Program Files"), { recursive: true }); Deno.mkdirSync(spacedProjectDir, { recursive: true }); - // Create junction (Windows directory symlink) to actual dart-sass - const junctionResult = await new Deno.Command("cmd", { - args: ["/c", "mklink", "/J", spacedSassDir, sassInstallDir], - }).output(); + removeJunction = await createDartSassJunction(spacedSassDir); - if (!junctionResult.success) { - const stderr = new TextDecoder().decode(junctionResult.stderr); - throw new Error(`Failed to create junction: ${stderr}`); - } - - // Create test SCSS file in spaced path (args with spaces) const inputScss = join(spacedProjectDir, "test style.scss"); const outputCss = join(spacedProjectDir, "test style.css"); Deno.writeTextFileSync(inputScss, "body { color: red; }"); - const spacedSassPath = join(spacedSassDir, "sass.bat"); - - // This is the exact bug scenario: spaced exe path + spaced args - // Without the fix, this fails with "C:\...\Program" not recognized const result = await dartCommand([inputScss, outputCss], { - sassPath: spacedSassPath, + installDir: spacedSassDir, }); - // Verify compilation succeeded (no stdout expected for file-to-file compilation) assert( result === undefined || result === "", "Sass compile should succeed (no stdout for file-to-file compilation)", @@ -62,14 +72,103 @@ unitTest( "Output CSS file should be created", ); } finally { - // Cleanup: remove junction first (rmdir for junctions), then temp directory try { - await new Deno.Command("cmd", { - args: ["/c", "rmdir", spacedSassDir], - }).output(); + if (removeJunction) await removeJunction(); + await Deno.remove(tempBase, { recursive: true }); + } catch (e) { + console.debug("Test cleanup failed:", e); + } + } + }, + { ignore: !isWindows }, +); + +// Test that dartCommand handles ampersand in paths (issue #14534) +// Windows user profile paths like C:\Users\Tom & Jerry\ broke dart-sass +// because the temp .bat wrapper written by safeWindowsExec was split on `&` +// by cmd.exe. Direct dart.exe invocation bypasses cmd.exe entirely. +unitTest( + "dartCommand - handles ampersand in paths (issue #14534)", + async () => { + const tempBase = Deno.makeTempDirSync({ prefix: "quarto_test_" }); + const ampSassDir = join(tempBase, "Tom & Jerry", "dart-sass"); + const ampProjectDir = join(tempBase, "Tom & Jerry", "project"); + + let removeJunction: (() => Promise) | undefined; + + try { + Deno.mkdirSync(join(tempBase, "Tom & Jerry"), { recursive: true }); + Deno.mkdirSync(ampProjectDir, { recursive: true }); + + removeJunction = await createDartSassJunction(ampSassDir); + + const inputScss = join(ampProjectDir, "style.scss"); + const outputCss = join(ampProjectDir, "style.css"); + Deno.writeTextFileSync(inputScss, "body { color: green; }"); + + const result = await dartCommand([inputScss, outputCss], { + installDir: ampSassDir, + }); + + assert( + result === undefined || result === "", + "Sass compile should succeed with ampersand in path", + ); + assert( + Deno.statSync(outputCss).isFile, + "Output CSS file should be created at ampersand path", + ); + } finally { + try { + if (removeJunction) await removeJunction(); + await Deno.remove(tempBase, { recursive: true }); + } catch (e) { + console.debug("Test cleanup failed:", e); + } + } + }, + { ignore: !isWindows }, +); + +// Test that dartCommand handles accented characters in paths (issue #14267) +// Accented chars in user paths (e.g., C:\Users\Sébastien\) broke when +// dart-sass was invoked through a .bat wrapper with UTF-8/OEM mismatch. +unitTest( + "dartCommand - handles accented characters in paths (issue #14267)", + async () => { + const tempBase = Deno.makeTempDirSync({ prefix: "quarto_test_" }); + const accentedSassDir = join(tempBase, "Sébastien", "dart-sass"); + const accentedProjectDir = join(tempBase, "Sébastien", "project"); + + let removeJunction: (() => Promise) | undefined; + + try { + Deno.mkdirSync(join(tempBase, "Sébastien"), { recursive: true }); + Deno.mkdirSync(accentedProjectDir, { recursive: true }); + + removeJunction = await createDartSassJunction(accentedSassDir); + + const inputScss = join(accentedProjectDir, "style.scss"); + const outputCss = join(accentedProjectDir, "style.css"); + Deno.writeTextFileSync(inputScss, "body { color: blue; }"); + + const result = await dartCommand([inputScss, outputCss], { + installDir: accentedSassDir, + }); + + assert( + result === undefined || result === "", + "Sass compile should succeed with accented path", + ); + assert( + Deno.statSync(outputCss).isFile, + "Output CSS file should be created at accented path", + ); + } finally { + try { + if (removeJunction) await removeJunction(); await Deno.remove(tempBase, { recursive: true }); } catch (e) { - // Best effort cleanup - log for debugging if it fails console.debug("Test cleanup failed:", e); } } 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/file-information-cache.test.ts b/tests/unit/project/file-information-cache.test.ts index 9f552e9c96b..666a0d2915b 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,78 @@ 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", + ); + }, +); 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: () => { diff --git a/tests/unit/render-shared-output-dir.test.ts b/tests/unit/render-shared-output-dir.test.ts new file mode 100644 index 00000000000..41d3c65638f --- /dev/null +++ b/tests/unit/render-shared-output-dir.test.ts @@ -0,0 +1,75 @@ +/* + * render-shared-output-dir.test.ts + * + * Regression test for posit-dev/positron#13370 / quarto-dev/quarto-cli#14489. + * + * When `quarto preview file.qmd --output-dir ` runs in a directory + * without _quarto.yml, preview pre-creates a singleFileProjectContext and + * passes it to render() as pContext. The synthetic-project trigger in + * render-shared.ts only fired when pContext was null, so the path fell + * through to validateDocumentRenderFlags() and threw. + * + * This test mimics that exact pattern and asserts no throw + output + * landed in --output-dir. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../test.ts"; +import { assert } from "testing/asserts"; +import { join } from "../../src/deno_ral/path.ts"; +import { existsSync } from "../../src/deno_ral/fs.ts"; +import { render } from "../../src/command/render/render-shared.ts"; +import { singleFileProjectContext } from "../../src/project/types/single-file/single-file.ts"; +import { notebookContext } from "../../src/render/notebook/notebook-context.ts"; +import { renderServices } from "../../src/command/render/render-services.ts"; +import { initYamlIntelligenceResourcesFromFilesystem } from "../../src/core/schema/utils.ts"; + +unitTest( + "render() with --output-dir succeeds when caller pre-passes a single-file context (#14489)", + async () => { + await initYamlIntelligenceResourcesFromFilesystem(); + + const tempBase = Deno.makeTempDirSync({ prefix: "quarto_test_outdir_" }); + const inputDir = join(tempBase, "src"); + const outputDir = join(tempBase, "out"); + Deno.mkdirSync(inputDir); + Deno.mkdirSync(outputDir); + + const inputFile = join(inputDir, "test.qmd"); + Deno.writeTextFileSync( + inputFile, + "---\ntitle: Test\nformat: html\n---\n\nHello world.\n", + ); + + const nbCtx = notebookContext(); + const services = renderServices(nbCtx); + + const renderOptions = { + services, + flags: { outputDir }, + pandocArgs: [], + }; + + // Mimic preview's pattern: pre-create singleFileProjectContext, pass as pContext. + const project = await singleFileProjectContext(inputFile, nbCtx, renderOptions); + + try { + // Before the fix this throws "The --output-dir flag can only be used + // when rendering projects." + await render(inputFile, renderOptions, project); + + assert( + existsSync(join(outputDir, "test.html")), + `Expected output HTML at ${join(outputDir, "test.html")} after render with --output-dir`, + ); + } finally { + services.cleanup?.(); + try { + Deno.removeSync(tempBase, { recursive: true }); + } catch { + // best-effort cleanup + } + } + }, +); diff --git a/tests/unit/tools/chrome-for-testing.test.ts b/tests/unit/tools/chrome-for-testing.test.ts index b2af0ef1584..d5ef7ba707b 100644 --- a/tests/unit/tools/chrome-for-testing.test.ts +++ b/tests/unit/tools/chrome-for-testing.test.ts @@ -22,7 +22,7 @@ import { // Step 1: detectCftPlatform() unitTest("detectCftPlatform - returns valid CftPlatform for current system", async () => { const result = detectCftPlatform(); - const validPlatforms = ["linux64", "mac-arm64", "mac-x64", "win32", "win64"]; + const validPlatforms = ["linux64", "linux-arm64", "mac-arm64", "mac-x64", "win32", "win64"]; assert( validPlatforms.includes(result.platform), `Expected one of ${validPlatforms.join(", ")}, got: ${result.platform}`, @@ -172,3 +172,67 @@ unitTest( ignore: runningInCI(), }, ); + +// -- Playwright CDN tests (arm64 Linux support) -- + +import { + isPlaywrightCdnPlatform, + playwrightCdnDownloadUrl, + fetchPlaywrightBrowsersJson, +} from "../../../src/tools/impl/chrome-for-testing.ts"; + +// isPlaywrightCdnPlatform — pure logic, runs everywhere +unitTest("isPlaywrightCdnPlatform - returns false on non-arm64 platform", async () => { + if (os === "linux" && arch === "aarch64") return; // Skip on actual arm64 + const result = isPlaywrightCdnPlatform(); + assertEquals(result, false); +}); + +// playwrightCdnDownloadUrl — pure function, no HTTP +unitTest("playwrightCdnDownloadUrl - constructs correct arm64 URL", async () => { + const url = playwrightCdnDownloadUrl("1219"); + assert( + url.startsWith("https://cdn.playwright.dev/"), + `URL should start with cdn.playwright.dev, got: ${url}`, + ); + assert( + url.includes("/builds/chromium/1219/"), + `URL should contain revision path, got: ${url}`, + ); + assert( + url.endsWith("chromium-headless-shell-linux-arm64.zip"), + `URL should end with arm64 zip name, got: ${url}`, + ); +}); + +// fetchPlaywrightBrowsersJson — external HTTP, skip on CI +unitTest("fetchPlaywrightBrowsersJson - returns chromium-headless-shell entry", async () => { + const entry = await fetchPlaywrightBrowsersJson(); + assert(entry.revision, "revision should be non-empty"); + assert( + /^\d+$/.test(entry.revision), + `revision should be numeric, got: ${entry.revision}`, + ); + assert(entry.browserVersion, "browserVersion should be non-empty"); + assert( + /^\d+\.\d+\.\d+\.\d+$/.test(entry.browserVersion), + `browserVersion format wrong: ${entry.browserVersion}`, + ); +}, { ignore: runningInCI() }); + +// findCftExecutable with Playwright arm64 layout (chrome-linux/headless_shell) +unitTest("findCftExecutable - finds binary in Playwright arm64 layout", async () => { + if (isWindows) return; // arm64 layout is Linux-only, no .exe + const tempDir = Deno.makeTempDirSync(); + try { + const subdir = join(tempDir, "chrome-linux"); + Deno.mkdirSync(subdir); + Deno.writeTextFileSync(join(subdir, "headless_shell"), "fake binary"); + + const found = findCftExecutable(tempDir, "headless_shell"); + assert(found !== undefined, "should find headless_shell in chrome-linux/"); + assert(found!.endsWith("headless_shell"), `should end with headless_shell, got: ${found}`); + } finally { + safeRemoveSync(tempDir, { recursive: true }); + } +}); diff --git a/tests/unit/tools/chrome-headless-shell.test.ts b/tests/unit/tools/chrome-headless-shell.test.ts index 259caf685e9..00125ca0cc3 100644 --- a/tests/unit/tools/chrome-headless-shell.test.ts +++ b/tests/unit/tools/chrome-headless-shell.test.ts @@ -8,12 +8,13 @@ import { unitTest } from "../../test.ts"; import { assert, assertEquals } from "testing/asserts"; import { join } from "../../../src/deno_ral/path.ts"; import { existsSync, safeRemoveSync } from "../../../src/deno_ral/fs.ts"; -import { isWindows } from "../../../src/deno_ral/platform.ts"; +import { arch, isWindows, os } from "../../../src/deno_ral/platform.ts"; import { runningInCI } from "../../../src/core/ci-info.ts"; import { InstallContext } from "../../../src/tools/types.ts"; -import { detectCftPlatform, findCftExecutable } from "../../../src/tools/impl/chrome-for-testing.ts"; +import { detectCftPlatform, findCftExecutable, isPlaywrightCdnPlatform } from "../../../src/tools/impl/chrome-for-testing.ts"; import { installableTool, installableTools } from "../../../src/tools/tools.ts"; import { + chromeHeadlessShellBinaryName, chromeHeadlessShellInstallable, chromeHeadlessShellInstallDir, chromeHeadlessShellExecutablePath, @@ -92,10 +93,13 @@ unitTest("isInstalled - returns false when only binary exists (no version file)" const tempDir = Deno.makeTempDirSync(); try { const { platform } = detectCftPlatform(); + const binName = chromeHeadlessShellBinaryName(); + // CfT layout: chrome-headless-shell-{platform}/binary + // Playwright arm64 layout: chrome-linux/binary (found via walkSync fallback) const subdir = join(tempDir, `chrome-headless-shell-${platform}`); Deno.mkdirSync(subdir); - const binaryName = isWindows ? "chrome-headless-shell.exe" : "chrome-headless-shell"; - Deno.writeTextFileSync(join(subdir, binaryName), "fake"); + const target = isWindows ? `${binName}.exe` : binName; + Deno.writeTextFileSync(join(subdir, target), "fake"); assertEquals(isInstalled(tempDir), false); } finally { safeRemoveSync(tempDir, { recursive: true }); @@ -107,10 +111,11 @@ unitTest("isInstalled - returns true when version file and binary exist", async try { noteInstalledVersion(tempDir, "145.0.0.0"); const { platform } = detectCftPlatform(); + const binName = chromeHeadlessShellBinaryName(); const subdir = join(tempDir, `chrome-headless-shell-${platform}`); Deno.mkdirSync(subdir); - const binaryName = isWindows ? "chrome-headless-shell.exe" : "chrome-headless-shell"; - Deno.writeTextFileSync(join(subdir, binaryName), "fake"); + const target = isWindows ? `${binName}.exe` : binName; + Deno.writeTextFileSync(join(subdir, target), "fake"); assertEquals(isInstalled(tempDir), true); } finally { @@ -128,7 +133,12 @@ unitTest("latestRelease - returns valid RemotePackageInfo", async () => { `version format wrong: ${release.version}`, ); assert(release.url.startsWith("https://"), `URL should be https: ${release.url}`); - assert(release.url.includes(release.version), "URL should contain version"); + // CfT URLs contain the version; Playwright CDN URLs contain a revision number instead + if (!isPlaywrightCdnPlatform()) { + assert(release.url.includes(release.version), "CfT URL should contain version"); + } else { + assert(release.url.includes("cdn.playwright.dev"), "arm64 URL should use Playwright CDN"); + } assert(release.assets.length > 0, "should have at least one asset"); assertEquals(release.assets[0].name, "chrome-headless-shell"); }, { ignore: runningInCI() }); @@ -162,7 +172,7 @@ unitTest("preparePackage - downloads and extracts chrome-headless-shell", async try { assert(pkg.version, "version should be non-empty"); assert(pkg.filePath, "filePath should be non-empty"); - const binary = findCftExecutable(pkg.filePath, "chrome-headless-shell"); + const binary = findCftExecutable(pkg.filePath, chromeHeadlessShellBinaryName()); assert(binary !== undefined, "binary should exist in extracted dir"); } finally { safeRemoveSync(pkg.filePath, { recursive: true }); @@ -257,3 +267,10 @@ unitTest("tool registry - installableTool looks up chrome-headless-shell", async assert(tool !== undefined, "installableTool should find chrome-headless-shell"); assertEquals(tool.name, "Chrome Headless Shell"); }); + +// -- arm64 support -- + +unitTest("chromeHeadlessShellBinaryName - returns chrome-headless-shell on non-arm64", async () => { + if (os === "linux" && arch === "aarch64") return; // Skip on actual arm64 + assertEquals(chromeHeadlessShellBinaryName(), "chrome-headless-shell"); +}); diff --git a/tests/unit/tools/tinytex.test.ts b/tests/unit/tools/tinytex.test.ts index 5522c1bc7d7..6ca85ab8885 100644 --- a/tests/unit/tools/tinytex.test.ts +++ b/tests/unit/tools/tinytex.test.ts @@ -5,10 +5,15 @@ */ import { unitTest } from "../../test.ts"; -import { assert, assertEquals } from "testing/asserts"; -import { tinyTexPkgName } from "../../../src/tools/impl/tinytex.ts"; +import { assert, assertEquals, assertRejects } from "testing/asserts"; +import { + tinyTexInstallable, + tinyTexPkgName, +} from "../../../src/tools/impl/tinytex.ts"; import { getLatestRelease } from "../../../src/tools/github.ts"; -import { GitHubRelease } from "../../../src/tools/types.ts"; +import { GitHubRelease, InstallContext, PackageInfo } from "../../../src/tools/types.ts"; +import { join } from "../../../src/deno_ral/path.ts"; +import { isLinux } from "../../../src/deno_ral/platform.ts"; // ---- Pure logic tests for tinyTexPkgName ---- @@ -158,3 +163,92 @@ unitTest( assertAssetExists(candidates, assetNames, "Windows"); }, ); + +// ---- Extraction failure tests ---- + +function createMockContext(workingDir: string): InstallContext { + return { + workingDir, + info: (_msg: string) => {}, + withSpinner: async (_options, op) => { + await op(); + }, + error: (_msg: string) => {}, + confirm: async (_msg: string) => true, + download: async (_name: string, _url: string, _target: string) => {}, + props: {}, + flags: {}, + }; +} + +unitTest( + "install - throws on extraction failure for corrupt archive", + async () => { + const workingDir = Deno.makeTempDirSync({ prefix: "quarto-tinytex-test" }); + try { + // Create a corrupt .tar.xz file that tar cannot extract + const badArchive = join( + workingDir, + "TinyTeX-linux-x86_64-v2026.04.tar.xz", + ); + Deno.writeTextFileSync(badArchive, "this is not a valid archive"); + + const pkgInfo: PackageInfo = { + filePath: badArchive, + version: "v2026.04", + }; + const context = createMockContext(workingDir); + + await assertRejects( + () => tinyTexInstallable.install(pkgInfo, context), + Error, + "Failed to extract", + ); + } finally { + Deno.removeSync(workingDir, { recursive: true }); + } + }, +); + +unitTest( + "install - extraction failure for .tar.xz includes xz-utils hint only on Linux", + async () => { + const workingDir = Deno.makeTempDirSync({ prefix: "quarto-tinytex-test" }); + try { + const badArchive = join( + workingDir, + "TinyTeX-linux-x86_64-v2026.04.tar.xz", + ); + Deno.writeTextFileSync(badArchive, "this is not a valid archive"); + + const pkgInfo: PackageInfo = { + filePath: badArchive, + version: "v2026.04", + }; + const context = createMockContext(workingDir); + + try { + await tinyTexInstallable.install(pkgInfo, context); + throw new Error("Expected install to throw"); + } catch (e) { + assert( + e instanceof Error && e.message.includes("Failed to extract"), + `Error should indicate extraction failure, got: ${e}`, + ); + if (isLinux) { + assert( + e.message.includes("xz-utils"), + `On Linux, error should mention xz-utils, got: ${e.message}`, + ); + } else { + assert( + !e.message.includes("xz-utils"), + `On non-Linux, error should not mention xz-utils, got: ${e.message}`, + ); + } + } + } finally { + Deno.removeSync(workingDir, { recursive: true }); + } + }, +); diff --git a/tests/unit/windows-exec.test.ts b/tests/unit/windows-exec.test.ts index 2422a4c1ffe..80ac4709072 100644 --- a/tests/unit/windows-exec.test.ts +++ b/tests/unit/windows-exec.test.ts @@ -155,3 +155,58 @@ echo ARG: %~1 }, { ignore: !isWindows }, ); + +// Test that safeWindowsExec handles accented/Unicode characters in paths (issue #14267) +// The bug: safeWindowsExec writes .bat as UTF-8 but cmd.exe reads using OEM code page +// (e.g., CP850), garbling multi-byte chars like é (0xC3 0xA9 → two CP850 chars). +unitTest( + "safeWindowsExec - handles accented characters in paths (issue #14267)", + async () => { + const tempDir = Deno.makeTempDirSync({ prefix: "quarto-test" }); + + try { + // Create directory with accented characters (simulates C:\Users\Sébastien\) + const accentedDir = join(tempDir, "Sébastien", "project"); + Deno.mkdirSync(accentedDir, { recursive: true }); + + // Create a test file at the accented path + const testFile = join(accentedDir, "test.txt"); + Deno.writeTextFileSync(testFile, "accented path works"); + + // Create batch file that reads the accented-path file + const batFile = join(tempDir, "read-file.bat"); + Deno.writeTextFileSync( + batFile, + `@echo off +type %1 +`, + ); + + const quoted = requireQuoting([batFile, testFile]); + + const result = await safeWindowsExec( + quoted.args[0], + quoted.args.slice(1), + (cmd) => + execProcess({ + cmd: cmd[0], + args: cmd.slice(1), + stdout: "piped", + stderr: "piped", + }), + ); + + assert( + result.success, + `Should execute successfully with accented path. stderr: ${result.stderr}`, + ); + assert( + result.stdout?.includes("accented path works"), + `Should read file at accented path. Got: ${result.stdout}`, + ); + } finally { + Deno.removeSync(tempDir, { recursive: true }); + } + }, + { ignore: !isWindows }, +); diff --git a/version.txt b/version.txt index 6450ef95325..a7c3730ad04 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.9.36 +1.9.38