Compare commits

...

80 Commits

Author SHA1 Message Date
ethernet
5acc0e8f38 a 2026-06-18 16:56:49 -04:00
ethernet
151806bd95 wwwww 2026-06-18 16:52:20 -04:00
ethernet
f739a035db wwwwww 2026-06-18 16:48:36 -04:00
ethernet
4b8d696e88 www 2026-06-18 16:45:21 -04:00
ethernet
7de9695e8b keep going 2026-06-18 16:43:25 -04:00
ethernet
c601b4db3a w 2026-06-18 16:35:21 -04:00
ethernet
e591272d3e logs 2026-06-18 16:33:10 -04:00
ethernet
adb9f46eef ggg 2026-06-18 16:26:15 -04:00
ethernet
60b2bc99cd asdasdfasdf 2026-06-18 16:12:21 -04:00
ethernet
915f658da9 wwww 2026-06-18 16:12:21 -04:00
ethernet
4d88c0f161 wwwww 2026-06-18 16:12:21 -04:00
ethernet
7bf9e37167 wwwwwwwwwwwwwwwww 2026-06-18 16:12:21 -04:00
ethernet
0e59384410 ccccca 2026-06-18 16:12:21 -04:00
ethernet
e4457e75ca cont 2026-06-18 16:12:21 -04:00
ethernet
ce98264423 www\ 2026-06-18 16:12:21 -04:00
ethernet
a1564c0555 wwwwww 2026-06-18 16:12:21 -04:00
ethernet
a2abd0ca72 env? 2026-06-18 16:12:21 -04:00
ethernet
b4131d7ac6 fix cache weird idk uploady 2026-06-18 16:12:21 -04:00
ethernet
d14d35db5d wwwwwww 2026-06-18 16:12:21 -04:00
ethernet
12bb9725d3 g 2026-06-18 16:12:21 -04:00
ethernet
bdbc75df51 lalala
`
2026-06-18 16:12:21 -04:00
ethernet
7eb3c1fdd2 gg 2026-06-18 16:12:21 -04:00
ethernet
c57cbe6ad6 wwwww 2026-06-18 16:12:21 -04:00
ethernet
3d4317ea8a why 2026-06-18 16:12:21 -04:00
ethernet
664f4f285c wwww 2026-06-18 16:12:21 -04:00
ethernet
b19f0f0020 fix 2026-06-18 16:12:21 -04:00
ethernet
f8166541d6 a 2026-06-18 16:12:21 -04:00
ethernet
0b84b8f09f keep goin 2026-06-18 16:12:21 -04:00
ethernet
3ad131eddf www 2026-06-18 16:12:14 -04:00
ethernet
db10a1d535 mkv 2026-06-18 16:12:14 -04:00
ethernet
c30a20b097 aawawa 2026-06-18 16:12:14 -04:00
ethernet
d65c427b46 cache bust 2026-06-18 16:12:14 -04:00
ethernet
e0e6f9fa8f dont pin to build commit 2026-06-18 16:12:14 -04:00
ethernet
d8d3ac120f babababa 2026-06-18 16:12:14 -04:00
ethernet
64a59a8ecd stdin redir? 2026-06-18 16:12:14 -04:00
ethernet
fdddf8c2f6 ffmpeg2 2026-06-18 16:12:14 -04:00
ethernet
f65ae38f2c yay 2026-06-18 16:12:14 -04:00
ethernet
9b6eef489b asdasd 2026-06-18 16:12:14 -04:00
ethernet
c099065064 ww 2026-06-18 16:12:14 -04:00
ethernet
fe1a02ae0b ffmpeg 2026-06-18 16:12:14 -04:00
ethernet
c2b2034dd5 www 2026-06-18 16:12:14 -04:00
ethernet
83f893f6a2 dirs 2026-06-18 16:12:14 -04:00
ethernet
f4652ac940 dir 2026-06-18 16:12:14 -04:00
ethernet
c9f40a5118 installdir 2026-06-18 16:12:14 -04:00
ethernet
e17d3174c5 ww 2026-06-18 16:12:14 -04:00
ethernet
2f7bd2f425 www 2026-06-18 16:12:14 -04:00
ethernet
9c1377dd71 typo 2026-06-18 16:12:14 -04:00
ethernet
4b20e00a14 bs 2026-06-18 16:12:14 -04:00
ethernet
5b8309ee07 www 2026-06-18 16:12:14 -04:00
ethernet
e865350442 fix weird checkout 2026-06-18 16:12:14 -04:00
ethernet
c8021ba5ed ffmpreg 2026-06-18 16:12:14 -04:00
ethernet
b9b52dde9b heckout optimization 2026-06-18 16:12:14 -04:00
ethernet
042de5911e fixie 2026-06-18 16:12:14 -04:00
ethernet
bf05cf270c ahk and thing 2026-06-18 16:12:14 -04:00
ethernet
e69d782577 done 2026-06-18 16:12:14 -04:00
ethernet
1f509b9459 ahk dir correct 2026-06-18 16:12:14 -04:00
ethernet
63ab33ec76 aaa 2026-06-18 16:12:14 -04:00
ethernet
5e0cd3e75c w 2026-06-18 16:12:14 -04:00
ethernet
22b5329532 w 2026-06-18 16:12:14 -04:00
ethernet
7cc5123ca1 windwos ache 2026-06-18 16:12:14 -04:00
ethernet
81b9f8bc8f dl artifact 2026-06-18 16:12:14 -04:00
ethernet
13417bb1b9 simpler installer cache 2026-06-18 16:12:14 -04:00
ethernet
a9a9e5addf wip 2026-06-18 16:12:14 -04:00
ethernet
5bc4c4b950 wwwwwwww 2026-06-18 16:12:14 -04:00
ethernet
7fdc7211f6 wip wip installer 2026-06-18 16:12:14 -04:00
ethernet
d844bc2f71 cache 2026-06-18 16:12:14 -04:00
ethernet
f87ed7ba03 always npm 2026-06-18 16:12:14 -04:00
ethernet
fea41c2585 ww 2026-06-18 16:12:14 -04:00
ethernet
8c11153d44 ahk 2026-06-18 16:12:14 -04:00
ethernet
f66b676423 check 2026-06-18 16:12:14 -04:00
ethernet
841926ca4f winget paths 2026-06-18 16:12:14 -04:00
ethernet
8977a5e3b4 installers 2026-06-18 16:12:14 -04:00
ethernet
3bedf4bd97 wwwwwwww 2026-06-18 16:12:14 -04:00
ethernet
71ca055988 ahk 2026-06-18 16:12:14 -04:00
ethernet
2617034684 t 2026-06-18 16:12:14 -04:00
ethernet
fd86766077 longer timeout 2026-06-18 16:12:14 -04:00
ethernet
4c2d7661fd p 2026-06-18 16:12:14 -04:00
ethernet
8a91c3034f tc 2026-06-18 16:12:14 -04:00
ethernet
5ebc28516e w 2026-06-18 16:12:14 -04:00
ethernet
d33975816b wip e2e 2026-06-18 16:12:14 -04:00
8 changed files with 753 additions and 100 deletions

View File

@ -1,100 +0,0 @@
name: Build Windows Installer
on:
workflow_dispatch:
permissions:
contents: read
jobs:
# Gate: workflow_dispatch is already restricted to users with write access,
# but we want ADMIN-only. Explicitly check the triggering actor's repo
# permission via the API and fail fast for anyone below admin.
authorize:
name: Authorize (admins only)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Check actor is a repo admin
env:
GH_TOKEN: ${{ github.token }}
ACTOR: ${{ github.actor }}
run: |
set -euo pipefail
perm=$(gh api \
"repos/${{ github.repository }}/collaborators/${ACTOR}/permission" \
--jq '.permission')
echo "Actor '${ACTOR}' has permission: ${perm}"
if [ "${perm}" != "admin" ]; then
echo "::error::'${ACTOR}' is not a repo admin (permission=${perm}). Refusing to build/sign."
exit 1
fi
echo "Authorized: '${ACTOR}' is an admin."
build:
name: Hermes-Setup.exe
needs: authorize
runs-on: windows-latest
timeout-minutes: 30
permissions:
contents: read
# Required for OIDC auth to Azure (azure/login federated credentials).
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
- name: Install npm dependencies
run: npm ci
- name: Setup Rust
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
- name: Cache Rust targets
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
workspaces: apps/bootstrap-installer/src-tauri
- name: Build installer
run: npm run tauri:build
working-directory: apps/bootstrap-installer
- name: Azure login (OIDC)
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Sign Hermes-Setup.exe with Azure Artifact Signing
uses: azure/artifact-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2
with:
endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }}
signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ vars.AZURE_SIGNING_CERTIFICATE_PROFILE }}
# Sign both the raw exe and the bundled NSIS installer.
files-folder: ${{ github.workspace }}\apps\bootstrap-installer\src-tauri\target\release
files-folder-filter: exe
files-folder-recurse: true
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
- name: Upload NSIS installer
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Hermes-Setup-installer
path: apps/bootstrap-installer/src-tauri/target/release/bundle/nsis/*.exe
- name: Upload raw exe
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: Hermes-Setup-exe
path: apps/bootstrap-installer/src-tauri/target/release/Hermes-Setup.exe

389
.github/workflows/e2e-windows.yml vendored Normal file
View File

@ -0,0 +1,389 @@
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
build-installer:
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-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 installer
if: steps.installer-cache.outputs.cache-hit != 'true'
run: npm run tauri:build
working-directory: apps/bootstrap-installer
timeout-minutes: 10
env:
HERMES_BUILD_PIN_BRANCH: "main" # build the installer exactly as it would build on `main`. we'll override this when running it.
# 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: Run installer test
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
id: installer-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
with:
path: Hermes-Setup.exe
key: hermes-installer-cache-${{ runner.os }}-${{ hashFiles('source/package-lock.json', 'source/apps/bootstrap-installer/**', '!source/apps/bootstrap-installer/src-tauri/target/**') }}
- 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: 5
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.
#
# -t 600 is a belt-and-suspenders cap so the container still
# finalizes even if the 'q' is somehow missed; -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 -t 600 " +
"-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))"
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 "$env:INSTALL_DIR\e2e\windows\install-hermes-desktop.ahk", "$PWD\ahk.log" -PassThru -NoNewWindow `
-RedirectStandardOutput ahk-stdout.log -RedirectStandardError ahk-stderr.log
# Wait for AHK helper to finish
$ahkProc | Wait-Process -Timeout 30 -ErrorAction SilentlyContinue
if (-not $ahkProc.HasExited) {
Write-Host "AHK helper is still running; stopping it"
Stop-Process -Id $ahkProc.Id -Force -ErrorAction SilentlyContinue
}
# Leave the installer running briefly for the recording, then stop it.
Start-Sleep -Seconds 5
if ($proc -and -not $proc.HasExited) {
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
}
}
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"
}
# ── 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`:h=$h`:color=red@0.9:t=2,split=2[box][win];[win]crop=$w`:$h`:$x`:$y,drawgrid=w=16:h=16:t=1:c=red@0.4[grid];[box][grid]overlay=$x`:$y"
# Label the X and Y axes at 64px intervals
$labelStep = 16
$txt = "fontfile='C\:\\Windows\\Fonts\\arial.ttf':fontsize=8:fontcolor=white:box=1:boxcolor=black@1"
$labels = @()
for ($i = 0; $i * $labelStep -le $w; $i++) {
$val = $i * $labelStep
$lx = $x + $val
$ly = if ($y -ge 18) { $y - 18 } else { $y + 4 }
$labels += "drawtext=text='$val':$txt`:x=$lx`:y=$ly"
}
for ($j = 0; $j * $labelStep -le $h; $j++) {
$val = $j * $labelStep
$lx = if ($x -ge 40) { $x - 40 } else { $x + 4 }
$ly = $y + $val
$labels += "drawtext=text='$val':$txt`:x=$lx`:y=$ly"
}
if ($labels) {
$vf = "$grid,$($labels -join ','),$vf"
} else {
$vf = "$grid,$vf"
}
}
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: Convert screen recording to compact GIF
if: always()
shell: pwsh
run: |
ffmpeg -y -i recording.mkv `
-vf "fps=15,scale=800:-1:flags=lanczos,split=2[s0][s1];[s0]palettegen=max_colors=64[p];[s1][p]paletteuse=dither=bayer" `
recording.gif
- name: Upload compact GIF
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
id: upload-gif
if: always()
with:
name: screen-recording-gif-${{ github.sha }}
path: recording.gif
retention-days: 1
archive: false
overwrite: true
- name: Link screen recording in job summary
shell: pwsh
env:
MKV_URL: ${{ steps.upload-recording.outputs.artifact-url }}
GIF_URL: ${{ steps.upload-gif.outputs.artifact-url }}
run: |
Add-Content $env:GITHUB_STEP_SUMMARY "## Installer screen recording"
Add-Content $env:GITHUB_STEP_SUMMARY ""
Add-Content $env:GITHUB_STEP_SUMMARY "[download recording.mkv]($env:MKV_URL)"
Add-Content $env:GITHUB_STEP_SUMMARY ""
Add-Content $env:GITHUB_STEP_SUMMARY "![recording.gif]($env:GIF_URL)"
- name: Upload AHK logs and script
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: ahk-discovery
path: |
ahk.log
ahk-stdout.log
ahk-stderr.log
capture-button.ahk
if-no-files-found: ignore

View File

@ -0,0 +1,208 @@
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
import { expect, test } from '@playwright/test'
/**
* E2E smoke tests for the built Hermes desktop app.
*
* These tests launch the real packaged Electron binary (produced by
* `npm run pack` `electron-builder --dir`) with:
* - HERMES_DESKTOP_BOOT_FAKE=1 simulates boot progress without
* spawning a real Hermes backend
* - HERMES_DESKTOP_USER_DATA_DIR isolated electron userData
* - HERMES_DESKTOP_IGNORE_EXISTING=1 forces the bootstrap path
* - HERMES_HOME isolated throwaway directory
* - All credential env vars stripped
*
* The binary path is resolved per-platform to match electron-builder's
* output layout. On Windows CI the app is built with `npm run pack`
* before these tests run.
*/
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
const BINARY_PATH: string = (() => {
const downloadsExe = path.join(os.homedir(), 'Downloads', 'Hermes-Setup.exe')
if (fs.existsSync(downloadsExe)) {
return downloadsExe
}
const platform = process.platform
if (platform === 'darwin') {
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes')
}
if (platform === 'win32') {
return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe')
}
return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes')
})()
// Credential-suffix filter — matches test-desktop.mjs's isCredentialEnvVar.
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 buildSandboxEnv(): { env: Record<string, string>; sandbox: string } {
const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-e2e-'))
const userDataDir = path.join(sandbox, 'electron-user-data')
const hermesHome = path.join(sandbox, 'hermes-home')
fs.mkdirSync(userDataDir, { recursive: true })
fs.mkdirSync(hermesHome, { recursive: true })
// Strip credentials, inject sandboxed env.
const env: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (!value) {continue}
if (isCredentialEnvVar(key)) {continue}
env[key] = value
}
// Fake boot: simulates progress steps without spawning the real backend.
env.HERMES_DESKTOP_BOOT_FAKE = '1'
env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS = '120'
// Force bootstrap path even if a hermes install exists on the runner.
env.HERMES_DESKTOP_IGNORE_EXISTING = '1'
// Isolate electron's userData and HERMES_HOME to the sandbox.
env.HERMES_DESKTOP_USER_DATA_DIR = userDataDir
env.HERMES_HOME = hermesHome
// Clear any dev-server override — we want the packaged renderer, not vite.
delete env.HERMES_DESKTOP_DEV_SERVER
delete env.HERMES_DESKTOP_HERMES
delete env.HERMES_DESKTOP_HERMES_ROOT
return { env, sandbox }
}
let app: ElectronApplication
let page: Page
let sandbox: string
test.beforeAll(async () => {
test.skip(
!fs.existsSync(BINARY_PATH),
`Built app binary not found: ${BINARY_PATH}. Run 'npm run pack' first.`
)
const { env, sandbox: dir } = buildSandboxEnv()
sandbox = dir
app = await _electron.launch({
executablePath: BINARY_PATH,
args: ['--disable-gpu', '--no-sandbox', '--disable-software-rasterizer'],
env,
})
page = await app.firstWindow()
})
test.afterAll(async () => {
await app?.close().catch(() => undefined)
try {
if (sandbox) {fs.rmSync(sandbox, { recursive: true, force: true })}
} catch {
// best-effort cleanup
}
})
test('window opens with the Hermes title', async () => {
// The main.cjs sets the window title to APP_NAME ('Hermes') during
// createBrowserWindow. Verify it before anything else.
const title = await page.title()
expect(title).toContain('Hermes')
})
test('renderer loads and shows DOM content', async () => {
// Wait for the React root to mount. The app renders into #root
// (see src/main.tsx). Give it a generous timeout for cold boot on CI.
await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 })
// The root should have children after React hydrates — the boot overlay
// or the main app shell.
const childCount = await page.locator('#root > *').count()
expect(childCount).toBeGreaterThan(0)
})
test('boot progress overlay fades out or shows error state', async () => {
// With BOOT_FAKE mode the app simulates boot progress steps. Without a
// real backend, boot will eventually fail — the app shows a
// BootFailureOverlay. Either outcome (success → overlay disappears,
// failure → error overlay renders) proves the renderer is working.
//
// Wait for one of:
// (a) the boot overlay disappears (renderer.ready), OR
// (b) an error message becomes visible (boot failure path)
//
// Use a waitForFunction so we don't depend on specific CSS selectors
// that might change between refactors.
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. Look for
// a chat input, sidebar, or settings gear as indicators.
// 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 () => {
// This doubles as both a sanity check (page is renderable) and a
// useful CI artifact — the screenshot is attached to the test report.
const screenshot = await page.screenshot()
expect(screenshot.byteLength).toBeGreaterThan(0)
})

View File

@ -43,6 +43,8 @@
"lint:fix": "eslint src/ electron/ --fix",
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.{js,cjs}' '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.cjs && vite preview --host 127.0.0.1 --port 4174"
},
@ -106,6 +108,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.61.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/hast": "^3.0.4",

View File

@ -0,0 +1,21 @@
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: {
/* Capture traces and videos on failure invaluable when the CI runner
* has no display we can watch live. */
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
video: 'retain-on-failure',
},
})

View File

@ -21,5 +21,6 @@
}
},
"include": ["src", "../shared/src"],
"exclude": ["e2e", "electron", "playwright.config.ts"],
"references": []
}

View File

@ -0,0 +1,67 @@
#Requires AutoHotkey v2.0
#SingleInstance Force
logPath := A_Args.Length >= 1 ? A_Args[1] : "ahk.log"
ClickWithMarker(x, y, button := "Left") {
; Draw marker
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)
; Remove marker after 500ms
SetTimer(() => g.Destroy(), -500)
; Perform click
Click(x, y, button)
}
ToolTip("Waiting for the installer window to appear...")
winTitle := "Hermes"
try {
WinWait(winTitle, , 30)
} catch {
FileAppend("ERROR: Hermes installer window did not appear within 30s`n", logPath)
ExitApp(1)
}
ToolTip("Installer window appeared. Sleeping for a few seconds.....")
WinGetPos(&x, &y, &w, &h, winTitle)
FileAppend(Format("Window found at x={1} y={2} w={3} h={4}`n", x, y, w, h), logPath)
Sleep(3000)
ToolTip("Clicking install at ")
; click install
clickX := x + 448
clickY := y + 418
ClickWithMarker(x, y)
Sleep(2000)
ToolTip("Done")
; done
ExitApp(0)

64
package-lock.json generated
View File

@ -120,6 +120,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.61.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/hast": "^3.0.4",
@ -2589,6 +2590,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",
@ -14614,6 +14631,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",