Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// 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', 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`, () => {
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<string, string> = { '@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',
timeout: 240_000,
});
}, 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<PackageManifest>(
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');
});
});
195 changes: 195 additions & 0 deletions apps/e2e/demo-e2e-published-imports/e2e/helpers/published-package.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
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<string, string>;
peerDependencies?: Record<string, string>;
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
optionalDependencies?: Record<string, string>;
}

export interface PublishedScript {
path: string;
source: string;
}

export interface PublishedPackage {
manifest: PackageManifest;
scripts: PublishedScript[];
}

export async function workspaceDistDirs(): Promise<Map<string, string>> {
const distDirs = new Map<string, string>();
for (const group of ['libs', 'plugins']) {
for (const entry of await readdir(path.join(WORKSPACE, group))) {
const manifest = await readJSON<PackageManifest>(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<string, string>, name: string): Promise<string> {
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<PublishedPackage> {
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<string, string>): Promise<string[]> {
const closure = new Set<string>();
const pending = [...names];
while (pending.length > 0) {
const name = pending.pop();
if (!name || closure.has(name)) continue;
closure.add(name);
const manifest = await readJSON<PackageManifest>(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<string>();
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<string> {
const names = new Set<string>();
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;
}

function definedNames(sourceFile: ts.SourceFile): string[] {
const names: string[] = [];
const visit = (node: ts.Node): void => {
const name = definedName(node);
if (name) names.push(name);
ts.forEachChild(node, visit);
};
visit(sourceFile);
return names;
}

export function definitionsNamed(script: PublishedScript, names: ReadonlySet<string>): string[] {
return definedNames(parse(script))
.filter((name) => names.has(name))
.map((name) => `${script.path}: ${name}`);
}

export async function sourceDefinitions(packageRoot: string): Promise<Set<string>> {
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<string>();
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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// 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,
sourceDefinitions,
undeclaredImports,
workspaceDistDirs,
type PublishedPackage,
type PublishedScript,
} 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<string, string>;
let utilsScript: PublishedScript;
let utilsExports: Set<string>;

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');
utilsScript = { path: 'esm/index.mjs', source: await readFile(utilsEsm) };
utilsExports = exportedNames(utilsScript);
});

afterAll(async () => {
if (workDir) await rm(workDir, { recursive: true, force: true });
});

describe.each(PACKAGES)('%s', (name) => {
let packageRoot: string;
let published: PublishedPackage;

beforeAll(async () => {
const distDir = await builtDistDir(distDirs, name);
packageRoot = path.dirname(distDir);
published = await readPublishedPackage(distDir, 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, 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', () => {
expect(undeclaredImports(published)).toEqual([]);
});
});
});
23 changes: 23 additions & 0 deletions apps/e2e/demo-e2e-published-imports/jest.e2e.config.ts
Original file line number Diff line number Diff line change
@@ -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: ['<rootDir>/e2e/**/*.e2e.spec.ts'],
testTimeout: 120000,
maxWorkers: 1,
transform: {
'^.+\\.[tj]s$': [
'@swc/jest',
{
jsc: {
parser: { syntax: 'typescript' },
target: 'es2022',
},
},
],
},
};

export default config;
19 changes: 19 additions & 0 deletions apps/e2e/demo-e2e-published-imports/project.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
10 changes: 10 additions & 0 deletions apps/e2e/demo-e2e-published-imports/tsconfig.e2e.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"noEmit": true,
"types": ["jest", "node"]
},
"include": ["e2e/**/*.ts"]
}
9 changes: 9 additions & 0 deletions apps/e2e/demo-e2e-published-imports/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../../tsconfig.base.json",
"files": [],
"include": [],
"references": [{ "path": "./tsconfig.e2e.json" }],
"compilerOptions": {
"esModuleInterop": true
}
}
Loading
Loading