skillhub/cli/test/integration/version-upgrade-flow.test.ts
XiaoSeS 6ab8faa6b9
feat(cli): add source-safe skill upgrades (#796)
* feat(cli): add source-safe skill upgrades

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): harden skill upgrade lifecycle

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): make multi-target upgrades failure-safe

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): cover upgrade selection and fallback boundaries

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): use a dead pid for stale lock recovery

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): prove upgrade safety invariants

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): prove manual ownership remains untouched

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): bind ownership sentinels to each fixture

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): keep upgrade assertions registry-scoped

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): close upgrade commit races

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): keep rollback backup path narrowed

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): reject upgrade targets removed after planning

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): serialize remove with target upgrades

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): prove shared target lock cleanup

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): make stale target lock recovery ownership-safe

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): use proven cross-process target locks

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): synchronize target lock contenders

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): bound target lock worker cleanup

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): satisfy target lock worker lint

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): serialize inventory and alias target mutations

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): cover lock root safety boundaries

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): keep target lock identity stable

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): report partial upgrade failures

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): preserve committed upgrade results

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): surface install lifecycle warnings

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): prove lifecycle warning outputs

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): assert structured upgrade failures

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): keep portable install paths

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* fix(cli): unify aliased target identity

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): accept canonical relative paths

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): verify portable target identity

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

* test(cli): synchronize stale lock contenders

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>

---------

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
2026-09-02 10:50:09 +08:00

152 lines
6.1 KiB
TypeScript

/**
* CLI skill version-upgrade flow.
*
* These tests cover scenarios that span multiple `install` invocations
* against a registry whose state changes between runs — i.e. the user-facing
* "upgrade an installed skill" workflow. They complement the per-command
* tests in install-command.test.ts which use a single static registry.
*
* Coverage focus (per test-case-design-skill methodology):
* - VU1 (state-transition): full v1 → v2 upgrade lifecycle. Asserts that
* metadata.json, inventory.json AND the on-disk bundle all reflect v2
* after `install --force`, with no orphaned v1 entry left behind.
* - VU2 (equivalence-class on `--version`): pinning to a specific version
* forwards `?version=` to /resolve so the registry can serve the
* intended bundle.
*/
import { mkdir, readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { zipSync, strToU8 } from 'fflate'
import { createTempHome } from '../helpers/temp-env'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
function makeSkillZipWithBody(body: string): Uint8Array {
return zipSync({ 'SKILL.md': strToU8(body) })
}
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop()
registry = undefined
})
describe('version upgrade flow', () => {
// -------------------------------------------------------------------------
// VU1 — full upgrade lifecycle:
// 1. Registry serves pdf-parser@1.0.0 → install → metadata=v1, content=v1
// 2. The same registry serves pdf-parser@2.0.0
// 3. Install --force from the same source identity
// 4. metadata.json, inventory.json AND on-disk SKILL.md all reflect v2
// -------------------------------------------------------------------------
test('VU1 install v1 then upgrade to v2 with --force replaces metadata, inventory, and content', async () => {
const env = await createTempHome()
// --- Stage 1: install v1 ----------------------------------------------
const skill = {
namespace: 'global',
slug: 'pdf-parser',
version: '1.0.0',
versionId: 1,
zipBytes: makeSkillZipWithBody('# pdf-parser v1\n\nVersion one body.')
}
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [skill]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'skills-upgrade')
await mkdir(installDir, { recursive: true })
const r1 = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r1.exitCode).toBe(0)
const skillFile = join(installDir, 'pdf-parser', 'SKILL.md')
const metaPath = join(installDir, 'pdf-parser', '.skillhub', 'metadata.json')
const inventoryPath = join(env.home, '.skillhub', 'inventory.json')
{
const meta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(meta.version).toBe('1.0.0')
const body = await readFile(skillFile, 'utf-8')
expect(body).toContain('Version one body.')
}
// --- Stage 2: publish v2 from the same source --------------------------
skill.version = '2.0.0'
skill.versionId = 2
skill.zipBytes = makeSkillZipWithBody('# pdf-parser v2\n\nVersion two body.')
const r2 = await runCli(
['install', 'pdf-parser', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok', '--force'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(r2.exitCode).toBe(0)
// --- Stage 3: assert end state ----------------------------------------
const finalMeta = JSON.parse(await readFile(metaPath, 'utf-8'))
expect(finalMeta.version).toBe('2.0.0')
expect(finalMeta.registry).toBe(registry.url)
const finalBody = await readFile(skillFile, 'utf-8')
expect(finalBody).toContain('Version two body.')
expect(finalBody).not.toContain('Version one body.')
const inventory = JSON.parse(await readFile(inventoryPath, 'utf-8')) as {
items: Array<{ namespace: string; slug: string; version: string; targets: Array<{ installDir: string }> }>
}
const matching = inventory.items.filter(i => i.namespace === 'global' && i.slug === 'pdf-parser')
expect(matching).toHaveLength(1) // no duplicate v1 entry
expect(matching[0]?.version).toBe('2.0.0')
})
// -------------------------------------------------------------------------
// VU2 — version pinning:
// `install --version=X` must forward ?version=X to /resolve so the
// registry can serve the requested bundle. The fake registry captures
// the resolve query for assertion.
// -------------------------------------------------------------------------
test('VU2 --version pins resolve to the requested version string', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u1', displayName: 'User One' },
skills: [{
namespace: 'global',
slug: 'pdf-parser',
version: '1.5.0',
zipBytes: makeSkillZipWithBody('# pinned')
}]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'skills-pin')
await mkdir(installDir, { recursive: true })
const result = await runCli(
[
'install', 'pdf-parser',
'--version', '1.5.0',
'--dir', installDir,
'--registry', registry.url,
'--token', 'sk_ok'
],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
expect(registry.received.resolve?.namespace).toBe('global')
expect(registry.received.resolve?.slug).toBe('pdf-parser')
expect(registry.received.resolve?.version).toBe('1.5.0')
const meta = JSON.parse(await readFile(join(installDir, 'pdf-parser', '.skillhub', 'metadata.json'), 'utf-8'))
expect(meta.version).toBe('1.5.0')
})
})