diff --git a/package-lock.json b/package-lock.json index 3d248556a497..d17a5f9d16f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "apps/*", "ui-tui", "ui-tui/packages/*", - "web" + "web", + "tests-js" ], "dependencies": { "@streamdown/math": "^1.0.2", @@ -19678,6 +19679,15 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" + }, + "tests-js": { + "name": "@hermes/root-tests", + "version": "0.0.0", + "dev": true + }, + "node_modules/@hermes/root-tests": { + "resolved": "tests-js", + "link": true } } } diff --git a/package.json b/package.json index 03d4579ce269..fe4bf346b66e 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "apps/*", "ui-tui", "ui-tui/packages/*", - "web" + "web", + "tests-js" ], "scripts": { "postinstall": "echo '✅ Browser tools ready. Run: python run_agent.py --help'", diff --git a/apps/desktop/electron/assistant-ui-tap-compat.test.ts b/tests-js/assistant-ui-tap-compat.test.ts similarity index 91% rename from apps/desktop/electron/assistant-ui-tap-compat.test.ts rename to tests-js/assistant-ui-tap-compat.test.ts index afbca904ee78..4c6390da814d 100644 --- a/apps/desktop/electron/assistant-ui-tap-compat.test.ts +++ b/tests-js/assistant-ui-tap-compat.test.ts @@ -37,7 +37,7 @@ import path from 'node:path' import { test } from 'vitest' -const REPO_ROOT = path.resolve(__dirname, '..', '..', '..') +const REPO_ROOT = path.resolve(__dirname, '..') const LOCK_PATH = path.join(REPO_ROOT, 'package-lock.json') const TAP = '@assistant-ui/tap' @@ -51,18 +51,25 @@ 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') + + 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) {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 + + 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) { @@ -70,6 +77,7 @@ function caretSatisfies(version: string, spec: string): boolean { } else { hi = [0, 0, lo[2] + 1] } + if ( (ver[0] < hi[0]) || (ver[0] === hi[0] && ver[1] < hi[1]) || @@ -83,6 +91,7 @@ function caretSatisfies(version: string, spec: string): boolean { } } } + return false } @@ -94,39 +103,52 @@ interface LockPackage { } function lockPackages(): Record { + if (!fs.existsSync(LOCK_PATH)) {return {}} const lock = JSON.parse(fs.readFileSync(LOCK_PATH, 'utf-8')) + return (lock.packages ?? {}) as Record } function sharedTapVersion(packages: Record): string { /** The one tap version every install site resolves to. */ const versions = new Set() + 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) + 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 + + if (!name.startsWith('@assistant-ui/') || name === TAP) {continue} const peerMeta = (meta.peerDependenciesMeta ?? {})[TAP] - if (peerMeta?.optional) continue + + if (peerMeta?.optional) {continue} const spec = (meta.dependencies ?? {})[TAP] || (meta.peerDependencies ?? {})[TAP] - if (!spec) continue + + if (!spec) {continue} + if (!caretSatisfies(tapVersion, spec)) { offenders.push(`${name} requires ${TAP}"${spec}"`) } diff --git a/tests-js/eslint.config.mjs b/tests-js/eslint.config.mjs new file mode 100644 index 000000000000..65dcae46bc46 --- /dev/null +++ b/tests-js/eslint.config.mjs @@ -0,0 +1,5 @@ +import shared from '../eslint.config.shared.mjs' + +export default [ + ...shared +] diff --git a/apps/desktop/electron/package-json-lazy-deps.test.ts b/tests-js/package-json-lazy-deps.test.ts similarity index 98% rename from apps/desktop/electron/package-json-lazy-deps.test.ts rename to tests-js/package-json-lazy-deps.test.ts index b0c80c77ee28..d00491f6e1a6 100644 --- a/apps/desktop/electron/package-json-lazy-deps.test.ts +++ b/tests-js/package-json-lazy-deps.test.ts @@ -35,7 +35,7 @@ import path from 'node:path' import { test } from 'vitest' -const REPO_ROOT = path.resolve(__dirname, '..', '..', '..') +const REPO_ROOT = path.resolve(__dirname, '..') const ROOT_PKG = path.join(REPO_ROOT, 'package.json') const ROOT_LOCK = path.join(REPO_ROOT, 'package-lock.json') @@ -72,6 +72,7 @@ test('root lockfile has no camofox entries', () => { // Some CI matrix shards skip lockfile materialization. return } + const text = fs.readFileSync(ROOT_LOCK, 'utf-8') assert.ok( !text.includes('@askjo/camofox-browser'), diff --git a/tests-js/package.json b/tests-js/package.json new file mode 100644 index 000000000000..692d02e13790 --- /dev/null +++ b/tests-js/package.json @@ -0,0 +1,18 @@ +{ + "name": "@hermes/root-tests", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json", + "test": "vitest run", + "check": "npm run typecheck && npm run test", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "fix": "npm run lint:fix" + }, + "devDependencies": { + "eslint": "^9.39.4", + "typescript": "^6.0.3", + "vitest": "^4.1.9" + } +} diff --git a/tests-js/tsconfig.json b/tests-js/tsconfig.json new file mode 100644 index 000000000000..61bea60736a2 --- /dev/null +++ b/tests-js/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/tests-js/vitest.config.ts b/tests-js/vitest.config.ts new file mode 100644 index 000000000000..5d654e7e30d4 --- /dev/null +++ b/tests-js/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + environment: 'node', + include: ['**/*.test.ts'], + }, +})