|
|
@@ -1,11 +1,10 @@
|
|
|
-import { existsSync, mkdtempSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, realpathSync, writeFileSync } from 'node:fs'
|
|
|
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, realpathSync, writeFileSync } from 'node:fs'
|
|
|
import { tmpdir } from 'node:os'
|
|
|
-import { dirname, join } from 'node:path'
|
|
|
+import { join } from 'node:path'
|
|
|
import { pathToFileURL } from 'node:url'
|
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
|
import { resolveDesktopPaths } from '../src/paths.ts'
|
|
|
import { DesktopProjectManager, packageNameFromSpec, type DesktopProjectHooks } from '../src/project-manager.ts'
|
|
|
-import { readDesktopProfileState } from '../src/profile-packages.ts'
|
|
|
import { runtimeFixture } from './runtime-fixture.ts'
|
|
|
|
|
|
const roots: string[] = []
|
|
|
@@ -48,7 +47,7 @@ if (command !== 'rebuild') {
|
|
|
return path
|
|
|
}
|
|
|
function hooks(overrides: Partial<DesktopProjectHooks> = {}): DesktopProjectHooks {
|
|
|
- return { beforeActivate: async () => {}, afterActivate: async () => {}, ...overrides }
|
|
|
+ return { beforeChange: async () => {}, afterChange: async () => {}, ...overrides }
|
|
|
}
|
|
|
function setup(): { root: string; manager: DesktopProjectManager } {
|
|
|
const root = temporaryRoot()
|
|
|
@@ -63,6 +62,85 @@ function calls(root: string): { args: string[]; registry: string }[] {
|
|
|
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
|
|
|
|
|
|
describe('desktop external plugin profile', () => {
|
|
|
+ it('starts with disabled plugins even when their installed manifests are corrupt', async () => {
|
|
|
+ const { manager } = setup()
|
|
|
+ await manager.applyRelease()
|
|
|
+ await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
+ const manifest = join(manager.paths.profile, 'node_modules/plugin/package.json')
|
|
|
+ writeFileSync(manifest, '{broken')
|
|
|
+ await expect(manager.applyRelease()).rejects.toThrow()
|
|
|
+ await manager.mutate({ type: 'plugins-disable-all' }, hooks())
|
|
|
+ await expect(manager.applyRelease()).resolves.toBe(false)
|
|
|
+ expect(readFileSync(manifest, 'utf8')).toBe('{broken')
|
|
|
+ await manager.resetConfiguration(hooks())
|
|
|
+ expect(existsSync(manifest)).toBe(false)
|
|
|
+ await expect(manager.applyRelease()).resolves.toBe(false)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('disables every third-party bundle without reading a broken plugin patch declaration', async () => {
|
|
|
+ const { root, manager } = setup()
|
|
|
+ await manager.applyRelease()
|
|
|
+ await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
+ const patch = join(manager.paths.profile, 'node_modules/plugin/bundle.yml')
|
|
|
+ unlinkSync(patch)
|
|
|
+ await manager.mutate({ type: 'plugins-disable-all' }, hooks({ afterChange: async () => {
|
|
|
+ expect(manager.hasEnabledPlugins()).toBe(false)
|
|
|
+ } }))
|
|
|
+ expect(manager.hasEnabledPlugins()).toBe(false)
|
|
|
+ expect(existsSync(join(manager.paths.profile, 'node_modules/plugin/package.json'))).toBe(true)
|
|
|
+ expect(calls(root)).toHaveLength(2)
|
|
|
+ await expect(manager.applyRelease()).resolves.toBe(false)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('resets the entire profile without backups while retaining its lock and shared data', async () => {
|
|
|
+ const { root, manager } = setup()
|
|
|
+ await manager.applyRelease()
|
|
|
+ await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
+ const profile = manager.paths.profile
|
|
|
+ expect(manager.paths.lock).toBe(join(profile, 'lock'))
|
|
|
+ const task = join(root, '.dsh', 'task-sentinel')
|
|
|
+ const homeEnvironment = join(root, '.dsh', '.env')
|
|
|
+ writeFileSync(homeEnvironment, 'HOME_SETTING=retained')
|
|
|
+ writeFileSync(task, 'retained task')
|
|
|
+ writeFileSync(join(profile, 'desktop-runtime-state.json'), '{broken')
|
|
|
+ writeFileSync(join(profile, 'cordis.patch.yml'), ': broken')
|
|
|
+ writeFileSync(join(profile, '.env'), 'NODE_OPTIONS=--bad')
|
|
|
+ mkdirSync(join(profile, '.extra'))
|
|
|
+ writeFileSync(join(profile, '.extra', 'custom-file'), 'remove')
|
|
|
+ const shared = join(root, 'shared-data')
|
|
|
+ mkdirSync(shared)
|
|
|
+ writeFileSync(join(shared, 'sentinel'), 'preserve')
|
|
|
+ symlinkSync(shared, join(profile, 'external-link'), process.platform === 'win32' ? 'junction' : 'dir')
|
|
|
+ await expect(manager.applyRelease()).rejects.toMatchObject({ recovery: 'configuration' })
|
|
|
+ await manager.resetConfiguration(hooks({
|
|
|
+ beforeChange: async () => { expect(readFileSync(join(profile, 'cordis.patch.yml'), 'utf8')).toBe(': broken') },
|
|
|
+ afterChange: async () => {
|
|
|
+ manager.assertProfileRuntime(profile)
|
|
|
+ expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(String(process.pid))
|
|
|
+ await expect(manager.applyRelease()).rejects.toThrow('another package transaction is active')
|
|
|
+ },
|
|
|
+ }))
|
|
|
+ expect(manager.listPlugins()).toEqual([])
|
|
|
+ expect(existsSync(join(profile, 'node_modules/plugin'))).toBe(false)
|
|
|
+ expect(existsSync(join(profile, 'cordis.patch.yml'))).toBe(false)
|
|
|
+ expect(existsSync(join(profile, '.env'))).toBe(false)
|
|
|
+ expect(existsSync(join(profile, '.extra'))).toBe(false)
|
|
|
+ expect(existsSync(join(profile, 'external-link'))).toBe(false)
|
|
|
+ expect(readFileSync(join(shared, 'sentinel'), 'utf8')).toBe('preserve')
|
|
|
+ expect(readFileSync(task, 'utf8')).toBe('retained task')
|
|
|
+ expect(readFileSync(homeEnvironment, 'utf8')).toBe('HOME_SETTING=retained')
|
|
|
+ expect(readdirSync(profile).some(name => name.includes('backup'))).toBe(false)
|
|
|
+ expect(calls(root)).toHaveLength(2)
|
|
|
+ await expect(manager.applyRelease()).resolves.toBe(false)
|
|
|
+ expect(existsSync(homeEnvironment)).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reports damaged application metadata as a reinstall failure', async () => {
|
|
|
+ const { manager } = setup()
|
|
|
+ writeFileSync(join(manager.runtime.dsh, 'desktop-runtime.json'), '{broken')
|
|
|
+ await expect(manager.applyRelease()).rejects.toMatchObject({ recovery: 'reinstall' })
|
|
|
+ })
|
|
|
+
|
|
|
it('accepts registry names and tags but rejects alternate sources and flags', () => {
|
|
|
expect(packageNameFromSpec('@scope/plugin@1.2.3')).toBe('@scope/plugin')
|
|
|
expect(packageNameFromSpec('plugin@next')).toBe('plugin')
|
|
|
@@ -73,9 +151,8 @@ describe('desktop external plugin profile', () => {
|
|
|
|
|
|
it('initializes and restarts offline without executing pnpm', async () => {
|
|
|
const { root, manager } = setup()
|
|
|
- await expect(manager.applyRelease('2.0.0', hooks())).rejects.toThrow(/does not match Electron/u)
|
|
|
- await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(true)
|
|
|
- await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(false)
|
|
|
+ await expect(manager.applyRelease()).resolves.toBe(true)
|
|
|
+ await expect(manager.applyRelease()).resolves.toBe(false)
|
|
|
expect(manager.listPlugins()).toEqual([])
|
|
|
expect(calls(root)).toEqual([])
|
|
|
expect(existsSync(manager.paths.pnpm.store)).toBe(false)
|
|
|
@@ -85,21 +162,17 @@ describe('desktop external plugin profile', () => {
|
|
|
|
|
|
it('repairs a removed managed link without running pnpm', async () => {
|
|
|
const { root, manager } = setup()
|
|
|
- await manager.applyRelease('1.0.0', hooks())
|
|
|
+ await manager.applyRelease()
|
|
|
unlinkSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))
|
|
|
- await expect(manager.applyRelease('1.0.0', hooks())).resolves.toBe(true)
|
|
|
+ await expect(manager.applyRelease()).resolves.toBe(true)
|
|
|
expect(calls(root)).toEqual([])
|
|
|
})
|
|
|
|
|
|
it.skipIf(process.platform !== 'win32')('reuses the profile when the launch path changes only Windows letter casing', async () => {
|
|
|
const { manager } = setup()
|
|
|
- await manager.applyRelease('1.0.0', hooks())
|
|
|
+ await manager.applyRelease()
|
|
|
const relaunched = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh: manager.runtime.dsh.toUpperCase() })
|
|
|
- let started = false
|
|
|
- await expect(relaunched.applyRelease('1.0.0', hooks({
|
|
|
- afterActivate: async () => { started = true },
|
|
|
- }))).resolves.toBe(false)
|
|
|
- expect(started).toBe(false)
|
|
|
+ await expect(relaunched.applyRelease()).resolves.toBe(false)
|
|
|
})
|
|
|
|
|
|
it.each(['changed', 'same-size', 'extra', 'missing'])('starts and reuses a profile without checking %s runtime bytes', async (operation) => {
|
|
|
@@ -108,22 +181,16 @@ describe('desktop external plugin profile', () => {
|
|
|
if (operation === 'same-size') writeFileSync(join(manager.runtime.dsh, 'package.json'), '{"type":"Module"}\n')
|
|
|
if (operation === 'extra') writeFileSync(join(manager.runtime.dsh, 'extra'), '')
|
|
|
if (operation === 'missing') unlinkSync(join(manager.runtime.dsh, 'package.json'))
|
|
|
- let starts = 0
|
|
|
- await expect(manager.applyRelease('1.0.0', hooks({
|
|
|
- afterActivate: async () => { starts++ },
|
|
|
- }))).resolves.toBe(true)
|
|
|
+ await expect(manager.applyRelease()).resolves.toBe(true)
|
|
|
const relaunched = new DesktopProjectManager(manager.paths, manager.runtime)
|
|
|
- await expect(relaunched.applyRelease('1.0.0', hooks({
|
|
|
- afterActivate: async () => { starts++ },
|
|
|
- }))).resolves.toBe(false)
|
|
|
- expect(starts).toBe(1)
|
|
|
+ await expect(relaunched.applyRelease()).resolves.toBe(false)
|
|
|
expect(existsSync(manager.paths.profile)).toBe(true)
|
|
|
expect(calls(root)).toEqual([])
|
|
|
})
|
|
|
|
|
|
it('installs only plugins and checks the graph before running lifecycle scripts', async () => {
|
|
|
const { root, manager } = setup()
|
|
|
- await manager.applyRelease('1.0.0', hooks())
|
|
|
+ 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([
|
|
|
@@ -136,7 +203,7 @@ describe('desktop external plugin profile', () => {
|
|
|
|
|
|
it('retains disabled plugin versions through updates and enables them explicitly', async () => {
|
|
|
const { root, manager } = setup()
|
|
|
- await manager.applyRelease('1.0.0', hooks())
|
|
|
+ await manager.applyRelease()
|
|
|
await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
await manager.mutate({ type: 'plugins-disable-all' }, hooks())
|
|
|
expect(calls(root)).toHaveLength(2)
|
|
|
@@ -151,30 +218,29 @@ describe('desktop external plugin profile', () => {
|
|
|
|
|
|
it('keeps plugin files and patches through a compatible release and application relocation', async () => {
|
|
|
const { root, manager } = setup()
|
|
|
- await manager.applyRelease('1.0.0', hooks())
|
|
|
+ await manager.applyRelease()
|
|
|
await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
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('1.1.0', hooks())).resolves.toBe(true)
|
|
|
+ await expect(next.applyRelease()).resolves.toBe(true)
|
|
|
expect(next.listPlugins()).toEqual(manager.listPlugins())
|
|
|
expect(next.releaseVersion()).toBe('1.1.0')
|
|
|
expect(readFileSync(join(manager.paths.profile, 'cordis.patch.yml'), 'utf8')).toBe('[]\n')
|
|
|
expect(calls(root)).toHaveLength(2)
|
|
|
expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis'))).toBe(realpathSync(join(nextRoot, 'node_modules/@deepseek-ai/cordis')))
|
|
|
- writeFileSync(join(manager.paths.rollback, 'node_modules/plugin/bundle.yml'), 'rollback only')
|
|
|
expect(readFileSync(join(manager.paths.profile, 'node_modules/plugin/bundle.yml'), 'utf8')).toBe('[]\n')
|
|
|
})
|
|
|
|
|
|
it('reinstalls the locked plugin graph when bundled Node changes', async () => {
|
|
|
const { root, manager } = setup()
|
|
|
- await manager.applyRelease('1.0.0', hooks())
|
|
|
+ await manager.applyRelease()
|
|
|
await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
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('1.1.0', hooks())
|
|
|
+ await next.applyRelease()
|
|
|
expect(calls(root).slice(2).map(call => call.args.filter(arg => !arg.startsWith('--config.')))).toEqual([
|
|
|
['install', '--frozen-lockfile', '--ignore-scripts'], ['rebuild', '--pending'],
|
|
|
])
|
|
|
@@ -183,60 +249,70 @@ describe('desktop external plugin profile', () => {
|
|
|
|
|
|
it('allows incompatible plugins to be disabled in recovery without deleting them', async () => {
|
|
|
const { root, manager } = setup()
|
|
|
- await manager.applyRelease('1.0.0', hooks())
|
|
|
+ await manager.applyRelease()
|
|
|
await manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
const dsh = join(root, 'next-major')
|
|
|
runtimeFixture(dsh, '2.0.0')
|
|
|
const next = new DesktopProjectManager(manager.paths, { ...manager.runtime, dsh })
|
|
|
- await expect(next.applyRelease('2.0.0', hooks())).rejects.toThrow(/requires @deepseek-ai\/cordis/u)
|
|
|
- expect(next.releaseVersion()).toBe('1.0.0')
|
|
|
- expect(() =>{ next.assertProfileRuntime(manager.paths.profile) }).toThrow(/does not match/u)
|
|
|
+ await expect(next.applyRelease()).rejects.toThrow(/requires @deepseek-ai\/cordis/u)
|
|
|
+ expect(next.releaseVersion()).toBe('2.0.0')
|
|
|
await next.mutate({ type: 'plugins-disable-all' }, hooks())
|
|
|
expect(next.releaseVersion()).toBe('2.0.0')
|
|
|
expect(next.listPlugins()).toEqual([{ name: 'plugin', version: '1.0.0', enabled: false }])
|
|
|
})
|
|
|
|
|
|
- it.each(['before', 'after'] as const)('keeps the active profile when %s activation fails', async (phase) => {
|
|
|
+ it.each(['before', 'after'] as const)('retains direct writes when the %s change hook fails', async (phase) => {
|
|
|
const { manager } = setup()
|
|
|
- await manager.applyRelease('1.0.0', hooks())
|
|
|
+ await manager.applyRelease()
|
|
|
let starts = 0
|
|
|
await expect(manager.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks({
|
|
|
- beforeActivate: async () => { if (phase === 'before') throw new Error('before failed') },
|
|
|
- afterActivate: async () => { if (phase === 'after' && starts++ === 0) throw new Error('after failed') },
|
|
|
+ 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([])
|
|
|
- expect(existsSync(manager.paths.pending)).toBe(false)
|
|
|
+ expect(manager.listPlugins()).toEqual(phase === 'before' ? [] : [{ name: 'plugin', version: '1.0.0', enabled: true }])
|
|
|
+ expect(starts).toBe(phase === 'before' ? 0 : 1)
|
|
|
+ expect(existsSync(join(manager.paths.root, 'staging'))).toBe(false)
|
|
|
+ expect(existsSync(join(manager.paths.root, 'rollback'))).toBe(false)
|
|
|
+ expect(existsSync(join(manager.paths.root, 'pending.json'))).toBe(false)
|
|
|
})
|
|
|
|
|
|
- it('recovers a directory move using recorded runtime identities', async () => {
|
|
|
- const { manager } = setup()
|
|
|
- await manager.applyRelease('1.0.0', hooks())
|
|
|
- const id = readDesktopProfileState(manager.paths.profile)?.runtimeId
|
|
|
- const stagingProfile = join(manager.paths.staging, 'interrupted', 'profile')
|
|
|
- mkdirSync(stagingProfile, { recursive: true })
|
|
|
- mkdirSync(dirname(manager.paths.rollback), { recursive: true })
|
|
|
- renameSync(manager.paths.profile, manager.paths.rollback)
|
|
|
- writeFileSync(manager.paths.pending, JSON.stringify({ schemaVersion: 1, id: 'interrupted', stagingProfile, fromRuntimeId: id, toRuntimeId: id, step: 'active-moved' }))
|
|
|
- manager.recover()
|
|
|
- expect(manager.releaseVersion()).toBe('1.0.0')
|
|
|
- expect(existsSync(stagingProfile)).toBe(false)
|
|
|
- expect(existsSync(manager.paths.pending)).toBe(false)
|
|
|
+ it('keeps partial package changes and restores host links 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(0)
|
|
|
+ expect(existsSync(manager.paths.lock)).toBe(false)
|
|
|
+ expect(realpathSync(join(manager.paths.profile, 'node_modules/@deepseek-ai/cordis')))
|
|
|
+ .toBe(realpathSync(join(manager.runtime.dsh, 'node_modules/@deepseek-ai/cordis')))
|
|
|
+ await manager.mutate({ type: 'plugin-remove', name: 'plugin' }, hooks())
|
|
|
+ expect(manager.listPlugins()).toEqual([])
|
|
|
})
|
|
|
|
|
|
it('holds the transaction lock until the pnpm worker exits', async () => {
|
|
|
const { root, manager } = setup()
|
|
|
- await manager.applyRelease('1.0.0', hooks())
|
|
|
+ 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('1.0.0', hooks())
|
|
|
+ await worker.applyRelease()
|
|
|
const pending = worker.mutate({ type: 'plugin-add', spec: 'plugin@1.0.0' }, hooks())
|
|
|
try {
|
|
|
await expect.poll(() => existsSync(ready)).toBe(true)
|
|
|
expect(readFileSync(manager.paths.lock, 'utf8').trim()).toBe(readFileSync(ready, 'utf8'))
|
|
|
- await expect(manager.applyRelease('1.0.0', hooks())).rejects.toThrow(/another package transaction/u)
|
|
|
+ await expect(manager.applyRelease()).rejects.toThrow(/another package transaction/u)
|
|
|
} finally {
|
|
|
writeFileSync(release, 'continue')
|
|
|
await pending
|