test(install-e2e): HEAD -> NEXT legs accept the reused desktop app's build commit

Every HEAD -> NEXT install/update E2E leg (Linux, macOS, Windows) failed with
"Running commit <HEAD> does not equal expected <NEXT>". The synthetic NEXT
commit adds only a marker file, so since 7a954418ce `hermes update` keeps the
packaged desktop app ("no desktop build input changed"), and that app keeps
naming the commit it was built from by design.

The smoke now accepts the app reporting NEXT's parent only when the installed
commit is a source checkout whose single change from that parent is the
synthetic marker file. Any real commit still requires the app to report it,
so a stale app after a real desktop change stays a failure.
This commit is contained in:
Teknium committed 2026-11-02 00:02:44 -08:00
1 parent 4d26e0ccea
commit ddc8c31514
4 files changed
+86 -6

No files matched your search

+7 -4
View File
@@ -16,6 +16,8 @@ export interface DesktopChatSmokeOptions {
outDir: string
expectCommit?: string
provenanceCommit?: string
/** A commit the running app may report instead of expectCommit: the build a reused packaged app came from. */
reusedBuildCommit?: string
observePrompts?: () => Promise<readonly string[]>
}
@@ -75,9 +77,10 @@ export async function readChatIdentity(page: Page): Promise<ChatIdentity> {
return { ...identity, commit: identity.commit ?? null }
}
/** Old desktops do not report a commit; their caller must verify the installed runtime. */
export function assertChatCommit(identity: ChatIdentity, expected: string, provenanceCommit?: string): void {
if (identity.commit !== null && identity.commit !== expected) {
/** Old desktops do not report a commit; their caller must verify the installed runtime.
* A reused packaged app reports the commit it was built from (`reusedBuildCommit`). */
export function assertChatCommit(identity: ChatIdentity, expected: string, provenanceCommit?: string, reusedBuildCommit?: string): void {
if (identity.commit !== null && identity.commit !== expected && identity.commit !== reusedBuildCommit) {
throw new Error(`Running commit ${identity.commit} does not equal expected ${expected}`)
}
@@ -279,7 +282,7 @@ export async function runDesktopChatSmoke(page: Page, options: DesktopChatSmokeO
const identity = await readChatIdentity(page)
if (expectCommit) { assertChatCommit(identity, expectCommit, options.provenanceCommit) }
if (expectCommit) { assertChatCommit(identity, expectCommit, options.provenanceCommit, options.reusedBuildCommit) }
const beforeIds = (await readTranscript(page)).map((message: TranscriptMessage): string => message.id)
const before = (await observe()).length
await composer.click()
@@ -0,0 +1,48 @@
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { expect, test } from 'vitest'
import { assertChatCommit } from './desktop-chat-smoke.ts'
import { reusedDesktopBuildCommit } from './desktop-smoke-process.ts'
function git(repo: string, ...args: string[]): string {
const result = spawnSync('git', ['-C', repo, ...args], {
encoding: 'utf8',
env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t.invalid', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t.invalid' },
})
if (result.status !== 0) { throw new Error(result.stderr) }
return result.stdout.trim()
}
function commitFile(repo: string, name: string): string {
fs.writeFileSync(path.join(repo, name), `${name}\n`)
git(repo, 'add', name)
git(repo, 'commit', '-q', '-m', name)
return git(repo, 'rev-parse', 'HEAD')
}
test('a reused packaged app may report its build commit only across the synthetic NEXT commit', (): void => {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'reused-build-'))
try {
git(repo, 'init', '-q')
const head = commitFile(repo, 'app.ts')
const next = commitFile(repo, '.hermes-e2e-next')
const app = { appVersion: '0.0.0', commit: head, hermesRoot: repo, platform: process.platform }
// HEAD -> NEXT: only the marker changed, so the app built at HEAD stands for NEXT.
const reused = reusedDesktopBuildCommit(repo, 'source', next)
expect(reused).toBe(head)
expect((): void => assertChatCommit(app, next, next, reused)).not.toThrow()
// A commit that changes anything else gives no allowance: a stale app stays a failure.
const real = commitFile(repo, 'feature.ts')
expect(reusedDesktopBuildCommit(repo, 'source', real)).toBeUndefined()
expect((): void => assertChatCommit({ ...app, commit: next }, real, real, reusedDesktopBuildCommit(repo, 'source', real))).toThrow('does not equal expected')
} finally { fs.rmSync(repo, { recursive: true, force: true }) }
})
+28
View File
@@ -63,6 +63,34 @@ export function readInstallationCommit(root: string, origin: 'source' | 'bundled
return z.string().regex(/^[0-9a-f]{40}$/).parse(commit)
}
/** The file the HEAD -> NEXT legs' synthetic commit adds (installer-common.sh::resolve_update_ref,
* windows-e2e.ps1::New-NextCommit). */
const SYNTHETIC_NEXT_MARKER = '.hermes-e2e-next'
/** The commit a reused packaged app may still report for an installed source checkout.
*
* `hermes update` keeps the packaged app when no desktop build input changed, and that app keeps
* naming the commit it was built from (hermes_cli/desktop_build_reuse.py). The harness can prove
* that case only for the synthetic NEXT commit, whose sole change is one marker file: there the
* parent is the commit the app may report. Any other installed commit, and any bundled install,
* returns undefined. */
export function reusedDesktopBuildCommit(root: string, origin: 'source' | 'bundled', installed: string): string | undefined {
if (origin !== 'source') {
return undefined
}
const git: string = process.env.HERMES_E2E_REAL_GIT || 'git'
const parents = nativeText(git, ['-C', root, 'rev-list', '--parents', '-n', '1', installed]).split(/\s+/).slice(1)
if (parents.length !== 1) {
return undefined
}
const changed = nativeText(git, ['-C', root, 'diff', '--name-only', parents[0], installed])
return changed === SYNTHETIC_NEXT_MARKER ? parents[0] : undefined
}
const bundleEnvSchema = z.record(z.string(), z.string().nullable())
/** The baked runtime defaults/clears of a bundled artifact, recorded in the
+3 -2
View File
@@ -12,7 +12,7 @@ import { z } from 'zod'
import { resolveDesktopHermesHome } from '../../../apps/desktop/electron/data-paths.mjs'
import { applyBundleEnvironment } from '../../../apps/desktop/scripts/bundle-env.mjs'
import { type ChatIdentity, readChatIdentity, runDesktopChatSmoke, waitForChatReady } from '../../../tests-js/scripts/desktop-chat-smoke.ts'
import { assertBackendOrigin, localBackendProcess, readBundledBundleEnv, readInstallationCommit } from '../../../tests-js/scripts/desktop-smoke-process.ts'
import { assertBackendOrigin, localBackendProcess, readBundledBundleEnv, readInstallationCommit, reusedDesktopBuildCommit } from '../../../tests-js/scripts/desktop-smoke-process.ts'
import { validateMockUrl, writeEnvFile, writeMockProviderConfig } from '../../../tests-js/scripts/mock-provider-config.ts'
import { type MockServer, startMockServer } from '../../../tests-js/scripts/mock-server.ts'
@@ -353,7 +353,8 @@ export async function runInstalledDesktopSmoke(options: SmokeOptions, launchApp:
if (provenanceCommit !== options['expect-commit']) { throw new Error('Installed commit differs from --expect-commit') }
// Trace only chat actions. Connection probes can return credential-bearing logs.
await app.context().tracing.start({ screenshots: true, snapshots: false, sources: false })
const chat = await runDesktopChatSmoke(page, { mockUrl, phase: options.phase, outDir: out, expectCommit: options['expect-commit'], provenanceCommit })
const reusedBuildCommit = reusedDesktopBuildCommit(options.root, options.origin, provenanceCommit)
const chat = await runDesktopChatSmoke(page, { mockUrl, phase: options.phase, outDir: out, expectCommit: options['expect-commit'], provenanceCommit, reusedBuildCommit })
fs.writeFileSync(receiptPath, `${JSON.stringify({ ...chat, status: 'closing' }, null, 2)}\n`)
const result = { ...chat, origin: options.origin, root: options.root, executable: options.exe, running, predictedHome,
localModeConfigured: true, backend: { ...backend, command: redact(backend.command) }, launchKind: options.phase === 'new' ? 'post-update-launch' : 'installed-launch' }