From 195283a72f36b04a05a62973c249f7c2a2f04380 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 15:47:21 +0200 Subject: [PATCH 1/2] perf(plugins,edge): import @frontmcp/utils from plugin-cache, plugin-dashboard and edge instead of bundling it The three packages bundled @frontmcp/utils, so their builds imported utils' own dependencies (@noble/ciphers, @noble/hashes, @enclave-vm/ast, @upstash/redis, @vercel/kv, ioredis, zod, @frontmcp/lazy-zod) without declaring them, and loaded a second copy of utils next to the SDK's. - add @frontmcp/utils to the external list of the CommonJS and ESM builds - declare @frontmcp/utils at the internal version in plugin-dashboard and edge - drop the @enclave-vm/ast dependency added in #953; utils declares it - add demo-e2e-published-imports: packs each package, fails on bundled utils code or an undeclared bare import, and loads all three under require and import in a fresh npm project with @enclave-vm/ast 2.16.0 at the top level Closes #956 --- .../e2e/enclave-2-16-top-level.e2e.spec.ts | 58 ++++++ .../e2e/helpers/published-package.ts | 178 ++++++++++++++++++ .../e2e/published-imports.e2e.spec.ts | 63 +++++++ .../jest.e2e.config.ts | 23 +++ .../demo-e2e-published-imports/project.json | 19 ++ .../tsconfig.e2e.json | 10 + .../demo-e2e-published-imports/tsconfig.json | 9 + libs/edge/package.json | 4 +- libs/edge/project.json | 4 +- plugins/plugin-cache/package.json | 1 - plugins/plugin-cache/project.json | 20 +- plugins/plugin-dashboard/package.json | 2 +- plugins/plugin-dashboard/project.json | 4 +- yarn.lock | 5 +- 14 files changed, 387 insertions(+), 13 deletions(-) create mode 100644 apps/e2e/demo-e2e-published-imports/e2e/enclave-2-16-top-level.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-published-imports/e2e/helpers/published-package.ts create mode 100644 apps/e2e/demo-e2e-published-imports/e2e/published-imports.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-published-imports/jest.e2e.config.ts create mode 100644 apps/e2e/demo-e2e-published-imports/project.json create mode 100644 apps/e2e/demo-e2e-published-imports/tsconfig.e2e.json create mode 100644 apps/e2e/demo-e2e-published-imports/tsconfig.json diff --git a/apps/e2e/demo-e2e-published-imports/e2e/enclave-2-16-top-level.e2e.spec.ts b/apps/e2e/demo-e2e-published-imports/e2e/enclave-2-16-top-level.e2e.spec.ts new file mode 100644 index 000000000..1f89bffd8 --- /dev/null +++ b/apps/e2e/demo-e2e-published-imports/e2e/enclave-2-16-top-level.e2e.spec.ts @@ -0,0 +1,58 @@ +// 1.10.9 failed here with ERR_PACKAGE_PATH_NOT_EXPORTED: bundled utils code resolved the top-level @enclave-vm/ast, which has no ./redos (#956) +import { execFileSync, spawnSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; + +import { mkdir, mkdtemp, readJSON, rm, writeJSON } from '@frontmcp/utils'; + +import { + builtDistDir, + internalClosure, + packPackage, + workspaceDistDirs, + type PackageManifest, +} from './helpers/published-package'; + +const PACKAGES = ['@frontmcp/plugin-cache', '@frontmcp/plugin-dashboard', '@frontmcp/edge']; +const TOP_LEVEL_ENCLAVE = '2.16.0'; + +function load(project: string, args: string[]): string { + const result = spawnSync(process.execPath, args, { cwd: project, encoding: 'utf8' }); + return result.status === 0 ? 'loaded' : result.stderr; +} + +describe(`a fresh npm project with @enclave-vm/ast ${TOP_LEVEL_ENCLAVE} at the top level`, () => { + let project: string; + + beforeAll(async () => { + project = await mkdtemp(path.join(tmpdir(), 'frontmcp-enclave-2-16-')); + const tarballs = path.join(project, 'tarballs'); + await mkdir(tarballs); + const distDirs = await workspaceDistDirs(); + const dependencies: Record = { '@enclave-vm/ast': TOP_LEVEL_ENCLAVE }; + for (const name of await internalClosure(PACKAGES, distDirs)) { + dependencies[name] = `file:${packPackage(await builtDistDir(distDirs, name), tarballs).tarball}`; + } + await writeJSON(path.join(project, 'package.json'), { name: 'enclave-2-16-project', private: true, dependencies }); + execFileSync('npm', ['install', '--no-audit', '--no-fund', '--ignore-scripts'], { cwd: project, stdio: 'pipe' }); + }, 300_000); + + afterAll(async () => { + if (project) await rm(project, { recursive: true, force: true }); + }); + + it(`keeps @enclave-vm/ast ${TOP_LEVEL_ENCLAVE} at the top level`, async () => { + const manifest = await readJSON( + path.join(project, 'node_modules', '@enclave-vm', 'ast', 'package.json'), + ); + expect(manifest?.version).toBe(TOP_LEVEL_ENCLAVE); + }); + + it.each(PACKAGES)('%s loads with require', (name) => { + expect(load(project, ['-e', `require(${JSON.stringify(name)})`])).toBe('loaded'); + }); + + it.each(PACKAGES)('%s loads with import', (name) => { + expect(load(project, ['--input-type=module', '-e', `await import(${JSON.stringify(name)})`])).toBe('loaded'); + }); +}); diff --git a/apps/e2e/demo-e2e-published-imports/e2e/helpers/published-package.ts b/apps/e2e/demo-e2e-published-imports/e2e/helpers/published-package.ts new file mode 100644 index 000000000..4777a4481 --- /dev/null +++ b/apps/e2e/demo-e2e-published-imports/e2e/helpers/published-package.ts @@ -0,0 +1,178 @@ +import { execFileSync } from 'node:child_process'; +import { isBuiltin } from 'node:module'; +import * as path from 'node:path'; + +import * as ts from 'typescript'; + +import { fileExists, mkdir, readdir, readFile, readJSON } from '@frontmcp/utils'; + +export const WORKSPACE = path.resolve(__dirname, '../../../../..'); + +export interface PackageManifest { + name: string; + version: string; + dependencies?: Record; + peerDependencies?: Record; + peerDependenciesMeta?: Record; + optionalDependencies?: Record; +} + +export interface PublishedScript { + path: string; + source: string; +} + +export interface PublishedPackage { + manifest: PackageManifest; + scripts: PublishedScript[]; +} + +export async function workspaceDistDirs(): Promise> { + const distDirs = new Map(); + for (const group of ['libs', 'plugins']) { + for (const entry of await readdir(path.join(WORKSPACE, group))) { + const manifest = await readJSON(path.join(WORKSPACE, group, entry, 'package.json')); + if (manifest) distDirs.set(manifest.name, path.join(WORKSPACE, group, entry, 'dist')); + } + } + return distDirs; +} + +export async function builtDistDir(distDirs: Map, name: string): Promise { + const distDir = distDirs.get(name); + if (!distDir || !(await fileExists(path.join(distDir, 'package.json')))) { + throw new Error(`${name} is not built: run its build target first`); + } + return distDir; +} + +export function packPackage(distDir: string, destination: string): { tarball: string; files: string[] } { + const output = execFileSync('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', destination], { + cwd: distDir, + encoding: 'utf8', + }); + const [result] = JSON.parse(output) as Array<{ filename: string; files: Array<{ path: string }> }>; + return { tarball: path.join(destination, result.filename), files: result.files.map((file) => file.path) }; +} + +export async function readPublishedPackage(distDir: string, workDir: string): Promise { + const { tarball, files } = packPackage(distDir, workDir); + const extracted = path.join(workDir, path.basename(tarball, '.tgz')); + await mkdir(extracted, { recursive: true }); + execFileSync('tar', ['-xzf', tarball, '-C', extracted]); + const root = path.join(extracted, 'package'); + const manifest = JSON.parse(await readFile(path.join(root, 'package.json'))) as PackageManifest; + const scripts: PublishedScript[] = []; + for (const file of files.filter((name) => /\.[cm]?js$/.test(name))) { + scripts.push({ path: file, source: await readFile(path.join(root, file)) }); + } + return { manifest, scripts }; +} + +// The @frontmcp packages npm installs with `names`: dependencies and non-optional peers, transitively +export async function internalClosure(names: readonly string[], distDirs: Map): Promise { + const closure = new Set(); + const pending = [...names]; + while (pending.length > 0) { + const name = pending.pop(); + if (!name || closure.has(name)) continue; + closure.add(name); + const manifest = await readJSON(path.join(await builtDistDir(distDirs, name), 'package.json')); + const requiredPeers = Object.keys(manifest?.peerDependencies ?? {}).filter( + (peer) => !manifest?.peerDependenciesMeta?.[peer]?.optional, + ); + const installed = [...Object.keys(manifest?.dependencies ?? {}), ...requiredPeers]; + pending.push(...installed.filter((dependency) => dependency.startsWith('@frontmcp/'))); + } + return [...closure].sort(); +} + +function parse(script: PublishedScript): ts.SourceFile { + return ts.createSourceFile(script.path, script.source, ts.ScriptTarget.Latest, false, ts.ScriptKind.JS); +} + +// esbuild's ESM output loads a CommonJS-only external through its `__require` shim +function isModuleLoad(callee: ts.Expression): boolean { + if (callee.kind === ts.SyntaxKind.ImportKeyword) return true; + return ts.isIdentifier(callee) && (callee.text === 'require' || callee.text === '__require'); +} + +export function loadedSpecifiers(script: PublishedScript): string[] { + const specifiers: string[] = []; + const visit = (node: ts.Node): void => { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + specifiers.push(node.moduleSpecifier.text); + } else if (ts.isCallExpression(node) && isModuleLoad(node.expression)) { + const [first] = node.arguments; + if (first && ts.isStringLiteralLike(first)) specifiers.push(first.text); + } + ts.forEachChild(node, visit); + }; + visit(parse(script)); + return specifiers; +} + +// undefined for relative paths, `#` imports, URLs and Node builtins +export function packageNameOf(specifier: string): string | undefined { + if (/^[./#]/.test(specifier) || specifier.includes(':') || isBuiltin(specifier)) return undefined; + const segments = specifier.split('/'); + return specifier.startsWith('@') ? segments.slice(0, 2).join('/') : segments[0]; +} + +export function undeclaredImports(published: PublishedPackage): string[] { + const { manifest } = published; + const declared = new Set([ + manifest.name, + ...Object.keys(manifest.dependencies ?? {}), + ...Object.keys(manifest.peerDependencies ?? {}), + ...Object.keys(manifest.optionalDependencies ?? {}), + ]); + const undeclared = new Set(); + for (const script of published.scripts) { + for (const specifier of loadedSpecifiers(script)) { + const name = packageNameOf(specifier); + if (name && !declared.has(name)) undeclared.add(`${script.path}: ${specifier}`); + } + } + return [...undeclared].sort(); +} + +export function exportedNames(script: PublishedScript): Set { + const names = new Set(); + for (const statement of parse(script).statements) { + if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) { + for (const element of statement.exportClause.elements) names.add(element.name.text); + } + } + return names; +} + +function definedName(node: ts.Node): string | undefined { + if ((ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) && node.name) return node.name.text; + if ( + ts.isVariableDeclaration(node) && + ts.isIdentifier(node.name) && + node.initializer && + (ts.isFunctionExpression(node.initializer) || + ts.isArrowFunction(node.initializer) || + ts.isClassExpression(node.initializer)) + ) { + return node.name.text; + } + return undefined; +} + +export function definitionsNamed(script: PublishedScript, names: ReadonlySet): string[] { + const definitions: string[] = []; + const visit = (node: ts.Node): void => { + const name = definedName(node); + if (name && names.has(name)) definitions.push(`${script.path}: ${name}`); + ts.forEachChild(node, visit); + }; + visit(parse(script)); + return definitions; +} diff --git a/apps/e2e/demo-e2e-published-imports/e2e/published-imports.e2e.spec.ts b/apps/e2e/demo-e2e-published-imports/e2e/published-imports.e2e.spec.ts new file mode 100644 index 000000000..3c784e6d7 --- /dev/null +++ b/apps/e2e/demo-e2e-published-imports/e2e/published-imports.e2e.spec.ts @@ -0,0 +1,63 @@ +// The packed JavaScript of plugin-cache, plugin-dashboard and edge imports @frontmcp/utils instead of bundling it (#956) +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; + +import { mkdtemp, readFile, rm } from '@frontmcp/utils'; + +import { + builtDistDir, + definitionsNamed, + exportedNames, + loadedSpecifiers, + readPublishedPackage, + undeclaredImports, + workspaceDistDirs, + type PublishedPackage, +} from './helpers/published-package'; + +const PACKAGES = ['@frontmcp/plugin-cache', '@frontmcp/plugin-dashboard', '@frontmcp/edge']; + +describe('published JavaScript that uses @frontmcp/utils', () => { + let workDir: string; + let distDirs: Map; + let utilsExports: Set; + + beforeAll(async () => { + workDir = await mkdtemp(path.join(tmpdir(), 'frontmcp-published-imports-')); + distDirs = await workspaceDistDirs(); + const utilsEsm = path.join(await builtDistDir(distDirs, '@frontmcp/utils'), 'esm', 'index.mjs'); + utilsExports = exportedNames({ path: 'esm/index.mjs', source: await readFile(utilsEsm) }); + }); + + afterAll(async () => { + if (workDir) await rm(workDir, { recursive: true, force: true }); + }); + + describe.each(PACKAGES)('%s', (name) => { + let published: PublishedPackage; + + beforeAll(async () => { + published = await readPublishedPackage(await builtDistDir(distDirs, name), workDir); + }); + + it('declares @frontmcp/utils at its own version', () => { + expect(published.manifest.dependencies?.['@frontmcp/utils']).toBe(published.manifest.version); + }); + + it('imports @frontmcp/utils from its CommonJS and ESM builds', () => { + const importers = published.scripts + .filter((script) => loadedSpecifiers(script).includes('@frontmcp/utils')) + .map((script) => script.path); + expect(importers).toEqual(expect.arrayContaining(['index.js', 'esm/index.mjs'])); + }); + + it('defines none of the functions and classes @frontmcp/utils exports', () => { + expect(utilsExports.size).toBeGreaterThan(0); + expect(published.scripts.flatMap((script) => definitionsNamed(script, utilsExports))).toEqual([]); + }); + + it('imports only packages its package.json declares', () => { + expect(undeclaredImports(published)).toEqual([]); + }); + }); +}); diff --git a/apps/e2e/demo-e2e-published-imports/jest.e2e.config.ts b/apps/e2e/demo-e2e-published-imports/jest.e2e.config.ts new file mode 100644 index 000000000..37a086d5c --- /dev/null +++ b/apps/e2e/demo-e2e-published-imports/jest.e2e.config.ts @@ -0,0 +1,23 @@ +import type { Config } from '@jest/types'; + +const config: Config.InitialOptions = { + displayName: 'demo-e2e-published-imports', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + testMatch: ['/e2e/**/*.e2e.spec.ts'], + testTimeout: 120000, + maxWorkers: 1, + transform: { + '^.+\\.[tj]s$': [ + '@swc/jest', + { + jsc: { + parser: { syntax: 'typescript' }, + target: 'es2022', + }, + }, + ], + }, +}; + +export default config; diff --git a/apps/e2e/demo-e2e-published-imports/project.json b/apps/e2e/demo-e2e-published-imports/project.json new file mode 100644 index 000000000..f32087eb7 --- /dev/null +++ b/apps/e2e/demo-e2e-published-imports/project.json @@ -0,0 +1,19 @@ +{ + "name": "demo-e2e-published-imports", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/e2e/demo-e2e-published-imports/e2e", + "projectType": "application", + "tags": ["scope:demo", "type:e2e", "feature:published-imports"], + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/apps/e2e/demo-e2e-published-imports"], + "dependsOn": [{ "projects": ["plugin-cache", "plugin-dashboard", "edge"], "target": "build" }], + "options": { + "jestConfig": "apps/e2e/demo-e2e-published-imports/jest.e2e.config.ts", + "runInBand": true, + "passWithNoTests": false + } + } + } +} diff --git a/apps/e2e/demo-e2e-published-imports/tsconfig.e2e.json b/apps/e2e/demo-e2e-published-imports/tsconfig.e2e.json new file mode 100644 index 000000000..6f65e2392 --- /dev/null +++ b/apps/e2e/demo-e2e-published-imports/tsconfig.e2e.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "noEmit": true, + "types": ["jest", "node"] + }, + "include": ["e2e/**/*.ts"] +} diff --git a/apps/e2e/demo-e2e-published-imports/tsconfig.json b/apps/e2e/demo-e2e-published-imports/tsconfig.json new file mode 100644 index 000000000..bc24af1db --- /dev/null +++ b/apps/e2e/demo-e2e-published-imports/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [{ "path": "./tsconfig.e2e.json" }], + "compilerOptions": { + "esModuleInterop": true + } +} diff --git a/libs/edge/package.json b/libs/edge/package.json index 6bd9405d2..f9ca40a09 100644 --- a/libs/edge/package.json +++ b/libs/edge/package.json @@ -49,8 +49,8 @@ "node": ">=24.0.0" }, "dependencies": { - "@enclave-vm/ast": "^2.17.0", - "@frontmcp/plugin-skilled-openapi": "1.10.16" + "@frontmcp/plugin-skilled-openapi": "1.10.16", + "@frontmcp/utils": "1.10.16" }, "peerDependencies": { "@frontmcp/sdk": "1.10.16" diff --git a/libs/edge/project.json b/libs/edge/project.json index 0a788a18b..577d231dd 100644 --- a/libs/edge/project.json +++ b/libs/edge/project.json @@ -24,7 +24,7 @@ "outExtension": { ".js": ".js" }, - "external": ["@frontmcp/sdk", "@frontmcp/plugin-skilled-openapi"] + "external": ["@frontmcp/sdk", "@frontmcp/utils", "@frontmcp/plugin-skilled-openapi"] } }, "dependsOn": ["^build"] @@ -47,7 +47,7 @@ "outExtension": { ".js": ".mjs" }, - "external": ["@frontmcp/sdk", "@frontmcp/plugin-skilled-openapi"] + "external": ["@frontmcp/sdk", "@frontmcp/utils", "@frontmcp/plugin-skilled-openapi"] } } }, diff --git a/plugins/plugin-cache/package.json b/plugins/plugin-cache/package.json index 9b8bdeacf..2b4dc4803 100644 --- a/plugins/plugin-cache/package.json +++ b/plugins/plugin-cache/package.json @@ -50,7 +50,6 @@ } }, "dependencies": { - "@enclave-vm/ast": "^2.17.0", "@frontmcp/sdk": "1.10.16", "@frontmcp/utils": "1.10.16", "ioredis": "^5.8.0" diff --git a/plugins/plugin-cache/project.json b/plugins/plugin-cache/project.json index 3c0715554..322300c43 100644 --- a/plugins/plugin-cache/project.json +++ b/plugins/plugin-cache/project.json @@ -30,7 +30,15 @@ "outExtension": { ".js": ".js" }, - "external": ["@frontmcp/lazy-zod", "ioredis", "@frontmcp/sdk", "@vercel/kv", "zod", "reflect-metadata"] + "external": [ + "@frontmcp/lazy-zod", + "@frontmcp/utils", + "ioredis", + "@frontmcp/sdk", + "@vercel/kv", + "zod", + "reflect-metadata" + ] } } }, @@ -52,7 +60,15 @@ "outExtension": { ".js": ".mjs" }, - "external": ["@frontmcp/lazy-zod", "ioredis", "@frontmcp/sdk", "@vercel/kv", "zod", "reflect-metadata"] + "external": [ + "@frontmcp/lazy-zod", + "@frontmcp/utils", + "ioredis", + "@frontmcp/sdk", + "@vercel/kv", + "zod", + "reflect-metadata" + ] } } }, diff --git a/plugins/plugin-dashboard/package.json b/plugins/plugin-dashboard/package.json index a7ff0851b..b6199b510 100644 --- a/plugins/plugin-dashboard/package.json +++ b/plugins/plugin-dashboard/package.json @@ -54,7 +54,7 @@ "reflect-metadata": "^0.2.2" }, "dependencies": { - "@enclave-vm/ast": "^2.17.0" + "@frontmcp/utils": "1.10.16" }, "peerDependencies": { "@frontmcp/lazy-zod": "1.10.16", diff --git a/plugins/plugin-dashboard/project.json b/plugins/plugin-dashboard/project.json index fe35942a2..0fe2c926f 100644 --- a/plugins/plugin-dashboard/project.json +++ b/plugins/plugin-dashboard/project.json @@ -30,7 +30,7 @@ "outExtension": { ".js": ".js" }, - "external": ["@frontmcp/lazy-zod", "@frontmcp/sdk", "zod", "reflect-metadata"] + "external": ["@frontmcp/lazy-zod", "@frontmcp/utils", "@frontmcp/sdk", "zod", "reflect-metadata"] } } }, @@ -52,7 +52,7 @@ "outExtension": { ".js": ".mjs" }, - "external": ["@frontmcp/lazy-zod", "@frontmcp/sdk", "zod", "reflect-metadata"] + "external": ["@frontmcp/lazy-zod", "@frontmcp/utils", "@frontmcp/sdk", "zod", "reflect-metadata"] } } }, diff --git a/yarn.lock b/yarn.lock index 137687e21..e270857a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2443,8 +2443,8 @@ __metadata: version: 0.0.0-use.local resolution: "@frontmcp/edge@workspace:libs/edge" dependencies: - "@enclave-vm/ast": "npm:^2.17.0" "@frontmcp/plugin-skilled-openapi": "npm:1.10.16" + "@frontmcp/utils": "npm:1.10.16" "@types/node": "npm:^24.0.0" typescript: "npm:^5.0.0" peerDependencies: @@ -2541,7 +2541,6 @@ __metadata: version: 0.0.0-use.local resolution: "@frontmcp/plugin-cache@workspace:plugins/plugin-cache" dependencies: - "@enclave-vm/ast": "npm:^2.17.0" "@frontmcp/sdk": "npm:1.10.16" "@frontmcp/utils": "npm:1.10.16" ioredis: "npm:^5.8.0" @@ -2576,8 +2575,8 @@ __metadata: version: 0.0.0-use.local resolution: "@frontmcp/plugin-dashboard@workspace:plugins/plugin-dashboard" dependencies: - "@enclave-vm/ast": "npm:^2.17.0" "@frontmcp/sdk": "npm:1.10.16" + "@frontmcp/utils": "npm:1.10.16" reflect-metadata: "npm:^0.2.2" peerDependencies: "@frontmcp/lazy-zod": 1.10.16 From e007e7ac2e794f8a8384fb919c498246c4ee95aa Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 16:23:13 +0200 Subject: [PATCH 2/2] test(e2e): ignore a package's own function names in the bundled-utils scan and time out npm child processes The scan reported any function or class named like a @frontmcp/utils export, so a local helper such as escapeHtml failed it. Names the package's own sources define are now left out, and a positive control checks that the remaining names still identify definitions in utils' build. npm install and the load checks run synchronously, which blocks Jest's own timeouts; they now time out on their own. --- .../e2e/enclave-2-16-top-level.e2e.spec.ts | 11 +++++--- .../e2e/helpers/published-package.ts | 27 +++++++++++++++---- .../e2e/published-imports.e2e.spec.ts | 19 +++++++++---- 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/apps/e2e/demo-e2e-published-imports/e2e/enclave-2-16-top-level.e2e.spec.ts b/apps/e2e/demo-e2e-published-imports/e2e/enclave-2-16-top-level.e2e.spec.ts index 1f89bffd8..66d95ca86 100644 --- a/apps/e2e/demo-e2e-published-imports/e2e/enclave-2-16-top-level.e2e.spec.ts +++ b/apps/e2e/demo-e2e-published-imports/e2e/enclave-2-16-top-level.e2e.spec.ts @@ -17,8 +17,9 @@ const PACKAGES = ['@frontmcp/plugin-cache', '@frontmcp/plugin-dashboard', '@fron const TOP_LEVEL_ENCLAVE = '2.16.0'; function load(project: string, args: string[]): string { - const result = spawnSync(process.execPath, args, { cwd: project, encoding: 'utf8' }); - return result.status === 0 ? 'loaded' : result.stderr; + const result = spawnSync(process.execPath, args, { cwd: project, encoding: 'utf8', timeout: 60_000 }); + if (result.status === 0) return 'loaded'; + return result.stderr || `exited with ${result.signal ?? result.status}`; } describe(`a fresh npm project with @enclave-vm/ast ${TOP_LEVEL_ENCLAVE} at the top level`, () => { @@ -34,7 +35,11 @@ describe(`a fresh npm project with @enclave-vm/ast ${TOP_LEVEL_ENCLAVE} at the t dependencies[name] = `file:${packPackage(await builtDistDir(distDirs, name), tarballs).tarball}`; } await writeJSON(path.join(project, 'package.json'), { name: 'enclave-2-16-project', private: true, dependencies }); - execFileSync('npm', ['install', '--no-audit', '--no-fund', '--ignore-scripts'], { cwd: project, stdio: 'pipe' }); + execFileSync('npm', ['install', '--no-audit', '--no-fund', '--ignore-scripts'], { + cwd: project, + stdio: 'pipe', + timeout: 240_000, + }); }, 300_000); afterAll(async () => { diff --git a/apps/e2e/demo-e2e-published-imports/e2e/helpers/published-package.ts b/apps/e2e/demo-e2e-published-imports/e2e/helpers/published-package.ts index 4777a4481..999953b03 100644 --- a/apps/e2e/demo-e2e-published-imports/e2e/helpers/published-package.ts +++ b/apps/e2e/demo-e2e-published-imports/e2e/helpers/published-package.ts @@ -166,13 +166,30 @@ function definedName(node: ts.Node): string | undefined { return undefined; } -export function definitionsNamed(script: PublishedScript, names: ReadonlySet): string[] { - const definitions: string[] = []; +function definedNames(sourceFile: ts.SourceFile): string[] { + const names: string[] = []; const visit = (node: ts.Node): void => { const name = definedName(node); - if (name && names.has(name)) definitions.push(`${script.path}: ${name}`); + if (name) names.push(name); ts.forEachChild(node, visit); }; - visit(parse(script)); - return definitions; + visit(sourceFile); + return names; +} + +export function definitionsNamed(script: PublishedScript, names: ReadonlySet): string[] { + return definedNames(parse(script)) + .filter((name) => names.has(name)) + .map((name) => `${script.path}: ${name}`); +} + +export async function sourceDefinitions(packageRoot: string): Promise> { + const { config } = ts.readConfigFile(path.join(packageRoot, 'tsconfig.lib.json'), ts.sys.readFile); + const { fileNames } = ts.parseJsonConfigFileContent(config, ts.sys, packageRoot); + const names = new Set(); + for (const fileName of fileNames) { + const sourceFile = ts.createSourceFile(fileName, await readFile(fileName), ts.ScriptTarget.Latest); + for (const name of definedNames(sourceFile)) names.add(name); + } + return names; } diff --git a/apps/e2e/demo-e2e-published-imports/e2e/published-imports.e2e.spec.ts b/apps/e2e/demo-e2e-published-imports/e2e/published-imports.e2e.spec.ts index 3c784e6d7..602e46d54 100644 --- a/apps/e2e/demo-e2e-published-imports/e2e/published-imports.e2e.spec.ts +++ b/apps/e2e/demo-e2e-published-imports/e2e/published-imports.e2e.spec.ts @@ -10,9 +10,11 @@ import { exportedNames, loadedSpecifiers, readPublishedPackage, + sourceDefinitions, undeclaredImports, workspaceDistDirs, type PublishedPackage, + type PublishedScript, } from './helpers/published-package'; const PACKAGES = ['@frontmcp/plugin-cache', '@frontmcp/plugin-dashboard', '@frontmcp/edge']; @@ -20,13 +22,15 @@ const PACKAGES = ['@frontmcp/plugin-cache', '@frontmcp/plugin-dashboard', '@fron describe('published JavaScript that uses @frontmcp/utils', () => { let workDir: string; let distDirs: Map; + let utilsScript: PublishedScript; let utilsExports: Set; beforeAll(async () => { workDir = await mkdtemp(path.join(tmpdir(), 'frontmcp-published-imports-')); distDirs = await workspaceDistDirs(); const utilsEsm = path.join(await builtDistDir(distDirs, '@frontmcp/utils'), 'esm', 'index.mjs'); - utilsExports = exportedNames({ path: 'esm/index.mjs', source: await readFile(utilsEsm) }); + utilsScript = { path: 'esm/index.mjs', source: await readFile(utilsEsm) }; + utilsExports = exportedNames(utilsScript); }); afterAll(async () => { @@ -34,10 +38,13 @@ describe('published JavaScript that uses @frontmcp/utils', () => { }); describe.each(PACKAGES)('%s', (name) => { + let packageRoot: string; let published: PublishedPackage; beforeAll(async () => { - published = await readPublishedPackage(await builtDistDir(distDirs, name), workDir); + const distDir = await builtDistDir(distDirs, name); + packageRoot = path.dirname(distDir); + published = await readPublishedPackage(distDir, workDir); }); it('declares @frontmcp/utils at its own version', () => { @@ -51,9 +58,11 @@ describe('published JavaScript that uses @frontmcp/utils', () => { expect(importers).toEqual(expect.arrayContaining(['index.js', 'esm/index.mjs'])); }); - it('defines none of the functions and classes @frontmcp/utils exports', () => { - expect(utilsExports.size).toBeGreaterThan(0); - expect(published.scripts.flatMap((script) => definitionsNamed(script, utilsExports))).toEqual([]); + it('defines none of the functions and classes @frontmcp/utils exports, apart from its own', async () => { + const ownNames = await sourceDefinitions(packageRoot); + const utilsOnlyNames = new Set([...utilsExports].filter((utilsName) => !ownNames.has(utilsName))); + expect(definitionsNamed(utilsScript, utilsOnlyNames).length).toBeGreaterThan(0); + expect(published.scripts.flatMap((script) => definitionsNamed(script, utilsOnlyNames))).toEqual([]); }); it('imports only packages its package.json declares', () => {