Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Invariants tying ``allowScripts`` to the lockfile it gates.
|
||||
*
|
||||
* npm's ``allowScripts`` allowlist is keyed by exact ``name@version``, so an
|
||||
* entry silently stops matching the moment that dependency is bumped. Nothing
|
||||
* else in the build notices: npm downgrades the blocked script to a warning
|
||||
* buried in install output, and the failure only surfaces much later as a
|
||||
* missing native artifact.
|
||||
*
|
||||
* That has now bitten twice on Windows. ``get-windows`` was added to
|
||||
* ``apps/desktop`` without an allow entry, so its node-pre-gyp install script
|
||||
* never downloaded the win32 binding and ``hermes desktop`` died in
|
||||
* ``stage-native-deps``. In the same window, a CVE sweep moved Electron to
|
||||
* 40.10.6 and left the ``electron@40.10.2`` pin behind, blocking Electron's
|
||||
* own postinstall on any clean install.
|
||||
*
|
||||
* Two contracts keep the allowlist honest:
|
||||
*
|
||||
* - Every versioned pin names a version the lockfile actually resolves, so a
|
||||
* dependency bump that orphans its pin fails here instead of in a user's
|
||||
* build.
|
||||
* - Every package the lockfile marks as having an install script is covered
|
||||
* by a decision — allowed at its exact version, or denied by name.
|
||||
*
|
||||
* A bare-name key (no ``@version``) is a deliberate standing decision that
|
||||
* survives version bumps, which is how ``unicode-animations: false`` stays a
|
||||
* permanent denial.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { describe, test } from 'vitest'
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..')
|
||||
|
||||
const MANIFESTS = [
|
||||
{ name: 'root', dir: '.' },
|
||||
{ name: 'website', dir: 'website' }
|
||||
]
|
||||
|
||||
function manifestLabel(dir: string): string {
|
||||
return path.join(dir === '.' ? '' : dir, 'package.json')
|
||||
}
|
||||
|
||||
interface LockPackage {
|
||||
name?: string
|
||||
version?: string
|
||||
hasInstallScript?: boolean
|
||||
}
|
||||
|
||||
function readJson(filePath: string): Record<string, unknown> {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'))
|
||||
}
|
||||
|
||||
function packageNameFor(lockPath: string, entry: LockPackage): string {
|
||||
return entry.name ?? lockPath.split('node_modules/').pop() ?? lockPath
|
||||
}
|
||||
|
||||
/** Every version of every package the lockfile installs, keyed by name. */
|
||||
function installedVersions(lock: Record<string, unknown>): Map<string, Set<string>> {
|
||||
const versions = new Map<string, Set<string>>()
|
||||
|
||||
for (const [lockPath, entry] of Object.entries(
|
||||
(lock.packages ?? {}) as Record<string, LockPackage>
|
||||
)) {
|
||||
if (!lockPath || !entry.version) {
|
||||
continue
|
||||
}
|
||||
|
||||
const name = packageNameFor(lockPath, entry)
|
||||
const seen = versions.get(name) ?? new Set<string>()
|
||||
|
||||
seen.add(entry.version)
|
||||
versions.set(name, seen)
|
||||
}
|
||||
|
||||
return versions
|
||||
}
|
||||
|
||||
function splitPin(key: string): { name: string; version: string } | null {
|
||||
// Scoped packages carry a leading @, so match the LAST @ as the separator.
|
||||
const match = key.match(/^(.+)@([^@]+)$/)
|
||||
|
||||
return match ? { name: match[1], version: match[2] } : null
|
||||
}
|
||||
|
||||
describe.each(MANIFESTS)('$name allowScripts', ({ dir }) => {
|
||||
const manifestPath = path.join(REPO_ROOT, dir, 'package.json')
|
||||
const lockPath = path.join(REPO_ROOT, dir, 'package-lock.json')
|
||||
const label = manifestLabel(dir)
|
||||
|
||||
test('every versioned pin matches a version in the lockfile', () => {
|
||||
if (!fs.existsSync(lockPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
const allow = (readJson(manifestPath).allowScripts ?? {}) as Record<string, boolean>
|
||||
const versions = installedVersions(readJson(lockPath))
|
||||
const stale: string[] = []
|
||||
|
||||
for (const key of Object.keys(allow)) {
|
||||
const pin = splitPin(key)
|
||||
|
||||
if (!pin) {
|
||||
continue
|
||||
}
|
||||
|
||||
const installed = versions.get(pin.name)
|
||||
|
||||
if (!installed?.has(pin.version)) {
|
||||
stale.push(` "${key}" — lockfile resolves ${pin.name} to ${installed ? [...installed].join(', ') : '<nothing>'}`)
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
stale,
|
||||
[],
|
||||
`Stale allowScripts entries in ${label}:\n${stale.join('\n')}\n` +
|
||||
"npm matches these by exact version, so each package's install script is " +
|
||||
'silently blocked. Update the pin to the installed version, or drop the ' +
|
||||
'entry if the dependency is gone.'
|
||||
)
|
||||
})
|
||||
|
||||
test('every package with an install script has an allowScripts decision', () => {
|
||||
if (!fs.existsSync(lockPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
const allow = (readJson(manifestPath).allowScripts ?? {}) as Record<string, boolean>
|
||||
const lock = readJson(lockPath)
|
||||
const uncovered: string[] = []
|
||||
|
||||
for (const [entryPath, entry] of Object.entries(
|
||||
(lock.packages ?? {}) as Record<string, LockPackage>
|
||||
)) {
|
||||
if (!entryPath || !entry.hasInstallScript) {
|
||||
continue
|
||||
}
|
||||
|
||||
const name = packageNameFor(entryPath, entry)
|
||||
|
||||
if (!(`${name}@${entry.version}` in allow) && !(name in allow)) {
|
||||
uncovered.push(` ${name}@${entry.version}`)
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
uncovered,
|
||||
[],
|
||||
`Packages with install scripts and no allowScripts decision in ${label}:\n` +
|
||||
`${uncovered.join('\n')}\n` +
|
||||
'npm blocks these. Add "<name>@<version>": true to allow, or "<name>": false ' +
|
||||
'to deny permanently.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 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'))
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Regression for the bootstrap installer's stage timers (issue report:
|
||||
* "it says 744 hours or minutes — I don't know how to adjust the counter").
|
||||
*
|
||||
* ``formatElapsed`` rendered a running stage as ``m:ss`` with unbounded
|
||||
* minutes: a node-deps stage left hanging overnight showed ``744:38`` (12h24m
|
||||
* of minutes), which the user read as 744 hours. ``formatDuration`` (completed
|
||||
* stages) had the same unbounded-minutes shape. These pin the hour rollover.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
formatDuration,
|
||||
formatElapsed,
|
||||
} from '../apps/bootstrap-installer/src/lib/format'
|
||||
|
||||
const SEC = 1000
|
||||
const MIN = 60 * SEC
|
||||
const HOUR = 60 * MIN
|
||||
|
||||
describe('formatElapsed (live stage timer)', () => {
|
||||
it('renders bare seconds under a minute', () => {
|
||||
expect(formatElapsed(0)).toBe('0s')
|
||||
expect(formatElapsed(59 * SEC)).toBe('59s')
|
||||
})
|
||||
|
||||
it('renders m:ss between a minute and an hour', () => {
|
||||
expect(formatElapsed(MIN)).toBe('1:00')
|
||||
expect(formatElapsed(12 * MIN + 38 * SEC)).toBe('12:38')
|
||||
expect(formatElapsed(59 * MIN + 59 * SEC)).toBe('59:59')
|
||||
})
|
||||
|
||||
it('rolls over to h:mm:ss past an hour', () => {
|
||||
expect(formatElapsed(HOUR)).toBe('1:00:00')
|
||||
// The overnight-hang report: 744 minutes 38 seconds must NOT render as
|
||||
// "744:38".
|
||||
expect(formatElapsed(744 * MIN + 38 * SEC)).toBe('12:24:38')
|
||||
})
|
||||
|
||||
it('clamps negative input (clock skew) to zero', () => {
|
||||
expect(formatElapsed(-5 * SEC)).toBe('0s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDuration (completed stage)', () => {
|
||||
it('keeps the sub-hour shapes', () => {
|
||||
expect(formatDuration(999)).toBe('999ms')
|
||||
expect(formatDuration(1500)).toBe('1.5s')
|
||||
expect(formatDuration(2 * MIN + 5 * SEC)).toBe('2m 5s')
|
||||
})
|
||||
|
||||
it('rolls over to hours past 60 minutes', () => {
|
||||
expect(formatDuration(HOUR)).toBe('1h 0m')
|
||||
expect(formatDuration(12 * HOUR + 24 * MIN)).toBe('12h 24m')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Regression for #37718: macOS microphone entitlement must be inherited.
|
||||
*
|
||||
* Hermes Desktop signs with ``hardenedRuntime: true`` and points
|
||||
* electron-builder at two entitlement files (see ``apps/desktop/package.json``):
|
||||
*
|
||||
* - ``entitlements`` → ``electron/entitlements.mac.plist`` (the main app), and
|
||||
* - ``entitlementsInherit`` → ``electron/entitlements.mac.inherit.plist``
|
||||
* (the Electron Helper / Setup processes).
|
||||
*
|
||||
* Under the hardened runtime, the process that actually opens the microphone
|
||||
* is a Helper, which inherits the *inherit* plist.
|
||||
* ``com.apple.security.device.audio-input`` lived only in the main plist, so
|
||||
* macOS' TCC layer refused the microphone with:
|
||||
*
|
||||
* Prompting policy for hardened runtime; service: kTCCServiceMicrophone
|
||||
* requires entitlement com.apple.security.device.audio-input but it is missing
|
||||
*
|
||||
* and never showed the permission prompt. These tests pin that every device
|
||||
* entitlement granted to the main app is also granted to the inherited helpers.
|
||||
*
|
||||
* (Ported from tests/test_desktop_mac_entitlements.py — plist assertions about
|
||||
* apps/desktop/electron/*.plist belong in the JS lane because the CI change
|
||||
* classifier skips the Python suite on apps/-only PRs.)
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import * as plist from 'plist'
|
||||
import { test } from 'vitest'
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..')
|
||||
const ELECTRON_DIR = path.join(REPO_ROOT, 'apps', 'desktop', 'electron')
|
||||
const MAIN_PLIST = path.join(ELECTRON_DIR, 'entitlements.mac.plist')
|
||||
const INHERIT_PLIST = path.join(ELECTRON_DIR, 'entitlements.mac.inherit.plist')
|
||||
const BOOTSTRAP_TAURI_DIR = path.join(REPO_ROOT, 'apps', 'bootstrap-installer', 'src-tauri')
|
||||
const BOOTSTRAP_TAURI_CONFIG = path.join(BOOTSTRAP_TAURI_DIR, 'tauri.conf.json')
|
||||
const BOOTSTRAP_ENTITLEMENTS = path.join(BOOTSTRAP_TAURI_DIR, 'entitlements.plist')
|
||||
const BOOTSTRAP_INFO_PLIST = path.join(BOOTSTRAP_TAURI_DIR, 'Info.plist')
|
||||
|
||||
const DEVICE_PREFIX = 'com.apple.security.device.'
|
||||
|
||||
function loadEntitlements(plistPath: string): Record<string, unknown> {
|
||||
assert.ok(fs.existsSync(plistPath), `missing entitlements file: ${plistPath}`)
|
||||
const data = plist.parse(fs.readFileSync(plistPath, 'utf-8'))
|
||||
assert.ok(
|
||||
typeof data === 'object' && data !== null && !Array.isArray(data),
|
||||
`${path.basename(plistPath)} should parse to a dict`
|
||||
)
|
||||
|
||||
return data as Record<string, unknown>
|
||||
}
|
||||
|
||||
test('inherit plist grants microphone (regression #37718)', () => {
|
||||
const inherit = loadEntitlements(INHERIT_PLIST)
|
||||
assert.equal(
|
||||
inherit['com.apple.security.device.audio-input'],
|
||||
true,
|
||||
'entitlements.mac.inherit.plist must grant ' +
|
||||
'`com.apple.security.device.audio-input`; without it the ' +
|
||||
'hardened-runtime Helper process is denied the microphone and no ' +
|
||||
'TCC prompt appears (#37718).'
|
||||
)
|
||||
})
|
||||
|
||||
test('every device.* entitlement on the main app is also inherited', () => {
|
||||
const main = loadEntitlements(MAIN_PLIST)
|
||||
const inherit = loadEntitlements(INHERIT_PLIST)
|
||||
|
||||
const missing = Object.entries(main)
|
||||
.filter(([key, val]) => key.startsWith(DEVICE_PREFIX) && val === true)
|
||||
.map(([key]) => key)
|
||||
.filter((key) => inherit[key] !== true)
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
'Device entitlements present in entitlements.mac.plist but missing from ' +
|
||||
`entitlements.mac.inherit.plist: ${JSON.stringify(missing)}. ` +
|
||||
'Helper/Setup processes inherit the latter under hardenedRuntime, so ' +
|
||||
'any device access the app needs must be listed in both (#37718).'
|
||||
)
|
||||
})
|
||||
|
||||
for (const plist of [MAIN_PLIST, INHERIT_PLIST]) {
|
||||
test(`${path.basename(plist)} is a well-formed non-empty entitlement dict`, () => {
|
||||
const data = loadEntitlements(plist)
|
||||
assert.ok(
|
||||
Object.keys(data).length > 0,
|
||||
`${path.basename(plist)} should be a non-empty dict`
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
test('bootstrap installer carries microphone entitlement for launcher attribution', () => {
|
||||
const config = JSON.parse(fs.readFileSync(BOOTSTRAP_TAURI_CONFIG, 'utf-8'))
|
||||
assert.equal(
|
||||
config.bundle?.macOS?.entitlements,
|
||||
'entitlements.plist',
|
||||
'the macOS bootstrap installer must sign with its entitlements.plist. ' +
|
||||
'When /Applications/Hermes.app is the setup launcher, macOS TCC treats ' +
|
||||
'com.nousresearch.hermes.setup as the responsible process for the desktop ' +
|
||||
'app it opens; without audio-input on the setup app, microphone access is ' +
|
||||
'denied before a permission prompt can appear.'
|
||||
)
|
||||
|
||||
const entitlements = loadEntitlements(BOOTSTRAP_ENTITLEMENTS)
|
||||
assert.equal(
|
||||
entitlements['com.apple.security.device.audio-input'],
|
||||
true,
|
||||
'bootstrap installer entitlements must grant audio-input because it is the ' +
|
||||
'TCC responsible process for the desktop app launched from the setup fast path'
|
||||
)
|
||||
})
|
||||
|
||||
test('bootstrap installer Info.plist explains microphone usage', () => {
|
||||
const info = plist.parse(fs.readFileSync(BOOTSTRAP_INFO_PLIST, 'utf-8')) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
assert.equal(
|
||||
typeof info.NSMicrophoneUsageDescription,
|
||||
'string',
|
||||
'macOS requires NSMicrophoneUsageDescription before it can prompt for microphone access'
|
||||
)
|
||||
assert.match(
|
||||
info.NSMicrophoneUsageDescription as string,
|
||||
/microphone/i,
|
||||
'microphone usage description should be user-visible and specific'
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Regression for #54551: macOS Info.plist privacy usage descriptions
|
||||
* declared by the Desktop electron-builder config
|
||||
* (`apps/desktop/package.json -> build.mac.extendInfo`) must pin every
|
||||
* `NS*UsageDescription` key the renderer relies on.
|
||||
*
|
||||
* Each entry is a key/value pair that lands in the packaged Hermes.app's
|
||||
* Info.plist via electron-builder's `extendInfo` merge. Missing or mis-stated
|
||||
* keys cause macOS to either silently deny the related API or surface a
|
||||
* mysteriously-worded system permission prompt at runtime (TCC's
|
||||
* `kTCCServiceMediaLibrary`, `kTCCServiceAppleEvents`, etc.).
|
||||
*
|
||||
* The Desktop renderer initializes Chromium's audio stack on user gesture
|
||||
* (completion chimes, TTS playback, voice mode). On macOS 26+, that init can
|
||||
* register the helper with the media subsystem and surface as a
|
||||
* "Hermes wants to access Music" prompt unless the Info.plist disclaims it
|
||||
* explicitly. This test pins every usage-description string the desktop
|
||||
* currently relies on so accidental drops break CI instead of breaking users.
|
||||
*
|
||||
* Why this test lives in tests-js/, not tests/*.py
|
||||
* -------------------------------------------------
|
||||
*
|
||||
* `AGENTS.md:1319-1329` requires assertions about `package.json` and JS-side
|
||||
* artifacts to live in the JS/Vitest suite: the CI change classifier can
|
||||
* skip Python coverage on a JS-only PR (the classifier's `python` lane is
|
||||
* skipped when all paths match `_FRONTEND` or `_PY_SKIP`, both of which
|
||||
* cover `apps/desktop/package.json`). A regression would then go green on
|
||||
* the PR and red on `main` where the classifier fails open. See also
|
||||
* `tests-js/desktop-mac-entitlements.test.ts` which ports an earlier Python
|
||||
* entitlements regression for the same reason.
|
||||
*
|
||||
* Why this test exists
|
||||
* --------------------
|
||||
*
|
||||
* The project has a recurring class of bug: a macOS privacy-sensitive API is
|
||||
* called at runtime, but the Info.plist doesn't declare the corresponding
|
||||
* `NS*UsageDescription` key, so the system prompt is either silent (with a
|
||||
* generic "denied" error to the agent) or worded in a way that confuses the
|
||||
* user ("Hermes wants to access Music" when Hermes never touches the Music
|
||||
* library). The closed-PR family (#59486 / its duplicates #59833, #59915,
|
||||
* #59950, #60013 for Contacts; #39854 for Calendar; #64582 for Reminders)
|
||||
* established that the right fix shape is: add the key + pin it in a test.
|
||||
* This file is the canonical test for that pattern at the Desktop layer.
|
||||
*
|
||||
* When adding a new NS*UsageDescription key to `build.mac.extendInfo`, add a
|
||||
* matching row to EXPECTED_USAGE_DESCRIPTIONS below. The drift-protection
|
||||
* assertion at the bottom of this file will fail otherwise.
|
||||
*/
|
||||
|
||||
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 DESKTOP_PKG = path.join(REPO_ROOT, 'apps', 'desktop', 'package.json')
|
||||
|
||||
interface UsageDescriptionRow {
|
||||
key: string
|
||||
requiredSubstring: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
function desktopPkg(): Record<string, unknown> {
|
||||
assert.ok(fs.existsSync(DESKTOP_PKG), `missing ${DESKTOP_PKG}`)
|
||||
|
||||
return JSON.parse(fs.readFileSync(DESKTOP_PKG, 'utf-8'))
|
||||
}
|
||||
|
||||
function extendInfo(): Record<string, string> {
|
||||
const pkg = desktopPkg()
|
||||
const build = (pkg.build ?? {}) as Record<string, unknown>
|
||||
const mac = (build.mac ?? {}) as Record<string, unknown>
|
||||
assert.ok(
|
||||
typeof mac.extendInfo === 'object' &&
|
||||
mac.extendInfo !== null &&
|
||||
!Array.isArray(mac.extendInfo),
|
||||
'build.mac.extendInfo is missing or invalid in apps/desktop/package.json'
|
||||
)
|
||||
const extend = mac.extendInfo as Record<string, unknown>
|
||||
|
||||
// Narrow to Record<string, string> with a runtime guard — the value type
|
||||
// for NS*UsageDescription is string, but electron-builder's `extendInfo`
|
||||
// accepts arbitrary plist scalars (bool, number, array, object) and we want
|
||||
// a clean assertion error here, not a downstream `value.trim is not a
|
||||
// function` crash in the whitespace test.
|
||||
for (const [key, value] of Object.entries(extend)) {
|
||||
assert.equal(
|
||||
typeof value,
|
||||
'string',
|
||||
`\`${key}\` in build.mac.extendInfo must be a string (got ${typeof value})`
|
||||
)
|
||||
}
|
||||
|
||||
return extend as Record<string, string>
|
||||
}
|
||||
|
||||
// Each entry: Info.plist key, required substring (case-insensitive), and a
|
||||
// plain-language reason. The substring check lets future copy edits pass
|
||||
// while still catching silent drops of the key itself.
|
||||
const EXPECTED_USAGE_DESCRIPTIONS: UsageDescriptionRow[] = [
|
||||
{
|
||||
key: 'NSMicrophoneUsageDescription',
|
||||
requiredSubstring: 'microphone',
|
||||
reason: 'Microphone capture is required for voice input mode.'
|
||||
},
|
||||
{
|
||||
key: 'NSAudioCaptureUsageDescription',
|
||||
requiredSubstring: 'audio',
|
||||
reason: 'Audio capture backs the voice conversation pipeline.'
|
||||
},
|
||||
{
|
||||
key: 'NSCameraUsageDescription',
|
||||
requiredSubstring: 'camera',
|
||||
reason: 'Camera access is requested by plugins/features the user enables.'
|
||||
},
|
||||
{
|
||||
key: 'NSAppleMusicUsageDescription',
|
||||
requiredSubstring: 'Music',
|
||||
reason:
|
||||
"Disclaim MediaLibrary access so the system audio stack does not " +
|
||||
'surface a misleading Apple Music permission prompt ' +
|
||||
'(kTCCServiceMediaLibrary) when the renderer initializes audio for ' +
|
||||
'completion chimes, TTS, or voice.'
|
||||
},
|
||||
{
|
||||
key: 'NSCalendarsUsageDescription',
|
||||
requiredSubstring: 'Calendar',
|
||||
reason: 'Calendar access backs meeting and scheduling support (#64571).'
|
||||
},
|
||||
{
|
||||
key: 'NSCalendarsFullAccessUsageDescription',
|
||||
requiredSubstring: 'Calendar',
|
||||
reason: 'macOS 14+ full-access variant of the calendar declaration.'
|
||||
},
|
||||
{
|
||||
key: 'NSRemindersUsageDescription',
|
||||
requiredSubstring: 'Reminders',
|
||||
reason: 'Reminders access backs personal-assistant scheduling (#64571).'
|
||||
},
|
||||
{
|
||||
key: 'NSRemindersFullAccessUsageDescription',
|
||||
requiredSubstring: 'Reminders',
|
||||
reason: 'macOS 14+ full-access variant of the reminders declaration.'
|
||||
},
|
||||
{
|
||||
key: 'NSScreenCaptureUsageDescription',
|
||||
requiredSubstring: 'screen',
|
||||
reason: 'macOS 15+ periodic screen-recording re-prompts show this copy.'
|
||||
},
|
||||
{
|
||||
key: 'NSLocalNetworkUsageDescription',
|
||||
requiredSubstring: 'local network',
|
||||
reason:
|
||||
'macOS 15+ Local Network Privacy silently denies undeclared apps ' +
|
||||
'(#81563); declaration is required for the prompt to appear at all.'
|
||||
}
|
||||
]
|
||||
|
||||
test.each(EXPECTED_USAGE_DESCRIPTIONS)(
|
||||
'`$key` is declared in build.mac.extendInfo',
|
||||
({ key, requiredSubstring, reason }) => {
|
||||
const info = extendInfo()
|
||||
const value = info[key]
|
||||
|
||||
assert.ok(
|
||||
value !== undefined,
|
||||
`Info.plist privacy usage description \`${key}\` is missing from ` +
|
||||
'apps/desktop/package.json build.mac.extendInfo. macOS will surface ' +
|
||||
'a misleading system prompt or silently deny the related API.\n' +
|
||||
`Reason: ${reason}`
|
||||
)
|
||||
|
||||
assert.ok(
|
||||
value.toLowerCase().includes(requiredSubstring.toLowerCase()),
|
||||
`\`${key}\` exists but does not mention '${requiredSubstring}'. ` +
|
||||
`Current value: ${JSON.stringify(value)}. Reason: ${reason}`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('every extendInfo value is free of leading/trailing whitespace and newlines', () => {
|
||||
const info = extendInfo()
|
||||
|
||||
for (const [key, value] of Object.entries(info)) {
|
||||
assert.equal(
|
||||
value,
|
||||
value.trim(),
|
||||
`\`${key}\` in build.mac.extendInfo has leading/trailing whitespace: ` +
|
||||
JSON.stringify(value)
|
||||
)
|
||||
// electron-builder writes strings as-is; newlines would render as
|
||||
// literal control chars in the macOS prompt.
|
||||
assert.ok(
|
||||
!value.includes('\n') && !value.includes('\r'),
|
||||
`\`${key}\` contains a newline; macOS will render it as a control ` +
|
||||
'character in the system permission prompt.'
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('every NS*UsageDescription in extendInfo is pinned in this test', () => {
|
||||
const info = extendInfo()
|
||||
const declaredKeys = new Set(EXPECTED_USAGE_DESCRIPTIONS.map((row) => row.key))
|
||||
|
||||
// Non-privacy keys (CFBundleDisplayName etc.) are exempt — this test
|
||||
// only governs NS*UsageDescription entries.
|
||||
const privacyKeysInPlist = new Set(
|
||||
Object.keys(info).filter(
|
||||
(k) => k.startsWith('NS') && k.endsWith('UsageDescription')
|
||||
)
|
||||
)
|
||||
|
||||
const missing = [...privacyKeysInPlist].filter((k) => !declaredKeys.has(k))
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`extendInfo declares privacy usage keys ${JSON.stringify(missing.sort())} ` +
|
||||
'that this test does not pin. Add them to EXPECTED_USAGE_DESCRIPTIONS ' +
|
||||
'with a reason, or remove them from the build config.'
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import shared from '../eslint.config.shared.mjs'
|
||||
|
||||
export default [
|
||||
...shared
|
||||
]
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { describe, test } from 'vitest'
|
||||
|
||||
const REPO_ROOT = path.resolve(__dirname, '..')
|
||||
|
||||
interface Manifest {
|
||||
engines?: { node?: string }
|
||||
}
|
||||
|
||||
interface Lockfile {
|
||||
packages?: Record<string, Manifest>
|
||||
}
|
||||
|
||||
function readJson<T>(relativePath: string): T {
|
||||
return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, relativePath), 'utf-8')) as T
|
||||
}
|
||||
|
||||
function parseVersion(version: string): [number, number, number] {
|
||||
const [major = 0, minor = 0, patch = 0] = version.split('-', 1)[0].split('.').map(Number)
|
||||
|
||||
return [major, minor, patch]
|
||||
}
|
||||
|
||||
function compare(left: string, right: string): number {
|
||||
const have = parseVersion(left)
|
||||
const want = parseVersion(right)
|
||||
|
||||
for (let index = 0; index < have.length; index += 1) {
|
||||
if (have[index] !== want[index]) {
|
||||
return have[index] - want[index]
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function satisfiesClause(version: string, clause: string): boolean {
|
||||
assert.match(clause, /^(?:\^|>=|<=|>|<|=)?\d+(?:\.\d+){0,2}$/, `unsupported semver clause: ${clause}`)
|
||||
|
||||
if (clause.startsWith('^')) {
|
||||
const bound = clause.slice(1)
|
||||
|
||||
return parseVersion(version)[0] === parseVersion(bound)[0] && compare(version, bound) >= 0
|
||||
}
|
||||
|
||||
const match = clause.match(/^(>=|<=|>|<|=)?(.+)$/)
|
||||
assert.ok(match)
|
||||
const [, operator = '=', bound] = match
|
||||
const result = compare(version, bound)
|
||||
|
||||
return operator === '>='
|
||||
? result >= 0
|
||||
: operator === '<='
|
||||
? result <= 0
|
||||
: operator === '>'
|
||||
? result > 0
|
||||
: operator === '<'
|
||||
? result < 0
|
||||
: result === 0
|
||||
}
|
||||
|
||||
function satisfiesRange(version: string, range: string): boolean {
|
||||
const alternatives = range.split('||').map(alternative => alternative.trim().split(/\s+/))
|
||||
alternatives.flat().forEach(clause => satisfiesClause(version, clause))
|
||||
|
||||
return alternatives.some(clauses => clauses.every(clause => satisfiesClause(version, clause)))
|
||||
}
|
||||
|
||||
const rootManifest = readJson<Manifest>('package.json')
|
||||
const desktopManifest = readJson<Manifest>('apps/desktop/package.json')
|
||||
const lockfile = readJson<Lockfile>('package-lock.json')
|
||||
|
||||
function nodeRange(manifest: Manifest, label: string): string {
|
||||
assert.ok(manifest.engines?.node, `${label} must declare engines.node`)
|
||||
|
||||
return manifest.engines.node
|
||||
}
|
||||
|
||||
describe('Node engine alignment', () => {
|
||||
const rootRange = nodeRange(rootManifest, 'root package.json')
|
||||
const desktopRange = nodeRange(desktopManifest, 'apps/desktop/package.json')
|
||||
|
||||
test.each(['22.22.0', '22.23.1', '24.11.0', '24.18.2', '26.0.0'])('all workspace manifests accept supported Node %s', version => {
|
||||
assert.ok(satisfiesRange(version, rootRange))
|
||||
assert.ok(satisfiesRange(version, desktopRange))
|
||||
})
|
||||
|
||||
test.each(['22.21.1', '23.0.0', '24.0.0', '24.10.9', '25.2.1'])(
|
||||
'all workspace manifests reject dependency-incompatible Node %s',
|
||||
version => {
|
||||
assert.ok(!satisfiesRange(version, rootRange))
|
||||
assert.ok(!satisfiesRange(version, desktopRange))
|
||||
}
|
||||
)
|
||||
|
||||
test('lockfile workspace mirrors match their manifests', () => {
|
||||
assert.equal(nodeRange(lockfile.packages?.[''] ?? {}, 'root lock entry'), rootRange)
|
||||
assert.equal(nodeRange(lockfile.packages?.['apps/desktop'] ?? {}, 'desktop lock entry'), desktopRange)
|
||||
})
|
||||
|
||||
test.each(['~22.22.0', '22.x', '>=26.0.0-rc.1'])(
|
||||
'the alignment helper rejects unsupported semver clause %s instead of misclassifying it',
|
||||
clause => {
|
||||
assert.throws(() => satisfiesRange('26.0.0', clause), /unsupported semver clause/)
|
||||
}
|
||||
)
|
||||
|
||||
test('unsupported clauses are rejected even after a matching alternative', () => {
|
||||
assert.throws(() => satisfiesRange('26.0.0', '>=26.0.0 || ~28.0.0'), /unsupported semver clause/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Invariants for what is eager vs lazy in the root ``package.json``.
|
||||
*
|
||||
* The root ``package.json`` is installed by ``hermes update`` on every user,
|
||||
* including users who never opted into a given browser backend. Anything
|
||||
* listed in ``dependencies`` therefore runs its npm postinstall script for
|
||||
* everyone, and — per #43564 — is also part of the npm workspace install
|
||||
* graph, where a workspace-scoped ``npm ci`` (``--workspace ui-tui
|
||||
* --workspace web``) can silently prune it right back out on the next
|
||||
* ``hermes update``.
|
||||
*
|
||||
* The contract:
|
||||
*
|
||||
* - ``agent-browser`` is NOT a root dependency. It used to be eager (see
|
||||
* #27055, which reasoned its postinstall was small enough to keep eager
|
||||
* unlike Camofox's) but #43564 found that keeping ANY dependency in root
|
||||
* ``package.json`` — however small its postinstall — entangles it with
|
||||
* the ui-tui/web workspace install and risks it being pruned. It now
|
||||
* resolves at runtime via ``npx agent-browser`` (see
|
||||
* ``tools/browser_tool.py::_find_agent_browser``), which sidesteps the
|
||||
* workspace graph entirely. ``hermes update`` and ``hermes doctor --fix``
|
||||
* both fire-and-forget ``warm_agent_browser_npx_cache()`` to keep npx's
|
||||
* own cache warm, preserving the "available before any session starts"
|
||||
* property #27055 cared about without re-entangling the dependency.
|
||||
*
|
||||
* - ``@streamdown/math`` is NOT a root dependency either. It's imported only
|
||||
* by desktop's own TS code (``apps/desktop/src/...``), so it belongs in
|
||||
* ``apps/desktop/package.json`` (alongside its sibling ``@streamdown/code``)
|
||||
* — not root, where it was subject to the exact same pruning risk.
|
||||
*
|
||||
* - ``@askjo/camofox-browser`` is NOT eager. It is an explicit opt-in
|
||||
* alternative browser backend, selected by the user via
|
||||
* ``hermes tools`` → Browser Automation → Camofox, and only used at
|
||||
* runtime when ``CAMOFOX_URL`` is set. Its postinstall fetches a ~300MB
|
||||
* Firefox-fork binary, which silently blocked ``hermes update`` for
|
||||
* multi-minute stretches on slow / network-restricted connections
|
||||
* (notably users in China running through a VPN). The package is
|
||||
* installed on demand by ``tools_config.py`` ``post_setup_key ==
|
||||
* "camofox"`` when the user actually selects Camofox.
|
||||
*
|
||||
* If a future PR re-adds any of these to root ``dependencies``, this test
|
||||
* fails — read the lazy-install guidance in the ``hermes-agent-dev`` skill
|
||||
* before changing the expectations.
|
||||
*/
|
||||
|
||||
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 ROOT_PKG = path.join(REPO_ROOT, 'package.json')
|
||||
const ROOT_LOCK = path.join(REPO_ROOT, 'package-lock.json')
|
||||
const DESKTOP_PKG = path.join(REPO_ROOT, 'apps', 'desktop', 'package.json')
|
||||
|
||||
function rootPackageJson(): Record<string, unknown> {
|
||||
return JSON.parse(fs.readFileSync(ROOT_PKG, 'utf-8'))
|
||||
}
|
||||
|
||||
test('camofox is not in root dependencies (must stay opt-in)', () => {
|
||||
const deps = (rootPackageJson().dependencies ?? {}) as Record<string, string>
|
||||
assert.ok(
|
||||
!('@askjo/camofox-browser' in deps),
|
||||
'Camofox is a ~300MB binary-postinstall backend that must stay ' +
|
||||
'out of root package.json dependencies. It belongs in the ' +
|
||||
'Camofox post_setup handler in hermes_cli/tools_config.py so it ' +
|
||||
'only installs when the user explicitly selects Camofox via ' +
|
||||
'`hermes tools` → Browser Automation → Camofox.'
|
||||
)
|
||||
})
|
||||
|
||||
test('agent-browser is not in root dependencies (resolves via npx, #43564)', () => {
|
||||
const deps = (rootPackageJson().dependencies ?? {}) as Record<string, string>
|
||||
assert.ok(
|
||||
!('agent-browser' in deps),
|
||||
'agent-browser must not be a root package.json dependency — it ' +
|
||||
'resolves lazily via `npx agent-browser` instead (see ' +
|
||||
'tools/browser_tool.py::_find_agent_browser and ' +
|
||||
'warm_agent_browser_npx_cache). Putting it back in root ' +
|
||||
'dependencies re-entangles it with the ui-tui/web workspace ' +
|
||||
'install graph and reintroduces #43564.'
|
||||
)
|
||||
})
|
||||
|
||||
test('@streamdown/math is not in root dependencies (desktop-only import)', () => {
|
||||
const deps = (rootPackageJson().dependencies ?? {}) as Record<string, string>
|
||||
assert.ok(
|
||||
!('@streamdown/math' in deps),
|
||||
'@streamdown/math is only imported by apps/desktop\'s own TS code ' +
|
||||
'(markdown-text.tsx, katex-memo.ts) — it belongs in ' +
|
||||
'apps/desktop/package.json alongside its sibling @streamdown/code, ' +
|
||||
'not root, where it\'s subject to the same workspace-pruning risk ' +
|
||||
'agent-browser had (#43564).'
|
||||
)
|
||||
})
|
||||
|
||||
test('@streamdown/math is in desktop dependencies', () => {
|
||||
const deps = (JSON.parse(fs.readFileSync(DESKTOP_PKG, 'utf-8')).dependencies ??
|
||||
{}) as Record<string, string>
|
||||
|
||||
assert.ok(
|
||||
'@streamdown/math' in deps,
|
||||
'@streamdown/math is imported by apps/desktop\'s own TS code ' +
|
||||
'(markdown-text.tsx, katex-memo.ts) and must be declared in ' +
|
||||
'apps/desktop/package.json now that it is no longer a root ' +
|
||||
'dependency.'
|
||||
)
|
||||
})
|
||||
|
||||
test('root lockfile has no camofox entries', () => {
|
||||
if (!fs.existsSync(ROOT_LOCK)) {
|
||||
// Some CI matrix shards skip lockfile materialization.
|
||||
return
|
||||
}
|
||||
|
||||
const text = fs.readFileSync(ROOT_LOCK, 'utf-8')
|
||||
assert.ok(
|
||||
!text.includes('@askjo/camofox-browser'),
|
||||
'package-lock.json still references @askjo/camofox-browser. ' +
|
||||
'Regenerate the lockfile after removing the dep: ' +
|
||||
'`rm package-lock.json && npm install --package-lock-only ' +
|
||||
'--ignore-scripts --no-fund --no-audit`.'
|
||||
)
|
||||
assert.ok(
|
||||
!text.includes('camoufox-js'),
|
||||
'package-lock.json still references camoufox-js (transitive of ' +
|
||||
'@askjo/camofox-browser). Regenerate the lockfile.'
|
||||
)
|
||||
})
|
||||
|
||||
test('root lockfile has no agent-browser entry (#43564)', () => {
|
||||
if (!fs.existsSync(ROOT_LOCK)) {
|
||||
// Some CI matrix shards skip lockfile materialization.
|
||||
return
|
||||
}
|
||||
|
||||
const text = fs.readFileSync(ROOT_LOCK, 'utf-8')
|
||||
assert.ok(
|
||||
!text.includes('"node_modules/agent-browser"'),
|
||||
'package-lock.json still has a node_modules/agent-browser entry. ' +
|
||||
'It must resolve lazily via `npx agent-browser` instead — ' +
|
||||
'regenerate the lockfile after removing the dep: ' +
|
||||
'`rm package-lock.json && npm install --package-lock-only ' +
|
||||
'--ignore-scripts --no-fund --no-audit`.'
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"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 && npm run lint",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"fix": "npm run lint:fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/plist": "3.0.5",
|
||||
"plist": "3.1.1",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Invariant: every workspace that renders React ships one react/react-dom pair.
|
||||
*
|
||||
* React validates at import time that ``react`` and ``react-dom`` come from the
|
||||
* same installed copy. When they don't, it throws "Minified React error #527"
|
||||
* *before* the first paint — the Electron window just stays blank white, with
|
||||
* the only clue buried in the devtools console of a packaged build.
|
||||
*
|
||||
* npm never warns about this. ``apps/desktop`` pins both packages to one exact
|
||||
* version, but a root dependency whose react peer is a loose range (e.g.
|
||||
* ``^18.0.0 || ^19.0.0``, and with no react-dom peer to keep the two in step)
|
||||
* makes npm hoist the newest react to the monorepo root while react-dom stays
|
||||
* at the pinned version. react-dom's own peer range is a caret, so the newer
|
||||
* react still "satisfies" it and the install reports success.
|
||||
*
|
||||
* ``apps/desktop/vite.config.ts`` used to alias both packages to a hardcoded
|
||||
* ``../../node_modules/<pkg>`` — i.e. straight into the split — so the bundle
|
||||
* shipped the mismatched pair. It now resolves them from the workspace itself,
|
||||
* where npm guarantees the declared versions are reachable.
|
||||
*
|
||||
* This is a *contract* test: it asserts no specific version, only that the two
|
||||
* halves of the pair can never drift apart again — neither through a loosened
|
||||
* manifest spec, nor by re-pinning the bundler at the hoisted copy.
|
||||
*/
|
||||
|
||||
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 DESKTOP_VITE_CONFIG = path.join(REPO_ROOT, 'apps', 'desktop', 'vite.config.ts')
|
||||
|
||||
interface Manifest {
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
workspaces?: string[]
|
||||
}
|
||||
|
||||
function readManifest(file: string): Manifest {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf-8')) as Manifest
|
||||
}
|
||||
|
||||
/** Every workspace manifest, resolved from the root ``workspaces`` globs. */
|
||||
function workspaceManifests(): { name: string, manifest: Manifest }[] {
|
||||
const patterns = readManifest(path.join(REPO_ROOT, 'package.json')).workspaces ?? []
|
||||
const found: { name: string, manifest: Manifest }[] = []
|
||||
|
||||
for (const pattern of patterns) {
|
||||
// The globs in use are plain paths or a single trailing ``/*``.
|
||||
const parent = pattern.endsWith('/*') ? path.join(REPO_ROOT, pattern.slice(0, -2)) : null
|
||||
|
||||
const dirs = parent === null
|
||||
? [pattern]
|
||||
: fs.existsSync(parent)
|
||||
? fs.readdirSync(parent).map((entry) => `${pattern.slice(0, -2)}/${entry}`)
|
||||
: []
|
||||
|
||||
for (const dir of dirs) {
|
||||
const file = path.join(REPO_ROOT, dir, 'package.json')
|
||||
|
||||
if (fs.existsSync(file)) {found.push({ name: dir, manifest: readManifest(file) })}
|
||||
}
|
||||
}
|
||||
|
||||
return found
|
||||
}
|
||||
|
||||
test('workspaces declaring react and react-dom pin them to the same exact version', () => {
|
||||
const offenders: string[] = []
|
||||
|
||||
for (const { name, manifest } of workspaceManifests()) {
|
||||
const deps = { ...manifest.devDependencies, ...manifest.dependencies }
|
||||
const react = deps['react']
|
||||
const reactDom = deps['react-dom']
|
||||
|
||||
if (!react || !reactDom) {continue}
|
||||
|
||||
if (react !== reactDom) {
|
||||
offenders.push(`${name} declares react"${react}" but react-dom"${reactDom}"`)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (!/^\d/.test(react)) {
|
||||
offenders.push(`${name} declares a floating range react/react-dom"${react}"`)
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
offenders,
|
||||
[],
|
||||
'react and react-dom must be pinned to the same exact version per workspace, ' +
|
||||
'otherwise npm can hoist a newer react next to the older react-dom and React ' +
|
||||
`throws error #527 (blank window): ${offenders.join('; ')}`
|
||||
)
|
||||
})
|
||||
|
||||
test('the desktop bundler does not alias react at the hoisted root copy', () => {
|
||||
const config = fs.readFileSync(DESKTOP_VITE_CONFIG, 'utf-8')
|
||||
|
||||
assert.ok(
|
||||
!config.includes('node_modules/react'),
|
||||
'apps/desktop/vite.config.ts hardcodes a node_modules path for react/react-dom. ' +
|
||||
'That pins the bundle to the hoisted copies, which npm is free to resolve to a ' +
|
||||
'different version than the pinned react-dom. Resolve both from the workspace ' +
|
||||
"instead (see this test's module docstring)."
|
||||
)
|
||||
})
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user