Files
hermes-agent/apps/desktop/scripts/commit-bundles.windows.test.mjs
ethernet c4e2d937f7 fix(desktop): isolate canary and commit package identities
Canary and commit builds must not replace stable or share its desktop
state. Package names alone are insufficient because Electron reads the
product name before main initializes its paths. Pin nonstable userData
before the first lookup, and keep the packaged identity independent of
runtime build variables.

Keep release artifact filenames unchanged. Qualify payload CLI names,
route each nonstable MSIX alias to its own entrypoint, and copy the
immutable desktop provenance into the embedded Python checkout. Only
stable releases can use the official Store identity.

Targeted validation: 75 JavaScript tests passed, 2 platform skips;
15 Python tests passed with file retries disabled. Native Windows SDK
manifest proof is tracked separately. Full app install, signing and macOS
launch validation are not claimed.
2026-09-11 19:59:38 -04:00

139 lines
8.7 KiB
JavaScript

import assert from 'node:assert/strict'
import { execFileSync, spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import { ensureWindowsBundleTools } from './windows-bundle-tools.mjs'
const repo = path.resolve(import.meta.dirname, '../../..')
const sha256 = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex')
function run(command, args, cwd, env) {
return execFileSync(command, args, { cwd, env, encoding: 'utf8', timeout: 60_000, stdio: ['ignore', 'pipe', 'pipe'] })
}
function fixture(kit) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'commit-msix-'))
const desktop = path.join(root, 'apps/desktop')
fs.mkdirSync(path.join(desktop, 'scripts'), { recursive: true })
fs.mkdirSync(path.join(root, 'scripts'))
for (const file of ['stage-msixbundle.mjs', 'bundle-store-msixbundle.mjs', 'msix-shared.mjs', 'release-content-types.json']) {
fs.copyFileSync(path.join(repo, 'scripts', file), path.join(root, 'scripts', file))
}
fs.copyFileSync(path.join(repo, 'apps/desktop/scripts/windows-bundle-tools.mjs'), path.join(desktop, 'scripts/windows-bundle-tools.mjs'))
fs.copyFileSync(path.join(repo, 'apps/desktop/product-identity.cjs'), path.join(desktop, 'product-identity.cjs'))
fs.writeFileSync(path.join(desktop, 'package.json'), JSON.stringify({ name: 'fixture', version: '0.21.1' }))
fs.writeFileSync(path.join(desktop, 'electron-builder.config.cjs'), `module.exports=${JSON.stringify({ directories: { buildResources: kit }, toolsets: { winCodeSign: { url: 'file://' + kit } } })}\n`)
fs.symlinkSync(path.join(repo, 'node_modules'), path.join(root, 'node_modules'), 'junction')
const env = Object.fromEntries(Object.entries(process.env).filter(([key]) =>
!/^(AZURE_|CLOUDFLARE_|HERMES_|GITHUB_|GH_|NODE_OPTIONS$)/i.test(key)))
Object.assign(env, { HERMES_PAYLOAD_TAG: '', CI: '', GIT_CONFIG_GLOBAL: path.join(root, 'git-config'),
GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'Fixture', GIT_AUTHOR_EMAIL: 'fixture@example.invalid',
GIT_COMMITTER_NAME: 'Fixture', GIT_COMMITTER_EMAIL: 'fixture@example.invalid',
GIT_AUTHOR_DATE: '2026-09-10T10:20:30Z', GIT_COMMITTER_DATE: '2026-09-10T10:20:30Z' })
for (const args of [['init', '-q'], ['add', 'scripts', 'apps'], ['-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture']]) run('git', args, root, env)
const commit = run('git', ['rev-parse', 'HEAD'], root, env).trim()
env.HERMES_BUILD_COMMIT = commit
env.HERMES_PAYLOAD_VERSION = '0.21.1'
const release = path.join(desktop, 'release')
fs.mkdirSync(release)
return { root, desktop, env, commit, release }
}
function identity(root, env, variant) {
return JSON.parse(run(process.execPath, ['--input-type=module', '-e',
"import{appIdentity}from'./scripts/msix-shared.mjs';console.log(JSON.stringify(appIdentity(process.cwd()+'/apps/desktop','')))"],
root, { ...env, HERMES_DESKTOP_VARIANT: variant }))
}
function makePackage(makeappx, root, release, metadata, variant, arch, env) {
const content = path.join(root, `${variant}-${arch}`)
fs.mkdirSync(content)
fs.mkdirSync(path.join(content, 'assets'))
for (const name of ['StoreLogo.png', 'Square150x150Logo.png', 'Square44x44Logo.png']) {
fs.copyFileSync(path.join(repo, 'apps/desktop/assets/appx', name), path.join(content, 'assets', name))
}
const name = metadata.identity.store ? metadata.identity.storeMsix.identityName : metadata.identity.msixAppIdWithOrg
const publisher = metadata.identity.store ? metadata.identity.storeMsix.publisher : 'CN=Fixture'
fs.writeFileSync(path.join(content, 'index.html'), '<!doctype html><title>Assembly fixture</title>Not an installed Hermes application.')
fs.writeFileSync(path.join(content, 'AppxManifest.xml'), `<?xml version="1.0" encoding="utf-8"?>
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" IgnorableNamespaces="uap">
<Identity Name="${name}" Publisher="${publisher}" Version="${metadata.version}" ProcessorArchitecture="${arch}" />
<Properties><DisplayName>Assembly fixture</DisplayName><PublisherDisplayName>Fixture</PublisherDisplayName><Logo>assets\\StoreLogo.png</Logo></Properties>
<Resources><Resource Language="en-us" /></Resources>
<Dependencies><TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.22621.0" MaxVersionTested="10.0.26100.0" /></Dependencies>
<Applications><Application Id="Fixture" StartPage="index.html"><uap:VisualElements DisplayName="Assembly fixture" Description="No installed application" BackgroundColor="transparent" Square150x150Logo="assets\\Square150x150Logo.png" Square44x44Logo="assets\\Square44x44Logo.png" /></Application></Applications>
</Package>\n`)
const file = path.join(release, `${variant === 'store' ? 'Store-' : ''}${metadata.name}-${metadata.fileVersion}-win-${arch}.msix`)
run(makeappx, ['pack', '/o', '/d', content, '/p', file], root, env)
assert.ok(fs.statSync(file).size > 0)
return file
}
test.runIf(process.platform === 'win32')('commit assembly preserves per-arch packages and rejects Store bundles', { timeout: 180_000 }, async () => {
const tools = await ensureWindowsBundleTools()
const { root, env, commit, release } = fixture(path.dirname(path.dirname(tools.makeappx)))
try {
const info = identity(root, env, 'bundled')
assert.throws(() => identity(root, env, 'store'), /Store.*stable/)
const inputs = {}
for (const arch of ['x64', 'arm64']) {
const file = makePackage(tools.makeappx, root, release, info, 'bundled', arch, env)
inputs[file] = sha256(file)
}
const script = path.join(root, 'scripts/stage-msixbundle.mjs')
const args = ['--commit', commit, '--version', '0.21.1', '--variant', 'bundled', '--no-upload']
const result = spawnSync(process.execPath, [script, ...args], { cwd: root, env, encoding: 'utf8', timeout: 60_000 })
assert.equal(result.status, 0, result.stdout + result.stderr)
const bundle = path.join(release, `${info.name}-${info.version}-win.msixbundle`)
assert.ok(fs.statSync(bundle).size > 0)
const expanded = path.join(root, 'expanded')
run(tools.makeappx, ['unbundle', '/o', '/p', bundle, '/d', expanded], root, env)
const xml = fs.readFileSync(path.join(expanded, 'AppxMetadata/AppxBundleManifest.xml'), 'utf8')
const identityTag = /<Identity\b[^>]*>/.exec(xml)[0]
assert.ok(identityTag.includes(`Version="${info.version}"`), xml)
const bundledNames = [...xml.matchAll(/FileName="([^"]+\.msix)"/g)].map(match => match[1]).sort()
const expected = Object.keys(inputs).map(file => path.basename(file)).sort()
assert.deepEqual(bundledNames, expected)
for (const name of expected) assert.equal(sha256(path.join(expanded, name)), inputs[path.join(release, name)])
const digest = sha256(bundle)
const modified = fs.statSync(bundle).mtimeMs
const invalid = [
[...args, '--candidate'], [...args, '--tag', 'v0.21.1'], [...args, '--unknown'],
['--commit', commit.slice(0, 8), '--version', '0.21.1', '--no-upload'],
['--commit', commit, '--version', '1.65536.0', '--no-upload'],
args.filter(value => value !== '--no-upload'),
]
for (const badArgs of invalid) {
const rejected = spawnSync(process.execPath, [script, ...badArgs], { cwd: root, env, encoding: 'utf8', timeout: 30_000 })
assert.notEqual(rejected.status, 0, `accepted ${badArgs.join(' ')}\n${rejected.stdout}${rejected.stderr}`)
assert.equal(sha256(bundle), digest)
assert.equal(fs.statSync(bundle).mtimeMs, modified)
}
const before = fs.readdirSync(release).sort()
const store = spawnSync(process.execPath, [path.join(root, 'scripts/bundle-store-msixbundle.mjs'), '--commit', commit], { cwd: root, env, encoding: 'utf8' })
assert.notEqual(store.status, 0)
assert.match(store.stderr, /Unknown option.*commit/)
assert.deepEqual(fs.readdirSync(release).sort(), before)
const missing = path.join(release, expected[0])
fs.renameSync(missing, `${missing}.held`)
try {
const refused = spawnSync(process.execPath, [script, ...args], { cwd: root, env, encoding: 'utf8', timeout: 30_000 })
assert.notEqual(refused.status, 0)
assert.match(refused.stdout + refused.stderr, /need both per-arch/)
assert.equal(sha256(bundle), digest)
assert.equal(fs.statSync(bundle).mtimeMs, modified)
} finally {
fs.renameSync(`${missing}.held`, missing)
}
assert.equal(fs.readdirSync(release).some(name => name.endsWith('.appinstaller')), false)
for (const [file, inputDigest] of Object.entries(inputs)) assert.equal(sha256(file), inputDigest)
} finally {
fs.rmSync(root, { recursive: true, force: true })
}
})