test: isolate optional SDK role dependencies
This commit is contained in:
parent
43eed45404
commit
7664bb1010
1 changed files with 209 additions and 9 deletions
159
scripts/check-dependency-isolation.mjs
Normal file
159
scripts/check-dependency-isolation.mjs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(root, "package.json"), "utf8"),
|
||||
);
|
||||
const client = "@modelcontextprotocol/client";
|
||||
const server = "@modelcontextprotocol/server";
|
||||
|
||||
for (const role of [client, server]) {
|
||||
if (!packageJson.peerDependencies?.[role]) {
|
||||
throw new Error(`${role} must remain a peer dependency`);
|
||||
}
|
||||
if (packageJson.peerDependenciesMeta?.[role]?.optional !== true) {
|
||||
throw new Error(`${role} must be an optional peer dependency`);
|
||||
}
|
||||
}
|
||||
|
||||
const temporaryRoot = mkdtempSync(join(tmpdir(), "ext-apps-role-peers-"));
|
||||
try {
|
||||
const npmEnvironment = {
|
||||
...process.env,
|
||||
npm_config_cache: join(temporaryRoot, "npm-cache"),
|
||||
};
|
||||
const packOutput = JSON.parse(
|
||||
execFileSync(
|
||||
"npm",
|
||||
[
|
||||
"pack",
|
||||
"--ignore-scripts",
|
||||
"--json",
|
||||
"--pack-destination",
|
||||
temporaryRoot,
|
||||
],
|
||||
{ cwd: root, encoding: "utf8", env: npmEnvironment },
|
||||
),
|
||||
);
|
||||
const tarball = join(temporaryRoot, packOutput[0].filename);
|
||||
|
||||
const consumers = [
|
||||
{
|
||||
name: "app-only",
|
||||
dependencies: {
|
||||
"@types/node": packageJson.devDependencies["@types/node"],
|
||||
[client]: packageJson.devDependencies[client],
|
||||
"@modelcontextprotocol/ext-apps": `file:${tarball}`,
|
||||
},
|
||||
absent: server,
|
||||
entry:
|
||||
'import { App } from "@modelcontextprotocol/ext-apps"; import { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge"; console.log(App, AppBridge);',
|
||||
},
|
||||
{
|
||||
name: "server-only",
|
||||
dependencies: {
|
||||
"@types/node": packageJson.devDependencies["@types/node"],
|
||||
"@modelcontextprotocol/ext-apps": `file:${tarball}`,
|
||||
[server]: packageJson.devDependencies[server],
|
||||
},
|
||||
absent: client,
|
||||
entry:
|
||||
'import { registerAppTool } from "@modelcontextprotocol/ext-apps/server"; console.log(registerAppTool);',
|
||||
},
|
||||
];
|
||||
|
||||
for (const consumer of consumers) {
|
||||
const directory = join(temporaryRoot, consumer.name);
|
||||
mkdirSync(directory);
|
||||
writeFileSync(
|
||||
join(directory, "package.json"),
|
||||
JSON.stringify({
|
||||
private: true,
|
||||
type: "module",
|
||||
dependencies: consumer.dependencies,
|
||||
}),
|
||||
);
|
||||
execFileSync(
|
||||
"npm",
|
||||
[
|
||||
"install",
|
||||
"--ignore-scripts",
|
||||
"--package-lock=false",
|
||||
"--no-audit",
|
||||
"--no-fund",
|
||||
],
|
||||
{ cwd: directory, stdio: "pipe", env: npmEnvironment },
|
||||
);
|
||||
|
||||
const absentPath = join(
|
||||
directory,
|
||||
"node_modules",
|
||||
...consumer.absent.split("/"),
|
||||
"package.json",
|
||||
);
|
||||
try {
|
||||
readFileSync(absentPath);
|
||||
throw new Error(
|
||||
`${consumer.name} unexpectedly installed ${consumer.absent}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") throw error;
|
||||
}
|
||||
|
||||
writeFileSync(join(directory, "entry.ts"), consumer.entry);
|
||||
writeFileSync(
|
||||
join(directory, "tsconfig.json"),
|
||||
JSON.stringify({
|
||||
compilerOptions: {
|
||||
lib: ["ES2020", "DOM"],
|
||||
module: "ESNext",
|
||||
moduleResolution: "bundler",
|
||||
noEmit: true,
|
||||
skipLibCheck: false,
|
||||
strict: true,
|
||||
target: "ES2020",
|
||||
},
|
||||
include: ["entry.ts"],
|
||||
}),
|
||||
);
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[join(root, "node_modules", "typescript", "bin", "tsc")],
|
||||
{ cwd: directory, stdio: "inherit" },
|
||||
);
|
||||
|
||||
const metafile = join(directory, "bundle-meta.json");
|
||||
execFileSync(
|
||||
join(root, "node_modules", ".bin", "esbuild"),
|
||||
[
|
||||
"entry.ts",
|
||||
"--bundle",
|
||||
"--platform=browser",
|
||||
"--outfile=bundle.js",
|
||||
`--metafile=${metafile}`,
|
||||
],
|
||||
{ cwd: directory, stdio: "pipe" },
|
||||
);
|
||||
const bundleInputs = Object.keys(
|
||||
JSON.parse(readFileSync(metafile, "utf8")).inputs,
|
||||
).join("\n");
|
||||
if (bundleInputs.includes(`/node_modules/${consumer.absent}/`)) {
|
||||
throw new Error(
|
||||
`${consumer.name} unexpectedly bundled ${consumer.absent}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("Role peer dependency isolation checks passed.");
|
||||
Loading…
Reference in a new issue