|
|
@@ -1,73 +1,45 @@
|
|
|
-import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
|
+import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
|
import { tmpdir } from 'node:os'
|
|
|
import { join } from 'node:path'
|
|
|
-import { pathToFileURL } from 'node:url'
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
import { resolveDesktopPaths } from '../src/paths.ts'
|
|
|
-import { DesktopProjectManager, type DesktopProjectHooks } from '../src/project-manager.ts'
|
|
|
+import { DesktopProjectManager } from '../src/project-manager.ts'
|
|
|
+import { readProfilePlugins } from '@deepseek-ai/dsh-app-boot'
|
|
|
import { runtimeFixture } from './runtime-fixture.ts'
|
|
|
|
|
|
const roots: string[] = []
|
|
|
-const releaseWorkers: Array<() => Promise<void>> = []
|
|
|
function temporaryRoot(): string {
|
|
|
const root = mkdtempSync(join(tmpdir(), 'dsh-desktop-test-'))
|
|
|
roots.push(root)
|
|
|
return root
|
|
|
}
|
|
|
-function writeFakePnpm(root: string): string {
|
|
|
- const path = join(root, 'pnpm.mjs')
|
|
|
- writeFileSync(path, `
|
|
|
-import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
|
-import { join } from 'node:path'
|
|
|
-const args = process.argv.slice(2)
|
|
|
-const project = process.cwd()
|
|
|
-const command = args.find(value => ['install', 'add', 'remove', 'rebuild'].includes(value))
|
|
|
-appendFileSync(${JSON.stringify(join(root, 'pnpm-log.jsonl'))}, JSON.stringify({args, registry: process.env.NPM_CONFIG_REGISTRY, userconfig: process.env.NPM_CONFIG_USERCONFIG, pnpmHome: process.env.PNPM_HOME, configHome: process.env.XDG_CONFIG_HOME}) + '\\n')
|
|
|
-if (command !== 'rebuild') {
|
|
|
- const manifestPath = join(project, 'package.json')
|
|
|
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
|
- if (command === 'add') {
|
|
|
- const spec = args.includes('--') ? args[args.indexOf('--') + 1] : args[args.indexOf(command) + 1]
|
|
|
- const index = spec.lastIndexOf('@')
|
|
|
- const name = index > 0 ? spec.slice(0, index) : spec
|
|
|
- manifest.dependencies[name] = index > 0 ? spec.slice(index + 1) : '1.0.0'
|
|
|
- }
|
|
|
- if (command === 'remove') delete manifest.dependencies[args[args.indexOf('--') + 1]]
|
|
|
- writeFileSync(manifestPath, JSON.stringify(manifest))
|
|
|
- rmSync(join(project, 'node_modules'), { recursive: true, force: true })
|
|
|
- for (const [name, version] of Object.entries(manifest.dependencies)) {
|
|
|
- const packageRoot = join(project, 'node_modules', name)
|
|
|
- mkdirSync(packageRoot, { recursive: true })
|
|
|
- writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({name, version,
|
|
|
- peerDependencies: {'@deepseek-ai/cordis': '^1.0.0'}, dsh: {bundle: {patch: './bundle.yml'}}}))
|
|
|
- writeFileSync(join(packageRoot, 'bundle.yml'), '[]\\n')
|
|
|
+function seedPlugin(manager: DesktopProjectManager): void {
|
|
|
+ const path = join(manager.paths.profile, 'package.json')
|
|
|
+ const manifest = JSON.parse(readFileSync(path, 'utf8')) as {
|
|
|
+ dependencies: Record<string, string>
|
|
|
+ dsh: { profile: { bundles: string[] } }
|
|
|
}
|
|
|
- writeFileSync(join(project, 'pnpm-lock.yaml'), JSON.stringify(manifest.dependencies))
|
|
|
+ manifest.dependencies.plugin = '1.0.0'
|
|
|
+ manifest.dsh.profile.bundles.push('plugin')
|
|
|
+ writeFileSync(path, JSON.stringify(manifest))
|
|
|
+ const directory = join(manager.paths.profile, 'node_modules/plugin')
|
|
|
+ mkdirSync(directory, { recursive: true })
|
|
|
+ writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: 'plugin', version: '1.0.0', dsh: { bundle: { patch: 'bundle.yml' } } }))
|
|
|
+ writeFileSync(join(directory, 'bundle.yml'), '[]\n')
|
|
|
}
|
|
|
-`)
|
|
|
- return path
|
|
|
-}
|
|
|
-function hooks(overrides: Partial<DesktopProjectHooks> = {}): DesktopProjectHooks {
|
|
|
- return { beforeChange: async () => {}, afterChange: async () => {}, ...overrides }
|
|
|
+function plugins(manager: DesktopProjectManager) {
|
|
|
+ return readProfilePlugins({ binName: 'dsh', profileDir: manager.paths.profile,
|
|
|
+ installAnchor: join(manager.runtime.dsh, 'node_modules/@deepseek-ai/dsh/package.json') }).dependencies
|
|
|
+ .map(({ name, version, enabled }) => ({ name, version, enabled }))
|
|
|
}
|
|
|
function setup(): { root: string; manager: DesktopProjectManager } {
|
|
|
const root = temporaryRoot()
|
|
|
const dsh = join(root, 'resources', 'dsh')
|
|
|
runtimeFixture(dsh)
|
|
|
- return { root, manager: new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { node: process.execPath, pnpm: writeFakePnpm(root), dsh }) }
|
|
|
-}
|
|
|
-function calls(root: string): { args: string[]; registry: string }[] {
|
|
|
- const path = join(root, 'pnpm-log.jsonl')
|
|
|
- return existsSync(path) ? readFileSync(path, 'utf8').trim().split('\n').map(line => JSON.parse(line) as { args: string[]; registry: string }) : []
|
|
|
+ return { root, manager: new DesktopProjectManager(resolveDesktopPaths(join(root, '.dsh')), { dsh }) }
|
|
|
}
|
|
|
-afterEach(async () => {
|
|
|
- const cleanups = releaseWorkers.splice(0)
|
|
|
- const directories = roots.splice(0)
|
|
|
- const results = await Promise.allSettled(cleanups.map(cleanup => cleanup()))
|
|
|
- for (const root of directories) rmSync(root, { recursive: true, force: true })
|
|
|
- vi.unstubAllEnvs()
|
|
|
- const failures: unknown[] = results.flatMap((result): unknown[] => result.status === 'rejected' ? [result.reason] : [])
|
|
|
- if (failures.length > 0) throw new AggregateError(failures, 'desktop worker cleanup failed')
|
|
|
+afterEach(() => {
|
|
|
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
|
|
})
|
|
|
|
|
|
describe('desktop external plugin profile', () => {
|
|
|
@@ -85,7 +57,7 @@ describe('desktop external plugin profile', () => {
|
|
|
it('reuses plugin files without scanning manifests and can disable them', async () => {
|
|
|
const { manager } = setup()
|
|
|
await manager.applyRelease()
|
|
|
- await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
+ seedPlugin(manager)
|
|
|
const manifest = join(manager.paths.profile, 'node_modules/plugin/package.json')
|
|
|
writeFileSync(manifest, '{broken')
|
|
|
await expect(manager.applyRelease()).resolves.toBeUndefined()
|
|
|
@@ -95,9 +67,9 @@ describe('desktop external plugin profile', () => {
|
|
|
})
|
|
|
|
|
|
it('disables every third-party bundle without reading a broken plugin patch declaration', async () => {
|
|
|
- const { root, manager } = setup()
|
|
|
+ const { manager } = setup()
|
|
|
await manager.applyRelease()
|
|
|
- await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
+ seedPlugin(manager)
|
|
|
const patch = join(manager.paths.profile, 'node_modules/plugin/bundle.yml')
|
|
|
unlinkSync(patch)
|
|
|
await manager.disableAllPlugins()
|
|
|
@@ -105,14 +77,13 @@ describe('desktop external plugin profile', () => {
|
|
|
dsh: { profile: { bundles: string[] } }
|
|
|
}).dsh.profile.bundles).not.toContain('plugin')
|
|
|
expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true)
|
|
|
- expect(calls(root)).toHaveLength(1)
|
|
|
await expect(manager.applyRelease()).resolves.toBeUndefined()
|
|
|
})
|
|
|
|
|
|
it('disables plugins before runtime initialization and backs up the patch while preserving package files', async () => {
|
|
|
const { manager } = setup()
|
|
|
await manager.applyRelease()
|
|
|
- await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
+ seedPlugin(manager)
|
|
|
const patch = join(manager.paths.profile, 'cordis.patch.yml')
|
|
|
writeFileSync(patch, ': broken')
|
|
|
const uninitialized = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: 'missing-runtime' })
|
|
|
@@ -151,47 +122,6 @@ describe('desktop external plugin profile', () => {
|
|
|
expect(existsSync(manager.paths.lock)).toBe(false)
|
|
|
})
|
|
|
|
|
|
- it.each(['missing', 'malformed', 'unversioned'] as const)('lists, disables, and removes a package with %s metadata', async (damage) => {
|
|
|
- const { manager } = setup()
|
|
|
- await manager.applyRelease()
|
|
|
- await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
- const path = join(manager.paths.profile, 'node_modules/plugin/package.json')
|
|
|
- if (damage === 'missing') unlinkSync(path)
|
|
|
- else writeFileSync(path, damage === 'malformed' ? '{broken' : '{}')
|
|
|
- expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }])
|
|
|
- await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: false }, hooks())
|
|
|
- expect(manager.listPlugins()[0]?.enabled).toBe(false)
|
|
|
- await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
|
|
|
- expect(manager.listPlugins()).toEqual([])
|
|
|
- })
|
|
|
-
|
|
|
- it('preserves custom profile metadata and bundle order during plugin changes', async () => {
|
|
|
- const { manager } = setup()
|
|
|
- await manager.applyRelease()
|
|
|
- const path = join(manager.paths.profile, 'package.json')
|
|
|
- writeFileSync(path, JSON.stringify({ name: 'custom', private: false, custom: true,
|
|
|
- dependencies: {}, dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle'] } } }))
|
|
|
- await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
- expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({ name: 'custom', private: false, custom: true,
|
|
|
- dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle', 'plugin'] } } })
|
|
|
- await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
|
|
|
- expect(JSON.parse(readFileSync(path, 'utf8'))).toMatchObject({
|
|
|
- dsh: { profile: { bundles: ['custom-bundle', 'custom-bundle'] } } })
|
|
|
- })
|
|
|
-
|
|
|
- it('passes user registry and pnpm configuration through to the bundled executable', async () => {
|
|
|
- const { root, manager } = setup()
|
|
|
- vi.stubEnv('NPM_CONFIG_REGISTRY', 'https://packages.example.test/')
|
|
|
- vi.stubEnv('NPM_CONFIG_USERCONFIG', join(root, 'user.npmrc'))
|
|
|
- vi.stubEnv('PNPM_HOME', join(root, 'user-pnpm'))
|
|
|
- vi.stubEnv('XDG_CONFIG_HOME', join(root, 'user-config'))
|
|
|
- await manager.applyRelease()
|
|
|
- await manager.mutate({ type: 'plugin-add', spec: 'plugin@next' }, hooks())
|
|
|
- expect(calls(root)[0]).toMatchObject({ registry: 'https://packages.example.test/',
|
|
|
- userconfig: join(root, 'user.npmrc'), pnpmHome: join(root, 'user-pnpm'), configHome: join(root, 'user-config'),
|
|
|
- args: ['add', '--', 'plugin@next'] })
|
|
|
- })
|
|
|
-
|
|
|
it('initializes the same pnpm settings as a Web profile without a Desktop build allowlist', async () => {
|
|
|
const { manager } = setup()
|
|
|
await manager.applyRelease()
|
|
|
@@ -215,8 +145,6 @@ describe('desktop external plugin profile', () => {
|
|
|
writeFileSync(path, custom)
|
|
|
await manager.applyRelease()
|
|
|
expect(readFileSync(path, 'utf8')).toBe(custom)
|
|
|
- await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
- expect(manager.listPlugins()[0]?.name).toBe('plugin')
|
|
|
})
|
|
|
|
|
|
it('reports damaged application metadata as a reinstall failure', async () => {
|
|
|
@@ -238,11 +166,10 @@ describe('desktop external plugin profile', () => {
|
|
|
})
|
|
|
|
|
|
it('initializes and restarts offline without executing pnpm', async () => {
|
|
|
- const { root, manager } = setup()
|
|
|
+ const { manager } = setup()
|
|
|
await expect(manager.applyRelease()).resolves.toBeUndefined()
|
|
|
await expect(manager.applyRelease()).resolves.toBeUndefined()
|
|
|
- expect(manager.listPlugins()).toEqual([])
|
|
|
- expect(calls(root)).toEqual([])
|
|
|
+ expect(plugins(manager)).toEqual([])
|
|
|
expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: {} })
|
|
|
})
|
|
|
|
|
|
@@ -254,7 +181,7 @@ describe('desktop external plugin profile', () => {
|
|
|
})
|
|
|
|
|
|
it.each(['changed', 'same-size', 'extra', 'missing'])('starts and reuses a profile without checking %s runtime bytes', async (operation) => {
|
|
|
- const { root, manager } = setup()
|
|
|
+ const { manager } = setup()
|
|
|
if (operation === 'changed') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{}')
|
|
|
if (operation === 'same-size') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{"type":"Module"}\n')
|
|
|
if (operation === 'extra') writeFileSync(join(manager.runtime.dsh, 'extra'), '')
|
|
|
@@ -263,173 +190,101 @@ describe('desktop external plugin profile', () => {
|
|
|
const relaunched = new DesktopProjectManager(manager.paths, manager.runtime)
|
|
|
await expect(relaunched.applyRelease()).resolves.toBeUndefined()
|
|
|
expect(existsSync(manager.paths.profile)).toBe(true)
|
|
|
- expect(calls(root)).toEqual([])
|
|
|
- })
|
|
|
-
|
|
|
- it('installs plugins with pnpm script settings', async () => {
|
|
|
- const { root, manager } = setup()
|
|
|
- await manager.applyRelease()
|
|
|
- await manager.mutate({ type: 'plugin-add', spec: '@scope/plugin@2.0.0' }, hooks())
|
|
|
- expect(manager.listPlugins()).toEqual([{ name: '@scope/plugin', version: '2.0.0', enabled: true }])
|
|
|
- expect(calls(root).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([
|
|
|
- ['add', '--', '@scope/plugin@2.0.0'],
|
|
|
- ])
|
|
|
- expect(calls(root).every(call => call.registry === process.env.NPM_CONFIG_REGISTRY)).toBe(true)
|
|
|
- expect(JSON.parse(readFileSync(join(manager.paths.profile, 'package.json'), 'utf8'))).toMatchObject({ dependencies: { '@scope/plugin': '2.0.0' } })
|
|
|
- await expect(manager.applyRelease()).resolves.toBeUndefined()
|
|
|
- expect(calls(root)).toHaveLength(1)
|
|
|
- })
|
|
|
-
|
|
|
- it('retains pnpm-installed host package copies instead of rejecting their dependency placement', async () => {
|
|
|
- const { manager } = setup()
|
|
|
- await manager.applyRelease()
|
|
|
- await manager.mutate({ type: 'plugin-add', spec: '@deepseek-ai/cordis@2.0.0' }, hooks())
|
|
|
- expect(manager.listPlugins()).toEqual([{ name: '@deepseek-ai/cordis', version: '2.0.0', enabled: false }])
|
|
|
- await expect(manager.applyRelease()).resolves.toBeUndefined()
|
|
|
- await manager.mutate({ type: 'plugin-remove', name: '@deepseek-ai/cordis' }, hooks())
|
|
|
- })
|
|
|
-
|
|
|
- it('retains disabled plugin versions through updates and enables them explicitly', async () => {
|
|
|
- const { root, manager } = setup()
|
|
|
- await manager.applyRelease()
|
|
|
- await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
- await manager.disableAllPlugins()
|
|
|
- expect(calls(root)).toHaveLength(1)
|
|
|
- expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
|
|
|
- await manager.mutate({ type: 'plugin-update', name: 'plugin', version: '1.1.0' }, hooks())
|
|
|
- expect(manager.listPlugins()).toEqual([{ name: 'plugin', version: '1.1.0', enabled: false }])
|
|
|
- await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: true }, hooks())
|
|
|
- expect(manager.listPlugins()[0]?.enabled).toBe(true)
|
|
|
- await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
|
|
|
- expect(manager.listPlugins()).toEqual([])
|
|
|
})
|
|
|
|
|
|
it('keeps plugin files and patches through a compatible release and application relocation', async () => {
|
|
|
const { root, manager } = setup()
|
|
|
await manager.applyRelease()
|
|
|
- await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
+ seedPlugin(manager)
|
|
|
writeFileSync(join(manager.paths.profile, 'cordis.patch.yml'), '[]\n')
|
|
|
const nextRoot = join(root, 'relocated', 'dsh')
|
|
|
runtimeFixture(nextRoot, '1.1.0')
|
|
|
const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: nextRoot })
|
|
|
await expect(next.applyRelease()).resolves.toBeUndefined()
|
|
|
- expect(next.listPlugins()).toEqual(manager.listPlugins())
|
|
|
- expect(next.dshVersion()).toBe('1.1.0')
|
|
|
+ expect(plugins(next)).toEqual(plugins(manager))
|
|
|
expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
|
|
|
- expect(calls(root)).toHaveLength(1)
|
|
|
expect(readFileSync(join(manager.paths.profile, 'node_modules/plugin/bundle.yml'), 'utf8')).toBe('[]\n')
|
|
|
})
|
|
|
|
|
|
it('preserves plugin files without running pnpm when bundled Node changes', async () => {
|
|
|
const { root, manager } = setup()
|
|
|
await manager.applyRelease()
|
|
|
- await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
+ seedPlugin(manager)
|
|
|
const dsh = join(root, 'new-node')
|
|
|
runtimeFixture(dsh, '1.1.0', '24.18.0')
|
|
|
const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
|
|
|
await next.applyRelease()
|
|
|
- expect(calls(root)).toHaveLength(1)
|
|
|
- expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }])
|
|
|
+ expect(plugins(next)).toEqual([{ name: 'plugin', version: '1.0.0', enabled: true }])
|
|
|
})
|
|
|
|
|
|
it('allows peer version mismatches to reach Host startup and remain available for recovery', async () => {
|
|
|
const { root, manager } = setup()
|
|
|
await manager.applyRelease()
|
|
|
- await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
+ seedPlugin(manager)
|
|
|
const dsh = join(root, 'next-major')
|
|
|
runtimeFixture(dsh, '2.0.0')
|
|
|
const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
|
|
|
await expect(next.applyRelease()).resolves.toBeUndefined()
|
|
|
- expect(next.dshVersion()).toBe('2.0.0')
|
|
|
await next.disableAllPlugins()
|
|
|
- expect(next.dshVersion()).toBe('2.0.0')
|
|
|
- expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
|
|
|
+ expect(plugins(next)).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
|
|
|
})
|
|
|
+})
|
|
|
|
|
|
- it.each(['before', 'after'] as const)('retains direct writes when the %s change hook fails', async (phase) => {
|
|
|
+describe.each(['applyRelease', 'disableAllPlugins'] as const)('desktop profile lock during %s', (operation) => {
|
|
|
+ it('preserves a live owner lock and leaves the profile untouched', async () => {
|
|
|
const { manager } = setup()
|
|
|
- await manager.applyRelease()
|
|
|
- let starts = 0
|
|
|
- await expect(manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({
|
|
|
- beforeChange: async () => {
|
|
|
- expect(manager.listPlugins()).toEqual([])
|
|
|
- if (phase === 'before') throw new Error('before failed')
|
|
|
- },
|
|
|
- afterChange: async () => { starts++; throw new Error('after failed') },
|
|
|
- }))).rejects.toThrow(`${phase} failed`)
|
|
|
- expect(manager.listPlugins()).toEqual(phase === 'before' ? [] : [{ name: 'plugin', version: '1.0.0', enabled: true }])
|
|
|
- expect(starts).toBe(phase === 'before' ? 0 : 1)
|
|
|
+ mkdirSync(manager.paths.profile, { recursive: true })
|
|
|
+ const owner = `${String(process.pid)}\n`
|
|
|
+ writeFileSync(manager.paths.lock, owner)
|
|
|
+ await expect(manager[operation]()).rejects.toThrow('another profile operation is active')
|
|
|
+ expect(readFileSync(manager.paths.lock, 'utf8')).toBe(owner)
|
|
|
+ expect(readdirSync(manager.paths.profile)).toEqual(['lock'])
|
|
|
})
|
|
|
|
|
|
- it('keeps partial package changes available for recovery after pnpm fails', async () => {
|
|
|
- const { root, manager } = setup()
|
|
|
- await manager.applyRelease()
|
|
|
- const failingPnpm = join(root, 'failing.mjs')
|
|
|
- writeFileSync(failingPnpm, `await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)}); process.exitCode = 1`)
|
|
|
- const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: failingPnpm })
|
|
|
- await worker.applyRelease()
|
|
|
- let starts = 0
|
|
|
- await expect(worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({
|
|
|
- afterChange: async () => { starts++ },
|
|
|
- }))).rejects.toThrow(/pnpm exited with 1/u)
|
|
|
- expect(worker.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
|
|
|
- expect(starts).toBe(1)
|
|
|
- expect(existsSync(manager.paths.lock)).toBe(false)
|
|
|
- writeFileSync(join(manager.paths.profile, 'desktop-packages-pending'), '')
|
|
|
- await manager.applyRelease()
|
|
|
- await manager.mutate({ type: 'plugin-toggle', name: 'plugin', enabled: false }, hooks({ afterChange: async () => { starts++ } }))
|
|
|
- expect(starts).toBe(2)
|
|
|
- await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
|
|
|
- expect(manager.listPlugins()).toEqual([])
|
|
|
+ it('reclaims a stale owner lock and releases it after the operation', async () => {
|
|
|
+ const { manager } = setup()
|
|
|
+ mkdirSync(manager.paths.profile, { recursive: true })
|
|
|
+ writeFileSync(manager.paths.lock, `${String(process.pid)}\n`)
|
|
|
+ const probe = vi.spyOn(process, 'kill').mockImplementation(() => {
|
|
|
+ throw Object.assign(new Error('process absent'), { code: 'ESRCH' })
|
|
|
+ })
|
|
|
+ try {
|
|
|
+ await expect(manager[operation]()).resolves.toBeUndefined()
|
|
|
+ expect(probe).toHaveBeenCalledExactlyOnceWith(process.pid, 0)
|
|
|
+ expect(existsSync(manager.paths.lock)).toBe(false)
|
|
|
+ } finally {
|
|
|
+ probe.mockRestore()
|
|
|
+ }
|
|
|
})
|
|
|
|
|
|
- it('retains both package and restart errors when the changed profile cannot start', async () => {
|
|
|
- const { root, manager } = setup()
|
|
|
- await manager.applyRelease()
|
|
|
- const failingPnpm = join(root, 'failing-restart.mjs')
|
|
|
- writeFileSync(failingPnpm, 'process.exitCode = 1')
|
|
|
- const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: failingPnpm })
|
|
|
- await worker.applyRelease()
|
|
|
- const afterChange = vi.fn(async () => { throw new Error('Host initialization failed') })
|
|
|
- const failure = await worker.mutate({ type: 'plugin-add', spec: 'missing' }, hooks({ afterChange }))
|
|
|
- .catch((error: unknown) => error)
|
|
|
- expect(failure).toBeInstanceOf(AggregateError)
|
|
|
- expect((failure as AggregateError).errors.map((error: Error) => error.message))
|
|
|
- .toEqual([expect.stringContaining('pnpm exited with 1'), 'Host initialization failed'])
|
|
|
- expect(afterChange).toHaveBeenCalledOnce()
|
|
|
- expect(existsSync(manager.paths.lock)).toBe(false)
|
|
|
+ it.each(['invalid', '0', '-1', '9007199254740992'])('preserves a lock with invalid owner %s', async (owner) => {
|
|
|
+ const { manager } = setup()
|
|
|
+ mkdirSync(manager.paths.profile, { recursive: true })
|
|
|
+ writeFileSync(manager.paths.lock, owner)
|
|
|
+ await expect(manager[operation]()).rejects.toThrow('another profile operation is active')
|
|
|
+ expect(readFileSync(manager.paths.lock, 'utf8')).toBe(owner)
|
|
|
})
|
|
|
|
|
|
- it('holds the transaction lock until the pnpm worker exits', async ({ task, signal }) => {
|
|
|
+ it('rejects a directory at the lock path', async () => {
|
|
|
+ const { manager } = setup()
|
|
|
+ mkdirSync(manager.paths.lock, { recursive: true })
|
|
|
+ await expect(manager[operation]()).rejects.toThrow('profile lock is not a regular file')
|
|
|
+ expect(lstatSync(manager.paths.lock).isDirectory()).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('rejects a linked lock without touching its target', async () => {
|
|
|
const { root, manager } = setup()
|
|
|
- await manager.applyRelease()
|
|
|
- const ready = join(root, 'ready')
|
|
|
- const release = join(root, 'release')
|
|
|
- const blocker = join(root, 'blocking.mjs')
|
|
|
- writeFileSync(blocker, `import {existsSync, writeFileSync} from 'node:fs'; import {setTimeout as sleep} from 'node:timers/promises'; writeFileSync(${JSON.stringify(ready)}, String(process.pid)); while (!existsSync(${JSON.stringify(release)})) await sleep(10); await import(${JSON.stringify(pathToFileURL(manager.runtime.pnpm).href)})`)
|
|
|
- const worker = new DesktopProjectManager(manager.paths, { ...manager.runtime, pnpm: blocker })
|
|
|
- await worker.applyRelease()
|
|
|
- const pending = worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
- // Teardown observes failures even if the runner has abandoned the test body.
|
|
|
- const completed = pending.then(value => ({ value }), (error: unknown) => ({ error }))
|
|
|
- releaseWorkers.push(async () => {
|
|
|
- writeFileSync(release, 'continue')
|
|
|
- const outcome = await completed
|
|
|
- if ('error' in outcome) throw outcome.error
|
|
|
- })
|
|
|
+ mkdirSync(manager.paths.profile, { recursive: true })
|
|
|
+ const target = join(root, 'lock-target')
|
|
|
+ mkdirSync(target)
|
|
|
+ writeFileSync(join(target, 'sentinel'), 'retain')
|
|
|
+ symlinkSync(target, manager.paths.lock, process.platform === 'win32' ? 'junction' : 'dir')
|
|
|
try {
|
|
|
- // Child startup shares the test budget; an aborted poll must not resume ownership assertions.
|
|
|
- await expect.poll(() => {
|
|
|
- signal.throwIfAborted()
|
|
|
- return existsSync(ready)
|
|
|
- }, { timeout: task.timeout }).toBe(true)
|
|
|
- signal.throwIfAborted()
|
|
|
- expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(readFileSync(ready, 'utf8'))
|
|
|
- await expect(manager.applyRelease()).rejects.toThrow(/another package transaction/u)
|
|
|
+ await expect(manager[operation]()).rejects.toThrow('profile lock is not a regular file')
|
|
|
+ expect(lstatSync(manager.paths.lock).isSymbolicLink()).toBe(true)
|
|
|
+ expect(readFileSync(join(target, 'sentinel'), 'utf8')).toBe('retain')
|
|
|
} finally {
|
|
|
- writeFileSync(release, 'continue')
|
|
|
- await pending
|
|
|
+ unlinkSync(manager.paths.lock)
|
|
|
}
|
|
|
- expect(existsSync(manager.paths.lock)).toBe(false)
|
|
|
})
|
|
|
})
|