Skip to content

Commit 5bc59b4

Browse files
committed
Add package isolation module for test environments
1 parent 0f322f1 commit 5bc59b4

1 file changed

Lines changed: 235 additions & 0 deletions

File tree

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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

Comments
 (0)