fix install e2e update-window setup
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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']) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user