|
|
@@ -3,13 +3,14 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
|
|
import { realpath } from 'node:fs/promises'
|
|
|
import { join } from 'node:path'
|
|
|
import { tmpdir } from 'node:os'
|
|
|
+import { pathToFileURL } from 'node:url'
|
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
|
import { expect, it, onTestFinished, vi } from 'vitest'
|
|
|
import {
|
|
|
- boot, composeEntries, initProfile, readProfilePatches, readProfileManifest, reconcileProfilePatches,
|
|
|
+ boot, composeEntries, initProfile, readProfilePatches, readProfileManifest, reconcileProfilePatches, OPTIONAL_BUNDLES,
|
|
|
type ProfileContext,
|
|
|
} from '@deepseek-ai/dsh-app-boot'
|
|
|
-import PluginManager, { type Config } from '../src/index.ts'
|
|
|
+import PluginManager, { type Config, type PluginChange, type PluginInstallLogChunk, type PluginInstallProgress, type PluginInstallRequestId } from '../src/index.ts'
|
|
|
import Hmr from '@deepseek-ai/dsh-hmr'
|
|
|
import Timer from '@deepseek-ai/cordis-plugin-timer'
|
|
|
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
|
|
@@ -62,16 +63,49 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre
|
|
|
}
|
|
|
|
|
|
it('lists bundle versions and current-profile plugin targets', async () => {
|
|
|
- const { manager } = await fixture()
|
|
|
+ const { manager, dir } = await fixture()
|
|
|
const plugins = await manager.listPlugins()
|
|
|
expect(plugins.find(row => row.entryId === 'include:managed')).toMatchObject({ patchId: 'managed', enabled: true })
|
|
|
- expect(plugins.find(row => row.entryId === 'include:manager')?.readOnlyReason).toBeDefined()
|
|
|
+ expect(plugins.find(row => row.entryId === 'include:manager')?.readOnlyReason).toBe('management-required')
|
|
|
expect(await manager.listBundles()).toEqual([
|
|
|
- { name: 'core', version: '1.0.0', enabled: true, removable: false, readOnlyReason: 'management-required' },
|
|
|
- { name: 'extra', version: '1.0.0', enabled: true, removable: true },
|
|
|
+ {
|
|
|
+ name: 'core', version: '1.0.0', enabled: true, installed: false, optional: false, removable: false, readOnlyReason: 'management-required',
|
|
|
+ rows: [{ rowId: 'manager', moduleName: 'cordis:manager', entryId: 'include:manager' }], overrides: [],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ name: 'extra', version: '1.0.0', enabled: true, installed: true, optional: false, removable: true,
|
|
|
+ rows: [{ rowId: 'managed', moduleName: pathToFileURL(join(dir, 'node_modules', 'extra', 'plugin.mjs')).href, entryId: 'include:managed' }], overrides: [],
|
|
|
+ },
|
|
|
])
|
|
|
})
|
|
|
|
|
|
+it('describes a bundle by its manifest and patch: one-liner, rows without a live entry, and the built-in rows it changes', async () => {
|
|
|
+ const { manager, dir, bundle } = await fixture()
|
|
|
+ bundle('described', [{ id: 'described-row', name: './plugin.mjs' }])
|
|
|
+ writeFileSync(join(dir, 'node_modules', 'described', 'package.json'), JSON.stringify({
|
|
|
+ name: 'described', version: '2.0.0', description: 'Describes itself.', dsh: { bundle: { patch: './cordis.patch.yml' } },
|
|
|
+ }))
|
|
|
+ // An anonymous row is not addressable and is left out of the rows.
|
|
|
+ writeFileSync(join(dir, 'node_modules', 'described', 'cordis.patch.yml'), JSON.stringify([
|
|
|
+ { insert: [{ id: 'described-row', name: './plugin.mjs' }, { name: './plugin.mjs' }] }, { id: 'managed', disabled: true }, { id: 'described-row', config: {} },
|
|
|
+ ]))
|
|
|
+ const manifest = readProfileManifest('test', dir)
|
|
|
+ manifest.dependencies = { ...manifest.dependencies, described: '2.0.0' }
|
|
|
+ writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
|
|
+ const moduleName = pathToFileURL(join(dir, 'node_modules', 'described', 'plugin.mjs')).href
|
|
|
+ expect((await manager.listBundles()).find(row => row.name === 'described')).toEqual({
|
|
|
+ name: 'described', version: '2.0.0', description: 'Describes itself.', enabled: false, installed: true, optional: false, removable: true,
|
|
|
+ rows: [{ rowId: 'described-row', moduleName }], overrides: ['managed'],
|
|
|
+ })
|
|
|
+ await manager.setBundleEnabled('described', true)
|
|
|
+ expect((await manager.listBundles()).find(row => row.name === 'described')?.rows).toEqual([
|
|
|
+ { rowId: 'described-row', moduleName, entryId: 'include:described-row' },
|
|
|
+ ])
|
|
|
+ // Off again, the rows lose their entries.
|
|
|
+ await manager.setBundleEnabled('described', false)
|
|
|
+ expect((await manager.listBundles()).find(row => row.name === 'described')?.rows).toEqual([{ rowId: 'described-row', moduleName }])
|
|
|
+})
|
|
|
+
|
|
|
it('turns a plugin off and on without duplicating patch overrides', async () => {
|
|
|
const { manager, dir } = await fixture()
|
|
|
const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
|
|
|
@@ -105,16 +139,17 @@ it('saves startup-only toggles and refuses removal of currently used packages',
|
|
|
expect(await manager.setPluginEnabled(id, false)).toMatchObject({ application: 'restart-required' })
|
|
|
expect((await manager.listPlugins()).find(row => row.entryId === id)?.enabled).toBe(true)
|
|
|
await manager.setBundleEnabled('extra', false)
|
|
|
- expect(await manager.removeBundle('extra')).toMatchObject({ changed: false, application: 'failed' })
|
|
|
+ expect(await manager.removeBundle('extra')).toMatchObject({ changed: false, application: 'failed', error: { code: 'stop-profile' } })
|
|
|
})
|
|
|
|
|
|
it('refuses self-disable, unknown entries and removal of installation-owned bundles', async () => {
|
|
|
const { manager } = await fixture()
|
|
|
const id = (await manager.listPlugins()).find(row => row.entryId === 'include:manager')!.entryId
|
|
|
- expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: false, application: 'failed' })
|
|
|
- expect(await manager.setPluginEnabled('missing' as typeof id, true)).toMatchObject({ changed: false, application: 'failed' })
|
|
|
- expect(await manager.removeBundle('core')).toMatchObject({ changed: false, application: 'failed' })
|
|
|
- expect(await manager.setBundleEnabled('unknown', true)).toMatchObject({ changed: false, application: 'failed' })
|
|
|
+ expect(await manager.setPluginEnabled(id, false)).toMatchObject({ changed: false, application: 'failed', error: { code: 'management-required' } })
|
|
|
+ expect(await manager.setPluginEnabled('missing' as typeof id, true)).toMatchObject({ changed: false, application: 'failed', error: { code: 'unknown-plugin' } })
|
|
|
+ expect(await manager.removeBundle('core')).toMatchObject({ changed: false, application: 'failed', error: { code: 'not-removable' } })
|
|
|
+ // A name no bundle directory answers to fails with the resolver's own diagnostic.
|
|
|
+ expect(await manager.setBundleEnabled('unknown', true)).toMatchObject({ changed: false, application: 'failed', error: { code: 'operation-error' } })
|
|
|
})
|
|
|
|
|
|
it('installs only valid bundle declarations and honors installation without activation', async () => {
|
|
|
@@ -131,7 +166,9 @@ it('installs only valid bundle declarations and honors installation without acti
|
|
|
return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
|
|
|
})
|
|
|
onTestFinished(() => { install.mockRestore() })
|
|
|
- expect(await manager.installBundle('new-bundle', { enabled: false })).toMatchObject({ changed: true, application: 'applied', packageResult: { exitCode: 0 } })
|
|
|
+ expect(await manager.installBundle('new-bundle', { enabled: false })).toMatchObject({
|
|
|
+ changed: true, application: 'applied', stage: 'enable', target: 'new-bundle', bundle: 'new-bundle', packageResult: { exitCode: 0 },
|
|
|
+ })
|
|
|
expect((await manager.listBundles()).find(row => row.name === 'new-bundle')?.enabled).toBe(false)
|
|
|
expect(await manager.setBundleEnabled('new-bundle', true)).toMatchObject({ application: 'applied' })
|
|
|
expect((await manager.listPlugins()).find(row => row.patchId === 'new-bundle')?.fiberPhase).toBe('active')
|
|
|
@@ -139,37 +176,34 @@ it('installs only valid bundle declarations and honors installation without acti
|
|
|
expect((await manager.listBundles()).find(row => row.name === 'another-bundle')?.enabled).toBe(true)
|
|
|
})
|
|
|
|
|
|
-it('cleans a blocked installation and retries only after explicit profile build approval', async () => {
|
|
|
+it('reports blocked scripts after a failed installation and retries only after explicit profile build approval', async () => {
|
|
|
const { manager, dir, bundle } = await fixture()
|
|
|
const policy = join(dir, 'pnpm-workspace.yaml')
|
|
|
- const run = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => {
|
|
|
+ const run = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
|
|
|
const manifest = readProfileManifest('test', dir)
|
|
|
const completion = { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') }
|
|
|
- if (args[0] === 'remove') {
|
|
|
- delete manifest.dependencies?.addon
|
|
|
+ manifest.dependencies = { ...manifest.dependencies, addon: '1.0.0' }
|
|
|
+ if (run.mock.calls.length === 1) {
|
|
|
+ writeFileSync(policy, 'allowBuilds:\n native: set this to true or false\n denied: false\n')
|
|
|
+ completion.exitCode = 1
|
|
|
+ completion.output = 'ERR_PNPM_IGNORED_BUILDS'
|
|
|
} else {
|
|
|
- manifest.dependencies = { ...manifest.dependencies, addon: '1.0.0' }
|
|
|
- if (run.mock.calls.length === 1) {
|
|
|
- writeFileSync(policy, 'allowBuilds:\n native: set this to true or false\n denied: false\n')
|
|
|
- completion.exitCode = 1
|
|
|
- completion.output = 'ERR_PNPM_IGNORED_BUILDS'
|
|
|
- } else {
|
|
|
- expect(parse(readFileSync(policy, 'utf8'))).toEqual({ allowBuilds: { native: true, denied: false } })
|
|
|
- bundle('addon', [])
|
|
|
- }
|
|
|
+ expect(parse(readFileSync(policy, 'utf8'))).toEqual({ allowBuilds: { native: true, denied: false } })
|
|
|
+ bundle('addon', [])
|
|
|
}
|
|
|
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
|
|
return completion
|
|
|
})
|
|
|
onTestFinished(() => { run.mockRestore() })
|
|
|
- expect(await manager.installBundle('addon')).toMatchObject({
|
|
|
- application: 'failed', pendingBuilds: ['native'], cleanup: { name: 'addon' },
|
|
|
- })
|
|
|
+ // The failed run's manifest change is put back; the policy pnpm wrote stays, so its undecided names can be offered.
|
|
|
+ expect(await manager.installBundle('addon')).toMatchObject({ application: 'failed', pendingBuilds: ['native'] })
|
|
|
expect(readProfileManifest('test', dir).dependencies).not.toHaveProperty('addon')
|
|
|
expect(parse(readFileSync(policy, 'utf8'))).toMatchObject({ allowBuilds: { native: 'set this to true or false' } })
|
|
|
expect(await manager.installBundle('addon', { approvedBuilds: ['denied'] })).toMatchObject({ application: 'failed', changed: false, error: { code: 'stale-approval' } })
|
|
|
- expect(run).toHaveBeenCalledTimes(2)
|
|
|
- expect(await manager.installBundle('addon', { approvedBuilds: ['native'], enabled: false })).toMatchObject({ application: 'applied', changed: true })
|
|
|
+ expect(run).toHaveBeenCalledTimes(1)
|
|
|
+ expect(await manager.installBundle('addon', { approvedBuilds: ['native'], enabled: false })).toMatchObject({
|
|
|
+ application: 'applied', changed: true, approvedBuilds: ['native'],
|
|
|
+ })
|
|
|
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).not.toContain('addon')
|
|
|
})
|
|
|
|
|
|
@@ -184,7 +218,7 @@ it('retains approved policy and reports it as changed when the registry fails be
|
|
|
expect(parse(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8'))).toEqual({ allowBuilds: { native: true } })
|
|
|
})
|
|
|
|
|
|
-it('runs a real pnpm dependency script only after approval and cleanup retry', async () => {
|
|
|
+it('runs a real pnpm dependency script only after approval and retry', async () => {
|
|
|
const { manager, dir, profile } = await fixture('startup')
|
|
|
const addon = join(profile.cwd, 'addon')
|
|
|
mkdirSync(addon)
|
|
|
@@ -198,7 +232,7 @@ it('runs a real pnpm dependency script only after approval and cleanup retry', a
|
|
|
policy.set('storeDir', join(profile.cwd, 'store'))
|
|
|
writeFileSync(join(dir, 'pnpm-workspace.yaml'), String(policy))
|
|
|
const blocked = await manager.installBundle('file:./addon', { enabled: false })
|
|
|
- expect(blocked, JSON.stringify(blocked)).toMatchObject({ application: 'failed', cleanup: { name: 'approval-fixture-addon' } })
|
|
|
+ expect(blocked, JSON.stringify(blocked)).toMatchObject({ application: 'failed', packageResult: { kind: 'build-blocked' } })
|
|
|
expect(blocked.pendingBuilds).toHaveLength(1)
|
|
|
const built = join(dir, 'node_modules', 'approval-fixture-addon', 'built.txt')
|
|
|
expect(existsSync(built)).toBe(false)
|
|
|
@@ -208,6 +242,14 @@ it('runs a real pnpm dependency script only after approval and cleanup retry', a
|
|
|
expect(readFileSync(built, 'utf8')).toBe('built')
|
|
|
})
|
|
|
|
|
|
+it.each(['[', 'allowBuilds: false\n'])('preserves pnpm diagnostics when pending approvals cannot be read: %s', async (policy) => {
|
|
|
+ const { manager, dir } = await fixture()
|
|
|
+ writeFileSync(join(dir, 'pnpm-workspace.yaml'), policy)
|
|
|
+ const run = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 1, output: 'original pnpm failure', truncated: false, logPath: '/log' })
|
|
|
+ onTestFinished(() => { run.mockRestore() })
|
|
|
+ expect(await manager.installBundle('addon')).toMatchObject({ application: 'failed', error: { diagnostic: 'original pnpm failure' } })
|
|
|
+})
|
|
|
+
|
|
|
it('unloads before removing packages and retries inactive dependencies whose files are missing', async () => {
|
|
|
const { manager, dir, ctx } = await fixture()
|
|
|
const remove = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
|
|
|
@@ -231,18 +273,31 @@ it('unloads before removing packages and retries inactive dependencies whose fil
|
|
|
expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false)
|
|
|
})
|
|
|
|
|
|
-it('reports package failure separately from partial disk changes', async () => {
|
|
|
+it('restores the manifest and lockfile after a failed package run, classifying the failure', async () => {
|
|
|
const { manager, dir } = await fixture()
|
|
|
+ const lockPath = join(dir, 'pnpm-lock.yaml')
|
|
|
const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
|
|
|
const manifest = readProfileManifest('test', dir)
|
|
|
manifest.dependencies = { ...manifest.dependencies, partial: '1' }
|
|
|
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
|
|
- return { exitCode: 42, output: 'fetch failed', truncated: false, logPath: join(dir, 'pnpm.log') }
|
|
|
+ writeFileSync(lockPath, 'partial lockfile\n')
|
|
|
+ return { exitCode: 42, output: 'ERR_PNPM_META_FETCH_FAIL GET https://registry/partial: ENOTFOUND', truncated: false, logPath: join(dir, 'pnpm.log') }
|
|
|
})
|
|
|
onTestFinished(() => { install.mockRestore() })
|
|
|
- expect(await manager.installBundle('partial')).toMatchObject({ changed: true, application: 'failed', packageResult: { exitCode: 42 } })
|
|
|
- expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra'])
|
|
|
- expect((await manager.listBundles()).find(row => row.name === 'partial')?.error?.diagnostic).toContain('cannot resolve profile bundle')
|
|
|
+ const before = readFileSync(join(dir, 'package.json'), 'utf8')
|
|
|
+ expect(await manager.installBundle('partial')).toMatchObject({
|
|
|
+ changed: false, application: 'failed', stage: 'install', target: 'partial',
|
|
|
+ error: { code: 'operation-error', diagnostic: expect.stringContaining('ENOTFOUND') as string },
|
|
|
+ packageResult: { exitCode: 42, kind: 'network' },
|
|
|
+ })
|
|
|
+ expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
|
|
|
+ // A lockfile the run created is removed; one that existed is put back.
|
|
|
+ expect(existsSync(lockPath)).toBe(false)
|
|
|
+ writeFileSync(lockPath, 'original lockfile\n')
|
|
|
+ expect(await manager.installBundle('partial')).toMatchObject({ changed: false, application: 'failed' })
|
|
|
+ expect(readFileSync(lockPath, 'utf8')).toBe('original lockfile\n')
|
|
|
+ expect((await manager.listBundles()).some(row => row.name === 'partial')).toBe(false)
|
|
|
+ expect(install).toHaveBeenCalledTimes(2)
|
|
|
})
|
|
|
|
|
|
it('keeps saved changes after activation failure and allows a corrected configuration to retry', async () => {
|
|
|
@@ -257,13 +312,14 @@ it('keeps saved changes after activation failure and allows a corrected configur
|
|
|
})
|
|
|
|
|
|
|
|
|
-it('reports plain dependencies, missing versions and invalid selected bundles distinctly', async () => {
|
|
|
+it('reports a selected plain dependency as a problem, omits an unselected one, and reports missing versions', async () => {
|
|
|
const { manager, dir, profile } = await fixture()
|
|
|
writeFileSync(profile.installAnchor, '{}')
|
|
|
writeFileSync(join(dir, 'node_modules', 'extra', 'package.json'), '{"name":"extra"}')
|
|
|
expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ enabled: true, error: { code: 'not-bundle' } })
|
|
|
expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ application: 'applied' })
|
|
|
- expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ enabled: false, removable: true, error: { code: 'not-bundle' } })
|
|
|
+ // Switched off, a dependency without a bundle patch is a library the page has no business with.
|
|
|
+ expect((await manager.listBundles()).some(row => row.name === 'extra')).toBe(false)
|
|
|
expect(await manager.setBundleEnabled('extra', true)).toMatchObject({ changed: false, application: 'failed' })
|
|
|
writeFileSync(join(dir, 'node_modules', 'core', 'package.json'), '{"name":"core","dsh":{"bundle":{"patch":"./cordis.patch.yml"}}}')
|
|
|
expect((await manager.listBundles())[0]?.version).toBeUndefined()
|
|
|
@@ -322,37 +378,27 @@ it('addresses children inside profile groups and marks ambiguous ids read-only',
|
|
|
|
|
|
it.each(['', '-g'])('rejects an invalid installation spec before calling pnpm: %j', async (spec) => {
|
|
|
const { manager } = await fixture()
|
|
|
- expect(await manager.installBundle(spec)).toMatchObject({ changed: false, application: 'failed' })
|
|
|
+ expect(await manager.installBundle(spec)).toMatchObject({ changed: false, application: 'failed', error: { code: 'invalid-spec' } })
|
|
|
})
|
|
|
|
|
|
-it.each([0, 1])('cleans a newly added non-bundle once and exposes leftovers when cleanup exits %i', async (cleanupExit) => {
|
|
|
+it('restores the manifest when the package pnpm added declares no bundle', async () => {
|
|
|
const { manager, dir, bundle } = await fixture()
|
|
|
- const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => {
|
|
|
+ const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
|
|
|
+ bundle('plain', [])
|
|
|
+ writeFileSync(join(dir, 'node_modules', 'plain', 'package.json'), '{"name":"plain"}')
|
|
|
const manifest = readProfileManifest('test', dir)
|
|
|
- if (args[0] === 'add') {
|
|
|
- bundle('plain', [])
|
|
|
- writeFileSync(join(dir, 'node_modules', 'plain', 'package.json'), '{"name":"plain"}')
|
|
|
- manifest.dependencies = { ...manifest.dependencies, plain: '1' }
|
|
|
- } else if (cleanupExit === 0) {
|
|
|
- delete manifest.dependencies?.plain
|
|
|
- rmSync(join(dir, 'node_modules', 'plain'), { recursive: true })
|
|
|
- }
|
|
|
+ manifest.dependencies = { ...manifest.dependencies, plain: '1' }
|
|
|
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
|
|
- return { exitCode: args[0] === 'add' ? 0 : cleanupExit, output: 'package diagnostic', truncated: false, logPath: join(dir, `${args[0]}.log`) }
|
|
|
+ return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
|
|
|
})
|
|
|
onTestFinished(() => { install.mockRestore() })
|
|
|
- expect(await manager.installBundle('plain')).toMatchObject({ changed: cleanupExit !== 0, application: 'failed', stage: 'install',
|
|
|
- error: { code: 'not-bundle' }, packageResult: { exitCode: 0 },
|
|
|
- cleanup: { name: 'plain', packageResult: { exitCode: cleanupExit } },
|
|
|
- remainingDependencies: cleanupExit === 0 ? [] : ['plain'] })
|
|
|
- expect(install.mock.calls.map(call => call[1])).toEqual([['add', 'plain'], ['remove', 'plain']])
|
|
|
- expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra'])
|
|
|
- const leftover = (await manager.listBundles()).find(row => row.name === 'plain')
|
|
|
- if (cleanupExit === 0) expect(leftover).toBeUndefined()
|
|
|
- else {
|
|
|
- expect(leftover).toMatchObject({ enabled: false, removable: true, error: { code: 'not-bundle' } })
|
|
|
- expect(await manager.removeBundle('plain')).toMatchObject({ stage: 'remove', application: 'failed' })
|
|
|
- }
|
|
|
+ expect(await manager.installBundle('plain')).toMatchObject({
|
|
|
+ changed: false, application: 'failed', stage: 'install', error: { code: 'not-bundle' }, packageResult: { exitCode: 0 },
|
|
|
+ })
|
|
|
+ // The manifest is put back rather than cleaned through another pnpm run.
|
|
|
+ expect(install).toHaveBeenCalledOnce()
|
|
|
+ expect(readProfileManifest('test', dir)).toMatchObject({ dependencies: { extra: '1.0.0' }, dsh: { profile: { bundles: ['core', 'extra'] } } })
|
|
|
+ expect((await manager.listBundles()).some(row => row.name === 'plain')).toBe(false)
|
|
|
})
|
|
|
|
|
|
it('never removes an existing dependency after installation validation fails', async () => {
|
|
|
@@ -362,7 +408,6 @@ it('never removes an existing dependency after installation validation fails', a
|
|
|
onTestFinished(() => { install.mockRestore() })
|
|
|
const result = await manager.installBundle('extra')
|
|
|
expect(result).toMatchObject({ application: 'failed', stage: 'install', error: { code: 'not-bundle' } })
|
|
|
- expect(result.cleanup).toBeUndefined()
|
|
|
expect(install).toHaveBeenCalledOnce()
|
|
|
expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
|
|
|
})
|
|
|
@@ -378,8 +423,7 @@ it('keeps a valid installed bundle when its subsequent activation fails', async
|
|
|
})
|
|
|
onTestFinished(() => { install.mockRestore() })
|
|
|
const result = await manager.installBundle('broken')
|
|
|
- expect(result).toMatchObject({ application: 'failed', stage: 'enable', packageResult: { exitCode: 0 } })
|
|
|
- expect(result.cleanup).toBeUndefined()
|
|
|
+ expect(result).toMatchObject({ application: 'failed', stage: 'enable', target: 'broken', bundle: 'broken', packageResult: { exitCode: 0 } })
|
|
|
expect(install).toHaveBeenCalledOnce()
|
|
|
expect((await manager.listBundles()).find(row => row.name === 'broken')).toMatchObject({ enabled: true, removable: true })
|
|
|
})
|
|
|
@@ -421,12 +465,179 @@ it('reports repeated installs as requiring restart and ambiguous package changes
|
|
|
onTestFinished(() => { install.mockRestore() })
|
|
|
expect(await manager.installBundle('extra')).toMatchObject({ changed: false, application: 'restart-required' })
|
|
|
expect(await manager.installBundle('extra@1')).toMatchObject({ changed: false, application: 'restart-required' })
|
|
|
- expect(await manager.installBundle('extra-long@1')).toMatchObject({ changed: false, application: 'failed' })
|
|
|
+ expect(await manager.installBundle('extra-long@1')).toMatchObject({ changed: false, application: 'failed', error: { code: 'ambiguous-install' } })
|
|
|
install.mockImplementationOnce(async () => {
|
|
|
writeFileSync(join(dir, 'package.json'), '{}')
|
|
|
return { exitCode: 0, output: '', truncated: false, logPath: join(dir, 'pnpm.log') }
|
|
|
})
|
|
|
- expect(await manager.installBundle('unknown')).toMatchObject({ changed: true, application: 'failed' })
|
|
|
+ expect(await manager.installBundle('unknown')).toMatchObject({ changed: false, application: 'failed', error: { code: 'ambiguous-install' } })
|
|
|
+ expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
|
|
|
+})
|
|
|
+
|
|
|
+it('streams pnpm output, reports the installation phases, and names the installed bundle', async () => {
|
|
|
+ const { ctx, manager, dir, bundle } = await fixture()
|
|
|
+ const chunks: PluginInstallLogChunk[] = []
|
|
|
+ const phases: PluginInstallProgress[] = []
|
|
|
+ const changes: PluginChange[] = []
|
|
|
+ ctx.on('plugin-manager/install-log', (chunk) => { chunks.push(chunk) })
|
|
|
+ ctx.on('plugin-manager/install-state', (progress) => { phases.push(progress) })
|
|
|
+ ctx.on('plugin-manager/changed', (change) => { changes.push(change) })
|
|
|
+ const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args, options) => {
|
|
|
+ options.onOutput?.('Progress: resolved 1\n', 'stdout')
|
|
|
+ options.onOutput?.('warning\n', 'stderr')
|
|
|
+ const name = String(args[1])
|
|
|
+ bundle(name, [{ id: name, name: './plugin.mjs', config: { service: name } }])
|
|
|
+ const manifest = readProfileManifest('test', dir)
|
|
|
+ manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' }
|
|
|
+ writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
|
|
+ return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
|
|
|
+ })
|
|
|
+ onTestFinished(() => { install.mockRestore() })
|
|
|
+ const requestId = 'f2340b6d-40bb-46b7-8b94-217bdf5010bd' as PluginInstallRequestId
|
|
|
+ expect(await manager.installBundle('streamed', { enabled: false, requestId })).toMatchObject({ application: 'applied', changed: true, bundle: 'streamed' })
|
|
|
+ expect(install).toHaveBeenCalledWith(expect.objectContaining({ profile: 'test' }), ['add', 'streamed'],
|
|
|
+ expect.objectContaining({ command: 'pnpm', execution: 'service' }))
|
|
|
+ const jobId = chunks[0]?.jobId
|
|
|
+ expect(chunks).toEqual([
|
|
|
+ { requestId, jobId, argv: ['pnpm', 'add', 'streamed'], cwd: dir, stream: 'stdout', text: 'Progress: resolved 1\n' },
|
|
|
+ { requestId, jobId, argv: ['pnpm', 'add', 'streamed'], cwd: dir, stream: 'stderr', text: 'warning\n' },
|
|
|
+ { requestId, jobId, argv: ['pnpm', 'add', 'streamed'], cwd: dir, stream: 'stdout', text: '', exitCode: 0 },
|
|
|
+ ])
|
|
|
+ expect(phases).toEqual([{ requestId, phase: 'installing' }, { requestId, phase: 'applying' }])
|
|
|
+ expect(changes).toEqual([{ reason: 'install' }])
|
|
|
+ // A run without a request id streams too, unidentified.
|
|
|
+ await manager.removeBundle('streamed')
|
|
|
+ expect(chunks.at(-1)).toMatchObject({ argv: ['pnpm', 'remove', 'streamed'], stream: 'stdout', exitCode: 0 })
|
|
|
+ expect(chunks.at(-1)).not.toHaveProperty('requestId')
|
|
|
+ expect(changes).toEqual([{ reason: 'install' }, { reason: 'remove' }])
|
|
|
+})
|
|
|
+
|
|
|
+it('stops a run on request, restores the files, and answers not-running or too-late otherwise', async () => {
|
|
|
+ const { ctx, manager, dir, bundle } = await fixture()
|
|
|
+ const phases: PluginInstallProgress[] = []
|
|
|
+ ctx.on('plugin-manager/install-state', (progress) => { phases.push(progress) })
|
|
|
+ const started = Promise.withResolvers<undefined>()
|
|
|
+ const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, _args, options) => {
|
|
|
+ const manifest = readProfileManifest('test', dir)
|
|
|
+ manifest.dependencies = { ...manifest.dependencies, slow: '1' }
|
|
|
+ writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
|
|
+ started.resolve(undefined)
|
|
|
+ await new Promise<undefined>((resolve) => { options.signal?.addEventListener('abort', () => { resolve(undefined) }, { once: true }) })
|
|
|
+ return { exitCode: 1, output: 'killed', truncated: false, logPath: join(dir, 'pnpm.log') }
|
|
|
+ })
|
|
|
+ onTestFinished(() => { install.mockRestore() })
|
|
|
+ const requestId = 'f2340b6d-40bb-46b7-8b94-217bdf5010bd' as PluginInstallRequestId
|
|
|
+ const before = readFileSync(join(dir, 'package.json'), 'utf8')
|
|
|
+ const run = manager.installBundle('slow', { requestId })
|
|
|
+ await started.promise
|
|
|
+ expect(await manager.cancelInstall('00000000-0000-4000-8000-000000000000' as PluginInstallRequestId)).toEqual({ status: 'not-running' })
|
|
|
+ expect(await manager.cancelInstall(requestId)).toEqual({ status: 'cancelled' })
|
|
|
+ expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
|
|
|
+ const cancelled = await run
|
|
|
+ expect(cancelled).toMatchObject({ application: 'cancelled', changed: false, stage: 'install', packageResult: { exitCode: 1 } })
|
|
|
+ expect(cancelled.error).toBeUndefined()
|
|
|
+ expect(phases).toEqual([{ requestId, phase: 'installing' }, { requestId, phase: 'cancelling' }])
|
|
|
+ expect(await manager.cancelInstall(requestId)).toEqual({ status: 'not-running' })
|
|
|
+ // Once pnpm has exited and the bundle is being applied, the run cannot be stopped.
|
|
|
+ install.mockImplementation(async (_context, args) => {
|
|
|
+ const name = String(args[1])
|
|
|
+ bundle(name, [{ id: name, name: './plugin.mjs', config: { service: name } }])
|
|
|
+ const manifest = readProfileManifest('test', dir)
|
|
|
+ manifest.dependencies = { ...manifest.dependencies, [name]: '1.0.0' }
|
|
|
+ writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
|
|
+ return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
|
|
|
+ })
|
|
|
+ let tooLate: Promise<unknown> | undefined
|
|
|
+ ctx.on('plugin-manager/install-state', (progress) => { if (progress.phase === 'applying') tooLate = manager.cancelInstall(progress.requestId) })
|
|
|
+ expect(await manager.installBundle('late', { requestId })).toMatchObject({ application: 'applied', bundle: 'late' })
|
|
|
+ expect(await tooLate).toEqual({ status: 'too-late' })
|
|
|
+ // A request aborted before its turn under the lock never starts pnpm.
|
|
|
+ const early = manager.installBundle('never', { requestId })
|
|
|
+ const queued = manager.installBundle('after', { requestId: '11111111-1111-4111-8111-111111111111' as PluginInstallRequestId })
|
|
|
+ expect(await manager.cancelInstall('11111111-1111-4111-8111-111111111111' as PluginInstallRequestId)).toEqual({ status: 'cancelled' })
|
|
|
+ await early
|
|
|
+ expect(await queued).toMatchObject({ application: 'cancelled', changed: false })
|
|
|
+})
|
|
|
+
|
|
|
+it('reads what a spec names before installing it', async () => {
|
|
|
+ const { manager, dir, profile } = await fixture(undefined, false, undefined, { inspectTimeoutMs: 1000, pnpmCommand: 'pnpm-test' })
|
|
|
+ const view = vi.spyOn(operations, 'viewProfilePackage')
|
|
|
+ onTestFinished(() => { view.mockRestore() })
|
|
|
+ const answers = (stdout: string) => view.mockResolvedValueOnce({ exitCode: 0, stdout, stderr: '', timedOut: false })
|
|
|
+ answers(JSON.stringify({ name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', dsh: { bundle: { patch: './cordis.patch.yml' } } }))
|
|
|
+ expect(await manager.inspect('dsh-x')).toEqual({
|
|
|
+ status: 'accepted', kind: 'registry', name: 'dsh-x', version: '1.4.2', description: 'A sidebar.', bundle: true,
|
|
|
+ })
|
|
|
+ expect(view).toHaveBeenCalledWith(dir, 'dsh-x', { command: 'pnpm-test', timeoutMs: 1000 })
|
|
|
+ const signal = AbortSignal.abort()
|
|
|
+ answers(JSON.stringify([{ name: 'dsh-lib', version: '1.0.0', dsh: { bundle: {} } }, { name: 'dsh-lib', version: '1.1.0', dsh: null }]))
|
|
|
+ expect(await manager.inspect('dsh-lib@^1', signal)).toEqual({ status: 'refused', problem: 'not-a-bundle', reason: 'dsh-lib declares no dsh.bundle' })
|
|
|
+ expect(view).toHaveBeenLastCalledWith(dir, 'dsh-lib@^1', { command: 'pnpm-test', timeoutMs: 1000, signal })
|
|
|
+ // An answer that names no package keeps the name the spec gave; colour escapes around the JSON are dropped.
|
|
|
+ answers('\x1b[36m' + JSON.stringify({ version: '0.0.1', description: '', dsh: { bundle: { patch: './p.yml' } } }) + '\x1b[39m\n')
|
|
|
+ expect(await manager.inspect('dsh-bare')).toEqual({ status: 'accepted', kind: 'registry', name: 'dsh-bare', version: '0.0.1', bundle: true })
|
|
|
+ const failure = (stderr: string, exitCode: number | null = 1, more: Partial<operations.PackageViewResult> = {}) =>
|
|
|
+ view.mockResolvedValueOnce({ exitCode, stdout: '', stderr, timedOut: false, ...more })
|
|
|
+ failure('npm error code E404\nnpm error 404 Not Found - GET https://registry/nope\n')
|
|
|
+ expect(await manager.inspect('nope')).toMatchObject({ status: 'refused', problem: 'not-found', reason: expect.stringContaining('E404') as string })
|
|
|
+ failure('ERR_PNPM_NO_MATCHING_VERSION No matching version found for old@9\n')
|
|
|
+ expect(await manager.inspect('old@9')).toMatchObject({ status: 'refused', problem: 'not-found' })
|
|
|
+ failure('ERR_PNPM_META_FETCH_FAIL request failed, reason: getaddrinfo ENOTFOUND registry\n')
|
|
|
+ expect(await manager.inspect('far')).toMatchObject({ status: 'refused', problem: 'network' })
|
|
|
+ view.mockResolvedValueOnce({ exitCode: 3, stdout: 'plain text\n', stderr: '', timedOut: false })
|
|
|
+ expect(await manager.inspect('odd')).toEqual({ status: 'refused', problem: 'unknown', reason: 'plain text' })
|
|
|
+ failure('', 4)
|
|
|
+ expect(await manager.inspect('quiet')).toEqual({ status: 'refused', problem: 'unknown', reason: 'pnpm view exited with 4' })
|
|
|
+ failure('', null, { timedOut: true })
|
|
|
+ expect(await manager.inspect('slow')).toEqual({ status: 'refused', problem: 'unknown', reason: 'pnpm view timed out after 1000ms' })
|
|
|
+ failure('', null, { cause: Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' }) })
|
|
|
+ expect(await manager.inspect('gone')).toMatchObject({ status: 'refused', problem: 'unknown', reason: expect.stringContaining('ENOENT') as string })
|
|
|
+ answers('not json')
|
|
|
+ expect(await manager.inspect('garbled')).toMatchObject({ status: 'refused', problem: 'unknown', reason: expect.stringContaining('unreadable pnpm view output') as string })
|
|
|
+ answers('"just a string"')
|
|
|
+ expect(await manager.inspect('scalar')).toEqual({ status: 'refused', problem: 'unknown', reason: 'pnpm view answered no package' })
|
|
|
+ answers('')
|
|
|
+ expect(await manager.inspect('silent')).toEqual({ status: 'refused', problem: 'unknown', reason: 'pnpm view answered no package' })
|
|
|
+ // What is installed, or supplied by the installation, is refused before the registry is asked.
|
|
|
+ expect(await manager.inspect('extra')).toEqual({ status: 'refused', problem: 'already-installed', reason: 'extra is already installed' })
|
|
|
+ expect(await manager.inspect('./relative')).toEqual({ status: 'refused', problem: 'invalid-spec', reason: 'a local path must be absolute' })
|
|
|
+ expect(await manager.inspect('github:acme/dsh-remote')).toEqual({ status: 'accepted', kind: 'git', bundle: null })
|
|
|
+ const tarball = join(profile.home, 'pack.tgz')
|
|
|
+ expect(await manager.inspect(tarball)).toEqual({ status: 'refused', problem: 'not-a-package', reason: 'the tarball does not exist' })
|
|
|
+ writeFileSync(tarball, '')
|
|
|
+ expect(await manager.inspect(tarball)).toEqual({ status: 'accepted', kind: 'tarball', bundle: null })
|
|
|
+ expect(await manager.inspect('https://cdn.example.com/x/y/z/dsh-x-1.0.0.tgz')).toEqual({ status: 'accepted', kind: 'tarball', bundle: null })
|
|
|
+ // A directory answers from its own manifest.
|
|
|
+ const local = join(profile.home, 'dev', 'dsh-local')
|
|
|
+ mkdirSync(local, { recursive: true })
|
|
|
+ expect(await manager.inspect(join(profile.home, 'dev', 'missing'))).toEqual({ status: 'refused', problem: 'not-a-package', reason: 'the path does not exist' })
|
|
|
+ expect(await manager.inspect(local)).toMatchObject({ status: 'refused', problem: 'not-a-package', reason: expect.stringContaining('no readable package.json') as string })
|
|
|
+ writeFileSync(join(local, 'package.json'), '{"version":"1.0.0"}')
|
|
|
+ expect(await manager.inspect(local)).toEqual({ status: 'refused', problem: 'not-a-package', reason: 'the package.json names no package' })
|
|
|
+ writeFileSync(join(local, 'package.json'), JSON.stringify({ name: 'dsh-local', version: '0.1.0', description: 'Local.' }))
|
|
|
+ expect(await manager.inspect(local)).toEqual({ status: 'refused', problem: 'not-a-bundle', reason: 'dsh-local declares no dsh.bundle' })
|
|
|
+ writeFileSync(join(local, 'package.json'), JSON.stringify({ name: 'dsh-local', version: '0.1.0', description: 'Local.', dsh: { bundle: { patch: './p.yml' } } }))
|
|
|
+ expect(await manager.inspect(`file:${local}`)).toEqual({ status: 'accepted', kind: 'path', name: 'dsh-local', version: '0.1.0', description: 'Local.', bundle: true })
|
|
|
+ writeFileSync(join(local, 'package.json'), JSON.stringify({ name: 'core', dsh: { bundle: { patch: './p.yml' } } }))
|
|
|
+ expect(await manager.inspect(local)).toEqual({ status: 'refused', problem: 'already-installed', reason: 'core is already installed' })
|
|
|
+ // A profile and an installation that list nothing know nothing.
|
|
|
+ writeFileSync(join(dir, 'package.json'), '{}')
|
|
|
+ writeFileSync(profile.installAnchor, '{}')
|
|
|
+ expect(await manager.inspect(local)).toEqual({ status: 'accepted', kind: 'path', name: 'core', bundle: true })
|
|
|
+ expect(view).toHaveBeenCalledTimes(13)
|
|
|
+})
|
|
|
+
|
|
|
+it('announces each manager operation as a change, and a patch generation applied outside it not at all', async () => {
|
|
|
+ const { ctx, manager, profile } = await fixture('startup')
|
|
|
+ const changes: PluginChange[] = []
|
|
|
+ ctx.on('plugin-manager/changed', (change) => { changes.push(change) })
|
|
|
+ await reconcileProfilePatches(ctx, readProfilePatches('test', profile), 'test')
|
|
|
+ expect(changes).toEqual([])
|
|
|
+ const id = (await manager.listPlugins()).find(row => row.patchId === 'managed')!.entryId
|
|
|
+ await manager.setPluginEnabled(id, false)
|
|
|
+ expect(changes).toEqual([{ reason: 'plugin' }])
|
|
|
+ await manager.setBundleEnabled('extra', false)
|
|
|
+ expect(changes).toEqual([{ reason: 'plugin' }, { reason: 'bundle' }])
|
|
|
})
|
|
|
|
|
|
it('handles missing patch files and retains non-Error package diagnostics', async () => {
|
|
|
@@ -463,6 +674,29 @@ it('refuses removal of a hot-installed bundle after HMR is disabled', async () =
|
|
|
expect(await manager.removeBundle('later')).toMatchObject({ changed: false, application: 'failed' })
|
|
|
})
|
|
|
|
|
|
+it('offers the launcher\'s optional bundles switched off and never removable', async () => {
|
|
|
+ const { manager, profile } = await fixture()
|
|
|
+ // The launcher names the bundles the installation ships; the fixture supplies one of them from the
|
|
|
+ // installation's own node_modules, which the resolver consults before the profile's and before the repository's.
|
|
|
+ const offered = OPTIONAL_BUNDLES[0]!
|
|
|
+ const supplied = join(profile.home, 'node_modules', offered)
|
|
|
+ mkdirSync(supplied, { recursive: true })
|
|
|
+ writeFileSync(join(supplied, 'package.json'), JSON.stringify({
|
|
|
+ name: offered, version: '3.0.0', description: 'Package one-liner.', dsh: { bundle: { patch: './cordis.patch.yml' } },
|
|
|
+ }))
|
|
|
+ writeFileSync(join(supplied, 'cordis.patch.yml'), JSON.stringify([{ insert: [{ id: 'offered-row', name: './plugin.mjs', config: { service: 'offeredProbe' } }] }]))
|
|
|
+ writeFileSync(join(supplied, 'plugin.mjs'), 'export function apply(ctx, config) { ctx.provide(config?.service ?? "offeredProbe", true) }\n')
|
|
|
+ writeFileSync(profile.installAnchor, JSON.stringify({ name: 'installation', dependencies: { [offered]: '3.0.0' } }))
|
|
|
+ expect((await manager.listBundles()).find(row => row.name === offered)).toEqual({
|
|
|
+ name: offered, version: '3.0.0', description: 'Package one-liner.',
|
|
|
+ enabled: false, installed: false, optional: true, removable: false,
|
|
|
+ rows: [{ rowId: 'offered-row', moduleName: pathToFileURL(join(supplied, 'plugin.mjs')).href }], overrides: [],
|
|
|
+ })
|
|
|
+ expect(await manager.setBundleEnabled(offered, true)).toMatchObject({ application: 'applied' })
|
|
|
+ expect((await manager.listBundles()).find(row => row.name === offered)).toMatchObject({ enabled: true, optional: true, removable: false })
|
|
|
+ expect(await manager.removeBundle(offered)).toMatchObject({ changed: false, application: 'failed' })
|
|
|
+})
|
|
|
+
|
|
|
it('omits installation-owned plain packages from the bundle inventory', async () => {
|
|
|
const { manager, dir, profile, bundle } = await fixture()
|
|
|
bundle('installation-plain', [])
|
|
|
@@ -471,48 +705,6 @@ it('omits installation-owned plain packages from the bundle inventory', async ()
|
|
|
expect((await manager.listBundles()).some(row => row.name === 'installation-plain')).toBe(false)
|
|
|
})
|
|
|
|
|
|
-it.each(['throw', 'remove-manifest-dependencies', 'invalid-manifest'] as const)('preserves the original installation failure when cleanup encounters %s', async (failure) => {
|
|
|
- const { manager, dir, profile } = await fixture()
|
|
|
- writeFileSync(profile.installAnchor, '{}')
|
|
|
- const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async (_context, args) => {
|
|
|
- if (args[0] === 'add') {
|
|
|
- const manifest = readProfileManifest('test', dir)
|
|
|
- manifest.dependencies = { ...manifest.dependencies, partial: '1' }
|
|
|
- writeFileSync(join(dir, 'package.json'), failure === 'invalid-manifest' ? '{' : JSON.stringify(manifest))
|
|
|
- } else if (failure === 'throw') throw new Error('cleanup unavailable')
|
|
|
- else writeFileSync(join(dir, 'package.json'), '{}')
|
|
|
- return { exitCode: args[0] === 'add' ? 42 : 0, output: 'fetch failed', truncated: false, logPath: '/operation.log' }
|
|
|
- })
|
|
|
- onTestFinished(() => { install.mockRestore() })
|
|
|
- const result = await manager.installBundle('partial')
|
|
|
- expect(result).toMatchObject({ application: 'failed', error: { code: 'operation-error', diagnostic: 'fetch failed' } })
|
|
|
- if (failure === 'throw') {
|
|
|
- expect(result.cleanup).toMatchObject({ name: 'partial', error: { diagnostic: 'cleanup unavailable' } })
|
|
|
- expect(result.remainingDependencies).toEqual(['partial'])
|
|
|
- } else if (failure === 'invalid-manifest') {
|
|
|
- expect(result.warnings?.[0]).toContain('SyntaxError')
|
|
|
- expect(install).toHaveBeenCalledOnce()
|
|
|
- } else expect(result.remainingDependencies).toEqual([])
|
|
|
-})
|
|
|
-
|
|
|
-it.each(['ambiguous', 'selected', 'installation-owned'] as const)('does not clean newly declared dependencies that are %s', async (reason) => {
|
|
|
- const { manager, dir, profile } = await fixture()
|
|
|
- const install = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
|
|
|
- const manifest = readProfileManifest('test', dir)
|
|
|
- manifest.dependencies = { ...manifest.dependencies, partial: '1', ...reason === 'ambiguous' ? { another: '1' } : {} }
|
|
|
- if (reason === 'selected') manifest.dsh!.profile!.bundles!.push('partial')
|
|
|
- if (reason === 'installation-owned') writeFileSync(profile.installAnchor, '{"dependencies":{"partial":"1"}}')
|
|
|
- writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
|
|
- return { exitCode: 42, output: 'fetch failed', truncated: false, logPath: '/operation.log' }
|
|
|
- })
|
|
|
- onTestFinished(() => { install.mockRestore() })
|
|
|
- const result = await manager.installBundle('partial')
|
|
|
- expect(result.application).toBe('failed')
|
|
|
- expect(result.cleanup).toBeUndefined()
|
|
|
- expect(result.remainingDependencies).toContain('partial')
|
|
|
- expect(install).toHaveBeenCalledOnce()
|
|
|
-})
|
|
|
-
|
|
|
it('does not delete a bundle retained by a higher-priority overlay', async () => {
|
|
|
const { ctx, manager, overlays, dir } = await fixture()
|
|
|
const entry = [...ctx.loader.entries()].find(row => row.id === 'include:managed')!
|
|
|
@@ -550,11 +742,3 @@ it('applies watched configuration while pnpm installation is still running', asy
|
|
|
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core', 'extra', 'new-bundle'])
|
|
|
expect(ctx.get('managedProbe')).toBeUndefined()
|
|
|
})
|
|
|
-
|
|
|
-it.each(['[', 'allowBuilds: false\n'])('preserves pnpm diagnostics when pending approvals cannot be read: %s', async (policy) => {
|
|
|
- const { manager, dir } = await fixture()
|
|
|
- writeFileSync(join(dir, 'pnpm-workspace.yaml'), policy)
|
|
|
- const run = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 1, output: 'original pnpm failure', truncated: false, logPath: '/log' })
|
|
|
- onTestFinished(() => { run.mockRestore() })
|
|
|
- expect(await manager.installBundle('addon')).toMatchObject({ application: 'failed', error: { diagnostic: 'original pnpm failure' } })
|
|
|
-})
|