fix install e2e update-window setup

This commit is contained in:
ethernet
2026-09-22 12:09:47 -04:00
parent 8da6c5f386
commit 8ba2b2b558
6 changed files with 102 additions and 34 deletions

View File

@@ -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.

View File

@@ -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']) {

View File

@@ -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');

View File

@@ -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 };
}

View File

@@ -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<UpdateWindowChatOptions, 'executable' | 'origin' | 'root' | 'userData'>,
): void
export function assertUpdateWindowBackendOrigin(
backend: NativeProcess,
identity: UpdateWindowIdentity,

View File

@@ -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());