diff --git a/test/e2e/bundle.test.ts b/test/e2e/bundle.test.ts index 529044f..2d17d44 100644 --- a/test/e2e/bundle.test.ts +++ b/test/e2e/bundle.test.ts @@ -1,50 +1,10 @@ -import { readdirSync, readFileSync } from 'node:fs' -import { isBuiltin } from 'node:module' -import { join, relative } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' -import { init, parse } from 'es-module-lexer' -import { resolveModuleURL } from 'exsolve' -import { expect, it } from 'vitest' +import { fileURLToPath } from 'node:url' +import { it } from 'vitest' +import { expectSelfContainedBundle } from '../server-bundle' const serverDir = fileURLToPath(new URL('../../playground/.output/server/', import.meta.url)) -// Optional peers imported behind a try/catch, absent unless a site installs them. -const OPTIONAL_IMPORTS = new Set([ - // `@nuxtjs/mcp-toolkit` Code Mode. - 'secure-exec', -]) - -/** The bundled chunks: the traced `node_modules/` are third-party files Nitro copied as they are. */ -function bundledFiles(): string[] { - return readdirSync(serverDir, { withFileTypes: true, recursive: true }) - .filter((entry) => entry.isFile() && entry.name.endsWith('.mjs')) - .map((entry) => join(entry.parentPath, entry.name)) - .filter((file) => !relative(serverDir, file).startsWith('node_modules')) -} - // A lazy chunk (a component, a route) only fails once requested, so the route crawl alone can miss it. it('every import in the server bundle resolves inside the build output', async () => { - await init() - const files = bundledFiles() - const broken: string[] = [] - let checked = 0 - - for (const file of files) { - const [imports] = parse(readFileSync(file, 'utf8')) - for (const entry of imports) { - // `import.meta`, computed dynamic imports and template globs name no single module. - const specifier = entry.type === 'dynamic' && entry.glob ? undefined : entry.specifier - if (!specifier || isBuiltin(specifier) || OPTIONAL_IMPORTS.has(specifier)) continue - checked++ - const resolved = resolveModuleURL(specifier, { from: pathToFileURL(file), try: true }) - // Resolving into the repo's own `node_modules` works locally but not once deployed. - if (!resolved?.startsWith(pathToFileURL(serverDir).href)) { - broken.push(`${relative(serverDir, file)} imports ${specifier}`) - } - } - } - - // Guards against a vacuous pass, e.g. the lexer reporting no specifiers after an API change. - expect(checked).toBeGreaterThan(files.length) - expect(broken).toEqual([]) + await expectSelfContainedBundle(serverDir) }) diff --git a/test/server-bundle.ts b/test/server-bundle.ts new file mode 100644 index 0000000..e6d2ff3 --- /dev/null +++ b/test/server-bundle.ts @@ -0,0 +1,48 @@ +import { readdirSync, readFileSync } from 'node:fs' +import { isBuiltin } from 'node:module' +import { join, relative } from 'node:path' +import { pathToFileURL } from 'node:url' +import { init, parse } from 'es-module-lexer' +import { resolveModuleURL } from 'exsolve' +import { expect } from 'vitest' + +// Optional peers imported behind a try/catch, absent unless a site installs them. +const OPTIONAL_IMPORTS = new Set([ + // `@nuxtjs/mcp-toolkit` Code Mode. + 'secure-exec', +]) + +/** The bundled chunks: the traced `node_modules/` are third-party files Nitro copied as they are. */ +function bundledFiles(serverDir: string): string[] { + return readdirSync(serverDir, { withFileTypes: true, recursive: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.mjs')) + .map((entry) => join(entry.parentPath, entry.name)) + .filter((file) => !relative(serverDir, file).startsWith('node_modules')) +} + +/** Asserts every import in a Nitro server bundle resolves inside `serverDir`. */ +export async function expectSelfContainedBundle(serverDir: string) { + await init() + const files = bundledFiles(serverDir) + const broken: string[] = [] + let checked = 0 + + for (const file of files) { + const [imports] = parse(readFileSync(file, 'utf8')) + for (const entry of imports) { + // `import.meta`, computed dynamic imports and template globs name no single module. + const specifier = entry.type === 'dynamic' && entry.glob ? undefined : entry.specifier + if (!specifier || isBuiltin(specifier) || OPTIONAL_IMPORTS.has(specifier)) continue + checked++ + const resolved = resolveModuleURL(specifier, { from: pathToFileURL(file), try: true }) + // Resolving into the repo's own `node_modules` works locally but not once deployed. + if (!resolved?.startsWith(pathToFileURL(serverDir).href)) { + broken.push(`${relative(serverDir, file)} imports ${specifier}`) + } + } + } + + // Guards against a vacuous pass, e.g. the lexer reporting no specifiers after an API change. + expect(checked).toBeGreaterThan(files.length) + expect(broken).toEqual([]) +} diff --git a/test/vercel/output.test.ts b/test/vercel/output.test.ts index 0009b49..175534d 100644 --- a/test/vercel/output.test.ts +++ b/test/vercel/output.test.ts @@ -2,6 +2,7 @@ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs' import { join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' +import { expectSelfContainedBundle } from '../server-bundle' const outputDir = fileURLToPath(new URL('../../playground/.vercel/output/', import.meta.url)) const functionsDir = join(outputDir, 'functions') @@ -151,6 +152,11 @@ describe('server function', () => { expect(bytes / 1024 / 1024).toBeLessThan(MAX_FUNCTION_MB) }) + // Vercel uploads the function directory alone, with its own traced `node_modules/`. + it('resolves every import inside the function', async () => { + await expectSelfContainedBundle(serverDir) + }) + // Without it a cold start walks the content repository on GitHub. it('bundles the content snapshot and its manifest, stamped with one commit', async () => { const snapshot = await readRawAsset<{ ref: string; items: Array<{ path: string }> }>('snapshot')