hermes-agent/tests-js/assistant-ui-tap-compat.test.ts

176 lines
6.3 KiB
TypeScript

/**
* Invariant: the @assistant-ui dependency cluster agrees on one tap version.
*
* The Hermes desktop app (``apps/desktop``) is built from source on every
* install/update via ``scripts/install.ps1`` → ``npm ci``/``npm install`` →
* ``tsc -b && vite build``. The ``@assistant-ui`` packages share an internal
* reactivity lib, ``@assistant-ui/tap``, and they only interoperate when they
* all resolve the *same* tap version:
*
* - ``@assistant-ui/react@0.12.28`` and ``@assistant-ui/core`` pin
* ``@assistant-ui/tap@^0.5.x`` (which exports ``.`` and ``./react``).
* - ``@assistant-ui/store@0.2.18`` bumped its tap peer to ``^0.9.0`` and started
* importing ``@assistant-ui/tap/react-shim`` — an entry point that only exists
* in the tap ``0.9.x`` line.
*
* Because ``react@0.12.28`` requests ``store@^0.2.9`` (a caret range), a fresh
* install silently floated ``store`` up to ``0.2.18``, which then could not find
* ``./react-shim`` in the hoisted ``tap@0.5.x`` and crashed ``vite build`` with:
*
* "./react-shim" is not exported ... from package @assistant-ui/tap
*
* i.e. the opaque "apps/desktop build failed (exit 1)" every user hit when
* updating. The fix pins ``@assistant-ui/store`` (via root ``overrides``) to the
* last release that targets ``tap@^0.5.x``.
*
* This is a *contract* test, not a snapshot: it does not assert specific version
* numbers, only that the cluster resolves a single shared tap (wherever npm
* places it — hoisted to root, or nested under the ``apps/desktop`` workspace
* since the 0.14 bump dropped the ``store`` override) and that this tap satisfies
* every ``@assistant-ui/*`` package's declared requirement. It fails if any
* future bump reintroduces a split tap version or requirement across the cluster.
*/
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { test } from 'vitest'
const REPO_ROOT = path.resolve(__dirname, '..')
const LOCK_PATH = path.join(REPO_ROOT, 'package-lock.json')
const TAP = '@assistant-ui/tap'
/**
* Minimal npm semver check for the ranges this cluster actually uses.
*
* Supports exact versions, ``^x.y.z`` (with correct 0.x semantics), and
* ``||`` unions. Pre-release tags are ignored (none are used here).
*/
function caretSatisfies(version: string, spec: string): boolean {
function parse(v: string): [number, number, number] {
const core = v.replace(/^[^0-9]+/, '').split('-')[0].split('+')[0]
const parts = core.split('.').slice(0, 3)
while (parts.length < 3) {parts.push('0')}
return [parseInt(parts[0], 10), parseInt(parts[1], 10), parseInt(parts[2], 10)]
}
const ver = parse(version)
for (const clause of spec.split('||')) {
const trimmed = clause.trim()
if (!trimmed) {continue}
if (trimmed.startsWith('^')) {
const lo = parse(trimmed)
if (ver[0] < lo[0] || (ver[0] === lo[0] && ver[1] < lo[1]) || (ver[0] === lo[0] && ver[1] === lo[1] && ver[2] < lo[2])) {continue}
let hi: [number, number, number]
if (lo[0] > 0) {
hi = [lo[0] + 1, 0, 0]
} else if (lo[1] > 0) {
hi = [0, lo[1] + 1, 0]
} else {
hi = [0, 0, lo[2] + 1]
}
if (
(ver[0] < hi[0]) ||
(ver[0] === hi[0] && ver[1] < hi[1]) ||
(ver[0] === hi[0] && ver[1] === hi[1] && ver[2] < hi[2])
) {
return true
}
} else if (trimmed[0].match(/\d/) || trimmed.startsWith('v')) {
if (ver[0] === parse(trimmed)[0] && ver[1] === parse(trimmed)[1] && ver[2] === parse(trimmed)[2]) {
return true
}
}
}
return false
}
interface LockPackage {
version?: string
dependencies?: Record<string, string>
peerDependencies?: Record<string, string>
peerDependenciesMeta?: Record<string, { optional?: boolean }>
}
function lockPackages(): Record<string, LockPackage> {
if (!fs.existsSync(LOCK_PATH)) {return {}}
const lock = JSON.parse(fs.readFileSync(LOCK_PATH, 'utf-8'))
return (lock.packages ?? {}) as Record<string, LockPackage>
}
function sharedTapVersion(packages: Record<string, LockPackage>): string {
/** The one tap version every install site resolves to. */
const versions = new Set<string>()
for (const [key, meta] of Object.entries(packages)) {
const idx = key.lastIndexOf('node_modules/')
const name = idx >= 0 ? key.slice(idx + 'node_modules/'.length) : key
if (name === TAP) {
if (meta.version) {versions.add(meta.version)}
}
}
assert.ok(versions.size > 0, 'package-lock.json has no @assistant-ui/tap entry — the @assistant-ui cluster should resolve a single shared tap version.')
assert.ok(versions.size === 1, `@assistant-ui/tap resolves to multiple versions ${[...versions].sort()} — the cluster must share one tap line (see this test's docstring).`)
return [...versions][0]!
}
test('every @assistant-ui/* package\'s tap requirement is satisfiable', () => {
const packages = lockPackages()
if (Object.keys(packages).length === 0) {return} // lockfile not materialized
const tapVersion = sharedTapVersion(packages)
const offenders: string[] = []
for (const [key, meta] of Object.entries(packages)) {
const idx = key.lastIndexOf('node_modules/')
const name = idx >= 0 ? key.slice(idx + 'node_modules/'.length) : key
if (!name.startsWith('@assistant-ui/') || name === TAP) {continue}
const peerMeta = (meta.peerDependenciesMeta ?? {})[TAP]
if (peerMeta?.optional) {continue}
const spec = (meta.dependencies ?? {})[TAP] || (meta.peerDependencies ?? {})[TAP]
if (!spec) {continue}
if (!caretSatisfies(tapVersion, spec)) {
offenders.push(`${name} requires ${TAP}"${spec}"`)
}
}
assert.deepEqual(
offenders,
[],
`Hoisted ${TAP}@${tapVersion} does not satisfy: ` +
offenders.join('; ') +
'. The @assistant-ui cluster has split tap requirements — pin the ' +
'offending package (e.g. via root package.json `overrides`) so the ' +
'whole cluster shares one tap line. See this test\'s module docstring.'
)
})
test('caretSatisfies helper', () => {
assert.ok(caretSatisfies('0.5.14', '^0.5.10'))
assert.ok(caretSatisfies('0.5.14', '^0.5.14'))
assert.ok(!caretSatisfies('0.5.14', '^0.9.0'))
assert.ok(!caretSatisfies('0.5.14', '^0.6.0'))
assert.ok(caretSatisfies('1.2.5', '^1.2.0'))
assert.ok(!caretSatisfies('2.0.0', '^1.2.0'))
assert.ok(caretSatisfies('0.5.14', '^0.5.0 || ^0.9.0'))
})