fix(boot): resolve profile dependencies from real package directories
This commit is contained in:
1 parent
4b9141a89e
commit
6d8aac066d
6 files changed
+378
-8
No files matched your search
@@ -17,6 +17,7 @@ import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { execa } from 'execa'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { testProfileResolution } from './profiles/headless/tests/profile-resolution.ts'
|
||||
|
||||
/** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
@@ -341,6 +342,8 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
|
||||
}
|
||||
|
||||
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
|
||||
testProfileResolution('lib')
|
||||
|
||||
it('requires a profile and rejects removed flags', async () => {
|
||||
const bare = await runBuiltBin()
|
||||
expect(bare.code).toBe(1)
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
/** Source-profile tool-dispatch evidence from the real Agent and Node module cache. */
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type {} from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-cmdline'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/** Observations from one isolated Agent's run through the shipped headless profile. */
|
||||
export interface SourceToolEvidence {
|
||||
execArgv: string[]
|
||||
modules: string[]
|
||||
errors: string[]
|
||||
events: SessionEvent<'tool/call' | 'tool/result' | 'turn/end'>[]
|
||||
}
|
||||
|
||||
/** Services needed to drive the Agent and inspect loaded modules. */
|
||||
export const inject = ['agents', 'loader']
|
||||
|
||||
/**
|
||||
* Drive one mock-model turn after application readiness and dispose it before requesting exit.
|
||||
* @param ctx - context in the source-launched headless Loader tree.
|
||||
* @param config - isolated working directory for the Agent.
|
||||
*/
|
||||
export function apply(ctx: Context, config: { cwd: string }): void {
|
||||
const ready = ctx.get('appReady')
|
||||
const exit = ctx.get('appExit')
|
||||
if (ready === undefined || exit === undefined) throw new Error('Source tool driver requires appReady and appExit')
|
||||
const events: SourceToolEvidence['events'] = []
|
||||
const errors: string[] = []
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'tool/call' || event.type === 'tool/result' || event.type === 'turn/end') events.push(event)
|
||||
}, { global: true })
|
||||
ctx.on('agent/error', ({ error }) => { errors.push(error instanceof Error ? error.message : String(error)) }, { global: true })
|
||||
|
||||
const run = async (): Promise<void> => {
|
||||
const handle = await ctx.agents.create({
|
||||
sessionId: SessionId(randomUUID()),
|
||||
meta: { cwd: config.cwd },
|
||||
agentOptions: { provider: 'cli-mock', model: 'cli-mock' },
|
||||
})
|
||||
try {
|
||||
handle.agent.followup(createUserMessage({
|
||||
source: { kind: 'user' },
|
||||
content: [{ type: 'text', text: 'Run the shell smoke command once.' }],
|
||||
}))
|
||||
await handle.agent.whenIdle()
|
||||
const internal = ctx.loader.internal
|
||||
if (internal === undefined) throw new Error('Source tool driver requires Node module-cache introspection')
|
||||
const evidence: SourceToolEvidence = {
|
||||
execArgv: process.execArgv,
|
||||
modules: [...internal.loadCache.keys()].filter(url => /\/packages\/core\/(?:tools|agent-loop)\//.test(url)),
|
||||
errors,
|
||||
events,
|
||||
}
|
||||
process.stdout.write(`DSH_SOURCE_TOOL_RESULT ${JSON.stringify(evidence)}\n`)
|
||||
} finally {
|
||||
await handle.dispose()
|
||||
}
|
||||
exit(errors.length === 0 ? 0 : 1)
|
||||
}
|
||||
|
||||
ctx.effect(() => ready.onReady(() => {
|
||||
void run().catch((error: unknown) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`)
|
||||
exit(1)
|
||||
})
|
||||
}), 'source tool driver')
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/** Real CLI profile imports in source and built launches, including npm-link dependency layouts. */
|
||||
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink, unlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { resolveExampleLaunch, type ExampleMode } from '@deepseek-ai/dsh-loader-smoke'
|
||||
import { execa } from 'execa'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url))
|
||||
const processTimeoutMs = 75_000
|
||||
const bundleName = 'profile-resolution-bundle'
|
||||
const bridgeName = 'profile-resolution-bridge'
|
||||
const leafName = 'profile-resolution-leaf'
|
||||
const marker = 'DSH_PROFILE_RESOLUTION '
|
||||
|
||||
interface ResolutionEvidence {
|
||||
execArgv: string[]
|
||||
esm: { version: string; url: string }
|
||||
cjs: { version: string; filename: string }
|
||||
sameEsmLeaf: boolean
|
||||
sameCjsLeaf: boolean
|
||||
toolsInstance: boolean
|
||||
scheduler: boolean
|
||||
modules: string[]
|
||||
}
|
||||
|
||||
async function writePackage(dir: string, manifest: Record<string, unknown>, files: Record<string, string>): Promise<void> {
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'package.json'), JSON.stringify(manifest) + '\n')
|
||||
for (const [name, content] of Object.entries(files)) await writeFile(join(dir, name), content + '\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the same runtime-resolution assertions in the source-only and built-bin lanes.
|
||||
* @param mode - source uses the CLI's ESM-only tsx hook; lib requires built workspace exports.
|
||||
*/
|
||||
export function testProfileResolution(mode: ExampleMode): void {
|
||||
it.each(['installed', 'npm-link'] as const)(`resolves a %s profile dependency graph in ${mode} mode`, {
|
||||
timeout: processTimeoutMs + 15_000, retry: 0,
|
||||
}, async (layout) => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-profile-resolution-'))
|
||||
const links: string[] = []
|
||||
try {
|
||||
const home = join(root, 'home')
|
||||
const profileDir = join(home, 'profiles', 'headless')
|
||||
const installedBundle = join(profileDir, 'node_modules', bundleName)
|
||||
const bundleDir = layout === 'npm-link' ? join(root, 'work', bundleName) : installedBundle
|
||||
const installedBridge = join(bundleDir, 'node_modules', bridgeName)
|
||||
const bridgeDir = layout === 'npm-link' ? join(root, 'dependencies', bridgeName) : installedBridge
|
||||
const logicalLeaf = join(bundleDir, 'node_modules', leafName)
|
||||
const realLeaf = join(root, 'dependencies', 'node_modules', leafName)
|
||||
const exports = { import: './index.mjs', require: './index.cjs' }
|
||||
await writePackage(bundleDir, {
|
||||
name: bundleName, version: '1.0.0', dependencies: { [bridgeName]: '*' },
|
||||
dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||
}, { 'cordis.patch.yml': '[]' })
|
||||
await writePackage(bridgeDir, {
|
||||
name: bridgeName, version: '1.0.0', exports, dependencies: { [leafName]: '*' },
|
||||
}, {
|
||||
'index.mjs': `export { leaf } from '${leafName}'`,
|
||||
'index.cjs': `module.exports = require('${leafName}')`,
|
||||
})
|
||||
for (const [dir, version] of [[logicalLeaf, '1.0.0'], [realLeaf, '2.0.0']] as const) {
|
||||
await writePackage(dir, { name: leafName, version, exports }, {
|
||||
'index.mjs': `export const leaf = { version: '${version}', url: import.meta.url }`,
|
||||
'index.cjs': `exports.leaf = { version: '${version}', filename: __filename }`,
|
||||
})
|
||||
}
|
||||
if (layout === 'npm-link') {
|
||||
const globalBundle = join(root, 'npm-global', 'node_modules', bundleName)
|
||||
for (const [target, link] of [[bundleDir, globalBundle], [globalBundle, installedBundle], [bridgeDir, installedBridge]] as const) {
|
||||
await mkdir(dirname(link), { recursive: true })
|
||||
await symlink(target, link, 'junction')
|
||||
links.push(link)
|
||||
}
|
||||
}
|
||||
|
||||
// The probe imports from the profile, where the bundle's transitive dependencies need fallback.
|
||||
await writePackage(profileDir, {
|
||||
name: 'resolution-profile', private: true, dependencies: { [bundleName]: '*' },
|
||||
dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless', bundleName] } },
|
||||
}, {
|
||||
'probe.mjs': [
|
||||
"import { createRequire } from 'node:module'",
|
||||
"import Tools, { TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools'",
|
||||
`import { leaf } from '${leafName}'`,
|
||||
`import { leaf as bridgeLeaf } from '${bridgeName}'`,
|
||||
'const require = createRequire(import.meta.url)',
|
||||
"export const inject = ['tools', 'agentLoop', 'loader']",
|
||||
'export function apply(ctx) {',
|
||||
" const ready = ctx.get('appReady')",
|
||||
" const exit = ctx.get('appExit')",
|
||||
' ctx.effect(() => ready.onReady(() => {',
|
||||
` const cjs = require('${leafName}').leaf`,
|
||||
' const evidence = {',
|
||||
' execArgv: process.execArgv, esm: leaf, cjs,',
|
||||
' sameEsmLeaf: leaf === bridgeLeaf,',
|
||||
` sameCjsLeaf: cjs === require('${bridgeName}').leaf,`,
|
||||
' toolsInstance: ctx.tools instanceof Tools,',
|
||||
" scheduler: typeof ctx.tools[TOOL_RUNTIME_SCHEDULER]?.prepare === 'function',",
|
||||
' modules: [...ctx.loader.internal.loadCache.keys()]',
|
||||
' .filter(url => /\\/packages\\/core\\/(?:tools|agent-loop)\\//.test(url)),',
|
||||
' }',
|
||||
` process.stdout.write('${marker}' + JSON.stringify(evidence) + '\\n')`,
|
||||
' exit(0)',
|
||||
" }), 'profile resolution probe')",
|
||||
'}',
|
||||
].join('\n'),
|
||||
'cordis.patch.yml': JSON.stringify([
|
||||
{ id: 'headless-startup', disabled: true },
|
||||
{ id: 'headless-runner', disabled: true },
|
||||
{ id: 'llm-deepseek', disabled: true },
|
||||
{ insert: [{ id: 'resolution-probe', name: join(profileDir, 'probe.mjs') }] },
|
||||
]),
|
||||
})
|
||||
const launch = resolveExampleLaunch({
|
||||
srcBin: join(repoRoot, 'apps/cli/src/bin.ts'),
|
||||
mode, sourceImport: 'tsx/esm', tsconfigPath: join(repoRoot, 'tsconfig.json'),
|
||||
configArgs: ['--profile', 'headless'],
|
||||
env: {
|
||||
DSH_HOME: home, DSH_AGENTS_HOME: join(root, 'agents'), DSH_TELEMETRY_DISABLED: '1',
|
||||
NODE_OPTIONS: undefined, TSX_TSCONFIG_PATH: undefined,
|
||||
},
|
||||
})
|
||||
const result = await execa(launch.command, launch.args, {
|
||||
cwd: repoRoot, env: launch.env, input: '', timeout: processTimeoutMs,
|
||||
killSignal: 'SIGKILL', reject: false,
|
||||
})
|
||||
const diagnostic = `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`
|
||||
expect(result.timedOut, diagnostic).toBe(false)
|
||||
expect(result.signal, diagnostic).toBeUndefined()
|
||||
expect(result.exitCode, diagnostic).toBe(0)
|
||||
const records = result.stdout.split('\n').filter(line => line.startsWith(marker))
|
||||
expect(records, diagnostic).toHaveLength(1)
|
||||
const evidence = JSON.parse(records[0]!.slice(marker.length)) as ResolutionEvidence
|
||||
const selectedLeaf = layout === 'npm-link' ? realLeaf : logicalLeaf
|
||||
const version = layout === 'npm-link' ? '2.0.0' : '1.0.0'
|
||||
expect(evidence.esm).toEqual({ version, url: pathToFileURL(await realpath(join(selectedLeaf, 'index.mjs'))).href })
|
||||
expect(evidence.cjs).toEqual({ version, filename: await realpath(join(selectedLeaf, 'index.cjs')) })
|
||||
expect(evidence.sameEsmLeaf).toBe(true)
|
||||
expect(evidence.sameCjsLeaf).toBe(true)
|
||||
expect(evidence.toolsInstance).toBe(true)
|
||||
expect(evidence.scheduler).toBe(true)
|
||||
expect(evidence.execArgv).toEqual(mode === 'src' ? launch.args.slice(0, 2) : [])
|
||||
for (const name of ['tools', 'agent-loop']) {
|
||||
const selected = mode === 'src' ? 'src/index.ts' : 'lib/index.js'
|
||||
const other = mode === 'src' ? 'lib' : 'src'
|
||||
expect(evidence.modules.filter(url => url.endsWith(`/packages/core/${name}/${selected}`))).toHaveLength(1)
|
||||
expect(evidence.modules.filter(url => url.includes(`/packages/core/${name}/${other}/`))).toEqual([])
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
for (const link of links.reverse()) await unlink(link)
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,22 +1,30 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { clearedProxyEnv } from '@deepseek-ai/dsh-http-proxy'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SourceToolEvidence } from './fixtures/source-tool-driver.ts'
|
||||
import { testProfileResolution } from './profiles/headless/tests/profile-resolution.ts'
|
||||
|
||||
/**
|
||||
* Keyless smoke for SOURCE `dsh` execution: run `apps/cli/src/bin.ts`
|
||||
* with the exact production runtime vector (`node --import tsx/esm`, the
|
||||
* vector the root `dsh` script invokes directly) and assert the
|
||||
* required-config diagnostic. The Node compatibility matrix runs this
|
||||
* WHOLE file, so a Node release changing module hooks or TypeScript handling
|
||||
* required-config diagnostic, profile dependency resolution, and headless tool dispatch. The Node compatibility
|
||||
* matrix runs this WHOLE file, so a Node release changing module hooks or TypeScript handling
|
||||
* breaks this gate instead of every developer's `pnpm dsh`; the built-bin
|
||||
* suite covers the published `lib/` entry, not this source chain.
|
||||
*/
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const dshSourceBin = 'apps/cli/src/bin.ts'
|
||||
const sourceToolTimeoutMs = 90_000
|
||||
|
||||
describe('dsh SOURCE launcher (node --import tsx/esm)', () => {
|
||||
testProfileResolution('src')
|
||||
|
||||
it('launches the source CLI without building', async () => {
|
||||
const rootPackage = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as {
|
||||
readonly scripts?: Record<string, string>
|
||||
@@ -39,4 +47,84 @@ describe('dsh SOURCE launcher (node --import tsx/esm)', () => {
|
||||
expect(result.stderr).toContain('--profile <name> is required')
|
||||
expect(result.stdout).toBe('')
|
||||
}, 30_000)
|
||||
|
||||
it('dispatches a source-profile shell call to a result, including sandbox-unavailable hosts, without mixing tools src/lib', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-source-tool-'))
|
||||
try {
|
||||
const cwd = join(root, 'workspace')
|
||||
await mkdir(cwd)
|
||||
const patch = join(root, 'source-tool.patch.yml')
|
||||
await writeFile(patch, JSON.stringify([
|
||||
{ id: 'headless-startup', disabled: true },
|
||||
{ id: 'headless-runner', disabled: true },
|
||||
{ id: 'llm-deepseek', disabled: true },
|
||||
{ id: 'agent-default-model', config: { provider: 'cli-mock', model: 'cli-mock' } },
|
||||
{ insert: [
|
||||
{
|
||||
id: 'cli-mock-llm',
|
||||
name: join(repoRoot, 'packages/test-support/loader-smoke/tests/fixtures/cli-mock-llm.ts'),
|
||||
},
|
||||
{
|
||||
id: 'source-tool-driver',
|
||||
name: fileURLToPath(new URL('./fixtures/source-tool-driver.ts', import.meta.url)),
|
||||
config: { cwd },
|
||||
},
|
||||
] },
|
||||
]))
|
||||
// Source resolution is the subject; this profile and observer need no Web dist or built driver.
|
||||
const result = await execa(process.execPath, [
|
||||
'--import', 'tsx/esm', dshSourceBin, '--profile', 'headless', '--patch', patch,
|
||||
], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...clearedProxyEnv(),
|
||||
DSH_HOME: join(root, 'home'),
|
||||
DSH_AGENTS_HOME: join(root, 'agents'),
|
||||
DSH_TELEMETRY_DISABLED: '1',
|
||||
DSH_TOOLS_MODE: 'native',
|
||||
DSH_CLI_MOCK_FAILURE: '0',
|
||||
},
|
||||
input: '',
|
||||
timeout: sourceToolTimeoutMs - 15_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
})
|
||||
const diagnostic = `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`
|
||||
expect(result.timedOut, diagnostic).toBe(false)
|
||||
expect(result.signal, diagnostic).toBeUndefined()
|
||||
expect(result.exitCode, diagnostic).toBe(0)
|
||||
const records = result.stdout.split('\n').filter(line => line.startsWith('DSH_SOURCE_TOOL_RESULT '))
|
||||
expect(records, diagnostic).toHaveLength(1)
|
||||
const evidence = JSON.parse(records[0]!.slice('DSH_SOURCE_TOOL_RESULT '.length)) as SourceToolEvidence
|
||||
expect(evidence.execArgv).toEqual(['--import', 'tsx/esm'])
|
||||
expect(evidence.errors).toEqual([])
|
||||
for (const pkg of ['tools', 'agent-loop']) {
|
||||
expect(evidence.modules.filter(url => url.endsWith(`/packages/core/${pkg}/src/index.ts`))).toHaveLength(1)
|
||||
expect(evidence.modules.filter(url => url.includes(`/packages/core/${pkg}/lib/`))).toEqual([])
|
||||
}
|
||||
const calls = evidence.events.filter(event => event.type === 'tool/call')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.data).toMatchObject({
|
||||
callId: 'cli-smoke-call', name: process.platform === 'win32' ? 'pwsh' : 'bash',
|
||||
})
|
||||
const results = evidence.events.filter(event => event.type === 'tool/result')
|
||||
expect(results).toHaveLength(1)
|
||||
const toolResult = results[0]!.data
|
||||
expect(toolResult.message.content).toHaveLength(1)
|
||||
const block = toolResult.message.content[0]
|
||||
expect(block).toMatchObject({ type: 'tool-result', toolCallId: 'cli-smoke-call' })
|
||||
if (toolResult.error !== undefined) {
|
||||
expect(toolResult.error).toMatchObject({ name: 'SandboxUnavailableError', code: 'SANDBOX_UNAVAILABLE' })
|
||||
expect(block.isError).toBe(true)
|
||||
} else {
|
||||
expect(block.isError).not.toBe(true)
|
||||
expect(block.content.filter(part => part.type === 'text').map(part => part.text).join(''))
|
||||
.toContain('CLI_TOOL_ROUND_TRIP')
|
||||
}
|
||||
expect(evidence.events.filter(event => event.type === 'turn/end').map(event => event.data.reason))
|
||||
.toEqual([{ kind: 'completed' }])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
}, sourceToolTimeoutMs)
|
||||
})
|
||||
@@ -213,19 +213,21 @@ function resolveModuleFallbackEntries(
|
||||
declarers: ReadonlyMap<string, string>
|
||||
versions: ReadonlyMap<string, string | undefined>
|
||||
} {
|
||||
const appManifest = readModuleFallbackManifest(installAnchor)
|
||||
// tsx skips workspace path mappings for importers under node_modules.
|
||||
const canonicalAnchor = join(realModuleDirectory(dirname(installAnchor)), basename(installAnchor))
|
||||
const appManifest = readModuleFallbackManifest(canonicalAnchor)
|
||||
const links = new Map<string, string>()
|
||||
const declarers = new Map<string, string>()
|
||||
const versions = new Map<string, string | undefined>()
|
||||
/* v8 ignore next -- a real app manifest always declares its name */
|
||||
if (appManifest.name !== undefined) {
|
||||
links.set(appManifest.name, dirname(installAnchor))
|
||||
declarers.set(appManifest.name, installAnchor)
|
||||
declarers.set(appManifest.name, canonicalAnchor)
|
||||
versions.set(appManifest.name, appManifest.version)
|
||||
}
|
||||
// BFS over the resolvable dependency graph; the visited set is the link
|
||||
// map itself (first resolution wins, matching Node's own nearest-wins).
|
||||
const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: installAnchor, manifest: appManifest }]
|
||||
const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: canonicalAnchor, manifest: appManifest }]
|
||||
for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
|
||||
// Peer dependencies participate: Service Definition packages (dsh-subprocess,
|
||||
// dsh-compaction, ...) are peers of their implementations, never plain
|
||||
@@ -237,7 +239,7 @@ function resolveModuleFallbackEntries(
|
||||
// A declared-but-uninstalled dependency cannot be a loader-visible
|
||||
// plugin; skip it rather than fail the whole boot.
|
||||
if (dir === undefined) continue
|
||||
const manifestPath = join(dir, 'package.json')
|
||||
const manifestPath = join(realModuleDirectory(dir), 'package.json')
|
||||
let manifest: ProfileManifest
|
||||
try {
|
||||
manifest = skippedBundles.has(dep) ? readProfileManifest('dsh', dir) : readModuleFallbackManifest(manifestPath)
|
||||
@@ -359,7 +361,7 @@ function dependencyClosure(
|
||||
visited.add(dep)
|
||||
links.set(dep, dir)
|
||||
declarers?.set(dep, next.anchor)
|
||||
const manifestPath = join(dir, 'package.json')
|
||||
const manifestPath = join(realModuleDirectory(dir), 'package.json')
|
||||
const dependencyManifest = readModuleFallbackManifest(manifestPath)
|
||||
versions?.set(dep, dependencyManifest.version)
|
||||
queue.push({ anchor: manifestPath, manifest: dependencyManifest })
|
||||
|
||||
@@ -206,6 +206,51 @@ describe('profile resolution generation', { concurrent: false }, () => {
|
||||
expect(existsSync(join(f.root, 'profiles', 'node_modules'))).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['installation', 'symlink'],
|
||||
['installation', 'directory'],
|
||||
['bundle', 'symlink'],
|
||||
['bundle', 'directory'],
|
||||
] as const)('canonicalizes transitive %s import anchors (%s packages)', async (origin, layout) => {
|
||||
const f = fixture('bridge')
|
||||
const linked = layout === 'symlink'
|
||||
const workspace = join(f.root, 'workspace')
|
||||
const bridge = linked ? join(workspace, 'bridge') : f.installed
|
||||
const middle = linked ? join(workspace, 'middle') : join(bridge, 'node_modules', 'middle')
|
||||
pkg(bridge, 'bridge', 1, { middle: '*' })
|
||||
pkg(middle, 'middle', 2, { leaf: '*' })
|
||||
const logicalLeaf = join(bridge, 'node_modules', 'leaf')
|
||||
const workspaceLeaf = join(workspace, 'node_modules', 'leaf')
|
||||
pkg(logicalLeaf, 'leaf', 1)
|
||||
pkg(workspaceLeaf, 'leaf', 2)
|
||||
const installAnchor = linked ? join(f.root, 'install-link', 'package.json') : f.installAnchor
|
||||
if (linked) {
|
||||
rmSync(f.installed, { recursive: true })
|
||||
symlinkSync(bridge, f.installed, 'junction')
|
||||
symlinkSync(middle, join(bridge, 'node_modules', 'middle'), 'junction')
|
||||
symlinkSync(dirname(f.installAnchor), dirname(installAnchor), 'junction')
|
||||
}
|
||||
if (origin === 'bundle') {
|
||||
pkg(dirname(f.installAnchor), 'test-app', 0)
|
||||
f.profile.layers.push({
|
||||
packageName: 'bridge', packageDir: f.installed,
|
||||
patchPath: join(f.installed, 'cordis.patch.yml'), patches: [],
|
||||
})
|
||||
}
|
||||
expect(createRequire(join(bridge, 'node_modules', 'middle', 'package.json'))('leaf')).toEqual({ marker: 1 })
|
||||
const generation = await createProfileResolutionGeneration({
|
||||
installAnchor, profile: f.profile, home: f.root,
|
||||
})
|
||||
expect(generation.entries.find(entry => entry.name === 'test-app')?.declarer).toBe(f.installAnchor)
|
||||
expect(generation.entries.find(entry => entry.name === 'middle')?.declarer)
|
||||
.toBe(join(bridge, 'package.json'))
|
||||
expect(generation.entries.find(entry => entry.name === 'leaf')).toMatchObject({
|
||||
packageDir: linked ? workspaceLeaf : logicalLeaf,
|
||||
declarer: join(middle, 'package.json'),
|
||||
version: linked ? '2.0.0' : '1.0.0',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps each earlier root complete before considering a later root', async () => {
|
||||
const f = fixture('installation-bridge')
|
||||
const installedBridge = f.installed
|
||||
|
||||
Reference in new issue
Block a user