From 8ba2b2b558720b6aaca116a8384491e3d642b4a0 Mon Sep 17 00:00:00 2001 From: ethernet Date: Tue, 22 Sep 2026 12:09:47 -0400 Subject: [PATCH] fix install e2e update-window setup --- tests-js/scripts/desktop-smoke.test.ts | 62 ++++++++++++++----- tests/install/e2e-assets/launch-from-spec.mjs | 1 + .../e2e-assets/mac-bundled-update-driver.mjs | 10 ++- .../e2e-assets/source-runtime-settle.mjs | 23 ++++--- .../e2e-assets/update-window-chat.d.mts | 12 ++++ .../install/e2e-assets/update-window-chat.mjs | 28 ++++++--- 6 files changed, 102 insertions(+), 34 deletions(-) diff --git a/tests-js/scripts/desktop-smoke.test.ts b/tests-js/scripts/desktop-smoke.test.ts index 0a046e12b3..2d34b6aa5c 100644 --- a/tests-js/scripts/desktop-smoke.test.ts +++ b/tests-js/scripts/desktop-smoke.test.ts @@ -8,7 +8,7 @@ import { expect, test } from 'vitest' import { candidateSmokeHermesHomes, predictSmokeHermesHome, resolveSmokeLaunch, runInstalledDesktopSmoke, smokeEnvironment } from '../../tests/install/e2e-assets/desktop-smoke.ts' import { sourceRuntimeSettleCommand } from '../../tests/install/e2e-assets/source-runtime-settle.mjs' -import { assertUpdateWindowBackendOrigin } from '../../tests/install/e2e-assets/update-window-chat.mjs' +import { assertUpdateWindowBackendOrigin, assertUpdateWindowProcess } from '../../tests/install/e2e-assets/update-window-chat.mjs' import { assertChatCommit, newCompletedPair, readMockPrompts, type TranscriptMessage } from './desktop-chat-smoke.ts' import { assertBackendOrigin, localBackendProcess, readBundledBundleEnv, readInstallationCommit } from './desktop-smoke-process.ts' @@ -364,49 +364,79 @@ printf 'clean source runtime settled\\n' } finally { fs.rmSync(workspace, { recursive: true, force: true }) } }) -test('Windows source settle prefers the current cmd launcher over a stale historical exe', (): void => { +test('Windows source settle bypasses the current cmd launcher beside a stale historical exe', (): void => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'smoke-windows-settle-')) try { const bin = path.join(root, '.hermes', 'bin') fs.mkdirSync(bin, { recursive: true }) const current = path.join(bin, 'hermes.cmd') - fs.writeFileSync(current, '@echo off\r\n') + const python = path.join(root, 'managed python', 'python.exe') + fs.mkdirSync(path.dirname(python), { recursive: true }) + fs.writeFileSync(python, '') + const bootstrap = path.join(root, 'hermes_bootstrap.py') + fs.writeFileSync(bootstrap, '') + fs.writeFileSync(current, `@"${python}" -I -c "import base64; exec(base64.b64decode('eA=='))" %*\r\n`) fs.writeFileSync(path.join(bin, 'hermes.exe'), 'locked historical launcher') const invocation = sourceRuntimeSettleCommand(root, { ComSpec: 'C:\\Windows\\System32\\cmd.exe' }, 'win32') expect(invocation).toEqual({ launcher: current, - command: 'C:\\Windows\\System32\\cmd.exe', - args: ['/d', '/s', '/c', `""${current}" status"`], - windowsVerbatimArguments: true, + command: python, + args: ['-I', '-B', '-c', `import runpy, sys; sys.path.insert(0, ${JSON.stringify(root)}); runpy.run_path(${JSON.stringify(bootstrap)}, run_name='__main__')`], + windowsVerbatimArguments: false, }) } finally { fs.rmSync(root, { recursive: true, force: true }) } }) -test.runIf(process.platform === 'win32')('Windows source settle executes a cmd launcher whose path contains spaces', (): void => { +test('Windows source settle bypasses the generated cmd command line', (): void => { const workspace = fs.mkdtempSync(path.join(os.tmpdir(), 'smoke-windows-settle-live-')) const root = path.join(workspace, 'source with spaces') try { const bin = path.join(root, '.hermes', 'bin') const witness = path.join(workspace, 'settled.txt') fs.mkdirSync(bin, { recursive: true }) - fs.writeFileSync(path.join(bin, 'hermes.cmd'), [ - '@echo off', - 'if not "%~1"=="status" exit /b 91', - `>"${witness}" echo current-cmd`, - 'exit /b 0', - '', - ].join('\r\n')) + const pythonProbe = spawnSync('python', ['-c', 'import sys; print(sys.executable)'], { encoding: 'utf8' }) + expect(pythonProbe.status, pythonProbe.stderr || String(pythonProbe.error)).toBe(0) + const python = pythonProbe.stdout.trim() + fs.writeFileSync(path.join(bin, 'hermes.cmd'), `@"${python}" -I -c "import base64; exec(base64.b64decode('eA=='))" %*\r\n`) + fs.writeFileSync(path.join(root, 'hermes_bootstrap.py'), `from pathlib import Path\nPath(${JSON.stringify(witness)}).write_text('direct-bootstrap\\n')\n`) fs.writeFileSync(path.join(bin, 'hermes.exe'), 'locked historical launcher') - const invocation = sourceRuntimeSettleCommand(root, process.env) + const invocation = sourceRuntimeSettleCommand(root, process.env, 'win32') const result = spawnSync(invocation.command, invocation.args, { cwd: root, env: process.env, encoding: 'utf8', windowsHide: true, windowsVerbatimArguments: invocation.windowsVerbatimArguments, }) expect(result.status, result.stderr || String(result.error)).toBe(0) - expect(fs.readFileSync(witness, 'utf8').trim()).toBe('current-cmd') + expect(fs.readFileSync(witness, 'utf8').trim()).toBe('direct-bootstrap') } finally { fs.rmSync(workspace, { recursive: true, force: true }) } }) +test('update-window process checks use the isolated launch environment, not the driver environment', (): void => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'smoke-update-window-process-')) + try { + const executable = path.join(root, 'Hermes') + const isolated = path.join(root, 'isolated-user-data') + const driver = path.join(root, 'driver-user-data') + fs.writeFileSync(executable, '') + fs.mkdirSync(isolated) + fs.mkdirSync(driver) + const prior = process.env.HERMES_DESKTOP_USER_DATA_DIR + process.env.HERMES_DESKTOP_USER_DATA_DIR = driver + try { + expect(() => assertUpdateWindowProcess( + { executable, resources: root, userData: isolated }, + { executable, root, origin: 'source', userData: isolated }, + )).not.toThrow() + expect(() => assertUpdateWindowProcess( + { executable, resources: root, userData: isolated }, + { executable, root, origin: 'source', userData: driver }, + )).toThrow('OLD update window did not honor isolated userData') + } finally { + if (prior === undefined) delete process.env.HERMES_DESKTOP_USER_DATA_DIR + else process.env.HERMES_DESKTOP_USER_DATA_DIR = prior + } + } finally { fs.rmSync(root, { recursive: true, force: true }) } +}) + test('predictSmokeHermesHome replays the bundle banner through the shared resolver', (): void => { const launchEnv = { HERMES_HOME: '/pinned/home', HERMES_DESKTOP_USER_DATA_DIR: '/pinned/userdata', LOCALAPPDATA: 'C:/Users/runner/AppData/Local' } // No baked env: the driver's own HERMES_HOME pin wins. diff --git a/tests/install/e2e-assets/launch-from-spec.mjs b/tests/install/e2e-assets/launch-from-spec.mjs index 816234c4be..51c5c56ccd 100644 --- a/tests/install/e2e-assets/launch-from-spec.mjs +++ b/tests/install/e2e-assets/launch-from-spec.mjs @@ -163,6 +163,7 @@ async function main() { mockUrl: values['mock-url'], outDir: values['chat-out'], expectCommit: values['old-sha'], root: values['repo-dir'], origin: 'source', executable: launch.executablePath, + userData: launchEnv.HERMES_DESKTOP_USER_DATA_DIR, }); if (values['no-update']) { diff --git a/tests/install/e2e-assets/mac-bundled-update-driver.mjs b/tests/install/e2e-assets/mac-bundled-update-driver.mjs index bcd3f400c8..255c0854af 100644 --- a/tests/install/e2e-assets/mac-bundled-update-driver.mjs +++ b/tests/install/e2e-assets/mac-bundled-update-driver.mjs @@ -55,12 +55,19 @@ fs.mkdirSync(values.shots, { recursive: true }); fs.mkdirSync(values['chat-out'], { recursive: true }); log(`launching ${appBin}`); +// Preserve the exact environment for post-launch identity assertions. The +// driver process itself may name a different userData path. +const launchEnv = updateWindowEnvironment( + process.env, + path.resolve(path.dirname(appBin), '..', 'Resources', 'agent-payload'), + 'bundled', +); const app = await _electron.launch({ executablePath: appBin, cwd: path.dirname(appBin), // Inherit the driver env: HERMES_HOME / HOME / updates feed config must // reach the main process exactly as a user's double-click would. - env: updateWindowEnvironment(process.env, path.resolve(path.dirname(appBin), '..', 'Resources', 'agent-payload'), 'bundled'), + env: launchEnv, timeout: 120_000, }); const child = app.process(); @@ -78,6 +85,7 @@ await runUpdateWindowChat(app, page, { expectCommit: values['old-sha'], origin: 'bundled', executable: appBin, root: path.resolve(path.dirname(appBin), '..', 'Resources', 'agent-payload'), + userData: launchEnv.HERMES_DESKTOP_USER_DATA_DIR, }); await shot(page, '01-app-booted'); diff --git a/tests/install/e2e-assets/source-runtime-settle.mjs b/tests/install/e2e-assets/source-runtime-settle.mjs index 09b76bdf2d..9c65de849f 100644 --- a/tests/install/e2e-assets/source-runtime-settle.mjs +++ b/tests/install/e2e-assets/source-runtime-settle.mjs @@ -25,11 +25,20 @@ export function sourceRuntimeSettleCommand(root, env, platform = process.platfor if (platform !== 'win32' || path.extname(launcher).toLowerCase() !== '.cmd') { return { launcher, command: launcher, args: ['status'], windowsVerbatimArguments: false }; } - if (launcher.includes('"')) throw new Error('Source launcher path contains an invalid quote'); - const command = env.ComSpec || env.COMSPEC - || (env.SystemRoot ? path.join(env.SystemRoot, 'System32', 'cmd.exe') : 'cmd.exe'); - // /s applies cmd.exe's documented outer-quote stripping to this one command - // string. The doubled outer quotes keep a launcher path containing spaces - // intact while `status` remains a separate command-file argument. - return { launcher, command, args: ['/d', '/s', '/c', `""${launcher}" status"`], windowsVerbatimArguments: true }; + void env; + // PM's fallback command launcher embeds the Python bootstrap in a base64 + // `-c` argument. Running that .cmd through cmd.exe constrains the already + // long command to 8191 characters; a source update's clean-interpreter + // relaunch then exceeds CreateProcess' limit as well. Use the launcher's + // selected Python to run the source bootstrap by file instead. This drives + // the same lazy source-update completion without nesting either command. + const commandFile = fs.readFileSync(launcher, 'utf8'); + const generated = commandFile.match(/^\s*@?"([^"\r\n]+)"\s+-I(?:\s|$)/m); + if (!generated) throw new Error(`Unrecognized source command launcher: ${launcher}`); + const command = generated[1]; + const bootstrap = path.join(root, 'hermes_bootstrap.py'); + if (!fs.existsSync(command)) throw new Error(`Source launcher Python does not exist: ${command}`); + if (!fs.existsSync(bootstrap)) throw new Error(`Source bootstrap does not exist: ${bootstrap}`); + const code = `import runpy, sys; sys.path.insert(0, ${JSON.stringify(root)}); runpy.run_path(${JSON.stringify(bootstrap)}, run_name='__main__')`; + return { launcher, command, args: ['-I', '-B', '-c', code], windowsVerbatimArguments: false }; } diff --git a/tests/install/e2e-assets/update-window-chat.d.mts b/tests/install/e2e-assets/update-window-chat.d.mts index 0a37561c21..8ce557f806 100644 --- a/tests/install/e2e-assets/update-window-chat.d.mts +++ b/tests/install/e2e-assets/update-window-chat.d.mts @@ -13,8 +13,20 @@ export interface UpdateWindowChatOptions { origin: 'source' | 'bundled' root: string executable: string + userData: string } +export interface UpdateWindowProcess { + executable: string + resources: string + userData: string +} + +export function assertUpdateWindowProcess( + running: UpdateWindowProcess, + options: Pick, +): void + export function assertUpdateWindowBackendOrigin( backend: NativeProcess, identity: UpdateWindowIdentity, diff --git a/tests/install/e2e-assets/update-window-chat.mjs b/tests/install/e2e-assets/update-window-chat.mjs index 594a1c4f0b..b4cf0bab79 100644 --- a/tests/install/e2e-assets/update-window-chat.mjs +++ b/tests/install/e2e-assets/update-window-chat.mjs @@ -17,11 +17,27 @@ export function assertUpdateWindowBackendOrigin(backend, identity, root, origin) assertBackendOrigin(backend, root, origin, { appReportedRoot: identity.hermesRoot }); } +/** + * Assert the OLD process against the exact environment passed to Electron. + * The driver's process environment still names the pre-clone userData path. + */ +export function assertUpdateWindowProcess(running, options) { + if (!options.userData || fs.realpathSync(running.userData) !== fs.realpathSync(options.userData)) { + throw new Error('OLD update window did not honor isolated userData'); + } + if (fs.realpathSync(running.executable) !== fs.realpathSync(options.executable)) { + throw new Error('OLD update window executable differs from the installed app'); + } + if (options.origin === 'bundled' && fs.realpathSync(path.join(running.resources, 'agent-payload')) !== fs.realpathSync(options.root)) { + throw new Error('OLD update window resources differ from the installed payload'); + } +} + /** * @param {import('@playwright/test').ElectronApplication} app * @param {import('@playwright/test').Page} page * @param {{mockUrl: string, outDir: string, expectCommit: string, - * origin: 'source'|'bundled', root: string, executable: string}} options + * origin: 'source'|'bundled', root: string, executable: string, userData: string}} options */ export async function runUpdateWindowChat(app, page, options) { const receiptPath = path.join(options.outDir, 'desktop-chat-old.json'); @@ -32,15 +48,7 @@ export async function runUpdateWindowChat(app, page, options) { userData: electronApp.getPath('userData'), })); const { userData } = running; - if (!process.env.HERMES_DESKTOP_USER_DATA_DIR || fs.realpathSync(userData) !== fs.realpathSync(process.env.HERMES_DESKTOP_USER_DATA_DIR)) { - throw new Error('OLD update window did not honor isolated userData'); - } - if (fs.realpathSync(running.executable) !== fs.realpathSync(options.executable)) { - throw new Error('OLD update window executable differs from the installed app'); - } - if (options.origin === 'bundled' && fs.realpathSync(path.join(running.resources, 'agent-payload')) !== fs.realpathSync(options.root)) { - throw new Error('OLD update window resources differ from the installed payload'); - } + assertUpdateWindowProcess(running, options); await waitForChatReady(page); const identity = await readChatIdentity(page); const connection = await page.evaluate(() => window.hermesDesktop.getConnection());