mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
test(desktop): extract Windows hermes-resolution helpers for real unit tests
windows-hermes-resolution.test.ts regexed main.ts source text to check three Windows resolution bugs that caused desktop reinstall loops: 1. findOnPath()'s PATHEXT extension order (must try real extensions before the empty one, or an extensionless Git-Bash hermes shim shadows hermes.cmd/.exe). 2. handOffWindowsBootstrapRecovery()'s --update vs --repair choice (must gate on any real-install signal, not just the hermes.exe shim). 3. unwrapWindowsVenvHermesCommand()'s probe-before-trust behavior (must canImportHermesCli() before returning a venv python, or a broken venv gets re-selected forever). Extract all three into pure, dependency-injected functions in windows-hermes-path.ts (no Electron import): buildPathExtCandidates(), chooseUpdaterArgs(), resolveVenvHermesCommand(). main.ts's findOnPath/handOffWindowsBootstrapRecovery/unwrapWindowsVenvHermesCommand now call these with their existing helpers (fileExists, canImportHermesCli, getVenvPython, etc.) passed through as deps. windows-hermes-path.test.ts replaces windows-hermes-resolution.test.ts, importing the pure functions directly and asserting real return values with fake/injected dependencies (fake venvs, fake probes) -- no readFileSync, no regex-on-source.
This commit is contained in:
parent
4527943e91
commit
ec2cb3ab47
5 changed files with 364 additions and 155 deletions
|
|
@ -129,6 +129,7 @@ import { hiddenWindowsChildOptions } from './windows-child-options'
|
|||
import { readWindowsUserEnvVar } from './windows-user-env'
|
||||
import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd'
|
||||
import { readWslWindowsClipboardImage } from './wsl-clipboard-image'
|
||||
import { buildPathExtCandidates, chooseUpdaterArgs, resolveVenvHermesCommand } from './windows-hermes-path'
|
||||
|
||||
const USER_DATA_OVERRIDE = process.env.HERMES_DESKTOP_USER_DATA_DIR
|
||||
|
||||
|
|
@ -1415,9 +1416,7 @@ function findOnPath(command) {
|
|||
// shell-script shim named `hermes` — must not shadow `hermes.cmd`/`hermes.exe`.
|
||||
// The empty entry is kept LAST so callers that already include the extension
|
||||
// (py.exe, pwsh.exe, powershell.exe) still resolve.
|
||||
const extensions = IS_WINDOWS
|
||||
? [...(process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), '']
|
||||
: ['']
|
||||
const extensions = buildPathExtCandidates(process.env.PATHEXT, IS_WINDOWS)
|
||||
|
||||
for (const entry of pathEntries) {
|
||||
for (const extension of extensions) {
|
||||
|
|
@ -1437,71 +1436,21 @@ function isCommandScript(command) {
|
|||
}
|
||||
|
||||
function unwrapWindowsVenvHermesCommand(command, backendArgs) {
|
||||
if (!IS_WINDOWS || !command || isCommandScript(command)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resolved = path.resolve(String(command))
|
||||
|
||||
if (!/^hermes(?:\.exe)?$/i.test(path.basename(resolved))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scriptsDir = path.dirname(resolved)
|
||||
|
||||
if (path.basename(scriptsDir).toLowerCase() !== 'scripts') {
|
||||
return null
|
||||
}
|
||||
|
||||
const venvRoot = path.dirname(scriptsDir)
|
||||
const python = getVenvPython(venvRoot)
|
||||
|
||||
if (!fileExists(python)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const root = path.dirname(venvRoot)
|
||||
|
||||
// Smoke-test the venv interpreter before trusting it. A venv whose update
|
||||
// died mid-`pip install` still has python.exe + hermes.exe on disk, but the
|
||||
// backend dies on its first import (e.g. ModuleNotFoundError: dotenv) before
|
||||
// the gateway ever binds. Returning it here also BYPASSED the caller's
|
||||
// `--version` probe, so Retry/"Repair install" re-resolved the same broken
|
||||
// venv forever instead of falling through to the bootstrap installer.
|
||||
// Mirror isActiveRuntimeUsable(): probe with the checkout on PYTHONPATH so a
|
||||
// healthy source-tree venv passes.
|
||||
if (
|
||||
!canImportHermesCli(python, {
|
||||
env: {
|
||||
PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH]
|
||||
.filter(Boolean)
|
||||
.join(path.delimiter)
|
||||
}
|
||||
})
|
||||
) {
|
||||
rememberLog(
|
||||
`Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
label: `existing Hermes Python at ${python}`,
|
||||
command: python,
|
||||
args: ['-m', 'hermes_cli.main', ...backendArgs],
|
||||
bootstrap: false,
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome: HERMES_HOME,
|
||||
pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)],
|
||||
venvRoot
|
||||
}),
|
||||
kind: 'python',
|
||||
// Surfaced so backendSupportsServe() can read this runtime's source for the
|
||||
// `serve` capability check instead of falling back to a heavyweight probe.
|
||||
root,
|
||||
shell: false
|
||||
}
|
||||
return resolveVenvHermesCommand(command, backendArgs, {
|
||||
isWindows: IS_WINDOWS,
|
||||
isCommandScript,
|
||||
fileExists,
|
||||
directoryExists,
|
||||
canImportHermesCli,
|
||||
getVenvPython,
|
||||
getVenvSitePackagesEntries,
|
||||
buildDesktopBackendEnv,
|
||||
hermesHome: HERMES_HOME,
|
||||
resolvePath: (...segments) => path.resolve(...segments),
|
||||
dirname: p => path.dirname(p),
|
||||
basename: p => path.basename(p),
|
||||
rememberLog
|
||||
})
|
||||
}
|
||||
|
||||
// Does the resolved runtime understand the `serve` subcommand? The desktop
|
||||
|
|
@ -2656,7 +2605,7 @@ async function handOffWindowsBootstrapRecovery(reason) {
|
|||
const haveRealInstall =
|
||||
fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
|
||||
|
||||
const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
const updaterArgs = chooseUpdaterArgs(haveRealInstall, branch)
|
||||
|
||||
await releaseBackendLockForUpdate(updateRoot)
|
||||
|
||||
|
|
|
|||
139
apps/desktop/electron/windows-hermes-path.test.ts
Normal file
139
apps/desktop/electron/windows-hermes-path.test.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// Unit tests for the pure Windows `hermes` resolution helpers extracted from
|
||||
// main.ts's findOnPath(), handOffWindowsBootstrapRecovery(), and
|
||||
// unwrapWindowsVenvHermesCommand(). These pin the two Windows resolution bugs
|
||||
// that caused desktop reinstall loops:
|
||||
// 1. buildPathExtCandidates() — PATHEXT extensions must be tried BEFORE the
|
||||
// empty extension, or an extensionless Git-Bash `hermes` shim shadows
|
||||
// the real hermes.cmd/hermes.exe.
|
||||
// 2. chooseUpdaterArgs() — must gate on haveRealInstall (any real-install
|
||||
// signal), not just the hermes.exe console-script shim, or healthy
|
||||
// installs get forced into a destructive --repair.
|
||||
// 3. resolveVenvHermesCommand() — must probe the venv python via
|
||||
// canImportHermesCli() before trusting it, or a broken venv gets
|
||||
// re-selected forever instead of falling through to bootstrap.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { buildPathExtCandidates, chooseUpdaterArgs, resolveVenvHermesCommand } from './windows-hermes-path'
|
||||
|
||||
test('buildPathExtCandidates: Windows tries PATHEXT extensions before the empty extension', () => {
|
||||
const extensions = buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', true)
|
||||
|
||||
assert.deepEqual(extensions, ['.COM', '.EXE', '.BAT', '.CMD', ''])
|
||||
assert.equal(extensions[extensions.length - 1], '', 'empty extension must be last, not first')
|
||||
assert.notEqual(extensions[0], '', 'the buggy empty-extension-first order must not return')
|
||||
})
|
||||
|
||||
test('buildPathExtCandidates: defaults to .COM;.EXE;.BAT;.CMD when PATHEXT is unset on Windows', () => {
|
||||
assert.deepEqual(buildPathExtCandidates(undefined, true), ['.COM', '.EXE', '.BAT', '.CMD', ''])
|
||||
})
|
||||
|
||||
test('buildPathExtCandidates: respects a custom PATHEXT, still empty-last', () => {
|
||||
assert.deepEqual(buildPathExtCandidates('.EXE;.PS1', true), ['.EXE', '.PS1', ''])
|
||||
})
|
||||
|
||||
test('buildPathExtCandidates: non-Windows only tries the bare name', () => {
|
||||
assert.deepEqual(buildPathExtCandidates('.COM;.EXE;.BAT;.CMD', false), [''])
|
||||
assert.deepEqual(buildPathExtCandidates(undefined, false), [''])
|
||||
})
|
||||
|
||||
test('chooseUpdaterArgs: gentle --update when a real-install signal is present', () => {
|
||||
assert.deepEqual(chooseUpdaterArgs(true, 'main'), ['--update', '--branch', 'main'])
|
||||
})
|
||||
|
||||
test('chooseUpdaterArgs: destructive --repair only when NO real-install signal is present', () => {
|
||||
assert.deepEqual(chooseUpdaterArgs(false, 'main'), ['--repair', '--branch', 'main'])
|
||||
})
|
||||
|
||||
test('chooseUpdaterArgs: passes the branch through unchanged in both cases', () => {
|
||||
assert.deepEqual(chooseUpdaterArgs(true, 'release/1.2'), ['--update', '--branch', 'release/1.2'])
|
||||
assert.deepEqual(chooseUpdaterArgs(false, 'release/1.2'), ['--repair', '--branch', 'release/1.2'])
|
||||
})
|
||||
|
||||
function makeDeps(overrides: Partial<Parameters<typeof resolveVenvHermesCommand>[2]> = {}) {
|
||||
return {
|
||||
isWindows: true,
|
||||
isCommandScript: () => false,
|
||||
fileExists: () => true,
|
||||
directoryExists: () => false,
|
||||
canImportHermesCli: () => true,
|
||||
getVenvPython: (venvRoot: string) => `${venvRoot}/Scripts/python.exe`,
|
||||
getVenvSitePackagesEntries: () => [],
|
||||
buildDesktopBackendEnv: () => ({ FAKE_ENV: '1' }),
|
||||
hermesHome: '/fake/hermes-home',
|
||||
resolvePath: (...segments: string[]) => segments.join('/').replace(/\/+/g, '/'),
|
||||
dirname: (p: string) => p.slice(0, p.lastIndexOf('/')) || '/',
|
||||
basename: (p: string) => p.slice(p.lastIndexOf('/') + 1),
|
||||
rememberLog: () => {},
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
test('resolveVenvHermesCommand: returns null off Windows', () => {
|
||||
const deps = makeDeps({ isWindows: false })
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null for a .cmd/.bat script command', () => {
|
||||
const deps = makeDeps({ isCommandScript: () => true })
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.cmd', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null when the basename is not hermes/hermes.exe', () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/python.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null when the parent dir is not Scripts', () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/bin/hermes.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns null when the venv python does not exist on disk', () => {
|
||||
const deps = makeDeps({ fileExists: () => false })
|
||||
|
||||
assert.equal(resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', [], deps), null)
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: probes the venv python before trusting it (returns null on failed probe)', () => {
|
||||
let probed = false
|
||||
|
||||
const deps = makeDeps({
|
||||
canImportHermesCli: (python: string) => {
|
||||
probed = true
|
||||
assert.equal(python, '/root/venv/Scripts/python.exe')
|
||||
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve'], deps)
|
||||
|
||||
assert.equal(probed, true, 'must probe the venv interpreter; a broken venv must not be re-selected forever')
|
||||
assert.equal(result, null, 'a failed probe must fall through (return null) so the resolver reaches bootstrap')
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: returns the resolved python backend descriptor when the probe passes', () => {
|
||||
const deps = makeDeps()
|
||||
const result = resolveVenvHermesCommand('/root/venv/Scripts/hermes.exe', ['serve', '--port', '0'], deps)
|
||||
|
||||
assert.ok(result, 'a passing probe must return a backend descriptor, not null')
|
||||
assert.equal(result.command, '/root/venv/Scripts/python.exe')
|
||||
assert.deepEqual(result.args, ['-m', 'hermes_cli.main', 'serve', '--port', '0'])
|
||||
assert.equal(result.bootstrap, false)
|
||||
assert.equal(result.kind, 'python')
|
||||
assert.equal(result.shell, false)
|
||||
assert.deepEqual(result.env, { FAKE_ENV: '1' })
|
||||
})
|
||||
|
||||
test('resolveVenvHermesCommand: is case-insensitive on hermes.exe and the Scripts dir name', () => {
|
||||
const deps = makeDeps()
|
||||
|
||||
assert.ok(resolveVenvHermesCommand('/root/venv/Scripts/HERMES.EXE', [], deps))
|
||||
assert.ok(resolveVenvHermesCommand('/root/venv/SCRIPTS/hermes.exe', [], deps))
|
||||
})
|
||||
206
apps/desktop/electron/windows-hermes-path.ts
Normal file
206
apps/desktop/electron/windows-hermes-path.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* windows-hermes-path.ts
|
||||
*
|
||||
* Pure, dependency-injected pieces of Windows `hermes` resolution pulled out
|
||||
* of main.ts's findOnPath(), handOffWindowsBootstrapRecovery(), and
|
||||
* unwrapWindowsVenvHermesCommand(). Each of the three functions here pins one
|
||||
* of the Windows resolution bugs that caused desktop reinstall loops:
|
||||
*
|
||||
* 1. buildPathExtCandidates() — findOnPath() tried the empty extension
|
||||
* FIRST, so an extensionless Git-Bash `hermes` shim shadowed the real
|
||||
* hermes.cmd/hermes.exe; the shim then failed the --version probe and
|
||||
* the desktop fell through to a spurious bootstrap/repair. The fix:
|
||||
* PATHEXT extensions first, empty extension LAST.
|
||||
* 2. chooseUpdaterArgs() — handOffWindowsBootstrapRecovery() chose
|
||||
* --update vs the destructive --repair by checking ONLY
|
||||
* venv\Scripts\hermes.exe (the console-script shim, written at the END
|
||||
* of venv setup and absent in interrupted states), so it escalated to a
|
||||
* full venv recreate even on healthy installs. The fix: gate on ANY
|
||||
* real-install signal, not just the shim.
|
||||
* 3. resolveVenvHermesCommand() — unwrapWindowsVenvHermesCommand() returned
|
||||
* the venv python with NO runtime probe (bypassing the caller's
|
||||
* --version check too), so a venv broken mid-update (e.g. missing
|
||||
* python-dotenv) was re-selected forever: Retry / "Repair install"
|
||||
* resolved the same dead interpreter instead of falling through to the
|
||||
* bootstrap installer. The fix: probe-before-trust.
|
||||
*
|
||||
* Kept in a standalone ts module (no Electron imports, dependencies passed
|
||||
* as parameters) so it can be unit-tested with `node --test` without
|
||||
* mocking Electron or the filesystem, same pattern as backend-probes.ts and
|
||||
* backend-command.ts.
|
||||
*/
|
||||
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* Build the ordered list of extensions findOnPath() should try when
|
||||
* resolving a bare command name off PATH.
|
||||
*
|
||||
* On Windows this MUST try PATHEXT extensions (.COM;.EXE;.BAT;.CMD by
|
||||
* default) BEFORE the bare/empty-extension name: a real command resolves via
|
||||
* its .exe/.cmd per Windows command-resolution semantics, and an
|
||||
* extensionless file (e.g. a Git-Bash shell-script shim named `hermes`) must
|
||||
* not shadow `hermes.cmd`/`hermes.exe`. The empty entry is kept LAST so
|
||||
* callers that already include the extension (py.exe, pwsh.exe,
|
||||
* powershell.exe) still resolve.
|
||||
*
|
||||
* On non-Windows platforms there is no PATHEXT concept: only the bare name
|
||||
* is tried.
|
||||
*
|
||||
* @param {string | undefined} pathext - process.env.PATHEXT (or undefined).
|
||||
* @param {boolean} isWindows
|
||||
* @returns {string[]} extensions to try, in order, always ending in ''.
|
||||
*/
|
||||
export function buildPathExtCandidates(pathext: string | undefined, isWindows: boolean): string[] {
|
||||
if (!isWindows) {
|
||||
return ['']
|
||||
}
|
||||
|
||||
return [...(pathext || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean), '']
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the Windows bootstrap-recovery updater invocation: the gentle
|
||||
* in-place --update when ANY real-install signal is present, the
|
||||
* destructive --repair (full venv recreate) otherwise.
|
||||
*
|
||||
* haveRealInstall must be computed by the caller from ALL real-install
|
||||
* signals (venv python interpreter, venv hermes shim, bootstrap-complete
|
||||
* marker) — gating on just the hermes.exe console-script shim alone is the
|
||||
* regression this function's callers must avoid: that shim is written at
|
||||
* the END of venv setup and is absent in exactly the interrupted/quarantined
|
||||
* states this recovery exists to heal.
|
||||
*
|
||||
* @param {boolean} haveRealInstall
|
||||
* @param {string} branch
|
||||
* @returns {string[]} updater argv, e.g. ['--update', '--branch', 'main'].
|
||||
*/
|
||||
export function chooseUpdaterArgs(haveRealInstall: boolean, branch: string): string[] {
|
||||
return haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
}
|
||||
|
||||
export interface ResolveVenvHermesCommandDeps {
|
||||
isWindows: boolean
|
||||
isCommandScript: (command: string) => boolean
|
||||
fileExists: (filePath: string) => boolean
|
||||
directoryExists: (filePath: string) => boolean
|
||||
canImportHermesCli: (python: string, opts?: { env?: Record<string, string> }) => boolean
|
||||
getVenvPython: (venvRoot: string) => string
|
||||
getVenvSitePackagesEntries: (venvRoot: string) => string[]
|
||||
buildDesktopBackendEnv: (opts: {
|
||||
hermesHome: string
|
||||
pythonPathEntries: string[]
|
||||
venvRoot: string
|
||||
}) => Record<string, string>
|
||||
hermesHome: string
|
||||
resolvePath: (...segments: string[]) => string
|
||||
dirname: (p: string) => string
|
||||
basename: (p: string) => string
|
||||
rememberLog?: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* If `command` is a Windows venv `hermes`/`hermes.exe` console-script shim
|
||||
* (i.e. `<venvRoot>/Scripts/hermes(.exe)`), resolve it to the underlying
|
||||
* venv python invoked as `python -m hermes_cli.main <backendArgs>` — but
|
||||
* ONLY after smoke-testing that interpreter with canImportHermesCli(). A
|
||||
* venv whose update died mid-`pip install` still has python.exe + hermes.exe
|
||||
* on disk, but the backend dies on its first import (e.g.
|
||||
* ModuleNotFoundError: dotenv) before the gateway ever binds. Returning it
|
||||
* unprobed also bypasses the caller's `--version` probe, so Retry/"Repair
|
||||
* install" re-resolves the same broken venv forever instead of falling
|
||||
* through to the bootstrap installer.
|
||||
*
|
||||
* Mirrors isActiveRuntimeUsable(): probes with the checkout on PYTHONPATH so
|
||||
* a healthy source-tree venv passes.
|
||||
*
|
||||
* Returns null when `command` is not a venv hermes shim, the underlying
|
||||
* python doesn't exist, or the import probe fails. Otherwise returns the
|
||||
* resolved backend descriptor.
|
||||
*/
|
||||
export function resolveVenvHermesCommand(
|
||||
command: string,
|
||||
backendArgs: string[],
|
||||
deps: ResolveVenvHermesCommandDeps
|
||||
): {
|
||||
label: string
|
||||
command: string
|
||||
args: string[]
|
||||
bootstrap: false
|
||||
env: Record<string, string>
|
||||
kind: 'python'
|
||||
root: string
|
||||
shell: false
|
||||
} | null {
|
||||
const {
|
||||
isWindows,
|
||||
isCommandScript,
|
||||
fileExists,
|
||||
directoryExists,
|
||||
canImportHermesCli,
|
||||
getVenvPython,
|
||||
getVenvSitePackagesEntries,
|
||||
buildDesktopBackendEnv,
|
||||
hermesHome,
|
||||
resolvePath,
|
||||
dirname,
|
||||
basename,
|
||||
rememberLog
|
||||
} = deps
|
||||
|
||||
if (!isWindows || !command || isCommandScript(command)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const resolved = resolvePath(String(command))
|
||||
|
||||
if (!/^hermes(?:\.exe)?$/i.test(basename(resolved))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const scriptsDir = dirname(resolved)
|
||||
|
||||
if (basename(scriptsDir).toLowerCase() !== 'scripts') {
|
||||
return null
|
||||
}
|
||||
|
||||
const venvRoot = dirname(scriptsDir)
|
||||
const python = getVenvPython(venvRoot)
|
||||
|
||||
if (!fileExists(python)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const root = dirname(venvRoot)
|
||||
|
||||
if (
|
||||
!canImportHermesCli(python, {
|
||||
env: {
|
||||
PYTHONPATH: [...(directoryExists(root) ? [root] : []), process.env.PYTHONPATH]
|
||||
.filter((entry): entry is string => Boolean(entry))
|
||||
.join(path.delimiter)
|
||||
}
|
||||
})
|
||||
) {
|
||||
rememberLog?.(
|
||||
`Ignoring venv Hermes at ${python}: runtime import probe failed (broken/partial venv); falling through to bootstrap.`
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
label: `existing Hermes Python at ${python}`,
|
||||
command: python,
|
||||
args: ['-m', 'hermes_cli.main', ...backendArgs],
|
||||
bootstrap: false,
|
||||
env: buildDesktopBackendEnv({
|
||||
hermesHome,
|
||||
pythonPathEntries: [...(directoryExists(root) ? [root] : []), ...getVenvSitePackagesEntries(venvRoot)],
|
||||
venvRoot
|
||||
}),
|
||||
kind: 'python',
|
||||
root,
|
||||
shell: false
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
// Regression guards for Windows `hermes` resolution in main.ts.
|
||||
//
|
||||
// main.ts has no module.exports, so these follow the repo's source-assertion
|
||||
// test pattern (see windows-child-process.test.ts). They pin the two Windows
|
||||
// resolution bugs that caused desktop reinstall loops:
|
||||
// 1. findOnPath() tried the empty extension FIRST, so an extensionless
|
||||
// Git-Bash `hermes` shim shadowed the real hermes.cmd/hermes.exe; the
|
||||
// shim then failed the --version probe and the desktop fell through to a
|
||||
// spurious bootstrap/repair.
|
||||
// 2. handOffWindowsBootstrapRecovery() chose --update vs the destructive
|
||||
// --repair by checking ONLY venv\Scripts\hermes.exe (the console-script
|
||||
// shim, written at the END of venv setup and absent in interrupted
|
||||
// states), so it escalated to a full venv recreate even on healthy
|
||||
// installs.
|
||||
// 3. unwrapWindowsVenvHermesCommand() returned the venv python with NO
|
||||
// runtime probe (bypassing the caller's --version check too), so a venv
|
||||
// broken mid-update (e.g. missing python-dotenv) was re-selected forever:
|
||||
// Retry / "Repair install" resolved the same dead interpreter instead of
|
||||
// falling through to the bootstrap installer.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function readMain() {
|
||||
return fs.readFileSync(path.join(__dirname, 'main.ts'), 'utf8').replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
||||
test('findOnPath tries PATHEXT extensions before the bare (empty) name on Windows', () => {
|
||||
const source = readMain()
|
||||
// Fixed order: PATHEXT first, empty string LAST.
|
||||
assert.match(
|
||||
source,
|
||||
/\(process\.env\.PATHEXT \|\| '\.COM;\.EXE;\.BAT;\.CMD'\)\.split\(';'\)\.filter\(Boolean\), ''\]/,
|
||||
'extensions array must end with the empty string, not start with it'
|
||||
)
|
||||
// The buggy empty-first order must not return.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/\['', \.\.\.\(process\.env\.PATHEXT/,
|
||||
'empty-extension-first order regressed: an extensionless shim can shadow hermes.cmd/.exe'
|
||||
)
|
||||
})
|
||||
|
||||
test('Windows bootstrap recovery chooses --update when any real-install signal is present', () => {
|
||||
const source = readMain()
|
||||
assert.match(source, /const haveRealInstall =/, 'recovery must compute haveRealInstall')
|
||||
assert.match(source, /fileExists\(venvPython\)/, 'recovery must accept the venv interpreter as a real-install signal')
|
||||
assert.match(
|
||||
source,
|
||||
/\.hermes-bootstrap-complete/,
|
||||
'recovery must accept the bootstrap-complete marker as a real-install signal'
|
||||
)
|
||||
assert.match(source, /updaterArgs = haveRealInstall \? \['--update'/, 'updaterArgs must gate on haveRealInstall')
|
||||
// The old too-narrow check (only venv\Scripts\hermes.exe) must not return.
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/updaterArgs = fileExists\(venvHermes\) \?/,
|
||||
'recovery regressed to gating only on the hermes.exe shim, which forces destructive --repair'
|
||||
)
|
||||
})
|
||||
|
||||
test('unwrapWindowsVenvHermesCommand smoke-tests the venv python before trusting it', () => {
|
||||
const source = readMain()
|
||||
const fnStart = source.indexOf('function unwrapWindowsVenvHermesCommand(')
|
||||
assert.notEqual(fnStart, -1, 'unwrapWindowsVenvHermesCommand must exist in main.ts')
|
||||
// Slice out just the function body (up to the next top-level function decl)
|
||||
const fnEnd = source.indexOf('\nfunction ', fnStart + 1)
|
||||
const body = source.slice(fnStart, fnEnd === -1 ? undefined : fnEnd)
|
||||
assert.match(
|
||||
body,
|
||||
/canImportHermesCli\(python/,
|
||||
'unwrap must probe the venv interpreter; returning it unprobed re-selects a broken venv ' +
|
||||
'forever (Retry/Repair loop on a mid-update venv missing e.g. python-dotenv)'
|
||||
)
|
||||
assert.match(
|
||||
body,
|
||||
/return null\s*\n\s*\}\s*\n\s*return \{/,
|
||||
'a failed probe must fall through (return null) so the resolver reaches the bootstrap rung'
|
||||
)
|
||||
})
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.ts electron/hardening.test.ts electron/backend-env.test.ts electron/backend-probes.test.ts electron/backend-ready.test.ts electron/bootstrap-runner.test.ts electron/connection-config.test.ts electron/dashboard-token.test.ts electron/gateway-ws-probe.test.ts electron/oauth-net-request.test.ts electron/desktop-uninstall.test.ts electron/session-windows.test.ts electron/link-title-window.test.ts electron/workspace-cwd.test.ts electron/fs-read-dir.test.ts electron/git-root.test.ts electron/git-worktree-ops.test.ts electron/windows-child-options.test.ts electron/update-remote.test.ts electron/update-count.test.ts electron/update-rebuild.test.ts electron/update-marker.test.ts electron/update-relaunch.test.ts electron/windows-user-env.test.ts electron/wsl-clipboard-image.test.ts electron/titlebar-overlay-width.test.ts electron/window-state.test.ts electron/zoom.test.ts electron/windows-hermes-resolution.test.ts electron/oauth-session-request.test.ts",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.ts electron/hardening.test.ts electron/backend-env.test.ts electron/backend-probes.test.ts electron/backend-ready.test.ts electron/bootstrap-runner.test.ts electron/connection-config.test.ts electron/dashboard-token.test.ts electron/gateway-ws-probe.test.ts electron/oauth-net-request.test.ts electron/desktop-uninstall.test.ts electron/session-windows.test.ts electron/link-title-window.test.ts electron/workspace-cwd.test.ts electron/fs-read-dir.test.ts electron/git-root.test.ts electron/git-worktree-ops.test.ts electron/windows-child-options.test.ts electron/update-remote.test.ts electron/update-count.test.ts electron/update-rebuild.test.ts electron/update-marker.test.ts electron/update-relaunch.test.ts electron/windows-user-env.test.ts electron/wsl-clipboard-image.test.ts electron/titlebar-overlay-width.test.ts electron/window-state.test.ts electron/zoom.test.ts electron/windows-hermes-path.test.ts electron/oauth-session-request.test.ts",
|
||||
"typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue