2396 lines
129 KiB
TypeScript
2396 lines
129 KiB
TypeScript
/**
|
|
* The manager store: what it reads, how actions cross the wire, which
|
|
* outcomes become toasts, and how the install run folds its output.
|
|
*/
|
|
|
|
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
|
import type { BundleInfo, ChangeResult, ManagementError, PluginEntryId, PluginInfo, PluginInstallRequestId } from '@deepseek-ai/dsh-api-remotes/client'
|
|
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
|
|
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
|
import type { PluginTab } from '../src/client/tab-ledger.ts'
|
|
import type { ConfigLedger } from '../src/client/config-ledger.ts'
|
|
import { installable, offeredRegistries, packageView, PluginManagerController, rowKey, sortPackages, updateCandidate } from '../src/client/manager-store.ts'
|
|
|
|
const INCOMPATIBLE = { name: 'dsh-late', version: '2.0.0', runtimeVersion: '0.1.0', requirements: { '@deepseek-ai/dsh': '^0.2.0' } }
|
|
const ROW_ENTRY = 'include:sidebar' as PluginEntryId
|
|
|
|
const BUNDLE: BundleInfo = {
|
|
name: 'dsh-better-sidebar',
|
|
official: false,
|
|
availability: 'profile',
|
|
version: '0.16.0',
|
|
description: 'A sidebar.',
|
|
enabled: false,
|
|
installed: true,
|
|
source: 'dsh-better-sidebar@^0.16.0',
|
|
optional: false,
|
|
removable: true,
|
|
updatable: false,
|
|
rows: [{ rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY }, { rowId: 'theme', moduleName: 'dsh-better-sidebar/theme' }],
|
|
overrides: [],
|
|
}
|
|
|
|
const PLUGINS: PluginInfo[] = [
|
|
{ entryId: ROW_ENTRY, moduleName: 'dsh-better-sidebar', enabled: true, fiberPhase: 'active', patchId: 'sidebar' },
|
|
{ entryId: 'include:core' as PluginEntryId, moduleName: '@deepseek-ai/dsh-base', enabled: true, fiberPhase: 'active', readOnlyReason: 'management-required' },
|
|
]
|
|
|
|
const MIRROR = 'https://registry.npmmirror.com/'
|
|
const OFFICIAL = 'https://registry.npmjs.org/'
|
|
const CORP = 'https://npm.corp.example/'
|
|
|
|
/** The registries the Host asks: pnpm's own first, which names npm's own registry, then the mirror. */
|
|
const REGISTRIES = { registry: null, fallbackRegistries: [MIRROR], resolved: OFFICIAL }
|
|
|
|
/** What the check answers for a registry name. */
|
|
const INSPECTED = { status: 'accepted' as const, kind: 'registry' as const, name: 'dsh-better-sidebar', version: '1.0.0', bundle: true, registry: null }
|
|
|
|
const APPLIED: ChangeResult = { changed: true, application: 'applied', stage: 'enable', target: 'dsh-better-sidebar' }
|
|
|
|
/** A change the Host could not apply, with the refusal it names. */
|
|
function failed(
|
|
error?: ManagementError, packageResult?: ChangeResult['packageResult'], facts: Pick<ChangeResult, 'stage' | 'enabled'> = { stage: 'install' },
|
|
): ChangeResult {
|
|
return {
|
|
changed: false, application: 'failed', target: 'dsh-better-sidebar', ...facts,
|
|
...error === undefined ? {} : { error }, ...packageResult === undefined ? {} : { packageResult },
|
|
}
|
|
}
|
|
|
|
function ok<T>(value: T) {
|
|
return { ok: true as const, value }
|
|
}
|
|
|
|
function refused(code: string, message: string, details: object = {}) {
|
|
// The double's code map is keyed by literal codes; a spec-chosen string stands in.
|
|
return { ok: false as const, error: new RemoteError(code as never, message, details as never) }
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void
|
|
const promise = new Promise<T>((res) => { resolve = res })
|
|
return { promise, resolve }
|
|
}
|
|
|
|
/** A configuration ledger with nothing registered, as the page binds it beside the store. */
|
|
const NO_CONFIG: HostObservable<ConfigLedger> = {
|
|
getSnapshot: () => ({ items: [], bundles: new Set(), rows: new Set() }),
|
|
subscribe: () => () => {},
|
|
}
|
|
|
|
const NO_TABS: HostObservable<readonly PluginTab[]> = { getSnapshot: () => [], subscribe: () => () => {} }
|
|
|
|
function bench(overrides: Partial<Record<string, ReturnType<typeof vi.fn>>> = {}, enabled = true) {
|
|
const inventory = { list: overrides.inventory ?? vi.fn(() => Promise.resolve(ok({ entries: [], managementAvailable: true }))) }
|
|
const plugins = {
|
|
listBundles: vi.fn<() => Promise<ReturnType<typeof ok<BundleInfo[]>> | ReturnType<typeof refused>>>(
|
|
() => Promise.resolve(ok([BUNDLE])),
|
|
),
|
|
listPlugins: vi.fn(() => Promise.resolve(ok(PLUGINS))),
|
|
inspect: vi.fn(() => Promise.resolve(ok(INSPECTED))),
|
|
registries: vi.fn(() => Promise.resolve(ok(REGISTRIES))),
|
|
installBundle: vi.fn(() => Promise.resolve(ok({ ...APPLIED, bundle: 'dsh-new' }))),
|
|
waitForInstall: vi.fn(() => Promise.resolve(refused('gateway/internal', 'offline'))),
|
|
cancelInstall: vi.fn(() => Promise.resolve(ok({ status: 'cancelled' }))),
|
|
removeBundle: vi.fn(() => Promise.resolve(ok(APPLIED))),
|
|
setBundleEnabled: vi.fn(() => Promise.resolve(ok(APPLIED))),
|
|
setPluginEnabled: vi.fn(() => Promise.resolve(ok(APPLIED))),
|
|
...overrides,
|
|
}
|
|
const probe = { fastest: overrides.fastest ?? vi.fn(() => Promise.resolve(ok(null))) }
|
|
const track = vi.fn()
|
|
const ctx = {
|
|
get: () => ({ enabled, track }),
|
|
configForms: { describe: () => ({ getSnapshot: () => ({ view: { namespaces: [] } }), subscribe: () => () => {} }), get: vi.fn((id: string) => `form:${id}`) },
|
|
remote: { pluginManager: plugins, pluginInventory: inventory, pluginRegistryProbe: probe },
|
|
} as never
|
|
const controller = new PluginManagerController(ctx)
|
|
onTestFinished(() => { controller.dispose() })
|
|
const face = controller.inject(NO_CONFIG, NO_TABS, text => typeof text === 'string' ? text : text.en)
|
|
const state = () => controller.getSnapshot()
|
|
/** The request id of the run the dialog just handed to the Host. */
|
|
const started = async (): Promise<PluginInstallRequestId> => {
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('starting') })
|
|
return state().install.requestId as PluginInstallRequestId
|
|
}
|
|
return { plugins, inventory, probe, controller, face, state, started, track }
|
|
}
|
|
|
|
it('hands a custom page the shared configuration form of its entry', () => {
|
|
const { face } = bench()
|
|
expect(face.configForm('bundle#row')).toBe('form:bundle#row' as never)
|
|
})
|
|
|
|
describe('packageView', () => {
|
|
it('carries the current inventory failure into a component row', () => {
|
|
const plugins = PLUGINS.map(plugin => plugin.entryId === ROW_ENTRY
|
|
? { ...plugin, fiberPhase: 'failed' as const, diagnostic: 'native preparation unavailable' } : plugin)
|
|
expect(packageView(BUNDLE, plugins).rows[0]?.diagnostic).toBe('native preparation unavailable')
|
|
expect(packageView(BUNDLE, PLUGINS).rows[0]?.diagnostic).toBeUndefined()
|
|
})
|
|
|
|
it('joins a bundle with the entries its rows run as', () => {
|
|
expect(packageView(BUNDLE, PLUGINS)).toEqual({
|
|
name: 'dsh-better-sidebar', version: '0.16.0', description: 'A sidebar.', source: 'dsh-better-sidebar@^0.16.0',
|
|
installed: true, official: false, availability: 'profile', optional: false, removable: true, updatable: false, enabled: false,
|
|
rows: [
|
|
{ rowId: 'sidebar', moduleName: 'dsh-better-sidebar', entryId: ROW_ENTRY, enabled: true, phase: 'active' },
|
|
{ rowId: 'theme', moduleName: 'dsh-better-sidebar/theme', enabled: false, phase: null },
|
|
],
|
|
})
|
|
// A row the inventory no longer lists, a protected row, and a bundle the Host cannot read.
|
|
const protectedBundle: BundleInfo = {
|
|
name: '@deepseek-ai/dsh-base', enabled: true, installed: false, official: false, availability: 'installation', optional: false, removable: false, updatable: false, readOnlyReason: 'management-required',
|
|
error: { code: 'operation-error', diagnostic: 'broken' },
|
|
rows: [{ rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core' as PluginEntryId }, { rowId: 'gone', moduleName: 'x', entryId: 'include:gone' as PluginEntryId }],
|
|
overrides: [],
|
|
}
|
|
expect(packageView(protectedBundle, PLUGINS)).toEqual({
|
|
name: '@deepseek-ai/dsh-base', installed: false, official: false, availability: 'installation', optional: false, removable: false, updatable: false, enabled: true, readOnlyReason: 'management-required',
|
|
error: { code: 'operation-error', diagnostic: 'broken' },
|
|
rows: [
|
|
{ rowId: 'core', moduleName: '@deepseek-ai/dsh-base', entryId: 'include:core', enabled: true, phase: 'active', readOnlyReason: 'management-required' },
|
|
{ rowId: 'gone', moduleName: 'x', entryId: 'include:gone', enabled: false, phase: null },
|
|
],
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('sortPackages', () => {
|
|
it('orders packages by the short name a person reads, not by the Host order or enablement', async () => {
|
|
const plain = { enabled: true, installed: true, official: false, availability: 'profile' as const, optional: false, removable: true, updatable: false, rows: [], overrides: [] }
|
|
const zeta: BundleInfo = { ...plain, name: 'dsh-zeta' }
|
|
const alpha: BundleInfo = { ...plain, name: '@acme/dsh-alpha', enabled: false }
|
|
const views = [zeta, BUNDLE, alpha].map(bundle => packageView(bundle, PLUGINS))
|
|
expect(sortPackages(views).map(pkg => pkg.name)).toEqual(['@acme/dsh-alpha', 'dsh-better-sidebar', 'dsh-zeta'])
|
|
// The store lists what it read in that order, whatever the Host's order.
|
|
const { state, controller } = bench({ listBundles: vi.fn(() => Promise.resolve(ok([zeta, BUNDLE, alpha]))) })
|
|
await controller.load()
|
|
expect(state().packages.map(pkg => pkg.name)).toEqual(['@acme/dsh-alpha', 'dsh-better-sidebar', 'dsh-zeta'])
|
|
})
|
|
})
|
|
|
|
describe('PluginManagerController', () => {
|
|
it.each(['before', 'after'] as const)('closes immediately and retries an early cancellation when acceptance arrives %s its reply', async (order) => {
|
|
const install = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const firstCancel = deferred<ReturnType<typeof ok<{ status: 'not-running' }>>>()
|
|
const stopped = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
|
|
const { face, state, controller, plugins, started } = bench({
|
|
installBundle: vi.fn().mockReturnValue(install.promise),
|
|
cancelInstall: vi.fn().mockReturnValueOnce(firstCancel.promise).mockReturnValueOnce(stopped.promise),
|
|
})
|
|
face.openInstall()
|
|
face.editInstallSpec('slow')
|
|
face.runInstall()
|
|
const requestId = await started()
|
|
face.closeInstall()
|
|
expect(state().install).toMatchObject({ open: false, phase: 'cancelling', requestId })
|
|
expect(plugins.cancelInstall).toHaveBeenCalledExactlyOnceWith(requestId)
|
|
face.openInstall()
|
|
face.runInstall()
|
|
expect(state().install).toMatchObject({ open: true, requestId, spec: 'slow' })
|
|
expect(plugins.installBundle).toHaveBeenCalledOnce()
|
|
if (order === 'before') controller.installProgress({ requestId, phase: 'installing' })
|
|
firstCancel.resolve(ok({ status: 'not-running' }))
|
|
if (order === 'after') {
|
|
await vi.waitFor(() => { expect(state().install.failure?.uncertainty).toBe('acceptance') })
|
|
controller.installProgress({ requestId, phase: 'installing' })
|
|
}
|
|
await vi.waitFor(() => { expect(plugins.cancelInstall).toHaveBeenCalledTimes(2) })
|
|
expect(state().install.phase).toBe('cancelling')
|
|
stopped.resolve(ok({ status: 'cancelled' }))
|
|
await vi.waitFor(() => { expect(state().install).toMatchObject({ open: true, phase: 'idle', spec: 'slow' }) })
|
|
install.resolve(ok({ ...failed(), application: 'cancelled' }))
|
|
})
|
|
|
|
it('keeps local package and row metadata in the loaded view without changing technical identities', async () => {
|
|
const meta = { title: { en: 'Sidebar', zh: '侧栏' }, description: 'Local package', error: 'locale/zh.json: invalid title' }
|
|
const rowMeta = { title: { en: 'Theme', zh: '主题' }, description: { en: 'Display options', zh: '显示选项' } }
|
|
const bundle: BundleInfo = {
|
|
...BUNDLE,
|
|
meta,
|
|
rows: BUNDLE.rows.map(row => ({ ...row, meta: rowMeta })),
|
|
}
|
|
const { controller, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([bundle])) })
|
|
|
|
await controller.load()
|
|
|
|
expect(state().packages[0]).toMatchObject({ name: BUNDLE.name, meta })
|
|
expect(state().packages[0]!.meta).toBe(meta)
|
|
expect(state().packages[0]!.rows[0]).toMatchObject({ rowId: 'sidebar', moduleName: BUNDLE.name, entryId: ROW_ENTRY, meta: rowMeta })
|
|
expect(state().packages[0]!.rows[0]!.meta).toBe(rowMeta)
|
|
expect(state().packages[0]!.error).toBeUndefined()
|
|
})
|
|
|
|
it('starts idle, reads the inventory then the bundles and entries on first use, and folds concurrent loads', async () => {
|
|
const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
|
|
const { plugins, inventory, face, state, controller } = bench({
|
|
listBundles: vi.fn().mockReturnValueOnce(gate.promise).mockResolvedValue(ok([BUNDLE])),
|
|
})
|
|
expect(state().status).toBe('idle')
|
|
face.ensure()
|
|
face.ensure()
|
|
await Promise.resolve()
|
|
expect(state().status).toBe('loading')
|
|
const mid = controller.load()
|
|
gate.resolve(ok([BUNDLE]))
|
|
await mid
|
|
expect(state().status).toBe('ready')
|
|
expect(state().packages).toEqual([packageView(BUNDLE, PLUGINS)])
|
|
expect(inventory.list).toHaveBeenCalledTimes(2)
|
|
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
|
expect(plugins.listPlugins).toHaveBeenCalledTimes(2)
|
|
face.ensure()
|
|
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('keeps cached packages while a manual refresh reads and ignores repeated refreshes without a success notice', async () => {
|
|
vi.useFakeTimers()
|
|
onTestFinished(() => { vi.useRealTimers() })
|
|
const { plugins, inventory, face, state, controller } = bench()
|
|
await controller.load()
|
|
const packages = state().packages
|
|
const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
|
|
plugins.listBundles.mockReturnValueOnce(gate.promise)
|
|
face.refresh()
|
|
expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null })
|
|
expect(state().packages).toBe(packages)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
|
face.refresh()
|
|
face.refresh()
|
|
gate.resolve(ok([{ ...BUNDLE, version: '0.17.0' }]))
|
|
await vi.advanceTimersByTimeAsync(399)
|
|
expect(state().refreshStatus).toBe('refreshing')
|
|
await vi.advanceTimersByTimeAsync(1)
|
|
expect(state().refreshStatus).toBe('idle')
|
|
expect(state().packages[0]?.version).toBe('0.17.0')
|
|
expect(state().status).toBe('ready')
|
|
expect(state().notice).toBeNull()
|
|
expect(inventory.list).toHaveBeenCalledTimes(2)
|
|
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
|
expect(plugins.listPlugins).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it.each(['refused', 'rejected'] as const)('keeps cached packages ready after a %s refresh and clears its toast when retry starts', async (failure) => {
|
|
vi.useFakeTimers()
|
|
onTestFinished(() => { vi.useRealTimers() })
|
|
const { inventory, plugins, face, state, controller } = bench()
|
|
await controller.load()
|
|
const packages = state().packages
|
|
if (failure === 'refused') inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline'))
|
|
else inventory.list.mockRejectedValueOnce(new Error('transport down'))
|
|
face.refresh()
|
|
await vi.advanceTimersByTimeAsync(400)
|
|
expect(state().refreshStatus).toBe('idle')
|
|
expect(state()).toMatchObject({ status: 'ready', notice: { kind: 'refresh-failed', seq: 1 } })
|
|
expect(state().packages).toBe(packages)
|
|
const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
|
|
plugins.listBundles.mockReturnValueOnce(gate.promise)
|
|
face.refresh()
|
|
expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null })
|
|
expect(state().packages).toBe(packages)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
|
gate.resolve(ok([BUNDLE]))
|
|
await vi.advanceTimersByTimeAsync(400)
|
|
expect(state().refreshStatus).toBe('idle')
|
|
expect(state()).toMatchObject({ status: 'ready', notice: null })
|
|
inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline again'))
|
|
face.refresh()
|
|
await vi.advanceTimersByTimeAsync(400)
|
|
expect(state().notice).toEqual({ kind: 'refresh-failed', seq: 2 })
|
|
face.dismissNotice()
|
|
expect(state().notice).toBeNull()
|
|
})
|
|
|
|
it.each(['refused', 'rejected'] as const)('keeps a %s first refresh failure inline without a toast', async (failure) => {
|
|
vi.useFakeTimers()
|
|
onTestFinished(() => { vi.useRealTimers() })
|
|
const inventory = failure === 'refused'
|
|
? vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline'))
|
|
: vi.fn().mockRejectedValueOnce(new Error('transport down'))
|
|
const { face, state } = bench({ inventory })
|
|
face.refresh()
|
|
await vi.advanceTimersByTimeAsync(400)
|
|
expect(state().refreshStatus).toBe('failed')
|
|
expect(state()).toMatchObject({ status: 'error', packages: [], notice: null })
|
|
})
|
|
|
|
it('treats an empty successful inventory as cached for refresh failures', async () => {
|
|
vi.useFakeTimers()
|
|
onTestFinished(() => { vi.useRealTimers() })
|
|
const { inventory, face, state, controller } = bench({ listBundles: vi.fn().mockResolvedValue(ok([])) })
|
|
await controller.load()
|
|
expect(state().packages).toEqual([])
|
|
inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline'))
|
|
face.refresh()
|
|
await vi.advanceTimersByTimeAsync(400)
|
|
expect(state().refreshStatus).toBe('idle')
|
|
expect(state()).toMatchObject({ status: 'ready', packages: [], notice: { kind: 'refresh-failed' } })
|
|
})
|
|
|
|
it('forgets cached inventory when the managed profile becomes unavailable', async () => {
|
|
vi.useFakeTimers()
|
|
onTestFinished(() => { vi.useRealTimers() })
|
|
const { inventory, face, state, controller } = bench()
|
|
await controller.load()
|
|
inventory.list.mockResolvedValueOnce(ok({ entries: [], managementAvailable: false }))
|
|
await controller.load()
|
|
expect(state()).toMatchObject({ status: 'unavailable', packages: [] })
|
|
inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline'))
|
|
face.refresh()
|
|
await vi.advanceTimersByTimeAsync(400)
|
|
expect(state().refreshStatus).toBe('failed')
|
|
expect(state()).toMatchObject({ status: 'error', packages: [], notice: null })
|
|
})
|
|
|
|
it('preserves unrelated notices when a manual refresh starts and succeeds', async () => {
|
|
vi.useFakeTimers()
|
|
onTestFinished(() => { vi.useRealTimers() })
|
|
const { face, state, controller } = bench({
|
|
setBundleEnabled: vi.fn().mockResolvedValue(ok({ ...APPLIED, application: 'restart-required' })),
|
|
})
|
|
await controller.load()
|
|
face.setEnabled(BUNDLE.name, true)
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(state().notice?.kind).toBe('restart')
|
|
const notice = state().notice
|
|
face.refresh()
|
|
expect(state().notice).toBe(notice)
|
|
await vi.advanceTimersByTimeAsync(400)
|
|
expect(state().refreshStatus).toBe('idle')
|
|
expect(state().notice).toBe(notice)
|
|
})
|
|
|
|
it('keeps background reads quiet and clears an uncached refresh failure when they succeed', async () => {
|
|
vi.useFakeTimers()
|
|
onTestFinished(() => { vi.useRealTimers() })
|
|
const { plugins, face, state, controller } = bench({
|
|
inventory: vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline'))
|
|
.mockResolvedValue(ok({ entries: [], managementAvailable: true })),
|
|
})
|
|
face.refresh()
|
|
await vi.advanceTimersByTimeAsync(400)
|
|
expect(state().refreshStatus).toBe('failed')
|
|
const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
|
|
plugins.listBundles.mockReturnValueOnce(gate.promise)
|
|
const loading = controller.load()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(plugins.listBundles).toHaveBeenCalledOnce()
|
|
expect(state().refreshStatus).toBe('failed')
|
|
gate.resolve(ok([BUNDLE]))
|
|
await loading
|
|
expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'idle', notice: null })
|
|
})
|
|
|
|
it.each(['background', 'manual'] as const)('holds the refresh indicator through coalesced reads when %s starts first', async (first) => {
|
|
vi.useFakeTimers()
|
|
onTestFinished(() => { vi.useRealTimers() })
|
|
const { plugins, face, state, controller } = bench()
|
|
await controller.load()
|
|
const initial = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
|
|
const rerun = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
|
|
plugins.listBundles.mockReturnValueOnce(initial.promise).mockReturnValueOnce(rerun.promise)
|
|
const background = first === 'background' ? controller.load() : undefined
|
|
if (first === 'manual') face.refresh()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
|
expect(state().refreshStatus).toBe(first === 'background' ? 'idle' : 'refreshing')
|
|
const completion = background ?? controller.load()
|
|
if (first === 'background') face.refresh()
|
|
initial.resolve(ok([{ ...BUNDLE, version: '0.17.0' }]))
|
|
await vi.advanceTimersByTimeAsync(400)
|
|
expect(plugins.listBundles).toHaveBeenCalledTimes(3)
|
|
expect(state()).toMatchObject({ status: 'ready', refreshStatus: 'refreshing', notice: null })
|
|
expect(state().packages[0]?.version).toBe('0.17.0')
|
|
face.refresh()
|
|
rerun.resolve(ok([{ ...BUNDLE, version: '0.18.0' }]))
|
|
await completion
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(state().refreshStatus).toBe('idle')
|
|
expect(state().packages[0]?.version).toBe('0.18.0')
|
|
expect(state().notice).toBeNull()
|
|
expect(plugins.listBundles).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it.each([false, true])('reports only the final coalesced refresh result (failure: %s)', async (failure) => {
|
|
vi.useFakeTimers()
|
|
onTestFinished(() => { vi.useRealTimers() })
|
|
const { plugins, face, state, controller } = bench()
|
|
await controller.load()
|
|
const initial = deferred<ReturnType<typeof ok<BundleInfo[]>> | ReturnType<typeof refused>>()
|
|
const rerun = deferred<ReturnType<typeof ok<BundleInfo[]>> | ReturnType<typeof refused>>()
|
|
plugins.listBundles.mockReturnValueOnce(initial.promise).mockReturnValueOnce(rerun.promise)
|
|
face.refresh()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
|
const loading = controller.load()
|
|
initial.resolve(failure ? ok([BUNDLE]) : refused('gateway/internal', 'offline'))
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(plugins.listBundles).toHaveBeenCalledTimes(3)
|
|
expect(state().refreshStatus).toBe('refreshing')
|
|
expect(state().notice).toBeNull()
|
|
rerun.resolve(failure ? refused('gateway/internal', 'offline') : ok([BUNDLE]))
|
|
await loading
|
|
await vi.advanceTimersByTimeAsync(400)
|
|
expect(state().refreshStatus).toBe('idle')
|
|
expect(state().status).toBe('ready')
|
|
expect(state().notice).toEqual(failure ? { kind: 'refresh-failed', seq: 1 } : null)
|
|
})
|
|
|
|
it.each([false, true])('does not publish a manual refresh that settles after disposal (failure: %s)', async (failure) => {
|
|
vi.useFakeTimers()
|
|
onTestFinished(() => { vi.useRealTimers() })
|
|
const { plugins, face, state, controller } = bench()
|
|
await controller.load()
|
|
const gate = deferred<ReturnType<typeof ok<BundleInfo[]>> | ReturnType<typeof refused>>()
|
|
plugins.listBundles.mockReturnValueOnce(gate.promise)
|
|
face.refresh()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
|
const completion = controller.load()
|
|
const before = state()
|
|
const listener = vi.fn()
|
|
const unsubscribe = face.hooks.pluginManager.subscribe(listener)
|
|
onTestFinished(unsubscribe)
|
|
controller.dispose()
|
|
gate.resolve(failure ? refused('gateway/internal', 'offline') : ok([{ ...BUNDLE, version: '0.17.0' }]))
|
|
await completion
|
|
face.refresh()
|
|
await vi.advanceTimersByTimeAsync(400)
|
|
expect(state()).toBe(before)
|
|
expect(listener).not.toHaveBeenCalled()
|
|
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('reports a Host without a managed profile as unavailable and keeps the last packages across a failed read', async () => {
|
|
vi.useFakeTimers()
|
|
onTestFinished(() => { vi.useRealTimers() })
|
|
const { inventory, plugins, face, state, controller } = bench()
|
|
await controller.load()
|
|
expect(state().packages).toHaveLength(1)
|
|
inventory.list.mockResolvedValueOnce(ok({ entries: [] }))
|
|
await controller.load()
|
|
expect(state()).toMatchObject({ status: 'unavailable', packages: [] })
|
|
inventory.list.mockResolvedValueOnce(refused('gateway/internal', 'offline'))
|
|
await controller.load()
|
|
expect(state().status).toBe('error')
|
|
await controller.load()
|
|
expect(state().status).toBe('ready')
|
|
plugins.listPlugins.mockResolvedValueOnce(refused('gateway/internal', 'offline') as never)
|
|
await controller.load()
|
|
expect(state()).toMatchObject({ status: 'error', packages: [packageView(BUNDLE, PLUGINS)] })
|
|
plugins.listBundles.mockResolvedValueOnce(refused('gateway/internal', 'offline'))
|
|
await controller.load()
|
|
expect(state().status).toBe('error')
|
|
face.refresh()
|
|
await vi.advanceTimersByTimeAsync(400)
|
|
expect(state().status).toBe('ready')
|
|
})
|
|
|
|
it('enables a bundle, marks it busy meanwhile, and says when a restart is needed or a layer overrides it', async () => {
|
|
const gate = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const { plugins, face, state, controller } = bench({
|
|
setBundleEnabled: vi.fn()
|
|
.mockReturnValueOnce(gate.promise)
|
|
.mockResolvedValueOnce(ok({ ...APPLIED, application: 'restart-required' }))
|
|
.mockResolvedValueOnce(ok({ ...APPLIED, application: 'overridden' })),
|
|
})
|
|
await controller.load()
|
|
face.setEnabled(BUNDLE.name, true)
|
|
face.setEnabled(BUNDLE.name, true)
|
|
await Promise.resolve()
|
|
expect(state().busy).toEqual([BUNDLE.name])
|
|
expect(plugins.setBundleEnabled).toHaveBeenCalledExactlyOnceWith(BUNDLE.name, true)
|
|
gate.resolve(ok(APPLIED))
|
|
await vi.waitFor(() => { expect(state().busy).toEqual([]) })
|
|
expect(state().notice).toBeNull()
|
|
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
|
face.setEnabled(BUNDLE.name, false)
|
|
await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'restart', packageName: BUNDLE.name, seq: 1 }) })
|
|
face.setEnabled(BUNDLE.name, false)
|
|
await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'overridden', packageName: BUNDLE.name, seq: 2 }) })
|
|
})
|
|
|
|
it('turns a change the Host could not apply, or a refused answer, into a notice carrying its code and words', async () => {
|
|
const { face, state, controller } = bench({
|
|
setBundleEnabled: vi.fn()
|
|
.mockResolvedValueOnce(ok(failed({ code: 'operation-error', diagnostic: 'the tree rejected it' })))
|
|
.mockResolvedValueOnce(refused('gateway/internal', 'offline'))
|
|
.mockRejectedValueOnce(new Error('transport down'))
|
|
.mockRejectedValueOnce('odd')
|
|
.mockResolvedValueOnce(ok(failed({ code: 'bundle-in-use' })))
|
|
.mockResolvedValueOnce(ok(failed()))
|
|
.mockResolvedValueOnce(ok({ ...failed(), application: 'cancelled' }))
|
|
.mockResolvedValueOnce(ok(failed({ code: 'incompatible-version', incompatible: [INCOMPATIBLE] }))),
|
|
})
|
|
await controller.load()
|
|
face.setEnabled(BUNDLE.name, true)
|
|
await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', code: 'operation-error', reason: 'the tree rejected it', packageName: BUNDLE.name, seq: 1 }) })
|
|
face.setEnabled(BUNDLE.name, false)
|
|
await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'disable', reason: 'offline', packageName: BUNDLE.name, seq: 2 }) })
|
|
face.setEnabled(BUNDLE.name, true)
|
|
await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', reason: 'transport down', packageName: BUNDLE.name, seq: 3 }) })
|
|
face.setEnabled(BUNDLE.name, true)
|
|
await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', reason: 'odd', packageName: BUNDLE.name, seq: 4 }) })
|
|
// A refusal keeps its code with no words of its own; a failure without a code has neither.
|
|
face.setEnabled(BUNDLE.name, true)
|
|
await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', code: 'bundle-in-use', reason: '', packageName: BUNDLE.name, seq: 5 }) })
|
|
face.setEnabled(BUNDLE.name, true)
|
|
await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'enable', reason: '', packageName: BUNDLE.name, seq: 6 }) })
|
|
// A change the Host stopped is said in passing.
|
|
face.setEnabled(BUNDLE.name, true)
|
|
await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'cancelled', seq: 7 }) })
|
|
// An incompatibility keeps the packages it names for the page to word.
|
|
face.setEnabled(BUNDLE.name, true)
|
|
await vi.waitFor(() => {
|
|
expect(state().notice).toEqual({
|
|
kind: 'failed', action: 'enable', code: 'incompatible-version', incompatible: [INCOMPATIBLE], reason: '', packageName: BUNDLE.name, seq: 8,
|
|
})
|
|
})
|
|
face.dismissNotice()
|
|
expect(state().notice).toBeNull()
|
|
})
|
|
|
|
it('always asks before uninstalling, and cancelling runs nothing', async () => {
|
|
const { plugins, face, state, controller, track } = bench()
|
|
await controller.load()
|
|
face.uninstall(BUNDLE.name)
|
|
expect(state().confirm).toEqual({ action: 'uninstall', packageName: BUNDLE.name })
|
|
face.cancelConfirm()
|
|
expect(state().confirm).toBeNull()
|
|
face.confirm()
|
|
expect(plugins.removeBundle).not.toHaveBeenCalled()
|
|
expect(track).not.toHaveBeenCalled()
|
|
face.uninstall(BUNDLE.name)
|
|
face.confirm()
|
|
expect(state().confirm).toBeNull()
|
|
await vi.waitFor(() => { expect(plugins.removeBundle).toHaveBeenCalledExactlyOnceWith(BUNDLE.name) })
|
|
expect(track).toHaveBeenCalledExactlyOnceWith('confirm_uninstall_plugin', { plugin_name: BUNDLE.name })
|
|
await vi.waitFor(() => { expect(state().busy).toEqual([]) })
|
|
// A refused removal names the action it was.
|
|
plugins.removeBundle.mockResolvedValueOnce(ok({ ...failed(), stage: 'remove', error: { code: 'not-removable' } }) as never)
|
|
face.uninstall(BUNDLE.name)
|
|
face.confirm()
|
|
await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'uninstall', code: 'not-removable', reason: '', packageName: BUNDLE.name, seq: 1 }) })
|
|
})
|
|
|
|
it('switches rows under their own busy keys and reports what the Host said', async () => {
|
|
const gate = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const { plugins, face, state, controller } = bench({
|
|
setPluginEnabled: vi.fn().mockReturnValueOnce(gate.promise).mockResolvedValueOnce(ok(failed({ code: 'unaddressable' }))),
|
|
})
|
|
await controller.load()
|
|
face.setRowEnabled(ROW_ENTRY, false)
|
|
face.setRowEnabled(ROW_ENTRY, false)
|
|
await Promise.resolve()
|
|
expect(state().busy).toEqual([rowKey(ROW_ENTRY)])
|
|
expect(plugins.setPluginEnabled).toHaveBeenCalledExactlyOnceWith(ROW_ENTRY, false)
|
|
gate.resolve(ok(APPLIED))
|
|
await vi.waitFor(() => { expect(state().busy).toEqual([]) })
|
|
face.setRowEnabled(ROW_ENTRY, true)
|
|
await vi.waitFor(() => { expect(state().notice).toEqual({ kind: 'failed', action: 'rowEnable', code: 'unaddressable', reason: '', packageName: ROW_ENTRY, seq: 1 }) })
|
|
})
|
|
|
|
it('installs a Discover spec through the dialog, asking its registry first, and reopens a hidden installation instead', async () => {
|
|
const gate = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const installBundle = vi.fn((): Promise<ReturnType<typeof ok<ChangeResult>>> => Promise.resolve(ok({ ...APPLIED, bundle: 'dsh-other' })))
|
|
.mockReturnValueOnce(gate.promise)
|
|
const { plugins, face, state, controller, started, track } = bench({ installBundle })
|
|
await controller.load()
|
|
face.installFrom({ spec: '[email protected]', registry: 'https://r.example/', title: 'New', version: '1.0.0' })
|
|
expect(track).not.toHaveBeenCalledWith('plugin_add_button_click', {})
|
|
expect(state().install).toMatchObject({ open: true, spec: '[email protected]', confirmed: { title: 'New', version: '1.0.0' }, registry: { kind: 'custom', url: 'https://r.example/' } })
|
|
await started()
|
|
expect(plugins.inspect).toHaveBeenCalledWith('[email protected]', { registry: 'https://r.example/' }, expect.any(AbortSignal))
|
|
face.closeInstall()
|
|
face.installFrom({ spec: '[email protected]', title: 'Other', version: '2.0.0' })
|
|
expect(state().install).toMatchObject({ open: true, spec: '[email protected]' })
|
|
gate.resolve(ok({ ...APPLIED, bundle: 'dsh-new' }))
|
|
await vi.waitFor(() => { expect(state().install.requestId).toBeUndefined() })
|
|
face.closeInstall()
|
|
face.installFrom({ spec: '[email protected]', title: 'Other', version: '2.0.0' })
|
|
expect(state().install).toMatchObject({ open: true, spec: '[email protected]', registry: { kind: 'offered', registry: null } })
|
|
await vi.waitFor(() => { expect(plugins.inspect).toHaveBeenLastCalledWith('[email protected]', { registry: null }, expect.any(AbortSignal)) })
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
expect(installBundle).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('checks the spec, hands the run to the Host, and folds the chunks that carry its request id', async () => {
|
|
const gate = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const { plugins, face, state, controller, started } = bench({ installBundle: vi.fn().mockReturnValueOnce(gate.promise) })
|
|
await controller.load()
|
|
face.runInstall()
|
|
expect(plugins.inspect).not.toHaveBeenCalled()
|
|
face.openInstall()
|
|
expect(state().install).toMatchObject({ open: true, spec: '', phase: 'idle', inputError: null, subject: null })
|
|
face.editInstallSpec(' dsh-new ')
|
|
face.runInstall()
|
|
face.runInstall()
|
|
expect(state().install.phase).toBe('checking')
|
|
// Neither typing nor a second run reaches the Host while it checks.
|
|
face.editInstallSpec('other')
|
|
expect(state().install.spec).toBe(' dsh-new ')
|
|
await vi.waitFor(() => { expect(plugins.inspect).toHaveBeenCalledTimes(1) })
|
|
expect(plugins.inspect).toHaveBeenCalledWith('dsh-new', { registry: null }, expect.any(AbortSignal))
|
|
const requestId = await started()
|
|
expect(state().install.subject).toEqual({ spec: 'dsh-new', ...INSPECTED })
|
|
expect(plugins.installBundle).toHaveBeenCalledTimes(1)
|
|
expect(plugins.installBundle).toHaveBeenCalledWith('dsh-new', { enabled: false, requestId, registry: null })
|
|
// The Host's acknowledgement makes the run stoppable; a chunk of another request is not this run's.
|
|
controller.installProgress({ requestId, phase: 'installing' })
|
|
expect(state().install.phase).toBe('running')
|
|
controller.appendLog({ requestId: 'other' as PluginInstallRequestId, jobId: 'j1', argv: [], cwd: '/p', stream: 'stdout', text: 'x' })
|
|
expect(state().install.runs).toEqual([])
|
|
const argv = ['pnpm', 'add', 'dsh-new']
|
|
controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', stream: 'stdout', text: 'Progress\n' })
|
|
// A second run of the same install is its own terminal; a later chunk lands on the run it names.
|
|
controller.appendLog({ requestId, jobId: 'j2', argv: ['pnpm', 'remove', 'lib'], cwd: '/p', stream: 'stdout', text: '- lib\n', exitCode: 0 })
|
|
controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', stream: 'stderr', text: 'Done\n' })
|
|
expect(state().install.runs).toEqual([
|
|
{ jobId: 'j1', command: 'pnpm add dsh-new', cwd: '/p', output: 'Progress\nDone\n' },
|
|
{ jobId: 'j2', command: 'pnpm remove lib', cwd: '/p', output: '- lib\n', exitCode: 0 },
|
|
])
|
|
face.toggleInstallDetails()
|
|
expect(state().install.detailsOpen).toBe(true)
|
|
gate.resolve(ok({ ...APPLIED, bundle: 'dsh-new', version: '1.0.0' }))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
expect(state().install).toMatchObject({ installed: 'dsh-new', installedVersion: '1.0.0', restartRequired: false, detailsOpen: true })
|
|
// The answer and the log travel over separate transports: the settled dialog keeps a trailing chunk of a
|
|
// known run, and a run whose first chunk arrived after the answer settles with the outcome, not as running.
|
|
controller.appendLog({ requestId, jobId: 'j1', argv, cwd: '/p', stream: 'stdout', text: '', exitCode: 0 })
|
|
controller.appendLog({ requestId, jobId: 'j3', argv, cwd: '/p', stream: 'stdout', text: 'late diagnostic' })
|
|
expect(state().install.runs).toEqual([
|
|
{ jobId: 'j1', command: 'pnpm add dsh-new', cwd: '/p', output: 'Progress\nDone\n', exitCode: 0 },
|
|
{ jobId: 'j2', command: 'pnpm remove lib', cwd: '/p', output: '- lib\n', exitCode: 0 },
|
|
{ jobId: 'j3', command: 'pnpm add dsh-new', cwd: '/p', output: 'late diagnostic', exitCode: null },
|
|
])
|
|
await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalledTimes(2) })
|
|
// Cancelling from the finished screen does nothing, nor does the Host's late progress; a new spec after it starts over.
|
|
face.cancelInstall()
|
|
controller.installProgress({ requestId, phase: 'applying' })
|
|
expect(state().install.phase).toBe('done')
|
|
face.editInstallSpec('another')
|
|
expect(state().install).toMatchObject({ phase: 'idle', spec: 'another', runs: [], installed: null, subject: null })
|
|
face.closeInstall()
|
|
expect(state().install.open).toBe(false)
|
|
})
|
|
|
|
it('refuses a spec the list already shows without asking the Host, and words what the Host refused', async () => {
|
|
const shipped: BundleInfo = { ...BUNDLE, name: '@deepseek-ai/dsh-official', installed: false, official: true, availability: 'installation', optional: true, removable: false }
|
|
const { plugins, face, state, controller } = bench({
|
|
listBundles: vi.fn(() => Promise.resolve(ok([BUNDLE, shipped]))),
|
|
inspect: vi.fn()
|
|
.mockResolvedValueOnce(ok({ status: 'refused', problem: 'not-found', reason: 'E404', registries: [null, MIRROR] }))
|
|
.mockResolvedValueOnce(ok({ status: 'refused', problem: 'not-a-bundle', reason: 'plain declares no dsh.bundle' }))
|
|
.mockResolvedValueOnce(refused('gateway/internal', 'offline')),
|
|
})
|
|
await controller.load()
|
|
face.openInstall()
|
|
face.editInstallSpec(BUNDLE.name)
|
|
face.runInstall()
|
|
expect(plugins.inspect).not.toHaveBeenCalled()
|
|
expect(state().install).toMatchObject({ phase: 'idle', inputError: { problem: 'already-installed', reason: BUNDLE.name } })
|
|
// A bundle the installation supplies upgrades with DSH instead.
|
|
face.editInstallSpec(shipped.name)
|
|
face.runInstall()
|
|
expect(state().install).toMatchObject({ phase: 'idle', inputError: { problem: 'shipped', reason: shipped.name } })
|
|
// Typing clears the refusal.
|
|
face.editInstallSpec('nope')
|
|
expect(state().install.inputError).toBeNull()
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'not-found', reason: 'E404', registries: [null, MIRROR] }) })
|
|
expect(state().install.phase).toBe('idle')
|
|
expect(plugins.installBundle).not.toHaveBeenCalled()
|
|
face.editInstallSpec('plain')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'not-a-bundle', reason: 'plain declares no dsh.bundle' }) })
|
|
// A refused answer, rather than a refused spec, reads as unknown with the transport's words.
|
|
face.editInstallSpec('x')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(state().install.inputError).toEqual({ problem: 'unknown', reason: 'offline' }) })
|
|
})
|
|
|
|
it('offers a default-hidden shipped bundle by name, permits cancellation, and enables without installing', async () => {
|
|
let enabled = false
|
|
const shipped: BundleInfo = { ...BUNDLE, name: '@acme/shipped-exploration', installed: false, official: true,
|
|
availability: 'installation', optional: true, removable: false, listedByDefault: false }
|
|
const { plugins, face, state, controller, track } = bench({
|
|
listBundles: vi.fn(() => Promise.resolve(ok([{ ...shipped, enabled }]))),
|
|
setBundleEnabled: vi.fn(() => { enabled = true; return Promise.resolve(ok(APPLIED)) }),
|
|
})
|
|
await controller.load()
|
|
expect(state().packages[0]?.listedByDefault).toBe(false)
|
|
face.openInstall()
|
|
face.editInstallSpec(shipped.name)
|
|
face.runInstall()
|
|
expect(state().install).toMatchObject({ phase: 'done', installed: shipped.name, subject: { bundled: true }, runs: [] })
|
|
expect(plugins.setBundleEnabled).not.toHaveBeenCalled()
|
|
face.closeInstall()
|
|
expect(enabled).toBe(false)
|
|
expect(track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ error_reason: 'user_cancelled' }))
|
|
face.openInstall()
|
|
face.editInstallSpec(shipped.name)
|
|
face.runInstall()
|
|
face.enableInstalled()
|
|
face.enableInstalled()
|
|
await vi.waitFor(() => { expect(state().install.open).toBe(false) })
|
|
expect(plugins.setBundleEnabled).toHaveBeenCalledExactlyOnceWith(shipped.name, true)
|
|
expect(state().packages[0]?.enabled).toBe(true)
|
|
expect(state().highlight).toBe(shipped.name)
|
|
expect(plugins.inspect).not.toHaveBeenCalled()
|
|
expect(plugins.installBundle).not.toHaveBeenCalled()
|
|
expect(track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ is_success: true, plugin_name: shipped.name }))
|
|
// The same name cannot start another activation after it is enabled.
|
|
face.openInstall()
|
|
face.editInstallSpec(shipped.name)
|
|
face.runInstall()
|
|
expect(state().install.inputError?.problem).toBe('shipped')
|
|
})
|
|
|
|
it('reports a refused bundled activation without downloading a replacement', async () => {
|
|
const shipped: BundleInfo = { ...BUNDLE, name: '@acme/shipped-exploration', installed: false, official: true,
|
|
availability: 'installation', optional: true, removable: false, listedByDefault: false }
|
|
const { plugins, face, state, controller } = bench({
|
|
listBundles: vi.fn(() => Promise.resolve(ok([shipped]))),
|
|
setBundleEnabled: vi.fn(() => Promise.resolve(ok(failed({ code: 'operation-error', diagnostic: 'activation refused' })))),
|
|
})
|
|
await controller.load()
|
|
face.openInstall()
|
|
face.editInstallSpec(shipped.name)
|
|
face.runInstall()
|
|
face.enableInstalled()
|
|
await vi.waitFor(() => { expect(state().install.open).toBe(false) })
|
|
expect(state().notice).toMatchObject({ kind: 'failed', action: 'enable', packageName: shipped.name, reason: 'activation refused' })
|
|
expect(state().packages[0]?.enabled).toBe(false)
|
|
expect(plugins.installBundle).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('accepts a bundled package without optional version or description and reports a lost activation reply', async () => {
|
|
const shipped: BundleInfo = { name: '@acme/shipped-exploration', installed: false, official: true, enabled: false,
|
|
availability: 'installation', optional: true, removable: false, updatable: false, listedByDefault: false, rows: [], overrides: [] }
|
|
const { plugins, face, state, controller } = bench({
|
|
listBundles: vi.fn(() => Promise.resolve(ok([shipped]))),
|
|
setBundleEnabled: vi.fn(() => Promise.resolve(refused('gateway/internal', 'offline'))),
|
|
})
|
|
await controller.load()
|
|
face.openInstall()
|
|
face.editInstallSpec(shipped.name)
|
|
face.runInstall()
|
|
expect(state().install.installedVersion).toBeNull()
|
|
expect(state().install.subject).not.toHaveProperty('version')
|
|
expect(state().install.subject).not.toHaveProperty('description')
|
|
face.enableInstalled()
|
|
await vi.waitFor(() => { expect(state().install.open).toBe(false) })
|
|
expect(state().notice).toMatchObject({ kind: 'failed', action: 'enable', reason: 'offline' })
|
|
expect(plugins.installBundle).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('leaves the check or the failed screen for the spec at once', async () => {
|
|
const inspectGate = deferred<ReturnType<typeof ok<typeof INSPECTED>>>()
|
|
const { plugins, face, state, controller } = bench({
|
|
inspect: vi.fn().mockReturnValueOnce(inspectGate.promise).mockResolvedValue(ok(INSPECTED)),
|
|
installBundle: vi.fn().mockResolvedValue(ok(failed({ code: 'operation-error', diagnostic: 'ERR' }, { exitCode: 1, output: 'ERR', truncated: false, logPath: '/l', kind: 'network' }))),
|
|
})
|
|
await controller.load()
|
|
face.openInstall()
|
|
face.editInstallSpec('dsh-x')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(plugins.inspect).toHaveBeenCalledOnce() })
|
|
const checkSignal = (plugins.inspect.mock.calls[0] as unknown[])[2] as AbortSignal
|
|
face.cancelInstall()
|
|
expect(checkSignal.aborted).toBe(true)
|
|
expect(state().install).toMatchObject({ open: true, phase: 'idle', spec: 'dsh-x', inputError: null })
|
|
// The settlement of the dropped check changes nothing.
|
|
inspectGate.resolve(ok(INSPECTED))
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(state().install.phase).toBe('idle')
|
|
expect(plugins.installBundle).not.toHaveBeenCalled()
|
|
// Closing during a check drops it too.
|
|
face.runInstall()
|
|
face.closeInstall()
|
|
expect(state().install.open).toBe(false)
|
|
expect((plugins.inspect.mock.calls[1] as unknown[])[2]).toMatchObject({ aborted: true })
|
|
// From the failed screen the same control goes back to the spec.
|
|
face.openInstall()
|
|
face.editInstallSpec('dsh-x')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
|
|
expect(state().install.failure).toEqual({ reason: 'ERR', code: 'operation-error', kind: 'network', stage: 'install' })
|
|
face.cancelInstall()
|
|
expect(state().install).toMatchObject({ open: true, phase: 'idle', spec: 'dsh-x', failure: null, subject: null })
|
|
})
|
|
|
|
it('asks the Host to stop a run, keeps the spec once it confirms, and forgets the stopped run', async () => {
|
|
const first = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const second = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
|
|
const { plugins, face, state, controller, started } = bench({
|
|
installBundle: vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise),
|
|
cancelInstall: vi.fn().mockReturnValueOnce(cancellation.promise),
|
|
})
|
|
face.openInstall()
|
|
face.editInstallSpec('slow')
|
|
face.runInstall()
|
|
const requestId = await started()
|
|
controller.installProgress({ requestId, phase: 'installing' })
|
|
face.cancelInstall()
|
|
face.cancelInstall()
|
|
face.closeInstall()
|
|
face.openInstall()
|
|
expect(plugins.cancelInstall).toHaveBeenCalledExactlyOnceWith(requestId)
|
|
expect(state().install).toMatchObject({ phase: 'cancelling', open: true, spec: 'slow' })
|
|
// A queued start cannot undo the request to stop; the Host's own cancelling phase is the same.
|
|
controller.installProgress({ requestId, phase: 'installing' })
|
|
controller.installProgress({ requestId, phase: 'cancelling' })
|
|
expect(state().install.phase).toBe('cancelling')
|
|
cancellation.resolve(ok({ status: 'cancelled' }))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('idle') })
|
|
// The spec is offered again, the run is forgotten, and a toast says the Host stopped it.
|
|
expect(state().install).toMatchObject({ open: true, spec: 'slow', subject: null, runs: [] })
|
|
expect(state().install.requestId).toBeUndefined()
|
|
expect(state().notice).toEqual({ kind: 'cancelled', seq: 1 })
|
|
face.runInstall()
|
|
const nextId = await started()
|
|
expect(nextId).not.toBe(requestId)
|
|
// The stopped run's answer, progress, and chunks belong to a request the dialog no longer has.
|
|
first.resolve(ok({ ...failed(), application: 'cancelled' }))
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(state().install).toMatchObject({ requestId: nextId, phase: 'starting' })
|
|
controller.installProgress({ requestId, phase: 'applying' })
|
|
controller.appendLog({ requestId, jobId: 'old', argv: [], cwd: '/p', stream: 'stdout', text: 'late' })
|
|
expect(state().install).toMatchObject({ phase: 'starting', runs: [] })
|
|
second.resolve(ok({ ...APPLIED, application: 'restart-required', restartReason: 'no-hmr', bundle: 'slow' }))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
expect(state().install).toMatchObject({ installed: 'slow', restartRequired: true, restartReason: 'no-hmr' })
|
|
})
|
|
|
|
it.each(['too-late', 'not-running', 'offline'] as const)('keeps a stop the Host answered %s apart from a stopped run', async (status) => {
|
|
const pending = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const { plugins, face, state, controller, started } = bench({
|
|
installBundle: vi.fn().mockReturnValue(pending.promise),
|
|
cancelInstall: vi.fn().mockResolvedValue(status === 'offline' ? refused('gateway/internal', 'offline') : ok({ status })),
|
|
})
|
|
face.openInstall()
|
|
face.editInstallSpec('slow')
|
|
face.runInstall()
|
|
const requestId = await started()
|
|
controller.installProgress({ requestId, phase: 'installing' })
|
|
face.cancelInstall()
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe(status === 'too-late' ? 'applying' : 'unconfirmed') })
|
|
expect(plugins.cancelInstall).toHaveBeenCalledOnce()
|
|
// A stop the Host did not confirm says so over the running screen, with the transport's words when it has them.
|
|
expect(state().install.failure).toEqual(
|
|
status === 'too-late' ? null : { reason: status === 'offline' ? 'offline' : '', uncertainty: 'cancellation' },
|
|
)
|
|
// The Host's own word that it stopped the run still ends it.
|
|
pending.resolve(ok({ ...failed(), application: 'cancelled' }))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('idle') })
|
|
expect(state().install).toMatchObject({ open: true, spec: 'slow', failure: null })
|
|
expect(state().notice).toEqual({ kind: 'cancelled', seq: 1 })
|
|
})
|
|
|
|
it.each(['cancelled', 'too-late', 'offline'] as const)('keeps a hidden installation recoverable when cancellation answers %s', async (status) => {
|
|
const pending = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const { plugins, face, state, controller, started } = bench({
|
|
installBundle: vi.fn().mockReturnValue(pending.promise),
|
|
cancelInstall: vi.fn().mockResolvedValue(status === 'offline' ? refused('gateway/internal', 'offline') : ok({ status })),
|
|
})
|
|
face.openInstall()
|
|
face.editInstallSpec('slow')
|
|
face.runInstall()
|
|
const requestId = await started()
|
|
controller.installProgress({ requestId, phase: 'installing' })
|
|
face.closeInstall()
|
|
expect(state().install).toMatchObject({ open: false, phase: 'cancelling', requestId })
|
|
if (status === 'cancelled') {
|
|
await vi.waitFor(() => { expect(state().install).toMatchObject({ open: false, phase: 'idle', spec: 'slow' }) })
|
|
expect(state().notice).toEqual({ kind: 'cancelled', seq: 1 })
|
|
} else {
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe(status === 'too-late' ? 'applying' : 'unconfirmed') })
|
|
expect(state().install.open).toBe(false)
|
|
expect(state().notice).toMatchObject({ kind: 'install', outcome: status === 'too-late' ? 'applying' : 'unconfirmed' })
|
|
face.openInstall()
|
|
expect(state().install).toMatchObject({ open: true, requestId, spec: 'slow' })
|
|
}
|
|
expect(plugins.cancelInstall).toHaveBeenCalledExactlyOnceWith(requestId)
|
|
pending.resolve(ok({ ...failed(), application: 'cancelled' }))
|
|
})
|
|
|
|
it.each(['done', 'failed'] as const)('keeps a hidden %s result available without reopening the dialog', async (phase) => {
|
|
const pending = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const { face, state, controller, plugins, started } = bench({ installBundle: vi.fn().mockReturnValue(pending.promise) })
|
|
face.openInstall()
|
|
face.editInstallSpec('slow')
|
|
face.runInstall()
|
|
const requestId = await started()
|
|
controller.installProgress({ requestId, phase: 'applying' })
|
|
face.closeInstall()
|
|
expect(state().install).toMatchObject({ open: false, phase: 'applying', requestId })
|
|
expect(plugins.cancelInstall).not.toHaveBeenCalled()
|
|
pending.resolve(ok(phase === 'done' ? { ...APPLIED, bundle: 'slow' } : failed()))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe(phase) })
|
|
expect(state().install.open).toBe(false)
|
|
expect(state().notice).toMatchObject({ kind: 'install', outcome: phase })
|
|
face.openInstall()
|
|
expect(state().install).toMatchObject({ open: true, phase, requestId })
|
|
face.closeInstall()
|
|
face.openInstall()
|
|
expect(state().install).toMatchObject({ open: true, phase: 'idle', spec: '' })
|
|
})
|
|
|
|
it('retries a premature cancellation when output arrives without a start notification', async () => {
|
|
const pending = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
|
|
const { face, state, controller, plugins, started } = bench({
|
|
installBundle: vi.fn().mockReturnValue(pending.promise),
|
|
cancelInstall: vi.fn().mockResolvedValueOnce(ok({ status: 'not-running' })).mockReturnValueOnce(cancellation.promise),
|
|
})
|
|
face.openInstall()
|
|
face.editInstallSpec('slow')
|
|
face.runInstall()
|
|
const requestId = await started()
|
|
face.closeInstall()
|
|
await vi.waitFor(() => { expect(state().install.failure?.uncertainty).toBe('acceptance') })
|
|
controller.appendLog({ requestId, jobId: 'j1', argv: ['pnpm', 'add', 'slow'], cwd: '/p', stream: 'stdout', text: 'Waiting for download' })
|
|
expect(plugins.cancelInstall).toHaveBeenCalledTimes(2)
|
|
expect(state().install).toMatchObject({ open: false, phase: 'cancelling', runs: [{ output: 'Waiting for download' }] })
|
|
cancellation.resolve(ok({ status: 'cancelled' }))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('idle') })
|
|
pending.resolve(ok({ ...failed(), application: 'cancelled' }))
|
|
})
|
|
|
|
it('retains cancellation intent after a transport failure before the Host accepts installation', async () => {
|
|
const pending = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const { face, state, controller, plugins, started } = bench({
|
|
installBundle: vi.fn().mockReturnValue(pending.promise),
|
|
cancelInstall: vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline')).mockResolvedValueOnce(ok({ status: 'cancelled' })),
|
|
})
|
|
face.openInstall()
|
|
face.editInstallSpec('slow')
|
|
face.runInstall()
|
|
const requestId = await started()
|
|
face.closeInstall()
|
|
await vi.waitFor(() => { expect(state().install).toMatchObject({ open: false, phase: 'unconfirmed' }) })
|
|
controller.installProgress({ requestId, phase: 'installing' })
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('idle') })
|
|
expect(plugins.cancelInstall).toHaveBeenCalledTimes(2)
|
|
expect(state().notice).toMatchObject({ kind: 'cancelled' })
|
|
pending.resolve(ok({ ...failed(), application: 'cancelled' }))
|
|
})
|
|
|
|
it('keeps an in-flight cancellation authoritative when the installation response is lost', async () => {
|
|
const pending = deferred<ReturnType<typeof refused>>()
|
|
const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
|
|
const { face, state, plugins, started } = bench({
|
|
installBundle: vi.fn().mockReturnValue(pending.promise),
|
|
cancelInstall: vi.fn().mockReturnValue(cancellation.promise),
|
|
})
|
|
face.openInstall()
|
|
face.editInstallSpec('slow')
|
|
face.runInstall()
|
|
const requestId = await started()
|
|
face.closeInstall()
|
|
pending.resolve(refused('gateway/internal', 'offline'))
|
|
await vi.waitFor(() => { expect(state().install).toMatchObject({ open: false, phase: 'unconfirmed', requestId }) })
|
|
face.openInstall()
|
|
face.runInstall()
|
|
face.cancelInstall()
|
|
expect(plugins.installBundle).toHaveBeenCalledOnce()
|
|
expect(plugins.cancelInstall).toHaveBeenCalledOnce()
|
|
cancellation.resolve(ok({ status: 'cancelled' }))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('idle') })
|
|
expect(state().install.open).toBe(true)
|
|
})
|
|
|
|
it.each(['not-running', 'too-late'] as const)('recovers a lost install reply after cancellation answers %s', async (status) => {
|
|
const original = deferred<ReturnType<typeof refused>>()
|
|
const recovery = deferred<ReturnType<typeof ok<ChangeResult | null>>>()
|
|
const { face, state, plugins, started } = bench({
|
|
installBundle: vi.fn().mockReturnValueOnce(original.promise).mockResolvedValue(ok(APPLIED)),
|
|
cancelInstall: vi.fn().mockResolvedValue(ok({ status })),
|
|
waitForInstall: vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline')).mockReturnValue(recovery.promise),
|
|
})
|
|
face.openInstall()
|
|
face.editInstallSpec('git+https://example.test/plugin')
|
|
face.runInstall()
|
|
const requestId = await started()
|
|
original.resolve(refused('gateway/internal', 'lost reply'))
|
|
await vi.waitFor(() => { expect(state().install.failure?.reason).toBe('offline') })
|
|
face.closeInstall()
|
|
await vi.waitFor(() => { expect(plugins.waitForInstall).toHaveBeenCalledTimes(2) })
|
|
expect(plugins.waitForInstall).toHaveBeenLastCalledWith(requestId)
|
|
expect(state().install.phase).toBe(status === 'too-late' ? 'applying' : 'unconfirmed')
|
|
recovery.resolve(ok(status === 'too-late' ? { ...APPLIED, bundle: 'recovered' } : null))
|
|
const phase = status === 'too-late' ? 'done' : 'unknown'
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe(phase) })
|
|
expect(state().install.open).toBe(false)
|
|
expect(state().notice).toMatchObject({ kind: 'install', outcome: phase })
|
|
await vi.waitFor(() => { expect(plugins.listBundles).toHaveBeenCalled() })
|
|
face.openInstall()
|
|
expect(state().install.phase).toBe(phase)
|
|
if (status === 'not-running') {
|
|
face.cancelInstall()
|
|
expect(state().install).toMatchObject({ phase: 'idle', spec: 'git+https://example.test/plugin' })
|
|
}
|
|
face.editInstallSpec('another')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(plugins.installBundle).toHaveBeenCalledTimes(2) })
|
|
})
|
|
|
|
it('keeps Host acceptance across cancellation retries', async () => {
|
|
const original = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const { face, state, controller, plugins, started } = bench({
|
|
installBundle: vi.fn().mockReturnValue(original.promise),
|
|
cancelInstall: vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline')).mockResolvedValue(ok({ status: 'not-running' })),
|
|
})
|
|
face.openInstall()
|
|
face.editInstallSpec('slow')
|
|
face.runInstall()
|
|
const requestId = await started()
|
|
controller.installProgress({ requestId, phase: 'installing' })
|
|
face.cancelInstall()
|
|
await vi.waitFor(() => { expect(state().install.failure?.reason).toBe('offline') })
|
|
face.cancelInstall()
|
|
await vi.waitFor(() => { expect(state().install.failure).toEqual({ reason: '', uncertainty: 'cancellation' }) })
|
|
controller.installProgress({ requestId, phase: 'installing' })
|
|
expect(plugins.cancelInstall).toHaveBeenCalledTimes(2)
|
|
original.resolve(ok(APPLIED))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
})
|
|
|
|
it.each(['offline', 'not-running'] as const)('preserves applying when a late cancellation answers %s', async (status) => {
|
|
const original = deferred<ReturnType<typeof refused>>()
|
|
const cancellation = deferred<ReturnType<typeof refused> | ReturnType<typeof ok<{ status: 'not-running' }>>>()
|
|
const { face, state, controller, started } = bench({
|
|
installBundle: vi.fn().mockReturnValue(original.promise),
|
|
cancelInstall: vi.fn().mockReturnValue(cancellation.promise),
|
|
})
|
|
face.openInstall()
|
|
face.editInstallSpec('slow')
|
|
face.runInstall()
|
|
const requestId = await started()
|
|
face.cancelInstall()
|
|
controller.installProgress({ requestId, phase: 'applying' })
|
|
cancellation.resolve(status === 'offline' ? refused('gateway/internal', 'offline') : ok({ status }))
|
|
await vi.waitFor(() => { expect(state().install.failure?.uncertainty).toBe('cancellation') })
|
|
expect(state().install.phase).toBe('applying')
|
|
controller.installProgress({ requestId, phase: 'installing' })
|
|
expect(state().install.phase).toBe('applying')
|
|
original.resolve(refused('gateway/internal', 'offline'))
|
|
await vi.waitFor(() => { expect(state().install.failure?.uncertainty).toBe('result') })
|
|
expect(state().install.phase).toBe('applying')
|
|
controller.dispose()
|
|
})
|
|
|
|
it('can retry recovery after both installation and cancellation replies are lost', async () => {
|
|
const original = deferred<ReturnType<typeof refused>>()
|
|
const cancellation = deferred<ReturnType<typeof refused>>()
|
|
const { face, state, plugins, controller, started } = bench({
|
|
installBundle: vi.fn().mockReturnValue(original.promise),
|
|
cancelInstall: vi.fn().mockReturnValue(cancellation.promise),
|
|
waitForInstall: vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'offline')).mockResolvedValue(ok(null)),
|
|
})
|
|
face.openInstall()
|
|
face.editInstallSpec('slow')
|
|
face.runInstall()
|
|
const requestId = await started()
|
|
controller.installProgress({ requestId, phase: 'installing' })
|
|
face.cancelInstall()
|
|
original.resolve(refused('gateway/internal', 'lost install reply'))
|
|
await vi.waitFor(() => { expect(state().install.failure?.reason).toBe('offline') })
|
|
cancellation.resolve(refused('gateway/internal', 'lost cancellation reply'))
|
|
await vi.waitFor(() => { expect(state().install.failure).toEqual({ reason: 'lost cancellation reply', uncertainty: 'result' }) })
|
|
face.reconcileInstall()
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('unknown') })
|
|
expect(plugins.waitForInstall).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it.each(['failed', 'cancelled', 'disposed'] as const)('deduplicates recovery and handles its %s outcome', async (outcome) => {
|
|
const recovery = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const { face, state, controller, plugins } = bench({
|
|
installBundle: vi.fn().mockResolvedValue(refused('gateway/internal', 'offline')),
|
|
waitForInstall: vi.fn().mockReturnValue(recovery.promise),
|
|
})
|
|
face.openInstall()
|
|
face.editInstallSpec('slow')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(plugins.waitForInstall).toHaveBeenCalledOnce() })
|
|
face.reconcileInstall()
|
|
await controller.load()
|
|
expect(plugins.waitForInstall).toHaveBeenCalledOnce()
|
|
if (outcome === 'disposed') controller.dispose()
|
|
recovery.resolve(ok({ ...failed(), application: outcome === 'cancelled' ? 'cancelled' : 'failed' }))
|
|
await recovery.promise
|
|
await Promise.resolve()
|
|
expect(state().install.phase).toBe(outcome === 'disposed' ? 'unconfirmed' : outcome === 'cancelled' ? 'idle' : 'failed')
|
|
})
|
|
|
|
it.each([false, true])('drops a stop the Host confirms once the run settled, or after disposal (%s)', async (dispose) => {
|
|
const answer = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const cancellation = deferred<ReturnType<typeof ok<{ status: 'cancelled' }>>>()
|
|
const { face, state, controller, started } = bench({
|
|
installBundle: vi.fn().mockReturnValue(answer.promise),
|
|
cancelInstall: vi.fn().mockReturnValue(cancellation.promise),
|
|
})
|
|
await controller.load()
|
|
face.openInstall()
|
|
face.editInstallSpec('slow')
|
|
face.runInstall()
|
|
controller.installProgress({ requestId: await started(), phase: 'installing' })
|
|
face.cancelInstall()
|
|
expect(state().install.phase).toBe('cancelling')
|
|
if (dispose) {
|
|
controller.dispose()
|
|
controller.installProgress({ requestId: state().install.requestId!, phase: 'applying' })
|
|
}
|
|
answer.resolve(ok({ ...APPLIED, bundle: 'slow' }))
|
|
if (!dispose) await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
cancellation.resolve(ok({ status: 'cancelled' }))
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(state().install.phase).toBe(dispose ? 'cancelling' : 'done')
|
|
expect(state().notice).toBeNull()
|
|
})
|
|
|
|
it('enables what a finished install added from its screen, closes, and marks it in the list', async () => {
|
|
const { plugins, face, state, controller } = bench({
|
|
installBundle: vi.fn()
|
|
.mockResolvedValueOnce(ok({ ...APPLIED, bundle: 'dsh-a' }))
|
|
.mockResolvedValueOnce(ok({ ...APPLIED, bundle: 'dsh-a' }))
|
|
.mockResolvedValueOnce(ok({ ...APPLIED, application: 'overridden' })),
|
|
setBundleEnabled: vi.fn()
|
|
.mockResolvedValueOnce(ok({ ...APPLIED, application: 'restart-required' }))
|
|
.mockResolvedValueOnce(ok(failed({ code: 'operation-error', diagnostic: 'the tree rejected it' }))),
|
|
})
|
|
await controller.load()
|
|
face.enableInstalled()
|
|
expect(plugins.setBundleEnabled).not.toHaveBeenCalled()
|
|
face.openInstall()
|
|
face.editInstallSpec('dsh-a')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
face.enableInstalled()
|
|
face.enableInstalled()
|
|
expect(state().install.enabling).toBe(true)
|
|
await vi.waitFor(() => { expect(state().install.open).toBe(false) })
|
|
expect(plugins.setBundleEnabled).toHaveBeenCalledExactlyOnceWith('dsh-a', true)
|
|
// A restart it waits for is said in passing; the list marks it.
|
|
expect(state().notice).toEqual({ kind: 'restart', packageName: 'dsh-a', seq: 1 })
|
|
expect(state().highlight).toBe('dsh-a')
|
|
face.clearHighlight()
|
|
face.clearHighlight()
|
|
expect(state().highlight).toBeNull()
|
|
|
|
// A refusal toasts it and still closes.
|
|
face.openInstall()
|
|
face.editInstallSpec('dsh-a')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
face.enableInstalled()
|
|
await vi.waitFor(() => { expect(state().install.open).toBe(false) })
|
|
expect(plugins.setBundleEnabled).toHaveBeenCalledTimes(2)
|
|
expect(state().notice).toEqual({ kind: 'failed', action: 'enable', code: 'operation-error', reason: 'the tree rejected it', packageName: 'dsh-a', seq: 2 })
|
|
expect(state().highlight).toBe('dsh-a')
|
|
|
|
// An install that named no bundle has nothing to enable or mark: the screen just closes.
|
|
face.openInstall()
|
|
face.editInstallSpec('lib')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
expect(state().install.installed).toBeNull()
|
|
face.enableInstalled()
|
|
await vi.waitFor(() => { expect(state().install.open).toBe(false) })
|
|
expect(plugins.setBundleEnabled).toHaveBeenCalledTimes(2)
|
|
expect(state().highlight).toBeNull()
|
|
})
|
|
|
|
it.each([true, false])('offers the scripts a blocked run left pending, and retries with analytics enabled=%s', async (enabled) => {
|
|
const gates: ReturnType<typeof deferred<Awaited<ReturnType<typeof ok<ChangeResult>> | ReturnType<typeof refused>>>>[] = []
|
|
const { face, state, controller, plugins, started } = bench({
|
|
installBundle: vi.fn(() => {
|
|
const gate = deferred<Awaited<ReturnType<typeof ok<ChangeResult>> | ReturnType<typeof refused>>>()
|
|
gates.push(gate)
|
|
return gate.promise
|
|
}),
|
|
}, enabled)
|
|
await controller.load()
|
|
face.openInstall()
|
|
face.editInstallSpec('x')
|
|
// Before the failed screen offers anything, the action does nothing.
|
|
face.approveBuildsAndRetry()
|
|
face.runInstall()
|
|
const first = await started()
|
|
gates[0]!.resolve(ok({
|
|
...failed({ code: 'operation-error', diagnostic: 'ERR_PNPM_IGNORED_BUILDS' },
|
|
{ exitCode: 1, output: 'ERR_PNPM_IGNORED_BUILDS', truncated: false, logPath: '/l', kind: 'build-blocked' }),
|
|
pendingBuilds: ['native'],
|
|
}))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
|
|
expect(state().install.failure).toEqual({ reason: 'ERR_PNPM_IGNORED_BUILDS', code: 'operation-error', kind: 'build-blocked', pendingBuilds: ['native'], stage: 'install' })
|
|
// The retry keeps the subject the check produced and carries the approved names under a new request id.
|
|
face.approveBuildsAndRetry()
|
|
const second = await started()
|
|
expect(second).not.toBe(first)
|
|
expect(plugins.installBundle).toHaveBeenLastCalledWith('x', { enabled: false, requestId: second, registry: null, approvedBuilds: ['native'] })
|
|
expect(state().install).toMatchObject({ subject: { spec: 'x', name: 'dsh-better-sidebar' }, failure: null })
|
|
gates[1]!.resolve(ok({ ...APPLIED, bundle: 'dsh-better-sidebar', approvedBuilds: ['native'] }))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
expect(state().install).toMatchObject({ installed: 'dsh-better-sidebar', approvedBuilds: ['native'] })
|
|
expect(plugins.installBundle).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('drops an enable from the installed screen that settles after disposal', async () => {
|
|
const enableGate = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const { plugins, face, state, controller } = bench({ setBundleEnabled: vi.fn().mockReturnValueOnce(enableGate.promise) })
|
|
await controller.load()
|
|
face.openInstall()
|
|
face.editInstallSpec('dsh-a')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
face.enableInstalled()
|
|
expect(plugins.setBundleEnabled).toHaveBeenCalledWith('dsh-new', true)
|
|
const before = state()
|
|
controller.dispose()
|
|
enableGate.resolve(ok(APPLIED))
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(state()).toBe(before)
|
|
})
|
|
|
|
it('settles an install while reads run beside it', async () => {
|
|
const gate = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const { plugins, face, state, controller, started } = bench({ installBundle: vi.fn().mockReturnValueOnce(gate.promise) })
|
|
await controller.load()
|
|
face.openInstall()
|
|
face.editInstallSpec('pkg')
|
|
face.runInstall()
|
|
await started()
|
|
// The Host announces the change before the run answers; the read it triggers must not drop the answer.
|
|
await controller.load()
|
|
gate.resolve(ok({ ...APPLIED, bundle: 'pkg' }))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
expect(plugins.listBundles).toHaveBeenCalledTimes(3)
|
|
})
|
|
|
|
it('keeps the Host words and kind of a failed install, and settles a run whose last chunk never came', async () => {
|
|
// Every run waits on its own gate, so chunks can land while it is installing.
|
|
const gates: ReturnType<typeof deferred<Awaited<ReturnType<typeof ok<ChangeResult>> | ReturnType<typeof refused>>>>[] = []
|
|
const { face, state, controller, plugins, started } = bench({
|
|
installBundle: vi.fn(() => {
|
|
const gate = deferred<Awaited<ReturnType<typeof ok<ChangeResult>> | ReturnType<typeof refused>>>()
|
|
gates.push(gate)
|
|
return gate.promise
|
|
}),
|
|
})
|
|
const argv = ['pnpm', 'add', 'x']
|
|
await controller.load()
|
|
face.openInstall()
|
|
face.editInstallSpec('x')
|
|
let requestId = '' as PluginInstallRequestId
|
|
const installing = async (): Promise<void> => {
|
|
face.runInstall()
|
|
requestId = await started()
|
|
}
|
|
const answer = (value: ReturnType<typeof ok<ChangeResult>> | ReturnType<typeof refused>): void => {
|
|
gates[gates.length - 1]?.resolve(value)
|
|
}
|
|
// No chunk arrived: the Host's words are the reason, and there is no run; the kind is kept.
|
|
await installing()
|
|
answer(ok(failed({ code: 'operation-error', diagnostic: 'ERR_PNPM' }, { exitCode: 1, output: 'ERR_PNPM', truncated: false, logPath: '/l', kind: 'network' })))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
|
|
expect(state().install.runs).toEqual([])
|
|
expect(state().install.failure).toEqual({ reason: 'ERR_PNPM', code: 'operation-error', kind: 'network', stage: 'install' })
|
|
expect(state().install.subject).toEqual({ spec: 'x', ...INSPECTED })
|
|
// A lost answer cannot establish whether the Host stopped the process.
|
|
await installing()
|
|
controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', stream: 'stderr', text: 'streamed' })
|
|
answer(refused('gateway/internal', 'offline'))
|
|
await vi.waitFor(() => { expect(state().install).toMatchObject({ phase: 'unconfirmed', failure: { reason: 'offline', uncertainty: 'result' } }) })
|
|
expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'streamed' }])
|
|
face.runInstall()
|
|
expect(plugins.installBundle).toHaveBeenCalledTimes(2)
|
|
face.cancelInstall()
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('idle') })
|
|
// A pnpm failure settles the open run with the code the answer names.
|
|
await installing()
|
|
controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', stream: 'stdout', text: 'Done' })
|
|
answer(ok(failed({ code: 'operation-error', diagnostic: 'tail' }, { exitCode: 7, output: 'tail', truncated: false, logPath: '/l', kind: 'unknown' })))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
|
|
expect(state().install.runs).toEqual([{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'Done', exitCode: 7 }])
|
|
// A failure after pnpm carries no package result: a run whose last chunk came keeps its own exit code, one still open settles without.
|
|
await installing()
|
|
controller.appendLog({ requestId, jobId: 'j', argv, cwd: '/p', stream: 'stdout', text: 'partial', exitCode: 0 })
|
|
controller.appendLog({ requestId, jobId: 'k', argv, cwd: '/p', stream: 'stdout', text: 'open' })
|
|
answer(ok(failed({ code: 'not-bundle' })))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
|
|
expect(state().install.runs).toEqual([
|
|
{ jobId: 'j', command: 'pnpm add x', cwd: '/p', output: 'partial', exitCode: 0 },
|
|
{ jobId: 'k', command: 'pnpm add x', cwd: '/p', output: 'open', exitCode: null },
|
|
])
|
|
expect(state().install.failure).toEqual({ reason: '', code: 'not-bundle', stage: 'install' })
|
|
// An incompatibility keeps the packages it names.
|
|
await installing()
|
|
answer(ok(failed({ code: 'incompatible-version', incompatible: [INCOMPATIBLE] })))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
|
|
expect(state().install.failure).toEqual({ reason: '', code: 'incompatible-version', incompatible: [INCOMPATIBLE], stage: 'install' })
|
|
// A failure the Host does not explain has neither code nor words.
|
|
await installing()
|
|
answer(ok(failed()))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
|
|
expect(state().install.failure).toEqual({ reason: '', stage: 'install' })
|
|
expect(plugins.installBundle).toHaveBeenCalledTimes(6)
|
|
// Editing the spec after a failure starts over too.
|
|
face.editInstallSpec('y')
|
|
expect(state().install).toMatchObject({ phase: 'idle', spec: 'y', runs: [], failure: null })
|
|
})
|
|
|
|
it('keeps the run whose first chunk arrives after the failed answer', async () => {
|
|
const { face, state, controller } = bench({
|
|
installBundle: vi.fn().mockResolvedValueOnce(ok(failed(
|
|
{ code: 'operation-error', diagnostic: 'offline' },
|
|
{ exitCode: 1, output: 'fatal: unable to access', truncated: false, logPath: '/l', kind: 'network' },
|
|
))),
|
|
})
|
|
face.openInstall()
|
|
face.editInstallSpec('https://github.com/a/b.git')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
|
|
const requestId = state().install.requestId as PluginInstallRequestId
|
|
expect(state().install.runs).toEqual([])
|
|
// The check's only chunk can arrive after the answer; the settled dialog still shows it and its log.
|
|
const argv = ['git', '-c', 'credential.helper=', 'ls-remote', '--', 'https://github.com/a/b.git', 'HEAD']
|
|
controller.appendLog({ requestId, jobId: 'g1', argv, cwd: '/p', stream: 'stderr', text: 'fatal: unable to access', exitCode: 1 })
|
|
expect(state().install.runs).toEqual([
|
|
{ jobId: 'g1', command: argv.join(' '), cwd: '/p', output: 'fatal: unable to access', exitCode: 1 },
|
|
])
|
|
// A chunk of another installation still is not this dialog's.
|
|
controller.appendLog({ requestId: 'other' as PluginInstallRequestId, jobId: 'g2', argv, cwd: '/p', stream: 'stderr', text: 'stray', exitCode: 1 })
|
|
expect(state().install.runs).toHaveLength(1)
|
|
})
|
|
|
|
it('keeps the step a failed install reached and the complete Host log it settled with', async () => {
|
|
const retry = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const failedLog = '/var/log/dsh/plugin-manager/install-failed.log'
|
|
const doneLog = '/var/log/dsh/plugin-manager/install-done.log'
|
|
const { face, state, plugins, started } = bench({
|
|
installBundle: vi.fn()
|
|
.mockResolvedValueOnce(ok(failed(
|
|
{ code: 'operation-error', diagnostic: 'activation refused' },
|
|
{ exitCode: 0, output: 'installed', truncated: false, logPath: failedLog },
|
|
{ stage: 'enable', enabled: true },
|
|
)))
|
|
.mockReturnValueOnce(retry.promise),
|
|
})
|
|
face.openInstall()
|
|
face.editInstallSpec('dsh-x')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
|
|
// The Host laid the failure at activation, and the details keep the run's complete log.
|
|
expect(state().install.failure).toEqual({ reason: 'activation refused', code: 'operation-error', stage: 'enable', enabled: true })
|
|
expect(state().install.logPath).toBe(failedLog)
|
|
// Retrying forgets the settled run's log until the next answer names its own.
|
|
face.runInstall()
|
|
await started()
|
|
expect(state().install.logPath).toBeNull()
|
|
retry.resolve(ok({
|
|
...APPLIED, bundle: 'dsh-x',
|
|
packageResult: { exitCode: 0, output: '', truncated: false, logPath: doneLog },
|
|
}))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
expect(state().install.logPath).toBe(doneLog)
|
|
expect(plugins.installBundle).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('drops every late settlement after disposal', async () => {
|
|
const enableGate = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const installGate = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const { face, state, controller, started } = bench({
|
|
setBundleEnabled: vi.fn().mockReturnValueOnce(enableGate.promise),
|
|
installBundle: vi.fn().mockReturnValueOnce(installGate.promise),
|
|
})
|
|
await controller.load()
|
|
face.openInstall()
|
|
face.editInstallSpec('x')
|
|
face.runInstall()
|
|
await started()
|
|
face.setEnabled(BUNDLE.name, true)
|
|
const before = state()
|
|
controller.dispose()
|
|
enableGate.resolve(ok(APPLIED))
|
|
installGate.resolve(ok(APPLIED))
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(state()).toBe(before)
|
|
await controller.load()
|
|
expect(state()).toBe(before)
|
|
face.setEnabled(BUNDLE.name, false)
|
|
expect(state()).toBe(before)
|
|
})
|
|
|
|
it('drops a read that settles after disposal', async () => {
|
|
const gate = deferred<ReturnType<typeof ok<{ entries: never[]; managementAvailable: boolean }>>>()
|
|
const { state, controller } = bench({ inventory: vi.fn().mockReturnValueOnce(gate.promise) })
|
|
const loading = controller.load()
|
|
await Promise.resolve()
|
|
const before = state()
|
|
controller.dispose()
|
|
gate.resolve(ok({ entries: [], managementAvailable: true }))
|
|
await loading
|
|
expect(state()).toBe(before)
|
|
})
|
|
|
|
it('drops a bundle read that settles after disposal', async () => {
|
|
const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
|
|
const { state, controller } = bench({ listBundles: vi.fn().mockReturnValueOnce(gate.promise) })
|
|
const loading = controller.load()
|
|
await vi.waitFor(() => { expect(state().status).toBe('loading') })
|
|
await Promise.resolve()
|
|
const before = state()
|
|
controller.dispose()
|
|
gate.resolve(ok([BUNDLE]))
|
|
await loading
|
|
expect(state()).toBe(before)
|
|
})
|
|
|
|
it.each([false, true])('drops refresh feedback when disposed during the spinner hold (failure: %s)', async (failure) => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const { plugins, face, state, controller } = bench()
|
|
await controller.load()
|
|
plugins.listBundles.mockResolvedValueOnce(failure ? refused('gateway/internal', 'offline') : ok([BUNDLE]))
|
|
face.refresh()
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(state().refreshStatus).toBe('refreshing')
|
|
expect(vi.getTimerCount()).toBe(1)
|
|
const before = state()
|
|
const listener = vi.fn()
|
|
const unsubscribe = face.hooks.pluginManager.subscribe(listener)
|
|
onTestFinished(unsubscribe)
|
|
controller.dispose()
|
|
await vi.advanceTimersByTimeAsync(400)
|
|
expect(state()).toBe(before)
|
|
expect(listener).not.toHaveBeenCalled()
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('settles a manual refresh whose read outlasts the spinner minimum without an extra hold', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
const { plugins, face, state, controller } = bench()
|
|
await controller.load()
|
|
const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
|
|
plugins.listBundles.mockReturnValueOnce(gate.promise)
|
|
face.refresh()
|
|
await vi.advanceTimersByTimeAsync(401)
|
|
expect(plugins.listBundles).toHaveBeenCalledTimes(2)
|
|
expect(state().refreshStatus).toBe('refreshing')
|
|
gate.resolve(ok([{ ...BUNDLE, version: '0.42.0' }]))
|
|
await vi.advanceTimersByTimeAsync(0)
|
|
expect(state().refreshStatus).toBe('idle')
|
|
expect(state().packages[0]?.version).toBe('0.42.0')
|
|
expect(state().status).toBe('ready')
|
|
expect(vi.getTimerCount()).toBe(0)
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
it('offers the registries the Host configured, starts from its first, and asks the check and the run to start where the person chose', async () => {
|
|
const gate = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const { plugins, face, state, controller, started } = bench({
|
|
inspect: vi.fn(() => Promise.resolve(ok({ ...INSPECTED, registry: MIRROR }))),
|
|
installBundle: vi.fn().mockReturnValueOnce(gate.promise),
|
|
})
|
|
face.openInstall()
|
|
expect(state().install).toMatchObject({ registries: null, registry: { kind: 'offered', registry: null }, registryOpen: false, registryError: false })
|
|
await vi.waitFor(() => { expect(state().install.registries).toEqual(REGISTRIES) })
|
|
expect(state().install.registry).toEqual({ kind: 'offered', registry: null })
|
|
face.toggleRegistryOptions()
|
|
expect(state().install.registryOpen).toBe(true)
|
|
// Changing the registry is for the failed screen; at the spec it does nothing.
|
|
face.changeRegistry()
|
|
expect(state().install.registryOpen).toBe(true)
|
|
face.chooseRegistry({ kind: 'offered', registry: MIRROR })
|
|
face.editInstallSpec('dsh-new')
|
|
face.runInstall()
|
|
expect(plugins.inspect).toHaveBeenCalledWith('dsh-new', { registry: MIRROR }, expect.any(AbortSignal))
|
|
// A choice made while the Host checks or runs is dropped.
|
|
face.chooseRegistry({ kind: 'offered', registry: null })
|
|
expect(state().install.registry).toEqual({ kind: 'offered', registry: MIRROR })
|
|
const requestId = await started()
|
|
// The run starts at the registry that answered the check.
|
|
expect(state().install.subject).toMatchObject({ registry: MIRROR })
|
|
expect(plugins.installBundle).toHaveBeenCalledWith('dsh-new', { enabled: false, requestId, registry: MIRROR })
|
|
// The Host names each registry it asks; the dialog keeps their order and how many there may be.
|
|
controller.installProgress({ requestId, phase: 'installing', attempt: { registry: MIRROR, index: 1, total: 2 } })
|
|
controller.installProgress({ requestId, phase: 'installing', attempt: { registry: null, index: 2, total: 2 } })
|
|
expect(state().install).toMatchObject({ phase: 'running', attempts: { registries: [MIRROR, null], total: 2 } })
|
|
gate.resolve(ok({ ...APPLIED, bundle: 'dsh-new', registries: [MIRROR, null] }))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
expect(state().install.attempts).toEqual({ registries: [MIRROR, null], total: 2 })
|
|
})
|
|
|
|
it('compares a registry that does not parse as written', () => {
|
|
// A remembered or configured address that no longer parses still stands for itself, so only an exact repeat folds.
|
|
expect(offeredRegistries({ registry: null, fallbackRegistries: ['garbage'], resolved: 'garbage' })).toEqual([null])
|
|
expect(offeredRegistries({ registry: 'garbage', fallbackRegistries: [], resolved: null })).toEqual(['garbage', null])
|
|
})
|
|
|
|
it('lists pnpm\'s own configuration once when it names the registry the Host also offers', async () => {
|
|
const shared = { registry: null, fallbackRegistries: [MIRROR], resolved: MIRROR }
|
|
const storage = new Map([['dsh.plugin-manager.install-registry', JSON.stringify({ kind: 'offered', registry: MIRROR })]])
|
|
vi.stubGlobal('localStorage', {
|
|
getItem: (key: string) => storage.get(key) ?? null,
|
|
setItem: (key: string, value: string) => { storage.set(key, value) },
|
|
removeItem: (key: string) => { storage.delete(key) },
|
|
})
|
|
try {
|
|
const { face, state } = bench({ registries: vi.fn(() => Promise.resolve(ok(shared))) })
|
|
face.openInstall()
|
|
await vi.waitFor(() => { expect(state().install.registries).toEqual(shared) })
|
|
// The mirror the Host offers is the registry pnpm's own configuration names, so the dialog lists one entry for it.
|
|
expect(offeredRegistries(shared)).toEqual([null])
|
|
// The remembered mirror takes the entry that still asks the same registry.
|
|
expect(state().install.registry).toEqual({ kind: 'offered', registry: null })
|
|
} finally {
|
|
vi.unstubAllGlobals()
|
|
}
|
|
})
|
|
|
|
it('keeps a remembered pnpm configuration that names its own registry', async () => {
|
|
const storage = new Map([['dsh.plugin-manager.install-registry', JSON.stringify({ kind: 'offered', registry: null })]])
|
|
vi.stubGlobal('localStorage', {
|
|
getItem: (key: string) => storage.get(key) ?? null,
|
|
setItem: (key: string, value: string) => { storage.set(key, value) },
|
|
removeItem: (key: string) => { storage.delete(key) },
|
|
})
|
|
try {
|
|
const { face, state } = bench()
|
|
face.openInstall()
|
|
await vi.waitFor(() => { expect(state().install.registries).toEqual(REGISTRIES) })
|
|
expect(state().install.registry).toEqual({ kind: 'offered', registry: null })
|
|
} finally {
|
|
vi.unstubAllGlobals()
|
|
}
|
|
})
|
|
|
|
it('starts from the registry the Host configured first while nothing is remembered', async () => {
|
|
const corporate = { registry: CORP, fallbackRegistries: [MIRROR], resolved: OFFICIAL }
|
|
const { plugins, face, state } = bench({
|
|
registries: vi.fn(() => Promise.resolve(ok(corporate))),
|
|
inspect: vi.fn(() => Promise.resolve(ok({ ...INSPECTED, registry: CORP }))),
|
|
})
|
|
face.openInstall()
|
|
await vi.waitFor(() => { expect(state().install.registries).toEqual(corporate) })
|
|
expect(state().install.registry).toEqual({ kind: 'offered', registry: CORP })
|
|
face.editInstallSpec('dsh-new')
|
|
face.runInstall()
|
|
expect(plugins.inspect).toHaveBeenCalledWith('dsh-new', { registry: CORP }, expect.any(AbortSignal))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
expect(plugins.installBundle).toHaveBeenCalledWith('dsh-new', expect.objectContaining({ registry: CORP }))
|
|
})
|
|
|
|
it('refuses a custom registry that is not an http(s) URL before asking the Host, and remembers the registry last used', async () => {
|
|
const storage = new Map<string, string>()
|
|
vi.stubGlobal('localStorage', {
|
|
getItem: (key: string) => storage.get(key) ?? null,
|
|
setItem: (key: string, value: string) => { storage.set(key, value) },
|
|
removeItem: (key: string) => { storage.delete(key) },
|
|
})
|
|
try {
|
|
const { plugins, face, state, track } = bench({
|
|
inspect: vi.fn(() => Promise.resolve(ok({ ...INSPECTED, registry: 'https://npm.corp.example/' }))),
|
|
})
|
|
face.openInstall()
|
|
face.editInstallSpec('dsh-new')
|
|
face.chooseRegistry({ kind: 'custom', url: ' npm.corp.example ' })
|
|
face.runInstall()
|
|
expect(state().install).toMatchObject({ phase: 'idle', registryError: true, registryOpen: true })
|
|
expect(plugins.inspect).not.toHaveBeenCalled()
|
|
expect(track.mock.calls.filter(([event]) => event === 'install_plugin_result')).toEqual([
|
|
['install_plugin_result', expect.objectContaining({ input_value: 'dsh-new', is_success: false, error_reason: 'invalid-registry' })],
|
|
])
|
|
// Typing again clears the refusal; a URL is asked as typed, trimmed.
|
|
face.chooseRegistry({ kind: 'custom', url: ' https://npm.corp.example ' })
|
|
expect(state().install.registryError).toBe(false)
|
|
face.runInstall()
|
|
expect(plugins.inspect).toHaveBeenCalledWith('dsh-new', { registry: 'https://npm.corp.example' }, expect.any(AbortSignal))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
expect(plugins.installBundle).toHaveBeenCalledWith('dsh-new', expect.objectContaining({ registry: 'https://npm.corp.example/' }))
|
|
const results = track.mock.calls.filter(([event]) => event === 'install_plugin_result')
|
|
expect(results).toHaveLength(2)
|
|
expect(results[1]).toEqual(['install_plugin_result', expect.objectContaining({ is_success: true, plugin_name: 'dsh-new' })])
|
|
// A dialog opened later, by another controller, starts from the registry last used.
|
|
const later = bench()
|
|
later.face.openInstall()
|
|
expect(later.state().install.registry).toEqual({ kind: 'custom', url: 'https://npm.corp.example' })
|
|
// A remembered registry the Host no longer offers is kept as a typed one.
|
|
storage.set('dsh.plugin-manager.install-registry', JSON.stringify({ kind: 'offered', registry: 'https://old.example/' }))
|
|
const stale = bench()
|
|
stale.face.openInstall()
|
|
expect(stale.state().install.registry).toEqual({ kind: 'offered', registry: 'https://old.example/' })
|
|
await vi.waitFor(() => { expect(stale.state().install.registries).toEqual(REGISTRIES) })
|
|
expect(stale.state().install.registry).toEqual({ kind: 'custom', url: 'https://old.example/' })
|
|
// A read the Host refuses leaves pnpm's own and a typed one; one that settles after the dialog closed is dropped.
|
|
const gate = deferred<ReturnType<typeof ok<typeof REGISTRIES>>>()
|
|
const closed = bench({ registries: vi.fn().mockResolvedValueOnce(refused('unavailable', 'no profile')).mockReturnValueOnce(gate.promise) })
|
|
closed.face.openInstall()
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(closed.state().install.registries).toBeNull()
|
|
closed.face.closeInstall()
|
|
closed.face.openInstall()
|
|
closed.face.closeInstall()
|
|
gate.resolve(ok(REGISTRIES))
|
|
await Promise.resolve()
|
|
await Promise.resolve()
|
|
expect(closed.state().install).toMatchObject({ open: false, registries: null })
|
|
} finally {
|
|
vi.unstubAllGlobals()
|
|
}
|
|
})
|
|
|
|
it('goes back to the spec with the registries open from a failed run, keeping the registries the Host asked', async () => {
|
|
const outcome = {
|
|
...failed({ code: 'operation-error', diagnostic: 'ETIMEDOUT' }, { exitCode: 1, output: '', truncated: false, logPath: '/l', kind: 'network' }),
|
|
registries: [null, MIRROR], failedAt: 'registry' as const,
|
|
}
|
|
const { face, state } = bench({ installBundle: vi.fn(() => Promise.resolve(ok(outcome))) })
|
|
face.openInstall()
|
|
face.editInstallSpec('dsh-new')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
|
|
expect(state().install.failure).toMatchObject({ kind: 'network', failedAt: 'registry' })
|
|
expect(state().install.attempts).toEqual({ registries: [null, MIRROR], total: 2 })
|
|
face.changeRegistry()
|
|
expect(state().install).toMatchObject({ phase: 'idle', spec: 'dsh-new', registryOpen: true, runs: [], failure: null })
|
|
})
|
|
|
|
it.each(['network', 'timeout'] as const)('recovers a GitHub %s failure without retrying its URL through a mirror', async (kind) => {
|
|
const spec = 'https://github.com/example/dsh-plugin.git'
|
|
const inspect = vi.fn().mockResolvedValueOnce(ok({ status: 'accepted', kind: 'git', bundle: null, registry: null, host: 'github.com' }))
|
|
.mockResolvedValue(ok({ ...INSPECTED, registry: MIRROR }))
|
|
const { face, state, plugins } = bench({
|
|
inspect,
|
|
installBundle: vi.fn().mockResolvedValueOnce(ok({
|
|
...failed(undefined, { exitCode: 1, output: 'Could not resolve host: github.com', truncated: false, logPath: '/l', kind }),
|
|
failedAt: 'spec-host',
|
|
})).mockResolvedValueOnce(ok({
|
|
...failed(undefined, { exitCode: 1, output: 'Registry connection failed', truncated: false, logPath: '/l', kind: 'network' }),
|
|
failedAt: 'registry',
|
|
})).mockResolvedValue(ok({ ...APPLIED, bundle: 'dsh-new' })),
|
|
})
|
|
face.openInstall()
|
|
await vi.waitFor(() => { expect(state().install.registries).toEqual(REGISTRIES) })
|
|
face.editInstallSpec(spec)
|
|
face.useGithubMirror()
|
|
expect(state().install.spec).toBe(spec)
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
|
|
face.useGithubMirror()
|
|
expect(state().install).toMatchObject({
|
|
phase: 'idle', open: true, spec: '', mirrorRecovery: true, registry: { kind: 'offered', registry: MIRROR },
|
|
registryOpen: false, failure: null, runs: [],
|
|
})
|
|
expect(plugins.installBundle).toHaveBeenCalledTimes(1)
|
|
face.editInstallSpec('dsh-new')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
|
|
face.changeRegistry()
|
|
expect(state().install).toMatchObject({ phase: 'idle', spec: 'dsh-new', mirrorRecovery: true, registryOpen: true })
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
expect(plugins.inspect).toHaveBeenLastCalledWith('dsh-new', { registry: MIRROR }, expect.any(AbortSignal))
|
|
expect(plugins.installBundle).toHaveBeenLastCalledWith('dsh-new', expect.objectContaining({ registry: MIRROR }))
|
|
face.closeInstall()
|
|
face.openInstall()
|
|
await vi.waitFor(() => { expect(state().install.registries).toEqual(REGISTRIES) })
|
|
expect(state().install.registry).toEqual({ kind: 'offered', registry: MIRROR })
|
|
})
|
|
|
|
it.each([
|
|
['typed as an address', { kind: 'custom', url: 'https://registry.npmmirror.com' }, REGISTRIES],
|
|
['named by pnpm\'s own configuration', { kind: 'offered', registry: null }, { ...REGISTRIES, resolved: MIRROR }],
|
|
] as const)('clears the GitHub address and keeps the registry when the install already asked the mirror %s', async (_how, choice, registries) => {
|
|
const { face, state, plugins } = bench({
|
|
registries: vi.fn(() => Promise.resolve(ok(registries))),
|
|
inspect: vi.fn(() => Promise.resolve(ok({ status: 'accepted', kind: 'git', bundle: null, registry: MIRROR, host: 'github.com' }))),
|
|
installBundle: vi.fn(() => Promise.resolve(ok({
|
|
...failed(undefined, { exitCode: 1, output: 'Could not resolve host: github.com', truncated: false, logPath: '/l', kind: 'timeout' }),
|
|
failedAt: 'spec-host',
|
|
}))),
|
|
})
|
|
face.openInstall()
|
|
await vi.waitFor(() => { expect(state().install.registries).toEqual(registries) })
|
|
face.chooseRegistry(choice)
|
|
face.editInstallSpec('https://github.com/example/dsh-plugin.git')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
|
|
face.useGithubMirror()
|
|
expect(state().install).toMatchObject({ phase: 'idle', spec: '', mirrorRecovery: true, registry: choice, failure: null })
|
|
// The choice stays as made, so the next dialog starts from it too.
|
|
face.closeInstall()
|
|
face.openInstall()
|
|
await vi.waitFor(() => { expect(state().install.registries).toEqual(registries) })
|
|
expect(state().install.registry).toEqual(choice)
|
|
expect(plugins.installBundle).toHaveBeenCalledTimes(1)
|
|
})
|
|
})
|
|
|
|
describe('Host registry response recommendation', () => {
|
|
it('inspects the remembered registry when the offered choice becomes custom during the initial read', async () => {
|
|
const key = 'dsh.plugin-manager.install-registry'
|
|
const registry = 'https://old.example/'
|
|
const storage = new Map([[key, JSON.stringify({ kind: 'offered', registry })]])
|
|
const registries = deferred<ReturnType<typeof ok<typeof REGISTRIES>>>()
|
|
vi.stubGlobal('localStorage', {
|
|
getItem: (name: string) => storage.get(name) ?? null,
|
|
setItem: (name: string, value: string) => { storage.set(name, value) },
|
|
removeItem: (name: string) => { storage.delete(name) },
|
|
})
|
|
try {
|
|
const { face, state, plugins, probe } = bench({
|
|
registries: vi.fn(() => registries.promise),
|
|
inspect: vi.fn(async () => ok({ ...INSPECTED, registry })),
|
|
})
|
|
face.openInstall()
|
|
face.editInstallSpec('dsh-new')
|
|
face.runInstall()
|
|
expect(state().install.phase).toBe('checking')
|
|
expect(plugins.inspect).not.toHaveBeenCalled()
|
|
registries.resolve(ok(REGISTRIES))
|
|
await vi.waitFor(() => { expect(plugins.inspect).toHaveBeenCalled() })
|
|
expect(plugins.inspect).toHaveBeenCalledWith('dsh-new', { registry }, expect.any(AbortSignal))
|
|
expect(state().install.registry).toEqual({ kind: 'custom', url: registry })
|
|
expect(JSON.parse(storage.get(key)!)).toEqual({ kind: 'custom', url: registry })
|
|
expect(probe.fastest).not.toHaveBeenCalled()
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
expect(plugins.installBundle).toHaveBeenCalledWith('dsh-new', expect.objectContaining({ registry }))
|
|
} finally {
|
|
registries.resolve(ok(REGISTRIES))
|
|
vi.unstubAllGlobals()
|
|
}
|
|
})
|
|
|
|
it('selects the mainland mirror before the first inspection, even when install was clicked during lookup', async () => {
|
|
const fastest = deferred<ReturnType<typeof ok<string | null>>>()
|
|
const { face, state, plugins, probe } = bench({ fastest: vi.fn(() => fastest.promise) })
|
|
face.openInstall()
|
|
face.editInstallSpec('dsh-new')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(probe.fastest).toHaveBeenCalledOnce() })
|
|
expect(plugins.inspect).not.toHaveBeenCalled()
|
|
fastest.resolve(ok(MIRROR))
|
|
await vi.waitFor(() => { expect(plugins.inspect).toHaveBeenCalled() })
|
|
expect(plugins.inspect).toHaveBeenCalledWith('dsh-new', { registry: MIRROR }, expect.any(AbortSignal))
|
|
expect(state().install.registry).toEqual({ kind: 'offered', registry: MIRROR })
|
|
})
|
|
|
|
it.each([OFFICIAL, null])('keeps the default for probe result %s', async (winner) => {
|
|
const { face, state, probe } = bench({ fastest: vi.fn(() => Promise.resolve(ok(winner))) })
|
|
face.openInstall()
|
|
await vi.waitFor(() => { expect(probe.fastest).toHaveBeenCalledOnce() })
|
|
expect(state().install.registry).toEqual({ kind: 'offered', registry: null })
|
|
})
|
|
|
|
it('keeps the default when the Host lookup is unavailable', async () => {
|
|
const { face, state, probe } = bench({ fastest: vi.fn(() => Promise.resolve(refused('gateway/internal', 'offline'))) })
|
|
face.openInstall()
|
|
await vi.waitFor(() => { expect(probe.fastest).toHaveBeenCalledOnce() })
|
|
expect(state().install.registry).toEqual({ kind: 'offered', registry: null })
|
|
})
|
|
|
|
it.each([
|
|
{ registry: MIRROR, resolved: OFFICIAL, fallbackRegistries: [MIRROR] },
|
|
{ registry: null, resolved: CORP, fallbackRegistries: [MIRROR] },
|
|
{ registry: null, resolved: null, fallbackRegistries: [MIRROR] },
|
|
{ registry: null, resolved: 'invalid', fallbackRegistries: [MIRROR] },
|
|
{ registry: null, resolved: OFFICIAL, fallbackRegistries: [] },
|
|
])('does not probe registries for an ineligible registry configuration %j', async (registries) => {
|
|
const { face, state, probe } = bench({ registries: vi.fn(() => Promise.resolve(ok(registries))) })
|
|
face.openInstall()
|
|
await vi.waitFor(() => { expect(state().install.registries).toEqual(registries) })
|
|
expect(state().install.registry).toEqual({ kind: 'offered', registry: registries.registry })
|
|
expect(probe.fastest).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('preserves a manual choice made before the registry list arrives', async () => {
|
|
const registries = deferred<ReturnType<typeof ok<typeof REGISTRIES>>>()
|
|
const { face, state, probe } = bench({ registries: vi.fn(() => registries.promise) })
|
|
face.openInstall()
|
|
face.chooseRegistry({ kind: 'custom', url: CORP })
|
|
registries.resolve(ok(REGISTRIES))
|
|
await vi.waitFor(() => { expect(state().install.registries).toEqual(REGISTRIES) })
|
|
expect(state().install.registry).toEqual({ kind: 'custom', url: CORP })
|
|
expect(probe.fastest).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not overwrite a manual choice or wait for registry probing after that choice', async () => {
|
|
const fastest = deferred<ReturnType<typeof ok<string | null>>>()
|
|
const { face, state, plugins, probe } = bench({ fastest: vi.fn(() => fastest.promise) })
|
|
face.openInstall()
|
|
await vi.waitFor(() => { expect(probe.fastest).toHaveBeenCalledOnce() })
|
|
face.chooseRegistry({ kind: 'custom', url: CORP })
|
|
face.editInstallSpec('dsh-new')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(plugins.inspect).toHaveBeenCalled() })
|
|
fastest.resolve(ok(MIRROR))
|
|
await fastest.promise
|
|
expect(state().install.registry).toEqual({ kind: 'custom', url: CORP })
|
|
expect(plugins.inspect).toHaveBeenCalledWith('dsh-new', { registry: CORP }, expect.any(AbortSignal))
|
|
})
|
|
|
|
it.each(['close', 'dispose'] as const)('discards a waiting install after %s', async (action) => {
|
|
const fastest = deferred<ReturnType<typeof ok<string | null>>>()
|
|
const { face, controller, plugins, probe } = bench({ fastest: vi.fn(() => fastest.promise) })
|
|
face.openInstall()
|
|
face.editInstallSpec('dsh-new')
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(probe.fastest).toHaveBeenCalledOnce() })
|
|
if (action === 'close') face.closeInstall()
|
|
else controller.dispose()
|
|
fastest.resolve(ok(MIRROR))
|
|
await fastest.promise
|
|
await Promise.resolve()
|
|
expect(plugins.inspect).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('recognizes the official registry without a trailing slash and with uppercase host letters', async () => {
|
|
const { face, state } = bench({
|
|
registries: vi.fn(async () => ok({ ...REGISTRIES, resolved: 'https://REGISTRY.NPMJS.ORG' })),
|
|
fastest: vi.fn(async () => ok(MIRROR)),
|
|
})
|
|
face.openInstall()
|
|
await vi.waitFor(() => { expect(state().install.registry).toEqual({ kind: 'offered', registry: MIRROR }) })
|
|
})
|
|
|
|
|
|
describe('desktop analytics outcomes', () => {
|
|
it('redacts authenticated installer URLs from click and failed-result events', async () => {
|
|
const b = bench({ inspect: vi.fn(async () => ok({ status: 'refused', problem: 'not-bundle', reason: 'refused' })) })
|
|
b.face.openInstall()
|
|
b.face.editInstallSpec('git+https://user:[email protected]/repo.git?token=private-token')
|
|
b.face.runInstall()
|
|
await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ input_value: '[git]' })) })
|
|
expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: '[git]' })
|
|
expect(JSON.stringify(b.track.mock.calls)).not.toContain('private-token')
|
|
})
|
|
|
|
it('reports input inspection failure separately from cancellation', async () => {
|
|
const b = bench({ inspect: vi.fn(async () => ok({ status: 'refused', problem: 'not-bundle', reason: 'not a bundle' })) })
|
|
b.face.openInstall()
|
|
b.face.editInstallSpec('dsh-new')
|
|
b.face.runInstall()
|
|
await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ input_value: 'dsh-new', is_success: false, error_reason: 'not-bundle', duration: expect.any(Number) as number })) })
|
|
expect(b.track).toHaveBeenCalledWith('plugin_install_click', { input_value: 'dsh-new' })
|
|
})
|
|
|
|
it('reports unknown only after recovery confirms the result is absent', async () => {
|
|
const lost = deferred<ReturnType<typeof ok<ChangeResult | null>>>()
|
|
const b = bench({ installBundle: vi.fn(async () => refused('gateway/internal', 'offline')), waitForInstall: vi.fn(() => lost.promise) })
|
|
b.face.openInstall()
|
|
b.face.editInstallSpec('dsh-new')
|
|
b.face.runInstall()
|
|
await vi.waitFor(() => { expect(b.plugins.waitForInstall).toHaveBeenCalledTimes(1) })
|
|
expect(b.track.mock.calls.filter(call => call[0] === 'install_plugin_result')).toEqual([])
|
|
lost.resolve(ok(null))
|
|
await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('install_plugin_result', expect.objectContaining({ error_reason: 'unknown_result', is_success: false })) })
|
|
b.face.reconcileInstall()
|
|
expect(b.track.mock.calls.filter(call => call[0] === 'install_plugin_result')).toHaveLength(1)
|
|
})
|
|
|
|
it('closing during inspection reports user_cancelled once', async () => {
|
|
const inspecting = deferred<ReturnType<typeof ok<typeof INSPECTED>>>()
|
|
const b = bench({ inspect: vi.fn(() => inspecting.promise) })
|
|
b.face.openInstall()
|
|
b.face.editInstallSpec('dsh-new')
|
|
b.face.runInstall()
|
|
await vi.waitFor(() => { expect(b.plugins.inspect).toHaveBeenCalledTimes(1) })
|
|
b.face.closeInstall()
|
|
inspecting.resolve(ok(INSPECTED))
|
|
await Promise.resolve()
|
|
const results = b.track.mock.calls.filter(call => call[0] === 'install_plugin_result')
|
|
expect(results).toHaveLength(1)
|
|
expect(results[0]?.[1]).toMatchObject({ error_reason: 'user_cancelled', is_success: false })
|
|
expect(results[0]?.[1]).not.toHaveProperty('result_status')
|
|
expect(results[0]?.[1]).not.toHaveProperty('plugin_type')
|
|
expect(b.plugins.installBundle).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uses bundle and plugin types only after successful changes', async () => {
|
|
const b = bench()
|
|
await b.controller.load()
|
|
b.face.setEnabled(BUNDLE.name, true)
|
|
await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('plugin_toggle', { plugin_name: BUNDLE.name, plugin_type: 'bundle', is_enabled: true, is_builtin: false }) })
|
|
b.face.setRowEnabled(ROW_ENTRY, false)
|
|
await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('plugin_toggle', { plugin_name: BUNDLE.name, plugin_type: 'plugin', is_enabled: false, is_builtin: false }) })
|
|
b.plugins.setBundleEnabled.mockResolvedValueOnce(ok({ ...APPLIED, application: 'cancelled' }))
|
|
b.face.setEnabled(BUNDLE.name, false)
|
|
await vi.waitFor(() => { expect(b.state().busy).toEqual([]) })
|
|
expect(b.track.mock.calls.filter(call => call[0] === 'plugin_toggle')).toHaveLength(2)
|
|
})
|
|
})
|
|
|
|
|
|
it('does not retain install identity when analytics is disabled', async () => {
|
|
const b = bench({}, false)
|
|
b.face.openInstall()
|
|
b.face.editInstallSpec('dsh-new')
|
|
b.face.runInstall()
|
|
await vi.waitFor(() => { expect(b.state().install.phase).toBe('done') })
|
|
expect(b.track.mock.calls.filter(([name]) => name === 'plugin_install_click' || name === 'install_plugin_result')).toEqual([])
|
|
})
|
|
|
|
it('reports restart-required row toggles and tolerates a vanished inventory entry', async () => {
|
|
const b = bench({ setPluginEnabled: vi.fn(async () => ok({ ...APPLIED, application: 'restart-required' })) })
|
|
await b.controller.load()
|
|
b.face.setRowEnabled(ROW_ENTRY, true)
|
|
await vi.waitFor(() => { expect(b.track).toHaveBeenCalledWith('plugin_toggle', expect.objectContaining({ plugin_type: 'plugin' })) })
|
|
b.face.setEnabled('vanished-package', true)
|
|
await vi.waitFor(() => { expect(b.state().busy).toEqual([]) })
|
|
expect(b.track.mock.calls.filter(([name]) => name === 'plugin_toggle')).toHaveLength(1)
|
|
})
|
|
|
|
it('reports a successful install with only documented result fields', async () => {
|
|
const b = bench()
|
|
b.face.openInstall()
|
|
b.face.editInstallSpec('dsh-new')
|
|
b.face.runInstall()
|
|
await vi.waitFor(() => { expect(b.state().install.phase).toBe('done') })
|
|
const results = b.track.mock.calls.filter(([name]) => name === 'install_plugin_result')
|
|
expect(results).toEqual([['install_plugin_result', {
|
|
input_value: 'dsh-new', is_success: true, duration: expect.any(Number) as number, plugin_name: 'dsh-new',
|
|
}]])
|
|
})
|
|
|
|
|
|
const CATALOG: BundleInfo = {
|
|
name: '@deepseek-ai/dsh-subagent-codex', official: true, availability: 'missing', installed: false, optional: false,
|
|
enabled: false, removable: false, updatable: false, rows: [], overrides: [],
|
|
installTarget: { spec: '@deepseek-ai/[email protected]', version: '2.0.0' },
|
|
}
|
|
|
|
it.each(['registry', 'source'] as const)('installs a typed default-hidden Official name using its %s target', async (mode) => {
|
|
const name = '@deepseek-ai/dsh-cua-bundle'
|
|
const spec = mode === 'source' ? 'link:/workspace/cua-bundle' : `${name}@2.0.0`
|
|
const entry = { ...CATALOG, name, listedByDefault: false, installTarget: { spec, version: '2.0.0' } }
|
|
const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([entry])) })
|
|
await controller.load()
|
|
face.openInstall()
|
|
face.editInstallSpec(name)
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(plugins.installBundle).toHaveBeenCalledOnce() })
|
|
expect(plugins.inspect).not.toHaveBeenCalled()
|
|
expect(state().install.subject?.selection).toBeUndefined()
|
|
expect(plugins.installBundle).toHaveBeenCalledWith(spec, {
|
|
enabled: false, ...mode === 'registry' ? { saveExact: true } : {}, requestId: state().install.requestId, registry: null,
|
|
})
|
|
})
|
|
|
|
it('installs an absent Official entry directly at the host target and enables it through the normal installer', async () => {
|
|
const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([CATALOG])) })
|
|
await controller.load()
|
|
face.setEnabled(CATALOG.name, true)
|
|
await vi.waitFor(() => { expect(plugins.installBundle).toHaveBeenCalledOnce() })
|
|
expect(plugins.inspect).not.toHaveBeenCalled()
|
|
expect(plugins.installBundle).toHaveBeenCalledWith(CATALOG.installTarget!.spec, {
|
|
enabled: true, saveExact: true, requestId: state().install.requestId, registry: null,
|
|
})
|
|
expect(state().install.subject).toMatchObject({ selection: true, saveExact: true, version: '2.0.0' })
|
|
})
|
|
|
|
it.each(['before', 'after'] as const)('reports one Official toggle when inventory refresh finishes %s installation', async (order) => {
|
|
const installing = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const refreshing = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
|
|
const installed: BundleInfo = { ...CATALOG, availability: 'profile', installed: true, enabled: true, removable: true, version: '2.0.0' }
|
|
const result = ok({ ...APPLIED, bundle: CATALOG.name })
|
|
const listBundles = vi.fn().mockResolvedValue(ok([installed]))
|
|
.mockResolvedValueOnce(ok([CATALOG])).mockReturnValueOnce(refreshing.promise)
|
|
const { controller, face, state, started, track } = bench({ listBundles, installBundle: vi.fn(() => installing.promise) })
|
|
const reads: Promise<void>[] = []
|
|
onTestFinished(async () => { installing.resolve(result); refreshing.resolve(ok([installed])); await Promise.all(reads) })
|
|
await controller.load()
|
|
face.setEnabled(CATALOG.name, true)
|
|
await started()
|
|
const refresh = controller.load()
|
|
reads.push(refresh)
|
|
await vi.waitFor(() => { expect(listBundles).toHaveBeenCalledTimes(2) })
|
|
expect(state().packages.find(pkg => pkg.name === CATALOG.name)?.availability).toBe('missing')
|
|
if (order === 'before') {
|
|
refreshing.resolve(ok([installed]))
|
|
await refresh
|
|
}
|
|
installing.resolve(result)
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
expect(track.mock.calls.filter(([event]) => event === 'plugin_toggle')).toEqual([['plugin_toggle', {
|
|
plugin_name: CATALOG.name, plugin_type: 'bundle', is_enabled: true, is_builtin: false,
|
|
}]])
|
|
refreshing.resolve(ok([installed]))
|
|
await refresh
|
|
await controller.load()
|
|
expect(track.mock.calls.filter(([event]) => event === 'plugin_toggle')).toHaveLength(1)
|
|
})
|
|
|
|
it.each([true, false])('updates an Official entry with enabled=%s by removing it, then using the same exact installer', async (enabled) => {
|
|
const installed: BundleInfo = { ...CATALOG, availability: 'profile', installed: true, version: '1.0.0', enabled, removable: true }
|
|
const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([installed])),
|
|
installBundle: vi.fn().mockResolvedValue(ok({ ...APPLIED, bundle: CATALOG.name, application: 'restart-required' })),
|
|
})
|
|
await controller.load()
|
|
face.update(CATALOG.name)
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
expect(plugins.inspect).not.toHaveBeenCalled()
|
|
expect(plugins.removeBundle).toHaveBeenCalledWith(CATALOG.name)
|
|
expect(plugins.removeBundle.mock.invocationCallOrder[0]).toBeLessThan(plugins.installBundle.mock.invocationCallOrder[0]!)
|
|
expect(plugins.installBundle).toHaveBeenCalledWith(CATALOG.installTarget!.spec, {
|
|
enabled, saveExact: true, requestId: state().install.requestId, registry: null,
|
|
})
|
|
expect(state().install.restartRequired).toBe(true)
|
|
})
|
|
|
|
it('keeps an installed Official entry and installs nothing when its removal for an update fails', async () => {
|
|
const installed: BundleInfo = { ...CATALOG, availability: 'profile', installed: true, version: '1.0.0', enabled: true, removable: true }
|
|
const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([installed])),
|
|
removeBundle: vi.fn().mockResolvedValue(ok({ ...APPLIED, application: 'failed', error: { code: 'bundle-in-use' } })),
|
|
})
|
|
await controller.load()
|
|
face.update(CATALOG.name)
|
|
await vi.waitFor(() => { expect(state().notice).toMatchObject({ kind: 'failed', action: 'uninstall' }) })
|
|
expect(plugins.installBundle).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it.each([
|
|
['an unreachable Host', () => refused('ERR_REMOTE', 'connection lost')],
|
|
['a profile without HMR', () => ok({ ...APPLIED, application: 'failed', error: { code: 'stop-profile' } })],
|
|
] as const)('installs an Official update only after the removal, except over a running bundle of %s', async (kind, answer) => {
|
|
const installed: BundleInfo = { ...CATALOG, availability: 'profile', installed: true, version: '1.0.0', enabled: true, removable: true }
|
|
const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([installed])),
|
|
removeBundle: vi.fn().mockResolvedValue(answer()),
|
|
})
|
|
await controller.load()
|
|
face.update(CATALOG.name)
|
|
if (kind === 'an unreachable Host') {
|
|
await vi.waitFor(() => { expect(state().notice).toMatchObject({ kind: 'failed', action: 'uninstall' }) })
|
|
expect(plugins.installBundle).not.toHaveBeenCalled()
|
|
} else {
|
|
// Without HMR a running bundle stays installed, and the installation over it waits for the restart.
|
|
await vi.waitFor(() => { expect(plugins.installBundle).toHaveBeenCalled() })
|
|
}
|
|
})
|
|
|
|
it('preserves the Official exact target and selection when script approval retries an installation', async () => {
|
|
const install = vi.fn().mockResolvedValueOnce(ok({ ...APPLIED, application: 'failed', stage: 'install',
|
|
error: { code: 'operation-error' }, pendingBuilds: ['native-provider'],
|
|
})).mockResolvedValueOnce(ok({ ...APPLIED, bundle: CATALOG.name }))
|
|
const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([CATALOG])), installBundle: install })
|
|
await controller.load()
|
|
face.setEnabled(CATALOG.name, true)
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
|
|
face.approveBuildsAndRetry()
|
|
await vi.waitFor(() => { expect(install).toHaveBeenCalledTimes(2) })
|
|
expect(plugins.inspect).not.toHaveBeenCalled()
|
|
expect(install).toHaveBeenLastCalledWith(CATALOG.installTarget!.spec, {
|
|
enabled: true, saveExact: true, requestId: state().install.requestId, registry: null, approvedBuilds: ['native-provider'],
|
|
})
|
|
})
|
|
|
|
it('retains a failed Official install target while changing its registry', async () => {
|
|
const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([CATALOG])),
|
|
installBundle: vi.fn().mockResolvedValueOnce(ok({ ...APPLIED, application: 'failed', stage: 'install', error: { code: 'operation-error' } }))
|
|
.mockResolvedValue(ok({ ...APPLIED, bundle: CATALOG.name })),
|
|
})
|
|
await controller.load()
|
|
face.setEnabled(CATALOG.name, true)
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('failed') })
|
|
face.changeRegistry()
|
|
face.chooseRegistry({ kind: 'custom', url: CORP })
|
|
face.runInstall()
|
|
await vi.waitFor(() => { expect(plugins.installBundle).toHaveBeenCalledTimes(2) })
|
|
expect(plugins.installBundle).toHaveBeenLastCalledWith(CATALOG.installTarget!.spec, {
|
|
enabled: true, saveExact: true, requestId: state().install.requestId, registry: CORP,
|
|
})
|
|
expect(plugins.inspect).not.toHaveBeenCalled()
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
face.closeInstall()
|
|
face.setEnabled(CATALOG.name, true)
|
|
await vi.waitFor(() => { expect(plugins.installBundle).toHaveBeenCalledTimes(3) })
|
|
expect(plugins.installBundle).toHaveBeenLastCalledWith(CATALOG.installTarget!.spec, {
|
|
enabled: true, saveExact: true, requestId: state().install.requestId, registry: CORP,
|
|
})
|
|
})
|
|
|
|
it('keeps Official disablement on the existing selection operation', async () => {
|
|
const { controller, plugins, face } = bench({ listBundles: vi.fn().mockResolvedValue(ok([{ ...CATALOG, enabled: true }])) })
|
|
await controller.load()
|
|
face.setEnabled(CATALOG.name, false)
|
|
await vi.waitFor(() => { expect(plugins.setBundleEnabled).toHaveBeenCalledWith(CATALOG.name, false) })
|
|
expect(plugins.installBundle).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('reopens one Official operation through registry preparation and installation instead of starting duplicates', async () => {
|
|
const registries = deferred<ReturnType<typeof ok<typeof REGISTRIES>>>()
|
|
const installing = deferred<ReturnType<typeof ok<ChangeResult>>>()
|
|
const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([CATALOG])),
|
|
registries: vi.fn().mockReturnValue(registries.promise), installBundle: vi.fn().mockReturnValue(installing.promise),
|
|
})
|
|
await controller.load()
|
|
face.setEnabled(CATALOG.name, true)
|
|
expect(state().install.phase).toBe('checking')
|
|
face.setEnabled(CATALOG.name, true)
|
|
expect(plugins.registries).toHaveBeenCalledOnce()
|
|
registries.resolve(ok(REGISTRIES))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('starting') })
|
|
face.update(CATALOG.name)
|
|
expect(plugins.installBundle).toHaveBeenCalledOnce()
|
|
installing.resolve(ok({ ...APPLIED, bundle: CATALOG.name }))
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
})
|
|
|
|
it('ignores a stale update target and prevents a disposed tab from beginning an Official install', async () => {
|
|
const { controller, plugins, face } = bench({ listBundles: vi.fn().mockResolvedValue(ok([CATALOG, BUNDLE])) })
|
|
await controller.load()
|
|
face.update('removed')
|
|
face.update(BUNDLE.name)
|
|
expect(plugins.installBundle).not.toHaveBeenCalled()
|
|
controller.dispose()
|
|
face.setEnabled(CATALOG.name, true)
|
|
expect(plugins.installBundle).not.toHaveBeenCalled()
|
|
})
|
|
|
|
|
|
it('installs a source Official entry as a local path through the ordinary installer', async () => {
|
|
const spec = 'link:/development/dsh/packages/subagent/subagent-codex'
|
|
const entry = { ...CATALOG, installTarget: { spec, version: '2.0.0' } }
|
|
const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([entry])) })
|
|
await controller.load()
|
|
face.setEnabled(entry.name, true)
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
expect(plugins.inspect).not.toHaveBeenCalled()
|
|
expect(plugins.installBundle).toHaveBeenCalledWith(spec, {
|
|
enabled: true, requestId: state().install.requestId, registry: null,
|
|
})
|
|
expect(state().install.subject).toMatchObject({ kind: 'path', spec, selection: true })
|
|
expect(state().install.subject?.saveExact).toBeUndefined()
|
|
})
|
|
|
|
it.each([true, false])('replaces a same-version registry package with its source link and preserves enabled=%s', async (enabled) => {
|
|
const spec = 'link:/development/dsh/packages/subagent/subagent-codex'
|
|
const entry: BundleInfo = { ...CATALOG, installTarget: { spec, version: '2.0.0' }, availability: 'profile',
|
|
installed: true, version: '2.0.0', source: `${CATALOG.name}@2.0.0`, enabled, removable: true }
|
|
const { controller, plugins, face, state } = bench({ listBundles: vi.fn().mockResolvedValue(ok([entry])),
|
|
installBundle: vi.fn().mockResolvedValue(ok({ ...APPLIED, bundle: CATALOG.name, application: 'restart-required' })),
|
|
})
|
|
await controller.load()
|
|
face.update(entry.name)
|
|
await vi.waitFor(() => { expect(state().install.phase).toBe('done') })
|
|
expect(plugins.removeBundle).toHaveBeenCalledWith(CATALOG.name)
|
|
expect(plugins.installBundle).toHaveBeenCalledWith(spec, { enabled, requestId: state().install.requestId, registry: null })
|
|
expect(state().install.subject).toMatchObject({ kind: 'path', spec, selection: enabled })
|
|
expect(state().install.restartRequired).toBe(true)
|
|
})
|
|
|
|
it('deselects a source bundle whose target is unavailable without starting installation', async () => {
|
|
const { installTarget: _target, ...withoutTarget } = CATALOG
|
|
const entry: BundleInfo = { ...withoutTarget, enabled: true,
|
|
error: { code: 'operation-error', diagnostic: 'Source package is not built' } }
|
|
const { controller, plugins, face } = bench({ listBundles: vi.fn().mockResolvedValue(ok([entry])) })
|
|
await controller.load()
|
|
face.setEnabled(entry.name, false)
|
|
await vi.waitFor(() => { expect(plugins.setBundleEnabled).toHaveBeenCalledWith(entry.name, false) })
|
|
expect(plugins.installBundle).not.toHaveBeenCalled()
|
|
})
|
|
|
|
describe('version checks and updates', () => {
|
|
const UPDATABLE: BundleInfo = { ...BUNDLE, updatable: true }
|
|
const LISTED = {
|
|
status: 'listed' as const, installed: '0.16.0', registry: MIRROR, omitted: 0,
|
|
attempts: [{ registry: null, problem: 'network' as const, reason: 'ETIMEDOUT' }, { registry: MIRROR }],
|
|
versions: [
|
|
{ version: '1.0.0', tags: ['latest'], prerelease: false, bundle: true, incompatible: INCOMPATIBLE },
|
|
{ version: '0.18.0-beta.1', tags: [], prerelease: true, bundle: true },
|
|
{ version: '0.17.1', tags: [], prerelease: false, bundle: true, deprecated: 'broken' },
|
|
{ version: '0.17.0', tags: [], prerelease: false, bundle: true },
|
|
],
|
|
}
|
|
const UPDATED = { from: '0.16.0', to: '0.17.0', step: 'install' as const, result: { ...APPLIED, version: '0.17.0' } }
|
|
|
|
it('offers the newest version that declares a bundle, accepts this DSH, and is neither deprecated nor a prerelease', () => {
|
|
expect(updateCandidate(LISTED)?.version).toBe('0.17.0')
|
|
expect(updateCandidate({ ...LISTED, versions: LISTED.versions.slice(0, 3) })).toBeUndefined()
|
|
expect(updateCandidate({ status: 'refused', problem: 'network', reason: 'down', attempts: [] })).toBeUndefined()
|
|
expect(installable({ version: '2.0.0', tags: [], prerelease: false, bundle: false })).toBe(false)
|
|
})
|
|
|
|
it('checks a package once on ensure, again on request, and keeps a transport failure as an unknown refusal', async () => {
|
|
const listVersions = vi.fn().mockResolvedValueOnce(ok(LISTED)).mockResolvedValueOnce(refused('gateway/internal', 'offline'))
|
|
const { face, state } = bench({ listVersions })
|
|
face.ensureVersions('dsh-better-sidebar')
|
|
expect(state().versions['dsh-better-sidebar']).toEqual({ phase: 'checking' })
|
|
// A check in flight is not started again.
|
|
face.checkVersions('dsh-better-sidebar')
|
|
await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']).toMatchObject({ phase: 'done', list: LISTED }) })
|
|
face.ensureVersions('dsh-better-sidebar')
|
|
expect(listVersions).toHaveBeenCalledOnce()
|
|
expect(listVersions).toHaveBeenCalledWith('dsh-better-sidebar', {})
|
|
face.checkVersions('dsh-better-sidebar')
|
|
await vi.waitFor(() => {
|
|
expect(state().versions['dsh-better-sidebar']).toMatchObject({ phase: 'done', list: { status: 'refused', problem: 'unknown', reason: 'offline', attempts: [] } })
|
|
})
|
|
expect(listVersions).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it('forgets a check answered for another installed version, or for a package that is no longer updatable', async () => {
|
|
const listBundles = vi.fn(() => Promise.resolve(ok([UPDATABLE, { ...UPDATABLE, name: 'dsh-a' }])))
|
|
const { controller, face, state } = bench({ listBundles, listVersions: vi.fn(() => Promise.resolve(ok(LISTED))) })
|
|
await controller.load()
|
|
face.checkVersions('dsh-better-sidebar')
|
|
face.checkVersions('dsh-a')
|
|
await vi.waitFor(() => { expect(state().versions['dsh-a']?.phase).toBe('done') })
|
|
await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']?.phase).toBe('done') })
|
|
// Another surface reinstalled one package at another version and replaced the other by a linked copy.
|
|
listBundles.mockResolvedValue(ok([{ ...UPDATABLE, version: '0.17.0' }, { ...UPDATABLE, name: 'dsh-a', updatable: false }]))
|
|
await controller.load()
|
|
expect(state().versions).toEqual({})
|
|
})
|
|
|
|
it('checks every updatable package in turn on a manual refresh and keeps the spinner until the checks settle', async () => {
|
|
const order: string[] = []
|
|
const last = deferred<ReturnType<typeof ok<typeof LISTED>>>()
|
|
const listVersions = vi.fn(async (name: string) => { order.push(name); return name === 'dsh-better-sidebar' ? last.promise : ok(LISTED) })
|
|
const listBundles = vi.fn(() => Promise.resolve(ok([UPDATABLE, { ...UPDATABLE, name: 'dsh-a' }, { ...BUNDLE, name: 'dsh-linked' }])))
|
|
const { face, state } = bench({ listVersions, listBundles })
|
|
face.refresh()
|
|
await vi.waitFor(() => { expect(order).toEqual(['dsh-a', 'dsh-better-sidebar']) })
|
|
expect(state().refreshStatus).toBe('refreshing')
|
|
last.resolve(ok(LISTED))
|
|
await vi.waitFor(() => { expect(state().refreshStatus).toBe('idle') })
|
|
expect(state().versions['dsh-better-sidebar']?.phase).toBe('done')
|
|
})
|
|
|
|
it('checks no versions after a failed refresh', async () => {
|
|
const listVersions = vi.fn(() => Promise.resolve(ok(LISTED)))
|
|
const listBundles = vi.fn(() => Promise.resolve(refused('gateway/internal', 'offline')))
|
|
const { face, state } = bench({ listVersions, listBundles })
|
|
face.refresh()
|
|
await vi.waitFor(() => { expect(state().refreshStatus).toBe('failed') })
|
|
expect(listVersions).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('confirms, runs, and keeps an update\'s steps and outcome, then checks the package again', async () => {
|
|
const answer = deferred<ReturnType<typeof ok<typeof UPDATED>>>()
|
|
const updateBundle = vi.fn(() => answer.promise)
|
|
const listVersions = vi.fn(() => Promise.resolve(ok(LISTED)))
|
|
const listBundles = vi.fn(() => Promise.resolve(ok([UPDATABLE])))
|
|
const { controller, face, state, plugins } = bench({ updateBundle, listVersions, listBundles })
|
|
await controller.load()
|
|
// Nothing to confirm before the package's versions are read.
|
|
face.openUpdate('dsh-better-sidebar', '0.17.0')
|
|
expect(state().update).toBeNull()
|
|
face.checkVersions('dsh-better-sidebar')
|
|
await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']?.phase).toBe('done') })
|
|
face.openUpdate('dsh-better-sidebar', '0.17.0')
|
|
expect(state().update).toEqual({
|
|
packageName: 'dsh-better-sidebar', from: '0.16.0', to: '0.17.0', registry: MIRROR, phase: 'confirm', steps: [], result: null, outputOpen: false,
|
|
})
|
|
face.closeUpdate()
|
|
expect(state().update).toBeNull()
|
|
face.openUpdate('dsh-better-sidebar', '0.17.0')
|
|
face.runUpdate()
|
|
expect(state().update).toMatchObject({ phase: 'running' })
|
|
const requestId = state().update?.requestId as PluginInstallRequestId
|
|
expect(updateBundle).toHaveBeenCalledWith('dsh-better-sidebar', '0.17.0', { registry: MIRROR, requestId })
|
|
face.runUpdate()
|
|
face.closeUpdate()
|
|
expect(updateBundle).toHaveBeenCalledOnce()
|
|
expect(state().update?.phase).toBe('running')
|
|
// Progress of another request, and a repeated step, change nothing.
|
|
controller.updateProgress({ requestId: 'other' as PluginInstallRequestId, step: 'checking' })
|
|
controller.updateProgress({ requestId, step: 'checking' })
|
|
controller.updateProgress({ requestId, step: 'checking' })
|
|
controller.updateProgress({ requestId, step: 'removing' })
|
|
expect(state().update?.steps).toEqual(['checking', 'removing'])
|
|
face.openUpdate('dsh-better-sidebar', '1.0.0')
|
|
expect(state().update?.to).toBe('0.17.0')
|
|
// A check of the package being replaced waits for the update.
|
|
face.checkVersions('dsh-better-sidebar')
|
|
expect(listVersions).toHaveBeenCalledOnce()
|
|
const reads = plugins.listBundles.mock.calls.length
|
|
answer.resolve(ok(UPDATED))
|
|
await vi.waitFor(() => { expect(state().update).toMatchObject({ phase: 'done', result: UPDATED, steps: ['checking', 'removing'] }) })
|
|
expect(plugins.listBundles.mock.calls.length).toBeGreaterThan(reads)
|
|
// The package's versions are read again for the version now installed.
|
|
await vi.waitFor(() => { expect(listVersions).toHaveBeenCalledTimes(2) })
|
|
await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']?.phase).toBe('done') })
|
|
controller.updateProgress({ requestId, step: 'installing' })
|
|
expect(state().update?.steps).toEqual(['checking', 'removing'])
|
|
face.toggleUpdateOutput()
|
|
expect(state().update?.outputOpen).toBe(true)
|
|
face.closeUpdate()
|
|
expect(state().update).toBeNull()
|
|
face.toggleUpdateOutput()
|
|
expect(state().update).toBeNull()
|
|
})
|
|
|
|
it('keeps a lost update answer as unknown and ignores an answer after disposal', async () => {
|
|
const updateBundle = vi.fn().mockResolvedValueOnce(refused('gateway/internal', 'socket closed'))
|
|
const listVersions = vi.fn(() => Promise.resolve(ok(LISTED)))
|
|
const { controller, face, state } = bench({ updateBundle, listVersions, listBundles: vi.fn(() => Promise.resolve(ok([UPDATABLE]))) })
|
|
await controller.load()
|
|
face.checkVersions('dsh-better-sidebar')
|
|
await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']?.phase).toBe('done') })
|
|
face.openUpdate('dsh-better-sidebar', '0.17.0')
|
|
face.runUpdate()
|
|
await vi.waitFor(() => { expect(state().update).toMatchObject({ phase: 'done', result: null, lost: 'socket closed' }) })
|
|
const late = deferred<ReturnType<typeof ok<typeof UPDATED>>>()
|
|
updateBundle.mockReturnValueOnce(late.promise)
|
|
await vi.waitFor(() => { expect(listVersions).toHaveBeenCalledTimes(2) })
|
|
await vi.waitFor(() => { expect(state().versions['dsh-better-sidebar']?.phase).toBe('done') })
|
|
face.closeUpdate()
|
|
face.openUpdate('dsh-better-sidebar', '0.17.0')
|
|
face.runUpdate()
|
|
controller.dispose()
|
|
late.resolve(ok(UPDATED))
|
|
await late.promise
|
|
expect(state().update?.phase).toBe('running')
|
|
})
|
|
})
|
|
|
|
|
|
it.each(['bundle', 'row'] as const)('publishes %s activity before dispatch and settles it without a mounted page', async (kind) => {
|
|
const f = bench()
|
|
await f.controller.load()
|
|
const activity = f.controller.observeChanges(BUNDLE.name)
|
|
const other = f.controller.observeChanges('another-bundle')
|
|
const response = Promise.withResolvers<ReturnType<typeof ok<ChangeResult>>>()
|
|
onTestFinished(() => { response.resolve(ok(APPLIED)) })
|
|
const remote = kind === 'bundle' ? f.plugins.setBundleEnabled : f.plugins.setPluginEnabled
|
|
remote.mockImplementationOnce(() => {
|
|
expect(activity.getSnapshot()).toBe(true)
|
|
expect(other.getSnapshot()).toBe(false)
|
|
return response.promise
|
|
})
|
|
const observations: boolean[] = []
|
|
const unsubscribe = activity.subscribe(() => { observations.push(activity.getSnapshot()) })
|
|
onTestFinished(unsubscribe)
|
|
expect(activity.getSnapshot()).toBe(false)
|
|
if (kind === 'bundle') f.face.setEnabled(BUNDLE.name, true)
|
|
else f.face.setRowEnabled(ROW_ENTRY, false)
|
|
expect(observations).toContain(true)
|
|
response.resolve(ok(APPLIED))
|
|
await vi.waitFor(() => { expect(activity.getSnapshot()).toBe(false) })
|
|
expect(observations.at(-1)).toBe(false)
|
|
unsubscribe()
|
|
const count = observations.length
|
|
await f.controller.load()
|
|
expect(observations).toHaveLength(count)
|
|
})
|