Files
hermes-agent/tests-js/product-builders.test.mjs
ethernet b3618bda35 fix(build): stop the runtime install stamp from staling web/desktop
Install/update completion rewrites the root install-stamp.json (fresh
builtAt) after products are built, and the stamp was still a shared
web/desktop source input, so every install left its own web_dist
"missing, stale, or damaged" and the post-install probe failed on every
installer E2E leg. Nothing in either build reads the root stamp;
desktop's baked stamp is already tracked as a prepared input.
2026-09-23 15:50:58 -04:00

341 lines
18 KiB
JavaScript

import { execFileSync } from 'node:child_process'
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, symlinkSync, statSync, utimesSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { afterEach, expect, test } from 'vitest'
import { buildTui } from '../scripts/build/tui.mjs'
import { buildWeb } from '../scripts/build/web.mjs'
import { productOutput, publishDirectory, withProduct } from '../scripts/build/frontend-common.mjs'
const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const require = createRequire(import.meta.url)
const temporary = []
afterEach(() => { for (const dir of temporary.splice(0)) rmSync(dir, { recursive: true, force: true }) })
function fixture() {
const dir = mkdtempSync(path.join(tmpdir(), 'product builders '))
temporary.push(dir)
return dir
}
function put(root, name, content) {
const file = path.join(root, name)
mkdirSync(path.dirname(file), { recursive: true })
writeFileSync(file, content)
}
function dependency(root, workspace, name) {
const target = path.dirname(require.resolve(`${name}/package.json`))
const link = path.join(root, workspace, 'node_modules', name)
mkdirSync(path.dirname(link), { recursive: true })
symlinkSync(target, link, 'junction')
}
function webSource(source) {
put(source, 'package.json', '{"private":true,"type":"module"}')
put(source, 'web/package.json', '{"type":"module"}')
put(source, 'web/index.html', '<html><head><link rel="icon" href="/favicon.ico"></head><body><script type="module" src="/src/main.ts"></script></body></html>')
put(source, 'web/src/main.ts', 'import { answer } from "./value"; document.body.textContent = answer;')
put(source, 'web/src/value.ts', 'export const answer: string = "prepared web";')
put(source, 'web/tsconfig.json', JSON.stringify({ files: [], references: [{ path: './tsconfig.app.json' }] }))
put(source, 'web/tsconfig.app.json', JSON.stringify({ compilerOptions: { target: 'ES2022', module: 'ESNext', types: [], noEmit: true, tsBuildInfoFile: './node_modules/.tmp/app.tsbuildinfo' }, include: ['src'] }))
put(source, 'web/vite.config.ts', 'import { defineConfig } from "vite"; export default defineConfig({base: "/dashboard/", build: {outDir: "should-not-be-used"}});')
put(source, 'web/public/retained.txt', 'retained public asset')
put(source, 'web/public/favicon.ico', 'stale source icon')
}
test('product outputs exclude the source tree except supported generated destinations', async () => {
const inputs = ['ui-tui', 'apps/shared', 'node_modules']
// Only call the read-only validator on real source paths: never publish here.
for (const name of ['pm', 'hermes_cli', 'scripts', 'pyproject.toml', '.git', 'tools', '.build', 'apps/desktop/build/products']) {
expect(() => productOutput(repo, path.join(repo, name), inputs), name).toThrow(/output/i)
}
const base = fixture()
const source = path.join(base, 'source')
mkdirSync(source)
for (const name of ['new-source-file', 'pm/generated', 'hermes_cli/web_dist-copy', 'apps/desktop/dist-copy', 'apps/desktop/build/native-deps-copy', 'hermes_cli/web_dist/assets']) {
expect(() => productOutput(source, path.join(source, name), inputs), name).toThrow(/output/i)
}
for (const name of ['hermes_cli/web_dist', 'apps/desktop/dist', 'apps/desktop/build/native-deps', 'apps/desktop/build/products/tui', 'apps/desktop/build/products/web', '.build/web', '.build/termux/tui']) {
const out = path.join(source, name)
expect(productOutput(source, out, inputs).out).toBe(out)
expect(existsSync(out)).toBe(false)
await withProduct(out, product => put(product, 'built.txt', 'product'))
expect(productOutput(source, out, inputs).out).toBe(out)
}
const adjacent = path.join(base, 'web-product')
expect(productOutput(source, adjacent, inputs).out).toBe(adjacent)
// Ownership never exempts a directory from source/input protection.
const sourceDir = path.join(source, 'pm')
await withProduct(sourceDir, product => put(product, 'keep.txt', 'source'))
expect(() => productOutput(source, sourceDir, inputs)).toThrow(/output/i)
const generatedInput = path.join(source, '.build/web')
expect(() => productOutput(source, generatedInput, ['.build/web'])).toThrow(/overlap/)
symlinkSync(sourceDir, path.join(source, '.build/redirect'), 'junction')
symlinkSync(sourceDir, path.join(base, 'source-alias'), 'junction')
for (const alias of [path.join(source, '.build/redirect'), path.join(base, 'source-alias')]) {
expect(() => productOutput(source, path.join(alias, 'output'), inputs)).toThrow(/output/i)
}
})
test('publication replaces only builder-owned directories and rechecks ownership after compilation', async () => {
const base = fixture()
const source = path.join(base, 'source')
mkdirSync(source)
const unowned = path.join(base, 'unowned')
put(unowned, 'keep.txt', 'not a product')
mkdirSync(path.join(base, 'empty'))
put(base, 'file', 'not a directory')
symlinkSync(unowned, path.join(base, 'link'), 'junction')
symlinkSync(path.join(base, 'absent'), path.join(base, 'dangling'), 'junction')
const inTree = path.join(source, '.build/unowned')
put(inTree, 'keep.txt', 'not a product either')
for (const out of [unowned, path.join(base, 'empty'), path.join(base, 'file'), path.join(base, 'link'), path.join(base, 'dangling'), inTree]) {
expect(() => productOutput(source, out, []), out).toThrow(/output/i)
let compiled = false
await expect(withProduct(out, () => { compiled = true })).rejects.toThrow(/output/i)
expect(compiled).toBe(false)
}
const staged = path.join(base, 'staged')
put(staged, 'new.txt', 'new product')
expect(() => publishDirectory(staged, unowned)).toThrow(/output/i)
expect(readFileSync(path.join(unowned, 'keep.txt'), 'utf8')).toBe('not a product')
expect(readFileSync(path.join(staged, 'new.txt'), 'utf8')).toBe('new product')
const out = path.join(base, 'product')
await withProduct(out, product => put(product, 'old.txt', 'old product'))
expect(productOutput(source, out, []).out).toBe(out)
await withProduct(out, product => put(product, 'new.txt', 'new product'))
expect(existsSync(path.join(out, 'old.txt'))).toBe(false)
await expect(withProduct(out, () => { throw new Error('compile failed') })).rejects.toThrow('compile failed')
expect(readFileSync(path.join(out, 'new.txt'), 'utf8')).toBe('new product')
const occupied = path.join(base, 'occupied-during-build')
await expect(withProduct(occupied, product => {
put(product, 'new.txt', 'new product')
put(occupied, 'keep.txt', 'created by someone else')
})).rejects.toThrow(/output/i)
expect(readFileSync(path.join(occupied, 'keep.txt'), 'utf8')).toBe('created by someone else')
})
test('existing npm build directories can be rebuilt without adopting arbitrary outputs', async () => {
const source = fixture()
for (const name of ['ui-tui/dist', 'hermes_cli/web_dist', 'apps/desktop/dist', 'apps/desktop/build/native-deps']) {
const out = path.join(source, name)
put(out, 'old-output', 'previous compiler output')
await withProduct(out, product => put(product, 'new-output', 'rebuilt'), { source })
expect(existsSync(path.join(out, 'old-output'))).toBe(false)
expect(readFileSync(path.join(out, 'new-output'), 'utf8')).toBe('rebuilt')
}
const unrelated = path.join(source, 'pm')
put(unrelated, 'keep', 'source')
await expect(withProduct(unrelated, () => {}, { source })).rejects.toThrow(/output/i)
})
test('web compiles with prepared icons and workspace-local tools without writing source or tsbuildinfo', async () => {
const { readdirSync, lstatSync } = await import('node:fs')
function snapshot(dir) {
return Object.fromEntries(readdirSync(dir, { recursive: true }).sort().map(name => {
const file = path.join(dir, name)
const stat = lstatSync(file)
return [name, stat.isFile() ? readFileSync(file).toString('base64') : 'directory or link']
}))
}
const base = fixture()
const source = path.join(base, 'source with spaces')
const icons = path.join(base, 'prepared icons')
webSource(source)
dependency(source, 'web', 'typescript')
dependency(source, 'web', 'vite')
put(icons, 'web/public/favicon.ico', 'prepared icon bytes')
const before = snapshot(source)
const out = path.join(base, 'web output')
execFileSync(process.execPath, [path.join(repo, 'scripts/build/web.mjs'), '--source', source, '--icons', icons, '--out', out], { cwd: base })
expect(readFileSync(path.join(out, 'favicon.ico'), 'utf8')).toBe('prepared icon bytes')
expect(readFileSync(path.join(out, 'retained.txt'), 'utf8')).toBe('retained public asset')
expect(readFileSync(path.join(out, 'index.html'), 'utf8')).toContain('/dashboard/assets/')
expect(snapshot(source)).toEqual(before)
const second = path.join(base, 'second output')
await buildWeb({ source, icons, out: second })
expect(readFileSync(path.join(second, 'index.html'))).toEqual(readFileSync(path.join(out, 'index.html')))
}, 30_000)
test('web rejects missing icons and compiler failures without publishing stale success', async () => {
const base = fixture()
const source = path.join(base, 'source')
const icons = path.join(base, 'icons')
const out = path.join(base, 'web')
webSource(source)
dependency(source, '', 'typescript')
dependency(source, '', 'vite')
await expect(buildWeb({ source, icons, out })).rejects.toThrow(/icon/i)
expect(existsSync(out)).toBe(false)
put(icons, 'web/public/favicon.ico', 'prepared icon')
await buildWeb({ source, icons, out })
const previous = readFileSync(path.join(out, 'index.html'))
put(source, 'web/src/value.ts', 'export const answer: string = 42;')
await expect(buildWeb({ source, icons, out })).rejects.toThrow(/TypeScript/)
expect(readFileSync(path.join(out, 'index.html'))).toEqual(previous)
put(source, 'web/src/value.ts', 'export const answer: string = "valid";')
put(source, 'web/index.html', '<script type="module" src="/missing-entry.js"></script>')
await expect(buildWeb({ source, icons, out })).rejects.toThrow()
expect(readFileSync(path.join(out, 'index.html'))).toEqual(previous)
}, 30_000)
function tuiSource(source) {
put(source, 'package.json', '{"private":true}')
put(source, 'ui-tui/package.json', '{"type":"module"}')
put(source, 'ui-tui/src/entry.tsx', '#!/usr/bin/env node\nimport { answer } from "@hermes/ink"; console.log(answer);')
put(source, 'ui-tui/packages/hermes-ink/src/entry-exports.ts', 'import devtools from "react-devtools-core"; devtools.initialize(); export const answer: string = "prepared source";')
}
test('TUI uses prepared workspace-local tools from an unrelated cwd and publishes a relocatable module product', () => {
const base = fixture()
const source = path.join(base, 'source with spaces')
tuiSource(source)
dependency(source, 'ui-tui', 'esbuild')
const out = path.join(base, 'product one')
execFileSync(process.execPath, [path.join(repo, 'scripts/build/tui.mjs'), '--source', source, '--out', out], { cwd: base })
expect(JSON.parse(readFileSync(path.join(out, 'package.json'), 'utf8')).type).toBe('module')
const entry = path.join(out, 'dist/entry.js')
expect(readFileSync(entry, 'utf8').startsWith('#!')).toBe(false)
rmSync(source, { recursive: true })
expect(execFileSync(process.execPath, [entry], { cwd: base, encoding: 'utf8' }).trim()).toBe('prepared source')
})
test('TUI failure does not publish or disturb a previous product, and missing prepared tools fail clearly', async () => {
const base = fixture()
const source = path.join(base, 'source')
tuiSource(source)
const out = path.join(base, 'out')
await expect(buildTui({ source, out })).rejects.toThrow(/esbuild.*prepared|prepared.*esbuild/i)
expect(existsSync(out)).toBe(false)
dependency(source, '', 'esbuild')
await buildTui({ source, out })
const { productCurrent } = await import('../scripts/build/freshness.mjs')
expect(productCurrent({ source, product: 'tui', out: path.join(out, 'dist') })).toBe(true)
const previous = readFileSync(path.join(out, 'dist/entry.js'))
put(source, 'ui-tui/src/entry.tsx', 'const broken = ;')
await expect(buildTui({ source, out })).rejects.toThrow()
expect(productCurrent({ source, product: 'tui', out: path.join(out, 'dist') })).toBe(false)
expect(readFileSync(path.join(out, 'dist/entry.js'))).toEqual(previous)
await expect(buildTui({ source, out: source })).rejects.toThrow(/output/i)
expect(existsSync(path.join(source, 'ui-tui/src/entry.tsx'))).toBe(true)
})
test('built web freshness follows shared sources and build inputs, not mtimes or generated trees', async () => {
const { productCurrent } = await import('../scripts/build/freshness.mjs')
const base = fixture()
const source = path.join(base, 'source')
const icons = path.join(base, 'icons')
const out = path.join(base, 'web')
webSource(source)
dependency(source, '', 'typescript')
dependency(source, '', 'vite')
put(icons, 'web/public/favicon.ico', 'icon')
put(source, 'apps/shared/src/client.ts', 'export const version = 1')
put(source, 'scripts/build/web.mjs', '// build input')
await buildWeb({ source, icons, out })
expect(productCurrent({ source, product: 'web', out })).toBe(true)
put(source, 'web/node_modules/.tmp/tsbuildinfo', 'generated')
expect(productCurrent({ source, product: 'web', out })).toBe(true)
// Install completion rewrites the runtime identity after building products.
put(source, 'install-stamp.json', '{"builtAt": "later"}')
expect(productCurrent({ source, product: 'web', out })).toBe(true)
put(icons, 'web/public/favicon.ico', 'changed prepared icon')
expect(productCurrent({ source, product: 'web', out })).toBe(false)
await buildWeb({ source, icons, out })
expect(readFileSync(path.join(out, 'favicon.ico'), 'utf8')).toBe('changed prepared icon')
for (const input of ['apps/shared/src/client.ts', 'scripts/build/web.mjs', 'assets/icon.svg', 'package-lock.json']) {
put(source, input, 'changed input')
expect(productCurrent({ source, product: 'web', out }), input).toBe(false)
await buildWeb({ source, icons, out })
expect(productCurrent({ source, product: 'web', out })).toBe(true)
}
expect(productCurrent({ source, product: 'desktop', out })).toBe(false)
rmSync(path.join(out, 'favicon.ico'))
expect(productCurrent({ source, product: 'web', out })).toBe(false)
}, 30_000)
test('built TUI stays current after documentation, test and unrelated recipe changes', async () => {
const { productCurrent } = await import('../scripts/build/freshness.mjs')
const base = fixture()
const source = path.join(base, 'source')
const out = path.join(base, 'tui')
tuiSource(source)
dependency(source, '', 'esbuild')
put(source, 'apps/shared/src/value.ts', 'export const value: string = "shared source";')
await buildTui({ source, out })
const entry = path.join(out, 'dist/entry.js')
const before = readFileSync(entry)
for (const name of [
'ui-tui/README.md', 'ui-tui/src/__tests__/new.test.ts', 'ui-tui/src/value.spec.ts',
'ui-tui/packages/hermes-ink/src/value.test.ts', 'apps/shared/src/value.test.ts',
'apps/shared/README.md', 'scripts/build/web.mjs', 'scripts/build/desktop.mjs',
'scripts/build/README.md', 'scripts/build/node-deps.mjs',
]) {
put(source, name, 'not a compiler input')
expect(productCurrent({ source, product: 'tui', out: path.join(out, 'dist') }), name).toBe(true)
}
await buildTui({ source, out })
expect(readFileSync(entry)).toEqual(before)
expect(execFileSync(process.execPath, [entry], { encoding: 'utf8' }).trim()).toBe('prepared source')
})
test('TUI freshness invalidates source, configuration and compiler inputs and damaged outputs', async () => {
const { productCurrent } = await import('../scripts/build/freshness.mjs')
const base = fixture()
const source = path.join(base, 'source')
const out = path.join(base, 'tui')
tuiSource(source)
dependency(source, '', 'esbuild')
// Include the shared workspace in the real compiler graph, not just the receipt.
put(source, 'ui-tui/src/value.ts', 'export const value: string = "shared source";')
put(source, 'apps/shared/src/value.ts', 'export { value } from "../../../ui-tui/src/value";')
put(source, 'ui-tui/src/entry.tsx', 'import { value } from "../../apps/shared/src/value"; console.log(value);')
put(source, 'package-lock.json', '{"lockfileVersion":3}')
const preservedTimes = new Date('2020-01-01T00:00:00Z')
const sameLengthChanges = [
['ui-tui/src/value.ts', 'shared', 'edited'],
['apps/shared/src/value.ts', 'value', 'VALUE'],
['package.json', 'true', 'null'],
['package-lock.json', ':3', ':2'],
]
for (const [name] of sameLengthChanges) utimesSync(path.join(source, name), preservedTimes, preservedTimes)
await buildTui({ source, out })
const current = () => productCurrent({ source, product: 'tui', out: path.join(out, 'dist') })
expect(current()).toBe(true)
for (const [name, from, to] of sameLengthChanges) {
const file = path.join(source, name), previous = readFileSync(file, 'utf8'), before = statSync(file)
expect(previous).toContain(from)
writeFileSync(file, previous.replace(from, to))
utimesSync(file, preservedTimes, preservedTimes)
expect([statSync(file).size, statSync(file).mtimeMs]).toEqual([before.size, before.mtimeMs])
expect(current(), `same-size and same-mtime change in ${name}`).toBe(false)
writeFileSync(file, previous)
utimesSync(file, preservedTimes, preservedTimes)
expect(current(), `restored ${name}`).toBe(true)
}
for (const name of [
'ui-tui/src/value.ts', 'apps/shared/src/value.ts',
'ui-tui/packages/hermes-ink/src/entry-exports.ts',
'ui-tui/tsconfig.json', 'ui-tui/packages/hermes-ink/tsconfig.json', 'apps/shared/tsconfig.json',
'ui-tui/package.json', 'ui-tui/packages/hermes-ink/package.json', 'apps/shared/package.json',
'tsconfig.json', 'package.json', 'package-lock.json', '.npmrc', 'pm/lock.json',
'scripts/build/tui.mjs', 'scripts/build/frontend-common.mjs', 'scripts/build/freshness.mjs',
]) {
const file = path.join(source, name)
const previous = existsSync(file) ? readFileSync(file) : null
put(source, name, 'changed input')
expect(current(), name).toBe(false)
if (previous) writeFileSync(file, previous)
else rmSync(file)
expect(current(), `restored ${name}`).toBe(true)
}
put(source, 'ui-tui/src/value.ts', 'export const value: string = "rebuilt source";')
await buildTui({ source, out })
expect(current()).toBe(true)
const entry = path.join(out, 'dist/entry.js')
expect(execFileSync(process.execPath, [entry], { encoding: 'utf8' }).trim()).toBe('rebuilt source')
writeFileSync(entry, 'damaged output')
expect(current()).toBe(false)
})