Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions extensions/vscode/src/bundler/bundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import * as zlib from "zlib";
import { extract as tarExtract, Headers } from "tar-stream";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createBundle } from "./bundler";
import { manifestFromConfig } from "./manifestFromConfig";
import { newManifest } from "./manifest";
import { ContentType } from "../api/types/configurations";
import { ProductType } from "../api/types/contentRecords";
import { Manifest, BundleProgressEvent } from "./types";

let tmpDir: string;
Expand Down Expand Up @@ -414,3 +417,70 @@ describe("createBundle", () => {
}
});
});

describe("pre-rendered Quarto website bundle", () => {
it("sets content_category to 'site' in the archived manifest for HTML with subdirectory entrypoint", async () => {
// Simulate a pre-rendered Quarto website: output lives under _site/
makeFile("_site/index.html", "<html><body>Home</body></html>");
makeFile(
"_site/slides.html",
"<html><body>Slides (revealjs)</body></html>",
);
makeFile("_site/site_libs/revealjs/reveal.js", "/* reveal.js library */");

// Build manifest from config the same way connectPublish does
const manifest = manifestFromConfig({
$schema: "" as never,
productType: ProductType.CONNECT,
type: ContentType.HTML,
entrypoint: "_site/index.html",
validate: true,
files: ["/_site"],
});

const result = await createBundle({
projectPath: tmpDir,
manifest,
filePatterns: ["/_site"],
});

// The archived manifest.json should have content_category: "site"
const entries = await extractTarEntries(result.bundle);
const archivedManifest = JSON.parse(
entries.get("manifest.json")!.data.toString(),
);
expect(archivedManifest.metadata.content_category).toBe("site");
expect(archivedManifest.metadata.appmode).toBe("static");
expect(archivedManifest.metadata.primary_html).toBe("_site/index.html");

// All site files should be present in the bundle
expect(entries.has("_site/index.html")).toBe(true);
expect(entries.has("_site/slides.html")).toBe(true);
expect(entries.has("_site/site_libs/revealjs/reveal.js")).toBe(true);
});

it("does not set content_category for single-file HTML deployment", async () => {
makeFile("index.html", "<html><body>Single page</body></html>");

const manifest = manifestFromConfig({
$schema: "" as never,
productType: ProductType.CONNECT,
type: ContentType.HTML,
entrypoint: "index.html",
validate: true,
files: ["/index.html"],
});

const result = await createBundle({
projectPath: tmpDir,
manifest,
filePatterns: ["/index.html"],
});

const entries = await extractTarEntries(result.bundle);
const archivedManifest = JSON.parse(
entries.get("manifest.json")!.data.toString(),
);
expect(archivedManifest.metadata.content_category).toBeUndefined();
});
});
61 changes: 61 additions & 0 deletions extensions/vscode/src/bundler/manifestFromConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,67 @@ describe("manifestFromConfig", () => {
});
});

describe("content_category", () => {
it("sets content_category to 'site' for HTML with _site/ entrypoint", () => {
const m = manifestFromConfig(
minimalConfig({
type: ContentType.HTML,
entrypoint: "_site/index.html",
}),
);
expect(m.metadata.content_category).toBe("site");
});

it("sets content_category to 'site' for HTML with _book/ entrypoint", () => {
const m = manifestFromConfig(
minimalConfig({
type: ContentType.HTML,
entrypoint: "_book/index.html",
}),
);
expect(m.metadata.content_category).toBe("site");
});

it("does not set content_category for HTML with flat entrypoint", () => {
const m = manifestFromConfig(
minimalConfig({
type: ContentType.HTML,
entrypoint: "index.html",
}),
);
expect(m.metadata.content_category).toBeUndefined();
});

it("does not set content_category for HTML in an arbitrary subdirectory", () => {
const m = manifestFromConfig(
minimalConfig({
type: ContentType.HTML,
entrypoint: "subdir/single-page.html",
}),
);
expect(m.metadata.content_category).toBeUndefined();
});

it("does not set content_category for non-HTML types with _site/ entrypoint", () => {
const m = manifestFromConfig(
minimalConfig({
type: ContentType.QUARTO_STATIC,
entrypoint: "_site/index.html",
}),
);
expect(m.metadata.content_category).toBeUndefined();
});

it("does not set content_category when entrypoint is undefined", () => {
const m = manifestFromConfig(
minimalConfig({
type: ContentType.HTML,
}),
);
expect(m.metadata.content_category).toBeUndefined();
});
});

describe("combined R and Python environment", () => {
it("sets both environment.r and environment.python", () => {
const m = manifestFromConfig(
Expand Down
25 changes: 25 additions & 0 deletions extensions/vscode/src/bundler/manifestFromConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export function manifestFromConfig(cfg: ConfigurationDetails): Manifest {
appmode,
entrypoint: cfg.entrypoint,
...primaryField(cfg.type, cfg.entrypoint),
...contentCategoryField(cfg),
// false is omitted so it doesn't appear in manifest.json
has_parameters: cfg.hasParameters || undefined,
},
Expand Down Expand Up @@ -115,3 +116,27 @@ function primaryField(
return undefined;
}
}

// Known Quarto output directories that indicate a pre-rendered multi-page site.
const quartoSiteOutputDirs = new Set(["_site", "_book"]);

// Detect multi-page site content for the manifest's content_category field.
// When an HTML deployment's entrypoint lives inside a known Quarto output
// directory (e.g. "_site/index.html" or "_book/index.html"), the content is a
// pre-rendered multi-page site. Connect uses content_category="site" to serve
// all files in the bundle rather than only the entrypoint.
//
// Only known Quarto output directories trigger this — an arbitrary subdirectory
// (e.g. "subdir/page.html") does not, to avoid false positives for standalone
// HTML files that happen to be nested.
function contentCategoryField(
cfg: ConfigurationDetails,
): { content_category: string } | undefined {
if (cfg.type === ContentType.HTML && cfg.entrypoint) {
const parts = cfg.entrypoint.split("/");
if (parts.length > 1 && quartoSiteOutputDirs.has(parts[0]!)) {
return { content_category: "site" };
}
}
return undefined;
}
132 changes: 128 additions & 4 deletions extensions/vscode/src/utils/quartoProjectHelper.smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,75 @@ Hello world
expect(fs.existsSync(path.join(tmpDir, "index.html"))).toBe(true);
}, 30_000);

test("renders a standalone revealjs document to slides, not plain HTML", async () => {
const qmdContent = `---
title: "Slide Deck"
format: revealjs
---

## Slide One

Hello slides
`;
fs.writeFileSync(path.join(tmpDir, "slides.qmd"), qmdContent);

const helper = new QuartoProjectHelper(
"slides.qmd",
"slides.html",
tmpDir,
);
await helper.render();

const outputPath = path.join(tmpDir, "slides.html");
expect(fs.existsSync(outputPath)).toBe(true);

const html = fs.readFileSync(outputPath, "utf-8");
// revealjs output contains reveal.js framework references
expect(html).toContain("reveal");
}, 30_000);

test("renders a Quarto project with mixed formats (html + revealjs)", async () => {
const quartoYml = `project:
type: website
output-dir: _site
`;
const indexQmd = `---
title: "Home"
---

Welcome
`;
const slidesQmd = `---
title: "Slides"
format: revealjs
---

## Slide One

Content
`;
fs.writeFileSync(path.join(tmpDir, "_quarto.yml"), quartoYml);
fs.writeFileSync(path.join(tmpDir, "index.qmd"), indexQmd);
fs.writeFileSync(path.join(tmpDir, "slides.qmd"), slidesQmd);

const helper = new QuartoProjectHelper("index.qmd", "index.html", tmpDir);
await helper.render();

// Both files should be rendered
const indexPath = path.join(tmpDir, "_site", "index.html");
const slidesPath = path.join(tmpDir, "_site", "slides.html");
expect(fs.existsSync(indexPath)).toBe(true);
expect(fs.existsSync(slidesPath)).toBe(true);

// slides.html should be revealjs, not plain HTML
const slidesHtml = fs.readFileSync(slidesPath, "utf-8");
expect(slidesHtml).toContain("reveal");

// index.html should NOT contain revealjs
const indexHtml = fs.readFileSync(indexPath, "utf-8");
expect(indexHtml).not.toContain("reveal");
}, 60_000);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for these tests! I presume they both fail without the change to quartoProjectHelper.ts?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't run these specific tests without the change - the reprex the customer gave executing with / without the change seemed like a better validation that we're actually fixing the thing - but this test does prove that the new behavior is correct!


test("renders a Quarto project to HTML", async () => {
const quartoYml = `project:
type: website
Expand All @@ -133,8 +202,63 @@ Hello project
);

// --------------------------------------------------------------------------
// Command construction with real filesystem
// Relative vs absolute projectDir — verifies the bug where a relative
// projectDir (e.g. ".") caused isQuartoYmlPresent() to check the wrong
// directory, falling through to single-document render.
// --------------------------------------------------------------------------
describe("QuartoProjectHelper - relative vs absolute projectDir", () => {
test("absolute projectDir detects _quarto.yml on disk", async () => {
fs.writeFileSync(
path.join(tmpDir, "_quarto.yml"),
"project:\n type: website\n",
);
const helper = new QuartoProjectHelper("index.qmd", "index.html", tmpDir);
expect(await helper.isQuartoYmlPresent()).toBe(true);
});

test("relative projectDir '.' only finds _quarto.yml if cwd matches", async () => {
// Guard: this test assumes cwd does NOT contain _quarto.yml.
// If it does, the assertion below would pass trivially or fail confusingly.
expect(fs.existsSync(path.join(process.cwd(), "_quarto.yml"))).toBe(false);
Comment on lines +219 to +222

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we can make QuartoProjectHelper handle this in the future so it doesn't need to be passed the absolute dir, but can create it itself.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude can you create a follow-up PR for this?

@posit-connect-projects posit-connect-projects Bot Apr 22, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude finished @zackverham's task in 5m 18s —— View job


  • Read current implementation of QuartoProjectHelper and homeView.ts
  • Create a new branch from main
  • Move path resolution logic into QuartoProjectHelper
  • Update homeView.ts to pass rootDir instead of resolving path manually
  • Update tests (unit + smoke)
  • Run lint and tests (all pass)
  • Push and create PR

Summary

Created follow-up PR #4070 which adds an optional rootDir parameter to QuartoProjectHelper's constructor. When provided, it resolves projectDir against rootDir internally using path.resolve(), so callers like homeView.ts no longer need to resolve paths themselves.

Changes:

  • quartoProjectHelper.ts — new rootDir constructor param, resolves projectDir internally
  • renders.ts — forwards rootDir to the helper
  • homeView.ts — passes this.root?.uri.fsPath as rootDir
  • Unit and smoke tests covering relative, nested relative, absolute, and omitted rootDir scenarios
    |


fs.writeFileSync(
path.join(tmpDir, "_quarto.yml"),
"project:\n type: website\n",
);
// With "." as projectDir, fileExistsAt resolves relative to process.cwd(),
// which is NOT tmpDir. This simulates the bug: the extension host's cwd
// differs from the project directory.
const helper = new QuartoProjectHelper("index.qmd", "index.html", ".");
// source doesn't contain "_quarto.yml", so it falls through to disk check.
// The disk check looks for path.join(".", "_quarto.yml") which resolves
// relative to cwd, not the project — so it won't find the file.
const found = await helper.isQuartoYmlPresent();
expect(found).toBe(false);
});

test("absolute projectDir triggers project render, not document render", async () => {
fs.writeFileSync(
path.join(tmpDir, "_quarto.yml"),
"project:\n type: website\n",
);

let capturedCommand: string | undefined;
mockRunTerminalCommand.mockImplementation((cmd: string) => {
if (cmd === "quarto --version") {
return Promise.resolve(0);
}
capturedCommand = cmd;
return Promise.resolve(0);
});

const helper = new QuartoProjectHelper("index.qmd", "index.html", tmpDir);
await helper.render();

// Should render the project directory, not the single document
expect(capturedCommand).toBe(`quarto render "${tmpDir}"`);
});
});

describe("QuartoProjectHelper - command construction smoke test", () => {
let capturedCommand: string | undefined;

Expand All @@ -158,15 +282,15 @@ describe("QuartoProjectHelper - command construction smoke test", () => {
const helper = new QuartoProjectHelper("index.qmd", "index.html", tmpDir);
await helper.render();

expect(capturedCommand).toBe(`quarto render "${tmpDir}" --to html`);
expect(capturedCommand).toBe(`quarto render "${tmpDir}"`);
});

test("constructs document render command when _quarto.yml does not exist", async () => {
const helper = new QuartoProjectHelper("index.qmd", "index.html", tmpDir);
await helper.render();

expect(capturedCommand).toBe(
`quarto render "${path.join(tmpDir, "index.qmd")}" --to html`,
`quarto render "${path.join(tmpDir, "index.qmd")}"`,
);
});

Expand All @@ -175,6 +299,6 @@ describe("QuartoProjectHelper - command construction smoke test", () => {
const helper = new QuartoProjectHelper("_quarto.yml", "index.html", tmpDir);
await helper.render();

expect(capturedCommand).toBe(`quarto render "${tmpDir}" --to html`);
expect(capturedCommand).toBe(`quarto render "${tmpDir}"`);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not related to this PR, but I wonder if we need both the command capture here as well as the similar unit tests in quartoProejctHelper.test.ts? If we do clean it up, IMO it should be in another PR not this one.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you mean if the assertions are necessary in all of these tests? I do think the capture here is a little too brittle - but I think we care that QuartoProjectHelper does successfully invoke the mocked terminal with some type of render on these inputs.

@claude can you create an issue to improve how these tests are handling the quarto invocation?

@posit-connect-projects posit-connect-projects Bot Apr 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude finished @zackverham's task in 53s —— View job


  • Read the smoke test and unit test files to understand the current approach
  • Draft and create a GitHub issue for improving quarto invocation handling in tests

Created #4058 to track this. The issue covers the overlap between the command construction smoke tests and the unit tests, and suggests relaxing the exact command-string assertions in the smoke tests to focus on what they uniquely validate (filesystem-to-render-mode integration) while leaving precise command construction assertions to the unit tests.

});
});
Loading
Loading