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') // Assembly does not depend on production artwork or the icon build step. const iconsScript = path.join(root, 'fixture-icons.ps1') fs.writeFileSync(iconsScript, String.raw` param([string]$Dir) $ErrorActionPreference = 'Stop' Add-Type -AssemblyName System.Drawing New-Item -ItemType Directory -Force $Dir | Out-Null foreach ($asset in @(@('StoreLogo.png',50), @('Square150x150Logo.png',150), @('Square44x44Logo.png',44))) { $image = New-Object System.Drawing.Bitmap([int]$asset[1], [int]$asset[1]) try { $image.Save((Join-Path $Dir $asset[0]), [System.Drawing.Imaging.ImageFormat]::Png) } finally { $image.Dispose() } } `) run('powershell.exe', ['-NoProfile', '-NonInteractive', '-File', iconsScript, '-Dir', path.join(root, 'icons')], root) 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(root, 'icons', 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'), 'Assembly fixtureNot an installed Hermes application.') fs.writeFileSync(path.join(content, 'AppxManifest.xml'), ` Assembly fixtureFixtureassets\\StoreLogo.png \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 = /]*>/.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 }) } })