Path: blob/main/tests/smoke/typst-gather/typst-gather.test.ts
12925 views
import { testQuartoCmd, unitTest, Verify } from "../../test.ts";1import { assert } from "testing/asserts";2import { existsSync } from "../../../src/deno_ral/fs.ts";3import { join } from "../../../src/deno_ral/path.ts";4import { execProcess } from "../../../src/core/process.ts";56// Test 1: Auto-detection from _extension.yml7const verifyPackagesCreated: Verify = {8name: "Verify typst/packages directory was created",9verify: async () => {10const packagesDir = "_extensions/test-format/typst/packages";11assert(12existsSync(packagesDir),13`Expected typst/packages directory not found: ${packagesDir}`,14);15},16};1718const verifyExamplePackageCached: Verify = {19name: "Verify @preview/example package was cached",20verify: async () => {21const packageDir =22"_extensions/test-format/typst/packages/preview/example/0.1.0";23assert(24existsSync(packageDir),25`Expected cached package not found: ${packageDir}`,26);2728// Verify typst.toml exists in the package29const manifestPath = `${packageDir}/typst.toml`;30assert(31existsSync(manifestPath),32`Expected package manifest not found: ${manifestPath}`,33);34},35};3637testQuartoCmd(38"call",39["typst-gather"],40[verifyPackagesCreated, verifyExamplePackageCached],41{42cwd: () => "smoke/typst-gather",43},44"typst-gather caches preview packages from extension templates",45);4647// Test 2: Config file with rootdir48const verifyConfigPackagesCreated: Verify = {49name: "Verify typst/packages directory was created via config",50verify: async () => {51const packagesDir = "_extensions/config-format/typst/packages";52assert(53existsSync(packagesDir),54`Expected typst/packages directory not found: ${packagesDir}`,55);56},57};5859const verifyConfigExamplePackageCached: Verify = {60name: "Verify @preview/example package was cached via config",61verify: async () => {62const packageDir =63"_extensions/config-format/typst/packages/preview/example/0.1.0";64assert(65existsSync(packageDir),66`Expected cached package not found: ${packageDir}`,67);6869const manifestPath = `${packageDir}/typst.toml`;70assert(71existsSync(manifestPath),72`Expected package manifest not found: ${manifestPath}`,73);74},75};7677testQuartoCmd(78"call",79["typst-gather"],80[verifyConfigPackagesCreated, verifyConfigExamplePackageCached],81{82cwd: () => "smoke/typst-gather/with-config",83},84"typst-gather uses rootdir from config file",85);8687// Test 3: --init-config generates config file88const verifyInitConfigCreated: Verify = {89name: "Verify typst-gather.toml was created",90verify: async () => {91assert(92existsSync("typst-gather.toml"),93"Expected typst-gather.toml to be created",94);9596// Read and verify content has rootdir97const content = Deno.readTextFileSync("typst-gather.toml");98assert(99content.includes("rootdir"),100"Expected typst-gather.toml to contain rootdir",101);102assert(103content.includes("_extensions/test-format"),104"Expected rootdir to point to extension directory",105);106},107};108109testQuartoCmd(110"call",111["typst-gather", "--init-config"],112[verifyInitConfigCreated],113{114cwd: () => "smoke/typst-gather",115teardown: async () => {116// Clean up generated config file117try {118Deno.removeSync("typst-gather.toml");119} catch {120// Ignore if already removed121}122},123},124"typst-gather --init-config generates config with rootdir",125);126127// Test 4: @local package is copied when [local] section is configured128const verifyLocalPackageCopied: Verify = {129name: "Verify @local/my-local-pkg was copied",130verify: async () => {131const packageDir =132"_extensions/local-format/typst/packages/local/my-local-pkg/0.1.0";133assert(134existsSync(packageDir),135`Expected local package not found: ${packageDir}`,136);137138const manifestPath = `${packageDir}/typst.toml`;139assert(140existsSync(manifestPath),141`Expected package manifest not found: ${manifestPath}`,142);143144const libPath = `${packageDir}/lib.typ`;145assert(existsSync(libPath), `Expected lib.typ not found: ${libPath}`);146},147};148149testQuartoCmd(150"call",151["typst-gather"],152[verifyLocalPackageCopied],153{154cwd: () => "smoke/typst-gather/with-local",155teardown: async () => {156// Clean up copied packages157try {158Deno.removeSync("_extensions/local-format/typst", { recursive: true });159} catch {160// Ignore if already removed161}162},163},164"typst-gather copies @local packages when configured",165);166167// Test 5: --init-config detects @local imports and generates [local] section168const verifyInitConfigWithLocal: Verify = {169name: "Verify --init-config detects @local imports",170verify: async () => {171assert(172existsSync("typst-gather.toml"),173"Expected typst-gather.toml to be created",174);175176const content = Deno.readTextFileSync("typst-gather.toml");177assert(178content.includes("[local]"),179"Expected typst-gather.toml to contain [local] section",180);181assert(182content.includes("my-local-pkg"),183"Expected typst-gather.toml to reference my-local-pkg",184);185assert(186content.includes("@local/my-local-pkg"),187"Expected typst-gather.toml to show found @local import",188);189},190};191192testQuartoCmd(193"call",194["typst-gather", "--init-config"],195[verifyInitConfigWithLocal],196{197cwd: () => "smoke/typst-gather/with-local",198setup: async () => {199// Remove existing config so --init-config can run200try {201Deno.renameSync("typst-gather.toml", "typst-gather.toml.bak");202} catch {203// Ignore if doesn't exist204}205},206teardown: async () => {207// Restore original config and clean up generated one208try {209Deno.removeSync("typst-gather.toml");210} catch {211// Ignore212}213try {214Deno.renameSync("typst-gather.toml.bak", "typst-gather.toml");215} catch {216// Ignore217}218},219},220"typst-gather --init-config detects @local imports",221);222223// Test 6: Rendering a project-based typst document with no package imports224// stages no packages (no preview/ or local/ dirs created)225const noPackagesProjectDir =226"docs/smoke-all/typst/no-packages-project";227228const verifyNoPackagesStaged: Verify = {229name: "Verify no packages staged for document with no imports",230verify: async () => {231const scratchPackages = join(noPackagesProjectDir, ".quarto/typst/packages");232const previewDir = join(scratchPackages, "preview");233const localDir = join(scratchPackages, "local");234assert(235!existsSync(previewDir),236`Expected no preview/ dir but found: ${previewDir}`,237);238assert(239!existsSync(localDir),240`Expected no local/ dir but found: ${localDir}`,241);242},243};244245testQuartoCmd(246"render",247[join(noPackagesProjectDir, "index.qmd"), "--to", "typst"],248[verifyNoPackagesStaged],249{250teardown: async () => {251try {252Deno.removeSync(join(noPackagesProjectDir, ".quarto"), {253recursive: true,254});255Deno.removeSync(join(noPackagesProjectDir, "index.pdf"));256} catch {257// Ignore258}259},260},261"no packages staged when typst document has no imports",262);263264// Helper to run quarto as an external process and capture exit code265async function runQuarto(266args: string[],267cwd: string,268env?: Record<string, string>,269): Promise<{ success: boolean; stdout: string; stderr: string }> {270const quartoCmd = Deno.build.os === "windows" ? "quarto.cmd" : "quarto";271const quartoPath = join(272Deno.cwd(),273"..",274"package/dist/bin",275quartoCmd,276);277const result = await execProcess({278cmd: quartoPath,279args,280cwd,281stdout: "piped",282stderr: "piped",283env: env ? { ...Deno.env.toObject(), ...env } : undefined,284});285return {286success: result.success,287stdout: result.stdout || "",288stderr: result.stderr || "",289};290}291292// Test 7: --init-config errors when typst-gather.toml already exists293unitTest(294"typst-gather --init-config errors when config already exists",295async () => {296const cwd = join(Deno.cwd(), "smoke/typst-gather");297// Create a typst-gather.toml so --init-config should fail298const configPath = join(cwd, "typst-gather.toml");299try {300Deno.writeTextFileSync(configPath, "# existing config\n");301const result = await runQuarto(302["call", "typst-gather", "--init-config"],303cwd,304);305assert(!result.success, "Expected --init-config to fail when config exists");306} finally {307try {308Deno.removeSync(configPath);309} catch {310// Ignore311}312}313},314);315316// Test 8: --init-config errors when no extension directory found317unitTest(318"typst-gather --init-config errors with no extension directory",319async () => {320const cwd = join(Deno.cwd(), "smoke/typst-gather/no-extension");321const result = await runQuarto(322["call", "typst-gather", "--init-config"],323cwd,324);325assert(326!result.success,327"Expected --init-config to fail with no extension",328);329},330);331332// Test 9: --init-config with extension that has no typst entries333// The extension has no typst template/template-partials, so extractTypstFiles334// returns empty. initConfig logs a warning but still generates a config with335// placeholder discover. This is not an error — it's a valid starting point.336unitTest(337"typst-gather --init-config warns with empty extension (no typst entries)",338async () => {339const cwd = join(Deno.cwd(), "smoke/typst-gather/empty-extension");340const result = await runQuarto(341["call", "typst-gather", "--init-config"],342cwd,343);344// initConfig still generates a config file with placeholders345const configPath = join(cwd, "typst-gather.toml");346try {347if (result.success) {348assert(existsSync(configPath), "Expected config file to be created");349const content = Deno.readTextFileSync(configPath);350// Should have placeholder discover comment351assert(352content.includes("discover"),353"Expected discover in generated config",354);355} else {356// If it failed, that's also acceptable — no typst files found357assert(true);358}359} finally {360try {361Deno.removeSync(configPath);362} catch {363// Ignore364}365}366},367);368369// Test 10: --init-config detects @local imports from extension template.370// Note: --init-config only passes `discover` paths, not `[local]` config,371// so typst-gather analyze can see the direct @local import but cannot follow372// it to find transitive @preview deps (it doesn't know where the local373// package lives). The transitive resolution happens at gather time with the374// full config.375const verifyTransitiveDeps: Verify = {376name: "Verify --init-config detects @local import from template",377verify: async () => {378assert(379existsSync("typst-gather.toml"),380"Expected typst-gather.toml to be created",381);382383const content = Deno.readTextFileSync("typst-gather.toml");384// Should have the @local import detected385assert(386content.includes("[local]"),387"Expected [local] section",388);389assert(390content.includes("dep-pkg"),391"Expected dep-pkg in [local] section",392);393},394};395396testQuartoCmd(397"call",398["typst-gather", "--init-config"],399[verifyTransitiveDeps],400{401cwd: () => "smoke/typst-gather/with-transitive-deps",402setup: async () => {403// Rename existing config so --init-config can run404try {405Deno.renameSync("typst-gather.toml", "typst-gather.toml.bak");406} catch {407// Ignore if doesn't exist408}409},410teardown: async () => {411// Restore original config and clean up generated one412try {413Deno.removeSync("typst-gather.toml");414} catch {415// Ignore416}417try {418Deno.renameSync("typst-gather.toml.bak", "typst-gather.toml");419} catch {420// Ignore421}422},423},424"typst-gather --init-config detects transitive deps from @local",425);426427// Test 11: Staging falls back to copy-all when typst-gather binary is missing428unitTest(429"staging falls back when typst-gather binary is missing",430async () => {431// Render a document that has packages, with QUARTO_TYPST_GATHER pointing432// to a nonexistent binary. The render should still succeed because433// analyzeNeededPackages catches the error and falls back to stageAll.434const projectDir = join(435Deno.cwd(),436"docs/smoke-all/typst/marginalia-only-project",437);438const result = await runQuarto(439["render", "index.qmd", "--to", "typst"],440projectDir,441{ QUARTO_TYPST_GATHER: "/nonexistent/typst-gather-binary" },442);443assert(444result.success,445`Expected render to succeed with fallback staging, but failed:\n${result.stderr}`,446);447// Clean up .quarto so fallback staging doesn't pollute subsequent tests448const quartoDir = join(projectDir, ".quarto");449if (existsSync(quartoDir)) {450Deno.removeSync(quartoDir, { recursive: true });451}452},453);454455// Test 12: Staging falls back when typst-gather analyze fails (non-zero exit)456unitTest(457"staging falls back when typst-gather analyze returns non-zero",458async () => {459// Use a real executable that will exit non-zero when called with460// typst-gather's arguments. On Unix, /usr/bin/false always exits 1.461// On Windows, use the quarto binary itself which will fail when called462// with "analyze -" arguments.463const falseCmd = Deno.build.os === "windows"464? Deno.execPath() // deno binary will fail on "analyze -" args465: "/usr/bin/false";466const projectDir = join(467Deno.cwd(),468"docs/smoke-all/typst/marginalia-only-project",469);470const result = await runQuarto(471["render", "index.qmd", "--to", "typst"],472projectDir,473{ QUARTO_TYPST_GATHER: falseCmd },474);475assert(476result.success,477`Expected render to succeed with fallback staging, but failed:\n${result.stderr}`,478);479// Clean up .quarto so fallback staging doesn't pollute subsequent tests480const quartoDir = join(projectDir, ".quarto");481if (existsSync(quartoDir)) {482Deno.removeSync(quartoDir, { recursive: true });483}484},485);486487// Test 13: Rendering a project using .column-margin stages only marginalia488const marginaliaProjectDir =489"docs/smoke-all/typst/marginalia-only-project";490491const verifyOnlyMarginaliaStaged: Verify = {492name: "Verify only marginalia package is staged",493verify: async () => {494const scratchPackages = join(marginaliaProjectDir, ".quarto/typst/packages");495const previewDir = join(scratchPackages, "preview");496497// marginalia should be staged498const marginaliaDir = join(previewDir, "marginalia");499assert(500existsSync(marginaliaDir),501`Expected marginalia package but not found: ${marginaliaDir}`,502);503504// No other packages should be staged505const unwanted = [506"fontawesome",507"showybox",508"theorion",509"octique",510"orange-book",511];512for (const pkg of unwanted) {513const pkgDir = join(previewDir, pkg);514assert(515!existsSync(pkgDir),516`Expected ${pkg} NOT to be staged but found: ${pkgDir}`,517);518}519520// No local packages should be staged521const localDir = join(scratchPackages, "local");522assert(523!existsSync(localDir),524`Expected no local/ dir but found: ${localDir}`,525);526},527};528529testQuartoCmd(530"render",531[join(marginaliaProjectDir, "index.qmd"), "--to", "typst"],532[verifyOnlyMarginaliaStaged],533{534teardown: async () => {535try {536Deno.removeSync(join(marginaliaProjectDir, ".quarto"), {537recursive: true,538});539Deno.removeSync(join(marginaliaProjectDir, "index.pdf"));540} catch {541// Ignore542}543},544},545"only marginalia staged when typst document uses column-margin",546);547548549