diff --git a/tests-js/scripts/desktop-chat-smoke.ts b/tests-js/scripts/desktop-chat-smoke.ts index 50dad62844..528d010379 100644 --- a/tests-js/scripts/desktop-chat-smoke.ts +++ b/tests-js/scripts/desktop-chat-smoke.ts @@ -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 } @@ -75,9 +77,10 @@ export async function readChatIdentity(page: Page): Promise { 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() diff --git a/tests-js/scripts/desktop-reused-build-commit.test.ts b/tests-js/scripts/desktop-reused-build-commit.test.ts new file mode 100644 index 0000000000..2f9424c1cd --- /dev/null +++ b/tests-js/scripts/desktop-reused-build-commit.test.ts @@ -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 }) } +}) diff --git a/tests-js/scripts/desktop-smoke-process.ts b/tests-js/scripts/desktop-smoke-process.ts index 34bdc39176..9191c451b0 100644 --- a/tests-js/scripts/desktop-smoke-process.ts +++ b/tests-js/scripts/desktop-smoke-process.ts @@ -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 diff --git a/tests/install/e2e-assets/desktop-smoke.ts b/tests/install/e2e-assets/desktop-smoke.ts index 273ba4d65f..4261e40a44 100644 --- a/tests/install/e2e-assets/desktop-smoke.ts +++ b/tests/install/e2e-assets/desktop-smoke.ts @@ -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' }