mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-16 23:21:32 +08:00
Compare commits
45 Commits
feat/opent
...
ethie/e2e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0323730f08 | ||
|
|
793ae40006 | ||
|
|
3b6f1c13cd | ||
|
|
c2f6accea3 | ||
|
|
685cd2be14 | ||
|
|
f70305aa1f | ||
|
|
61b0bdaf29 | ||
|
|
98f3c58486 | ||
|
|
87cb4b8013 | ||
|
|
ae211a74e0 | ||
|
|
b3eed31d7f | ||
|
|
e237b95265 | ||
|
|
713155ae8a | ||
|
|
77dc881912 | ||
|
|
77e666f4ee | ||
|
|
c8b4a5c4f8 | ||
|
|
f797a69b71 | ||
|
|
ad91f08ec1 | ||
|
|
b0462e1324 | ||
|
|
885f222945 | ||
|
|
c042102b40 | ||
|
|
933c041f41 | ||
|
|
8b52eb7955 | ||
|
|
81814323ca | ||
|
|
0e809ba8f5 | ||
|
|
37c62ad080 | ||
|
|
4d947778db | ||
|
|
5a58b87394 | ||
|
|
f59f8e2970 | ||
|
|
c60da6ab84 | ||
|
|
52d5e12ea7 | ||
|
|
a09d6eeb3b | ||
|
|
e6dc853d71 | ||
|
|
e67c5cc0d3 | ||
|
|
d11c6ca846 | ||
|
|
3275332ed2 | ||
|
|
8e8bc72c79 | ||
|
|
2c1e53633c | ||
|
|
a01f42bf95 | ||
|
|
0536ec2a95 | ||
|
|
518722c298 | ||
|
|
d32b619247 | ||
|
|
f616cbe4a1 | ||
|
|
bc2bb718cf | ||
|
|
40a1e6ba59 |
100
.github/workflows/build-windows-installer.yml
vendored
100
.github/workflows/build-windows-installer.yml
vendored
@@ -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
|
||||
297
.github/workflows/e2e-windows.yml
vendored
Normal file
297
.github/workflows/e2e-windows.yml
vendored
Normal file
@@ -0,0 +1,297 @@
|
||||
name: E2E Windows Desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ethie/e2e]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: e2e-windows-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
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-${{ 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: Install npm dependencies
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: checkout full tree on cache miss
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Cache Rust targets
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
uses: Swatinem/rust-cache@c106961feec855ef5a58494c5a2a31c9d80429a9
|
||||
with:
|
||||
workspaces: apps/bootstrap-installer/src-tauri
|
||||
|
||||
- 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:
|
||||
# Pin the installer to the exact commit being tested so that the
|
||||
# install script checks out this PR/code rather than remote main.
|
||||
HERMES_BUILD_PIN_COMMIT: ${{ github.sha }}
|
||||
|
||||
# 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)"
|
||||
|
||||
- name: Upload installer artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: hermes-installer-${{ runner.os }}-${{ hashFiles('package-lock.json', 'apps/bootstrap-installer/**', '!apps/bootstrap-installer/src-tauri/target/**') }}
|
||||
path: Hermes-Setup.exe
|
||||
if-no-files-found: error
|
||||
|
||||
e2e:
|
||||
name: E2E — Windows Desktop
|
||||
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
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: source
|
||||
|
||||
- name: Download installer artifact
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: hermes-installer-${{ 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: Copy checked-out workspace into isolated HERMES_HOME
|
||||
shell: pwsh
|
||||
run: |
|
||||
$installDir = "$env:HERMES_HOME\hermes-agent"
|
||||
New-Item -ItemType Directory -Path $installDir -Force
|
||||
Get-ChildItem -Path ${{ github.workspace }}\source -Force | Move-Item -Destination $installDir -Force
|
||||
Write-Host "Isolated install dir ready: $installDir"
|
||||
|
||||
# ── Screen recording for coordinate discovery ───────────────────
|
||||
- name: Start screen recording
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Start ffmpeg with stdin redirected so the teardown step can send 'q'
|
||||
# and let it finalize the container cleanly. Killing ffmpeg outright
|
||||
# leaves MP4/MKV files unplayable because the header is never written.
|
||||
$ffmpeg = Start-Process ffmpeg -ArgumentList "-y -f gdigrab -framerate 15 -i desktop -c:v libx264 -preset ultrafast recording.mkv" `
|
||||
-RedirectStandardInput ffmpeg.stdin `
|
||||
-RedirectStandardOutput ffmpeg.log `
|
||||
-RedirectStandardError ffmpeg.err -PassThru
|
||||
$ffmpeg.Id | Out-File ffmpeg.pid
|
||||
|
||||
# ── Run the headed installer + AHK helper ───────────────────────
|
||||
# For the first run we deliberately do NOT click the "Install Hermes"
|
||||
# button. AHK waits, captures the window, and exits. A maintainer
|
||||
# reviews the screenshots/recording, supplies the button coordinates,
|
||||
# and we then update the AHK script to click them.
|
||||
- name: Launch Hermes-Setup.exe and capture welcome screen
|
||||
shell: pwsh
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
$installer = "Hermes-Setup.exe"
|
||||
|
||||
# Run the AHK helper first so it can detect the window.
|
||||
$ahk = @'
|
||||
#Requires AutoHotkey v2.0
|
||||
#SingleInstance Force
|
||||
|
||||
; Wait for the Hermes installer window to appear.
|
||||
winTitle := "Hermes"
|
||||
if not WinWait(winTitle,, 30)
|
||||
{
|
||||
FileAppend("ERROR: Hermes installer window did not appear within 30s`n", "ahk.log")
|
||||
ExitApp(1)
|
||||
}
|
||||
|
||||
WinGetPos(&x, &y, &w, &h, winTitle)
|
||||
FileAppend(Format("Window found at x={1} y={2} w={3} h={4}`n", x, y, w, h), "ahk.log")
|
||||
|
||||
; TODO: replace with real coords after first CI run.
|
||||
; clickX := x + w // 2
|
||||
; clickY := y + h // 2 + 120
|
||||
; Click(clickX, clickY)
|
||||
; FileAppend(Format("Clicked at x={1} y={2}`n", clickX, clickY), "ahk.log")
|
||||
|
||||
; Take a screenshot and exit. Maintainer reviews this and sends coords.
|
||||
Sleep(1000)
|
||||
Send("{PrintScreen}")
|
||||
FileAppend("Captured screenshot, exiting`n", "ahk.log")
|
||||
ExitApp(0)
|
||||
'@
|
||||
$ahk | Out-File -Encoding utf8 "capture-button.ahk"
|
||||
|
||||
$ahkProc = Start-Process -FilePath ".\test-bins\autohotkey\AutoHotkey64.exe" `
|
||||
-ArgumentList '"capture-button.ahk"' -PassThru -NoNewWindow `
|
||||
-RedirectStandardOutput ahk-stdout.log -RedirectStandardError ahk-stderr.log
|
||||
|
||||
# Launch the real installer (headed).
|
||||
$proc = Start-Process -FilePath $installer -PassThru -NoNewWindow
|
||||
$proc.Id | Out-File installer.pid
|
||||
|
||||
# Wait for AHK helper to finish (30s window wait + 1s sleep).
|
||||
$ahkProc | Wait-Process -Timeout 45 -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
|
||||
}
|
||||
|
||||
# ── 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 ────────────────────────────────────────
|
||||
- name: Stop screen recording
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ffmpegpid = Get-Content ffmpeg.pid -ErrorAction SilentlyContinue
|
||||
$proc = $null
|
||||
if ($ffmpegpid) {
|
||||
$proc = Get-Process -Id $ffmpegpid -ErrorAction SilentlyContinue
|
||||
}
|
||||
if ($proc) {
|
||||
# Gracefully ask ffmpeg to finalize the container ('q' on stdin).
|
||||
"q" | Out-File -FilePath ffmpeg.stdin -NoNewline
|
||||
$proc.WaitForExit(5000) | Out-Null
|
||||
}
|
||||
if ($proc -and -not $proc.HasExited) {
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
- name: Upload screen recording
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: screen-recording
|
||||
path: recording.mp4
|
||||
if-no-files-found: ignore
|
||||
|
||||
- name: Upload AHK logs and script
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: ahk-discovery
|
||||
path: |
|
||||
ahk.log
|
||||
ahk-stdout.log
|
||||
ahk-stderr.log
|
||||
capture-button.ahk
|
||||
if-no-files-found: ignore
|
||||
208
apps/desktop/e2e/launch.spec.ts
Normal file
208
apps/desktop/e2e/launch.spec.ts
Normal 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)
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
21
apps/desktop/playwright.config.ts
Normal file
21
apps/desktop/playwright.config.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
@@ -21,5 +21,6 @@
|
||||
}
|
||||
},
|
||||
"include": ["src", "../shared/src"],
|
||||
"exclude": ["e2e", "electron", "playwright.config.ts"],
|
||||
"references": []
|
||||
}
|
||||
|
||||
64
package-lock.json
generated
64
package-lock.json
generated
@@ -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",
|
||||
@@ -2535,6 +2536,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",
|
||||
@@ -14553,6 +14570,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",
|
||||
|
||||
Reference in New Issue
Block a user