mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-13 13:53:15 +08:00
Compare commits
156 Commits
claude-cod
...
ethie/e2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1acf25ee5 | ||
|
|
221ef52ed2 | ||
|
|
dbf2a0d94b | ||
|
|
edddf0530c | ||
|
|
4a82329e2a | ||
|
|
046145a590 | ||
|
|
b180eea236 | ||
|
|
fe35926c46 | ||
|
|
7ac9cb1f90 | ||
|
|
6f98cc2fae | ||
|
|
095f9ab234 | ||
|
|
f511e81bf0 | ||
|
|
b87bdd5a73 | ||
|
|
8416a70a58 | ||
|
|
d66585a2a1 | ||
|
|
16b2ceb71a | ||
|
|
94278f4543 | ||
|
|
12e259087d | ||
|
|
9695c65ee4 | ||
|
|
b99fd0eefb | ||
|
|
2d6747fbb6 | ||
|
|
ebcb275fcf | ||
|
|
6ab3e3bba5 | ||
|
|
feaa22f746 | ||
|
|
2114a8b3d6 | ||
|
|
38939a0ca4 | ||
|
|
887239e566 | ||
|
|
df7d3c57bf | ||
|
|
e30de94c73 | ||
|
|
9d08ac703a | ||
|
|
1091f48cf9 | ||
|
|
a46f56702e | ||
|
|
df7d13bd19 | ||
|
|
b5d9ef054a | ||
|
|
f0f60e7ec6 | ||
|
|
71a8c7d80e | ||
|
|
3b791dc835 | ||
|
|
caec80dc38 | ||
|
|
998cd090af | ||
|
|
064a1cdc00 | ||
|
|
d86305d402 | ||
|
|
1ed98dc240 | ||
|
|
d95eb3b5f9 | ||
|
|
ddd7694af7 | ||
|
|
f209b283d0 | ||
|
|
a25dda5972 | ||
|
|
754c7e77c8 | ||
|
|
3578861070 | ||
|
|
0e7eeda906 | ||
|
|
2578378f86 | ||
|
|
1a931f856f | ||
|
|
8018aec689 | ||
|
|
d312733c52 | ||
|
|
ef1d05e784 | ||
|
|
a6c8d8c26e | ||
|
|
1b505e71b6 | ||
|
|
28ac374cd5 | ||
|
|
3e45df24cf | ||
|
|
16987a523f | ||
|
|
c57d4f2cb9 | ||
|
|
030ffd6000 | ||
|
|
65a3bb2d1a | ||
|
|
e2bb518dfe | ||
|
|
197599505e | ||
|
|
f96f644c22 | ||
|
|
4461fbcc49 | ||
|
|
4556c10e25 | ||
|
|
7bd1c38bd0 | ||
|
|
d6c82c1ae2 | ||
|
|
588f46cc25 | ||
|
|
fbd24a9b46 | ||
|
|
9f32459e97 | ||
|
|
d9b131c93e | ||
|
|
f69eceb415 | ||
|
|
5d605c5038 | ||
|
|
874521acac | ||
|
|
f8338a5a6a | ||
|
|
26183609a2 | ||
|
|
c125d07354 | ||
|
|
4840897b4d | ||
|
|
59f9b4c057 | ||
|
|
64d17d690e | ||
|
|
a4fb0caff8 | ||
|
|
ff1796bb1a | ||
|
|
012184aa06 | ||
|
|
565724703b | ||
|
|
e39c5bca91 | ||
|
|
1219b3bfd3 | ||
|
|
0621fad0e2 | ||
|
|
7085ff325f | ||
|
|
cbd853f194 | ||
|
|
839a9b4c78 | ||
|
|
a78838c1af | ||
|
|
acfc64dd1c | ||
|
|
47d90bdf11 | ||
|
|
ec9bfc5491 | ||
|
|
52d59e7d3a | ||
|
|
50f2681628 | ||
|
|
c69e5c476c | ||
|
|
af50946d68 | ||
|
|
ff42474ce3 | ||
|
|
753bc35c91 | ||
|
|
c69ec0566d | ||
|
|
1b49da6ba6 | ||
|
|
417fb844fc | ||
|
|
1dbce85abe | ||
|
|
c57cdbac54 | ||
|
|
56c3de01e3 | ||
|
|
69015b460d | ||
|
|
0feb19e622 | ||
|
|
77c3bf5222 | ||
|
|
4bd9bd6777 | ||
|
|
900ed4047c | ||
|
|
7147e0c5a9 | ||
|
|
ab9c1ec9a7 | ||
|
|
8fdd076c11 | ||
|
|
eab8a52fd9 | ||
|
|
9fcc8e2090 | ||
|
|
97521bedd2 | ||
|
|
f9e3b10d4f | ||
|
|
f56da0483e | ||
|
|
17cf3a37a1 | ||
|
|
c8cf31d3fb | ||
|
|
497b14788a | ||
|
|
f39818051d | ||
|
|
768f1bf95a | ||
|
|
556baad5ce | ||
|
|
138577052b | ||
|
|
9330c11839 | ||
|
|
25daa4d290 | ||
|
|
7c33990b72 | ||
|
|
fe2e07a20f | ||
|
|
0c9c34ba46 | ||
|
|
f23926b220 | ||
|
|
a17626cc41 | ||
|
|
f5368d4263 | ||
|
|
e6566b614e | ||
|
|
516692d034 | ||
|
|
de709ed677 | ||
|
|
b5452e4b6b | ||
|
|
0d867fe714 | ||
|
|
96f6f5e614 | ||
|
|
6d03a60cb6 | ||
|
|
89bd6ba230 | ||
|
|
cfe03d8537 | ||
|
|
81eac10f7a | ||
|
|
120c8213ac | ||
|
|
c6d8d53a5a | ||
|
|
11f857ef04 | ||
|
|
a626bfe03a | ||
|
|
602df2bcfc | ||
|
|
61526ce826 | ||
|
|
9ffec3f8eb | ||
|
|
50a447c1c3 | ||
|
|
624d91211c | ||
|
|
1dc9f7178a |
395
.github/workflows/e2e-windows.yml
vendored
Normal file
395
.github/workflows/e2e-windows.yml
vendored
Normal file
@@ -0,0 +1,395 @@
|
||||
name: E2E Windows Desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ethie/e2e]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: e2e-windows-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# this is separated so we don't have node.js and stuff polluting the system
|
||||
build-installer:
|
||||
if: false # NOTE: build-installer is disabled for now, since we don't ship updates to the installer. when we do, re-enable it.
|
||||
name: Build Hermes-Setup.exe
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: checkout cache inputs
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
package-lock.json
|
||||
apps/bootstrap-installer
|
||||
sparse-checkout-cone-mode: true
|
||||
|
||||
# The cache key is the exact installer build fingerprint. A hit means
|
||||
# this package-lock + bootstrap-installer source combo was already built,
|
||||
# so we can skip the entire Node/Rust/toolchain dance and just upload it.
|
||||
- name: Restore installer build cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
id: installer-cache
|
||||
with:
|
||||
path: Hermes-Setup.exe
|
||||
key: hermes-installer-built-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'apps/bootstrap-installer/**', '!apps/bootstrap-installer/src-tauri/target/**') }}
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
uses: dtolnay/rust-toolchain@1.96.0 # stable
|
||||
- name: checkout full tree on cache miss
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install npm dependencies
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Build dev installer for this branch
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
run: npm run tauri:build
|
||||
working-directory: apps/bootstrap-installer
|
||||
timeout-minutes: 10
|
||||
|
||||
# Only runs on cache miss. Pick the exe the Tauri build produced and
|
||||
# normalize its name so downstream jobs always know what to download.
|
||||
- name: Normalize installer artifact name
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$candidates = @(
|
||||
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes.exe',
|
||||
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes_0.0.1_x64.exe',
|
||||
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes_0.0.1_x64-setup.exe',
|
||||
'apps/bootstrap-installer/src-tauri/target/release/Hermes.exe'
|
||||
)
|
||||
$installer = $null
|
||||
foreach ($c in $candidates) {
|
||||
if (Test-Path $c) {
|
||||
$installer = Resolve-Path $c
|
||||
break
|
||||
}
|
||||
}
|
||||
if (-not $installer) {
|
||||
$installer = Get-ChildItem -Path 'apps/bootstrap-installer/src-tauri/target/release' `
|
||||
-Recurse -Filter '*.exe' | Where-Object { $_.Name -notlike '*setup*' -or $true } | Select-Object -First 1 -ExpandProperty FullName
|
||||
}
|
||||
if (-not $installer) {
|
||||
throw 'Could not find built Hermes-Setup.exe'
|
||||
}
|
||||
Copy-Item -Path $installer -Destination 'Hermes-Setup.exe' -Force
|
||||
Write-Host "Normalized installer: Hermes-Setup.exe (from $installer)"
|
||||
|
||||
e2e:
|
||||
name: Install this commit as latest main
|
||||
# needs: build-installer
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
env:
|
||||
# Isolated install directory so the real install flow doesn't touch the
|
||||
# runner's user profile. Kept under the workspace for easy cleanup.
|
||||
HERMES_HOME: ${{ github.workspace }}\.e2e-hermes-home
|
||||
INSTALL_DIR: ${{ github.workspace }}\.e2e-hermes-home\hermes-agent
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: source
|
||||
|
||||
- name: Restore installer from build cache
|
||||
if: false # build-installer is since we don't update the installer right now. instead, we download the latest installer from the website
|
||||
id: installer-cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: Hermes-Setup.exe
|
||||
key: hermes-installer-built-cache-${{ runner.os }}-${{ hashFiles('source/package-lock.json', 'source/apps/bootstrap-installer/**', '!source/apps/bootstrap-installer/src-tauri/target/**') }}
|
||||
|
||||
- name: Download latest production installer
|
||||
id: download-installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
Invoke-WebRequest https://hermes-assets.nousresearch.com/Hermes-Setup.exe -OutFile Hermes-Setup.exe
|
||||
|
||||
- name: Restore cached test tools
|
||||
id: test-tools-cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: test-bins
|
||||
key: test-bins-${{ runner.os }}-v1
|
||||
|
||||
- name: Install AutoHotkey v2 and ffmpeg
|
||||
if: steps.test-tools-cache.outputs.cache-hit != 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Install fresh when the cache missed.
|
||||
|
||||
New-Item -ItemType Directory -Path test-bins\autohotkey, test-bins\ffmpeg -Force | Out-Null
|
||||
|
||||
# AutoHotkey: copy its whole v2 directory so helper exes/dlls come along.
|
||||
winget install -e --id AutoHotkey.AutoHotkey --silent --accept-source-agreements --accept-package-agreements --disable-interactivity
|
||||
$ahkDir = "$env:ProgramW6432\AutoHotkey\v2"
|
||||
if (-not (Test-Path $ahkDir)) {
|
||||
throw "AutoHotkey install directory not found: $ahkDir"
|
||||
}
|
||||
Copy-Item -Path "$ahkDir\*" -Destination test-bins\autohotkey -Recurse -Force
|
||||
|
||||
# ffmpeg : just install into dir
|
||||
winget install -e --id Gyan.FFmpeg --silent --accept-source-agreements --accept-package-agreements --disable-interactivity --location ffmpeg_dir
|
||||
Copy-Item -Path "ffmpeg_dir\*\*" -Destination test-bins\ffmpeg -Recurse -Force
|
||||
|
||||
- name: Add test-bins to PATH
|
||||
shell: pwsh
|
||||
run: |
|
||||
ls "$PWD\test-bins\ffmpeg"
|
||||
Add-Content -Path $env:GITHUB_PATH -Value "$PWD\test-bins\autohotkey"
|
||||
Add-Content -Path $env:GITHUB_PATH -Value "$PWD\test-bins\ffmpeg\bin"
|
||||
|
||||
# ── Prepare an isolated HERMES_HOME and copy checked-out repo ──────
|
||||
# actions/checkout already has the right commit; just mirror it into the
|
||||
# isolated home so the installer doesn't need to reach GitHub.
|
||||
- name: Move checked-out workspace into isolated HERMES_HOME
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path $env:INSTALL_DIR -Force
|
||||
Get-ChildItem -Path ${{ github.workspace }}\source -Force | Move-Item -Destination $env:INSTALL_DIR -Force
|
||||
Write-Host "Isolated install dir ready: $env:INSTALL_DIR"
|
||||
|
||||
# ── Run the headed installer + AHK helper ───────────────────────
|
||||
- name: Launch Hermes-Setup.exe and install it
|
||||
shell: pwsh
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
HERMES_SETUP_DEV_REPO_ROOT: ${{ env.INSTALL_DIR }}
|
||||
run: |
|
||||
$installer = "Hermes-Setup.exe"
|
||||
|
||||
# ── Start screen recording (live stdin pipe) ──────────────────
|
||||
# ffmpeg must be started, fed, and stopped from the SAME step: the
|
||||
# graceful-stop signal is the character 'q' written to ffmpeg's live
|
||||
# stdin. A separate teardown step can't do this because the process
|
||||
# that owns the writable stdin pipe dies when this step ends.
|
||||
#
|
||||
# Start-Process / -RedirectStandardInput <file> does NOT work: it
|
||||
# hands ffmpeg a file handle opened once at EOF, so appending 'q' to
|
||||
# the file on disk never reaches the running process. We need a real
|
||||
# writable pipe, which only System.Diagnostics.Process exposes.
|
||||
#
|
||||
# -pix_fmt yuv420p keeps
|
||||
# the output broadly playable.
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = "ffmpeg"
|
||||
$psi.Arguments = "-y -f gdigrab -framerate 15 -i desktop " +
|
||||
"-hide_banner -loglevel error " +
|
||||
"-c:v libx264 -preset ultrafast -pix_fmt yuv420p recording.mkv"
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.UseShellExecute = $false
|
||||
$ffmpeg = [System.Diagnostics.Process]::Start($psi)
|
||||
$ffmpeg.Id | Out-File ffmpeg.pid
|
||||
# Note: stderr is intentionally left attached to the console so it is
|
||||
# captured in the step log. Do NOT redirect a stream we don't drain --
|
||||
# ffmpeg's progress output would fill the pipe buffer and block.
|
||||
Write-Host "ffmpeg recording started (pid $($ffmpeg.Id ))"
|
||||
|
||||
$e2eDir = "$env:RUNNER_TEMP\e2e-windows"
|
||||
|
||||
Copy-Item -Path "$env:INSTALL_DIR\e2e\windows" -Destination $e2eDir -Force -Recurse
|
||||
|
||||
$installerSuccess = $false
|
||||
try {
|
||||
# Launch the real installer
|
||||
$proc = Start-Process -FilePath $installer -PassThru -NoNewWindow
|
||||
$proc.Id | Out-File installer.pid
|
||||
|
||||
$ahkProc = Start-Process -FilePath ".\test-bins\autohotkey\AutoHotkey64.exe" `
|
||||
-ArgumentList "$e2eDir\install-hermes-desktop.ahk", "$PWD\ahk.log" -PassThru -NoNewWindow
|
||||
|
||||
# Wait for AHK helper to finish, and tail logs.
|
||||
|
||||
$logReader = $null
|
||||
$logStream = $null
|
||||
$logPath = Join-Path $env:HERMES_HOME "logs\bootstrap-installer.log"
|
||||
# can take a long time for installer!
|
||||
$deadline = (Get-Date).AddSeconds(60 * 8)
|
||||
|
||||
try {
|
||||
while ((Get-Date) -lt $deadline -and -not $ahkProc.HasExited) {
|
||||
if (-not $logReader) {
|
||||
if (Test-Path $logPath) {
|
||||
Write-Host "Found bootstrap-installer.log; tailing..."
|
||||
# FileShare.ReadWrite is required: the installer almost
|
||||
# certainly still has the file open for writing, and a
|
||||
# plain Get-Content/File.Open would throw or lock it out.
|
||||
$logStream = [System.IO.File]::Open(
|
||||
$logPath, 'Open', 'Read', 'ReadWrite')
|
||||
$logReader = New-Object System.IO.StreamReader($logStream)
|
||||
}
|
||||
} else {
|
||||
$line = $logReader.ReadLine()
|
||||
while ($null -ne $line) {
|
||||
Write-Host "[bootstrap] $line"
|
||||
$line = $logReader.ReadLine()
|
||||
}
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
|
||||
# Drain anything written in the final tick before exit/timeout.
|
||||
if ($logReader) {
|
||||
$line = $logReader.ReadLine()
|
||||
while ($null -ne $line) {
|
||||
Write-Host "[bootstrap] $line"
|
||||
$line = $logReader.ReadLine()
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($logReader) { $logReader.Dispose() }
|
||||
if ($logStream) { $logStream.Dispose() }
|
||||
}
|
||||
|
||||
if (-not $ahkProc.HasExited) {
|
||||
Write-Host "AHK helper is still running; stopping it"
|
||||
Stop-Process -Id $ahkProc.Id -Force -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
Write-Host "autohotkey helper exited"
|
||||
}
|
||||
}
|
||||
|
||||
finally {
|
||||
# Gracefully stop ffmpeg by writing 'q' to its LIVE stdin pipe, so
|
||||
# the container header/index are finalized and the mkv is playable.
|
||||
# This runs in the same step that owns the pipe, even on failure.
|
||||
if ($ffmpeg -and -not $ffmpeg.HasExited) {
|
||||
Write-Host "Stopping ffmpeg gracefully (q on stdin)"
|
||||
try {
|
||||
$ffmpeg.StandardInput.Write("q")
|
||||
$ffmpeg.StandardInput.Close()
|
||||
} catch {
|
||||
Write-Host "Failed to write q to ffmpeg stdin: $_"
|
||||
}
|
||||
if (-not $ffmpeg.WaitForExit(15000)) {
|
||||
Write-Host "ffmpeg did not exit after 15s; killing"
|
||||
$ffmpeg.Kill()
|
||||
}
|
||||
}
|
||||
Write-Host "ffmpeg stopped"
|
||||
|
||||
# Installer should have exited
|
||||
if ($proc.HasExited) {
|
||||
# TODO check exit code once we add exit code in installer
|
||||
$installerSuccess = $true
|
||||
} else {
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
throw "Installer is still running. Install did not succeed."
|
||||
}
|
||||
if (-not $installerSuccess) {
|
||||
throw "Installer did not exit after autohotkey script finished. Check installer logs!"
|
||||
}
|
||||
}
|
||||
|
||||
# ── Run Playwright against the installed binary ─────────────────
|
||||
# (placeholder: will be enabled once installer completes successfully.)
|
||||
- name: Launch installed app and run e2e
|
||||
if: false
|
||||
working-directory: source/apps/desktop
|
||||
run: npx playwright test e2e/ --reporter=list
|
||||
env:
|
||||
# Point the e2e spec at the desktop binary that the installer built.
|
||||
HERMES_E2E_INSTALL_ROOT: ${{ env.HERMES_HOME }}\hermes-agent
|
||||
|
||||
# ── Teardown & artifacts ────────────────────────────────────────
|
||||
# ffmpeg is normally started AND gracefully stopped inside the launch
|
||||
# step (so 'q' reaches its live stdin pipe). This step is only a
|
||||
# safety net: if the launch step timed out or crashed before its
|
||||
# finally block ran, force-kill any orphaned ffmpeg so the runner can
|
||||
# release recording.mkv for upload. The mkv container survives a hard
|
||||
# kill (only the trailing seek index is lost), so the artifact is still
|
||||
# usable for coordinate discovery even on this fallback path.
|
||||
- name: Stop orphaned screen recording (safety net)
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ffmpegpid = Get-Content ffmpeg.pid -ErrorAction SilentlyContinue
|
||||
if ($ffmpegpid) {
|
||||
$proc = Get-Process -Id $ffmpegpid -ErrorAction SilentlyContinue
|
||||
if ($proc) {
|
||||
Write-Host "Orphaned ffmpeg (pid $ffmpegpid) still running; force-stopping"
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 2
|
||||
} else {
|
||||
Write-Host "ffmpeg already exited cleanly; nothing to do"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Burn debug overlay into recording
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
$logPath = Join-Path $PWD 'ahk.log'
|
||||
$x = $y = $w = $h = $null
|
||||
if (Test-Path $logPath) {
|
||||
$line = Get-Content $logPath -Raw | Select-String -Pattern 'Window found at x=(\d+) y=(\d+) w=(\d+) h=(\d+)' -AllMatches | Select-Object -Last 1
|
||||
if ($line) {
|
||||
$x = [int]$line.Matches[0].Groups[1].Value
|
||||
$y = [int]$line.Matches[0].Groups[2].Value
|
||||
$w = [int]$line.Matches[0].Groups[3].Value
|
||||
$h = [int]$line.Matches[0].Groups[4].Value
|
||||
Write-Host "Parsed window rect: x=$x y=$y w=$w h=$h"
|
||||
} else {
|
||||
Write-Host "no window rect found in ahk.log; only timestamp will be burned"
|
||||
}
|
||||
} else {
|
||||
Write-Host "ahk.log not found; only timestamp will be burned"
|
||||
}
|
||||
|
||||
# Build the timestamp overlay
|
||||
$vf = "drawtext=text='%{pts\:hms}':fontfile='C\:\\Windows\\Fonts\\arial.ttf':fontsize=20:fontcolor=white:box=1:boxcolor=black@0.5:x=8:y=8"
|
||||
|
||||
if ($x -ne $null -and $y -ne $null -and $w -ne $null -and $h -ne $null) {
|
||||
# Window border + 16px grid only over the window + axis labels
|
||||
$grid = "drawbox=x=$x`:y=$y`:w=$w`:hf=$h`:color=red@0.9:t=2,split=2[box][win];[win]crop=$w`:$h`:$x`:$y`:$y"
|
||||
}
|
||||
|
||||
Write-Host "Overlay filter: $vf"
|
||||
|
||||
ffmpeg -y -i recording.mkv -vf "$vf" -c:v libx264 -preset veryfast -pix_fmt yuv420p recording-overlay.mkv
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "ffmpeg overlay failed"
|
||||
}
|
||||
|
||||
Move-Item -Path recording-overlay.mkv -Destination recording.mkv -Force
|
||||
Write-Host "Overlayed recording saved as recording.mkv"
|
||||
|
||||
- name: Upload screen recording
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
id: upload-recording
|
||||
if: always()
|
||||
with:
|
||||
name: screen-recording-${{ github.sha }}
|
||||
path: recording.mkv
|
||||
retention-days: 1
|
||||
archive: false
|
||||
overwrite: true
|
||||
|
||||
- name: Bootstrap Installer log
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-Content "$env:HERMES_HOME\logs\bootstrap-installer.log"
|
||||
|
||||
- name: Autohotkey log
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-Content ahk.log
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -4,6 +4,8 @@
|
||||
/_pycache/
|
||||
*.pyc*
|
||||
__pycache__/
|
||||
act/
|
||||
.act-sandbox-agent.*
|
||||
.venv/
|
||||
.venv
|
||||
.vscode/
|
||||
@@ -54,6 +56,7 @@ __pycache__/
|
||||
hermes_agent.egg-info/
|
||||
wandb/
|
||||
testlogs
|
||||
playwright-report/
|
||||
|
||||
# CLI config (may contain sensitive SSH paths)
|
||||
cli-config.yaml
|
||||
|
||||
@@ -118,17 +118,6 @@ def is_write_denied(path: str) -> bool:
|
||||
continue
|
||||
|
||||
for base_real in hermes_dirs:
|
||||
# Session transcripts are application-owned state. Letting the agent's
|
||||
# generic file tools rewrite state.db or legacy JSON snapshots can
|
||||
# falsify conversation history and invalidate resume/compression state.
|
||||
try:
|
||||
if resolved == os.path.realpath(os.path.join(base_real, "state.db")):
|
||||
return True
|
||||
sessions_real = os.path.realpath(os.path.join(base_real, "sessions"))
|
||||
if resolved == sessions_real or resolved.startswith(sessions_real + os.sep):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
mcp_real = os.path.realpath(os.path.join(base_real, mcp_tokens_dir_name))
|
||||
if resolved == mcp_real or resolved.startswith(mcp_real + os.sep):
|
||||
|
||||
51
apps/desktop/e2e/boot-failure.spec.ts
Normal file
51
apps/desktop/e2e/boot-failure.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* E2E boot-failure tests — verify the app shows an error overlay when the
|
||||
* backend can't reach the inference provider.
|
||||
*
|
||||
* Launches the app with a provider pointing at a dead endpoint (port 1).
|
||||
* The `hermes serve` backend starts, but when the renderer tries to connect
|
||||
* or when a runtime check fails, the app should show a boot failure or
|
||||
* onboarding error overlay.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
setupDeadBackend,
|
||||
type DeadBackendFixture,
|
||||
waitForBootFailure,
|
||||
} from './fixtures'
|
||||
|
||||
let fixture: DeadBackendFixture | null = null
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('boot failure with dead provider endpoint', () => {
|
||||
test('app shows error state or onboarding', async () => {
|
||||
fixture = await setupDeadBackend()
|
||||
|
||||
// With a dead provider endpoint, the app should eventually show either:
|
||||
// 1. A boot failure overlay (if the backend fails to start), or
|
||||
// 2. An onboarding overlay with an error (if the runtime check fails)
|
||||
// Both outcomes prove the app is handling provider failures gracefully.
|
||||
//
|
||||
// We give it a generous timeout — the backend needs to start, the
|
||||
// renderer needs to boot, and then the runtime check needs to fail.
|
||||
await waitForBootFailure(fixture.page, 90_000)
|
||||
})
|
||||
|
||||
test('screenshot of error state', async () => {
|
||||
if (!fixture) {
|
||||
test.skip(true, 'Previous test failed — no app running')
|
||||
return
|
||||
}
|
||||
|
||||
const screenshot = await fixture.page.screenshot()
|
||||
expect(screenshot.byteLength).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
74
apps/desktop/e2e/boot.spec.ts
Normal file
74
apps/desktop/e2e/boot.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* E2E smoke tests for the dev-mode desktop app.
|
||||
*
|
||||
* These tests launch the Electron app from the built dist/ (not the
|
||||
* packaged binary) with a real `hermes serve` backend pointed at a mock
|
||||
* inference server. The full chain is exercised:
|
||||
*
|
||||
* electron → hermes serve (python) → mock provider → renderer
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
* Run from the nix devshell:
|
||||
* sandbox --persistent -- npx playwright test e2e/boot.spec.ts --reporter=list
|
||||
*/
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('dev-mode boot with mock backend', () => {
|
||||
test('window opens with Hermes title', async () => {
|
||||
const title = await fixture!.page.title()
|
||||
expect(title).toContain('Hermes')
|
||||
})
|
||||
|
||||
test('renderer mounts and shows DOM content', async () => {
|
||||
const page = fixture!.page
|
||||
// Wait for the React root to mount. The app renders into #root
|
||||
// (see src/main.tsx), but content may arrive through portals — so
|
||||
// check the body for any interactive content instead.
|
||||
await page.waitForSelector('body', { state: 'attached' })
|
||||
// Wait for the main app shell — the composer is always present.
|
||||
await page.waitForSelector('textarea, [contenteditable="true"]', {
|
||||
state: 'attached',
|
||||
timeout: 30_000,
|
||||
})
|
||||
})
|
||||
|
||||
test('backend boots and app becomes ready', async () => {
|
||||
// This is the big one — wait for the full boot chain to complete:
|
||||
// electron starts → hermes serve is spawned → WS connects → config
|
||||
// loaded → sessions loaded → boot overlay dismissed → composer visible.
|
||||
await waitForAppReady(fixture!.page, 120_000)
|
||||
})
|
||||
|
||||
test('screenshot after boot', async () => {
|
||||
// Use a screenshot without waiting for fonts — the default
|
||||
// page.screenshot() waits for fonts to load, which can hang in
|
||||
// headless Electron. page.screenshot({ type: 'png', timeout: 10000 })
|
||||
// with a shorter timeout is more reliable.
|
||||
const screenshot = await fixture!.page.screenshot({ timeout: 10_000 }).catch(() => null)
|
||||
if (screenshot) {
|
||||
expect(screenshot.byteLength).toBeGreaterThan(0)
|
||||
} else {
|
||||
// If screenshot fails (e.g. GPU issues in headless), just skip —
|
||||
// the important tests are the boot and interaction ones above.
|
||||
test.skip(true, 'Screenshot timed out — likely a GPU/rendering issue in headless mode')
|
||||
}
|
||||
})
|
||||
})
|
||||
88
apps/desktop/e2e/chat.spec.ts
Normal file
88
apps/desktop/e2e/chat.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* E2E chat tests — send a message and verify a response appears.
|
||||
*
|
||||
* Requires the full boot chain to complete (hermes serve + mock inference
|
||||
* provider). The mock server returns a canned reply, so we verify the
|
||||
* response text shows up in the chat transcript.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
setupMockBackend,
|
||||
type MockBackendFixture,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture.page, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('chat interaction with mock backend', () => {
|
||||
test('send a message and receive a response', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
// Find the composer — it's a contenteditable textbox.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
|
||||
// Click to focus, then type the message character by character.
|
||||
// Using `type` instead of `fill` because the composer is a
|
||||
// contenteditable div with custom keydown handling that tracks
|
||||
// IME composition state — `fill` bypasses the event chain.
|
||||
await composer.click()
|
||||
await composer.type('Hello, can you hear me?', { delay: 20 })
|
||||
|
||||
// Submit with Enter — the composer's keydown handler intercepts
|
||||
// plain Enter (without Shift) and calls submitDraft().
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Wait for the user's message to appear in the transcript.
|
||||
// The message renders as an assistant-ui message in the chat view.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const body = document.body
|
||||
if (!body) {
|
||||
return false
|
||||
}
|
||||
return (body.textContent ?? '').includes('Hello, can you hear me?')
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
// Wait for the mock response to appear. The canned reply is:
|
||||
// "Hello from the mock inference server! The full boot chain is working."
|
||||
// Give it a generous timeout — the inference request goes through the
|
||||
// gateway → hermes serve → mock server → streaming SSE back.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const body = document.body
|
||||
if (!body) {
|
||||
return false
|
||||
}
|
||||
const text = body.textContent ?? ''
|
||||
return text.includes('mock inference server') || text.includes('boot chain is working')
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
})
|
||||
|
||||
test('screenshot of chat with messages', async () => {
|
||||
const screenshot = await fixture!.page.screenshot({ timeout: 10_000 }).catch(() => null)
|
||||
if (screenshot) {
|
||||
expect(screenshot.byteLength).toBeGreaterThan(0)
|
||||
} else {
|
||||
test.skip(true, 'Screenshot timed out — likely a GPU/rendering issue in headless mode')
|
||||
}
|
||||
})
|
||||
})
|
||||
569
apps/desktop/e2e/fixtures.ts
Normal file
569
apps/desktop/e2e/fixtures.ts
Normal file
@@ -0,0 +1,569 @@
|
||||
/**
|
||||
* Shared E2E fixtures for the Hermes desktop Playwright suite.
|
||||
*
|
||||
* Two fixture modes:
|
||||
*
|
||||
* 1. `mockBackend` — starts a mock inference server, writes a config.yaml
|
||||
* that points at it, and launches the desktop app so the full chain
|
||||
* (electron → hermes serve → provider → inference → renderer) is
|
||||
* exercised with a real backend but a fake LLM.
|
||||
*
|
||||
* 2. `noProvider` — launches the app with an empty config (no provider
|
||||
* configured). The onboarding overlay should appear. Used to test the
|
||||
* first-run flow without real credentials.
|
||||
*
|
||||
* Both modes launch the *dev* Electron app (`electron .` against the built
|
||||
* `dist/`), not the packaged binary. This avoids the multi-minute
|
||||
* `electron-builder --dir` step and matches `hermes desktop --source`. The
|
||||
* packaged-binary path is already covered by `launch.spec.ts`.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so that `dist/` exists.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
|
||||
|
||||
import { startMockServer } from './mock-server'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
|
||||
|
||||
// ─── Credential stripping (matches launch.spec.ts) ──────────────────────
|
||||
|
||||
const CREDENTIAL_SUFFIXES: string[] = [
|
||||
'_API_KEY',
|
||||
'_TOKEN',
|
||||
'_SECRET',
|
||||
'_PASSWORD',
|
||||
'_CREDENTIALS',
|
||||
'_ACCESS_KEY',
|
||||
'_PRIVATE_KEY',
|
||||
'_OAUTH_TOKEN',
|
||||
]
|
||||
|
||||
const CREDENTIAL_NAMES = new Set([
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_TOKEN',
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'AWS_SESSION_TOKEN',
|
||||
'CUSTOM_API_KEY',
|
||||
'GEMINI_BASE_URL',
|
||||
'OPENAI_BASE_URL',
|
||||
'OPENROUTER_BASE_URL',
|
||||
'OLLAMA_BASE_URL',
|
||||
'GROQ_BASE_URL',
|
||||
'XAI_BASE_URL',
|
||||
])
|
||||
|
||||
function isCredentialEnvVar(name: string): boolean {
|
||||
if (CREDENTIAL_NAMES.has(name)) {
|
||||
return true
|
||||
}
|
||||
return CREDENTIAL_SUFFIXES.some((suffix) => name.endsWith(suffix))
|
||||
}
|
||||
|
||||
function stripCredentials(env: Record<string, string | undefined>): Record<string, string> {
|
||||
const clean: Record<string, string> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (!value) {
|
||||
continue
|
||||
}
|
||||
if (isCredentialEnvVar(key)) {
|
||||
continue
|
||||
}
|
||||
clean[key] = value
|
||||
}
|
||||
|
||||
return clean
|
||||
}
|
||||
|
||||
// ─── Sandbox creation ──────────────────────────────────────────────────
|
||||
|
||||
export interface Sandbox {
|
||||
root: string
|
||||
hermesHome: string
|
||||
userDataDir: string
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
function createSandbox(prefix: string): Sandbox {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-e2e-${prefix}-${Math.random()}`))
|
||||
const hermesHome = path.join(root, 'hermes-home')
|
||||
const userDataDir = path.join(root, 'electron-user-data')
|
||||
|
||||
fs.mkdirSync(hermesHome, { recursive: true })
|
||||
fs.mkdirSync(userDataDir, { recursive: true })
|
||||
|
||||
return {
|
||||
root,
|
||||
hermesHome,
|
||||
userDataDir,
|
||||
cleanup: () => {
|
||||
try {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Config writing ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write a config.yaml that pre-configures a mock provider pointing at the
|
||||
* mock inference server. The provider is set as the active model provider so
|
||||
* the desktop app skips onboarding and boots straight to the chat UI.
|
||||
*/
|
||||
function writeMockProviderConfig(hermesHome: string, mockUrl: string): void {
|
||||
const configPath = path.join(hermesHome, 'config.yaml')
|
||||
const config = `# Auto-generated by E2E test fixtures
|
||||
model: mock-model
|
||||
providers:
|
||||
mock:
|
||||
api: ${mockUrl}/v1
|
||||
name: Mock
|
||||
api_mode: chat_completions
|
||||
key_env: MOCK_API_KEY
|
||||
models:
|
||||
mock-model: {}
|
||||
context_length: 4096
|
||||
`
|
||||
fs.writeFileSync(configPath, config, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a minimal .env with the mock API key. The key_env in config.yaml
|
||||
* references MOCK_API_KEY, so the backend resolves credentials from here.
|
||||
*/
|
||||
function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void {
|
||||
const envPath = path.join(hermesHome, '.env')
|
||||
fs.writeFileSync(envPath, `MOCK_API_KEY=${apiKey}\n`, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an empty config (no providers). The desktop app should show the
|
||||
* onboarding overlay because no inference provider is configured.
|
||||
*/
|
||||
function writeEmptyConfig(hermesHome: string): void {
|
||||
const configPath = path.join(hermesHome, 'config.yaml')
|
||||
fs.writeFileSync(configPath, '# Auto-generated by E2E test fixtures — no providers configured\n', 'utf8')
|
||||
}
|
||||
|
||||
// ─── Env building ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the environment for the Electron app process.
|
||||
*
|
||||
* Key env vars:
|
||||
* - HERMES_HOME → sandbox hermes-home (isolated config/sessions)
|
||||
* - HERMES_DESKTOP_USER_DATA_DIR → sandbox electron-user-data
|
||||
* - HERMES_DESKTOP_IGNORE_EXISTING=1 → don't pick up `hermes` from PATH
|
||||
* (we want the dev checkout at REPO_ROOT)
|
||||
* - HERMES_DESKTOP_HERMES_ROOT → REPO_ROOT (dev checkout resolution)
|
||||
* - HERMES_DESKTOP_APP_NAME → unique-ish per test (avoids single-instance lock)
|
||||
* - XDG_RUNTIME_DIR → ensure Electron has a writable runtime dir on Linux
|
||||
*/
|
||||
function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}): Record<string, string> {
|
||||
const clean = stripCredentials(process.env)
|
||||
|
||||
// XDG_RUNTIME_DIR is needed for Electron on Linux when running in a
|
||||
// headless/CI context — without it the zygote may fail to initialize.
|
||||
if (!clean.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR) {
|
||||
clean.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR
|
||||
}
|
||||
|
||||
// DISPLAY — needed for Electron to open a window.
|
||||
if (!clean.DISPLAY && process.env.DISPLAY) {
|
||||
clean.DISPLAY = process.env.DISPLAY
|
||||
}
|
||||
|
||||
return {
|
||||
...clean,
|
||||
HERMES_HOME: sandbox.hermesHome,
|
||||
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
|
||||
HERMES_DESKTOP_IGNORE_EXISTING: '1',
|
||||
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
|
||||
HERMES_DESKTOP_APP_NAME: `HermesE2E-${Date.now()}`,
|
||||
// Clear dev-server override — we want the built dist/, not a vite server.
|
||||
// The dev-server check in main.ts looks for this env var; if it's set,
|
||||
// it loads from the vite URL instead of the local file.
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Electron launch ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify that the desktop app has been built (dist/ exists). Playwright
|
||||
* tests can't run without it — the Electron main process loads
|
||||
* dist/electron-main.mjs and the renderer loads dist/index.html.
|
||||
*/
|
||||
function assertDistBuilt(): void {
|
||||
const distDir = path.join(DESKTOP_ROOT, 'dist')
|
||||
const electronMain = path.join(distDir, 'electron-main.mjs')
|
||||
const indexHtml = path.join(distDir, 'index.html')
|
||||
|
||||
if (!fs.existsSync(electronMain)) {
|
||||
throw new Error(
|
||||
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
|
||||
`Missing: ${electronMain}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(indexHtml)) {
|
||||
throw new Error(
|
||||
`Desktop dist/index.html not found. Run 'cd apps/desktop && npm run build' first.\n` +
|
||||
`Missing: ${indexHtml}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Electron binary. In the nix devshell, `electron` is on PATH.
|
||||
* As a fallback, use the node_modules/.bin/electron from the desktop package.
|
||||
*/
|
||||
function findElectron(): string {
|
||||
// In dev mode, we use the `electron` binary directly (not the packaged app).
|
||||
// The dev:electron script in package.json does exactly this: `electron .`
|
||||
// after building. We replicate that here.
|
||||
const localElectron = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron')
|
||||
|
||||
if (fs.existsSync(localElectron)) {
|
||||
return localElectron
|
||||
}
|
||||
|
||||
// Fall back to PATH
|
||||
const result = spawnSync('which', ['electron'], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
if (result.status === 0 && result.stdout.trim()) {
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Electron binary not found. Run "npm install" from the repo root to install devDependencies.',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the desktop app in dev mode.
|
||||
*
|
||||
* @param sandbox - isolated HERMES_HOME + userData
|
||||
* @param env - the process environment (already has HERMES_HOME etc.)
|
||||
* @returns the ElectronApplication + first Page
|
||||
*/
|
||||
async function launchDesktop(
|
||||
env: Record<string, string>,
|
||||
): Promise<{ app: ElectronApplication; page: Page }> {
|
||||
assertDistBuilt()
|
||||
|
||||
const electronBin = findElectron()
|
||||
// `electron .` loads from the package.json `main` field
|
||||
// (dist/electron-main.mjs after build).
|
||||
const app = await _electron.launch({
|
||||
executablePath: electronBin,
|
||||
args: [
|
||||
DESKTOP_ROOT, // `electron .` — the `.` is the desktop package dir
|
||||
'--disable-gpu',
|
||||
'--no-sandbox',
|
||||
'--disable-software-rasterizer',
|
||||
],
|
||||
env,
|
||||
cwd: DESKTOP_ROOT,
|
||||
})
|
||||
|
||||
const page = await app.firstWindow()
|
||||
return { app, page }
|
||||
}
|
||||
|
||||
// ─── Public fixtures ────────────────────────────────────────────────────
|
||||
|
||||
export interface MockBackendFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
mockUrl: string
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a full mock-backend E2E environment:
|
||||
* 1. Start the mock inference server
|
||||
* 2. Create a sandbox with config.yaml pointing at it
|
||||
* 3. Launch the desktop app
|
||||
* 4. Return handles for test interaction
|
||||
*/
|
||||
export async function setupMockBackend(): Promise<MockBackendFixture> {
|
||||
// 1. Start mock server
|
||||
const mock = await startMockServer()
|
||||
|
||||
// 2. Create sandbox + write config
|
||||
const sandbox = createSandbox('mock')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
// 3. Build env + launch
|
||||
const env = buildAppEnv(sandbox)
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface NoProviderFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the app with no provider configured. The onboarding overlay should
|
||||
* appear because there's no inference provider in config.yaml.
|
||||
*/
|
||||
export async function setupNoProvider(): Promise<NoProviderFixture> {
|
||||
const sandbox = createSandbox('noprovider')
|
||||
writeEmptyConfig(sandbox.hermesHome)
|
||||
|
||||
const env = buildAppEnv(sandbox)
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeadBackendFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the app with a provider pointing at a dead endpoint (port 1, which
|
||||
* nothing listens on). The boot should fail with a connection error,
|
||||
* triggering the BootFailureOverlay.
|
||||
*/
|
||||
export async function setupDeadBackend(): Promise<DeadBackendFixture> {
|
||||
const sandbox = createSandbox('dead')
|
||||
const configPath = path.join(sandbox.hermesHome, 'config.yaml')
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`# Auto-generated by E2E test fixtures — dead provider
|
||||
model: mock-model
|
||||
providers:
|
||||
mock:
|
||||
api: http://127.0.0.1:1/v1
|
||||
name: Mock
|
||||
api_mode: chat_completions
|
||||
key_env: MOCK_API_KEY
|
||||
models:
|
||||
mock-model: {}
|
||||
context_length: 4096
|
||||
`,
|
||||
'utf8',
|
||||
)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
const env = buildAppEnv(sandbox)
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Packaged-binary fixture ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the packaged Electron binary path, per-platform, matching
|
||||
* electron-builder's output layout under release/.
|
||||
*/
|
||||
function resolvePackagedBinaryPath(): string {
|
||||
// The Hermes-Setup.exe in Downloads is the Tauri installer, not the
|
||||
// Electron desktop binary — it's only launchable on Windows. Only use
|
||||
// it as the packaged binary on win32.
|
||||
if (process.platform === 'win32') {
|
||||
const downloadsExe = path.join(os.homedir(), 'Downloads', 'Hermes-Setup.exe')
|
||||
|
||||
if (fs.existsSync(downloadsExe)) {
|
||||
return downloadsExe
|
||||
}
|
||||
return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe')
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes')
|
||||
}
|
||||
|
||||
return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes')
|
||||
}
|
||||
|
||||
export const PACKAGED_BINARY_PATH = resolvePackagedBinaryPath()
|
||||
|
||||
export function packagedBinaryExists(): boolean {
|
||||
return fs.existsSync(PACKAGED_BINARY_PATH)
|
||||
}
|
||||
|
||||
export interface PackagedAppFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the *packaged* Electron binary (from `npm run pack` →
|
||||
* `electron-builder --dir`) with `BOOT_FAKE=1` so it simulates boot
|
||||
* progress without spawning a real Hermes backend.
|
||||
*
|
||||
* Uses the same sandbox isolation (credential stripping, isolated
|
||||
* HERMES_HOME + userData, unique app name) as the dev-mode fixtures.
|
||||
*
|
||||
* Skips if the packaged binary doesn't exist — run `npm run pack` first.
|
||||
*/
|
||||
export async function setupPackagedApp(): Promise<PackagedAppFixture> {
|
||||
if (!packagedBinaryExists()) {
|
||||
throw new Error(
|
||||
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
|
||||
)
|
||||
}
|
||||
|
||||
const sandbox = createSandbox('packaged')
|
||||
|
||||
// Build the sandbox env using the shared helpers, then add the
|
||||
// packaged-binary-specific overrides.
|
||||
const env = buildAppEnv(sandbox, {
|
||||
// Fake boot: simulates progress steps without spawning the real backend.
|
||||
HERMES_DESKTOP_BOOT_FAKE: '1',
|
||||
HERMES_DESKTOP_BOOT_FAKE_STEP_MS: '120',
|
||||
})
|
||||
|
||||
// Clear dev-server + hermes-root overrides — the packaged binary
|
||||
// should use its own bundled renderer, not the dev checkout.
|
||||
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_DEV_SERVER
|
||||
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES
|
||||
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES_ROOT
|
||||
|
||||
const app = await _electron.launch({
|
||||
executablePath: PACKAGED_BINARY_PATH,
|
||||
args: ['--disable-gpu', '--no-sandbox', '--disable-software-rasterizer'],
|
||||
env,
|
||||
})
|
||||
|
||||
const page = await app.firstWindow()
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Wait helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wait for the desktop app to finish booting and show the main chat UI.
|
||||
*
|
||||
* The boot overlay disappears when `completeDesktopBoot()` fires in the
|
||||
* renderer — at that point the gateway is open, config is loaded, and
|
||||
* sessions are loaded. We detect this by waiting for the boot/connecting
|
||||
* overlay to become invisible and the main app shell to be present.
|
||||
*/
|
||||
export async function waitForAppReady(page: Page, timeoutMs = 60_000): Promise<void> {
|
||||
// The connecting overlay has a data-testid or we can check for the
|
||||
// absence of boot indicators. The simplest reliable approach is to wait
|
||||
// for the composer (chat input) to become visible — it's disabled until
|
||||
// the gateway is open.
|
||||
await page.waitForSelector('textarea, [contenteditable="true"]', {
|
||||
state: 'visible',
|
||||
timeout: timeoutMs,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the onboarding overlay to appear (no provider configured).
|
||||
*/
|
||||
export async function waitForOnboarding(page: Page, timeoutMs = 60_000): Promise<void> {
|
||||
// The onboarding overlay contains a heading with "Choose your provider"
|
||||
// or similar text. We look for any text that indicates the picker.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const root = document.getElementById('root')
|
||||
if (!root) {
|
||||
return false
|
||||
}
|
||||
const text = root.textContent ?? ''
|
||||
return (
|
||||
text.includes('provider') ||
|
||||
text.includes('Provider') ||
|
||||
text.includes('Choose') ||
|
||||
text.includes('API key') ||
|
||||
text.includes('Sign in')
|
||||
)
|
||||
},
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the boot failure overlay to appear.
|
||||
*/
|
||||
export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const root = document.getElementById('root')
|
||||
if (!root) {
|
||||
return false
|
||||
}
|
||||
const text = root.textContent ?? ''
|
||||
// The boot failure overlay shows an error message + retry/repair
|
||||
// buttons. Look for error-related text.
|
||||
return (
|
||||
text.includes('error') ||
|
||||
text.includes('Error') ||
|
||||
text.includes('failed') ||
|
||||
text.includes('Failed') ||
|
||||
text.includes('Retry') ||
|
||||
text.includes('Repair')
|
||||
)
|
||||
},
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
}
|
||||
82
apps/desktop/e2e/launch-packaged-app.spec.ts
Normal file
82
apps/desktop/e2e/launch-packaged-app.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
packagedBinaryExists,
|
||||
PACKAGED_BINARY_PATH,
|
||||
setupPackagedApp,
|
||||
type PackagedAppFixture,
|
||||
} from './fixtures'
|
||||
|
||||
/**
|
||||
* E2E smoke tests for the packaged Hermes desktop app.
|
||||
*
|
||||
* Launches the real packaged Electron binary (produced by `npm run pack` →
|
||||
* `electron-builder --dir`) with BOOT_FAKE=1 and full sandbox isolation
|
||||
* (credential stripping, isolated HERMES_HOME + userData, unique app name).
|
||||
*
|
||||
* Skips if the packaged binary doesn't exist — run `npm run pack` first.
|
||||
*/
|
||||
|
||||
let fixture: PackagedAppFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.skip(
|
||||
!packagedBinaryExists(),
|
||||
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
|
||||
)
|
||||
|
||||
fixture = await setupPackagedApp()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('window opens with the Hermes title', async () => {
|
||||
const title = await fixture!.page.title()
|
||||
expect(title).toContain('Hermes')
|
||||
})
|
||||
|
||||
test('renderer loads and shows DOM content', async () => {
|
||||
const page = fixture!.page
|
||||
await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 })
|
||||
const childCount = await page.locator('#root > *').count()
|
||||
expect(childCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('boot progress overlay fades out or shows error state', async () => {
|
||||
const page = fixture!.page
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const root = document.getElementById('root')
|
||||
if (!root) {
|
||||
return false
|
||||
}
|
||||
const text = root.textContent ?? ''
|
||||
|
||||
// Error path: boot failure overlay renders an error message.
|
||||
if (text.includes('error') || text.includes('Error') || text.includes('failed')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Success path: overlay disappears and the app renders. If there's
|
||||
// no "boot" / "starting" / "installing" text visible, boot has
|
||||
// completed (either to the main UI or to onboarding).
|
||||
const bootIndicators = ['starting', 'resolving', 'spawning', 'waiting', 'installing']
|
||||
const lower = text.toLowerCase()
|
||||
|
||||
return !bootIndicators.some((word) => lower.includes(word))
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
})
|
||||
|
||||
test('can capture a screenshot for the CI artifact', async () => {
|
||||
const screenshot = await fixture!.page.screenshot({ timeout: 10_000 }).catch(() => null)
|
||||
if (screenshot) {
|
||||
expect(screenshot.byteLength).toBeGreaterThan(0)
|
||||
} else {
|
||||
test.skip(true, 'Screenshot timed out — likely a GPU/rendering issue in headless mode')
|
||||
}
|
||||
})
|
||||
203
apps/desktop/e2e/mock-server.ts
Normal file
203
apps/desktop/e2e/mock-server.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Minimal OpenAI-compatible mock inference server for E2E tests.
|
||||
*
|
||||
* Implements just enough of the /v1/* surface for `hermes serve` to resolve a
|
||||
* provider, list models, and stream a canned chat completion back to the
|
||||
* desktop app — without any real LLM.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /v1/models → { data: [{ id, ... }] }
|
||||
* POST /v1/chat/completions → streaming (SSE) or non-streaming response
|
||||
*
|
||||
* The canned response is a short, deterministic assistant message. Tool-call
|
||||
* requests are not simulated — the E2E tests only need the chat surface to
|
||||
* prove the full boot → gateway → inference → renderer chain works.
|
||||
*/
|
||||
|
||||
import http from 'node:http'
|
||||
|
||||
/** A canned assistant reply used for every chat completion request. */
|
||||
const CANNED_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
|
||||
|
||||
/**
|
||||
* Start the mock server on an ephemeral port.
|
||||
*
|
||||
* @returns a handle with `port`, `url`, and `close()`.
|
||||
*/
|
||||
export function startMockServer(): Promise<{ port: number; url: string; close: () => Promise<void> }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
// CORS headers — the Electron renderer doesn't need them, but they
|
||||
// don't hurt and make the server usable from a browser context too.
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Headers', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
// GET /v1/models — return a single fake model.
|
||||
if (req.method === 'GET' && req.url === '/v1/models') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
object: 'list',
|
||||
data: [
|
||||
{
|
||||
id: 'mock-model',
|
||||
object: 'model',
|
||||
created: 0,
|
||||
owned_by: 'mock',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// POST /v1/chat/completions — return a canned response.
|
||||
if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
|
||||
let body = ''
|
||||
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
body += chunk.toString()
|
||||
})
|
||||
|
||||
req.on('end', () => {
|
||||
let parsed: any = {}
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(body)
|
||||
} catch {
|
||||
// malformed JSON — treat as non-streaming with defaults
|
||||
}
|
||||
|
||||
const stream = parsed.stream === true
|
||||
const model = parsed.model || 'mock-model'
|
||||
|
||||
if (stream) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
})
|
||||
|
||||
// Send the content in a few chunks to simulate streaming.
|
||||
const words = CANNED_REPLY.split(' ')
|
||||
let i = 0
|
||||
|
||||
const sendChunk = () => {
|
||||
if (i >= words.length) {
|
||||
// Final chunk with finish_reason
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
})}\n\n`,
|
||||
)
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const word = i === 0 ? words[i] : ' ' + words[i]
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { content: word },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
})}\n\n`,
|
||||
)
|
||||
i++
|
||||
// Small delay between chunks to simulate real streaming.
|
||||
setTimeout(sendChunk, 20)
|
||||
}
|
||||
|
||||
sendChunk()
|
||||
} else {
|
||||
// Non-streaming response
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: CANNED_REPLY },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 30,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
req.on('error', () => {
|
||||
res.writeHead(400)
|
||||
res.end('Bad request')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback — 404 for anything else
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'Not found' }))
|
||||
})
|
||||
|
||||
server.on('error', reject)
|
||||
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address()
|
||||
if (addr === null || typeof addr === 'string') {
|
||||
reject(new Error('Failed to get server address'))
|
||||
return
|
||||
}
|
||||
|
||||
const port = addr.port
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
|
||||
resolve({
|
||||
port,
|
||||
url,
|
||||
close: () =>
|
||||
new Promise((resolveClose, rejectClose) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
rejectClose(err)
|
||||
} else {
|
||||
resolveClose()
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
73
apps/desktop/e2e/onboarding.spec.ts
Normal file
73
apps/desktop/e2e/onboarding.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* E2E onboarding tests — verify the provider picker appears when no
|
||||
* inference provider is configured.
|
||||
*
|
||||
* Launches the app with an empty config.yaml (no providers). The renderer
|
||||
* should detect the unconfigured state and show the DesktopOnboardingOverlay
|
||||
* with provider options / API key form.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
setupNoProvider,
|
||||
type NoProviderFixture,
|
||||
waitForOnboarding,
|
||||
} from './fixtures'
|
||||
|
||||
let fixture: NoProviderFixture | null = null
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('onboarding with no provider configured', () => {
|
||||
test('onboarding overlay appears on first boot', async () => {
|
||||
fixture = await setupNoProvider()
|
||||
|
||||
// The app should boot (hermes serve starts fine even without a provider),
|
||||
// but the renderer should show the onboarding overlay because no
|
||||
// provider is configured.
|
||||
await waitForOnboarding(fixture.page, 90_000)
|
||||
})
|
||||
|
||||
test('onboarding shows provider options or API key form', async () => {
|
||||
if (!fixture) {
|
||||
test.skip(true, 'Previous test failed — no app running')
|
||||
return
|
||||
}
|
||||
|
||||
const page = fixture.page
|
||||
|
||||
// The onboarding overlay should contain provider-related text.
|
||||
// It might show OAuth providers, an API key form, or a "choose later"
|
||||
// link. Verify at least one of these is visible.
|
||||
const rootText = await page.evaluate(() => {
|
||||
const root = document.getElementById('root')
|
||||
return root?.textContent ?? ''
|
||||
})
|
||||
|
||||
const hasProviderText =
|
||||
rootText.includes('provider') ||
|
||||
rootText.includes('Provider') ||
|
||||
rootText.includes('API key') ||
|
||||
rootText.includes('Sign in') ||
|
||||
rootText.includes('OpenRouter') ||
|
||||
rootText.includes('OpenAI')
|
||||
|
||||
expect(hasProviderText).toBe(true)
|
||||
})
|
||||
|
||||
test('screenshot of onboarding overlay', async () => {
|
||||
if (!fixture) {
|
||||
test.skip(true, 'Previous test failed — no app running')
|
||||
return
|
||||
}
|
||||
|
||||
const screenshot = await fixture.page.screenshot()
|
||||
expect(screenshot.byteLength).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@@ -44,6 +44,8 @@
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'",
|
||||
"fix": "npm run lint:fix && npm run fmt",
|
||||
"test:e2e": "playwright test e2e/",
|
||||
"test:e2e:headed": "cross-env HEADED=1 playwright test e2e/",
|
||||
"test:ui": "vitest run --environment jsdom",
|
||||
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174"
|
||||
},
|
||||
@@ -120,6 +122,7 @@
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.6",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@playwright/test": "^1.61.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
|
||||
18
apps/desktop/playwright.config.ts
Normal file
18
apps/desktop/playwright.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
/* Test files live under e2e/ so they never collide with the vitest suite
|
||||
* under src/ or the node:test files under electron/. */
|
||||
testDir: './e2e',
|
||||
/* The desktop app can take a while to bootstrap on cold CI runners — 90 s
|
||||
* per test gives us headroom without masking real hangs. */
|
||||
timeout: 90_000,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
/* Each test gets its own worker so the Electron process is fully isolated. */
|
||||
fullyParallel: false,
|
||||
reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]],
|
||||
use: {
|
||||
screenshot: 'on',
|
||||
trace: 'on',
|
||||
},
|
||||
})
|
||||
@@ -21,5 +21,6 @@
|
||||
}
|
||||
},
|
||||
"include": ["src", "../shared/src"],
|
||||
"exclude": ["e2e", "electron", "playwright.config.ts"],
|
||||
"references": [{ "path": "./tsconfig.electron.json" }]
|
||||
}
|
||||
|
||||
BIN
e2e/windows/install-button.png
Normal file
BIN
e2e/windows/install-button.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
126
e2e/windows/install-hermes-desktop.ahk
Normal file
126
e2e/windows/install-hermes-desktop.ahk
Normal file
@@ -0,0 +1,126 @@
|
||||
#Requires AutoHotkey v2.0
|
||||
#SingleInstance Force
|
||||
|
||||
|
||||
logPath := A_Args.Length >= 1 ? A_Args[1] : "ahk.log"
|
||||
|
||||
Log(text) {
|
||||
msg := Format("[autohotkey] {}`n", text)
|
||||
ToolTip(text)
|
||||
FileAppend(msg, '*')
|
||||
FileAppend(msg, logPath)
|
||||
}
|
||||
|
||||
OnError(LogError)
|
||||
|
||||
LogError(err, mode) {
|
||||
Log(Format("Unhandled error: {}", err.Message))
|
||||
ExitApp(1)
|
||||
return -1 ; suppress the standard error dialog
|
||||
}
|
||||
|
||||
SetWorkingDir(A_ScriptDir)
|
||||
CoordMode("Pixel", "Screen")
|
||||
CoordMode("Mouse", "Screen")
|
||||
|
||||
|
||||
ClickWithMarker(x, y, button := "Left") {
|
||||
Click(x, y, button)
|
||||
|
||||
Sleep(10)
|
||||
MouseMove(30, 30)
|
||||
Log(Format("Clicking at {1}, {2}", x, y))
|
||||
size := 20
|
||||
g := Gui("-Caption +AlwaysOnTop +ToolWindow")
|
||||
g.BackColor := "Red"
|
||||
g.Show(Format(
|
||||
"x{} y{} w{} h{} NoActivate"
|
||||
, x - size // 2
|
||||
, y - size // 2
|
||||
, size
|
||||
, size
|
||||
))
|
||||
hRegion := DllCall(
|
||||
"CreateEllipticRgn"
|
||||
, "Int", 0
|
||||
, "Int", 0
|
||||
, "Int", size
|
||||
, "Int", size
|
||||
, "Ptr"
|
||||
)
|
||||
DllCall("SetWindowRgn", "Ptr", g.Hwnd, "Ptr", hRegion, "Int", true)
|
||||
WinSetTransparent(255, g.Hwnd)
|
||||
SetTimer(() => g.Destroy(), -500)
|
||||
}
|
||||
|
||||
FindImageInWindow(winTitle, imageFile, &outX, &outY, timeoutMs := 10000, intervalMs := 250)
|
||||
{
|
||||
WinGetPos(&wx, &wy, &ww, &wh, winTitle)
|
||||
|
||||
hBitmap := LoadPicture(imageFile)
|
||||
|
||||
if !hBitmap {
|
||||
throw Error("LoadPicture failed: " imageFile)
|
||||
}
|
||||
bm := Buffer(32, 0) ; BITMAP structure on x64
|
||||
DllCall("GetObject", "Ptr", hBitmap, "Int", bm.Size, "Ptr", bm)
|
||||
|
||||
width := NumGet(bm, 4, "Int")
|
||||
height := NumGet(bm, 8, "Int")
|
||||
|
||||
|
||||
startTime := A_TickCount
|
||||
|
||||
timeLeft := 1
|
||||
|
||||
Log(Format("Searching for button file {} in window {}... {}s left", imageFile, winTitle, Round(timeLeft / 1000, 2)))
|
||||
searchImage := Format("*10 {}", imageFile)
|
||||
Log("SearchImage: " searchImage)
|
||||
while (timeLeft > 0)
|
||||
{
|
||||
if ImageSearch(&x, &y, wx, wy, wx + ww, wy + wh, searchImage)
|
||||
{
|
||||
outX := x + Floor(width / 2)
|
||||
outY := y + Floor(height / 2)
|
||||
Log("Found button!")
|
||||
return
|
||||
}
|
||||
|
||||
Sleep intervalMs
|
||||
timeLeft := timeoutMs - (A_TickCount - startTime)
|
||||
ToolTip(Format("Searching for button {} in window {}... {}s left", imageFile, winTitle, Round(timeLeft / 1000, 2)))
|
||||
}
|
||||
|
||||
throw Error(Format("Failed to find button {} in window {}", imageFile, winTitle))
|
||||
}
|
||||
|
||||
ClickCenterOfImageInWindow(winTitle, imageFile, timeoutMs := 10000, intervalMs := 250)
|
||||
{
|
||||
FindImageInWindow(winTitle, imageFile, &x, &y, timeoutMs, intervalMs)
|
||||
ClickWithMarker(x, y)
|
||||
}
|
||||
|
||||
|
||||
Log("Waiting for the installer window to appear...")
|
||||
winTitle := "Hermes"
|
||||
try {
|
||||
WinWait(winTitle, , 30)
|
||||
} catch {
|
||||
throw Error("Hermes installer window did not appear within 30s")
|
||||
}
|
||||
WinGetPos(&x, &y, &w, &h, winTitle)
|
||||
Log(Format("Window found at x={1} y={2} w={3} h={4}`n", x, y, w, h))
|
||||
|
||||
ClickCenterOfImageInWindow(winTitle, A_ScriptDir "\install-button.png")
|
||||
|
||||
; wait for install to finish
|
||||
FindImageInWindow(winTitle, A_ScriptDir "\launch-button.png", &launchX, &launchY, 1000 * 60 * 8)
|
||||
|
||||
; after we find the install button, close the window so we don't launch hermes.
|
||||
WinClose(winTitle)
|
||||
|
||||
; yay :D
|
||||
Sleep(2000)
|
||||
|
||||
; done
|
||||
ExitApp(0)
|
||||
BIN
e2e/windows/launch-button.png
Normal file
BIN
e2e/windows/launch-button.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
@@ -16,6 +16,8 @@ import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -239,16 +241,70 @@ def _install_uv_posix(env: dict[str, str]) -> None:
|
||||
|
||||
|
||||
def _install_uv_windows(env: dict[str, str]) -> None:
|
||||
"""Invoke the PowerShell installer."""
|
||||
cmd = (
|
||||
'irm https://astral.sh/uv/install.ps1 | iex'
|
||||
)
|
||||
subprocess.run(
|
||||
["powershell", "-ExecutionPolicy", "Bypass", "-c", cmd],
|
||||
env=env,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
"""Download the uv binary zip directly from GitHub releases.
|
||||
|
||||
We intentionally do NOT run the astral installer script
|
||||
(``irm https://astral.sh/uv/install.ps1 | iex``) anymore. That script
|
||||
calls ``Get-ExecutionPolicy`` internally (from the
|
||||
``Microsoft.PowerShell.Security`` module), and on some Windows installs
|
||||
that module fails to load -- killing the installer before it can download
|
||||
anything. Downloading the zip ourselves with stdlib avoids spawning any
|
||||
PowerShell child process at all, sidestepping the broken module entirely.
|
||||
"""
|
||||
# Detect the real OS architecture. platform.machine() reports the
|
||||
# emulated view (AMD64 on ARM under Prism), so prefer the env vars that
|
||||
# reflect the actual hardware. Mirrors Get-WindowsArch in install.ps1.
|
||||
proc_arch = (
|
||||
os.environ.get("PROCESSOR_ARCHITEW6432")
|
||||
or os.environ.get("PROCESSOR_ARCHITECTURE", "")
|
||||
).upper()
|
||||
if proc_arch in ("ARM64",):
|
||||
target_triple = "aarch64-pc-windows-msvc"
|
||||
elif proc_arch in ("AMD64", "X64"):
|
||||
target_triple = "x86_64-pc-windows-msvc"
|
||||
elif proc_arch in ("X86",):
|
||||
target_triple = "i686-pc-windows-msvc"
|
||||
else:
|
||||
# Fallback: platform.machine(). On native x64 this is "AMD64".
|
||||
machine = platform.machine().upper()
|
||||
if machine in ("ARM64", "AARCH64"):
|
||||
target_triple = "aarch64-pc-windows-msvc"
|
||||
elif machine in ("AMD64", "X64"):
|
||||
target_triple = "x86_64-pc-windows-msvc"
|
||||
else:
|
||||
target_triple = "i686-pc-windows-msvc"
|
||||
|
||||
zip_name = f"uv-{target_triple}.zip"
|
||||
urls = [
|
||||
f"https://github.com/astral-sh/uv/releases/latest/download/{zip_name}",
|
||||
f"https://releases.astral.sh/github/uv/releases/latest/download/{zip_name}",
|
||||
]
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
zip_path = Path(tmp) / zip_name
|
||||
last_err: Exception | None = None
|
||||
for url in urls:
|
||||
try:
|
||||
logging.debug("Downloading uv from %s", url)
|
||||
urllib.request.urlretrieve(url, zip_path)
|
||||
break
|
||||
except Exception as exc:
|
||||
last_err = exc
|
||||
logging.debug("Download failed from %s: %s", url, exc)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"Failed to download uv from all mirrors: {last_err}"
|
||||
) from last_err
|
||||
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
zf.extractall(tmp)
|
||||
|
||||
# Move every .exe from the archive into the target's parent (the
|
||||
# managed bin dir). The zip layout is flat (uv.exe, uvx.exe) but
|
||||
# handle nested just in case.
|
||||
bin_dir = env.get("UV_INSTALL_DIR") or str(Path(env.get("UV_UNMANAGED_INSTALL", "")).parent)
|
||||
for exe in Path(tmp).rglob("*.exe"):
|
||||
shutil.copy2(exe, Path(bin_dir) / exe.name)
|
||||
|
||||
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
|
||||
True # dont remove me. ask ethernet
|
||||
14
nix/lib.nix
14
nix/lib.nix
@@ -18,11 +18,15 @@ let
|
||||
# The workspace root — where the single package-lock.json lives.
|
||||
src = ../.;
|
||||
|
||||
# npm dependencies for the workspace, shared by all members. importNpmLock
|
||||
# resolves each package from the lockfile's own `integrity` hashes, so the
|
||||
# lockfile is the single source of truth — no separate dependency hash to
|
||||
# keep in sync with it.
|
||||
npmDeps = pkgs.importNpmLock.importNpmLock { npmRoot = src; };
|
||||
# Single npm deps fetch from the workspace root lockfile.
|
||||
# All workspace packages share this derivation.
|
||||
npmDepsHash = "sha256-k6v5qo56UQ3vx/thUrtDe3lX9jQTSZMIp2rQ9HotbL4=";
|
||||
|
||||
npmDeps = pkgs.fetchNpmDeps {
|
||||
inherit src;
|
||||
fetcherVersion = 2;
|
||||
hash = npmDepsHash;
|
||||
};
|
||||
in
|
||||
{
|
||||
# Returns a buildNpmPackage-compatible attrs set that provides:
|
||||
|
||||
64
package-lock.json
generated
64
package-lock.json
generated
@@ -133,6 +133,7 @@
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.6",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@playwright/test": "^1.61.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
@@ -3299,6 +3300,22 @@
|
||||
"node": ">=14.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
|
||||
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.61.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz",
|
||||
@@ -15472,6 +15489,53 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
||||
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/plist": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz",
|
||||
|
||||
@@ -412,36 +412,6 @@ function Install-AgentBrowser {
|
||||
# Dependency checks
|
||||
# ============================================================================
|
||||
|
||||
# Resolve the PowerShell host executable used to spawn child PowerShell
|
||||
# processes (the astral uv installer below). We must NOT hardcode the bare
|
||||
# name `powershell`: it names *Windows PowerShell* and only resolves when its
|
||||
# System32 directory is on PATH. When install.ps1 is run under PowerShell 7+
|
||||
# (`pwsh`) -- or any session where `powershell` isn't on PATH -- a bare
|
||||
# `powershell` spawn dies with "The term 'powershell' is not recognized",
|
||||
# aborting uv installation (field report: Windows install stuck, uv install
|
||||
# failed with exactly that message). Prefer the absolute path of the host we
|
||||
# are already running in (PATH-independent), then fall back to whichever of
|
||||
# powershell/pwsh is resolvable, and only then to the bare name.
|
||||
function Get-PowerShellHostExe {
|
||||
try {
|
||||
$hostExe = (Get-Process -Id $PID).Path
|
||||
if ($hostExe -and (Test-Path $hostExe)) {
|
||||
$leaf = Split-Path $hostExe -Leaf
|
||||
# Only trust the current host when it is a real PowerShell CLI
|
||||
# (not e.g. powershell_ise.exe or an embedded host that can't take
|
||||
# `-ExecutionPolicy`/`-Command`).
|
||||
if ($leaf -match '^(?i:powershell|pwsh)\.exe$') { return $hostExe }
|
||||
}
|
||||
} catch { }
|
||||
foreach ($candidate in @("powershell", "pwsh")) {
|
||||
$cmd = Get-Command $candidate -CommandType Application -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 1
|
||||
if ($cmd -and $cmd.Source) { return $cmd.Source }
|
||||
}
|
||||
# Last-ditch: hand back the bare name so the spawn surfaces its own error.
|
||||
return "powershell"
|
||||
}
|
||||
|
||||
function Install-Uv {
|
||||
# Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there —
|
||||
# no PATH probing, no conda guards, no multi-location resolution chains.
|
||||
@@ -457,20 +427,72 @@ function Install-Uv {
|
||||
}
|
||||
|
||||
Write-Info "Installing managed uv into $HermesHome\bin ..."
|
||||
New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null
|
||||
$binDir = Join-Path $HermesHome "bin"
|
||||
New-Item -ItemType Directory -Path $binDir -Force | Out-Null
|
||||
|
||||
# Download the uv binary zip directly from GitHub releases instead of
|
||||
# running the astral installer script (`irm https://astral.sh/uv/install.ps1 | iex`).
|
||||
# The astral installer calls Get-ExecutionPolicy internally (from the
|
||||
# Microsoft.PowerShell.Security module), and on some Windows installs that
|
||||
# module fails to load -- killing the installer before it can download
|
||||
# anything (field report: "The 'Get-ExecutionPolicy' command was found in
|
||||
# the module 'Microsoft.PowerShell.Security', but the module could not be
|
||||
# loaded"). Downloading the zip ourselves sidesteps the broken module
|
||||
# entirely: no child powershell spawn, no execution-policy check, no
|
||||
# script parsing. The astral installer is just a wrapper around this zip
|
||||
# anyway.
|
||||
$arch = Get-WindowsArch
|
||||
$targetTriple = switch ($arch) {
|
||||
"x64" { "x86_64-pc-windows-msvc" }
|
||||
"arm64" { "aarch64-pc-windows-msvc" }
|
||||
"x86" { "i686-pc-windows-msvc" }
|
||||
default { throw "Unsupported Windows architecture for uv: $arch" }
|
||||
}
|
||||
$zipName = "uv-$targetTriple.zip"
|
||||
# The /latest/download/ URL always serves the most recent release zip.
|
||||
$downloadUrls = @(
|
||||
"https://github.com/astral-sh/uv/releases/latest/download/$zipName",
|
||||
"https://releases.astral.sh/github/uv/releases/latest/download/$zipName"
|
||||
)
|
||||
|
||||
$tempZip = [System.IO.Path]::GetTempFileName() + ".zip"
|
||||
$tempExtract = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName())
|
||||
$downloaded = $false
|
||||
|
||||
foreach ($url in $downloadUrls) {
|
||||
try {
|
||||
Write-Info "Downloading uv from $url ..."
|
||||
Invoke-WebRequest -Uri $url -OutFile $tempZip -UseBasicParsing -ErrorAction Stop
|
||||
$downloaded = $true
|
||||
break
|
||||
} catch {
|
||||
Write-Warn "Download failed from $url : $_"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $downloaded) {
|
||||
Write-Err "Failed to download uv from all mirrors"
|
||||
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
|
||||
Remove-Item $tempZip -Force -ErrorAction SilentlyContinue
|
||||
return $false
|
||||
}
|
||||
|
||||
# UV_INSTALL_DIR tells the astral installer to place the binary
|
||||
# directly into $HermesHome\bin instead of ~/.local/bin.
|
||||
$prevEAP = $ErrorActionPreference
|
||||
try {
|
||||
$ErrorActionPreference = "Continue"
|
||||
$env:UV_INSTALL_DIR = Join-Path $HermesHome "bin"
|
||||
# Spawn via the resolved host exe (see Get-PowerShellHostExe) rather
|
||||
# than a bare `powershell`, which isn't guaranteed to be on PATH under
|
||||
# PowerShell 7 / pwsh-only setups.
|
||||
$psHostExe = Get-PowerShellHostExe
|
||||
& $psHostExe -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
|
||||
$ErrorActionPreference = $prevEAP
|
||||
New-Item -ItemType Directory -Path $tempExtract -Force | Out-Null
|
||||
Expand-Archive -Path $tempZip -DestinationPath $tempExtract -Force -ErrorAction Stop
|
||||
|
||||
# The zip contains uv.exe (and uvx.exe) either at the root or inside
|
||||
# a subdirectory. Find and move them to the managed bin dir.
|
||||
$executables = Get-ChildItem -Path $tempExtract -Recurse -Filter "*.exe" -ErrorAction SilentlyContinue
|
||||
if (-not $executables) {
|
||||
Write-Err "uv zip did not contain any executables"
|
||||
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
|
||||
return $false
|
||||
}
|
||||
|
||||
foreach ($exe in $executables) {
|
||||
Copy-Item -Path $exe.FullName -Destination $binDir -Force -ErrorAction Stop
|
||||
}
|
||||
|
||||
if (Test-Path $managedUv) {
|
||||
$script:UvCmd = $managedUv
|
||||
@@ -479,14 +501,16 @@ function Install-Uv {
|
||||
return $true
|
||||
}
|
||||
|
||||
Write-Err "uv installed but not found at $managedUv"
|
||||
Write-Err "uv.exe not found at $managedUv after extraction"
|
||||
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
|
||||
return $false
|
||||
} catch {
|
||||
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
|
||||
Write-Err "Failed to install uv: $_"
|
||||
Write-Err "Failed to extract uv: $_"
|
||||
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
|
||||
return $false
|
||||
} finally {
|
||||
Remove-Item $tempZip -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item $tempExtract -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Session transcript stores are read-only to agent file tools.
|
||||
|
||||
Inspired by Claude Code 2.1.205's auto-mode rule preventing transcript
|
||||
manipulation. Hermes keeps canonical conversation history in state.db and may
|
||||
also emit legacy JSON snapshots under sessions/; agent tools must not rewrite
|
||||
or delete either store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake_homes(tmp_path, monkeypatch):
|
||||
import agent.file_safety as fs
|
||||
|
||||
root = tmp_path / ".hermes"
|
||||
profile = root / "profiles" / "work"
|
||||
profile.mkdir(parents=True)
|
||||
monkeypatch.setattr(fs, "_hermes_home_path", lambda: profile)
|
||||
monkeypatch.setattr(fs, "_hermes_root_path", lambda: root)
|
||||
return root, profile
|
||||
|
||||
|
||||
@pytest.mark.parametrize("relative", ["state.db", "sessions/session_abc.json"])
|
||||
def test_session_state_paths_are_write_denied(fake_homes, relative):
|
||||
from agent.file_safety import is_write_denied
|
||||
|
||||
_root, profile = fake_homes
|
||||
target = profile / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text("existing transcript", encoding="utf-8")
|
||||
|
||||
assert is_write_denied(str(target)) is True
|
||||
|
||||
|
||||
def test_default_profile_state_db_is_write_denied_from_profile(fake_homes):
|
||||
from agent.file_safety import is_write_denied
|
||||
|
||||
root, _profile = fake_homes
|
||||
target = root / "state.db"
|
||||
target.write_text("canonical transcript", encoding="utf-8")
|
||||
|
||||
assert is_write_denied(str(target)) is True
|
||||
|
||||
|
||||
def test_project_local_state_db_remains_writable(fake_homes, tmp_path):
|
||||
from agent.file_safety import is_write_denied
|
||||
|
||||
target = tmp_path / "project" / "state.db"
|
||||
target.parent.mkdir()
|
||||
target.write_text("application database", encoding="utf-8")
|
||||
|
||||
assert is_write_denied(str(target)) is False
|
||||
|
||||
|
||||
def test_write_file_tool_preserves_existing_session_snapshot(fake_homes, monkeypatch):
|
||||
import tools.file_tools as ft
|
||||
|
||||
_root, profile = fake_homes
|
||||
target = profile / "sessions" / "session_abc.json"
|
||||
target.parent.mkdir(parents=True)
|
||||
target.write_text("original transcript", encoding="utf-8")
|
||||
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None)
|
||||
|
||||
result = json.loads(ft.write_file_tool(str(target), "tampered"))
|
||||
|
||||
assert "error" in result
|
||||
assert target.read_text(encoding="utf-8") == "original transcript"
|
||||
@@ -1,94 +0,0 @@
|
||||
"""Regression tests for #48352: Windows PowerShell 5.1 native stderr.
|
||||
|
||||
PowerShell 5.1 turns stderr from native commands into ``NativeCommandError``
|
||||
records when ``$ErrorActionPreference = "Stop"``. ``scripts/install.ps1`` has a
|
||||
few git/uv calls where stderr can be normal progress output, so those calls must
|
||||
run with EAP temporarily relaxed and then inspect ``$LASTEXITCODE``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
|
||||
|
||||
|
||||
def _install_ps1() -> str:
|
||||
return INSTALL_PS1.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _assert_relaxed_call(text: str, command_pattern: str) -> None:
|
||||
helper_block_pattern = (
|
||||
r"Invoke-NativeWithRelaxedErrorAction\s*\{[^}]*"
|
||||
+ command_pattern
|
||||
+ r"[^}]*\}"
|
||||
)
|
||||
inline_pattern = (
|
||||
r"\$ErrorActionPreference\s*=\s*\"Continue\"[\s\S]{0,900}?"
|
||||
+ command_pattern
|
||||
)
|
||||
assert re.search(helper_block_pattern, text) or re.search(inline_pattern, text), (
|
||||
f"install.ps1 must relax ErrorActionPreference around {command_pattern}"
|
||||
)
|
||||
|
||||
|
||||
def test_repository_stage_relieves_eap_for_ssh_and_https_git_clone() -> None:
|
||||
text = _install_ps1()
|
||||
assert "function Invoke-NativeWithRelaxedErrorAction" in text
|
||||
_assert_relaxed_call(
|
||||
text,
|
||||
r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlSsh \$InstallDir",
|
||||
)
|
||||
_assert_relaxed_call(
|
||||
text,
|
||||
r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlHttps \$InstallDir",
|
||||
)
|
||||
|
||||
|
||||
def test_uv_venv_and_dependency_installs_relax_eap() -> None:
|
||||
text = _install_ps1()
|
||||
_assert_relaxed_call(text, r"& \$UvCmd venv venv --python \$PythonVersion")
|
||||
_assert_relaxed_call(text, r"& \$UvCmd sync --extra all --locked")
|
||||
_assert_relaxed_call(text, r"& \$UvCmd pip install -e \$tier\.Spec")
|
||||
|
||||
|
||||
def test_uv_venv_failure_is_not_swallowed_after_eap_relax() -> None:
|
||||
"""Relaxing EAP must not let a genuine `uv venv` failure pass as success.
|
||||
|
||||
Once EAP is relaxed, a real non-zero `uv venv` exit no longer aborts on its
|
||||
own, so install.ps1 must capture $LASTEXITCODE right after the call and fail
|
||||
fast — otherwise the `venv` stage falsely reports success (Invoke-Stage emits
|
||||
ok=true) when no venv was created. Regression guard for the gap caught while
|
||||
reviewing #48372 (the explicit check originally proposed in #48463).
|
||||
"""
|
||||
text = _install_ps1()
|
||||
# The uv-venv invocation, then an exit-code capture, then a throw — all
|
||||
# within a small window after the relaxed call.
|
||||
guard = re.search(
|
||||
r"& \$UvCmd venv venv --python \$PythonVersion[\s\S]{0,400}?"
|
||||
r"\$LASTEXITCODE[\s\S]{0,200}?"
|
||||
r"-ne 0[\s\S]{0,200}?throw",
|
||||
text,
|
||||
)
|
||||
assert guard is not None, (
|
||||
"install.ps1 must capture uv venv's exit code and throw on failure after "
|
||||
"relaxing ErrorActionPreference, so a genuine venv-creation failure isn't "
|
||||
"reported as a successful stage"
|
||||
)
|
||||
|
||||
|
||||
def test_native_eap_helper_always_restores_previous_preference() -> None:
|
||||
text = _install_ps1()
|
||||
m = re.search(
|
||||
r"function Invoke-NativeWithRelaxedErrorAction \{(?P<body>[\s\S]*?)^\}",
|
||||
text,
|
||||
re.MULTILINE,
|
||||
)
|
||||
assert m is not None, "expected a shared helper for NativeCommandError-safe calls"
|
||||
body = m.group("body")
|
||||
assert "$prevEAP = $ErrorActionPreference" in body
|
||||
assert '$ErrorActionPreference = "Continue"' in body
|
||||
assert "finally" in body
|
||||
assert "$ErrorActionPreference = $prevEAP" in body
|
||||
@@ -1,77 +0,0 @@
|
||||
"""Regression: the Windows installer must not spawn a bare ``powershell``.
|
||||
|
||||
A user on Windows reported the installer getting stuck; running
|
||||
``irm https://hermes-agent.nousresearch.com/install.ps1 | iex`` failed at the
|
||||
uv step with::
|
||||
|
||||
[X] Failed to install uv: The term 'powershell' is not recognized as the
|
||||
name of a cmdlet, function, script file, or operable program.
|
||||
|
||||
Root cause: ``Install-Uv`` spawned the astral uv installer via a hardcoded
|
||||
bare ``powershell`` command. That name resolves only to *Windows PowerShell*
|
||||
and only when its System32 directory is on ``PATH``. Under PowerShell 7+
|
||||
(``pwsh``) -- or any session where ``powershell`` isn't on ``PATH`` -- the
|
||||
spawn dies and uv installation aborts.
|
||||
|
||||
The fix resolves the PowerShell host executable (preferring the absolute path
|
||||
of the running host, then ``powershell``/``pwsh`` via ``Get-Command``) and
|
||||
invokes *that* instead of a bare name. These tests lock that contract at the
|
||||
source level (the script only runs on Windows, so there's no runner to
|
||||
execute it on Linux CI).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_INSTALL_PS1 = Path(__file__).resolve().parents[1] / "scripts" / "install.ps1"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def source() -> str:
|
||||
return _INSTALL_PS1.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_astral_uv_installer_not_spawned_via_bare_powershell(source: str):
|
||||
"""The exact failing literal must be gone."""
|
||||
forbidden = 'powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv'
|
||||
assert forbidden not in source, (
|
||||
"Install-Uv still spawns the astral uv installer via a bare "
|
||||
"`powershell` — it must use the resolved PowerShell host exe so it "
|
||||
"works under pwsh / when powershell isn't on PATH."
|
||||
)
|
||||
|
||||
|
||||
def test_astral_uv_installer_invoked_via_resolved_host_variable(source: str):
|
||||
"""The astral uv installer line must use the call operator on a variable.
|
||||
|
||||
i.e. ``& $psHostExe -ExecutionPolicy ... irm https://astral.sh/uv...``
|
||||
rather than naming a fixed executable.
|
||||
"""
|
||||
lines = [ln for ln in source.splitlines() if "astral.sh/uv/install.ps1 | iex" in ln]
|
||||
# Exactly one invocation line carries the astral installer.
|
||||
invocation = [ln for ln in lines if "irm https://astral.sh/uv/install.ps1 | iex" in ln]
|
||||
assert invocation, "astral uv install invocation line not found"
|
||||
for ln in invocation:
|
||||
stripped = ln.strip()
|
||||
assert stripped.startswith("& $"), (
|
||||
f"astral uv installer must be invoked via the call operator on a "
|
||||
f"resolved host variable (`& $...`), got: {stripped!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_powershell_host_resolver_is_defined_and_portable(source: str):
|
||||
"""A host-resolver helper must exist and be PATH-independent + pwsh-aware."""
|
||||
assert "function Get-PowerShellHostExe" in source, (
|
||||
"expected a Get-PowerShellHostExe helper that resolves the host exe"
|
||||
)
|
||||
# PATH-independent: derive the absolute path of the running host.
|
||||
assert "Get-Process -Id $PID" in source, (
|
||||
"resolver must derive the current host's absolute path "
|
||||
"(Get-Process -Id $PID), which is independent of PATH"
|
||||
)
|
||||
# pwsh-aware fallback: PowerShell 7's executable is `pwsh`, not `powershell`.
|
||||
assert "pwsh" in source, (
|
||||
"resolver must fall back to pwsh (PowerShell 7) when powershell is "
|
||||
"unavailable"
|
||||
)
|
||||
Reference in New Issue
Block a user