|
| 1 | +/** |
| 2 | + * @fileoverview Package isolation utilities for testing. |
| 3 | + * Provides tools to set up isolated test environments for packages. |
| 4 | + */ |
| 5 | + |
| 6 | +import { existsSync, promises as fs } from 'node:fs' |
| 7 | +import os from 'node:os' |
| 8 | +import path from 'node:path' |
| 9 | + |
| 10 | +import WIN32 from '../constants/WIN32' |
| 11 | +import { isPath } from '../path' |
| 12 | +import { readPackageJson } from './operations' |
| 13 | + |
| 14 | +import type { PackageJson } from '../packages' |
| 15 | + |
| 16 | +/** |
| 17 | + * Copy options for fs.cp with cross-platform retry support. |
| 18 | + */ |
| 19 | +const FS_CP_OPTIONS = { |
| 20 | + dereference: true, |
| 21 | + errorOnExist: false, |
| 22 | + filter: (src: string) => |
| 23 | + !src.includes('node_modules') && !src.endsWith('.DS_Store'), |
| 24 | + force: true, |
| 25 | + recursive: true, |
| 26 | + ...(WIN32 ? { maxRetries: 3, retryDelay: 100 } : {}), |
| 27 | +} |
| 28 | + |
| 29 | +/** |
| 30 | + * Resolve a path to its real location, handling symlinks. |
| 31 | + */ |
| 32 | +async function resolveRealPath(pathStr: string): Promise<string> { |
| 33 | + return await fs.realpath(pathStr).catch(() => path.resolve(pathStr)) |
| 34 | +} |
| 35 | + |
| 36 | +/** |
| 37 | + * Merge and write package.json with original and new values. |
| 38 | + */ |
| 39 | +async function mergePackageJson( |
| 40 | + pkgJsonPath: string, |
| 41 | + originalPkgJson: PackageJson | undefined, |
| 42 | +): Promise<PackageJson> { |
| 43 | + const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, 'utf8')) |
| 44 | + const mergedPkgJson = originalPkgJson |
| 45 | + ? { ...originalPkgJson, ...pkgJson } |
| 46 | + : pkgJson |
| 47 | + return mergedPkgJson |
| 48 | +} |
| 49 | + |
| 50 | +export type IsolatePackageOptions = { |
| 51 | + imports?: Record<string, string> | undefined |
| 52 | + install?: ((cwd: string) => Promise<void>) | undefined |
| 53 | + onPackageJson?: |
| 54 | + | ((pkgJson: PackageJson) => PackageJson | Promise<PackageJson>) |
| 55 | + | undefined |
| 56 | + sourcePath?: string | undefined |
| 57 | +} |
| 58 | + |
| 59 | +export type IsolatePackageResult = { |
| 60 | + exports?: Record<string, any> | undefined |
| 61 | + tmpdir: string |
| 62 | +} |
| 63 | + |
| 64 | +/** |
| 65 | + * Isolates a package in a temporary test environment. |
| 66 | + * |
| 67 | + * Supports multiple input types: |
| 68 | + * 1. File system path (absolute or relative) |
| 69 | + * 2. Package name with optional version spec |
| 70 | + * 3. npm package spec (parsed via npm-package-arg) |
| 71 | + * |
| 72 | + * @throws {Error} When package installation or setup fails. |
| 73 | + */ |
| 74 | +export async function isolatePackage( |
| 75 | + packageSpec: string, |
| 76 | + options?: IsolatePackageOptions | undefined, |
| 77 | +): Promise<IsolatePackageResult> { |
| 78 | + const opts = { __proto__: null, ...options } as IsolatePackageOptions |
| 79 | + const { imports, install, onPackageJson, sourcePath: optSourcePath } = opts |
| 80 | + |
| 81 | + let sourcePath = optSourcePath |
| 82 | + let packageName: string | undefined |
| 83 | + let spec: string | undefined |
| 84 | + |
| 85 | + // Determine if this is a path or package spec. |
| 86 | + if (isPath(packageSpec)) { |
| 87 | + // File system path. |
| 88 | + sourcePath = path.resolve(packageSpec) |
| 89 | + |
| 90 | + if (!existsSync(sourcePath)) { |
| 91 | + throw new Error(`Source path does not exist: ${sourcePath}`) |
| 92 | + } |
| 93 | + |
| 94 | + // Read package.json to get the name. |
| 95 | + const pkgJson = await readPackageJson(sourcePath, { normalize: true }) |
| 96 | + packageName = pkgJson.name as string |
| 97 | + } else { |
| 98 | + // Parse as npm package spec. |
| 99 | + const npa = /*@__PURE__*/ require('../external/npm-package-arg') |
| 100 | + const parsed = npa(packageSpec) |
| 101 | + |
| 102 | + packageName = parsed.name |
| 103 | + |
| 104 | + if (parsed.type === 'directory' || parsed.type === 'file') { |
| 105 | + sourcePath = parsed.fetchSpec |
| 106 | + if (!existsSync(sourcePath)) { |
| 107 | + throw new Error(`Source path does not exist: ${sourcePath}`) |
| 108 | + } |
| 109 | + } else { |
| 110 | + // Registry package. |
| 111 | + spec = parsed.fetchSpec || parsed.rawSpec |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | + if (!packageName) { |
| 116 | + throw new Error(`Could not determine package name from: ${packageSpec}`) |
| 117 | + } |
| 118 | + |
| 119 | + // Create temp directory for this package. |
| 120 | + const sanitizedName = packageName.replace(/[@/]/g, '-') |
| 121 | + const tempDir = await fs.mkdtemp( |
| 122 | + path.join(os.tmpdir(), `socket-test-${sanitizedName}-`), |
| 123 | + ) |
| 124 | + const packageTempDir = path.join(tempDir, sanitizedName) |
| 125 | + await fs.mkdir(packageTempDir, { recursive: true }) |
| 126 | + |
| 127 | + let installedPath: string |
| 128 | + let originalPackageJson: PackageJson | undefined |
| 129 | + |
| 130 | + if (spec) { |
| 131 | + // Installing from registry first, then copying source on top if provided. |
| 132 | + await fs.writeFile( |
| 133 | + path.join(packageTempDir, 'package.json'), |
| 134 | + JSON.stringify( |
| 135 | + { |
| 136 | + name: 'test-temp', |
| 137 | + private: true, |
| 138 | + version: '1.0.0', |
| 139 | + }, |
| 140 | + null, |
| 141 | + 2, |
| 142 | + ), |
| 143 | + ) |
| 144 | + |
| 145 | + // Use custom install function or default pnpm install. |
| 146 | + if (install) { |
| 147 | + await install(packageTempDir) |
| 148 | + } else { |
| 149 | + const { spawn } = /*@__PURE__*/ require('../spawn') |
| 150 | + const packageInstallSpec = spec.startsWith('https://') |
| 151 | + ? spec |
| 152 | + : `${packageName}@${spec}` |
| 153 | + |
| 154 | + await spawn('pnpm', ['add', packageInstallSpec], { |
| 155 | + cwd: packageTempDir, |
| 156 | + stdio: 'pipe', |
| 157 | + }) |
| 158 | + } |
| 159 | + |
| 160 | + installedPath = path.join(packageTempDir, 'node_modules', packageName) |
| 161 | + |
| 162 | + // Save original package.json before copying source. |
| 163 | + originalPackageJson = await readPackageJson(installedPath, { |
| 164 | + normalize: true, |
| 165 | + }) |
| 166 | + |
| 167 | + // Copy source files on top if provided. |
| 168 | + if (sourcePath) { |
| 169 | + // Check if source and destination are the same (symlinked). |
| 170 | + const realInstalledPath = await resolveRealPath(installedPath) |
| 171 | + const realSourcePath = await resolveRealPath(sourcePath) |
| 172 | + |
| 173 | + if (realSourcePath !== realInstalledPath) { |
| 174 | + await fs.cp(sourcePath, installedPath, FS_CP_OPTIONS) |
| 175 | + } |
| 176 | + } |
| 177 | + } else { |
| 178 | + // Just copying local package, no registry install. |
| 179 | + if (!sourcePath) { |
| 180 | + throw new Error('sourcePath is required when no version spec provided') |
| 181 | + } |
| 182 | + |
| 183 | + const scopedPath = packageName.startsWith('@') |
| 184 | + ? path.join(packageTempDir, 'node_modules', packageName.split('/')[0]) |
| 185 | + : path.join(packageTempDir, 'node_modules') |
| 186 | + |
| 187 | + await fs.mkdir(scopedPath, { recursive: true }) |
| 188 | + installedPath = path.join(packageTempDir, 'node_modules', packageName) |
| 189 | + |
| 190 | + await fs.cp(sourcePath, installedPath, FS_CP_OPTIONS) |
| 191 | + } |
| 192 | + |
| 193 | + // Prepare package.json if callback provided or if we need to merge with original. |
| 194 | + if (onPackageJson || originalPackageJson) { |
| 195 | + const pkgJsonPath = path.join(installedPath, 'package.json') |
| 196 | + const mergedPkgJson = await mergePackageJson( |
| 197 | + pkgJsonPath, |
| 198 | + originalPackageJson, |
| 199 | + ) |
| 200 | + |
| 201 | + const finalPkgJson = onPackageJson |
| 202 | + ? await onPackageJson(mergedPkgJson) |
| 203 | + : mergedPkgJson |
| 204 | + |
| 205 | + await fs.writeFile(pkgJsonPath, JSON.stringify(finalPkgJson, null, 2)) |
| 206 | + } |
| 207 | + |
| 208 | + // Install dependencies. |
| 209 | + if (install) { |
| 210 | + await install(installedPath) |
| 211 | + } else { |
| 212 | + const { spawn } = /*@__PURE__*/ require('../spawn') |
| 213 | + await spawn('pnpm', ['install'], { |
| 214 | + cwd: installedPath, |
| 215 | + stdio: 'pipe', |
| 216 | + }) |
| 217 | + } |
| 218 | + |
| 219 | + // Load module exports if imports provided. |
| 220 | + const exports: Record<string, any> = imports |
| 221 | + ? { __proto__: null } |
| 222 | + : undefined! |
| 223 | + |
| 224 | + if (imports) { |
| 225 | + for (const { 0: key, 1: specifier } of Object.entries(imports)) { |
| 226 | + const fullPath = path.join(installedPath, specifier) |
| 227 | + exports[key] = require(fullPath) |
| 228 | + } |
| 229 | + } |
| 230 | + |
| 231 | + return { |
| 232 | + exports, |
| 233 | + tmpdir: installedPath, |
| 234 | + } |
| 235 | +} |
0 commit comments