Files
hermes-agent/tests-js/desktop-builder.test.mjs
Brooklyn Nicholson 8bcca88282 test(desktop): match the desktop.mjs compile step with native path separators
The --icons case found the compile command with endsWith('/scripts/build/desktop.mjs'),
which never matches the path.join-built argument on Windows, so the case failed with
TypeError instead of asserting. Match both separator styles.

Fixes https://github.com/NousResearch/hermes-agent/issues/125139
2026-09-27 08:34:17 -05:00

209 lines
13 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' }))
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('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)