skillhub/cli/test/integration/concurrency.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

146 lines
5.9 KiB
TypeScript

/**
* Concurrency tests for inventory.json bookkeeping.
*
* inventory-store.ts uses an OS-level lock file with retry + stale-lock
* detection. These tests exercise that path through real CLI subprocesses
* (Bun.spawn) running in parallel — the same way users hit it when scripts
* fan out installs.
*
* The unit test in test/unit/stores/inventory-store.test.ts pins the
* single-process lock recovery; here we cover the cross-process case.
*/
import { access, mkdir, readFile, readdir, utimes } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, describe, expect, test } from 'bun:test'
import { zipSync, strToU8 } from 'fflate'
import { startFakeRegistry } from '../helpers/fake-registry'
import { runCli } from '../helpers/run-cli'
import { createTempHome } from '../helpers/temp-env'
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
afterEach(() => {
registry?.stop(); registry = undefined
})
function makeSkillZip(): Uint8Array {
return zipSync({ 'SKILL.md': strToU8('# c') })
}
describe('cross-process concurrency on inventory.json', () => {
test('two parallel installs recover the same stale lock and preserve both inventory items', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [
{ namespace: 'global', slug: 'first', version: '1.0.0', zipBytes: makeSkillZip() },
{ namespace: 'global', slug: 'second', version: '1.0.0', zipBytes: makeSkillZip() }
]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const staleLockPath = join(env.home, '.skillhub', 'inventory.json.lock')
await mkdir(staleLockPath)
const staleTime = new Date(Date.now() - 60_000)
await utimes(staleLockPath, staleTime, staleTime)
const dirA = join(env.cwd, 'A')
const dirB = join(env.cwd, 'B')
await mkdir(dirA, { recursive: true })
await mkdir(dirB, { recursive: true })
const [r1, r2] = await Promise.all([
runCli(
['install', 'first', '--dir', dirA, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
),
runCli(
['install', 'second', '--dir', dirB, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
])
expect(r1.exitCode).toBe(0)
expect(r2.exitCode).toBe(0)
// Filesystem is correct: both bundles extracted independently.
expect(await Bun.file(join(dirA, 'first', 'SKILL.md')).exists()).toBe(true)
expect(await Bun.file(join(dirB, 'second', 'SKILL.md')).exists()).toBe(true)
const inv = JSON.parse(
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string }> }
const slugs = inv.items.map(i => i.slug).sort()
expect(slugs).toEqual(['first', 'second'])
await expect(access(staleLockPath)).rejects.toThrow()
expect((await readdir(join(env.home, '.skillhub')))
.filter(name => name.startsWith('inventory.json.') && name.endsWith('.tmp'))).toEqual([])
})
test('two parallel installs of the same slug to the same dir: exactly one wins, one conflicts', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'race', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
const installDir = join(env.cwd, 'race-dir')
await mkdir(installDir, { recursive: true })
const [r1, r2] = await Promise.all([
runCli(
['install', 'race', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
),
runCli(
['install', 'race', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
])
const codes = [r1.exitCode, r2.exitCode].sort((a, b) => a - b)
expect(codes).toEqual([0, 4])
const inv = JSON.parse(
await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string; targets: Array<{ installDir: string }> }> }
const item = inv.items.find(i => i.slug === 'race')
expect(item).toBeDefined()
expect(item!.targets).toHaveLength(1) // no duplicate targets
})
test('install proceeds after a stale lock file from a dead process', async () => {
const env = await createTempHome()
registry = await startFakeRegistry({
token: 'sk_ok',
user: { handle: 'u', displayName: 'U' },
skills: [{ namespace: 'global', slug: 'after-stale', version: '1.0.0', zipBytes: makeSkillZip() }]
})
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
// Plant a stale proper-lockfile directory.
const skillhubDir = join(env.home, '.skillhub')
await mkdir(skillhubDir, { recursive: true })
const lockPath = join(skillhubDir, 'inventory.json.lock')
await mkdir(lockPath)
const staleTime = new Date(Date.now() - 60_000)
await utimes(lockPath, staleTime, staleTime)
const installDir = join(env.cwd, 'stale')
await mkdir(installDir, { recursive: true })
const result = await runCli(
['install', 'after-stale', '--dir', installDir, '--registry', registry.url, '--token', 'sk_ok'],
{ HOME: env.home, USERPROFILE: env.home }
)
expect(result.exitCode).toBe(0)
const inv = JSON.parse(
await readFile(join(skillhubDir, 'inventory.json'), 'utf-8')
) as { items: Array<{ slug: string }> }
expect(inv.items.find(i => i.slug === 'after-stale')).toBeDefined()
})
})