diff --git a/tests/e2e/npx/npx-test-utils.ts b/tests/e2e/npx/npx-test-utils.ts index fcbad09dc..1a01cc067 100644 --- a/tests/e2e/npx/npx-test-utils.ts +++ b/tests/e2e/npx/npx-test-utils.ts @@ -4,7 +4,7 @@ * Helper functions for testing MCP debugger through npx distribution (npm pack) */ -import { exec } from 'child_process'; +import { exec, execFile } from 'child_process'; import { promisify } from 'util'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -15,6 +15,7 @@ import { appendFile, mkdir, writeFile } from 'fs/promises'; import { createHash } from 'crypto'; const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -442,8 +443,12 @@ export async function verifyPackageContents(tarballPath: string): Promise<{ console.log('[NPX Test] Verifying package contents...'); try { - // List tarball contents - const { stdout } = await execAsync(`tar -tzf "${tarballPath}"`); + // A relative archive argument keeps GNU tar from treating a Windows drive + // prefix as a remote host (#752); execFile preserves literal filenames. + const absoluteTarballPath = path.resolve(tarballPath); + const { stdout } = await execFileAsync('tar', ['-tzf', `./${path.basename(absoluteTarballPath)}`], { + cwd: path.dirname(absoluteTarballPath) + }); const contents = stdout.toLowerCase(); const entries = new Set(stdout.split('\n').map((line) => line.trim()).filter(Boolean)); diff --git a/tests/integration/cli/package-contents.test.ts b/tests/integration/cli/package-contents.test.ts new file mode 100644 index 000000000..7d7a8f2cb --- /dev/null +++ b/tests/integration/cli/package-contents.test.ts @@ -0,0 +1,59 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { verifyPackageContents } from '../../e2e/npx/npx-test-utils.js'; + +const execFileAsync = promisify(execFile); + +describe('package contents verification (issue #752)', () => { + let tempDir: string; + let tarballPath: string; + + beforeAll(async () => { + tempDir = await mkdtemp(path.join(tmpdir(), 'npx package contents ')); + const entries = [ + 'package/dist/cli.mjs', + 'package/dist/vendor/js-debug/vsDebugServer.cjs', + 'package/dist/vendor/debugpy/__init__.py', + 'package/dist/mock.js', + 'package/skills/debugging/SKILL.md', + 'package/pi.mcp.json', + ]; + for (const entry of entries) { + const filePath = path.join(tempDir, entry); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, 'fixture\n'); + } + + // Spaces and a literal dollar sign must survive without shell expansion. + const tarballName = 'package $contents.tgz'; + tarballPath = path.join(tempDir, tarballName); + await execFileAsync('tar', ['-czf', `./${tarballName}`, 'package'], { cwd: tempDir }); + }); + + afterAll(async () => { + if (tempDir) await rm(tempDir, { recursive: true, force: true }); + }); + + it.each(['absolute', 'relative'] as const)('reads a real archive using a path that is %s', async (kind) => { + const archivePath = kind === 'absolute' + ? tarballPath + : path.relative(process.cwd(), tarballPath); + + expect(await verifyPackageContents(archivePath)).toEqual({ + hasJavaScript: true, + hasPython: true, + hasMock: true, + hasSkill: true, + hasPiManifest: true, + tarballSize: (await stat(tarballPath)).size, + }); + }); + + it('rejects a missing archive', async () => { + await expect(verifyPackageContents(path.join(tempDir, 'missing.tgz'))).rejects.toThrow(); + }); +});