Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/tests/smoke/typst-gather/typst-gather.test.ts
12925 views
1
import { testQuartoCmd, unitTest, Verify } from "../../test.ts";
2
import { assert } from "testing/asserts";
3
import { existsSync } from "../../../src/deno_ral/fs.ts";
4
import { join } from "../../../src/deno_ral/path.ts";
5
import { execProcess } from "../../../src/core/process.ts";
6
7
// Test 1: Auto-detection from _extension.yml
8
const verifyPackagesCreated: Verify = {
9
name: "Verify typst/packages directory was created",
10
verify: async () => {
11
const packagesDir = "_extensions/test-format/typst/packages";
12
assert(
13
existsSync(packagesDir),
14
`Expected typst/packages directory not found: ${packagesDir}`,
15
);
16
},
17
};
18
19
const verifyExamplePackageCached: Verify = {
20
name: "Verify @preview/example package was cached",
21
verify: async () => {
22
const packageDir =
23
"_extensions/test-format/typst/packages/preview/example/0.1.0";
24
assert(
25
existsSync(packageDir),
26
`Expected cached package not found: ${packageDir}`,
27
);
28
29
// Verify typst.toml exists in the package
30
const manifestPath = `${packageDir}/typst.toml`;
31
assert(
32
existsSync(manifestPath),
33
`Expected package manifest not found: ${manifestPath}`,
34
);
35
},
36
};
37
38
testQuartoCmd(
39
"call",
40
["typst-gather"],
41
[verifyPackagesCreated, verifyExamplePackageCached],
42
{
43
cwd: () => "smoke/typst-gather",
44
},
45
"typst-gather caches preview packages from extension templates",
46
);
47
48
// Test 2: Config file with rootdir
49
const verifyConfigPackagesCreated: Verify = {
50
name: "Verify typst/packages directory was created via config",
51
verify: async () => {
52
const packagesDir = "_extensions/config-format/typst/packages";
53
assert(
54
existsSync(packagesDir),
55
`Expected typst/packages directory not found: ${packagesDir}`,
56
);
57
},
58
};
59
60
const verifyConfigExamplePackageCached: Verify = {
61
name: "Verify @preview/example package was cached via config",
62
verify: async () => {
63
const packageDir =
64
"_extensions/config-format/typst/packages/preview/example/0.1.0";
65
assert(
66
existsSync(packageDir),
67
`Expected cached package not found: ${packageDir}`,
68
);
69
70
const manifestPath = `${packageDir}/typst.toml`;
71
assert(
72
existsSync(manifestPath),
73
`Expected package manifest not found: ${manifestPath}`,
74
);
75
},
76
};
77
78
testQuartoCmd(
79
"call",
80
["typst-gather"],
81
[verifyConfigPackagesCreated, verifyConfigExamplePackageCached],
82
{
83
cwd: () => "smoke/typst-gather/with-config",
84
},
85
"typst-gather uses rootdir from config file",
86
);
87
88
// Test 3: --init-config generates config file
89
const verifyInitConfigCreated: Verify = {
90
name: "Verify typst-gather.toml was created",
91
verify: async () => {
92
assert(
93
existsSync("typst-gather.toml"),
94
"Expected typst-gather.toml to be created",
95
);
96
97
// Read and verify content has rootdir
98
const content = Deno.readTextFileSync("typst-gather.toml");
99
assert(
100
content.includes("rootdir"),
101
"Expected typst-gather.toml to contain rootdir",
102
);
103
assert(
104
content.includes("_extensions/test-format"),
105
"Expected rootdir to point to extension directory",
106
);
107
},
108
};
109
110
testQuartoCmd(
111
"call",
112
["typst-gather", "--init-config"],
113
[verifyInitConfigCreated],
114
{
115
cwd: () => "smoke/typst-gather",
116
teardown: async () => {
117
// Clean up generated config file
118
try {
119
Deno.removeSync("typst-gather.toml");
120
} catch {
121
// Ignore if already removed
122
}
123
},
124
},
125
"typst-gather --init-config generates config with rootdir",
126
);
127
128
// Test 4: @local package is copied when [local] section is configured
129
const verifyLocalPackageCopied: Verify = {
130
name: "Verify @local/my-local-pkg was copied",
131
verify: async () => {
132
const packageDir =
133
"_extensions/local-format/typst/packages/local/my-local-pkg/0.1.0";
134
assert(
135
existsSync(packageDir),
136
`Expected local package not found: ${packageDir}`,
137
);
138
139
const manifestPath = `${packageDir}/typst.toml`;
140
assert(
141
existsSync(manifestPath),
142
`Expected package manifest not found: ${manifestPath}`,
143
);
144
145
const libPath = `${packageDir}/lib.typ`;
146
assert(existsSync(libPath), `Expected lib.typ not found: ${libPath}`);
147
},
148
};
149
150
testQuartoCmd(
151
"call",
152
["typst-gather"],
153
[verifyLocalPackageCopied],
154
{
155
cwd: () => "smoke/typst-gather/with-local",
156
teardown: async () => {
157
// Clean up copied packages
158
try {
159
Deno.removeSync("_extensions/local-format/typst", { recursive: true });
160
} catch {
161
// Ignore if already removed
162
}
163
},
164
},
165
"typst-gather copies @local packages when configured",
166
);
167
168
// Test 5: --init-config detects @local imports and generates [local] section
169
const verifyInitConfigWithLocal: Verify = {
170
name: "Verify --init-config detects @local imports",
171
verify: async () => {
172
assert(
173
existsSync("typst-gather.toml"),
174
"Expected typst-gather.toml to be created",
175
);
176
177
const content = Deno.readTextFileSync("typst-gather.toml");
178
assert(
179
content.includes("[local]"),
180
"Expected typst-gather.toml to contain [local] section",
181
);
182
assert(
183
content.includes("my-local-pkg"),
184
"Expected typst-gather.toml to reference my-local-pkg",
185
);
186
assert(
187
content.includes("@local/my-local-pkg"),
188
"Expected typst-gather.toml to show found @local import",
189
);
190
},
191
};
192
193
testQuartoCmd(
194
"call",
195
["typst-gather", "--init-config"],
196
[verifyInitConfigWithLocal],
197
{
198
cwd: () => "smoke/typst-gather/with-local",
199
setup: async () => {
200
// Remove existing config so --init-config can run
201
try {
202
Deno.renameSync("typst-gather.toml", "typst-gather.toml.bak");
203
} catch {
204
// Ignore if doesn't exist
205
}
206
},
207
teardown: async () => {
208
// Restore original config and clean up generated one
209
try {
210
Deno.removeSync("typst-gather.toml");
211
} catch {
212
// Ignore
213
}
214
try {
215
Deno.renameSync("typst-gather.toml.bak", "typst-gather.toml");
216
} catch {
217
// Ignore
218
}
219
},
220
},
221
"typst-gather --init-config detects @local imports",
222
);
223
224
// Test 6: Rendering a project-based typst document with no package imports
225
// stages no packages (no preview/ or local/ dirs created)
226
const noPackagesProjectDir =
227
"docs/smoke-all/typst/no-packages-project";
228
229
const verifyNoPackagesStaged: Verify = {
230
name: "Verify no packages staged for document with no imports",
231
verify: async () => {
232
const scratchPackages = join(noPackagesProjectDir, ".quarto/typst/packages");
233
const previewDir = join(scratchPackages, "preview");
234
const localDir = join(scratchPackages, "local");
235
assert(
236
!existsSync(previewDir),
237
`Expected no preview/ dir but found: ${previewDir}`,
238
);
239
assert(
240
!existsSync(localDir),
241
`Expected no local/ dir but found: ${localDir}`,
242
);
243
},
244
};
245
246
testQuartoCmd(
247
"render",
248
[join(noPackagesProjectDir, "index.qmd"), "--to", "typst"],
249
[verifyNoPackagesStaged],
250
{
251
teardown: async () => {
252
try {
253
Deno.removeSync(join(noPackagesProjectDir, ".quarto"), {
254
recursive: true,
255
});
256
Deno.removeSync(join(noPackagesProjectDir, "index.pdf"));
257
} catch {
258
// Ignore
259
}
260
},
261
},
262
"no packages staged when typst document has no imports",
263
);
264
265
// Helper to run quarto as an external process and capture exit code
266
async function runQuarto(
267
args: string[],
268
cwd: string,
269
env?: Record<string, string>,
270
): Promise<{ success: boolean; stdout: string; stderr: string }> {
271
const quartoCmd = Deno.build.os === "windows" ? "quarto.cmd" : "quarto";
272
const quartoPath = join(
273
Deno.cwd(),
274
"..",
275
"package/dist/bin",
276
quartoCmd,
277
);
278
const result = await execProcess({
279
cmd: quartoPath,
280
args,
281
cwd,
282
stdout: "piped",
283
stderr: "piped",
284
env: env ? { ...Deno.env.toObject(), ...env } : undefined,
285
});
286
return {
287
success: result.success,
288
stdout: result.stdout || "",
289
stderr: result.stderr || "",
290
};
291
}
292
293
// Test 7: --init-config errors when typst-gather.toml already exists
294
unitTest(
295
"typst-gather --init-config errors when config already exists",
296
async () => {
297
const cwd = join(Deno.cwd(), "smoke/typst-gather");
298
// Create a typst-gather.toml so --init-config should fail
299
const configPath = join(cwd, "typst-gather.toml");
300
try {
301
Deno.writeTextFileSync(configPath, "# existing config\n");
302
const result = await runQuarto(
303
["call", "typst-gather", "--init-config"],
304
cwd,
305
);
306
assert(!result.success, "Expected --init-config to fail when config exists");
307
} finally {
308
try {
309
Deno.removeSync(configPath);
310
} catch {
311
// Ignore
312
}
313
}
314
},
315
);
316
317
// Test 8: --init-config errors when no extension directory found
318
unitTest(
319
"typst-gather --init-config errors with no extension directory",
320
async () => {
321
const cwd = join(Deno.cwd(), "smoke/typst-gather/no-extension");
322
const result = await runQuarto(
323
["call", "typst-gather", "--init-config"],
324
cwd,
325
);
326
assert(
327
!result.success,
328
"Expected --init-config to fail with no extension",
329
);
330
},
331
);
332
333
// Test 9: --init-config with extension that has no typst entries
334
// The extension has no typst template/template-partials, so extractTypstFiles
335
// returns empty. initConfig logs a warning but still generates a config with
336
// placeholder discover. This is not an error — it's a valid starting point.
337
unitTest(
338
"typst-gather --init-config warns with empty extension (no typst entries)",
339
async () => {
340
const cwd = join(Deno.cwd(), "smoke/typst-gather/empty-extension");
341
const result = await runQuarto(
342
["call", "typst-gather", "--init-config"],
343
cwd,
344
);
345
// initConfig still generates a config file with placeholders
346
const configPath = join(cwd, "typst-gather.toml");
347
try {
348
if (result.success) {
349
assert(existsSync(configPath), "Expected config file to be created");
350
const content = Deno.readTextFileSync(configPath);
351
// Should have placeholder discover comment
352
assert(
353
content.includes("discover"),
354
"Expected discover in generated config",
355
);
356
} else {
357
// If it failed, that's also acceptable — no typst files found
358
assert(true);
359
}
360
} finally {
361
try {
362
Deno.removeSync(configPath);
363
} catch {
364
// Ignore
365
}
366
}
367
},
368
);
369
370
// Test 10: --init-config detects @local imports from extension template.
371
// Note: --init-config only passes `discover` paths, not `[local]` config,
372
// so typst-gather analyze can see the direct @local import but cannot follow
373
// it to find transitive @preview deps (it doesn't know where the local
374
// package lives). The transitive resolution happens at gather time with the
375
// full config.
376
const verifyTransitiveDeps: Verify = {
377
name: "Verify --init-config detects @local import from template",
378
verify: async () => {
379
assert(
380
existsSync("typst-gather.toml"),
381
"Expected typst-gather.toml to be created",
382
);
383
384
const content = Deno.readTextFileSync("typst-gather.toml");
385
// Should have the @local import detected
386
assert(
387
content.includes("[local]"),
388
"Expected [local] section",
389
);
390
assert(
391
content.includes("dep-pkg"),
392
"Expected dep-pkg in [local] section",
393
);
394
},
395
};
396
397
testQuartoCmd(
398
"call",
399
["typst-gather", "--init-config"],
400
[verifyTransitiveDeps],
401
{
402
cwd: () => "smoke/typst-gather/with-transitive-deps",
403
setup: async () => {
404
// Rename existing config so --init-config can run
405
try {
406
Deno.renameSync("typst-gather.toml", "typst-gather.toml.bak");
407
} catch {
408
// Ignore if doesn't exist
409
}
410
},
411
teardown: async () => {
412
// Restore original config and clean up generated one
413
try {
414
Deno.removeSync("typst-gather.toml");
415
} catch {
416
// Ignore
417
}
418
try {
419
Deno.renameSync("typst-gather.toml.bak", "typst-gather.toml");
420
} catch {
421
// Ignore
422
}
423
},
424
},
425
"typst-gather --init-config detects transitive deps from @local",
426
);
427
428
// Test 11: Staging falls back to copy-all when typst-gather binary is missing
429
unitTest(
430
"staging falls back when typst-gather binary is missing",
431
async () => {
432
// Render a document that has packages, with QUARTO_TYPST_GATHER pointing
433
// to a nonexistent binary. The render should still succeed because
434
// analyzeNeededPackages catches the error and falls back to stageAll.
435
const projectDir = join(
436
Deno.cwd(),
437
"docs/smoke-all/typst/marginalia-only-project",
438
);
439
const result = await runQuarto(
440
["render", "index.qmd", "--to", "typst"],
441
projectDir,
442
{ QUARTO_TYPST_GATHER: "/nonexistent/typst-gather-binary" },
443
);
444
assert(
445
result.success,
446
`Expected render to succeed with fallback staging, but failed:\n${result.stderr}`,
447
);
448
// Clean up .quarto so fallback staging doesn't pollute subsequent tests
449
const quartoDir = join(projectDir, ".quarto");
450
if (existsSync(quartoDir)) {
451
Deno.removeSync(quartoDir, { recursive: true });
452
}
453
},
454
);
455
456
// Test 12: Staging falls back when typst-gather analyze fails (non-zero exit)
457
unitTest(
458
"staging falls back when typst-gather analyze returns non-zero",
459
async () => {
460
// Use a real executable that will exit non-zero when called with
461
// typst-gather's arguments. On Unix, /usr/bin/false always exits 1.
462
// On Windows, use the quarto binary itself which will fail when called
463
// with "analyze -" arguments.
464
const falseCmd = Deno.build.os === "windows"
465
? Deno.execPath() // deno binary will fail on "analyze -" args
466
: "/usr/bin/false";
467
const projectDir = join(
468
Deno.cwd(),
469
"docs/smoke-all/typst/marginalia-only-project",
470
);
471
const result = await runQuarto(
472
["render", "index.qmd", "--to", "typst"],
473
projectDir,
474
{ QUARTO_TYPST_GATHER: falseCmd },
475
);
476
assert(
477
result.success,
478
`Expected render to succeed with fallback staging, but failed:\n${result.stderr}`,
479
);
480
// Clean up .quarto so fallback staging doesn't pollute subsequent tests
481
const quartoDir = join(projectDir, ".quarto");
482
if (existsSync(quartoDir)) {
483
Deno.removeSync(quartoDir, { recursive: true });
484
}
485
},
486
);
487
488
// Test 13: Rendering a project using .column-margin stages only marginalia
489
const marginaliaProjectDir =
490
"docs/smoke-all/typst/marginalia-only-project";
491
492
const verifyOnlyMarginaliaStaged: Verify = {
493
name: "Verify only marginalia package is staged",
494
verify: async () => {
495
const scratchPackages = join(marginaliaProjectDir, ".quarto/typst/packages");
496
const previewDir = join(scratchPackages, "preview");
497
498
// marginalia should be staged
499
const marginaliaDir = join(previewDir, "marginalia");
500
assert(
501
existsSync(marginaliaDir),
502
`Expected marginalia package but not found: ${marginaliaDir}`,
503
);
504
505
// No other packages should be staged
506
const unwanted = [
507
"fontawesome",
508
"showybox",
509
"theorion",
510
"octique",
511
"orange-book",
512
];
513
for (const pkg of unwanted) {
514
const pkgDir = join(previewDir, pkg);
515
assert(
516
!existsSync(pkgDir),
517
`Expected ${pkg} NOT to be staged but found: ${pkgDir}`,
518
);
519
}
520
521
// No local packages should be staged
522
const localDir = join(scratchPackages, "local");
523
assert(
524
!existsSync(localDir),
525
`Expected no local/ dir but found: ${localDir}`,
526
);
527
},
528
};
529
530
testQuartoCmd(
531
"render",
532
[join(marginaliaProjectDir, "index.qmd"), "--to", "typst"],
533
[verifyOnlyMarginaliaStaged],
534
{
535
teardown: async () => {
536
try {
537
Deno.removeSync(join(marginaliaProjectDir, ".quarto"), {
538
recursive: true,
539
});
540
Deno.removeSync(join(marginaliaProjectDir, "index.pdf"));
541
} catch {
542
// Ignore
543
}
544
},
545
},
546
"only marginalia staged when typst document uses column-margin",
547
);
548
549