Files
hermes-agent/tests-js/desktop-builder.test.mjs
Halldrix 24fb52eb29 test(build): pin the mid-compile guard, the pre-build gate and the receipt clock (#123308)
The mid-compile guard test drives recordProduct directly: a clock-only restamp
must not throw, while a commit, payload or tag racing in must still fail it.

The pre-build gate tests cover the two ways a bundle could be left inconsistent:
a restamp after the build makes productCurrent report not current, and the
receipt records the clock the output baked rather than the live one, so a
restamp landing between the bake and the record is caught too. A receipt written
before the clock was recorded is documented as self-healing, and a missing or
unparsable stamp still hashes to a stable distinct value instead of throwing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-10-01 00:15:05 -05:00

332 lines
20 KiB
JavaScript

import { execFileSync } from 'node:child_process'
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, expect, test } from 'vitest'
import { stageGetWindows, stageNodePtyInto } from '../apps/desktop/scripts/stage-native-deps.mjs'
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const roots = []
test('desktop development composition reuses prepared icon pixels instead of provisioning them again', async () => {
const { buildSourceDesktop } = await import('../apps/desktop/scripts/build.mjs')
const input = fixture()
put(join(input.icons, 'apps/desktop/assets/icon.ico'), 'prepared packaging icon')
const commands = []
buildSourceDesktop({ source: input.source, icons: input.icons,
run: (command, args) => commands.push([command, ...args]),
})
expect(readFileSync(join(input.source, 'apps/desktop/assets/icon.ico'), 'utf8')).toBe('prepared packaging icon')
// path.join builds platform-native separators, so accept both `/` and `\`.
const compile = commands.find(command => command.some(arg => /[/\\]scripts[/\\]build[/\\]desktop\.mjs$/.test(arg)))
expect(compile[compile.indexOf('--icons') + 1]).toBe(input.icons)
})
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
function put(path, text) { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, text) }
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'desktop build with spaces-'))
roots.push(root)
const source = join(root, 'source')
const app = join(source, 'apps/desktop')
put(join(app, 'package.json'), '{"type":"module"}')
put(join(app, 'vite.config.mjs'), 'export default { base: "./", build: { minify: false } }')
put(join(app, 'index.html'), '<html><div id="app"></div><script type="module" src="/src/index.js"></script></html>')
put(join(app, 'src/index.js'), 'document.getElementById("app").textContent = "built renderer"')
put(join(app, 'electron/entry.ts'), "await import('./main')")
put(join(app, 'electron/main.ts'), 'console.log(JSON.stringify({ stamp: __HERMES_INSTALL_STAMP__, identity: __HERMES_PRODUCT_IDENTITY__ }))')
put(join(app, 'electron/preload.ts'), 'globalThis.fixturePreload = "compiled preload"')
put(join(app, 'electron/preview-guest-preload-entry.ts'), 'globalThis.fixtureGuestPreload = "compiled guest preload"')
cpSync(join(repo, 'apps/desktop/product-identity.cjs'), join(app, 'product-identity.cjs'))
cpSync(join(repo, 'apps/desktop/electron/native'), join(app, 'electron/native'), { recursive: true })
// product-identity.cjs resolves the channel request through the in-tree
// packaging helper (and its content-types table) by relative path.
for (const helper of ['scripts/msix-shared.mjs', 'scripts/release-content-types.json']) {
cpSync(join(repo, helper), join(source, helper))
}
symlinkSync(join(repo, 'node_modules'), join(app, 'node_modules'), 'junction')
const icons = join(root, 'icons')
put(join(icons, 'apps/desktop/public/apple-touch-icon.png'), 'fresh icon')
put(join(app, 'public/apple-touch-icon.png'), 'stale icon')
const nativeDeps = join(root, 'native')
// The native input is the real host binding, not a fake compiler/dependency.
stageNodePtyInto(join(repo, 'node_modules/node-pty'), join(nativeDeps, 'node-pty'))
stageGetWindows({ source: repo, out: nativeDeps })
put(join(nativeDeps, 'native/helper-fixture'), 'prepared executable resource')
const stamp = join(root, 'install-stamp.json')
put(stamp, JSON.stringify({ schemaVersion: 1, payload: 'light', updateMechanism: 'external', commit: 'a'.repeat(40), tag: 'v1.2.3', builtAt: '2026-01-01T00:00:00.000Z' }))
return { source, out: join(root, 'result'), icons, nativeDeps, stamp }
}
function files(root, dir = root) {
return readdirSync(dir, { withFileTypes: true }).flatMap(entry => {
const path = join(dir, entry.name)
if (entry.isDirectory()) return files(root, path)
return entry.isFile() ? [[path.slice(root.length), readFileSync(path).toString('base64')]] : []
})
}
test('desktop compiler consumes explicit immutable inputs, replaces variants, and preserves the last product on failure', async () => {
const { buildDesktop } = await import('../scripts/build/desktop.mjs')
const input = fixture()
const before = files(input.source)
await buildDesktop(input)
const { productCurrent } = await import('../scripts/build/freshness.mjs')
expect(productCurrent({ ...input, product: 'desktop' })).toBe(true)
for (const file of [join(input.icons, 'apps/desktop/public/apple-touch-icon.png'), input.stamp,
join(input.nativeDeps, 'node-pty/package.json'), join(input.nativeDeps, 'native/helper-fixture')]) {
const original = readFileSync(file)
put(file, 'changed prepared input')
expect(productCurrent({ ...input, product: 'desktop' }), file).toBe(false)
writeFileSync(file, original)
expect(productCurrent({ ...input, product: 'desktop' })).toBe(true)
}
put(join(input.source, 'apps/shared/src/client.ts'), 'export const shared = true')
expect(productCurrent({ ...input, product: 'desktop' })).toBe(false)
rmSync(join(input.source, 'apps/shared'), { recursive: true })
expect(productCurrent({ ...input, product: 'desktop' })).toBe(true)
expect(files(input.source)).toEqual(before)
expect(readFileSync(join(input.out, 'apple-touch-icon.png'), 'utf8')).toBe('fresh icon')
expect(existsSync(join(input.out, 'assets'))).toBe(true)
expect(existsSync(join(input.out, 'electron-preload.js'))).toBe(true)
expect(readFileSync(join(input.out, 'native/helper-fixture'), 'utf8')).toBe('prepared executable resource')
expect(existsSync(join(input.out, 'node_modules/native'))).toBe(false)
put(join(input.out, 'native/helper-fixture'), 'signed resource')
expect(productCurrent({ ...input, product: 'desktop' })).toBe(true)
rmSync(join(input.out, 'native/helper-fixture'))
expect(productCurrent({ ...input, product: 'desktop' })).toBe(false)
expect(files(join(input.out, 'node_modules/node-pty')).some(([name]) => name.endsWith('.node'))).toBe(true)
const run = () => JSON.parse(execFileSync(process.execPath, [join(input.out, 'electron-main.mjs')], { cwd: tmpdir(), encoding: 'utf8' }))
expect(run().identity.light).toBe(true)
put(input.stamp, JSON.stringify({ schemaVersion: 1, payload: 'bundled', updateMechanism: 'microsoft-store', commit: 'b'.repeat(40), tag: null }))
// Ambient variant/tag must not override explicit stamp inputs or cached CJS identity.
execFileSync(process.execPath, [join(repo, 'scripts/build/desktop.mjs'), ...Object.entries(input).flatMap(([key, value]) => [`--${key === 'nativeDeps' ? 'native-deps' : key}`, value])], {
cwd: tmpdir(), env: { ...process.env, PATH: '', HERMES_DESKTOP_VARIANT: 'light', HERMES_PAYLOAD_TAG: 'v1.0.0+canary.20260911T000000Z' }, stdio: 'pipe',
})
expect(run().identity.store).toBe(true)
expect(run().identity.light).toBe(false)
expect(run().stamp.commit).toBe('b'.repeat(40))
const built = files(input.out)
put(join(input.source, 'apps/desktop/electron/main.ts'), 'THIS IS NOT TYPESCRIPT !!!')
await expect(buildDesktop(input)).rejects.toThrow()
expect(files(input.out)).toEqual(built)
expect(files(input.source).some(([name]) => name.includes('.vite') || name.endsWith('tsbuildinfo'))).toBe(false)
}, 60000)
test('desktop output cannot overlap a source directory symlinked outside the checkout', async () => {
const { buildDesktop } = await import('../scripts/build/desktop.mjs')
const root = mkdtempSync(join(tmpdir(), 'desktop symlinked source-'))
roots.push(root)
const source = join(root, 'source')
const externalSource = join(root, 'external-source')
mkdirSync(join(source, 'apps/desktop'), { recursive: true })
put(join(externalSource, 'index.js'), 'source must not change')
symlinkSync(externalSource, join(source, 'apps/desktop/src'), 'junction')
const out = join(externalSource, 'product')
const before = files(root)
// Missing prepared inputs must not hide a failure to reject source overlap.
await expect(buildDesktop({ source, out,
icons: join(root, 'icons'), stamp: join(root, 'stamp.json'), nativeDeps: join(root, 'native'),
})).rejects.toThrow(/Output must not overlap build inputs/)
expect(files(root)).toEqual(before)
expect(readdirSync(externalSource)).toEqual(['index.js'])
expect(existsSync(out)).toBe(false)
})
test('in-tree desktop products rebuild after build exists without replacing prepared inputs', async () => {
const { buildDesktop } = await import('../scripts/build/desktop.mjs')
const { productCurrent } = await import('../scripts/build/freshness.mjs')
const input = fixture()
input.out = join(input.source, 'apps/desktop/build/products/desktop')
await buildDesktop(input)
put(join(input.source, 'apps/desktop/src/index.js'), 'document.getElementById("app").textContent = "warm rebuild"')
await buildDesktop(input)
expect(productCurrent({ ...input, product: 'desktop' })).toBe(true)
const assets = files(join(input.out, 'assets')).map(([, bytes]) => Buffer.from(bytes, 'base64').toString()).join('')
expect(assets).toContain('warm rebuild')
// The generated-path allowance never overrides an explicitly prepared input,
// even when that input itself is an earlier builder-owned product.
const built = files(input.out)
for (const prepared of [
{ stamp: join(input.out, 'hermes-build.json') },
{ nativeDeps: join(input.out, 'node_modules') },
{ icons: input.out },
]) {
await expect(buildDesktop({ ...input, ...prepared })).rejects.toThrow(/overlap/)
expect(files(input.out)).toEqual(built)
}
}, 30000)
test('the mid-compile guard ignores the build clock but still fails on a real provenance change', async () => {
const { buildDesktop } = await import('../scripts/build/desktop.mjs')
const { buildInputs, recordProduct } = await import('../scripts/build/freshness.mjs')
const input = fixture()
await buildDesktop(input)
const stamp = readFileSync(input.stamp, 'utf8')
// write-build-stamp.mjs rewrites `builtAt` on EVERY build (build.mjs step 20), so a
// second build racing the first must not be killed by its own input changing.
// This is the mid-compile guard only; productCurrent is covered by the restamp
// tests below.
const clockMoved = { ...JSON.parse(stamp), builtAt: '2027-01-01T00:00:00.000Z' }
expect(clockMoved).not.toEqual(JSON.parse(stamp))
put(input.stamp, JSON.stringify(clockMoved))
const paths = { icons: join(input.icons, 'apps/desktop/public'), stamp: input.stamp, nativeDeps: input.nativeDeps }
// The mid-compile guard compares the prepared inputs, and the clock is not one:
// re-recording with a restamped clock must NOT throw, or the racing build dies
// again on a change the build machinery made.
const inputs = buildInputs(input.source, 'desktop', paths)
expect(() => recordProduct({ source: input.source, product: 'desktop', out: input.out, inputs })).not.toThrow()
// A real provenance change, by contrast, is a genuine mid-compile change: the
// compiler captured inputs BEFORE the stamp moved, so the guard must fire.
for (const field of [{ commit: 'd'.repeat(40) }, { payload: 'store' }, { tag: 'v9.9.9' }]) {
put(input.stamp, JSON.stringify({ ...JSON.parse(stamp), ...field }))
expect(() => recordProduct({ source: input.source, product: 'desktop', out: input.out, inputs }))
.toThrow(/inputs changed/)
}
put(input.stamp, stamp)
}, 60000)
test('a desktop build that another build restamps mid-compile still publishes, and the clock-only rewrite is not what kills it', async () => {
const { buildDesktop } = await import('../scripts/build/desktop.mjs')
const input = fixture()
const raced = { ...JSON.parse(readFileSync(input.stamp, 'utf8')), builtAt: '2027-03-03T00:00:00.000Z' }
// The issue's actual shape: a second `hermes desktop` runs write-build-stamp.mjs
// while this build is compiling, so recordProduct() re-hashes a stamp whose only
// difference is the build clock. Provenance racing in must still fail the build.
put(join(input.source, 'apps/desktop/vite.config.mjs'), `
import { writeFileSync } from 'node:fs';
export default { plugins: [{ name: 'restamp-during-build', buildStart() {
writeFileSync(${JSON.stringify(input.stamp)}, ${JSON.stringify(JSON.stringify(raced))})
}}] }
`)
await expect(buildDesktop(input)).resolves.toBeTruthy()
expect(existsSync(join(input.out, 'hermes-build.json'))).toBe(true)
// The guard itself: a PROVENANCE change during compilation must still throw, or
// this test would pass simply because recordProduct stopped guarding anything.
const swapped = { ...JSON.parse(readFileSync(input.stamp, 'utf8')), commit: 'e'.repeat(40) }
put(join(input.source, 'apps/desktop/vite.config.mjs'), `
import { writeFileSync } from 'node:fs';
export default { plugins: [{ name: 'swap-commit-during-build', buildStart() {
writeFileSync(${JSON.stringify(input.stamp)}, ${JSON.stringify(JSON.stringify(swapped))})
}}] }
`)
await expect(buildDesktop(input)).rejects.toThrow(/inputs changed/)
}, 60000)
test('the pre-build gate still notices a restamp, so packaging cannot ship a bundle whose clock disagrees with the baked one', async () => {
const { buildDesktop } = await import('../scripts/build/desktop.mjs')
const { productCurrent } = await import('../scripts/build/freshness.mjs')
const input = fixture()
await buildDesktop(input)
expect(productCurrent({ ...input, product: 'desktop' })).toBe(true)
// What actually gets baked into the shipped main, read from the real bundle.
const baked = readFileSync(join(input.out, 'electron-main.mjs'), 'utf8')
.match(/builtAt:\s*"([^"]*)"/)?.[1] ?? null
expect(baked).toBeTruthy()
// A second build's write-build-stamp moves the LIVE stamp. electron-builder's
// extraResources copies that live file into the bundle, so skipping the rebuild
// here would ship Resources/install-stamp.json disagreeing with the baked main
// and make detectBundleSwap offer a relaunch for a bundle never replaced.
const restamped = { ...JSON.parse(readFileSync(input.stamp, 'utf8')), builtAt: '2027-04-04T00:00:00.000Z' }
put(input.stamp, JSON.stringify(restamped))
expect(productCurrent({ ...input, product: 'desktop' })).toBe(false)
// And rebuilding restores agreement, which is what the next `hermes desktop` does.
await buildDesktop(input)
expect(productCurrent({ ...input, product: 'desktop' })).toBe(true)
const rebaked = readFileSync(join(input.out, 'electron-main.mjs'), 'utf8')
.match(/builtAt:\s*"([^"]*)"/)?.[1] ?? null
expect(rebaked).toBe(restamped.builtAt)
}, 60000)
test('the receipt records the clock the output BAKED, so a restamp between the bake and the record still reads as not current', async () => {
const { buildDesktop } = await import('../scripts/build/desktop.mjs')
const { productCurrent, buildInputs, recordProduct } = await import('../scripts/build/freshness.mjs')
const input = fixture()
await buildDesktop(input)
const paths = { icons: join(input.icons, 'apps/desktop/public'), stamp: input.stamp, nativeDeps: input.nativeDeps }
const inputs = buildInputs(input.source, 'desktop', paths)
const baked = readFileSync(join(input.out, 'electron-main.mjs'), 'utf8').match(/builtAt:\s*"([^"]*)"/)?.[1]
// The window that matters: electron-builder's extraResources copies the LIVE
// stamp after the receipt exists, so a restamp landing between bundleElectronMain
// and recordProduct would otherwise be recorded as if this output had baked it.
put(input.stamp, JSON.stringify({ ...JSON.parse(readFileSync(input.stamp, 'utf8')), builtAt: '2028-08-08T00:00:00.000Z' }))
expect(() => recordProduct({
source: input.source, product: 'desktop', out: input.out, inputs, stampClock: baked,
})).not.toThrow()
const receipt = JSON.parse(readFileSync(join(input.out, 'hermes-build.json'), 'utf8'))
expect(receipt.stampClock).toBe(baked)
expect(productCurrent({ ...input, product: 'desktop' })).toBe(false)
}, 60000)
test('a missing or unparsable desktop stamp still hashes to a distinct value instead of failing the build', async () => {
const { buildInputs } = await import('../scripts/build/freshness.mjs')
const { buildDesktop } = await import('../scripts/build/desktop.mjs')
const input = fixture()
// prepared[] is sorted by name, so select the stamp entry by name, not index.
const stampHash = (path) => buildInputs(input.source, 'desktop', {
icons: join(input.icons, 'apps/desktop/public'), stamp: path, nativeDeps: input.nativeDeps,
}).prepared.find(entry => entry.name === 'stamp').hash
// A missing input must read as one stable hash, never throw: buildDesktop is
// allowed to require a stamp, but the freshness probe itself never aborts.
const missing = join(dirname(input.stamp), 'not-written-yet.json')
expect(stampHash(missing)).toMatch(/^[0-9a-f]{64}$/)
expect(stampHash(missing)).toBe(stampHash(missing))
expect(stampHash(missing)).not.toBe(stampHash(input.stamp))
// Unparsable bytes hash by content, so any edit still invalidates.
put(input.stamp, 'not json at all')
const junk = stampHash(input.stamp)
put(input.stamp, 'still not json, but different')
expect(junk).not.toBe(stampHash(input.stamp))
await expect(buildDesktop(input)).rejects.toThrow()
}, 60000)
test('a prepared input changing during desktop compilation cannot publish a current receipt', async () => {
const { buildDesktop } = await import('../scripts/build/desktop.mjs')
const input = fixture()
await buildDesktop(input)
const previous = files(input.out)
const changedStamp = { ...JSON.parse(readFileSync(input.stamp, 'utf8')), commit: 'c'.repeat(40) }
put(join(input.source, 'apps/desktop/vite.config.mjs'), `
import { writeFileSync } from 'node:fs';
export default { plugins: [{ name: 'change-prepared-input', buildStart() {
writeFileSync(${JSON.stringify(input.stamp)}, ${JSON.stringify(JSON.stringify(changedStamp))})
}}] }
`)
await expect(buildDesktop(input)).rejects.toThrow(/inputs changed/)
expect(files(input.out)).toEqual(previous)
}, 30000)
test('native preparation stages the selected source into an explicit tree before compilation', async () => {
const { prepareDesktopNativeDependencies } = await import('../apps/desktop/scripts/stage-native-deps.mjs')
const input = fixture()
cpSync(join(repo, 'package-lock.json'), join(input.source, 'package-lock.json'))
const nativeOut = join(dirname(input.out), 'prepared-native')
await prepareDesktopNativeDependencies({ source: input.source, out: nativeOut })
expect(existsSync(join(nativeOut, 'node-pty/package.json'))).toBe(true)
expect(files(join(nativeOut, 'node-pty')).some(([name]) => name.endsWith('.node'))).toBe(true)
expect(existsSync(join(input.source, 'apps/desktop/dist'))).toBe(false)
})
test('typecheck uses scratch state and incomplete prepared inputs fail before publication', async () => {
const { buildDesktop } = await import('../scripts/build/desktop.mjs')
const input = fixture()
put(join(input.source, 'apps/desktop/tsconfig.json'), JSON.stringify({ compilerOptions: { composite: true, skipLibCheck: true, types: [] }, include: ['src/*.ts'] }))
put(join(input.source, 'apps/desktop/src/typed.ts'), 'export const value: string = "valid"')
await buildDesktop({ ...input, typecheck: true })
const built = files(input.out)
expect(files(input.source).some(([name]) => name.endsWith('.tsbuildinfo') || name.endsWith('typed.js'))).toBe(false)
put(join(input.source, 'apps/desktop/src/typed.ts'), 'export const value: string = 123')
await expect(buildDesktop({ ...input, typecheck: true })).rejects.toThrow()
expect(files(input.out)).toEqual(built)
rmSync(join(input.icons, 'apps/desktop/public/apple-touch-icon.png'))
await expect(buildDesktop(input)).rejects.toThrow(/icon/i)
expect(files(input.out)).toEqual(built)
await expect(buildDesktop({ ...input, out: join(input.source, 'apps/desktop/scripts') })).rejects.toThrow(/Output/)
put(join(input.icons, 'apps/desktop/public/apple-touch-icon.png'), 'fresh icon')
rmSync(join(input.nativeDeps, 'node-pty/build'), { recursive: true, force: true })
rmSync(join(input.nativeDeps, 'node-pty/prebuilds'), { recursive: true, force: true })
await expect(buildDesktop(input)).rejects.toThrow(/native binding/)
expect(files(input.out)).toEqual(built)
}, 30000)