diff --git a/.gitignore b/.gitignore index 92973ce2bf..79be6fa3c6 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,4 @@ test/fixture/functions # Generated types *.d.ts !runtime-meta.d.ts +test/unit/.tmp-server-assets-perf/ diff --git a/docs/1.guide/8.assets.md b/docs/1.guide/8.assets.md index df591bba0b..c4f01006e9 100644 --- a/docs/1.guide/8.assets.md +++ b/docs/1.guide/8.assets.md @@ -121,3 +121,23 @@ export default defineEventHandler(async (event) => { return html }) ``` + +### Large catalogs (`embed`) + +By default each file becomes a lazy Rollup `raw:` module (`embed: true`). For directories with many small files you can opt in: + +| `embed` | Behavior | +| --- | --- | +| `true` (default) | One `raw:` chunk per file (unchanged) | +| `"inline"` | Single virtual module (fast builds; larger server entry) | +| `false` | Copy to `server/assets/` and read from disk at runtime (Node/Bun/Deno) | + +```ts +export default defineNitroConfig({ + serverAssets: [{ + baseName: 'i18n', + dir: './i18n-data', + embed: 'inline', // or false + }] +}) +``` diff --git a/src/core/utils/fs-tree.ts b/src/core/utils/fs-tree.ts index 0622e455f7..ad7e0e0e26 100644 --- a/src/core/utils/fs-tree.ts +++ b/src/core/utils/fs-tree.ts @@ -7,6 +7,13 @@ import prettyBytes from "pretty-bytes"; import { isTest } from "std-env"; import { runParallel } from "./parallel"; +/** + * Build a printable size tree for the server output directory. + * + * Files under `chunks/raw/` (one Rollup module per `serverAssets` file) are + * summarized with `stat` only. Gzipping each of them dominated build wall time + * for large catalogs (nitrojs/nitro#1833). + */ export async function generateFSTree( dir: string, options: { compressedSizes?: boolean } = {} @@ -15,7 +22,10 @@ export async function generateFSTree( return; } - const files = await globby("**/*.*", { cwd: dir, ignore: ["*.map"] }); + const files = await globby("**/*.*", { + cwd: dir, + ignore: ["*.map", "chunks/raw/**"], + }); const items: { file: string; path: string; size: number; gzip: number }[] = []; @@ -43,9 +53,9 @@ export async function generateFSTree( let treeText = ""; for (const [index, item] of items.entries()) { - let dir = dirname(item.file); - if (dir === ".") { - dir = ""; + let _dir = dirname(item.file); + if (_dir === ".") { + _dir = ""; } const rpath = relative(process.cwd(), item.path); const treeChar = index === items.length - 1 ? "└─" : "├─"; @@ -69,6 +79,25 @@ export async function generateFSTree( totalGzip += item.gzip; } + const rawFiles = await globby("chunks/raw/**/*.*", { + cwd: dir, + ignore: ["*.map"], + }); + if (rawFiles.length > 0) { + let rawSize = 0; + await runParallel( + new Set(rawFiles), + async (file) => { + rawSize += (await fsp.stat(resolve(dir, file))).size; + }, + { concurrency: 25 } + ); + totalSize += rawSize; + treeText += colors.gray( + ` ├─ chunks/raw/* (${rawFiles.length} files, ${prettyBytes(rawSize)}, gzip skipped)\n` + ); + } + treeText += `${colors.cyan("Σ Total size:")} ${prettyBytes( totalSize + totalNodeModulesSize )}`; diff --git a/src/rollup/plugins/raw.ts b/src/rollup/plugins/raw.ts index 7439279a3e..f704b70a70 100644 --- a/src/rollup/plugins/raw.ts +++ b/src/rollup/plugins/raw.ts @@ -89,6 +89,9 @@ function isBinary(id: string) { return true; } +/** Shared with server-assets inline embedding. */ +export { isBinary }; + function getHelpers() { const js = String.raw; return js` diff --git a/src/rollup/plugins/server-assets.ts b/src/rollup/plugins/server-assets.ts index 22cace2412..d16485447d 100644 --- a/src/rollup/plugins/server-assets.ts +++ b/src/rollup/plugins/server-assets.ts @@ -2,19 +2,79 @@ import { promises as fsp } from "node:fs"; import createEtag from "etag"; import { globby } from "globby"; import mime from "mime"; -import type { Nitro } from "nitropack/types"; -import { resolve } from "pathe"; +import type { Nitro, ServerAssetDir } from "nitropack/types"; +import { join, relative, resolve } from "pathe"; import type { Plugin } from "rollup"; import { normalizeKey } from "unstorage"; +import { runParallel } from "../../core/utils/parallel"; +import { isBinary } from "./raw"; import { virtual } from "./virtual"; interface ResolvedAsset { + /** Absolute source path (used for `raw:` imports). */ fsPath: string; meta: { type?: string; etag?: string; mtime?: string; }; + /** `embed: "inline"` — utf8 text or base64 payload. */ + data?: string; + encoding?: "base64"; + /** + * `embed: false` — path relative to `output.serverDir` + * (same idea as public-assets `assets[id].path` + `readAsset`). + */ + path?: string; + /** `embed: false` — decode as Uint8Array vs utf8 string. */ + binary?: boolean; +} + +type EmbedMode = boolean | "inline"; + +function resolveEmbedMode(asset: ServerAssetDir): EmbedMode { + // Default `true` preserves historical one-raw-module-per-file behavior. + return asset.embed ?? true; +} + +/** + * Path relative to `output.serverDir` — same convention as `public-assets` + * (`resolve(dirname(import.meta.url), path)` at runtime). + */ +function pathFromServerDir(nitro: Nitro, absPath: string): string { + return relative(nitro.options.output.serverDir, absPath).replace(/\\/g, "/"); +} + +async function collectAssetMeta( + asset: ServerAssetDir, + _id: string +): Promise<{ + id: string; + fsPath: string; + data: Buffer; + meta: ResolvedAsset["meta"]; +}> { + const fsPath = resolve(asset.dir, _id); + const id = normalizeKey(asset.baseName + "/" + _id); + // @ts-ignore TODO: Use mime@2 types + let type = mime.getType(id) || "text/plain"; + if (type.startsWith("text")) { + type += "; charset=utf-8"; + } + const [data, stat] = await Promise.all([ + fsp.readFile(fsPath), + fsp.stat(fsPath), + ]); + return { + id, + fsPath, + data, + meta: { + type, + etag: createEtag(data), + mtime: stat.mtime.toJSON(), + }, + }; } export function serverAssets(nitro: Nitro): Plugin { @@ -26,33 +86,92 @@ export function serverAssets(nitro: Nitro): Plugin { ); } - // Production: Bundle assets + const fsAssetDirs = nitro.options.serverAssets.filter( + (a) => resolveEmbedMode(a) === false + ); + + // Opt-in only: copy filesystem embeds after compile (does not run for default embed:true). + if (fsAssetDirs.length > 0) { + nitro.hooks.hook("compiled", async () => { + for (const asset of fsAssetDirs) { + const dest = join( + nitro.options.output.serverDir, + "assets", + asset.baseName + ); + await fsp.cp(asset.dir, dest, { recursive: true, force: true }); + } + }); + } + + // Production: Bundle assets (default) or keep on disk when embed:false return virtual( { "#nitro-internal-virtual/server-assets": async () => { - // Scan all assets - const assets: Record = {}; + const inlineAssets: Record = {}; + const rawAssets: Record = {}; + const diskAssets: Record = {}; + for (const asset of nitro.options.serverAssets) { + const mode = resolveEmbedMode(asset); const files = await globby(asset.pattern || "**/*", { cwd: asset.dir, absolute: false, ignore: asset.ignore, }); - for (const _id of files) { - const fsPath = resolve(asset.dir, _id); - const id = asset.baseName + "/" + _id; - assets[id] = { fsPath, meta: {} }; - // @ts-ignore TODO: Use mime@2 types - let type = mime.getType(id) || "text/plain"; - if (type.startsWith("text")) { - type += "; charset=utf-8"; - } - const etag = createEtag(await fsp.readFile(fsPath)); - const mtime = await fsp.stat(fsPath).then((s) => s.mtime.toJSON()); - assets[id].meta = { type, etag, mtime }; + + const { errors } = await runParallel( + new Set(files), + async (_id) => { + const { id, fsPath, data, meta } = await collectAssetMeta( + asset, + _id + ); + + if (mode === false) { + // public-assets-node style: meta + relative path, readFile at runtime + diskAssets[id] = { + fsPath, + meta, + path: pathFromServerDir( + nitro, + join( + nitro.options.output.serverDir, + "assets", + asset.baseName, + _id + ) + ), + binary: isBinary(fsPath), + }; + } else if (mode === "inline") { + const binary = isBinary(fsPath); + inlineAssets[id] = { + fsPath, + meta, + data: binary + ? data.toString("base64") + : data.toString("utf8"), + encoding: binary ? "base64" : undefined, + }; + } else { + // embed: true (default) — historical lazy raw: modules + rawAssets[id] = { fsPath, meta }; + } + }, + { concurrency: 25 } + ); + + if (errors.length > 0) { + throw new Error( + `Failed to process some server assets:\n- ${errors + .map((e) => (e instanceof Error ? e.message : String(e))) + .join("\n- ")}` + ); } } - return getAssetProd(assets); + + return getAssetProd(inlineAssets, rawAssets, diskAssets); }, }, nitro.vfs @@ -73,20 +192,117 @@ for (const asset of serverAssets) { }`; } -function getAssetProd(assets: Record) { - return ` -const _assets = {\n${Object.entries(assets) +/** + * Production virtual module. + * + * - raw only → historical template (byte-identical shape to pre-change Nitro) + * - otherwise → same map pattern as public-assets-data (+ raw imports / inline / disk paths) + */ +function getAssetProd( + inlineAssets: Record, + rawAssets: Record, + diskAssets: Record +) { + const hasInline = Object.keys(inlineAssets).length > 0; + const hasDisk = Object.keys(diskAssets).length > 0; + + // Default path unchanged: only raw: embeds → original template. + if (!hasInline && !hasDisk) { + return getAssetProdRawOnly(rawAssets); + } + + // Decode base64 like `#nitro-internal-virtual/public-assets-inline` (atob, not Buffer). + const inlineEntries = Object.entries(inlineAssets) + .map(([id, asset]) => { + const dataExpr = + asset.encoding === "base64" + ? `Uint8Array.from(atob(${JSON.stringify(asset.data)}), (c) => c.charCodeAt(0))` + : JSON.stringify(asset.data); + return ` [${JSON.stringify(id)}]: {\n data: ${dataExpr},\n meta: ${JSON.stringify(asset.meta)}\n }`; + }) + .join(",\n"); + + const rawEntries = Object.entries(rawAssets) .map( ([id, asset]) => - ` [${JSON.stringify( - normalizeKey(id) - )}]: {\n import: () => import(${JSON.stringify( + ` [${JSON.stringify(id)}]: {\n import: () => import(${JSON.stringify( "raw:" + asset.fsPath - )}).then(r => r.default || r),\n meta: ${JSON.stringify( + )}).then(r => r.default || r),\n meta: ${JSON.stringify(asset.meta)}\n }` + ) + .join(",\n"); + + const diskEntries = Object.entries(diskAssets) + .map( + ([id, asset]) => + ` [${JSON.stringify(id)}]: {\n path: ${JSON.stringify( + asset.path + )},\n binary: ${asset.binary ? "true" : "false"},\n meta: ${JSON.stringify( asset.meta )}\n }` ) - .join(",\n")}\n} + .join(",\n"); + + return ` +import { promises as fsp } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'pathe' + +const serverDir = dirname(fileURLToPath(import.meta.url)) + +const _inline = { +${inlineEntries} +} + +const _raw = { +${rawEntries} +} + +const _disk = { +${diskEntries} +} + +const normalizeKey = ${normalizeKey.toString()} + +export const assets = { + async getKeys() { + return [...Object.keys(_inline), ...Object.keys(_raw), ...Object.keys(_disk)] + }, + async hasItem (id) { + id = normalizeKey(id) + return id in _inline || id in _raw || id in _disk + }, + async getItem (id) { + id = normalizeKey(id) + if (id in _inline) return _inline[id].data + if (id in _raw) return _raw[id].import() + if (id in _disk) { + const a = _disk[id] + const buf = await fsp.readFile(resolve(serverDir, a.path)) + return a.binary ? new Uint8Array(buf) : buf.toString('utf8') + } + return null + }, + async getMeta (id) { + id = normalizeKey(id) + return _inline[id]?.meta || _raw[id]?.meta || _disk[id]?.meta || {} + } +} +`; +} + +/** Historical production template — one lazy `raw:` import per file. */ +function getAssetProdRawOnly(assets: Record) { + return ` +const _assets = { +${Object.entries(assets) + .map( + ([id, asset]) => + ` [${JSON.stringify(id)}]: {\n import: () => import(${JSON.stringify( + "raw:" + asset.fsPath + )}).then(r => r.default || r),\n meta: ${JSON.stringify(asset.meta)}\n }` + ) + .join(",\n")} +} const normalizeKey = ${normalizeKey.toString()} diff --git a/src/types/config.ts b/src/types/config.ts index 5034c64773..d24027587c 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -322,6 +322,16 @@ export interface ServerAssetDir { pattern?: string; dir: string; ignore?: string[]; + /** + * How assets are included in the production server build. + * + * - `true` (default): one lazy Rollup `raw:` module per file (historical behavior). + * - `"inline"`: embed contents into a single virtual module (much faster for many small files). + * - `false`: keep files on disk under `server/assets/` (Node/Bun/Deno; no `raw:`). + * + * @see https://nitro.build/docs/assets#server-assets + */ + embed?: boolean | "inline"; } // Storage mounts diff --git a/test/unit/server-assets-perf.test.ts b/test/unit/server-assets-perf.test.ts new file mode 100644 index 0000000000..a3fb41bafb --- /dev/null +++ b/test/unit/server-assets-perf.test.ts @@ -0,0 +1,353 @@ +import { + mkdirSync, + rmSync, + writeFileSync, + existsSync, + readdirSync, + readFileSync, +} from "node:fs"; +import { join } from "pathe"; +import { describe, expect, it } from "vitest"; +import { createNitro, build, prepare, copyPublicAssets } from "nitropack/core"; +import { listen } from "listhen"; + +const ROOT = join(import.meta.dirname, ".tmp-server-assets-perf"); +const DATA = join(ROOT, "i18n-data"); + +function genTree(dir: string, depth: number, branch: number): number { + mkdirSync(dir, { recursive: true }); + if (depth === 0) { + writeFileSync( + join(dir, "data.json"), + JSON.stringify({ v: 1, s: "x".repeat(200) }) + ); + return 1; + } + let n = 0; + for (let i = 0; i < branch; i++) { + n += genTree(join(dir, `l${i}`), depth - 1, branch); + } + return n; +} + +function countRawMjs(outDir: string): number { + const rawDir = join(outDir, "server/chunks/raw"); + if (!existsSync(rawDir)) { + return 0; + } + let n = 0; + const walk = (d: string) => { + for (const e of readdirSync(d, { withFileTypes: true })) { + if (e.isDirectory()) { + walk(join(d, e.name)); + } else if (e.name.endsWith(".mjs")) { + n++; + } + } + }; + walk(rawDir); + return n; +} + +async function buildWithAssets( + outName: string, + serverAssets: { + baseName: string; + dir: string; + embed?: boolean | "inline"; + pattern?: string; + ignore?: string[]; + }[], + handlers?: { route: string; handler: string }[] +) { + const outDir = join(ROOT, outName); + rmSync(outDir, { recursive: true, force: true }); + const t0 = performance.now(); + // node-listener exports `listener` without auto-listen (node-server binds :3000 on import). + const nitro = await createNitro({ + rootDir: ROOT, + srcDir: ROOT, + output: { dir: outDir }, + preset: "node-listener", + minify: false, + typescript: { generateTsConfig: false }, + logging: { compressedSizes: false }, + serverAssets, + handlers, + }); + await prepare(nitro); + await copyPublicAssets(nitro); + await build(nitro); + return { outDir, ms: performance.now() - t0 }; +} + +describe("serverAssets large catalogs", () => { + // depth=4, branch=5 → 625 JSON files + const fileCount = (() => { + rmSync(ROOT, { recursive: true, force: true }); + return genTree(DATA, 4, 5); + })(); + + // Tiny mixed fixture for runtime / binary / ignore tests + const MIX = join(ROOT, "mixed"); + mkdirSync(join(MIX, "txt"), { recursive: true }); + writeFileSync(join(MIX, "txt", "hello.json"), JSON.stringify({ ok: true })); + writeFileSync(join(MIX, "txt", "skip.me"), "nope"); + writeFileSync( + join(MIX, "txt", "pixel.bin"), + Buffer.from([0x00, 0x01, 0x02, 0xff]) + ); + + const handlerPath = join(ROOT, "routes", "read.get.ts"); + mkdirSync(join(ROOT, "routes"), { recursive: true }); + writeFileSync( + handlerPath, + `export default defineEventHandler(async (event) => { + const id = getQuery(event).id as string + const storage = useStorage('assets:i18n') + const item = await storage.getItem(id) + return { + has: await storage.hasItem(id), + // JSON-safe: Uint8Array → number[] (binary inline path) + item: item instanceof Uint8Array ? Array.from(item) : item, + meta: await storage.getMeta(id), + keys: await storage.getKeys(), + } +}) +` + ); + + it("generates hundreds of fixture files", () => { + expect(fileCount).toBe(625); + }); + + it( + "omit embed ≡ historical one raw: chunk per file (default unchanged)", + { timeout: 120_000 }, + async () => { + const { outDir } = await buildWithAssets("out-raw", [ + { baseName: "i18n", dir: DATA }, + ]); + expect(countRawMjs(outDir)).toBe(fileCount); + // Default template path: no fs mounts / no _inline bag + const entry = readFileSync(join(outDir, "server/index.mjs"), "utf8"); + // Virtual assets land in chunks — spot-check no assets/ copy for default + expect(existsSync(join(outDir, "server/assets/i18n"))).toBe(false); + void entry; + } + ); + + it( + "embed:true explicit matches omit (same raw: count)", + { timeout: 120_000 }, + async () => { + const { outDir } = await buildWithAssets("out-raw-explicit", [ + { baseName: "i18n", dir: join(MIX, "txt"), embed: true }, + ]); + // 3 files in MIX/txt (hello.json, skip.me, pixel.bin) + expect(countRawMjs(outDir)).toBe(3); + expect(existsSync(join(outDir, "server/assets"))).toBe(false); + } + ); + + it( + "embed:false copies assets, skips raw:, and serves getItem at runtime", + { timeout: 120_000 }, + async () => { + const { outDir } = await buildWithAssets( + "out-fs", + [{ baseName: "i18n", dir: DATA, embed: false }], + [{ route: "/read", handler: handlerPath }] + ); + expect(countRawMjs(outDir)).toBe(0); + expect( + existsSync(join(outDir, "server/assets/i18n/l0/l0/l0/l0/data.json")) + ).toBe(true); + + const { listener } = await import(join(outDir, "server/index.mjs")); + const server = await listen(listener, { port: 0 }); + try { + const url = `${server.url}read?id=${encodeURIComponent("l0/l0/l0/l0/data.json")}`; + const res = await fetch(url).then((r) => r.json()); + expect(res.has).toBe(true); + const item = + typeof res.item === "string" ? JSON.parse(res.item) : res.item; + expect(item).toMatchObject({ v: 1 }); + expect(res.meta?.type).toMatch(/json/); + expect(res.meta?.etag).toBeTruthy(); + expect(res.keys.length).toBeGreaterThanOrEqual(fileCount); + } finally { + await server.close(); + } + } + ); + + it( + "embed:'inline' has no raw: chunks and returns JSON + meta", + { timeout: 120_000 }, + async () => { + const { outDir, ms } = await buildWithAssets( + "out-inline", + [{ baseName: "i18n", dir: join(MIX, "txt"), embed: "inline" }], + [{ route: "/read", handler: handlerPath }] + ); + expect(countRawMjs(outDir)).toBe(0); + expect(ms).toBeLessThan(15_000); + + const { listener } = await import(join(outDir, "server/index.mjs")); + const server = await listen(listener, { port: 0 }); + try { + const res = await fetch( + `${server.url}read?id=${encodeURIComponent("hello.json")}` + ).then((r) => r.json()); + expect(res.has).toBe(true); + // Inline text may be string or already-parsed depending on unstorage + const item = + typeof res.item === "string" ? JSON.parse(res.item) : res.item; + expect(item).toEqual({ ok: true }); + expect(res.meta?.etag).toBeTruthy(); + expect(res.meta?.mtime).toBeTruthy(); + } finally { + await server.close(); + } + } + ); + + it( + "embed:'inline' preserves binary bytes", + { timeout: 60_000 }, + async () => { + const { outDir } = await buildWithAssets( + "out-inline-bin", + [{ baseName: "i18n", dir: join(MIX, "txt"), embed: "inline" }], + [{ route: "/read", handler: handlerPath }] + ); + const { listener } = await import(join(outDir, "server/index.mjs")); + const server = await listen(listener, { port: 0 }); + try { + const res = await fetch( + `${server.url}read?id=${encodeURIComponent("pixel.bin")}` + ).then((r) => r.json()); + expect(res.has).toBe(true); + const expected = readFileSync(join(MIX, "txt", "pixel.bin")); + expect(Array.isArray(res.item)).toBe(true); + expect(Buffer.from(res.item)).toEqual(expected); + } finally { + await server.close(); + } + } + ); + + it( + "embed:'inline' respects ignore patterns", + { timeout: 60_000 }, + async () => { + const { outDir } = await buildWithAssets( + "out-inline-ignore", + [ + { + baseName: "i18n", + dir: join(MIX, "txt"), + embed: "inline", + ignore: ["**/skip.me"], + }, + ], + [{ route: "/read", handler: handlerPath }] + ); + const { listener } = await import(join(outDir, "server/index.mjs")); + const server = await listen(listener, { port: 0 }); + try { + const res = await fetch( + `${server.url}read?id=${encodeURIComponent("skip.me")}` + ).then((r) => r.json()); + expect(res.has).toBe(false); + expect(res.keys).not.toContain("skip.me"); + expect(res.keys.some((k: string) => k.includes("hello.json"))).toBe( + true + ); + } finally { + await server.close(); + } + } + ); + + it( + "default raw: path still serves getItem at runtime", + { timeout: 60_000 }, + async () => { + const { outDir } = await buildWithAssets( + "out-raw-runtime", + [{ baseName: "i18n", dir: join(MIX, "txt") }], + [{ route: "/read", handler: handlerPath }] + ); + expect(countRawMjs(outDir)).toBe(3); + const { listener } = await import(join(outDir, "server/index.mjs")); + const server = await listen(listener, { port: 0 }); + try { + const res = await fetch( + `${server.url}read?id=${encodeURIComponent("hello.json")}` + ).then((r) => r.json()); + expect(res.has).toBe(true); + const item = + typeof res.item === "string" ? JSON.parse(res.item) : res.item; + expect(item).toEqual({ ok: true }); + expect(res.meta?.type).toMatch(/json/); + expect(res.meta?.etag).toBeTruthy(); + } finally { + await server.close(); + } + } + ); + + it( + "embed:'inline' respects pattern", + { timeout: 60_000 }, + async () => { + const { outDir } = await buildWithAssets( + "out-inline-pattern", + [ + { + baseName: "i18n", + dir: join(MIX, "txt"), + embed: "inline", + pattern: "**/*.json", + }, + ], + [{ route: "/read", handler: handlerPath }] + ); + const { listener } = await import(join(outDir, "server/index.mjs")); + const server = await listen(listener, { port: 0 }); + try { + const hello = await fetch( + `${server.url}read?id=${encodeURIComponent("hello.json")}` + ).then((r) => r.json()); + const bin = await fetch( + `${server.url}read?id=${encodeURIComponent("pixel.bin")}` + ).then((r) => r.json()); + expect(hello.has).toBe(true); + expect(bin.has).toBe(false); + expect(hello.keys.every((k: string) => k.endsWith(".json"))).toBe(true); + } finally { + await server.close(); + } + } + ); + + it( + "mixed dirs: embed:false + default raw do not interfere", + { timeout: 120_000 }, + async () => { + const small = join(ROOT, "small-raw"); + mkdirSync(small, { recursive: true }); + writeFileSync(join(small, "a.json"), JSON.stringify({ a: 1 })); + + const { outDir } = await buildWithAssets("out-mixed", [ + { baseName: "disk", dir: DATA, embed: false }, + { baseName: "bundled", dir: small }, + ]); + expect(existsSync(join(outDir, "server/assets/disk"))).toBe(true); + expect(countRawMjs(outDir)).toBe(1); + } + ); +}); +