Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
@@ -0,0 +1,40 @@
|
||||
# Rust / Cargo
|
||||
/src-tauri/target/
|
||||
/src-tauri/Cargo.lock
|
||||
|
||||
# Vite / build output
|
||||
/dist/
|
||||
/dist-ssr/
|
||||
*.local
|
||||
|
||||
# TypeScript build info + tsc emit (we don't ship .js for the
|
||||
# vite.config.ts; Vite reads it directly via ts-node-style loader).
|
||||
*.tsbuildinfo
|
||||
vite.config.d.ts
|
||||
vite.config.js
|
||||
|
||||
# Tauri generated artifacts (regenerated on each build)
|
||||
/src-tauri/gen/schemas/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Internal placeholder (re-create if needed)
|
||||
.tauri-note
|
||||
@@ -0,0 +1,5 @@
|
||||
import shared from '../../eslint.config.shared.mjs'
|
||||
|
||||
export default [
|
||||
...shared
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hermes</title>
|
||||
</head>
|
||||
<body class="h-full antialiased">
|
||||
<div id="root" class="h-full"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@hermes/bootstrap-installer",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"description": "Hermes Setup — signed installer that drives scripts/install.ps1 with a polished native UI.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 5175",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"tauri:dev": "tauri dev",
|
||||
"tauri:build": "tauri build",
|
||||
"tauri:build:debug": "tauri build --debug",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"check": "npm run typecheck && npm run lint",
|
||||
"lint": "eslint src/",
|
||||
"lint:fix": "eslint src/ --fix",
|
||||
"fix": "npm run lint:fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "0.18.2",
|
||||
"@tailwindcss/typography": "0.5.20",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@tauri-apps/api": "2.11.1",
|
||||
"@tauri-apps/plugin-dialog": "2.7.1",
|
||||
"@tauri-apps/plugin-opener": "2.5.4",
|
||||
"@tauri-apps/plugin-process": "2.3.1",
|
||||
"@tauri-apps/plugin-shell": "2.3.5",
|
||||
"@vscode/codicons": "0.0.45",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"katex": "0.16.47",
|
||||
"lucide-react": "0.577.0",
|
||||
"nanostores": "1.4.2",
|
||||
"radix-ui": "1.6.7",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"tailwind-merge": "3.6.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"tw-shimmer": "0.4.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.11.4",
|
||||
"@types/react": "19.2.17",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.3",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "8.2.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,80 @@
|
||||
[package]
|
||||
name = "hermes-bootstrap"
|
||||
version = "0.0.1"
|
||||
description = "Hermes Setup — signed installer that drives scripts/install.ps1"
|
||||
authors = ["Nous Research <info@nousresearch.com>"]
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
|
||||
# Rename the output binary so the distributed artifact is literally
|
||||
# `Hermes-Setup.exe` on disk — not `hermes-bootstrap.exe`. Grandma sees
|
||||
# what we hand her, period. Tauri honors [[bin]] over [package].name
|
||||
# for the produced executable name.
|
||||
[[bin]]
|
||||
name = "Hermes-Setup"
|
||||
path = "src/main.rs"
|
||||
|
||||
# The library target name MUST match the `withGlobalTauri` binding name that
|
||||
# tauri.conf.json's `app.windows[].label` references. We don't ship a separate
|
||||
# lib for now; everything is in src/.
|
||||
[lib]
|
||||
name = "hermes_bootstrap_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
# Tauri runtime + plugins
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
|
||||
# Async + IO
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures = "0.3"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# HTTP — rustls so we don't need OpenSSL on the build box
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] }
|
||||
|
||||
# Logging — emitted to a file under HERMES_HOME/logs/ and (optionally) the
|
||||
# webview console via Tauri's event channel.
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
tracing-appender = "0.2"
|
||||
|
||||
# Paths + utils
|
||||
dirs = "5"
|
||||
which = "6"
|
||||
anyhow = "1"
|
||||
thiserror = "1"
|
||||
once_cell = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
# Process control on Windows (CREATE_NO_WINDOW etc.)
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_Diagnostics_ToolHelp",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Console",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
# Signal-0 liveness probe for the update-lock marker owner (update.rs).
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[profile.release]
|
||||
# A 5-10MB signed installer is the goal. LTO + size-opt + single codegen unit.
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
strip = true
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSAudioCaptureUsageDescription</key>
|
||||
<string>Hermes launches the desktop app, which uses audio capture for voice conversations.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Hermes launches the desktop app, which uses the microphone for voice input and voice conversations.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,190 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
// -----------------------------------------------------------------
|
||||
// Bake the install.ps1 pin into the binary at compile time.
|
||||
//
|
||||
// BUILD_PIN_COMMIT and BUILD_PIN_BRANCH are read by bootstrap.rs's
|
||||
// `option_env!()` macro to default the install-script reference.
|
||||
// Precedence (matches install.ps1's own arg precedence): commit > branch.
|
||||
//
|
||||
// The COMMIT pin is opt-in. By default a dev build pins ONLY the branch,
|
||||
// so the produced installer follows that branch's HEAD at install time
|
||||
// (tolerant of fast-forwards/new commits, and never references a SHA the
|
||||
// local checkout hasn't pushed). Set HERMES_BUILD_PIN_COMMIT to bake an
|
||||
// immutable commit pin for reproducible/release installers.
|
||||
//
|
||||
// Commit pin resolution:
|
||||
// - HERMES_BUILD_PIN_COMMIT, if set and non-empty. Accepts a SHA, tag,
|
||||
// or branch name; resolved to an immutable SHA via `git rev-parse`
|
||||
// when possible, else used verbatim if it already looks like a SHA.
|
||||
// - Otherwise: NO commit pin (branch-follow is the default).
|
||||
//
|
||||
// Branch pin resolution:
|
||||
// 1. HERMES_BUILD_PIN_BRANCH, if set and non-empty.
|
||||
// 2. `git rev-parse --abbrev-ref HEAD` of the checkout this build.rs
|
||||
// lives in — the current branch. (None on a detached HEAD.)
|
||||
// 3. Last-resort fallback handled below: if neither commit nor branch
|
||||
// resolves, warn — the binary needs a runtime arg or dev-repo env.
|
||||
//
|
||||
// Build script reruns on git HEAD change so a new commit triggers
|
||||
// a rebuild without `cargo clean`.
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
let commit = resolve_commit_pin();
|
||||
let branch = resolve_branch_pin();
|
||||
|
||||
if let Some(c) = &commit {
|
||||
println!("cargo:rustc-env=BUILD_PIN_COMMIT={c}");
|
||||
println!(
|
||||
"cargo:warning=hermes-bootstrap: pinning to commit {}",
|
||||
short(c)
|
||||
);
|
||||
}
|
||||
if let Some(b) = &branch {
|
||||
println!("cargo:rustc-env=BUILD_PIN_BRANCH={b}");
|
||||
match &commit {
|
||||
Some(_) => println!("cargo:warning=hermes-bootstrap: pinning to branch {b}"),
|
||||
None => println!(
|
||||
"cargo:warning=hermes-bootstrap: following branch {b} HEAD (no commit pin; \
|
||||
set HERMES_BUILD_PIN_COMMIT for an immutable pin)"
|
||||
),
|
||||
}
|
||||
}
|
||||
if commit.is_none() && branch.is_none() {
|
||||
// Fail loudly rather than silently produce a binary that errors
|
||||
// at runtime with "no install-script pin supplied". A build that
|
||||
// can't resolve a pin almost certainly indicates a misconfigured
|
||||
// build environment.
|
||||
println!(
|
||||
"cargo:warning=hermes-bootstrap: no pin resolved at build time; binary will fail at runtime without HERMES_SETUP_DEV_REPO_ROOT or runtime args"
|
||||
);
|
||||
}
|
||||
|
||||
// Rerun build.rs when HEAD moves. With branch-follow as the default the
|
||||
// baked commit no longer changes per-commit, but a branch *switch* changes
|
||||
// the detected branch name, so we still re-trigger. When an explicit
|
||||
// HERMES_BUILD_PIN_COMMIT resolves a moving ref (tag/branch) to a SHA, a
|
||||
// HEAD move can also change that resolution. .git/HEAD changes on every
|
||||
// commit / branch switch / rebase.
|
||||
let git_dir = locate_git_dir();
|
||||
if let Some(gd) = &git_dir {
|
||||
println!("cargo:rerun-if-changed={}/HEAD", gd.display());
|
||||
// .git/HEAD often points at a ref (e.g. `ref: refs/heads/bb/gui`);
|
||||
// also watch the ref itself so a new commit on the same branch
|
||||
// re-triggers.
|
||||
if let Ok(head) = std::fs::read_to_string(gd.join("HEAD")) {
|
||||
if let Some(rest) = head.trim().strip_prefix("ref: ") {
|
||||
println!("cargo:rerun-if-changed={}/{}", gd.display(), rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("cargo:rerun-if-env-changed=HERMES_BUILD_PIN_COMMIT");
|
||||
println!("cargo:rerun-if-env-changed=HERMES_BUILD_PIN_BRANCH");
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Tauri windows manifest. See hermes-setup.manifest for rationale —
|
||||
// declares level="asInvoker" so Windows's installer-detection
|
||||
// heuristic doesn't refuse to launch us without UAC elevation.
|
||||
// -----------------------------------------------------------------
|
||||
#[cfg(target_os = "windows")]
|
||||
let attrs = {
|
||||
let manifest = include_str!("hermes-setup.manifest");
|
||||
let win = tauri_build::WindowsAttributes::new().app_manifest(manifest);
|
||||
tauri_build::Attributes::new().windows_attributes(win)
|
||||
};
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let attrs = tauri_build::Attributes::new();
|
||||
|
||||
tauri_build::try_build(attrs).expect("failed to run tauri-build");
|
||||
}
|
||||
|
||||
fn resolve_commit_pin() -> Option<String> {
|
||||
// Commit pinning is OPT-IN. Only bake a commit when the caller explicitly
|
||||
// asks for one via HERMES_BUILD_PIN_COMMIT. With no env var, we return
|
||||
// None and the installer follows the branch HEAD at install time.
|
||||
let requested = std::env::var("HERMES_BUILD_PIN_COMMIT").ok()?;
|
||||
let requested = requested.trim();
|
||||
if requested.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// Resolve the request (which may be a SHA, tag, or branch name) to an
|
||||
// immutable commit SHA so the baked pin is reproducible. `^{commit}`
|
||||
// dereferences tags to the commit they point at.
|
||||
if let Ok(out) = Command::new("git")
|
||||
.args(["rev-parse", "--verify", &format!("{requested}^{{commit}}")])
|
||||
.output()
|
||||
{
|
||||
if out.status.success() {
|
||||
if let Ok(s) = String::from_utf8(out.stdout) {
|
||||
let s = s.trim().to_string();
|
||||
if !s.is_empty() {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Couldn't resolve via git (e.g. building outside a checkout). Accept the
|
||||
// literal value only if it already looks like a SHA; otherwise fail loud
|
||||
// rather than bake an unresolvable ref into the binary.
|
||||
if is_sha(requested) {
|
||||
return Some(requested.to_string());
|
||||
}
|
||||
panic!(
|
||||
"HERMES_BUILD_PIN_COMMIT={requested:?} could not be resolved to a commit \
|
||||
(git rev-parse failed and it is not a valid SHA)"
|
||||
);
|
||||
}
|
||||
|
||||
/// True if `s` looks like an abbreviated-or-full git SHA (7..=40 hex chars).
|
||||
fn is_sha(s: &str) -> bool {
|
||||
let len = s.len();
|
||||
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn resolve_branch_pin() -> Option<String> {
|
||||
if let Ok(v) = std::env::var("HERMES_BUILD_PIN_BRANCH") {
|
||||
if !v.trim().is_empty() {
|
||||
return Some(v.trim().to_string());
|
||||
}
|
||||
}
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
|
||||
// "HEAD" is what you get on a detached checkout — no meaningful branch
|
||||
// to pin to. The commit pin still applies; just don't emit a branch.
|
||||
if s.is_empty() || s == "HEAD" {
|
||||
None
|
||||
} else {
|
||||
Some(s)
|
||||
}
|
||||
}
|
||||
|
||||
fn locate_git_dir() -> Option<std::path::PathBuf> {
|
||||
let out = Command::new("git")
|
||||
.args(["rev-parse", "--git-dir"])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8(out.stdout).ok()?.trim().to_string();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(std::path::PathBuf::from(s))
|
||||
}
|
||||
|
||||
fn short(commit: &str) -> &str {
|
||||
if commit.len() >= 12 {
|
||||
&commit[..12]
|
||||
} else {
|
||||
commit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2/capability",
|
||||
"identifier": "default",
|
||||
"description": "Capabilities required by Hermes Setup. Narrowly scoped: we don't write user files outside HERMES_HOME, we don't read arbitrary paths, and the only external network call goes through reqwest (Rust side, not exposed to the webview).",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-theme",
|
||||
"core:event:default",
|
||||
"opener:default",
|
||||
"dialog:default",
|
||||
"process:default",
|
||||
"shell:default"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<!--
|
||||
Hermes Setup application manifest.
|
||||
|
||||
The TL;DR: tell Windows we are NOT an installer in the classic "needs
|
||||
UAC elevation" sense, despite the product name. We provision into
|
||||
%LOCALAPPDATA%\hermes which is user-scoped and never touch HKLM or
|
||||
Program Files. install.ps1 runs as a child process and elevates
|
||||
itself only if a future stage explicitly needs HKLM access.
|
||||
|
||||
Without this manifest, the "Hermes Setup" productName embedded in
|
||||
the binary's resource trips Windows's installer-detection heuristic
|
||||
(https://learn.microsoft.com/en-us/windows/security/identity-protection/
|
||||
user-account-control/how-user-account-control-works#installer-detection)
|
||||
and CreateProcess fails with ERROR_ELEVATION_REQUIRED (740) when the
|
||||
user double-clicks. asInvoker disables that.
|
||||
-->
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<assemblyIdentity
|
||||
version="0.0.1.0"
|
||||
processorArchitecture="*"
|
||||
name="NousResearch.Hermes.Setup"
|
||||
type="win32"
|
||||
/>
|
||||
<description>Hermes Setup</description>
|
||||
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<security>
|
||||
<requestedPrivileges>
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
|
||||
<!-- Tell Windows we know about all supported OSes (10 + 11) so it
|
||||
doesn't shim us into Vista-compat mode. -->
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- Windows 10 / 11 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
|
||||
<!-- Windows 8.1 -->
|
||||
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
|
||||
<!-- Windows 8 -->
|
||||
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
|
||||
<!-- Windows 7 -->
|
||||
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
|
||||
<!-- Windows Vista -->
|
||||
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<!-- Per-monitor v2 DPI awareness so the installer doesn't go blurry
|
||||
on high-DPI displays when dragged between monitors. -->
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
|
||||
<!-- Use the modern common controls (v6 themes). Without this, our
|
||||
file picker / shell dialogs fall back to 1990s-era visuals. -->
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity
|
||||
type="win32"
|
||||
name="Microsoft.Windows.Common-Controls"
|
||||
version="6.0.0.0"
|
||||
processorArchitecture="*"
|
||||
publicKeyToken="6595b64144ccf1df"
|
||||
language="*"
|
||||
/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
||||
|
After Width: | Height: | Size: 674 KiB |
|
After Width: | Height: | Size: 674 KiB |
|
After Width: | Height: | Size: 674 KiB |
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,112 @@
|
||||
//! Event types streamed from Rust → React.
|
||||
//!
|
||||
//! These mirror `apps/desktop/electron/bootstrap-runner.ts`'s event shape
|
||||
//! 1:1 so the React installer code can be roughly identical to the Electron
|
||||
//! install-overlay we'll replace.
|
||||
//!
|
||||
//! The Tauri event channel name is `"bootstrap"` for all of these — the
|
||||
//! `type` discriminator on each payload is how the frontend routes.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Stage definition as reported by `install.ps1 -Manifest`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StageInfo {
|
||||
pub name: String,
|
||||
pub title: String,
|
||||
pub category: String,
|
||||
/// `needs_user_input=true` stages run with -NonInteractive and emit
|
||||
/// skipped=true; the post-install wizard takes over for those.
|
||||
#[serde(rename = "needs_user_input", alias = "needsUserInput")]
|
||||
pub needs_user_input: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub stages: Vec<StageInfo>,
|
||||
#[serde(rename = "protocol_version", alias = "protocolVersion", default)]
|
||||
pub protocol_version: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StageResultPayload {
|
||||
pub stage: String,
|
||||
pub ok: bool,
|
||||
#[serde(default)]
|
||||
pub skipped: bool,
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
/// install.ps1 may attach stage-specific structured data here.
|
||||
#[serde(default)]
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Run-state for a single stage as we transition through it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum StageState {
|
||||
Running,
|
||||
Succeeded,
|
||||
Skipped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Which pipe a raw log line came from. Reported as structured metadata so
|
||||
/// the UI can style stderr subtly rather than mislabeling it as an error:
|
||||
/// uv/pip/git/npm write normal progress to stderr by design.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum LogStream {
|
||||
Stdout,
|
||||
Stderr,
|
||||
}
|
||||
|
||||
/// The single event channel `bootstrap` emits these. `type` discriminates.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum BootstrapEvent {
|
||||
/// Sent once at the start with the full stage list.
|
||||
Manifest {
|
||||
stages: Vec<StageInfo>,
|
||||
#[serde(rename = "protocolVersion")]
|
||||
protocol_version: Option<u32>,
|
||||
},
|
||||
/// Stage state transition. `result` populated only on terminal states.
|
||||
Stage {
|
||||
name: String,
|
||||
state: StageState,
|
||||
#[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
|
||||
duration_ms: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<StageResultPayload>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
/// Raw stdout/stderr line from install.ps1 (or our wrapper). `stream`
|
||||
/// tells the UI which pipe it came from so stderr can be styled subtly
|
||||
/// instead of being mislabeled as an error.
|
||||
Log {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stage: Option<String>,
|
||||
line: String,
|
||||
stream: LogStream,
|
||||
},
|
||||
/// Sent once when all stages complete successfully.
|
||||
Complete {
|
||||
#[serde(rename = "installRoot")]
|
||||
install_root: String,
|
||||
marker: Option<serde_json::Value>,
|
||||
},
|
||||
/// Sent once if the run aborts.
|
||||
Failed {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stage: Option<String>,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl BootstrapEvent {
|
||||
/// Tauri event name. Single channel for all bootstrap events; the
|
||||
/// `type` tag tells the renderer how to interpret the payload.
|
||||
pub const CHANNEL: &'static str = "bootstrap";
|
||||
}
|
||||
@@ -0,0 +1,502 @@
|
||||
//! Resolves and downloads `scripts/install.ps1` (and `install.sh`).
|
||||
//!
|
||||
//! Resolution order:
|
||||
//! 1. Dev shortcut: a sibling repo checkout via $HERMES_SETUP_DEV_REPO_ROOT
|
||||
//! env var. Lets devs iterate without re-publishing the script.
|
||||
//! 2. Bundled fallback: if the installer was bundled with a script (e.g.
|
||||
//! tauri's `resource` mechanism), serve from there. Not used today.
|
||||
//! 3. Network: download from GitHub raw at a pinned commit or branch.
|
||||
//! Commit pins are immutable; branch pins are HEAD-tracking.
|
||||
//!
|
||||
//! Mirrors `apps/desktop/electron/bootstrap-runner.ts`'s `resolveInstallScript`,
|
||||
//! but the dev-checkout resolution is driven by an env var rather than the
|
||||
//! Electron app's APP_ROOT/../.. trick, because Hermes-Setup.exe is meant
|
||||
//! to live OUTSIDE any repo checkout.
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use crate::paths;
|
||||
|
||||
/// Identity of the install.ps1 we'll execute. Used by both the manifest
|
||||
/// fetch and the per-stage runs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedScript {
|
||||
pub path: PathBuf,
|
||||
pub source: ScriptSource,
|
||||
/// Commit pin (40-char SHA) if known. install.ps1's `-Commit` arg is
|
||||
/// what makes the repo stage clone the exact tested SHA.
|
||||
pub commit: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ScriptSource {
|
||||
DevCheckout,
|
||||
Bundled,
|
||||
Cached,
|
||||
Downloaded,
|
||||
}
|
||||
|
||||
/// What flavor of script (Windows .ps1 vs Unix .sh).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ScriptKind {
|
||||
Ps1,
|
||||
Sh,
|
||||
}
|
||||
|
||||
impl ScriptKind {
|
||||
pub fn for_current_os() -> Self {
|
||||
if cfg!(target_os = "windows") {
|
||||
Self::Ps1
|
||||
} else {
|
||||
Self::Sh
|
||||
}
|
||||
}
|
||||
|
||||
fn filename(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ps1 => "install.ps1",
|
||||
Self::Sh => "install.sh",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a string looks like a git SHA (7+ hex chars). Mirrors
|
||||
/// `STAMP_COMMIT_RE` from bootstrap-runner.ts.
|
||||
fn is_valid_commit(s: &str) -> bool {
|
||||
let len = s.len();
|
||||
(7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Resolver cache plan for a pin that already has a local path computed.
|
||||
///
|
||||
/// Immutable commit pins reuse cache forever. Mutable branch/tag pins always
|
||||
/// refresh, and only fall back to a stale cache when the refresh fails.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum CachePlan {
|
||||
/// On-disk hit for an immutable pin — skip the network.
|
||||
Reuse,
|
||||
/// Download (or re-download). `stale_ok` means a failed refresh may return
|
||||
/// the existing cache file (mutable pins with a prior download).
|
||||
Fetch { stale_ok: bool },
|
||||
}
|
||||
|
||||
pub(crate) fn cache_plan(immutable: bool, cached_exists: bool) -> CachePlan {
|
||||
if immutable && cached_exists {
|
||||
CachePlan::Reuse
|
||||
} else {
|
||||
CachePlan::Fetch {
|
||||
stale_ok: !immutable && cached_exists,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the install script to use for this run.
|
||||
///
|
||||
/// `pin` is the commit-or-branch from either Hermes-Setup's build-time
|
||||
/// constant (compiled into the installer) or a runtime override.
|
||||
pub async fn resolve(
|
||||
kind: ScriptKind,
|
||||
pin: &Pin,
|
||||
emit_log: &impl Fn(&str),
|
||||
) -> Result<ResolvedScript> {
|
||||
// 1. Dev shortcut.
|
||||
if let Ok(repo_root) = std::env::var("HERMES_SETUP_DEV_REPO_ROOT") {
|
||||
let candidate = PathBuf::from(repo_root).join("scripts").join(kind.filename());
|
||||
if candidate.exists() {
|
||||
emit_log(&format!(
|
||||
"[bootstrap] dev mode — using local {} at {}",
|
||||
kind.filename(),
|
||||
candidate.display()
|
||||
));
|
||||
return Ok(ResolvedScript {
|
||||
path: candidate,
|
||||
source: ScriptSource::DevCheckout,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. (Not implemented) bundled fallback.
|
||||
|
||||
// 3. Network. Pin must be a real commit or a branch ref.
|
||||
//
|
||||
// Commit SHAs are immutable — permanent cache reuse is safe.
|
||||
// Branch/tag pins are moving refs: always try to refresh so "Retry install"
|
||||
// cannot keep reusing a poisoned install-main.ps1 forever (#67193).
|
||||
let (commit_or_ref, immutable) = match (&pin.commit, &pin.branch) {
|
||||
(Some(c), _) if is_valid_commit(c) => (c.clone(), true),
|
||||
(_, Some(b)) if !b.trim().is_empty() => (b.clone(), false),
|
||||
(Some(other), _) => {
|
||||
return Err(anyhow!(
|
||||
"install script pin commit `{other}` is not a valid git SHA"
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"no install-script pin supplied — installer cannot resolve a script source"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let cached = cached_path(kind, &commit_or_ref);
|
||||
match cache_plan(immutable, cached.exists()) {
|
||||
CachePlan::Reuse => {
|
||||
emit_log(&format!(
|
||||
"[bootstrap] using cached {} for {}",
|
||||
kind.filename(),
|
||||
truncate_ref(&commit_or_ref)
|
||||
));
|
||||
// Immutable pins are cached forever, so a .ps1 cached by a
|
||||
// pre-BOM-fix installer would keep the #67193 encoding bug on
|
||||
// every retry. Upgrade it in place before handing it out.
|
||||
upgrade_cached_script(kind, &cached, emit_log);
|
||||
return Ok(ResolvedScript {
|
||||
path: cached,
|
||||
source: ScriptSource::Cached,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
});
|
||||
}
|
||||
CachePlan::Fetch { stale_ok } => {
|
||||
emit_log(&format!(
|
||||
"[bootstrap] downloading {} for {} {} from GitHub",
|
||||
kind.filename(),
|
||||
if immutable {
|
||||
"commit"
|
||||
} else {
|
||||
"mutable ref"
|
||||
},
|
||||
truncate_ref(&commit_or_ref)
|
||||
));
|
||||
|
||||
match download(kind, &commit_or_ref, &cached).await {
|
||||
Ok(()) => {
|
||||
emit_log(&format!("[bootstrap] cached to {}", cached.display()));
|
||||
Ok(ResolvedScript {
|
||||
path: cached,
|
||||
source: ScriptSource::Downloaded,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
})
|
||||
}
|
||||
Err(err) if stale_ok => {
|
||||
emit_log(&format!(
|
||||
"[bootstrap] WARNING: refresh failed for mutable ref {}; using stale cached {} at {}: {err:#}",
|
||||
truncate_ref(&commit_or_ref),
|
||||
kind.filename(),
|
||||
cached.display()
|
||||
));
|
||||
// Stale cache can predate the BOM fix too — upgrade it.
|
||||
upgrade_cached_script(kind, &cached, emit_log);
|
||||
Ok(ResolvedScript {
|
||||
path: cached,
|
||||
source: ScriptSource::Cached,
|
||||
commit: pin.commit.clone(),
|
||||
branch: pin.branch.clone(),
|
||||
})
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Pin {
|
||||
pub commit: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
}
|
||||
|
||||
fn cached_path(kind: ScriptKind, commit_or_ref: &str) -> PathBuf {
|
||||
let safe = sanitize_ref(commit_or_ref);
|
||||
let filename = match kind {
|
||||
ScriptKind::Ps1 => format!("install-{safe}.ps1"),
|
||||
ScriptKind::Sh => format!("install-{safe}.sh"),
|
||||
};
|
||||
paths::bootstrap_cache_dir().join(filename)
|
||||
}
|
||||
|
||||
/// Replace anything that's not [A-Za-z0-9._-] with `_`. Branch refs can
|
||||
/// contain `/`, dots, etc.; we want a flat filename.
|
||||
fn sanitize_ref(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn truncate_ref(s: &str) -> &str {
|
||||
if is_valid_commit(s) && s.len() >= 12 {
|
||||
&s[..12]
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// UTF-8 BOM. Windows PowerShell 5.1 reads a BOM-less `.ps1` using the system
|
||||
/// ANSI code page; a leading BOM is what tells it the file is UTF-8. The
|
||||
/// `irm | iex` / `[scriptblock]::Create` path strips BOMs on purpose, but the
|
||||
/// GUI bootstrap runs the *cached file* via `-File`, so we write the opposite
|
||||
/// (#67193).
|
||||
const UTF8_BOM: &[u8] = &[0xEF, 0xBB, 0xBF];
|
||||
|
||||
/// Prepare bytes for the on-disk bootstrap cache.
|
||||
///
|
||||
/// `.ps1` files get a UTF-8 BOM (unless one is already present). `.sh` files
|
||||
/// are left unchanged — a BOM would break `#!/bin/bash`.
|
||||
pub(crate) fn prepare_cached_script_bytes(kind: ScriptKind, bytes: &[u8]) -> Vec<u8> {
|
||||
match kind {
|
||||
ScriptKind::Ps1 => {
|
||||
if bytes.starts_with(UTF8_BOM) {
|
||||
bytes.to_vec()
|
||||
} else {
|
||||
let mut out = Vec::with_capacity(UTF8_BOM.len() + bytes.len());
|
||||
out.extend_from_slice(UTF8_BOM);
|
||||
out.extend_from_slice(bytes);
|
||||
out
|
||||
}
|
||||
}
|
||||
ScriptKind::Sh => bytes.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Upgrade a cached script written by a pre-BOM-fix installer in place.
|
||||
///
|
||||
/// `prepare_cached_script_bytes` only runs inside `download()`, but immutable
|
||||
/// commit pins (and the stale-fallback path) reuse the on-disk file without
|
||||
/// re-downloading — so a BOM-less `.ps1` cached before the #67193 fix would
|
||||
/// keep reproducing the ANSI-codepage parse failure on every retry. Rewrites
|
||||
/// through the same atomic tmp+rename shape as `download()`. Best-effort: a
|
||||
/// failed upgrade logs a warning and keeps the original file (which is no
|
||||
/// worse than the pre-existing behavior).
|
||||
fn upgrade_cached_script(kind: ScriptKind, cached: &Path, emit_log: &impl Fn(&str)) {
|
||||
if !matches!(kind, ScriptKind::Ps1) {
|
||||
return;
|
||||
}
|
||||
let bytes = match std::fs::read(cached) {
|
||||
Ok(b) => b,
|
||||
Err(err) => {
|
||||
emit_log(&format!(
|
||||
"[bootstrap] WARNING: could not read cached script {} for BOM check: {err}",
|
||||
cached.display()
|
||||
));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if bytes.starts_with(UTF8_BOM) {
|
||||
return;
|
||||
}
|
||||
let upgraded = prepare_cached_script_bytes(kind, &bytes);
|
||||
let tmp = cached.with_extension("ps1.tmp");
|
||||
let result = std::fs::write(&tmp, &upgraded).and_then(|()| std::fs::rename(&tmp, cached));
|
||||
match result {
|
||||
Ok(()) => emit_log(&format!(
|
||||
"[bootstrap] upgraded cached {} with UTF-8 BOM (#67193)",
|
||||
cached.display()
|
||||
)),
|
||||
Err(err) => {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
emit_log(&format!(
|
||||
"[bootstrap] WARNING: could not upgrade cached {} with UTF-8 BOM: {err}",
|
||||
cached.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Downloads to `dest_path` via reqwest with rustls. Atomically renames
|
||||
/// `dest_path.tmp` → `dest_path` so partial writes don't poison the cache.
|
||||
///
|
||||
/// The client carries explicit timeouts: mutable branch pins call this on
|
||||
/// EVERY run (#67193 cache-refresh fix), and the stale-cache fallback in
|
||||
/// `resolve()` only fires when this returns `Err`. Without a timeout, a
|
||||
/// black-holed connection (captive portal, hung proxy, silently dropped
|
||||
/// packets) never errors — the whole bootstrap would hang here instead of
|
||||
/// falling back to the cached script.
|
||||
async fn download(kind: ScriptKind, commit_or_ref: &str, dest_path: &Path) -> Result<()> {
|
||||
let url = format!(
|
||||
"https://raw.githubusercontent.com/NousResearch/hermes-agent/{}/scripts/{}",
|
||||
commit_or_ref,
|
||||
kind.filename()
|
||||
);
|
||||
|
||||
if let Some(parent) = dest_path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| {
|
||||
format!("creating bootstrap-cache parent dir {}", parent.display())
|
||||
})?;
|
||||
}
|
||||
|
||||
let tmp_path = dest_path.with_extension({
|
||||
let ext = dest_path
|
||||
.extension()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("tmp");
|
||||
format!("{ext}.tmp")
|
||||
});
|
||||
|
||||
let response = reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(10))
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.context("building download client")?
|
||||
.get(&url)
|
||||
.header("User-Agent", "hermes-setup/0.0.1")
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("GET {url}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!(
|
||||
"Failed to download {}: HTTP {} from {}",
|
||||
kind.filename(),
|
||||
response.status(),
|
||||
url
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.with_context(|| format!("reading body of {url}"))?;
|
||||
let bytes = prepare_cached_script_bytes(kind, &bytes);
|
||||
|
||||
let mut file = tokio::fs::File::create(&tmp_path)
|
||||
.await
|
||||
.with_context(|| format!("creating temp file {}", tmp_path.display()))?;
|
||||
file.write_all(&bytes)
|
||||
.await
|
||||
.with_context(|| format!("writing temp file {}", tmp_path.display()))?;
|
||||
file.flush().await.context("flushing temp file")?;
|
||||
drop(file);
|
||||
|
||||
tokio::fs::rename(&tmp_path, dest_path)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"renaming {} → {}",
|
||||
tmp_path.display(),
|
||||
dest_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_valid_commit_accepts_short_and_full_shas() {
|
||||
assert!(is_valid_commit("02d26981d3d4ad50e142399b8476f59ad5953ff0"));
|
||||
assert!(is_valid_commit("02d2698"));
|
||||
assert!(!is_valid_commit("02d269"));
|
||||
assert!(!is_valid_commit("not-a-sha"));
|
||||
assert!(!is_valid_commit(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_ref_replaces_slashes() {
|
||||
assert_eq!(sanitize_ref("bb/gui"), "bb_gui");
|
||||
assert_eq!(sanitize_ref("main"), "main");
|
||||
assert_eq!(sanitize_ref("release/1.2.3"), "release_1.2.3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_cached_ps1_prefixes_utf8_bom() {
|
||||
let out = prepare_cached_script_bytes(ScriptKind::Ps1, b"Write-Host hi\n");
|
||||
assert!(out.starts_with(UTF8_BOM), "cached .ps1 must start with UTF-8 BOM");
|
||||
assert_eq!(&out[UTF8_BOM.len()..], b"Write-Host hi\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_cached_ps1_does_not_double_bom() {
|
||||
let mut already = UTF8_BOM.to_vec();
|
||||
already.extend_from_slice(b"x");
|
||||
let out = prepare_cached_script_bytes(ScriptKind::Ps1, &already);
|
||||
assert_eq!(out, already);
|
||||
assert_eq!(out.windows(3).filter(|w| *w == UTF8_BOM).count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_cached_sh_stays_bomless() {
|
||||
let out = prepare_cached_script_bytes(ScriptKind::Sh, b"#!/bin/bash\n");
|
||||
assert!(!out.starts_with(UTF8_BOM));
|
||||
assert_eq!(out, b"#!/bin/bash\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_pins_are_immutable_branch_pins_are_not() {
|
||||
// Mirrors the resolve() immutable decision: SHA pins may reuse cache
|
||||
// forever; branch pins must refresh so Retry cannot keep a bad script.
|
||||
assert!(is_valid_commit("02d26981d3d4ad50e142399b8476f59ad5953ff0"));
|
||||
assert!(!is_valid_commit("main"));
|
||||
assert!(!is_valid_commit("release/1.2.3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_branch_cache_plans_refresh_with_stale_fallback() {
|
||||
// Resolver-level: a prior install-main.ps1 must not short-circuit
|
||||
// Retry — mutable pins refresh, and only fall back if download fails.
|
||||
assert_eq!(
|
||||
cache_plan(/*immutable=*/ false, /*cached_exists=*/ true),
|
||||
CachePlan::Fetch { stale_ok: true }
|
||||
);
|
||||
assert_eq!(
|
||||
cache_plan(/*immutable=*/ true, /*cached_exists=*/ true),
|
||||
CachePlan::Reuse
|
||||
);
|
||||
assert_eq!(
|
||||
cache_plan(/*immutable=*/ false, /*cached_exists=*/ false),
|
||||
CachePlan::Fetch { stale_ok: false }
|
||||
);
|
||||
assert_eq!(
|
||||
cache_plan(/*immutable=*/ true, /*cached_exists=*/ false),
|
||||
CachePlan::Fetch { stale_ok: false }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_cached_script_adds_bom_to_legacy_ps1() {
|
||||
// A .ps1 cached by a pre-#67193 installer has no BOM; the Reuse path
|
||||
// must upgrade it in place instead of serving the broken bytes forever.
|
||||
let dir = std::env::temp_dir().join(format!("hermes-bom-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let cached = dir.join("install-abc1234.ps1");
|
||||
std::fs::write(&cached, b"Write-Host legacy\n").unwrap();
|
||||
|
||||
upgrade_cached_script(ScriptKind::Ps1, &cached, &|_| {});
|
||||
let bytes = std::fs::read(&cached).unwrap();
|
||||
assert!(bytes.starts_with(UTF8_BOM), "legacy cache must gain a BOM");
|
||||
assert_eq!(&bytes[UTF8_BOM.len()..], b"Write-Host legacy\n");
|
||||
|
||||
// Idempotent: a second pass must not double the BOM.
|
||||
upgrade_cached_script(ScriptKind::Ps1, &cached, &|_| {});
|
||||
let again = std::fs::read(&cached).unwrap();
|
||||
assert_eq!(again, bytes);
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upgrade_cached_script_leaves_sh_untouched() {
|
||||
let dir = std::env::temp_dir().join(format!("hermes-bom-sh-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let cached = dir.join("install-main.sh");
|
||||
std::fs::write(&cached, b"#!/bin/bash\n").unwrap();
|
||||
|
||||
upgrade_cached_script(ScriptKind::Sh, &cached, &|_| {});
|
||||
assert_eq!(std::fs::read(&cached).unwrap(), b"#!/bin/bash\n");
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
//! Hermes Setup — Tauri entrypoint.
|
||||
//!
|
||||
//! Spawns a single window pointed at the React frontend (apps/bootstrap-installer/src/).
|
||||
//! All install-time work lives in `bootstrap.rs` and is invoked through the Tauri
|
||||
//! commands registered at the bottom of `run()`.
|
||||
//!
|
||||
//! The Windows-subsystem strip lives on the binary crate (src/main.rs), not
|
||||
//! here — a crate-level attribute on a lib doesn't propagate to the linker
|
||||
//! flags of the executable that consumes it.
|
||||
|
||||
mod bootstrap;
|
||||
mod events;
|
||||
mod install_script;
|
||||
mod powershell;
|
||||
mod paths;
|
||||
mod update;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// How the installer was invoked. Resolved once from the process args in
|
||||
/// `run()` and exposed to the frontend via `get_mode` so it can route to the
|
||||
/// install flow (first-run onboarding) or the update flow (driven by the
|
||||
/// desktop app handing off via `Hermes-Setup.exe --update`).
|
||||
///
|
||||
/// Bare launch (double-click, first-run) => Install.
|
||||
/// `--update` (spawned by the desktop's "Update" button) => Update.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AppMode {
|
||||
Install,
|
||||
Update,
|
||||
}
|
||||
|
||||
impl AppMode {
|
||||
/// Resolve the mode from an argument iterator. Anything containing the
|
||||
/// `--update` flag selects Update; otherwise Install. Kept arg-iterator
|
||||
/// generic (not reading `std::env` directly) so it's unit-testable.
|
||||
pub fn from_args<I, S>(args: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
for a in args {
|
||||
if a.as_ref() == "--update" {
|
||||
return AppMode::Update;
|
||||
}
|
||||
}
|
||||
AppMode::Install
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when the args request a forced installer UI (repair/reinstall)
|
||||
/// via `--reinstall` or `--repair`, which overrides the macOS launcher
|
||||
/// fast-path so a broken install can be repaired. Arg-iterator generic so it's
|
||||
/// unit-testable, mirroring `AppMode::from_args`. Independent of mode selection:
|
||||
/// these flags never flip Install<->Update.
|
||||
pub fn force_setup_from_args<I, S>(args: I) -> bool
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
args.into_iter()
|
||||
.any(|a| a.as_ref() == "--reinstall" || a.as_ref() == "--repair")
|
||||
}
|
||||
|
||||
/// Process-wide install state, shared across Tauri commands.
|
||||
///
|
||||
/// The bootstrap is a one-shot, single-tenant process — we only need one
|
||||
/// of these per window. `Arc<Mutex<...>>` lets command handlers grab it
|
||||
/// without lifetime gymnastics.
|
||||
pub struct AppState {
|
||||
pub bootstrap: Mutex<Option<bootstrap::BootstrapHandle>>,
|
||||
/// How this process was launched (install vs update). Immutable for the
|
||||
/// lifetime of the process; read by the `get_mode` command.
|
||||
pub mode: AppMode,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new(mode: AppMode) -> Self {
|
||||
Self {
|
||||
bootstrap: Mutex::new(None),
|
||||
mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Frontend → Rust: which flow should the UI render?
|
||||
#[tauri::command]
|
||||
fn get_mode(state: tauri::State<'_, Arc<AppState>>) -> AppMode {
|
||||
state.mode
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// Tracing → bootstrap-installer.log under HERMES_HOME/logs/ so install
|
||||
// failures leave a trail for support. Console output also goes here in
|
||||
// debug builds.
|
||||
let _guard = paths::init_logging();
|
||||
|
||||
let mode = AppMode::from_args(std::env::args().skip(1));
|
||||
// Escape hatch: `--reinstall`/`--repair` forces the installer UI even when
|
||||
// Hermes is already installed, so users can re-run setup to repair a broken
|
||||
// install instead of the launcher fast path silently relaunching the app.
|
||||
let force_setup = force_setup_from_args(std::env::args().skip(1));
|
||||
tracing::info!(?mode, force_setup, "Hermes installer starting");
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_process::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(Arc::new(AppState::new(mode)))
|
||||
.setup(move |app| {
|
||||
use tauri::Manager;
|
||||
// Launcher fast path (macOS only): a bare ("Install") launch when
|
||||
// Hermes is already installed should NOT show the installer or
|
||||
// rebuild — it should just open the app, so the /Applications
|
||||
// "Hermes" doubles as a normal launcher (first run installs, every
|
||||
// later run launches instantly). The window is kept hidden until
|
||||
// here via `"visible": false` so this path never flashes a window.
|
||||
//
|
||||
// Gated to macOS deliberately: on Windows/Linux the installer keeps
|
||||
// its existing behavior (Windows users relaunch via the Start
|
||||
// Menu/Desktop "Hermes" shortcuts that install.ps1 creates, and a
|
||||
// reliable detached relaunch there needs the DETACHED_PROCESS +
|
||||
// startup-grace handling used by launch_hermes_desktop — out of
|
||||
// scope here). So this is a pure no-op on non-macOS.
|
||||
//
|
||||
// `--reinstall`/`--repair` opts out so a broken install can be
|
||||
// repaired by re-running setup instead of launching the bad app.
|
||||
if cfg!(target_os = "macos") && mode == AppMode::Install && !force_setup {
|
||||
let install_root = paths::hermes_home().join("hermes-agent");
|
||||
if bootstrap::hermes_is_installed(&install_root) {
|
||||
match bootstrap::spawn_installed_desktop(&install_root) {
|
||||
Ok(()) => {
|
||||
// Brief grace so the spawned app is registered
|
||||
// before we exit (mirrors launch_hermes_desktop).
|
||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
||||
tracing::info!(
|
||||
"hermes already installed — relaunched desktop; exiting installer"
|
||||
);
|
||||
app.handle().exit(0);
|
||||
return Ok(());
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
?err,
|
||||
"relaunch of installed desktop failed; showing installer UI"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// First run / repair install, or Update mode: reveal the UI.
|
||||
match app.get_webview_window("main") {
|
||||
Some(win) => {
|
||||
if let Err(err) = win.show() {
|
||||
tracing::error!(?err, "failed to show main installer window");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::error!("main installer window not found; installer UI will not appear");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Mode (install vs update)
|
||||
get_mode,
|
||||
// Bootstrap lifecycle
|
||||
bootstrap::start_bootstrap,
|
||||
bootstrap::cancel_bootstrap,
|
||||
bootstrap::get_bootstrap_status,
|
||||
// Update lifecycle
|
||||
update::start_update,
|
||||
// Hand-off
|
||||
bootstrap::launch_hermes_desktop,
|
||||
// Diagnostics
|
||||
paths::get_log_path,
|
||||
paths::get_hermes_home,
|
||||
paths::open_log_dir,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Hermes Setup");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{force_setup_from_args, AppMode};
|
||||
|
||||
#[test]
|
||||
fn bare_args_are_install() {
|
||||
assert_eq!(AppMode::from_args(Vec::<String>::new()), AppMode::Install);
|
||||
assert_eq!(AppMode::from_args(["--foo", "bar"]), AppMode::Install);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_flag_selects_update() {
|
||||
assert_eq!(AppMode::from_args(["--update"]), AppMode::Update);
|
||||
assert_eq!(
|
||||
AppMode::from_args(["--something", "--update", "--else"]),
|
||||
AppMode::Update
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reinstall_and_repair_flags_force_setup() {
|
||||
assert!(force_setup_from_args(["--reinstall"]));
|
||||
assert!(force_setup_from_args(["--repair"]));
|
||||
assert!(force_setup_from_args(["--foo", "--repair", "--bar"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_or_unrelated_args_do_not_force_setup() {
|
||||
assert!(!force_setup_from_args(Vec::<String>::new()));
|
||||
assert!(!force_setup_from_args(["--foo", "bar"]));
|
||||
// --update must not be mistaken for a force-setup flag.
|
||||
assert!(!force_setup_from_args(["--update"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_setup_flags_do_not_affect_mode_selection() {
|
||||
// The repair flags must never flip Install<->Update.
|
||||
assert_eq!(AppMode::from_args(["--reinstall"]), AppMode::Install);
|
||||
assert_eq!(AppMode::from_args(["--repair"]), AppMode::Install);
|
||||
assert_eq!(
|
||||
AppMode::from_args(["--update", "--reinstall"]),
|
||||
AppMode::Update
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Hermes Setup — process entrypoint. All logic lives in lib.rs so it can
|
||||
// be unit-tested as a library; this file just calls into it.
|
||||
//
|
||||
// The windows_subsystem attribute MUST live here on the binary crate
|
||||
// (not lib.rs) — placing it on the lib was the bug that left a stray
|
||||
// cmd window behind Hermes-Setup.exe on release builds.
|
||||
//
|
||||
// `windows_subsystem = "windows"` strips the console allocation that
|
||||
// the default `windows_subsystem = "console"` would do, so double-clicking
|
||||
// the .exe gives you ONLY the Tauri window.
|
||||
//
|
||||
// debug_assertions guard: dev builds keep the console so tracing output
|
||||
// is visible during `cargo tauri dev`.
|
||||
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
hermes_bootstrap_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Filesystem paths + logging setup.
|
||||
//!
|
||||
//! Mirrors `hermes_constants.get_hermes_home()` from the Python CLI:
|
||||
//! Windows: %LOCALAPPDATA%\hermes
|
||||
//! macOS: ~/.hermes
|
||||
//! Linux: ~/.hermes (override via $HERMES_HOME)
|
||||
//!
|
||||
//! NOTE (macOS): Python's get_hermes_home(), scripts/install.sh, and the
|
||||
//! Electron desktop's resolveHermesHome() ALL use ~/.hermes on macOS — there
|
||||
//! is no ~/Library/Application Support branch anywhere else. An earlier
|
||||
//! version of this file used Application Support, which drifted from every
|
||||
//! other component: the installer wrote the install to one dir and the
|
||||
//! desktop looked for it in another, so first launch never found the backend.
|
||||
//!
|
||||
//! IMPORTANT: this must match exactly. Drift here means install.ps1
|
||||
//! writes to one place and the installer reads from another, breaking
|
||||
//! the bootstrap-complete check.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::process::Command;
|
||||
use tracing_appender::non_blocking::WorkerGuard;
|
||||
|
||||
/// Returns the canonical Hermes home directory, respecting $HERMES_HOME if set.
|
||||
pub fn hermes_home() -> PathBuf {
|
||||
if let Ok(override_path) = std::env::var("HERMES_HOME") {
|
||||
if !override_path.trim().is_empty() {
|
||||
return PathBuf::from(override_path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// %LOCALAPPDATA%\hermes — matches scripts/install.ps1's $HermesHome.
|
||||
if let Some(local_app_data) = dirs::data_local_dir() {
|
||||
return local_app_data.join("hermes");
|
||||
}
|
||||
}
|
||||
|
||||
// macOS + Linux + fallback: ~/.hermes (matches Python get_hermes_home(),
|
||||
// install.sh, and the Electron desktop's resolveHermesHome()).
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(".hermes");
|
||||
}
|
||||
|
||||
// Last resort — current dir, almost certainly wrong but at least
|
||||
// doesn't panic.
|
||||
PathBuf::from(".hermes")
|
||||
}
|
||||
|
||||
pub fn log_dir() -> PathBuf {
|
||||
hermes_home().join("logs")
|
||||
}
|
||||
|
||||
pub fn log_path() -> PathBuf {
|
||||
log_dir().join("bootstrap-installer.log")
|
||||
}
|
||||
|
||||
pub fn bootstrap_cache_dir() -> PathBuf {
|
||||
hermes_home().join("bootstrap-cache")
|
||||
}
|
||||
|
||||
/// Stable location the installer copies itself to after a successful install.
|
||||
/// The desktop app re-invokes this with `--update`, and the start-menu /
|
||||
/// desktop shortcuts can point users back to it. Lives directly under
|
||||
/// HERMES_HOME so it survives repo checkout deletion (unlike anything under
|
||||
/// hermes-agent/).
|
||||
///
|
||||
/// On Windows this is `%LOCALAPPDATA%\hermes\hermes-setup.exe`; on other
|
||||
/// platforms the extension differs but the directory is the same.
|
||||
pub fn installer_dest() -> PathBuf {
|
||||
let name = if cfg!(target_os = "windows") {
|
||||
"hermes-setup.exe"
|
||||
} else {
|
||||
"hermes-setup"
|
||||
};
|
||||
hermes_home().join(name)
|
||||
}
|
||||
|
||||
/// Marker the updater writes for the duration of an in-app update and removes
|
||||
/// when it finishes (see update.rs `UpdateMarkerGuard`). A freshly-launched
|
||||
/// desktop checks this before spawning its own local backend: spawning one
|
||||
/// mid-update re-locks the venv shim and triggers `force_kill_other_hermes`,
|
||||
/// which then kills that legitimate backend in a respawn loop (#50238).
|
||||
///
|
||||
/// Lives directly under HERMES_HOME (same rationale as `installer_dest`) so the
|
||||
/// Electron desktop — which resolves HERMES_HOME identically and pins it into
|
||||
/// the updater's env — agrees on the exact path.
|
||||
pub fn update_in_progress_marker() -> PathBuf {
|
||||
hermes_home().join(".hermes-update-in-progress")
|
||||
}
|
||||
|
||||
/// Copy the currently-running installer binary to `installer_dest()` so it's
|
||||
/// available for future `--update` runs and shortcut launches.
|
||||
///
|
||||
/// No-ops (returns Ok) when the running exe is ALREADY the destination — which
|
||||
/// is exactly the case during an `--update` run (the desktop launched us FROM
|
||||
/// that path), where copying onto ourselves would be a Windows sharing
|
||||
/// violation. Best-effort: a failure here must not fail the install, so the
|
||||
/// caller logs and continues.
|
||||
///
|
||||
/// NOTE: because of that no-op, a user's staged installer is only ever written
|
||||
/// by a full install/repair. Every later `--update` runs the ORIGINAL binary,
|
||||
/// so an installer-protocol change can strand the whole installed base on a
|
||||
/// binary that predates it (see `restage_from_checkout`, which repairs this
|
||||
/// from the freshly-updated checkout).
|
||||
pub fn copy_self_to_hermes_home() -> std::io::Result<()> {
|
||||
let src = std::env::current_exe()?;
|
||||
let dest = installer_dest();
|
||||
|
||||
// Skip if we're already running from the destination (update re-invocation
|
||||
// or a prior copy). canonicalize both so symlinks / 8.3 short paths / case
|
||||
// differences don't trick us into a self-copy.
|
||||
let same = match (src.canonicalize(), dest.canonicalize()) {
|
||||
(Ok(a), Ok(b)) => a == b,
|
||||
_ => src == dest,
|
||||
};
|
||||
if same {
|
||||
tracing::info!(?dest, "installer already at destination; skipping self-copy");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(parent) = dest.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::copy(&src, &dest)?;
|
||||
repair_macos_installer_helper(&dest);
|
||||
tracing::info!(?src, ?dest, "copied installer to HERMES_HOME");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn repair_macos_installer_helper(path: &Path) {
|
||||
// The staged helper may inherit quarantine from the downloaded installer.
|
||||
// Desktop later launches this exact file for in-app updates, so make it
|
||||
// executable before the update handoff reaches LaunchServices/Gatekeeper.
|
||||
let _ = Command::new("/usr/bin/xattr")
|
||||
.args(["-cr"])
|
||||
.arg(path)
|
||||
.status();
|
||||
|
||||
let verify = Command::new("/usr/bin/codesign")
|
||||
.arg("--verify")
|
||||
.arg(path)
|
||||
.status();
|
||||
|
||||
if !matches!(verify, Ok(status) if status.success()) {
|
||||
let _ = Command::new("/usr/bin/codesign")
|
||||
.args(["--force", "--sign", "-"])
|
||||
.arg(path)
|
||||
.status();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn repair_macos_installer_helper(_path: &Path) {}
|
||||
|
||||
/// Where the bootstrap-complete marker lives (existence-only for the Rust
|
||||
/// installer fast path; JSON schema-checked by the Electron app). Per main.ts:
|
||||
/// const BOOTSTRAP_COMPLETE_MARKER = path.join(ACTIVE_HERMES_ROOT, '.hermes-bootstrap-complete')
|
||||
/// We don't always know ACTIVE_HERMES_ROOT until install.ps1 reports it, so
|
||||
/// this is a probe helper, not a definitive path.
|
||||
pub fn likely_bootstrap_marker(install_root: &Path) -> PathBuf {
|
||||
install_root.join(".hermes-bootstrap-complete")
|
||||
}
|
||||
|
||||
/// Initializes tracing to bootstrap-installer.log under HERMES_HOME/logs/.
|
||||
/// Returns a guard that flushes the appender on drop — keep it alive for
|
||||
/// the lifetime of the process.
|
||||
pub fn init_logging() -> Option<WorkerGuard> {
|
||||
let dir = log_dir();
|
||||
if let Err(err) = std::fs::create_dir_all(&dir) {
|
||||
// No log dir → log to stderr only. Don't panic; the installer
|
||||
// should still be usable on an exotic filesystem.
|
||||
eprintln!("[hermes-setup] could not create log dir {dir:?}: {err}");
|
||||
return None;
|
||||
}
|
||||
|
||||
let file_appender = tracing_appender::rolling::never(&dir, "bootstrap-installer.log");
|
||||
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
|
||||
|
||||
let env_filter = tracing_subscriber::EnvFilter::try_from_env("HERMES_BOOTSTRAP_LOG")
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(env_filter)
|
||||
.with_writer(non_blocking)
|
||||
.with_ansi(false)
|
||||
.with_target(true)
|
||||
.init();
|
||||
|
||||
Some(guard)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_log_path() -> String {
|
||||
log_path().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_hermes_home() -> String {
|
||||
hermes_home().to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_log_dir(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
let path = log_dir();
|
||||
app.opener()
|
||||
.open_path(path.to_string_lossy(), None::<&str>)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,842 @@
|
||||
//! Drives PowerShell (Windows) or bash (Unix) for install.ps1 / install.sh.
|
||||
//!
|
||||
//! Port of `spawnPowerShell` from bootstrap-runner.ts, with the same
|
||||
//! line-buffered stdout/stderr streaming + cancellation semantics.
|
||||
//!
|
||||
//! On Windows we pass `-NoProfile -ExecutionPolicy Bypass -File <script>`.
|
||||
//! On Unix we shell out to `bash <script>` since install.sh expects bash.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::Path;
|
||||
use std::process::{ExitStatus, Stdio};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// CP1252 mapping for bytes `0x80..=0x9F` (the range that differs from Latin-1).
|
||||
/// Undefined slots keep the C1 control code points, matching Windows-1252
|
||||
/// best-fit behavior used by `encoding_rs::WINDOWS_1252`.
|
||||
const CP1252_80_9F: [char; 32] = [
|
||||
'\u{20AC}', // 0x80 €
|
||||
'\u{0081}', // 0x81
|
||||
'\u{201A}', // 0x82 ‚
|
||||
'\u{0192}', // 0x83 ƒ
|
||||
'\u{201E}', // 0x84 „
|
||||
'\u{2026}', // 0x85 …
|
||||
'\u{2020}', // 0x86 †
|
||||
'\u{2021}', // 0x87 ‡
|
||||
'\u{02C6}', // 0x88 ˆ
|
||||
'\u{2030}', // 0x89 ‰
|
||||
'\u{0160}', // 0x8A Š
|
||||
'\u{2039}', // 0x8B ‹
|
||||
'\u{0152}', // 0x8C Œ
|
||||
'\u{008D}', // 0x8D
|
||||
'\u{017D}', // 0x8E Ž
|
||||
'\u{008F}', // 0x8F
|
||||
'\u{0090}', // 0x90
|
||||
'\u{2018}', // 0x91 ‘
|
||||
'\u{2019}', // 0x92 ’
|
||||
'\u{201C}', // 0x93 “
|
||||
'\u{201D}', // 0x94 ”
|
||||
'\u{2022}', // 0x95 •
|
||||
'\u{2013}', // 0x96 –
|
||||
'\u{2014}', // 0x97 —
|
||||
'\u{02DC}', // 0x98 ˜
|
||||
'\u{2122}', // 0x99 ™
|
||||
'\u{0161}', // 0x9A š
|
||||
'\u{203A}', // 0x9B ›
|
||||
'\u{0153}', // 0x9C œ
|
||||
'\u{009D}', // 0x9D
|
||||
'\u{017E}', // 0x9E ž
|
||||
'\u{0178}', // 0x9F Ÿ
|
||||
];
|
||||
|
||||
fn decode_cp1252_byte(b: u8) -> char {
|
||||
match b {
|
||||
0x00..=0x7F => b as char,
|
||||
0x80..=0x9F => CP1252_80_9F[(b - 0x80) as usize],
|
||||
// 0xA0..=0xFF match Unicode Latin-1 / Windows-1252.
|
||||
_ => b as char,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode one stdout/stderr line from a child process.
|
||||
///
|
||||
/// Tokio's `BufReader::lines()` requires valid UTF-8 and aborts the line (with
|
||||
/// `stream did not contain valid UTF-8`) at the first accented byte. Windows
|
||||
/// PowerShell 5.1 emits localized ParserError text in the console ANSI code
|
||||
/// page (often CP1252), so Portuguese/Spanish/etc. users only saw a truncated
|
||||
/// `No` instead of `Não foi fornecido o terminador...` (#67193).
|
||||
///
|
||||
/// Prefer UTF-8 when the bytes are valid; otherwise decode as Windows-1252 so
|
||||
/// both Western-European letters and CP1252-only punctuation (e.g. `0x91` →
|
||||
/// U+2018) survive rather than disappearing into a read-error warning.
|
||||
pub(crate) fn decode_console_bytes(bytes: &[u8]) -> String {
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(_) => bytes.iter().copied().map(decode_cp1252_byte).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one line (LF or CRLF) and decode it with [`decode_console_bytes`].
|
||||
/// Returns `Ok(None)` on EOF with no bytes pending.
|
||||
pub(crate) async fn read_decoded_line<R>(
|
||||
reader: &mut R,
|
||||
buf: &mut Vec<u8>,
|
||||
) -> std::io::Result<Option<String>>
|
||||
where
|
||||
R: AsyncBufReadExt + Unpin,
|
||||
{
|
||||
// Cancel-safety: `buf` is NOT cleared on entry. When this future is
|
||||
// dropped mid-read inside `tokio::select!` (the other stream produced a
|
||||
// line first), `read_until` has already appended any consumed bytes to
|
||||
// `buf`; the next call resumes and appends the rest of the line. Clearing
|
||||
// on entry would silently drop those bytes. We clear only after a full
|
||||
// line has been decoded.
|
||||
let n = reader.read_until(b'\n', buf).await?;
|
||||
if n == 0 && buf.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
// n == 0 with a non-empty buf means EOF cut off an unterminated line
|
||||
// (possibly accumulated across cancelled reads) -- emit it.
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
}
|
||||
let line = decode_console_bytes(buf);
|
||||
buf.clear();
|
||||
Ok(Some(line))
|
||||
}
|
||||
|
||||
/// Hooks the caller installs to receive output.
|
||||
pub struct StreamSink {
|
||||
pub on_stdout_line: Box<dyn Fn(&str) + Send + Sync>,
|
||||
pub on_stderr_line: Box<dyn Fn(&str) + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Outcome of a script invocation. Mirrors bootstrap-runner.ts's
|
||||
/// `{stdout, stderr, code, signal, killed}` shape.
|
||||
#[derive(Debug)]
|
||||
pub struct ScriptResult {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit_code: Option<i32>,
|
||||
pub killed: bool,
|
||||
}
|
||||
|
||||
/// Cancellation signal — `cancel_tx.send(()).await` aborts the running script.
|
||||
pub type CancelRx = mpsc::Receiver<()>;
|
||||
|
||||
/// How long a child's pipes get to reach EOF AFTER the child itself has exited.
|
||||
///
|
||||
/// This is not a timeout on the child. The clock starts once the process is
|
||||
/// already gone and everything it wrote is sitting in the pipe buffer, so a
|
||||
/// 40-minute `uv pip install` is untouched — the grace only covers the final
|
||||
/// drain.
|
||||
///
|
||||
/// It exists because pipe EOF is not the child's to give. The write end of a
|
||||
/// redirected pipe is handed to the child as an inheritable handle, so every
|
||||
/// descendant spawned without its own redirection holds a duplicate, and the
|
||||
/// read side does not see EOF until the last of them closes it. `hermes update`
|
||||
/// deliberately runs its build steps with stdout inherited, so the tree under a
|
||||
/// child is arbitrarily deep and not something the caller can enumerate. When
|
||||
/// one of those descendants is a resident gateway, the pipe stays open for the
|
||||
/// life of the gateway — and every obligation downstream of the read is
|
||||
/// stranded with it.
|
||||
///
|
||||
/// Same bound `Invoke-HermesStep` grew in `scripts/desktop-update/windows.ps1`
|
||||
/// (#90455), and the same shape as Go's `exec.Cmd.WaitDelay`.
|
||||
pub(crate) const DRAIN_GRACE: Duration = Duration::from_secs(20);
|
||||
|
||||
/// What [`pump_child`] observed.
|
||||
pub(crate) struct PumpOutcome {
|
||||
pub exit_code: Option<i32>,
|
||||
/// The child was killed because the caller cancelled.
|
||||
pub killed: bool,
|
||||
/// The child exited but its pipes never reached EOF within [`DRAIN_GRACE`],
|
||||
/// so the tail of its output was dropped. Callers must say so out loud: a
|
||||
/// silently truncated log is indistinguishable from one that was empty.
|
||||
pub abandoned: bool,
|
||||
}
|
||||
|
||||
/// Stream a child's stdout/stderr line by line, then reap it.
|
||||
///
|
||||
/// The contract that matters: the exit status comes from waiting on the
|
||||
/// *process*, never from pipe EOF. See [`DRAIN_GRACE`] for why those are not
|
||||
/// the same event; callers pass it, tests pass something they can wait out.
|
||||
pub(crate) async fn pump_child<FO, FE>(
|
||||
child: &mut Child,
|
||||
mut on_stdout: FO,
|
||||
mut on_stderr: FE,
|
||||
cancel_rx: &mut Option<CancelRx>,
|
||||
grace: Duration,
|
||||
) -> Result<PumpOutcome>
|
||||
where
|
||||
FO: FnMut(&str),
|
||||
FE: FnMut(&str),
|
||||
{
|
||||
let stdout = child.stdout.take().context("stdout was piped")?;
|
||||
let stderr = child.stderr.take().context("stderr was piped")?;
|
||||
let mut out = BufReader::new(stdout);
|
||||
let mut err = BufReader::new(stderr);
|
||||
let mut out_buf = Vec::new();
|
||||
let mut err_buf = Vec::new();
|
||||
let mut out_done = false;
|
||||
let mut err_done = false;
|
||||
let mut status: Option<ExitStatus> = None;
|
||||
let mut cancelled = false;
|
||||
|
||||
// Phase 1 — the child is alive, so there is no deadline. A slow child is
|
||||
// not a stuck one, and the point of streaming is that a long build keeps
|
||||
// reporting. We leave on whichever lands first: both pipes at EOF (the
|
||||
// clean case), the process exiting (the case that used to hang here), or
|
||||
// cancellation.
|
||||
while !(out_done && err_done) {
|
||||
tokio::select! {
|
||||
line = read_decoded_line(&mut out, &mut out_buf), if !out_done => match line {
|
||||
Ok(Some(l)) => on_stdout(&l),
|
||||
Ok(None) => out_done = true,
|
||||
Err(e) => {
|
||||
tracing::warn!("stdout read error: {e}");
|
||||
out_done = true;
|
||||
}
|
||||
},
|
||||
line = read_decoded_line(&mut err, &mut err_buf), if !err_done => match line {
|
||||
Ok(Some(l)) => on_stderr(&l),
|
||||
Ok(None) => err_done = true,
|
||||
Err(e) => {
|
||||
tracing::warn!("stderr read error: {e}");
|
||||
err_done = true;
|
||||
}
|
||||
},
|
||||
reaped = child.wait() => {
|
||||
status = Some(reaped.context("waiting for child to exit")?);
|
||||
break;
|
||||
}
|
||||
_ = recv_cancel(cancel_rx) => {
|
||||
cancelled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kill outside the loop: `child.wait()` above holds the mutable borrow for
|
||||
// as long as the select is in scope.
|
||||
if cancelled {
|
||||
tracing::warn!("cancellation received — killing child");
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
|
||||
// Phase 2 — bounded. Whatever the child already wrote is still worth
|
||||
// keeping, so we keep reading; we just stop caring once a descendant is the
|
||||
// only thing still holding the pipe open. Cancelling does not rescue us
|
||||
// either: `start_kill` kills the child, not the grandchild with the handle.
|
||||
let mut abandoned = false;
|
||||
if !(out_done && err_done) {
|
||||
let drain = async {
|
||||
while !(out_done && err_done) {
|
||||
tokio::select! {
|
||||
line = read_decoded_line(&mut out, &mut out_buf), if !out_done => match line {
|
||||
Ok(Some(l)) => on_stdout(&l),
|
||||
_ => out_done = true,
|
||||
},
|
||||
line = read_decoded_line(&mut err, &mut err_buf), if !err_done => match line {
|
||||
Ok(Some(l)) => on_stderr(&l),
|
||||
_ => err_done = true,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
abandoned = timeout(grace, drain).await.is_err();
|
||||
}
|
||||
|
||||
let status = match status {
|
||||
Some(s) => s,
|
||||
// Both pipes reached EOF while the child stayed alive. Reads are done,
|
||||
// but the process is still the authoritative terminal condition — and
|
||||
// it may never exit on its own, so this wait stays cancellable. A bare
|
||||
// `child.wait()` here would strand the caller's cancel channel exactly
|
||||
// when it is the only way out.
|
||||
None => tokio::select! {
|
||||
reaped = child.wait() => reaped.context("waiting for child to exit")?,
|
||||
_ = recv_cancel(cancel_rx) => {
|
||||
tracing::warn!("cancellation received after EOF — killing child");
|
||||
cancelled = true;
|
||||
let _ = child.start_kill();
|
||||
child.wait().await.context("waiting for killed child to exit")?
|
||||
}
|
||||
},
|
||||
};
|
||||
Ok(PumpOutcome {
|
||||
exit_code: status.code(),
|
||||
killed: cancelled,
|
||||
abandoned,
|
||||
})
|
||||
}
|
||||
|
||||
/// Spawns install.ps1 / install.sh with the given args and streams output.
|
||||
///
|
||||
/// `hermes_home_override` propagates to the child as $HERMES_HOME so the
|
||||
/// install script writes to the same directory the installer is reading from.
|
||||
pub async fn run_script(
|
||||
script_path: &Path,
|
||||
args: &[String],
|
||||
sink: StreamSink,
|
||||
hermes_home_override: Option<&str>,
|
||||
cancel_rx: &mut Option<CancelRx>,
|
||||
) -> Result<ScriptResult> {
|
||||
let mut cmd = build_command(script_path, args);
|
||||
|
||||
// The installer can be launched from a .app bundle that is later replaced
|
||||
// during self-update. Pin child scripts to a stable directory so bash/zsh
|
||||
// never starts from a deleted cwd and emits getcwd/job-working-directory
|
||||
// errors at the end of an otherwise successful install.
|
||||
if let Some(cwd) = stable_script_cwd(script_path, hermes_home_override) {
|
||||
cmd.current_dir(cwd);
|
||||
}
|
||||
|
||||
if let Some(home) = hermes_home_override {
|
||||
cmd.env("HERMES_HOME", home);
|
||||
}
|
||||
|
||||
cmd.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
// On Windows, avoid spawning a flashing cmd window when we're hosted
|
||||
// inside a GUI process. Tauri's main window is already created, so
|
||||
// the side-effect console for the child is unwanted.
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// CREATE_NO_WINDOW = 0x08000000
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
}
|
||||
|
||||
let mut child: Child = cmd
|
||||
.spawn()
|
||||
.with_context(|| format!("spawning {} via {}", script_path.display(), interpreter_label()))?;
|
||||
|
||||
// Byte-oriented readers + [`decode_console_bytes`]: do NOT use
|
||||
// `BufReader::lines()`, which requires valid UTF-8 and hides localized
|
||||
// PowerShell errors on non-English Windows (#67193). [`pump_child`] owns
|
||||
// that, plus the rule that the exit status comes from the process and not
|
||||
// from pipe EOF.
|
||||
let mut combined_stdout = String::new();
|
||||
let mut combined_stderr = String::new();
|
||||
|
||||
let outcome = pump_child(
|
||||
&mut child,
|
||||
|l| {
|
||||
(sink.on_stdout_line)(l);
|
||||
combined_stdout.push_str(l);
|
||||
combined_stdout.push('\n');
|
||||
},
|
||||
|l| {
|
||||
(sink.on_stderr_line)(l);
|
||||
combined_stderr.push_str(l);
|
||||
combined_stderr.push('\n');
|
||||
},
|
||||
cancel_rx,
|
||||
DRAIN_GRACE,
|
||||
)
|
||||
.await
|
||||
.context("streaming install script output")?;
|
||||
|
||||
if outcome.abandoned {
|
||||
let note = format!(
|
||||
"install script exited but a surviving descendant still holds its \
|
||||
stdout/stderr; gave up on the last {}s of output (#90455)",
|
||||
DRAIN_GRACE.as_secs()
|
||||
);
|
||||
tracing::warn!("{note}");
|
||||
(sink.on_stderr_line)(¬e);
|
||||
combined_stderr.push_str(¬e);
|
||||
combined_stderr.push('\n');
|
||||
}
|
||||
|
||||
Ok(ScriptResult {
|
||||
stdout: combined_stdout,
|
||||
stderr: combined_stderr,
|
||||
exit_code: outcome.exit_code,
|
||||
killed: outcome.killed,
|
||||
})
|
||||
}
|
||||
|
||||
fn stable_script_cwd<'a>(script_path: &'a Path, hermes_home_override: Option<&'a str>) -> Option<&'a Path> {
|
||||
if let Some(home) = hermes_home_override {
|
||||
let path = Path::new(home);
|
||||
if path.is_dir() {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
script_path.parent().filter(|p| p.is_dir())
|
||||
}
|
||||
|
||||
async fn recv_cancel(rx: &mut Option<CancelRx>) {
|
||||
match rx {
|
||||
Some(r) => {
|
||||
let _ = r.recv().await;
|
||||
}
|
||||
None => std::future::pending::<()>().await,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_command(script_path: &Path, args: &[String]) -> Command {
|
||||
// We want PowerShell 5.1 / 7. install.ps1 uses 5.1-safe syntax everywhere.
|
||||
// Prefer `powershell.exe` (5.1 baseline, present on every Windows since 7)
|
||||
// over `pwsh.exe` (7+, may not be present). Resolve it by absolute path —
|
||||
// see `windows_powershell_exe`.
|
||||
let mut cmd = Command::new(windows_powershell_exe());
|
||||
cmd.arg("-NoProfile");
|
||||
cmd.arg("-ExecutionPolicy").arg("Bypass");
|
||||
cmd.arg("-File").arg(script_path);
|
||||
for a in args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn build_command(script_path: &Path, args: &[String]) -> Command {
|
||||
// install.sh expects bash. /bin/bash is fine on macOS (Apple still
|
||||
// ships an old 3.2 bash; install.sh is written to that baseline).
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg(script_path);
|
||||
for a in args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Canonical PowerShell 5.1 location under a Windows root (`%SystemRoot%`).
|
||||
/// Kept separate (and test-visible) so the path layout is unit-tested on any
|
||||
/// host, not just Windows.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn powershell_under_root(root: &Path) -> std::path::PathBuf {
|
||||
root.join("System32")
|
||||
.join("WindowsPowerShell")
|
||||
.join("v1.0")
|
||||
.join("powershell.exe")
|
||||
}
|
||||
|
||||
/// Resolves the PowerShell interpreter to spawn.
|
||||
///
|
||||
/// `Command::new("powershell.exe")` trusts PATH to contain
|
||||
/// `%SystemRoot%\System32\WindowsPowerShell\v1.0`. On machines whose PATH was
|
||||
/// trimmed or truncated (Windows silently drops entries once the variable grows
|
||||
/// past its length limit), that lookup fails and the spawn dies with
|
||||
/// "program not found" before install.ps1 ever runs — the installer then stalls
|
||||
/// at "0 of 0 steps". Resolve by absolute path first, then fall back to PATH
|
||||
/// (powershell 5.1, then pwsh 7), then a bare name as a last resort.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_powershell_exe() -> std::path::PathBuf {
|
||||
for var in ["SystemRoot", "windir"] {
|
||||
if let Ok(root) = std::env::var(var) {
|
||||
let candidate = powershell_under_root(Path::new(&root));
|
||||
if candidate.is_file() {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for exe in ["powershell.exe", "pwsh.exe"] {
|
||||
if let Ok(found) = which::which(exe) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
std::path::PathBuf::from("powershell.exe")
|
||||
}
|
||||
|
||||
/// Human-readable interpreter name for spawn-failure context. On Windows this
|
||||
/// is the resolved PowerShell path so a missing/odd interpreter is obvious in
|
||||
/// the log (the old message only printed the script path, which read as if the
|
||||
/// .ps1 itself was missing).
|
||||
#[cfg(target_os = "windows")]
|
||||
fn interpreter_label() -> String {
|
||||
windows_powershell_exe().display().to_string()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn interpreter_label() -> String {
|
||||
"bash".to_string()
|
||||
}
|
||||
|
||||
/// Parses the LAST line of stdout that looks like a JSON object matching
|
||||
/// the install.ps1 stage-result contract: `{ok: bool, stage: string, ...}`.
|
||||
///
|
||||
/// Mirrors `parseStageResult` from bootstrap-runner.ts. install.ps1 may
|
||||
/// print info/banner lines before the result frame; we scan from the end.
|
||||
pub fn parse_stage_result(stdout: &str) -> Option<crate::events::StageResultPayload> {
|
||||
for line in stdout.lines().rev() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
if value.get("ok").and_then(|v| v.as_bool()).is_some()
|
||||
&& value.get("stage").and_then(|v| v.as_str()).is_some()
|
||||
{
|
||||
if let Ok(parsed) =
|
||||
serde_json::from_value::<crate::events::StageResultPayload>(value)
|
||||
{
|
||||
return Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Same logic but for the `-Manifest` payload (the LAST line with a `stages`
|
||||
/// array). Returns the parsed manifest.
|
||||
pub fn parse_manifest(stdout: &str) -> Option<crate::events::Manifest> {
|
||||
for line in stdout.lines().rev() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(value) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
if value.get("stages").and_then(|v| v.as_array()).is_some() {
|
||||
if let Ok(parsed) = serde_json::from_value::<crate::events::Manifest>(value) {
|
||||
return Some(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_stage_result_picks_last_json_line() {
|
||||
let stdout = r#"
|
||||
[bootstrap] some info
|
||||
{"ok": false, "stage": "venv", "reason": "bad python"}
|
||||
{"ok": true, "stage": "venv"}
|
||||
final non-json banner
|
||||
"#;
|
||||
let result = parse_stage_result(stdout).unwrap();
|
||||
assert_eq!(result.stage, "venv");
|
||||
assert!(result.ok);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_manifest_finds_stages_array() {
|
||||
let stdout = r#"
|
||||
info line
|
||||
{"stages": [{"name": "uv", "title": "uv", "category": "prereqs", "needs_user_input": false}], "protocol_version": 1}
|
||||
"#;
|
||||
let m = parse_manifest(stdout).unwrap();
|
||||
assert_eq!(m.stages.len(), 1);
|
||||
assert_eq!(m.stages[0].name, "uv");
|
||||
assert_eq!(m.protocol_version, Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_returns_none_when_no_match() {
|
||||
assert!(parse_stage_result("just banner\n").is_none());
|
||||
assert!(parse_manifest("just banner\n").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_script_cwd_prefers_existing_hermes_home() {
|
||||
let script = Path::new("/tmp/install.sh");
|
||||
let cwd = stable_script_cwd(script, Some("/"));
|
||||
assert_eq!(cwd, Some(Path::new("/")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powershell_under_root_uses_system32_v1_layout() {
|
||||
let resolved = powershell_under_root(Path::new("C:\\Windows"));
|
||||
let normalized = resolved.to_string_lossy().replace('\\', "/");
|
||||
assert!(
|
||||
normalized.ends_with("System32/WindowsPowerShell/v1.0/powershell.exe"),
|
||||
"unexpected powershell path: {normalized}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_console_bytes_keeps_valid_utf8() {
|
||||
assert_eq!(decode_console_bytes("café — ok".as_bytes()), "café — ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_console_bytes_preserves_cp1252_portuguese_error() {
|
||||
// "Não foi fornecido o terminador..." as Windows PowerShell 5.1 emits
|
||||
// under CP1252 (0xE3 = ã). BufReader::lines() previously failed here
|
||||
// with "stream did not contain valid UTF-8" and the UI only showed "No".
|
||||
let bytes: &[u8] = b"N\xE3o foi fornecido o terminador";
|
||||
assert_eq!(decode_console_bytes(bytes), "Não foi fornecido o terminador");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_console_bytes_maps_cp1252_only_punctuation() {
|
||||
// 0x91/0x92 are curly quotes in Windows-1252, but C1 controls under
|
||||
// Latin-1 (`b as char`). This locks the real CP1252 fallback.
|
||||
let bytes: &[u8] = b"say \x91hi\x92";
|
||||
assert_eq!(decode_console_bytes(bytes), "say \u{2018}hi\u{2019}");
|
||||
assert_ne!(
|
||||
decode_console_bytes(bytes),
|
||||
bytes.iter().map(|&b| b as char).collect::<String>(),
|
||||
"Latin-1 byte mapping must not be used for the 0x80..=0x9F range"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_decoded_line_survives_non_utf8_and_crlf() {
|
||||
let data: &[u8] = b"N\xE3o erro\r\nnext\n";
|
||||
let mut reader = BufReader::new(data);
|
||||
let mut buf = Vec::new();
|
||||
assert_eq!(
|
||||
read_decoded_line(&mut reader, &mut buf)
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("Não erro")
|
||||
);
|
||||
assert_eq!(
|
||||
read_decoded_line(&mut reader, &mut buf)
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("next")
|
||||
);
|
||||
assert!(read_decoded_line(&mut reader, &mut buf)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_decoded_line_preserves_partial_line_across_cancellation() {
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let (mut tx, rx) = tokio::io::duplex(64);
|
||||
let mut reader = BufReader::new(rx);
|
||||
let mut buf = Vec::new();
|
||||
|
||||
tx.write_all(b"partial").await.unwrap();
|
||||
// Poll once, then cancel (drop) the future -- exactly what
|
||||
// tokio::select! does in run_script when the other stream produces
|
||||
// a line first. The consumed bytes must survive in `buf`.
|
||||
let _ = tokio::time::timeout(
|
||||
Duration::from_millis(0),
|
||||
read_decoded_line(&mut reader, &mut buf),
|
||||
)
|
||||
.await;
|
||||
|
||||
tx.write_all(b" line\n").await.unwrap();
|
||||
let line = read_decoded_line(&mut reader, &mut buf).await.unwrap();
|
||||
assert_eq!(line.as_deref(), Some("partial line"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_decoded_line_emits_unterminated_final_line_at_eof() {
|
||||
let data: &[u8] = b"no trailing newline";
|
||||
let mut reader = BufReader::new(data);
|
||||
let mut buf = Vec::new();
|
||||
assert_eq!(
|
||||
read_decoded_line(&mut reader, &mut buf)
|
||||
.await
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("no trailing newline")
|
||||
);
|
||||
assert!(read_decoded_line(&mut reader, &mut buf)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
|
||||
/// Spawn `sh -c <script>` with both pipes redirected, the way run_script and
|
||||
/// run_streamed do.
|
||||
#[cfg(unix)]
|
||||
fn sh(script: &str) -> Child {
|
||||
Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(script)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn /bin/sh")
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn pump_collect(child: &mut Child, grace: Duration) -> (PumpOutcome, Vec<String>) {
|
||||
let mut lines = Vec::new();
|
||||
let outcome = pump_child(
|
||||
child,
|
||||
|l| lines.push(l.to_string()),
|
||||
|_| {},
|
||||
&mut None,
|
||||
grace,
|
||||
)
|
||||
.await
|
||||
.expect("pump");
|
||||
(outcome, lines)
|
||||
}
|
||||
|
||||
/// The #90455 deadlock: a child exits, but a descendant it spawned still
|
||||
/// holds the inherited write end of the pipe, so EOF never comes. The pump
|
||||
/// must return on the *process* exiting and abandon the drain.
|
||||
///
|
||||
/// The Windows half of this contract lives in `-SelfTestPipeDrain`
|
||||
/// (scripts/desktop-update/windows.ps1) and runs on the Windows CI lane; the
|
||||
/// pump logic under test here is OS-agnostic, only the fixture is not.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn pump_child_returns_when_a_grandchild_still_holds_the_pipe() {
|
||||
// `sleep` inherits stdout and outlives the shell by design. Nothing
|
||||
// redirects it -- redirecting is what would close the handle and stop
|
||||
// the bug from reproducing at all.
|
||||
let mut child = sh("sleep 30 & echo hello; exit 7");
|
||||
let started = std::time::Instant::now();
|
||||
let (outcome, lines) = pump_collect(&mut child, Duration::from_millis(300)).await;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(outcome.abandoned, "drain should have been abandoned");
|
||||
assert_eq!(
|
||||
outcome.exit_code,
|
||||
Some(7),
|
||||
"exit code must survive an abandoned drain"
|
||||
);
|
||||
assert_eq!(
|
||||
lines,
|
||||
vec!["hello"],
|
||||
"output written before the pipe was stranded must survive"
|
||||
);
|
||||
assert!(!outcome.killed);
|
||||
// Far below the grandchild's 30s: a pass cannot be it exiting on its own.
|
||||
assert!(elapsed < Duration::from_secs(10), "took {elapsed:?}");
|
||||
}
|
||||
|
||||
/// The other cliff: a child that leaks nothing must not pay the grace. This
|
||||
/// is what fails if the pump ever waits on the deadline unconditionally
|
||||
/// instead of only when a pipe outlives its process.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn pump_child_does_not_pay_the_grace_when_pipes_close_cleanly() {
|
||||
let mut child = sh("echo one; echo two >&2; echo three; exit 3");
|
||||
let started = std::time::Instant::now();
|
||||
let (outcome, lines) = pump_collect(&mut child, Duration::from_secs(30)).await;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(!outcome.abandoned);
|
||||
assert_eq!(outcome.exit_code, Some(3));
|
||||
assert_eq!(lines, vec!["one", "three"]);
|
||||
assert!(elapsed < Duration::from_secs(10), "took {elapsed:?}");
|
||||
}
|
||||
|
||||
/// A chatty child must stream at pipe speed, not at one buffer per tick.
|
||||
/// The PowerShell port of this pump regressed exactly here: idling after
|
||||
/// every chunk it *did* read metered the drain and backpressured the running
|
||||
/// child. `hermes update` is this shape -- the Electron build alone is
|
||||
/// megabytes.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn pump_child_streams_a_flood_without_metering_it() {
|
||||
let mut child = sh("i=0; while [ $i -lt 20000 ]; do echo line$i; i=$((i+1)); done; exit 0");
|
||||
let started = std::time::Instant::now();
|
||||
let (outcome, lines) = pump_collect(&mut child, Duration::from_secs(30)).await;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert_eq!(outcome.exit_code, Some(0));
|
||||
assert!(!outcome.abandoned);
|
||||
assert_eq!(lines.len(), 20000, "every line must survive");
|
||||
assert_eq!(lines.last().unwrap(), "line19999");
|
||||
// Loose on purpose: this catches per-chunk sleeping (which would put
|
||||
// this in the tens of seconds), not small scheduler variance.
|
||||
assert!(elapsed < Duration::from_secs(20), "took {elapsed:?}");
|
||||
}
|
||||
|
||||
/// Cancelling kills the child, but not a grandchild holding the pipe --
|
||||
/// `start_kill` only reaches the child. The bounded drain is what actually
|
||||
/// lets a cancel return, so cancellation and the grace are one mechanism.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn pump_child_cancellation_returns_even_with_the_pipe_stranded() {
|
||||
let mut child = sh("sleep 30 & echo working; sleep 30");
|
||||
let (tx, rx) = mpsc::channel(1);
|
||||
let mut cancel = Some(rx);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let _ = tx.send(()).await;
|
||||
});
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let mut lines = Vec::new();
|
||||
let outcome = pump_child(
|
||||
&mut child,
|
||||
|l| lines.push(l.to_string()),
|
||||
|_| {},
|
||||
&mut cancel,
|
||||
Duration::from_millis(300),
|
||||
)
|
||||
.await
|
||||
.expect("pump");
|
||||
|
||||
assert!(outcome.killed, "cancellation should report killed");
|
||||
assert!(outcome.abandoned, "the grandchild still holds the pipe");
|
||||
assert_eq!(lines, vec!["working"]);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(10),
|
||||
"took {:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
/// The reverse topology of the test above: the pipes die and the *process*
|
||||
/// outlives them. Phase 1 leaves on EOF with no exit status, so the final
|
||||
/// wait is the only thing left holding the turn -- and it has to stay
|
||||
/// cancellable. A bare `child.wait()` there strands the cancel channel
|
||||
/// against a child that may never exit on its own.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn pump_child_cancellation_returns_after_both_pipes_reach_eof() {
|
||||
// Closes fd 1 and 2, then lingers: both reads hit EOF immediately while
|
||||
// the process stays alive far past any plausible test duration.
|
||||
let mut child = sh("echo bye; exec 1>&- 2>&-; sleep 30");
|
||||
let (tx, rx) = mpsc::channel(1);
|
||||
let mut cancel = Some(rx);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let _ = tx.send(()).await;
|
||||
});
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let mut lines = Vec::new();
|
||||
let outcome = pump_child(
|
||||
&mut child,
|
||||
|l| lines.push(l.to_string()),
|
||||
|_| {},
|
||||
&mut cancel,
|
||||
Duration::from_millis(300),
|
||||
)
|
||||
.await
|
||||
.expect("pump");
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(outcome.killed, "cancellation should report killed");
|
||||
assert!(
|
||||
!outcome.abandoned,
|
||||
"both pipes reached EOF, so nothing was abandoned"
|
||||
);
|
||||
assert_eq!(lines, vec!["bye"], "output written before EOF must survive");
|
||||
// Far below the child's 30s: a pass cannot be it exiting on its own.
|
||||
assert!(elapsed < Duration::from_secs(10), "took {elapsed:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hermes",
|
||||
"version": "0.0.1",
|
||||
"identifier": "com.nousresearch.hermes.setup",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://127.0.0.1:5175",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Hermes",
|
||||
"width": 880,
|
||||
"height": 620,
|
||||
"minWidth": 720,
|
||||
"minHeight": 520,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": true,
|
||||
"transparent": false,
|
||||
"center": true,
|
||||
"visible": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost"
|
||||
},
|
||||
"withGlobalTauri": false
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "DeveloperTool",
|
||||
"shortDescription": "Hermes",
|
||||
"longDescription": "Installs Hermes Agent on your machine. Drives scripts/install.ps1 (Windows) and scripts/install.sh (macOS/Linux).",
|
||||
"publisher": "Nous Research",
|
||||
"copyright": "Copyright © 2026 Nous Research",
|
||||
"targets": [
|
||||
"app",
|
||||
"dmg",
|
||||
"appimage"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
"type": "embedBootstrapper"
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "11.0",
|
||||
"hardenedRuntime": true,
|
||||
"entitlements": "entitlements.plist"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"shell": {
|
||||
"open": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import Failure from './routes/failure'
|
||||
import Progress from './routes/progress'
|
||||
import Success from './routes/success'
|
||||
import Welcome from './routes/welcome'
|
||||
import { $bootstrap, $route, initialize } from './store'
|
||||
|
||||
/*
|
||||
* App shell — Hermes Setup.
|
||||
*
|
||||
* No header chrome (the OS title bar already says "Hermes Setup"; an
|
||||
* in-window repeat of the H mark + words was redundant slop).
|
||||
*
|
||||
* Route state lives in a single $route atom — 4 screens, no react-router.
|
||||
*/
|
||||
export default function App() {
|
||||
const route = useStore($route)
|
||||
const bootstrap = useStore($bootstrap)
|
||||
|
||||
useEffect(() => {
|
||||
void initialize()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col overflow-hidden bg-background text-foreground">
|
||||
<main className="relative z-10 flex flex-1 flex-col overflow-hidden">
|
||||
{route === 'welcome' && <Welcome />}
|
||||
{route === 'progress' && <Progress bootstrap={bootstrap} />}
|
||||
{route === 'success' && <Success />}
|
||||
{route === 'failure' && <Failure bootstrap={bootstrap} />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
const assetPath = (path: string) => `${import.meta.env.BASE_URL}${path.replace(/^\/+/, '')}`
|
||||
|
||||
// Brand badge: nous-girl mark on a white tile, identical in light/dark.
|
||||
// Ported from apps/desktop's BrandMark; asset lives in this app's public/.
|
||||
export function BrandMark({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span className={cn('inline-flex size-14 shrink-0 items-center justify-center bg-white', className)} {...props}>
|
||||
<img alt="" className="size-full object-contain" src={assetPath('nous-girl.jpg')} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Slot } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
/*
|
||||
* Button — copied verbatim from apps/desktop/src/components/ui/button.tsx.
|
||||
*
|
||||
* We import the desktop's local shadcn-style Button rather than
|
||||
* @nous-research/ui's <Button>, because the DS Button uses bg-midground /
|
||||
* text-background-base utilities that resolve to the DS's hardcoded
|
||||
* gold/brown brand defaults (#ffac02 / #170d02) unless overridden in
|
||||
* runtime. The desktop never sets those vars; it routes through its
|
||||
* own --dt-* token chain via shadcn classes like bg-primary. We do
|
||||
* the same so visuals match exactly.
|
||||
*/
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-[2.5px] text-xs leading-4 font-medium whitespace-nowrap shadow-none transition-all duration-100 outline-none focus-visible:border-ring focus-visible:ring-[0.1875rem] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40',
|
||||
outline:
|
||||
'bg-transparent text-(--ui-text-primary) shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)] hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
||||
secondary:
|
||||
'bg-(--ui-bg-quaternary) text-(--ui-text-primary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
||||
ghost: 'text-(--ui-text-secondary) hover:bg-(--chrome-action-hover) hover:text-(--ui-text-primary)',
|
||||
link: 'text-primary underline-offset-4 decoration-current/20 hover:underline',
|
||||
text: 'text-muted-foreground underline-offset-4 hover:text-foreground hover:underline',
|
||||
textStrong: 'font-semibold text-muted-foreground underline underline-offset-4 hover:text-foreground'
|
||||
},
|
||||
size: {
|
||||
default: 'px-3 py-1.5 has-[>svg]:px-2.5',
|
||||
xs: "gap-1 px-2 py-0.5 text-[0.6875rem] leading-4 has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: 'px-2.5 py-1 has-[>svg]:px-2',
|
||||
lg: 'px-5 py-2 text-sm leading-5 has-[>svg]:px-4',
|
||||
inline: 'h-auto gap-1 p-0 has-[>svg]:px-0',
|
||||
icon: 'size-9 rounded-[4px]',
|
||||
'icon-xs': "size-6 rounded-[4px] [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm': 'size-8 rounded-[4px]',
|
||||
'icon-lg': 'size-10 rounded-[4px]'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ComponentProps<'button'>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot.Root : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
data-size={size}
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { buttonVariants }
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
/*
|
||||
* HackeryButton — the onboarding "Begin" CTA, ported standalone.
|
||||
*
|
||||
* Bracketed [ LABEL ], mono/uppercase, primary accent on a --stroke-nous hairline.
|
||||
* Lifted from apps/desktop's desktop-onboarding-overlay.tsx (sans the exit-scramble
|
||||
* choreography, which is overlay-specific). Self-contained: cn + lucide only.
|
||||
*/
|
||||
export function HackeryButton({
|
||||
className,
|
||||
label,
|
||||
loading,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<'button'>, 'children'> & { label: React.ReactNode; loading?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className={cn(
|
||||
'group inline-flex cursor-pointer items-center gap-2 rounded-md border border-(--stroke-nous) px-6 py-2.5',
|
||||
'font-mono text-xs font-semibold uppercase text-primary',
|
||||
'transition-all duration-150 hover:border-primary/60 hover:bg-primary/[0.06]',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
type="button"
|
||||
>
|
||||
<span className="text-primary/40 transition-colors group-hover:text-primary">[</span>
|
||||
{loading ? <Loader2 className="size-3 animate-spin" /> : null}
|
||||
<span className="-mr-[0.25em] pl-[0.25em] tracking-[0.25em]">{label}</span>
|
||||
<span className="text-primary/40 transition-colors group-hover:text-primary">]</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { type ComponentProps, useEffect, useRef } from 'react'
|
||||
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
/*
|
||||
* Loader — the desktop's "Fourier Flow" curve, ported standalone.
|
||||
*
|
||||
* The shim can't import apps/desktop's 559-line multi-curve <Loader> (cross-app
|
||||
* coupling + bundle bloat that defeats the point of a lightweight installer), so
|
||||
* this is just the one curve the installer uses. Math + tuning lifted verbatim
|
||||
* from apps/desktop/src/components/ui/loader.tsx ('fourier-flow'); rotation is
|
||||
* dropped because that curve never rotates. Keep the constants in sync if the
|
||||
* desktop's curve is retuned.
|
||||
*/
|
||||
|
||||
const TWO_PI = Math.PI * 2
|
||||
|
||||
const CURVE = {
|
||||
durationMs: 2200,
|
||||
particleCount: 92,
|
||||
pulseDurationMs: 2000,
|
||||
strokeWidth: 4.2,
|
||||
trailSpan: 0.31,
|
||||
point(progress: number, detailScale: number) {
|
||||
const t = progress * TWO_PI
|
||||
const mix = 1 + detailScale * 0.16
|
||||
const x = 17 * Math.cos(t) + 7.5 * Math.cos(3 * t + 0.6 * mix) + 3.2 * Math.sin(5 * t - 0.4)
|
||||
const y = 15 * Math.sin(t) + 8.2 * Math.sin(2 * t + 0.25) - 4.2 * Math.cos(4 * t - 0.5 * mix)
|
||||
|
||||
return { x: 50 + x, y: 50 + y }
|
||||
}
|
||||
}
|
||||
|
||||
const norm = (progress: number) => ((progress % 1) + 1) % 1
|
||||
|
||||
function detailScaleFor(time: number, phaseOffset: number) {
|
||||
const p = ((time + phaseOffset * CURVE.pulseDurationMs) % CURVE.pulseDurationMs) / CURVE.pulseDurationMs
|
||||
|
||||
return 0.52 + ((Math.sin(p * TWO_PI + 0.55) + 1) / 2) * 0.48
|
||||
}
|
||||
|
||||
function buildPath(detailScale: number, steps: number) {
|
||||
return Array.from({ length: steps + 1 }, (_, i) => {
|
||||
const { x, y } = CURVE.point(i / steps, detailScale)
|
||||
|
||||
return `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`
|
||||
}).join(' ')
|
||||
}
|
||||
|
||||
function particleFor(index: number, progress: number, detailScale: number, strokeScale: number) {
|
||||
const tail = index / (CURVE.particleCount - 1)
|
||||
const { x, y } = CURVE.point(norm(progress - tail * CURVE.trailSpan), detailScale)
|
||||
const fade = (1 - tail) ** 0.56
|
||||
|
||||
return { x, y, opacity: 0.04 + fade * 0.96, radius: (0.9 + fade * 2.7) * strokeScale }
|
||||
}
|
||||
|
||||
interface LoaderProps extends Omit<ComponentProps<'div'>, 'children'> {
|
||||
label?: string
|
||||
pathSteps?: number
|
||||
strokeScale?: number
|
||||
}
|
||||
|
||||
export function Loader({
|
||||
className,
|
||||
label = 'Loading',
|
||||
pathSteps = 240,
|
||||
role = 'status',
|
||||
strokeScale = 1,
|
||||
...props
|
||||
}: LoaderProps) {
|
||||
const particleRefs = useRef<Array<SVGCircleElement | null>>([])
|
||||
const pathRef = useRef<SVGPathElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let frame = 0
|
||||
const startedAt = performance.now()
|
||||
const phaseOffset = Math.random()
|
||||
particleRefs.current.length = CURVE.particleCount
|
||||
|
||||
const render = (now: number) => {
|
||||
const time = now - startedAt
|
||||
const progress = ((time + phaseOffset * CURVE.durationMs) % CURVE.durationMs) / CURVE.durationMs
|
||||
const detailScale = detailScaleFor(time, phaseOffset)
|
||||
|
||||
pathRef.current?.setAttribute('d', buildPath(detailScale, pathSteps))
|
||||
|
||||
particleRefs.current.forEach((node, index) => {
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
const p = particleFor(index, progress, detailScale, strokeScale)
|
||||
node.setAttribute('cx', p.x.toFixed(2))
|
||||
node.setAttribute('cy', p.y.toFixed(2))
|
||||
node.setAttribute('r', p.radius.toFixed(2))
|
||||
node.setAttribute('opacity', p.opacity.toFixed(3))
|
||||
})
|
||||
|
||||
frame = window.requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
render(performance.now())
|
||||
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [pathSteps, strokeScale])
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
aria-label={props['aria-label'] ?? label}
|
||||
className={cn('inline-grid size-10 place-items-center text-primary', className)}
|
||||
role={role}
|
||||
>
|
||||
<svg aria-hidden="true" className="size-full overflow-visible" fill="none" viewBox="0 0 100 100">
|
||||
<path
|
||||
opacity="0.1"
|
||||
ref={pathRef}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={CURVE.strokeWidth * strokeScale}
|
||||
/>
|
||||
{Array.from({ length: CURVE.particleCount }, (_, index) => (
|
||||
<circle
|
||||
fill="currentColor"
|
||||
key={index}
|
||||
ref={node => {
|
||||
particleRefs.current[index] = node
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Duration formatters for the stage list. Pure functions, no React — kept out
|
||||
* of progress.tsx so tests-js can exercise them without dragging in the Tauri
|
||||
* renderer.
|
||||
*/
|
||||
|
||||
// Duration of a completed stage: ms, then s, then "Xm Ys", then "Xh Ym".
|
||||
export function formatDuration(ms: number): string {
|
||||
if (ms < 1000) {return `${ms}ms`}
|
||||
|
||||
if (ms < 60000) {return `${(ms / 1000).toFixed(1)}s`}
|
||||
const m = Math.floor(ms / 60000)
|
||||
const s = Math.round((ms % 60000) / 1000)
|
||||
|
||||
if (m < 60) {return `${m}m ${s}s`}
|
||||
const h = Math.floor(m / 60)
|
||||
|
||||
return `${h}h ${m - h * 60}m`
|
||||
}
|
||||
|
||||
// Live elapsed for a running stage: bare seconds under a minute, then m:ss,
|
||||
// then h:mm:ss past an hour. Without the hour rollover a stalled overnight
|
||||
// stage read as "744:38" — minutes rendered unbounded — which one user
|
||||
// understandably reported as "744 hours".
|
||||
export function formatElapsed(ms: number): string {
|
||||
const s = Math.max(0, Math.floor(ms / 1000))
|
||||
|
||||
if (s < 60) {return `${s}s`}
|
||||
const m = Math.floor(s / 60)
|
||||
|
||||
if (m < 60) {return `${m}:${String(s - m * 60).padStart(2, '0')}`}
|
||||
const h = Math.floor(m / 60)
|
||||
|
||||
return `${h}:${String(m - h * 60).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
/*
|
||||
* cn — Tailwind-aware class merger. Same util the desktop and dashboard
|
||||
* use. clsx handles conditional classes; twMerge resolves utility
|
||||
* conflicts so `cn('px-2', condition && 'px-4')` ends up with px-4 only,
|
||||
* not both.
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import './styles.css'
|
||||
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import App from './app.tsx'
|
||||
import { watchTheme } from './theme'
|
||||
|
||||
// Follow the OS light/dark appearance. theme.ts paints the first frame on
|
||||
// import (synchronously, from the media query); this subscribes to live OS
|
||||
// theme changes via the authoritative Tauri window theme.
|
||||
void watchTheme()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { FileText, RefreshCw } from 'lucide-react'
|
||||
import { type CSSProperties } from 'react'
|
||||
|
||||
import { Button } from '../components/button'
|
||||
import {
|
||||
$logPath,
|
||||
$mode,
|
||||
type BootstrapStateModel,
|
||||
openLogDir,
|
||||
startInstall,
|
||||
startUpdate
|
||||
} from '../store'
|
||||
|
||||
interface FailureProps {
|
||||
bootstrap: BootstrapStateModel
|
||||
}
|
||||
|
||||
/*
|
||||
* Failure screen. Same hero treatment as Welcome/Success — the wordmark
|
||||
* carries the brand, so we keep it across every terminal state.
|
||||
*
|
||||
* The actual error message lives below in muted text. Two affordances on
|
||||
* shared Button tokens: Retry (primary) and Open logs (quiet text link).
|
||||
*/
|
||||
export default function Failure({ bootstrap }: FailureProps) {
|
||||
const logPath = useStore($logPath)
|
||||
const mode = useStore($mode)
|
||||
const isUpdate = mode === 'update'
|
||||
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-6 px-12 py-10">
|
||||
<div className="w-full max-w-2xl min-w-0 text-center">
|
||||
<p
|
||||
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-destructive mix-blend-plus-lighter dark:text-destructive/90"
|
||||
style={
|
||||
{
|
||||
'--fit-text-line-height': '0.9',
|
||||
'--fit-text-max': '5rem',
|
||||
'--fit-text-min': '2.25rem'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>{isUpdate ? 'Update didn\u2019t finish' : 'Install didn\u2019t finish'}</span>
|
||||
</span>
|
||||
<span aria-hidden="true">{isUpdate ? 'Update didn\u2019t finish' : 'Install didn\u2019t finish'}</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 mx-auto max-w-xl text-center text-sm leading-normal tracking-tight text-muted-foreground">
|
||||
{bootstrap.error ??
|
||||
(isUpdate
|
||||
? 'Something went wrong during the update.'
|
||||
: 'Something went wrong during installation.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button className="gap-1.5" onClick={() => void (isUpdate ? startUpdate() : startInstall())}>
|
||||
<RefreshCw />
|
||||
{isUpdate ? 'Retry update' : 'Retry install'}
|
||||
</Button>
|
||||
<Button className="gap-1.5" onClick={() => void openLogDir()} variant="text">
|
||||
<FileText />
|
||||
Open logs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{logPath && (
|
||||
<p className="max-w-lg text-center text-xs text-muted-foreground/70">
|
||||
Log: <code className="font-mono">{logPath}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import clsx from 'clsx'
|
||||
import { Check, ChevronRight, FileText, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { BrandMark } from '../components/brand-mark'
|
||||
import { Button } from '../components/button'
|
||||
import { Loader } from '../components/loader'
|
||||
import { formatDuration, formatElapsed } from '../lib/format'
|
||||
import {
|
||||
$mode,
|
||||
$progress,
|
||||
type BootstrapStateModel,
|
||||
cancelInstall,
|
||||
type StageState
|
||||
} from '../store'
|
||||
|
||||
interface ProgressProps {
|
||||
bootstrap: BootstrapStateModel
|
||||
}
|
||||
|
||||
/*
|
||||
* Progress screen — drives a stage list + collapsible log panel. Uses
|
||||
* the DS <Progress> for the top bar so its motion + ring match the rest
|
||||
* of the product.
|
||||
*/
|
||||
export default function ProgressScreen({ bootstrap }: ProgressProps) {
|
||||
const progress = useStore($progress)
|
||||
const mode = useStore($mode)
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const logEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (showLogs && logEndRef.current) {
|
||||
logEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [bootstrap.logs.length, showLogs])
|
||||
|
||||
// Tick once a second while the run is in flight so the active step shows a
|
||||
// live elapsed timer — a long single step (e.g. the dependency download)
|
||||
// reads as working, not frozen. Stops when nothing is running.
|
||||
useEffect(() => {
|
||||
if (bootstrap.status !== 'running') {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [bootstrap.status])
|
||||
|
||||
const isUpdate = mode === 'update'
|
||||
const title = bootstrap.status === 'completed' ? 'Done' : isUpdate ? 'Updating Hermes' : 'Setting up Hermes Agent'
|
||||
|
||||
const description = isUpdate
|
||||
? 'Hermes is updating to the latest version — this only takes a moment.'
|
||||
: 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. Subsequent launches will skip this step.'
|
||||
|
||||
const pct = Math.round(progress.fraction * 100)
|
||||
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col">
|
||||
{/* Header: brand + title + description, matching the desktop install overlay. */}
|
||||
<div className="flex shrink-0 items-start gap-4 px-6 pt-6 pb-4">
|
||||
<BrandMark className="size-11" />
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold tracking-tight">{title}</h2>
|
||||
<p className="mt-1.5 text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-6 pt-2 pb-4">
|
||||
{/* Progress line + bar; the count shimmers while the install runs.
|
||||
pt-2 matches the log header's py-2 so the "steps complete" line and
|
||||
the "Live output" header share a baseline. */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className={clsx(bootstrap.status === 'running' && 'shimmer')}>
|
||||
{progress.done} of {progress.total} steps complete
|
||||
</span>
|
||||
<span className="tabular-nums">{pct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-(--ui-bg-tertiary)">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300 ease-out"
|
||||
style={{ width: `${Math.max(2, progress.fraction * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flat stage list: only the running step is opaque; the rest read as
|
||||
muted. Running loader overhangs left so labels stay aligned; the
|
||||
terminal check/cross sits right of the label. */}
|
||||
<ol className="space-y-0.5">
|
||||
{bootstrap.stageOrder.map((name) => {
|
||||
const rec = bootstrap.stages[name]
|
||||
|
||||
if (!rec) {return null}
|
||||
|
||||
const meta =
|
||||
rec.state === 'running' && rec.startedAt != null
|
||||
? formatElapsed(now - rec.startedAt)
|
||||
: rec.durationMs != null && rec.state !== 'failed'
|
||||
? formatDuration(rec.durationMs)
|
||||
: null
|
||||
|
||||
return (
|
||||
<li
|
||||
className={clsx(
|
||||
'flex items-center gap-2.5 px-3 py-1.5 text-sm',
|
||||
rec.state === 'running'
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
key={name}
|
||||
>
|
||||
{rec.state === 'running' && <Loader className="-ml-2 size-6 shrink-0" />}
|
||||
<span className="flex-1 truncate">{rec.info.title}</span>
|
||||
{meta && <span className="text-xs tabular-nums text-muted-foreground/70">{meta}</span>}
|
||||
<StateIcon state={rec.state ?? null} />
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{showLogs && (
|
||||
<div className="flex w-1/2 flex-col border-l border-(--stroke-nous)">
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-(--stroke-nous) px-3 py-2 text-xs">
|
||||
<span className="font-medium text-foreground/80">Live output</span>
|
||||
<span className="tabular-nums text-muted-foreground">{bootstrap.logs.length} lines</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2 font-mono text-[10.5px] leading-relaxed">
|
||||
{bootstrap.logs.map((entry, idx) => (
|
||||
<div
|
||||
className={clsx(
|
||||
'whitespace-pre-wrap',
|
||||
entry.stream === 'stderr' ? 'text-foreground/45' : 'text-foreground/70'
|
||||
)}
|
||||
key={idx}
|
||||
>
|
||||
{entry.line}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between border-t border-(--stroke-nous) px-6 py-3">
|
||||
<button
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => setShowLogs((v) => !v)}
|
||||
type="button"
|
||||
>
|
||||
<FileText size={14} />
|
||||
{showLogs ? 'Hide details' : 'Show details'}
|
||||
<ChevronRight className={clsx('transition-transform', showLogs && 'rotate-90')} size={12} />
|
||||
</button>
|
||||
|
||||
{bootstrap.status === 'running' && (
|
||||
<Button onClick={() => void cancelInstall()} size="sm" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Terminal-state markers, neutral by design: a muted check for done/skipped
|
||||
// (no celebratory green), a destructive cross for failure. Running renders its
|
||||
// spinner on the left; pending stays icon-less.
|
||||
function StateIcon({ state }: { state: StageState | null }) {
|
||||
if (state === 'succeeded') {
|
||||
return <Check className="shrink-0 text-muted-foreground" size={13} />
|
||||
}
|
||||
|
||||
if (state === 'skipped') {
|
||||
return <Check className="shrink-0 text-muted-foreground/50" size={13} />
|
||||
}
|
||||
|
||||
if (state === 'failed') {
|
||||
return <X className="shrink-0 text-destructive" size={13} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { AlertCircle } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { type CSSProperties } from 'react'
|
||||
|
||||
import { HackeryButton } from '../components/hackery-button'
|
||||
import { launchHermesDesktop } from '../store'
|
||||
|
||||
/*
|
||||
* Success screen. HERMES AGENT wordmark stays as the visual anchor
|
||||
* (same Collapse Bold treatment as Welcome + the desktop chat intro),
|
||||
* with a status line below.
|
||||
*
|
||||
* Launching the desktop can fail (e.g. Stage-Desktop was skipped and
|
||||
* Hermes.exe doesn't exist). We catch the Tauri error and surface it
|
||||
* inline rather than silently doing nothing — the previous version
|
||||
* had `onClick={() => void launchHermesDesktop()}` which swallowed
|
||||
* the rejection and left the user staring at an unresponsive button.
|
||||
*/
|
||||
export default function Success() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [launching, setLaunching] = useState(false)
|
||||
|
||||
async function handleLaunch() {
|
||||
setError(null)
|
||||
setLaunching(true)
|
||||
|
||||
try {
|
||||
await launchHermesDesktop()
|
||||
// On success the installer exits — control never returns here.
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
setError(msg)
|
||||
setLaunching(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-8 px-12 py-10">
|
||||
<div className="w-full max-w-2xl min-w-0 text-center">
|
||||
<p
|
||||
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={
|
||||
{
|
||||
'--fit-text-line-height': '0.9',
|
||||
'--fit-text-max': '5rem',
|
||||
'--fit-text-min': '2.25rem'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>Hermes is ready</span>
|
||||
</span>
|
||||
<span aria-hidden="true">Hermes is ready</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 text-center text-base leading-normal tracking-tight text-muted-foreground">
|
||||
You can launch from here, or any time from your terminal with{' '}
|
||||
<code className="font-mono text-sm text-foreground/80">hermes desktop</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HackeryButton
|
||||
disabled={launching}
|
||||
label={launching ? 'Launching' : 'Launch'}
|
||||
loading={launching}
|
||||
onClick={() => void handleLaunch()}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="flex max-w-2xl items-start gap-2 text-sm" role="alert">
|
||||
<AlertCircle className="mt-0.5 shrink-0 text-destructive" size={16} />
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium text-destructive">Couldn’t launch the desktop app</div>
|
||||
<div className="mt-0.5 text-muted-foreground">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { type CSSProperties } from 'react'
|
||||
|
||||
import { HackeryButton } from '../components/hackery-button'
|
||||
import { startInstall } from '../store'
|
||||
|
||||
/*
|
||||
* Welcome screen.
|
||||
*
|
||||
* Mirrors the desktop's chat intro (apps/desktop/src/components/chat/intro.tsx):
|
||||
* - HERMES AGENT wordmark rendered in Collapse Bold, uppercase, tracked
|
||||
* - mix-blend-plus-lighter so the type "glows" on the canvas
|
||||
* - fit-text utility so the wordmark sizes itself to the column
|
||||
*
|
||||
* No install-path footer. The default install location is correct for
|
||||
* 99% of users; the rest will use the CLI installer with a -HermesHome
|
||||
* flag. Showing %LOCALAPPDATA% to grandma is developer-brain.
|
||||
*/
|
||||
export default function Welcome() {
|
||||
return (
|
||||
<div className="hermes-fade-in flex h-full flex-col items-center justify-center gap-10 px-12 py-10">
|
||||
{/* Hero — same recipe the desktop's chat/intro.tsx uses */}
|
||||
<div className="w-full max-w-2xl min-w-0 text-center">
|
||||
<p
|
||||
className="fit-text mx-auto mb-4 w-full font-['Collapse'] font-bold uppercase leading-[0.9] tracking-[0.08em] text-midground mix-blend-plus-lighter dark:text-foreground/90"
|
||||
style={
|
||||
{
|
||||
'--fit-text-line-height': '0.9',
|
||||
'--fit-text-max': '6rem',
|
||||
'--fit-text-min': '2.5rem'
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<span>HERMES AGENT</span>
|
||||
</span>
|
||||
<span aria-hidden="true">HERMES AGENT</span>
|
||||
</p>
|
||||
|
||||
<p className="m-0 text-center text-base leading-normal tracking-tight text-muted-foreground">
|
||||
The agent that grows with you. We’ll set things up in the
|
||||
background — takes a few minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HackeryButton label="Install" onClick={() => void startInstall()} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
/*
|
||||
* Bootstrap state store — single source of truth for installer screens.
|
||||
*
|
||||
* Lives in nanostores per the project's TypeScript guidelines (apps/desktop
|
||||
* AGENTS.md): "Prefer small nanostores over component state when state is
|
||||
* shared, reused, or read by distant UI."
|
||||
*
|
||||
* One channel from Rust ('bootstrap' event), discriminated by payload.type.
|
||||
* We translate those events into typed atom updates here so the rest of
|
||||
* the app only deals with React-friendly state.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types — mirror src-tauri/src/events.rs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface StageInfo {
|
||||
name: string
|
||||
title: string
|
||||
category: string
|
||||
needs_user_input: boolean
|
||||
}
|
||||
|
||||
export type StageState = 'running' | 'succeeded' | 'skipped' | 'failed'
|
||||
|
||||
export interface StageRecord {
|
||||
info: StageInfo
|
||||
state: StageState | null
|
||||
durationMs?: number
|
||||
/** Wall-clock time the stage entered `running`, stamped client-side so the UI
|
||||
* can tick a live elapsed timer for long steps. Preserved across repeated
|
||||
* running events. */
|
||||
startedAt?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface BootstrapStateModel {
|
||||
status: 'idle' | 'running' | 'completed' | 'failed'
|
||||
protocolVersion: number | null
|
||||
stages: Record<string, StageRecord>
|
||||
stageOrder: string[]
|
||||
currentStage: string | null
|
||||
installRoot: string | null
|
||||
error: string | null
|
||||
logs: Array<{ stage?: string; line: string; stream?: 'stdout' | 'stderr' }>
|
||||
}
|
||||
|
||||
const INITIAL: BootstrapStateModel = {
|
||||
status: 'idle',
|
||||
protocolVersion: null,
|
||||
stages: {},
|
||||
stageOrder: [],
|
||||
currentStage: null,
|
||||
installRoot: null,
|
||||
error: null,
|
||||
logs: []
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Atoms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Route = 'welcome' | 'progress' | 'success' | 'failure'
|
||||
|
||||
/// How the installer was launched, mirrored from src-tauri AppMode.
|
||||
/// 'install' = first-run onboarding (bare launch). 'update' = driven by the
|
||||
/// desktop app handing off via `Hermes-Setup.exe --update`.
|
||||
export type AppMode = 'install' | 'update'
|
||||
|
||||
export const $route = atom<Route>('welcome')
|
||||
export const $mode = atom<AppMode>('install')
|
||||
export const $bootstrap = atom<BootstrapStateModel>(INITIAL)
|
||||
export const $logPath = atom<string | null>(null)
|
||||
export const $hermesHome = atom<string | null>(null)
|
||||
|
||||
export const $progress = computed($bootstrap, (b) => {
|
||||
const total = b.stageOrder.length
|
||||
|
||||
if (total === 0) {return { done: 0, total: 0, fraction: 0 }}
|
||||
let done = 0
|
||||
|
||||
for (const name of b.stageOrder) {
|
||||
const s = b.stages[name]?.state
|
||||
|
||||
if (s === 'succeeded' || s === 'skipped' || s === 'failed') {done += 1}
|
||||
}
|
||||
|
||||
return { done, total, fraction: done / total }
|
||||
})
|
||||
|
||||
/** Apply a stage transition: stamp `startedAt` on the running edge, track the
|
||||
* active stage. Shared by the live Rust handler and the fake-boot preview so the
|
||||
* two behave identically. */
|
||||
function withStageState(
|
||||
cur: BootstrapStateModel,
|
||||
name: string,
|
||||
state: StageState,
|
||||
durationMs?: number,
|
||||
error?: string
|
||||
): BootstrapStateModel {
|
||||
const existing = cur.stages[name]
|
||||
|
||||
if (!existing) {return cur}
|
||||
|
||||
return {
|
||||
...cur,
|
||||
stages: {
|
||||
...cur.stages,
|
||||
[name]: {
|
||||
...existing,
|
||||
state,
|
||||
startedAt: state === 'running' ? (existing.startedAt ?? Date.now()) : existing.startedAt,
|
||||
durationMs,
|
||||
error
|
||||
}
|
||||
},
|
||||
currentStage: state === 'running' ? name : cur.currentStage
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tauri event subscription
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BootstrapManifestEvent {
|
||||
type: 'manifest'
|
||||
stages: StageInfo[]
|
||||
protocolVersion: number | null
|
||||
}
|
||||
|
||||
interface BootstrapStageEvent {
|
||||
type: 'stage'
|
||||
name: string
|
||||
state: StageState
|
||||
durationMs?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface BootstrapLogEvent {
|
||||
type: 'log'
|
||||
stage?: string
|
||||
line: string
|
||||
stream?: 'stdout' | 'stderr'
|
||||
}
|
||||
|
||||
interface BootstrapCompleteEvent {
|
||||
type: 'complete'
|
||||
installRoot: string
|
||||
marker: unknown
|
||||
}
|
||||
|
||||
interface BootstrapFailedEvent {
|
||||
type: 'failed'
|
||||
stage?: string
|
||||
error: string
|
||||
}
|
||||
|
||||
type BootstrapEvent =
|
||||
| BootstrapManifestEvent
|
||||
| BootstrapStageEvent
|
||||
| BootstrapLogEvent
|
||||
| BootstrapCompleteEvent
|
||||
| BootstrapFailedEvent
|
||||
|
||||
let unlisten: UnlistenFn | null = null
|
||||
|
||||
export async function initialize(): Promise<void> {
|
||||
if (unlisten) {return}
|
||||
|
||||
// Dev-only isolated preview (see runFakeBoot): drive the screens in a plain
|
||||
// browser, no Tauri backend, no real install.
|
||||
const fake = fakeMode()
|
||||
|
||||
if (fake) {
|
||||
unlisten = () => {}
|
||||
$logPath.set('~/.hermes/logs/bootstrap-installer.log')
|
||||
$hermesHome.set('~/.hermes')
|
||||
$mode.set(fake === 'update' ? 'update' : 'install')
|
||||
|
||||
// Update auto-runs (it's a hand-off); install/failure wait for the welcome click.
|
||||
if (fake === 'update') {void runFakeBoot('update')}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Pull static info on mount for the diagnostics footer.
|
||||
try {
|
||||
const [logPath, hermesHome, mode] = await Promise.all([
|
||||
invoke<string>('get_log_path'),
|
||||
invoke<string>('get_hermes_home'),
|
||||
invoke<AppMode>('get_mode')
|
||||
])
|
||||
|
||||
$logPath.set(logPath)
|
||||
$hermesHome.set(hermesHome)
|
||||
$mode.set(mode)
|
||||
} catch (err) {
|
||||
console.warn('failed to fetch installer paths', err)
|
||||
}
|
||||
|
||||
unlisten = await listen<BootstrapEvent>('bootstrap', (event) => {
|
||||
const payload = event.payload
|
||||
const cur = $bootstrap.get()
|
||||
|
||||
switch (payload.type) {
|
||||
case 'manifest': {
|
||||
const stages: Record<string, StageRecord> = {}
|
||||
const order: string[] = []
|
||||
|
||||
for (const s of payload.stages) {
|
||||
stages[s.name] = { info: s, state: null }
|
||||
order.push(s.name)
|
||||
}
|
||||
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
status: 'running',
|
||||
protocolVersion: payload.protocolVersion,
|
||||
stages,
|
||||
stageOrder: order,
|
||||
currentStage: null,
|
||||
installRoot: null,
|
||||
error: null,
|
||||
logs: []
|
||||
})
|
||||
$route.set('progress')
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'stage': {
|
||||
if (!cur.stages[payload.name]) {
|
||||
console.warn('stage event for unknown stage', payload.name)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
$bootstrap.set(
|
||||
withStageState(cur, payload.name, payload.state, payload.durationMs, payload.error)
|
||||
)
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'log': {
|
||||
const logs = [...cur.logs, { stage: payload.stage, line: payload.line, stream: payload.stream }]
|
||||
// Keep the rolling buffer bounded so the UI doesn't get OOM'd
|
||||
// during a long install (playwright chromium download is ~10k lines).
|
||||
const trimmed = logs.length > 2000 ? logs.slice(-2000) : logs
|
||||
$bootstrap.set({ ...cur, logs: trimmed })
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case 'complete':
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
status: 'completed',
|
||||
installRoot: payload.installRoot,
|
||||
currentStage: null
|
||||
})
|
||||
|
||||
// Install: show the "launch Hermes" success screen. Update: this is a
|
||||
// hand-off — the installer relaunches the desktop and exits within a
|
||||
// few hundred ms, so routing to success just flashes that screen
|
||||
// before the window closes. Stay on progress until we exit.
|
||||
if ($mode.get() !== 'update') {
|
||||
$route.set('success')
|
||||
}
|
||||
|
||||
break
|
||||
|
||||
case 'failed':
|
||||
$bootstrap.set({
|
||||
...cur,
|
||||
status: 'failed',
|
||||
error: payload.error,
|
||||
currentStage: null
|
||||
})
|
||||
$route.set('failure')
|
||||
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// Update mode is a hand-off, not a user-initiated flow: the desktop already
|
||||
// exited and re-launched us as `--update`. Kick the update immediately so
|
||||
// the user lands on progress, not a redundant "click to update" screen.
|
||||
if ($mode.get() === 'update') {
|
||||
void startUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function startInstall(opts?: { branch?: string }): Promise<void> {
|
||||
const fake = fakeMode()
|
||||
|
||||
if (fake) {
|
||||
void runFakeBoot(fake === 'failure' ? 'failure' : 'install')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Reset before kicking off so a retry from the failure screen clears
|
||||
// the previous run's state.
|
||||
$bootstrap.set(INITIAL)
|
||||
$route.set('progress')
|
||||
await invoke('start_bootstrap', {
|
||||
args: {
|
||||
commit: null,
|
||||
branch: opts?.branch ?? null,
|
||||
include_desktop: true,
|
||||
hermes_home: null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function startUpdate(): Promise<void> {
|
||||
if (fakeMode()) {
|
||||
void runFakeBoot('update')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Update is driven by the desktop handing off (Hermes-Setup.exe --update);
|
||||
// there's no welcome click. Reset + jump straight to progress, then let the
|
||||
// Rust side stream the synthetic update manifest.
|
||||
$bootstrap.set(INITIAL)
|
||||
$route.set('progress')
|
||||
await invoke('start_update')
|
||||
}
|
||||
|
||||
export async function cancelInstall(): Promise<void> {
|
||||
if (fakeMode()) {
|
||||
fakeCancelled = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await invoke('cancel_bootstrap')
|
||||
}
|
||||
|
||||
export async function launchHermesDesktop(): Promise<void> {
|
||||
if (fakeMode()) {throw new Error('Preview mode — launching is disabled.')}
|
||||
const installRoot = $bootstrap.get().installRoot
|
||||
|
||||
if (!installRoot) {throw new Error('no install root')}
|
||||
await invoke('launch_hermes_desktop', { installRoot })
|
||||
}
|
||||
|
||||
export async function openLogDir(): Promise<void> {
|
||||
if (fakeMode()) {return}
|
||||
await invoke('open_log_dir')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dev-only isolated preview ("fake boot")
|
||||
//
|
||||
// Synthesises the manifest + stage/log events Rust normally streams, so the
|
||||
// whole reskin can be reviewed in a plain browser (`npm run dev`):
|
||||
// ?fake=install welcome → [ INSTALL ] → success
|
||||
// ?fake=update auto-runs the granular update flow
|
||||
// ?fake=failure install that fails partway
|
||||
// Gated on import.meta.env.DEV → stripped from the shipped Tauri bundle.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type FakeMode = 'install' | 'update' | 'failure'
|
||||
|
||||
function fakeMode(): FakeMode | null {
|
||||
if (!import.meta.env.DEV || typeof window === 'undefined') {return null}
|
||||
const v = new URLSearchParams(window.location.search).get('fake')
|
||||
|
||||
return v === 'install' || v === 'update' || v === 'failure' ? v : null
|
||||
}
|
||||
|
||||
interface FakeStage {
|
||||
name: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const FAKE_INSTALL_STAGES: FakeStage[] = [
|
||||
{ name: 'system-packages', title: 'System packages' },
|
||||
{ name: 'uv', title: 'uv' },
|
||||
{ name: 'python', title: 'Python environment' },
|
||||
{ name: 'repo', title: 'Hermes repository' },
|
||||
{ name: 'dependencies', title: 'Python dependencies' },
|
||||
{ name: 'node', title: 'Node runtime' },
|
||||
{ name: 'desktop', title: 'Desktop app' }
|
||||
]
|
||||
|
||||
const FAKE_UPDATE_STAGES: FakeStage[] = [
|
||||
{ name: 'handoff', title: 'Preparing to update' },
|
||||
{ name: 'update', title: 'Downloading the latest version' },
|
||||
{ name: 'rebuild', title: 'Rebuilding the desktop app' },
|
||||
{ name: 'install', title: 'Installing the update' }
|
||||
]
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
let fakeRunning = false
|
||||
let fakeCancelled = false
|
||||
|
||||
const fakeStage = (name: string, state: StageState, durationMs?: number, error?: string) =>
|
||||
$bootstrap.set(withStageState($bootstrap.get(), name, state, durationMs, error))
|
||||
|
||||
const fakeLog = (stage: string, line: string) =>
|
||||
$bootstrap.set({ ...$bootstrap.get(), logs: [...$bootstrap.get().logs, { stage, line, stream: 'stdout' }] })
|
||||
|
||||
const fakeFail = (error: string) =>
|
||||
$bootstrap.set({ ...$bootstrap.get(), status: 'failed', error, currentStage: null })
|
||||
|
||||
async function runFakeBoot(kind: FakeMode): Promise<void> {
|
||||
if (fakeRunning) {return}
|
||||
fakeRunning = true
|
||||
fakeCancelled = false
|
||||
|
||||
try {
|
||||
const stages = kind === 'update' ? FAKE_UPDATE_STAGES : FAKE_INSTALL_STAGES
|
||||
|
||||
const cancelled = () => {
|
||||
if (!fakeCancelled) {return false}
|
||||
fakeFail(kind === 'update' ? 'Update cancelled.' : 'Install cancelled.')
|
||||
$route.set('failure')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
$bootstrap.set({
|
||||
...INITIAL,
|
||||
status: 'running',
|
||||
stageOrder: stages.map((s) => s.name),
|
||||
stages: Object.fromEntries(
|
||||
stages.map((s): [string, StageRecord] => [
|
||||
s.name,
|
||||
{ info: { ...s, category: kind, needs_user_input: false }, state: null }
|
||||
])
|
||||
)
|
||||
})
|
||||
$route.set('progress')
|
||||
|
||||
// Blow up midway in the failure preview so the failure screen shows.
|
||||
const failAt = kind === 'failure' ? stages[Math.floor(stages.length / 2)]?.name : null
|
||||
|
||||
for (const s of stages) {
|
||||
if (cancelled()) {return}
|
||||
fakeStage(s.name, 'running')
|
||||
|
||||
const durationMs = 700 + Math.floor(Math.random() * 2200)
|
||||
const lines = Math.max(2, Math.round(durationMs / 450))
|
||||
|
||||
for (let l = 0; l < lines; l++) {
|
||||
await sleep(durationMs / lines)
|
||||
|
||||
if (cancelled()) {return}
|
||||
fakeLog(s.name, `[${s.name}] ${s.title.toLowerCase()} — step ${l + 1}/${lines}…`)
|
||||
}
|
||||
|
||||
if (s.name === failAt) {
|
||||
fakeStage(s.name, 'failed', durationMs, 'Simulated failure for preview.')
|
||||
fakeFail('Simulated failure for preview (fake boot).')
|
||||
$route.set('failure')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
fakeStage(s.name, 'succeeded', durationMs)
|
||||
}
|
||||
|
||||
$bootstrap.set({ ...$bootstrap.get(), status: 'completed', currentStage: null })
|
||||
|
||||
// Install lands on success; update stays on progress (the real updater
|
||||
// relaunches the desktop and exits from there).
|
||||
if (kind !== 'update') {$route.set('success')}
|
||||
} finally {
|
||||
fakeRunning = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Hermes Setup — defer entirely to the desktop's styles.css.
|
||||
*
|
||||
* Rather than re-implement the Hermes design system (and inevitably drift
|
||||
* from it), we import apps/desktop/src/styles.css wholesale. The desktop
|
||||
* is the canonical source of truth for fonts, color tokens, button chrome,
|
||||
* scrollbars, layout utilities, and animations. Any change to the
|
||||
* Hermes look propagates here automatically with no copy-paste maintenance.
|
||||
*
|
||||
* Path resolution caveats:
|
||||
* - Tailwind v4's `@import` resolves relative to this file. The desktop's
|
||||
* `@source '../../../node_modules/...'` declarations therefore re-resolve
|
||||
* against apps/bootstrap-installer/src/. Since both apps live two levels
|
||||
* deep under the same repo root, `../../../node_modules` lands in the
|
||||
* same place. (Verify if either app ever moves.)
|
||||
* - The desktop's `@font-face url('../../../node_modules/...')` references
|
||||
* are baked into the *imported* stylesheet; CSS resolves url()s relative
|
||||
* to the file that contains them, so they continue to point at the
|
||||
* correct node_modules path even from here.
|
||||
*
|
||||
* Follows the OS appearance: the installer has no in-app theme switcher, so
|
||||
* src/theme.ts tracks the Tauri window theme and toggles `.dark` on
|
||||
* <html>. The desktop's runtime applyTheme() normally PAINTS the dark seed
|
||||
* colors inline (its imported :root.dark below only flips the per-mode mix
|
||||
* knobs + neutral chrome), so we supply the Nous *dark* seeds ourselves in the
|
||||
* :root.dark block at the end of this file.
|
||||
*/
|
||||
@import '../../desktop/src/styles.css';
|
||||
|
||||
/* Installer-only additions: a fade-in animation and a warm radial glow
|
||||
for the welcome screen. Everything else inherits from the desktop. */
|
||||
@keyframes hermes-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hermes-fade-in {
|
||||
animation: hermes-fade-in 0.45s ease-out both;
|
||||
}
|
||||
|
||||
.hermes-glow {
|
||||
background: radial-gradient(
|
||||
ellipse at center,
|
||||
color-mix(in srgb, var(--ui-warm) 18%, transparent) 0%,
|
||||
transparent 60%
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* Dark appearance — Nous dark seeds.
|
||||
*
|
||||
* The imported desktop :root.dark only flips the per-mode mix knobs + neutral
|
||||
* chrome; the seed COLORS are normally painted at runtime by the desktop's
|
||||
* applyTheme(). The installer has no theme runtime, so we mirror them here from
|
||||
* apps/desktop/src/themes/presets.ts (nousTheme.darkColors). The whole
|
||||
* --ui-* / --dt-* chain in the imported stylesheet derives from these seeds, so
|
||||
* flipping them is enough — we only additionally override the few tokens
|
||||
* applyTheme() sets inline that DON'T derive from a seed (primary-foreground on
|
||||
* the cream accent, destructive). Unlayered on purpose so it wins over the
|
||||
* imported @layer base :root light seeds. Keep in sync with nousTheme.darkColors
|
||||
* if that palette is retuned.
|
||||
*/
|
||||
:root.dark {
|
||||
color-scheme: dark;
|
||||
|
||||
--theme-foreground: #ffe6cb;
|
||||
--theme-primary: #ffe6cb;
|
||||
--theme-secondary: #1b45a4;
|
||||
--theme-accent-soft: #1540b1;
|
||||
--theme-midground: #0053fd;
|
||||
--theme-warm: #ffe6cb;
|
||||
--theme-background-seed: #0d2f86;
|
||||
--theme-sidebar-seed: #09286f;
|
||||
--theme-card-seed: #12378f;
|
||||
--theme-elevated-seed: #123a96;
|
||||
--theme-bubble-seed: #143b91;
|
||||
|
||||
/* Non-derived shadcn tokens applyTheme() paints inline (Nous dark values). */
|
||||
--dt-primary-foreground: #0d2f86;
|
||||
--dt-destructive: #c0473a;
|
||||
--dt-destructive-foreground: #fef2f2;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { getCurrentWindow, type Theme } from '@tauri-apps/api/window'
|
||||
|
||||
/*
|
||||
* OS appearance follower.
|
||||
*
|
||||
* The installer ships no in-app theme switcher, so it tracks the system the
|
||||
* way the desktop overlays do. Two Tauri realities shape this:
|
||||
*
|
||||
* 1. The strict `script-src 'self'` CSP (tauri.conf.json) forbids an inline
|
||||
* pre-paint <script> in index.html, so the earliest hook we get is this
|
||||
* bundled module.
|
||||
* 2. The webview's `prefers-color-scheme` is not reliable across WebView2 /
|
||||
* WebKitGTK. The authoritative signal in a Tauri window is the window's
|
||||
* OWN theme — `getCurrentWindow().theme()` + `onThemeChanged` — so we read
|
||||
* that and fall back to the media query only outside Tauri (e.g. plain
|
||||
* `vite preview`).
|
||||
*
|
||||
* We only flip the `.dark` class + `color-scheme`; the dark seed values live in
|
||||
* styles.css (:root.dark), mirroring apps/desktop's applyTheme() palette.
|
||||
*/
|
||||
|
||||
const prefersDark = (): boolean => window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
function paint(theme: Theme): void {
|
||||
const dark = theme === 'dark'
|
||||
const root = document.documentElement
|
||||
root.classList.toggle('dark', dark)
|
||||
root.style.colorScheme = dark ? 'dark' : 'light'
|
||||
}
|
||||
|
||||
// Best-effort synchronous first paint from the media query so the very first
|
||||
// frame is already in the right mode. Refined below by the authoritative Tauri
|
||||
// window theme once its IPC resolves.
|
||||
paint(prefersDark() ? 'dark' : 'light')
|
||||
|
||||
/** Adopt the Tauri window theme and keep tracking live OS appearance changes. */
|
||||
export async function watchTheme(): Promise<void> {
|
||||
try {
|
||||
const win = getCurrentWindow()
|
||||
const current = await win.theme()
|
||||
|
||||
if (current) {
|
||||
paint(current)
|
||||
}
|
||||
|
||||
await win.onThemeChanged(({ payload }) => paint(payload))
|
||||
} catch {
|
||||
// Non-Tauri context (e.g. `vite preview`): keep the media query live.
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => paint(e.matches ? 'dark' : 'light'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"esModuleInterop": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'node:path'
|
||||
|
||||
// Hermes Setup — Tauri-targeted Vite config.
|
||||
//
|
||||
// Port 5175 keeps us out of the way of:
|
||||
// web (vite default 5173)
|
||||
// apps/desktop dev (5174 per its package.json)
|
||||
//
|
||||
// `clearScreen: false` is the Tauri convention — they spawn vite as a child
|
||||
// process and want our errors to stay visible.
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 5175,
|
||||
strictPort: true,
|
||||
host: host || '127.0.0.1',
|
||||
hmr: host
|
||||
? {
|
||||
protocol: 'ws',
|
||||
host,
|
||||
port: 5176
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// Don't watch the Rust side — tauri-cli handles it.
|
||||
ignored: ['**/src-tauri/**']
|
||||
}
|
||||
},
|
||||
build: {
|
||||
target: 'esnext',
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,210 @@
|
||||
# Desktop Engineering Guide
|
||||
|
||||
How to build Hermes Desktop well. This is a judgment guide, not an inventory —
|
||||
it teaches the invariants and the reasoning behind them so a change fits the app
|
||||
even as files move. Read it with the repository `AGENTS.md` (root rules still
|
||||
apply) and [`DESIGN.md`](./DESIGN.md) for the visual and interaction contract.
|
||||
|
||||
When a rule here and the code disagree, trust the code and fix whichever is
|
||||
wrong — but never break an invariant to make a change easier.
|
||||
|
||||
## What this app is
|
||||
|
||||
Desktop is its own native chat surface. It is not the browser dashboard and it
|
||||
does not embed the TUI. Three parties, each authoritative for one thing:
|
||||
|
||||
- **Electron** owns the machine: process lifecycle, native filesystem/git/
|
||||
windows, install/update, and a narrow, typed capability bridge.
|
||||
- **The renderer** owns the experience: navigation, presentation, and ephemeral
|
||||
interaction state.
|
||||
- **The agent backend** owns the work: sessions, tools, model calls, streaming.
|
||||
|
||||
Keep the seams clean. The renderer never reaches for Node or Electron directly;
|
||||
native power arrives through a deliberate capability, not a general escape hatch.
|
||||
Agent behavior lives behind the gateway, never reimplemented in React. When a
|
||||
change blurs a seam, that is the smell — fix the seam, don't widen it.
|
||||
|
||||
## Decide state by authority
|
||||
|
||||
The first question for any piece of state is *who is allowed to be right about
|
||||
it*, not where it is convenient to store it. Put state with its authority:
|
||||
|
||||
- The **backend** is authoritative for anything another Hermes surface can also
|
||||
change. Treat the renderer's copy as a cache of that truth.
|
||||
- **Electron** is authoritative for machine and runtime facts.
|
||||
- The **renderer** owns only what is purely about this window's presentation.
|
||||
|
||||
From that, everything else follows: shared renderer state lives in small stores
|
||||
owned by the feature that owns the concern; request-shaped server data that wants
|
||||
invalidation lives in the query layer; short-lived interaction detail stays in
|
||||
the component; hot coordination that must not paint stays in a ref. Reach for the
|
||||
narrowest home that still lets the state be correct. A new global store is a
|
||||
claim that many distant surfaces need it — earn that claim.
|
||||
|
||||
Persisted state must declare its scope in its own key: is this global, or does it
|
||||
belong to a connection, a profile, a stored session, a project, or a window?
|
||||
Getting the scope wrong is how one profile's setting bleeds into another.
|
||||
|
||||
## Identity is not incidental
|
||||
|
||||
Sessions have more than one identity, and conflating them is a recurring source
|
||||
of "session not found" and vanishing history. Reason about which identity a
|
||||
surface needs: durable navigation and anything the user pins or persists key off
|
||||
the stable/durable identity; live streaming keys off the runtime identity; state
|
||||
that must outlive compression keys off the lineage root. Keep the mapping between
|
||||
them explicit and translate at the boundary rather than passing the wrong id
|
||||
inward.
|
||||
|
||||
## Server truth is cached, not owned
|
||||
|
||||
The renderer paints from a cache of backend truth, so it must reconcile, not
|
||||
assume:
|
||||
|
||||
- **Merge, don't clobber.** A refresh is new information layered over what you
|
||||
already know, not a replacement that can drop live or pinned rows.
|
||||
- **Be optimistic, then honest.** Direct manipulation should paint immediately
|
||||
from a snapshot; a failed write rolls back visibly and an authoritative
|
||||
refresh gets the last word.
|
||||
- **Guard against the past.** Async results can arrive out of order; a stale
|
||||
response must never overwrite newer intent. Generation counters and request
|
||||
tokens exist for this.
|
||||
- **Isolate the foreground.** Only the surface the user is looking at may publish
|
||||
into the shared view; background work updates its own cache quietly.
|
||||
- **Coalesce noise, flush signal.** Batch high-frequency cosmetic updates, but
|
||||
let terminal transitions (a turn finishing, needing input, failing) reach the
|
||||
user immediately.
|
||||
- **Preserve reference identity on no-ops.** Handing React a fresh array that
|
||||
contains the same data re-renders expensive trees for nothing.
|
||||
|
||||
## Switching context is a re-home, not a reboot
|
||||
|
||||
Changing profile, connection, or mode is a workspace switch, not a cold start.
|
||||
The shell and whatever the user was doing stay put; only the gateway-bound view
|
||||
is cleared and repopulated, and the previous context must not leak into the next
|
||||
one. Reserve the full-screen boot/connecting experience for a genuinely unusable
|
||||
backend.
|
||||
|
||||
There are three distinct switch shapes, and conflating them is the classic bug:
|
||||
|
||||
- A **connection/mode apply** (local ↔ remote ↔ cloud) is the soft re-home:
|
||||
shell mounted, gateway-bound stores explicitly wiped, then reconnect. Query
|
||||
invalidation alone cannot evict live session stores — wipe them.
|
||||
- A **runtime home change** (switching the underlying `HERMES_HOME` profile) is
|
||||
a hard re-home: the window legitimately reloads and state resets by remount.
|
||||
- A **live profile swap** in the same window activates another profile's socket
|
||||
while background profiles keep streaming; lists merge rather than wipe, and
|
||||
only an explicit user selection starts a fresh foreground draft.
|
||||
|
||||
Treating a soft switch as hard flickers the app; treating a hard one as soft
|
||||
strands stale rows. After any swap, the active socket, active profile, and
|
||||
connection atoms must agree, or REST and filesystem calls route to the wrong
|
||||
backend.
|
||||
|
||||
## Cross everything as an observable ladder
|
||||
|
||||
Desktop lives at the seams: versions, profiles, local vs remote vs cloud,
|
||||
partially installed runtimes, stale caches, older backends. The durable technique
|
||||
for all of it is the same — an ordered ladder of candidates:
|
||||
|
||||
1. Precedence is written down, in one place, as data or a pure function.
|
||||
2. A candidate is trusted only after it is validated at the right boundary.
|
||||
Existence is not proof; probe what you're about to rely on.
|
||||
3. A failed *read* falls to the next rung; a failed *authoritative write*
|
||||
surfaces or rolls back rather than silently retargeting.
|
||||
4. A missing capability and a transient failure are different: the first may
|
||||
enable a compatibility path or a disabled state; the second should retry.
|
||||
5. Retries are bounded and end in a real recovery affordance — never an infinite
|
||||
spinner or a hot loop.
|
||||
6. One resolver owns each policy so every caller gets the same answer. Scatter is
|
||||
how two call sites drift apart.
|
||||
|
||||
This is the shape of backend discovery, command/version fallbacks, connection and
|
||||
auth resolution, workspace-cwd selection, capability detection, and preview
|
||||
normalization alike. Learn the shape, not a snapshot of the current rungs.
|
||||
|
||||
Two auth-flavored corollaries worth naming because they are easy to get wrong:
|
||||
|
||||
- **One-time credentials are never reused.** An OAuth gateway connection mints a
|
||||
fresh WebSocket ticket on every dial and never falls back to the cached URL.
|
||||
Only a confirmed 401/403 (or an explicitly tagged auth rejection) means
|
||||
reauthentication; timeout, network, malformed-response, and server failures
|
||||
remain connectivity errors. Only long-lived token/local auth may reuse a
|
||||
cached URL as a lower rung.
|
||||
- **A connection test must exercise the leg you'll actually use.** An HTTP
|
||||
status probe passing while the WebSocket/auth leg fails is a false positive
|
||||
that ships as "it said connected but nothing works."
|
||||
|
||||
## Compatibility without carrying the past forever
|
||||
|
||||
Desktop and its runtime update on separate clocks, so a change can meet an older
|
||||
backend. Keep those users working: preserve the current feature, keep the
|
||||
fallback narrow and tied to an identified older runtime, and cover it with a
|
||||
test. A fallback that quietly degrades the feature it's meant to protect is worse
|
||||
than the crash it replaced.
|
||||
|
||||
## Keep the waist narrow, grow at the edges
|
||||
|
||||
The root contribution rubric governs here too. New capability should arrive at
|
||||
the smallest surface that solves it: extend what exists, add a feature locally,
|
||||
lean on an existing seam — before you invent a framework. The shell's internal
|
||||
registries are composition seams, not a public plugin ABI; do not build a
|
||||
universal extension system, a manifest, or a plugin adapter for a single
|
||||
consumer. Design a shared contract only once more than one real consumer proves
|
||||
its shape. "Plugin" means several unrelated things across Hermes — do not assume
|
||||
one surface's extension model runs in another.
|
||||
|
||||
When the new capability is an **agent-callable** one — a tool that acts on this
|
||||
renderer (open a pane, read the in-app browser, react to a message) — it is a
|
||||
property of the SESSION's client, not of the backend host. Wire its
|
||||
availability off the session source the app already sends on `session.create`
|
||||
(`source: 'desktop'`), never off an env var on the backend process: that
|
||||
process might be a remote or cloud gateway this app merely connected to. See
|
||||
the root AGENTS.md, "Surface capability is a property of the SESSION."
|
||||
|
||||
## Respect the person using it
|
||||
|
||||
Design and engineering meet at intent. The user's attention and context are
|
||||
sacred:
|
||||
|
||||
- Never navigate, move focus, or open a surface because something *happened* in
|
||||
the background. Offer; don't hijack.
|
||||
- The states around loading are distinct experiences — empty, loading,
|
||||
reconnecting, degraded/stale, and exhausted-recovery each deserve their own
|
||||
honest copy and their own way out.
|
||||
- Keyboard ownership follows focus. The focused surface wins its keys; one
|
||||
cancel gesture does exactly one thing.
|
||||
- Expensive, stateful surfaces (terminals, live tools) stay alive when hidden.
|
||||
Visibility is not lifecycle.
|
||||
|
||||
## Make it feel instant
|
||||
|
||||
Performance is a feature the user feels, especially in drag, resize, scroll,
|
||||
typing, streaming, and terminals. The principles are timeless even as the code
|
||||
changes: keep hot-path state local or narrowly derived; don't subscribe heavy
|
||||
trees to per-frame updates; coalesce pointer work; avoid reading layout right
|
||||
after writing style; and don't mount expensive content mid-gesture. Prove speed
|
||||
against realistic content — a fast empty demo proves nothing about a long
|
||||
transcript. If motion is masking latency, remove the motion, don't tune it.
|
||||
|
||||
## Testing as a habit of proof
|
||||
|
||||
Test the behavior that would actually break a user, not a snapshot of today's
|
||||
data. Favor invariants over frozen values. Exercise the real path for anything
|
||||
at a seam — resolver precedence and its failure rungs, identity and scope
|
||||
boundaries, optimistic rollback and stale-response ordering, and both sides of a
|
||||
local/remote adapter with its profile routing intact. Match how the suite is
|
||||
actually run rather than inventing a command; when in doubt, read the scripts.
|
||||
|
||||
## The taste test before you hand off
|
||||
|
||||
- Does every piece of state live with its authority, at the narrowest scope?
|
||||
- Would a background event ever steal the foreground or the user's focus?
|
||||
- Does each resolver have one home, a validated ladder, and a bounded, recoverable
|
||||
end?
|
||||
- Do local, remote, and profile routing still agree?
|
||||
- Does async failure leave a usable UI and a way forward?
|
||||
- Do hot interactions stay cheap under realistic load?
|
||||
- Does the change pass the [`DESIGN.md`](./DESIGN.md) checklist and update all
|
||||
locales?
|
||||
|
||||
If any answer is "not sure," that's the part to go verify.
|
||||
@@ -0,0 +1,364 @@
|
||||
# Desktop Design System
|
||||
|
||||
AITURK distribution: `aiturk` is the default first-party theme. Its turquoise
|
||||
accent is defined in `src/themes/presets.ts`; components consume theme tokens.
|
||||
`BrandMark` renders `public/aiturk.svg`. Keep the original theme presets intact.
|
||||
|
||||
Conventions for the Electron desktop app (`apps/desktop`). Read this before
|
||||
adding a component, overlay, or style. The rule of thumb: **one source per
|
||||
concern, tokens over literals, flat over boxed.** If you reach for a raw color,
|
||||
a one-off shadow, a bespoke button, or a hardcoded `px-*` on a control — stop,
|
||||
there's already a primitive for it.
|
||||
|
||||
This file owns the visual and interaction contract. Read
|
||||
[`AGENTS.md`](./AGENTS.md) for architecture, state, resolver, transport, and
|
||||
testing rules.
|
||||
|
||||
This doc contains two kinds of content, maintained differently:
|
||||
|
||||
- **Principles** (flatness, intent, feedback, motion, cancellation) are durable.
|
||||
They hold as components come and go.
|
||||
- **Named contracts** (tokens, `Button` variants, primitive names) are the
|
||||
design system's current API. They are maintained *with* the code: if you
|
||||
change a primitive, token, or variant, update its entry here **in the same
|
||||
change** — a stale name in this file is a bug, exactly like a stale type.
|
||||
|
||||
When a rule and the code disagree, fix whichever is wrong rather than forking a
|
||||
one-off at the call site.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Flat, not boxed.** No card-in-card, no divider borders inside a panel.
|
||||
Group with whitespace and a single hairline, never nested rounded boxes.
|
||||
2. **Borderless elevation for floating panels.** Overlays float on
|
||||
`shadow-nous` + a `--stroke-nous` hairline, not thick framed boxes. In-panel
|
||||
structure may use token hairlines sparingly.
|
||||
3. **One primitive per concern.** One `Button`, one set of control variants,
|
||||
one `SearchField`, one `Loader`, one `ErrorState`. Migrate onto them; don't
|
||||
fork.
|
||||
4. **Tokens, not literals.** Reference CSS vars (`--ui-*`, `--shadow-nous`,
|
||||
`--theme-*`), never raw hex / ad-hoc rgba in components.
|
||||
5. **Style lives in the primitive.** Variants and sizes own padding, radius,
|
||||
color, chrome. Call sites pass a `variant`/`size`, not `className` overrides
|
||||
that re-specify those.
|
||||
6. **Intent before automation.** Surface useful actions and previews, but do not
|
||||
open panes, move focus, or navigate because a tool happened to produce
|
||||
something.
|
||||
7. **Immediate feedback.** Direct manipulation updates the view first. Network
|
||||
or disk persistence reconciles afterward and rolls back visibly on failure.
|
||||
|
||||
## Information architecture
|
||||
|
||||
- **Chat is the home surface.** The transcript and composer stay primary; tools,
|
||||
previews, files, review, and terminal complement the conversation.
|
||||
- **Pages are durable destinations.** Chat, Skills, Messaging, and Artifacts
|
||||
remain in shell chrome. Do not hide a distinct product noun inside an
|
||||
unrelated page.
|
||||
- **Route overlays are short tasks.** Settings, Command Center, Cron, Profiles,
|
||||
Agents, and Starmap render as `OverlayView` cards and return to the previous
|
||||
route on close. Model/session pickers and dialogs layer above the current
|
||||
surface; they are not navigation stacks.
|
||||
- **Panes are working context.** Preview, files, review, and terminal remain
|
||||
attached to the current task. Their state survives temporary hiding and chat
|
||||
switches where the underlying tool is meant to persist.
|
||||
- **One action, one home.** A command may have keyboard, palette, and visible
|
||||
affordances, but they invoke the same action and state. Do not fork behavior
|
||||
per entry point.
|
||||
- **Projects own workspace cwd.** Use Sidebar → Projects for local folders and
|
||||
worktrees; do not reintroduce a per-session/right-sidebar folder-picker flow.
|
||||
|
||||
Navigation must preserve context. A background session finishing, a tool result
|
||||
arriving, or a project refresh may update badges and cached data; it must not
|
||||
replace the foreground transcript or steal focus.
|
||||
|
||||
## Surfaces & elevation
|
||||
|
||||
Floating panels (base `Dialog`, route overlays, boot/install/update surfaces,
|
||||
model-picker, onboarding, prompt overlays, notifications) use:
|
||||
|
||||
```
|
||||
shadow-nous /* downward-weighted, layered contact→ambient falloff */
|
||||
border-(--stroke-nous) /* currentColor hairline, theme-adaptive */
|
||||
```
|
||||
|
||||
Both are CSS vars in `src/styles.css` — tune in one place, everything inherits.
|
||||
Don't add per-overlay `shadow-[…]` or `border-(--ui-stroke-secondary)`
|
||||
one-offs; if elevation needs to change, change the token.
|
||||
|
||||
Menus and popovers use their own shared `shadow-md` +
|
||||
`--ui-stroke-secondary` primitive treatment. Drag affordances may use tokenized
|
||||
dashed targets and local blur. These are semantic surface classes, not licenses
|
||||
for call-site shadow or border inventions.
|
||||
|
||||
## Stroke & color tokens
|
||||
|
||||
| Token | Use |
|
||||
| --- | --- |
|
||||
| `--ui-stroke-primary…quaternary` | hairlines, in descending strength |
|
||||
| `--ui-stroke-tertiary` | the default in-panel divider / list hairline — and every bordered surface in the transcript |
|
||||
| `--stroke-nous` | the overlay hairline (pairs with `shadow-nous`) |
|
||||
| `--ui-text-primary / -secondary / -tertiary` | text hierarchy |
|
||||
| `--ui-bg-quaternary` | soft control fill (secondary button) |
|
||||
| `--ui-widget-surface-background` | fill for inline chat widgets (`WIDGET_SHELL_CLASS`) |
|
||||
| `--chrome-action-hover` | hover fill for quiet controls |
|
||||
| `--theme-primary`, `--ui-accent` | brand/accent |
|
||||
|
||||
Never hardcode `border-gray-*`, `bg-white`, `text-black`, etc. The white tile in
|
||||
`BrandMark` is the one sanctioned literal (the mark needs a fixed backdrop).
|
||||
|
||||
## Buttons — one component
|
||||
|
||||
`src/components/ui/button.tsx` is the single source. Pick a `variant` + `size`;
|
||||
do **not** pass `h-*`, `px-*`, `py-*`, or icon-size overrides.
|
||||
|
||||
**Variants:** `default` (primary), `destructive`, `secondary` (soft fill —
|
||||
the default non-primary look), `outline` (transparent + 1px inset ring, no
|
||||
fill/shadow), `ghost`, `link`, `text` (boxless quiet inline — "Cancel",
|
||||
"Clear"), `textStrong` (bold underlined inline affordance — "Change",
|
||||
"Open logs").
|
||||
|
||||
**Sizes:** `default`, `xs`, `sm`, `lg`, `inline` (flush, zero box — for buttons
|
||||
that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), `micro`
|
||||
(status-stack/table-footers), and the icon family `icon` / `icon-xs` /
|
||||
`icon-sm` / `icon-lg` / `icon-titlebar`.
|
||||
|
||||
**Tooltips only when hover teaches something new.** `<Tip>` is for discovery,
|
||||
not a tax on every icon. Ask: does hover reveal something the user cannot
|
||||
already see or infer? If not, skip the tip; keep an `aria-label` for a11y.
|
||||
|
||||
Tip unlabeled chrome when the job (or a keybind / truncated path / host /
|
||||
other detail) is not already on screen — toolbar / titlebar / statusbar icons,
|
||||
`TipKeybindLabel` shortcuts, ownership chips, unlabeled icon grids.
|
||||
|
||||
Do **not** tip:
|
||||
|
||||
- Menu triggers (kebabs / ⋯ / `ActionsMenu` / `DropdownMenuTrigger`) — the
|
||||
affordance is "open menu"; verbs live in the menu. Never tip
|
||||
`"Actions for ${row title}"` / `"Project actions"` / `"Actions"`.
|
||||
- Close / dismiss X buttons — the glyph is the label (`aria-label` only).
|
||||
- Controls whose visible label already says what the tip would ("click to…",
|
||||
paraphrases of the same words, timer labels restating "Running").
|
||||
|
||||
Never use native HTML `title=` on buttons — unstyled, ~500ms OS delay, clashes
|
||||
with the themed `Tip`. `src/components/ui/__tests__/no-native-title.test.ts`
|
||||
fails on any `<button>` / `<Button>` that still carries `title=`.
|
||||
|
||||
**Tooltip timing.** A hover is not a click — the cursor crosses triggers on
|
||||
the way somewhere else. `Tip` waits 200ms before the first open so a sweep
|
||||
does not flash a trail. After a tip has opened the page is warm: the next
|
||||
trigger within 300ms opens instantly. The cooldown starts on close, so a
|
||||
hover a second later waits again. Close is immediate. `OverflowTip` stays
|
||||
on its own longer delay (list titles must not trail while scanning).
|
||||
|
||||
**Keybind hints in tooltips.** On a tipped button bound to a rebindable hotkey,
|
||||
use `<TipKeybindLabel actionId="..." />` — it reads the i18n label and the
|
||||
current combo from `$bindings`. Pass `text={...}` only when the label is
|
||||
context-dependent (e.g. "Show" / "Hide"). Never hardcode combos; always use
|
||||
`useKeybindHint` or `TipKeybindLabel`.
|
||||
|
||||
Notes:
|
||||
- Text buttons are square (no radius) and sized by padding + line-height (no
|
||||
fixed heights). Only icon buttons carry the shared 4px radius.
|
||||
- SVGs inherit `size-3.5` (`size-3` at `xs`). Don't re-set icon size.
|
||||
- Polymorph with `asChild` when the button must render as a link/Slot.
|
||||
|
||||
## Badges — one component
|
||||
|
||||
`src/components/ui/badge.tsx`. Variants: `default` (tinted primary), `muted`,
|
||||
`warn`, `destructive`, `outline`, `solid` (primary fill — icon-corner counts).
|
||||
Sizes: `default`, `xs`, `overlay` (titlebar glyph counts).
|
||||
|
||||
## Form controls
|
||||
|
||||
- **`controlVariants`** (`src/components/ui/control.ts`) is the shared shape for
|
||||
`Input` / `Textarea` / `SelectTrigger`. New text-entry controls compose it.
|
||||
- **`SearchField`** — borderless, underline-on-focus, auto-width. The only
|
||||
search input. Don't build boxed search bars; don't wrap it in a bordered tile.
|
||||
Empty lists hide their search field.
|
||||
- **`SegmentedControl`** — the choice control for small mutually-exclusive sets
|
||||
(color mode, tool-call display, usage period). Replaces radio piles and
|
||||
pill rows.
|
||||
- **`Switch`** (`size="xs"`) — bare, with `aria-label`. No bordered text wrapper.
|
||||
|
||||
## Layout
|
||||
|
||||
- **Gutters:** `PAGE_INSET_X` (`src/app/layout-constants.ts`) for page side
|
||||
padding; `PAGE_INSET_NEG_X` to bleed a child to the edge. Don't hardcode
|
||||
`px-6`/`px-8` on pages.
|
||||
- **Master/detail overlays:** `OverlaySplitLayout` + `OverlaySidebar` /
|
||||
`OverlayMain`. Cron, profiles, etc. ride this — don't rebuild a titlebar
|
||||
shell.
|
||||
- **Rows:** `ListRow` (settings `primitives.tsx`) for label/description/action
|
||||
rows. Flat, flush-left; no per-row indentation that fights flush headers.
|
||||
- **No dividers between rows** unless the list genuinely needs them; prefer
|
||||
spacing. When you do need one, it's a single `--ui-stroke-tertiary` hairline.
|
||||
|
||||
## Feedback & empty/error/loading states
|
||||
|
||||
- **Loading:** `Loader` (`src/components/ui/loader.tsx`) — animated math/ascii
|
||||
curves (`lemniscate-bloom` for long ops). Never ship the literal text
|
||||
"Loading…".
|
||||
- **Errors:** `ErrorState` + the canonical `ErrorIcon` (no bg chip). One look
|
||||
for the React boundary, in-dialog errors, and the boot-failure banner. Pass
|
||||
nodes for title/description so Radix `DialogTitle`/`Description` can flow
|
||||
through for a11y.
|
||||
- **Logs:** `LogView` — no bg, hairline border, tight padding, small mono.
|
||||
Every place we surface raw logs uses it.
|
||||
- **Empty:** `EmptyState` for plain page bodies; `PanelEmpty` for overlay
|
||||
master/detail empties with an icon and action. Don't hand-roll a third
|
||||
centered empty.
|
||||
- **Confirmation:** `ConfirmDialog` is the only way we ask "are you sure". It
|
||||
opens focused on Confirm, so `Enter` confirms and `Esc` cancels, and it owns
|
||||
the pending → done → close beat and the inline error — a call site passes an
|
||||
async `onConfirm` and nothing else. A third way out (e.g. "Remove from
|
||||
sidebar" beside "Delete worktree") goes in the one `secondaryAction` slot.
|
||||
Never `window.confirm`: it's an unstyled blocking Chromium modal. A handler
|
||||
that wants the answer inline instead of a mounted dialog calls `confirm()`
|
||||
from `src/store/confirm.ts`, which renders this same primitive through the
|
||||
single `ConfirmHost` at the shell — the way `notify()` backs notifications.
|
||||
|
||||
## Chat, tools & boot surfaces
|
||||
|
||||
- The transcript and composer are built on `@assistant-ui/react`. Extend the
|
||||
existing components under `src/components/assistant-ui` and
|
||||
`src/app/chat/composer`; do not fork a second markdown, message, tool-call, or
|
||||
approval renderer for one feature.
|
||||
- **Inline widgets** — a tool result that renders as a panel the user reads or
|
||||
acts on (clarify, artifact card) wears `WIDGET_SHELL_CLASS`
|
||||
(`src/components/chat/widget-shell.ts`): shared radius, the
|
||||
`--ui-widget-surface-background` fill, no border. Its actions sit *outside*
|
||||
the panel, below it. Don't give one widget its own radius or fill.
|
||||
- Bordered surfaces in the transcript (tables, fences, callouts, attachments)
|
||||
use `--ui-stroke-tertiary`. Not `border-border` — that's the app-wide
|
||||
default and reads too hot against the thread.
|
||||
- A tool result may expose an inline action that opens a preview. It must not
|
||||
open the rail automatically.
|
||||
- Install, onboarding, connecting, boot failure, and reauthentication are
|
||||
distinct states with shared visual primitives. Preserve their recovery
|
||||
semantics when unifying appearance.
|
||||
- Respect `AppShell` overlay ownership. Persistent terminal/content layers,
|
||||
route overlays, dialogs, and boot surfaces must not compete through ad-hoc
|
||||
z-index literals. Pick a rung of the ladder in `styles.css` instead —
|
||||
`--z-modal-backdrop` / `--z-modal` / `--z-modal-popover`, `--z-over-modal`
|
||||
(toasts, tooltips, command surfaces) and `--z-over-modal-content`,
|
||||
`--z-switcher-backdrop` / `--z-switcher`, then the boot chain
|
||||
`--z-connecting` → `--z-onboarding` → `--z-setup` → `--z-crash`. Plain
|
||||
`z-10`/`z-20` are still right for stacking *within* one component.
|
||||
|
||||
## Iconography & brand
|
||||
|
||||
- **Tabler** is the default component/chrome set. Import its curated aliases and
|
||||
`iconSize` scale from `src/lib/icons.ts`; do not import icon packages directly
|
||||
in feature code.
|
||||
- **`Codicon`** is the compact editor/tool/status vocabulary. Use
|
||||
`src/components/ui/codicon.tsx`, including `codiconIcon()` where a
|
||||
Tabler-shaped component is required.
|
||||
- Pick the vocabulary by semantic context and reuse the existing icon for an
|
||||
action. Do not introduce a third icon set or mix styles within one control
|
||||
group.
|
||||
- **`BrandMark`** (`src/components/brand-mark.tsx`) is the brand glyph — the
|
||||
`nous-girl` mark on a white tile, softly rounded, identical in light/dark.
|
||||
It replaced scattered Sparkles glyphs in updates / onboarding / about. Use it
|
||||
for hero/brand moments; don't reintroduce decorative star/sparkle icons.
|
||||
|
||||
## Motion
|
||||
|
||||
- Quick, functional transitions (~100ms on controls). Respect
|
||||
`prefers-reduced-motion` for anything beyond a fade.
|
||||
- Choreographed exits (e.g. onboarding's "matrix" fade-down) stagger per-element
|
||||
then settle the surface — the outer container's fade is *delayed* so it
|
||||
doesn't swallow the inner animation. Don't let a global fade race the detail.
|
||||
- Motion follows state; it never delays state. Selection, drag targets, cancel,
|
||||
and pressed feedback paint in the current frame.
|
||||
- Do not animate layout geometry with `transition-all` on a hot interaction.
|
||||
Name the properties, avoid backdrop-filter repaints during movement, and
|
||||
remove animation before masking a performance problem.
|
||||
|
||||
## Direct manipulation & performance
|
||||
|
||||
The app should feel instant under real load — long transcripts, several panes,
|
||||
live streams. Design toward that:
|
||||
|
||||
- Direct manipulation paints first; persistence reconciles after and rolls back
|
||||
visibly on failure.
|
||||
- Keep interaction feedback cheap: hot-path state stays local or narrowly
|
||||
derived, not wired into heavy trees; pointer work coalesces per frame.
|
||||
- One drop region has one visual owner, and drop targets speak one affordance
|
||||
language across files, sessions, tabs, and panes. Overlapping targets resolve
|
||||
to the active one instead of stacking overlays.
|
||||
- Forgiving geometry beats pixel-perfect triggers; edge actions live near their
|
||||
edge, not clustered in the center.
|
||||
- Expensive stateful surfaces stay mounted when hidden. Visibility is not
|
||||
lifecycle.
|
||||
|
||||
Prove speed with realistic content. A fast empty-state demo says nothing about a
|
||||
long transcript or a busy terminal.
|
||||
|
||||
## Keyboard & cancellation
|
||||
|
||||
- Keyboard ownership follows focus. The focused surface wins its keys; shell
|
||||
shortcuts must not steal a terminal's or editor's bindings.
|
||||
- Register global shortcuts through the shared layer, not ad-hoc listeners.
|
||||
- One cancel gesture does one thing: cancel the active interaction, or close the
|
||||
topmost dismissable surface — never both, never the control underneath.
|
||||
- Cancellation is synchronous in the UI even if cleanup is async: overlays,
|
||||
cursors, and pending gesture state clear at once.
|
||||
- Flows that deliberately cannot be dismissed (install/onboarding, destructive
|
||||
confirmation) must make that explicit.
|
||||
|
||||
## i18n
|
||||
|
||||
- Every user-facing string goes through `useI18n()` (`src/i18n/context.tsx`).
|
||||
No literals in JSX.
|
||||
- **Update all locales together** — `en`, `ja`, `zh`, `zh-hant`. A string change
|
||||
in `en.ts` that skips the others is a regression (drifted punctuation,
|
||||
stale labels). Keep trailing-punctuation and tone consistent across all four.
|
||||
|
||||
## State (TypeScript)
|
||||
|
||||
The detailed state contract lives in the scoped
|
||||
[`AGENTS.md`](./AGENTS.md). Visual code follows these essentials:
|
||||
|
||||
- Shared/cross-component state → small **nanostores**, not prop-drilling.
|
||||
Each feature owns its atoms; shared atoms live in `src/store`.
|
||||
- Rendering components subscribe with `useStore`; non-render actions read with
|
||||
`$atom.get()`.
|
||||
- Subscribe to derived coarse facts instead of high-frequency source atoms when
|
||||
the component does not render the full value.
|
||||
- Colocated action modules over god hooks. A hook owns one narrow job.
|
||||
- Keep persistence beside the atom that owns it. Route roots stay thin.
|
||||
- Prefer `interface` for public props; extend React primitives
|
||||
(`React.ComponentProps<'button'>`, `Omit<…>`).
|
||||
|
||||
## Affordances
|
||||
|
||||
- `cursor-pointer` at the primitive level (Button, dropdown/select) — don't
|
||||
hardcode it per call site.
|
||||
- Global focus-ring reset; titlebar actions have no active-background state.
|
||||
- `Esc` closes every dismissable overlay/dialog (install/onboarding excluded);
|
||||
close is an x-icon, not the word "Close".
|
||||
|
||||
## Before you add something — checklist
|
||||
|
||||
- [ ] Reuse a primitive (`Button`, `SearchField`, `SegmentedControl`,
|
||||
`ListRow`, `Loader`, `ErrorState`, `LogView`, `ConfirmDialog`) instead of
|
||||
forking one?
|
||||
- [ ] Tokens (`--ui-*`, `shadow-nous`, `--stroke-nous`) — zero raw colors /
|
||||
one-off shadows?
|
||||
- [ ] No `className` overriding a primitive's padding / size / radius / chrome?
|
||||
- [ ] Tips only where hover teaches something new (no kebab / menu-trigger
|
||||
tips; unlabeled chrome that needs discovery gets `<Tip>` + `aria-label`)?
|
||||
- [ ] No native `title=` on buttons?
|
||||
- [ ] Keybind hints on tipped buttons use `useKeybindHint` / `TipKeybindLabel`?
|
||||
- [ ] Overlay uses `shadow-nous` + `border-(--stroke-nous)`, no hard border?
|
||||
- [ ] Flat — no card-in-card, no gratuitous row dividers?
|
||||
- [ ] No automatic navigation, focus steal, or pane opening from background
|
||||
events?
|
||||
- [ ] Direct manipulation paints immediately and rolls back cleanly on failure?
|
||||
- [ ] Hot interactions avoid broad subscriptions, layout thrash, and
|
||||
`transition-all`?
|
||||
- [ ] Keyboard ownership and single-action `Esc` behavior are correct?
|
||||
- [ ] All four locales updated for any new/changed string?
|
||||
- [ ] `cursor-pointer`, focus ring, and `Esc`-to-close behave?
|
||||
- [ ] Touched a primitive, token, or variant? Its named-contract entry in this
|
||||
file is updated in the same change.
|
||||
@@ -0,0 +1,244 @@
|
||||
# Hermes Desktop ☤
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NousResearch/hermes-agent/releases"><img src="https://img.shields.io/badge/Download-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux-FFD700?style=for-the-badge" alt="Download"></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
|
||||
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
|
||||
</p>
|
||||
|
||||
**The native desktop app for [Hermes Agent](../../README.md) — the self-improving AI agent from [Nous Research](https://nousresearch.com).** Same agent, same skills, same memory as the CLI and gateway, in a polished native window — chat with streaming tool output, side-by-side previews, a file browser, voice, and settings, no terminal required. Available for **macOS, Windows, and Linux**.
|
||||
|
||||
<table>
|
||||
<tr><td><b>Chat with the full agent</b></td><td>Streaming responses, live tool activity, structured tool summaries, and the same conversation history as every other Hermes surface.</td></tr>
|
||||
<tr><td><b>Side-by-side previews</b></td><td>Render web pages, files, and tool outputs in a right-hand pane while you keep chatting.</td></tr>
|
||||
<tr><td><b>File browser</b></td><td>Explore and preview the working directory without leaving the app.</td></tr>
|
||||
<tr><td><b>Voice</b></td><td>Talk to Hermes and hear it back.</td></tr>
|
||||
<tr><td><b>Settings & onboarding</b></td><td>Manage providers, models, tools, and credentials from a real UI. First-run setup gets you to your first message in seconds.</td></tr>
|
||||
<tr><td><b>Stays current</b></td><td>Built-in updates pull the latest agent and rebuild the app in place.</td></tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
### Install with Hermes (recommended)
|
||||
|
||||
Already have the Hermes CLI? Just run:
|
||||
|
||||
```bash
|
||||
hermes desktop
|
||||
```
|
||||
|
||||
It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. If Desktop cannot find a usable runtime or saved remote connection, first launch lets you connect to an existing Hermes gateway or install Hermes locally. Local onboarding then walks you through choosing a provider and model.
|
||||
|
||||
### Prebuilt installers
|
||||
|
||||
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/).
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
The app checks for updates in the background and offers a one-click update when one is ready. You can also update any time from the CLI:
|
||||
|
||||
```bash
|
||||
hermes update
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
The installer handles everything for you (Python 3.11+, a portable Git, ripgrep).
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
Want to hack on the app itself? Install workspace deps from the repo root once, then run the dev server from this directory:
|
||||
|
||||
```bash
|
||||
npm install # from repo root — links apps/desktop, web, apps/shared
|
||||
cd apps/desktop
|
||||
npm run dev # Vite renderer + Electron, which boots the Python backend
|
||||
```
|
||||
|
||||
Point the app at a specific source checkout, or sandbox it away from your real config:
|
||||
|
||||
```bash
|
||||
# throwaway HERMES_HOME, separate Electron userData, distinct app name to avoid the single-instance lock
|
||||
../scripts/dev-sandbox.sh npm run dev
|
||||
HERMES_DESKTOP_HERMES_ROOT=/path/to/clone npm run dev
|
||||
HERMES_HOME=/tmp/throwaway npm run dev
|
||||
npm run dev:fake-boot # exercise the startup overlay with deterministic delays
|
||||
```
|
||||
|
||||
### Building installers
|
||||
|
||||
```bash
|
||||
npm run dist:mac # DMG + zip
|
||||
npm run dist:win # NSIS + MSI
|
||||
npm run dist:linux # AppImage + deb + rpm
|
||||
npm run pack # unpacked app under release/ (no installer)
|
||||
```
|
||||
|
||||
Installers are built and uploaded to GitHub Releases manually. macOS/Windows signing & notarization happen automatically when the relevant credentials are present in the environment (`CSC_LINK` / `CSC_KEY_PASSWORD` / `APPLE_*` for macOS, `WIN_CSC_*` for Windows).
|
||||
|
||||
### How it works
|
||||
|
||||
The packaged app ships the Electron shell and a native React chat surface. On
|
||||
first launch it can install the Hermes Agent runtime into `HERMES_HOME`
|
||||
(`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows), using the same layout as a
|
||||
CLI install.
|
||||
|
||||
The app has three boundaries:
|
||||
|
||||
- **Electron** resolves and validates a runnable backend, owns native
|
||||
filesystem/git/window capabilities, and exposes a narrow preload bridge.
|
||||
- **React** owns the Desktop routes, panes, interaction state, and
|
||||
`@assistant-ui/react` transcript.
|
||||
- **Hermes Agent** runs as a headless `hermes serve` process and exposes the
|
||||
`tui_gateway` JSON-RPC/WebSocket API. The renderer connects through
|
||||
[`apps/shared`](../shared/), which is also used by the browser dashboard.
|
||||
|
||||
Backend resolution is an ordered ladder:
|
||||
|
||||
1. `HERMES_DESKTOP_HERMES_ROOT`
|
||||
2. the current source checkout during development
|
||||
3. a completed managed install
|
||||
4. `HERMES_DESKTOP_HERMES`, or `hermes` on `PATH`
|
||||
5. a system Python that can import the Hermes runtime
|
||||
6. the first-launch bootstrap installer
|
||||
|
||||
Candidates are probed before use; an existing shim or interpreter is not enough.
|
||||
A runtime that predates `serve` falls back to headless
|
||||
`dashboard --no-open`. This is compatibility for the backend command only and
|
||||
does not launch or embed the dashboard UI.
|
||||
|
||||
The Electron orchestration entry point is `electron/main.ts`; pure resolution,
|
||||
probe, hardening, and platform policies live in focused modules beside it. The
|
||||
renderer is under `src/`, with shared atoms in `src/store` and transport/native
|
||||
adapters in `src/lib`.
|
||||
|
||||
Before changing the app, read:
|
||||
|
||||
- [`AGENTS.md`](./AGENTS.md): architecture, state ownership, resolver/fallback,
|
||||
transport, performance, and testing rules.
|
||||
- [`DESIGN.md`](./DESIGN.md): visual system, information architecture, motion,
|
||||
direct manipulation, and keyboard behavior.
|
||||
|
||||
### Connections, projects, and switching
|
||||
|
||||
Desktop supports a managed local backend, explicit remote gateways, and Hermes
|
||||
Cloud connections. Remote and cloud modes use the same remote-capability path;
|
||||
authentication and discovery differ, not the renderer feature model.
|
||||
|
||||
When no usable local runtime or saved remote connection exists, the first-run
|
||||
screen offers **Connect to existing Hermes** before starting the local installer.
|
||||
Desktop probes the gateway to discover token or OAuth authentication, requires a
|
||||
successful HTTP and WebSocket connection test, and saves the connection using
|
||||
the same encrypted Desktop configuration used by Settings. A saved remote
|
||||
connection bypasses this choice on later launches. The regular Desktop build
|
||||
still includes the local-install option; this is a remote operating mode, not a
|
||||
separate client-only application.
|
||||
|
||||
In remote mode the gateway host is the execution boundary: agent tools,
|
||||
terminal commands, and file operations run against the remote Hermes host, not
|
||||
the computer displaying the Desktop UI.
|
||||
|
||||
Remote gateways that sit behind an access proxy may require extra headers on
|
||||
every HTTP and WebSocket request. Configure them per connection in Settings →
|
||||
Connections (Extra gateway headers), or add a `headers` object to Desktop's
|
||||
Electron `userData/connection.json` remote block:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "remote",
|
||||
"remote": {
|
||||
"url": "https://hermes.example.com",
|
||||
"authMode": "token",
|
||||
"token": { "encoding": "safeStorage", "value": "..." },
|
||||
"headers": {
|
||||
"CF-Access-Client-Id": { "encoding": "safeStorage", "value": "..." },
|
||||
"CF-Access-Client-Secret": { "encoding": "safeStorage", "value": "..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per-profile remote entries under `profiles[name].headers` use the same shape.
|
||||
Desktop applies these headers only to matching remote gateway requests, treats
|
||||
`https` and `wss` as the same gateway origin for WebSocket upgrades, and drops
|
||||
transport- or Hermes-managed header names such as `Authorization`, `Cookie`,
|
||||
`Host`, `Origin`, `Referer`, and `X-Hermes-Session-Token`.
|
||||
|
||||
Projects are the workspace abstraction. A project may own multiple folders,
|
||||
repositories, worktrees, and sessions; a bare new chat remains detached unless
|
||||
the user enters a project or configures a default project directory. Use the
|
||||
Projects UI rather than adding a second per-session folder-picker workflow.
|
||||
|
||||
Changing profiles or connection modes is a soft workspace switch, not another
|
||||
cold boot. The shell and current management overlay remain mounted while
|
||||
gateway-bound nanostores are wiped, query-backed data is invalidated, and the
|
||||
new connection repopulates skeletons. This prevents rows or transcripts from
|
||||
the previous gateway bleeding into the next one. Switching changes only the
|
||||
foreground view and request route: it does not cancel turns or stop a backend,
|
||||
and retained background sockets continue receiving events from running jobs.
|
||||
|
||||
### Verification
|
||||
|
||||
Run before opening a PR (lint may surface pre-existing warnings but must exit cleanly):
|
||||
|
||||
```bash
|
||||
npm run fix
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run test:ui
|
||||
npm run test:desktop:platforms
|
||||
```
|
||||
|
||||
Run `npm run test:desktop:all` for install, boot, update, packaging, or other
|
||||
release-path changes.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Boot logs land in `HERMES_HOME/logs/desktop.log` (includes backend output and recent Python tracebacks) — check it first if the app reports a boot failure.
|
||||
|
||||
**macOS / Linux:**
|
||||
|
||||
```bash
|
||||
# Force a clean first-launch setup
|
||||
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete"
|
||||
# Rebuild a broken Python venv
|
||||
rm -rf "$HOME/.hermes/hermes-agent/venv"
|
||||
# Reset a stuck macOS microphone prompt (macOS only)
|
||||
tccutil reset Microphone com.nousresearch.hermes
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
|
||||
```powershell
|
||||
# Force a clean first-launch setup
|
||||
Remove-Item "$env:LOCALAPPDATA\hermes\hermes-agent\.hermes-bootstrap-complete"
|
||||
# Rebuild a broken Python venv
|
||||
Remove-Item -Recurse -Force "$env:LOCALAPPDATA\hermes\hermes-agent\venv"
|
||||
```
|
||||
|
||||
> The default Hermes home on Windows is `%LOCALAPPDATA%\hermes`. Set the `HERMES_HOME` env var if you've relocated it.
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
- 💬 [Discord](https://discord.gg/NousResearch)
|
||||
- 📖 [Documentation](https://hermes-agent.nousresearch.com/docs/)
|
||||
- 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](../../LICENSE).
|
||||
|
||||
Built by [Nous Research](https://nousresearch.com).
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Nous Research
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,32 @@
|
||||
AITURK IDE — TurkServis
|
||||
|
||||
AITURK IDE is a TurkServis distribution derived from Hermes Agent / Hermes Desktop.
|
||||
Turkish localization and AITURK-specific modifications: Copyright (c) 2026 AITURK.
|
||||
Upstream: https://github.com/NousResearch/hermes-agent
|
||||
Upstream revision: 63279301bcbdc185c1b07b98a9312eb0c862f26d
|
||||
|
||||
This distribution is maintained by TurkServis. Nous Research does not publish,
|
||||
endorse, or provide support for the AITURK-branded distribution.
|
||||
Third-party provider names identify their respective services.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Nous Research
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
After Width: | Height: | Size: 561 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "tabler"
|
||||
}
|
||||
@@ -0,0 +1,999 @@
|
||||
/**
|
||||
* E2E at-rest contract for the remote-gateway session token (issue #77486).
|
||||
*
|
||||
* The reported bug: configuring a remote gateway persisted the dashboard
|
||||
* session token as PLAINTEXT into `connection.json` under the app's userData
|
||||
* dir (macOS `~/Library/Application Support/Hermes/connection.json`, Windows
|
||||
* `AppData\Roaming\Hermes\connection.json`). Anything that can read the file
|
||||
* — a backup, a sync client, another local process, a support bundle — got a
|
||||
* live gateway credential.
|
||||
*
|
||||
* The contract these tests encode is deliberately stated WITHOUT naming a
|
||||
* storage strategy:
|
||||
*
|
||||
* 1. ABSENT FROM DISK. After the app has been configured with a remote
|
||||
* gateway token, the token's plaintext value must not appear anywhere in
|
||||
* `connection.json`, in any sibling file the app writes under userData,
|
||||
* or in HERMES_HOME (logs included).
|
||||
* 2. STILL FUNCTIONAL. After a restart, the app must still be able to USE
|
||||
* that credential — it decrypts the stored blob and puts the exact
|
||||
* original token on the wire.
|
||||
* 3. UNREADABLE BY OTHER LOCAL ACCOUNTS. `connection.json` must not be
|
||||
* group/other-accessible, whether the app just wrote it or inherited it
|
||||
* from an older install.
|
||||
*
|
||||
* All three matter and none is sufficient alone. (1) alone is trivially
|
||||
* satisfied by a "fix" that drops the token on the floor; (2) alone is
|
||||
* satisfied by the bug itself. So (2) is verified through the app's own
|
||||
* connection test against a fake gateway that records the
|
||||
* `X-Hermes-Session-Token` header it receives — a dropped or mangled token
|
||||
* cannot produce that header.
|
||||
*
|
||||
* (3) is orthogonal to (1) and invisible to it: safeStorage keeps the token
|
||||
* opaque no matter what the file's mode is, so a 0644 `connection.json` passes
|
||||
* the raw-bytes scan every time while still exposing the ciphertext blob, the
|
||||
* gateway URL and the SSH host/user/keyPath to any other local account. It is
|
||||
* asserted explicitly (see `expectOwnerOnlyMode`) because no amount of
|
||||
* encryption evidence implies it.
|
||||
*
|
||||
* We deliberately do NOT assert `encoding === 'safeStorage'` or any other
|
||||
* shape of the stored blob. That would be a change-detector: a fix that moved
|
||||
* to the OS keychain proper, to an async safeStorage provider, or to a
|
||||
* separate credential file would break the test while being *more* correct.
|
||||
* The load-bearing assertion is the raw-bytes absence of the secret.
|
||||
*
|
||||
* Four at-rest paths, hence four tests — three enforced, one a documented gap:
|
||||
*
|
||||
* 1. A NEWLY configured token (ACTIVE). The app's own write path routes
|
||||
* through the strict `encryptDesktopSecret`; this test holds it there
|
||||
* against regression, and pins the mode of the file it actually wrote.
|
||||
* 2. An EXISTING `connection.json` at the old 0644 (ACTIVE). Covers the
|
||||
* read-side tighten, and ONLY the mode — its token is already ciphertext,
|
||||
* which is what keeps it independent of the migration test 4 defers.
|
||||
* 3. A CORRUPT `connection.json` at 0644 (ACTIVE). The tighten must not be
|
||||
* gated on the parse succeeding: a truncated file still holds the token
|
||||
* bytes, and the parse failure is swallowed, so nothing would ever come
|
||||
* back for it.
|
||||
* 4. An EXISTING plaintext `connection.json` (`test.fixme`). Legacy payloads
|
||||
* are deliberately NOT migrated yet. The test is kept, disabled, with a
|
||||
* precise reason — see the block comment above it.
|
||||
*
|
||||
* ── Correcting the record on test 4 ─────────────────────────────────────
|
||||
*
|
||||
* An earlier revision of this file asserted that migration and justified it by
|
||||
* claiming the first implementation (`d3d177283`) fell back to
|
||||
* `{ encoding: 'plain', value }` when `isEncryptionAvailable()` was false.
|
||||
* That citation is FALSE for this codebase. What is actually true:
|
||||
*
|
||||
* git merge-base --is-ancestor d3d1772837a7b0552940b55455ae734c72e0a8f1 HEAD -> 1 (NOT an ancestor)
|
||||
* git merge-base --is-ancestor 51c68d4ab1a9e3c62fb1048fccb84144c409f0e7 HEAD -> 0 (IS an ancestor)
|
||||
* git log -S 'Fall through to plaintext' upstream/main -- apps/desktop -> (no commits)
|
||||
*
|
||||
* `d3d177283` exists only on `upstream/bb/gui-mainmerge-tmp`,
|
||||
* `brooklyn/gui-installer-prereqs`, and the `desktop-pr20059-installers`
|
||||
* pre-release tag. Mainline NEVER shipped a code path that wrote a plaintext
|
||||
* gateway token: `51c68d4ab` ("Add Hermes desktop app (#20059)"), the commit
|
||||
* that brought the desktop app to mainline, already contained the strict
|
||||
* throw ("Secure token storage is unavailable, …") in `hardening.cjs`.
|
||||
*
|
||||
* One `{ encoding: 'plain', value }` literal does remain on mainline
|
||||
* (`electron/main.ts`, in `coerceDesktopConnectionConfig`), but it is
|
||||
* unreachable as an at-rest write: it is gated on `persistToken === false`,
|
||||
* whose only caller is the connection-TEST handler, which never calls
|
||||
* `writeDesktopConnectionConfig`. That token stays in memory for the duration
|
||||
* of one probe.
|
||||
*
|
||||
* So the affected population is not "anyone who configured a gateway before
|
||||
* the fix". It is narrow and non-mainline: pre-release `bb/gui` installs
|
||||
* (including the `desktop-pr20059-installers` build) plus hand-edited or
|
||||
* hand-migrated `connection.json` files. Those files DO still work, because
|
||||
* `decryptDesktopSecret` returns any non-safeStorage `value` verbatim on read
|
||||
* — the read path is intentionally unchanged, so nobody is signed out. That
|
||||
* read-path acceptance, not a mainline writer, is what makes the fixme'd
|
||||
* fixture realistic.
|
||||
*
|
||||
* Migration is DEFERRED, not forgotten. An adversarial review of the
|
||||
* migration that briefly lived here returned DO NOT SHIP, having reproduced
|
||||
* two token-loss scenarios: it silently reverts and then destroys the opt-in
|
||||
* plaintext choice that open upstream PR #62319 deliberately adds; and it
|
||||
* converts a portable credential into a keychain-bound one with no consent,
|
||||
* destroying the only recoverable copy while not actually remediating the
|
||||
* exposure (the plaintext is already in backups, so the real remedy is
|
||||
* ROTATION). The prerequisites are enumerated above test 2.
|
||||
*
|
||||
* Environment limits are encoded rather than papered over. Electron's
|
||||
* safeStorage is unavailable on Linux with no keyring, which is the shape of
|
||||
* this suite's CI runner (ubuntu-latest, see .github/workflows/e2e-desktop.yml).
|
||||
* The absence assertion is unconditional there — it is the security
|
||||
* requirement, and it must hold in every environment. Only the *other* half is
|
||||
* conditional: with secure storage the save must succeed, and without it the
|
||||
* save must fail loudly (which is what the current strict `encryptDesktopSecret`
|
||||
* does) instead of quietly writing plaintext. See the branch comments in each
|
||||
* test for the reasoning, including the one case this spec refuses to invent a
|
||||
* policy for.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { buildAppEnv, createSandbox, launchDesktop, type Sandbox } from './fixtures'
|
||||
import { allowErrorBanners, type ElectronApplication, expect, type Page, test } from './test'
|
||||
|
||||
/**
|
||||
* The secret under test. Long, random-looking, and unique to this spec so a
|
||||
* raw-bytes scan cannot produce a false negative by colliding with ordinary
|
||||
* config content. Kept to `[A-Za-z0-9-]` on purpose: encodeURIComponent() is
|
||||
* the identity function over this alphabet, so the raw-bytes needle also
|
||||
* covers the URL-encoded form the WS dialer builds (`?token=…`).
|
||||
*/
|
||||
const SENTINEL_TOKEN = 'hermes-e2e-at-rest-sentinel-Zq7Z4hV9nX2pL8sK3tB6wR1yM5jD0fG'
|
||||
|
||||
/** Skip absurdly large files during the leak scan (Chromium caches). */
|
||||
const MAX_SCAN_BYTES = 16 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* One fixed Electron app name for this spec, instead of the timestamped one
|
||||
* `buildAppEnv` generates. On macOS the safeStorage keychain item is derived
|
||||
* from the app name, so a per-launch name would (a) make the post-restart
|
||||
* decrypt fail for the wrong reason and (b) leave a fresh keychain entry on
|
||||
* the developer's login keychain on every run. Safe because the suite runs
|
||||
* one worker at a time and both launches here are sequential; the
|
||||
* single-instance lock keys off userData, which is per-sandbox.
|
||||
*/
|
||||
const STABLE_APP_NAME = 'HermesE2EAtRestStorage'
|
||||
|
||||
// ─── Fake gateway ───────────────────────────────────────────────────────
|
||||
|
||||
interface FakeGateway {
|
||||
url: string
|
||||
/** Every `X-Hermes-Session-Token` value the app has sent us. */
|
||||
sessionTokens: string[]
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal stand-in for a remote Hermes gateway. It serves the public
|
||||
* `/api/status` probe (which the desktop connection test hits first, with the
|
||||
* session token in a header) and refuses the WebSocket upgrade immediately so
|
||||
* the second leg of the connection test fails fast instead of burning the
|
||||
* probe's 10s connect timeout. We only care about the header it captured.
|
||||
*
|
||||
* The e2e mock-server is an OpenAI-compatible *inference* mock, not a gateway,
|
||||
* so it cannot answer /api/status — hence this small local server.
|
||||
*/
|
||||
async function startFakeGateway(): Promise<FakeGateway> {
|
||||
const sessionTokens: string[] = []
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const token = req.headers['x-hermes-session-token']
|
||||
|
||||
if (typeof token === 'string' && token) {
|
||||
sessionTokens.push(token)
|
||||
}
|
||||
|
||||
if (req.url?.startsWith('/api/status')) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ auth_required: false, ok: true, version: '0.0.0-e2e-fake' }))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ detail: 'not found' }))
|
||||
})
|
||||
|
||||
// Refuse the WS leg at once: the connection test's WS probe should return a
|
||||
// fast failure rather than hang. The status header is already captured.
|
||||
server.on('upgrade', (req, socket) => {
|
||||
const token = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('token')
|
||||
|
||||
if (token) {
|
||||
sessionTokens.push(token)
|
||||
}
|
||||
|
||||
socket.destroy()
|
||||
})
|
||||
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
|
||||
const { port } = server.address() as AddressInfo
|
||||
|
||||
return {
|
||||
close: () =>
|
||||
new Promise<void>(resolve => {
|
||||
server.closeAllConnections?.()
|
||||
server.close(() => resolve())
|
||||
}),
|
||||
sessionTokens,
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── On-disk leak scanning ──────────────────────────────────────────────
|
||||
|
||||
interface Needle {
|
||||
bytes: Buffer
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The forms a leak could take. Raw bytes, not JSON.parse + field inspection:
|
||||
* the point is that the secret is nowhere in the file — including inside a
|
||||
* nested field, a cached WS URL, or a field name nobody thought to check.
|
||||
*
|
||||
* The base64 needle catches the cheapest wrong "fix": base64 is an encoding,
|
||||
* not encryption, so a token that is merely base64'd is still plaintext at
|
||||
* rest. A real ciphertext will contain neither needle.
|
||||
*/
|
||||
function secretNeedles(secret: string): Needle[] {
|
||||
return [
|
||||
{ bytes: Buffer.from(secret, 'utf8'), label: 'plaintext' },
|
||||
{ bytes: Buffer.from(Buffer.from(secret, 'utf8').toString('base64'), 'utf8'), label: 'base64' },
|
||||
]
|
||||
}
|
||||
|
||||
/** Relative paths of every file under `root` whose bytes contain a needle. */
|
||||
function scanTreeForSecret(root: string, needles: Needle[]): string[] {
|
||||
const hits: string[] = []
|
||||
|
||||
const walk = (dir: string): void => {
|
||||
let entries: fs.Dirent[]
|
||||
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walk(full)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (!entry.isFile()) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.statSync(full).size > MAX_SCAN_BYTES) {
|
||||
continue
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
let buf: Buffer
|
||||
|
||||
try {
|
||||
buf = fs.readFileSync(full)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const needle of needles) {
|
||||
if (buf.includes(needle.bytes)) {
|
||||
hits.push(`${path.relative(root, full)} [${needle.label}]`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root)
|
||||
|
||||
return hits
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file's bytes, or an empty buffer when it does not exist. A correct
|
||||
* fix is allowed to delete/replace `connection.json` rather than rewrite it,
|
||||
* and the refusal path may never create it at all — neither should crash the
|
||||
* scan before its assertion runs.
|
||||
*/
|
||||
function readIfExists(filePath: string): Buffer {
|
||||
try {
|
||||
return fs.readFileSync(filePath)
|
||||
} catch {
|
||||
return Buffer.alloc(0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored token's `encoding` tag, for diagnostics only — never its value.
|
||||
* Reported on failure so a red run says *why* (e.g. still `plain`) instead of
|
||||
* only that a scan matched. Deliberately NOT an assertion: which encoding a
|
||||
* correct fix chooses is its own business.
|
||||
*/
|
||||
function storedTokenEncoding(connectionFile: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(readIfExists(connectionFile).toString('utf8'))
|
||||
|
||||
return String(parsed?.remote?.token?.encoding ?? '<none>')
|
||||
} catch {
|
||||
return '<unparsable>'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a credential file is not readable or writable by group/other.
|
||||
*
|
||||
* This is the one contract the raw-bytes scan above structurally cannot see:
|
||||
* safeStorage keeps the token opaque regardless of the file's mode, so a
|
||||
* world-readable `connection.json` passes every absence assertion in this file
|
||||
* while still handing the URL, the SSH host/user/keyPath, and the ciphertext
|
||||
* blob to any other local account. Encryption and permissions are independent
|
||||
* halves of "at rest", and only one of them was covered here.
|
||||
*
|
||||
* Asserted as `mode & 0o077 === 0` rather than `=== 0o600`: the requirement is
|
||||
* that nobody else can reach the file, and pinning the exact bits would make
|
||||
* this a change-detector against a future 0400 or a setgid-dir umask.
|
||||
*
|
||||
* POSIX only. `tightenSecretFileMode` no-ops on Windows deliberately (Node maps
|
||||
* chmod to the read-only bit there, and userData is already ACL'd to the user
|
||||
* profile — see the docstring in electron/hardening.ts, and PR #77527 for the
|
||||
* one place ACLs are being handled). Mode bits are advisory on Windows, so
|
||||
* asserting them would go red for behaviour the fix never claimed. The suite
|
||||
* runs ubuntu-latest today (.github/workflows/e2e-desktop.yml); nothing else in
|
||||
* this spec is platform-specific, and this assertion should not be what
|
||||
* changes that.
|
||||
*/
|
||||
function expectOwnerOnlyMode(filePath: string, why: string): void {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
const mode = fs.statSync(filePath).mode & 0o777
|
||||
|
||||
expect(mode & 0o077, `${why} (mode ${mode.toString(8)})`).toBe(0)
|
||||
}
|
||||
|
||||
// ─── App helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Launch the desktop app against `sandbox` with a fake boot failure injected.
|
||||
*
|
||||
* The credential path we are testing is entirely main-process (IPC handler →
|
||||
* coerce → safeStorage → userData write) and does not need a live agent
|
||||
* backend, so we skip spawning `hermes serve` (no Python needed, ~3s launch,
|
||||
* hermetic). This is also a real user situation rather than an artificial one:
|
||||
* the boot-failure overlay's own recovery affordance is "Connection settings",
|
||||
* i.e. pointing the app at a remote gateway is exactly what a user does from
|
||||
* this state. BOOT_FAKE_ERROR short-circuits startHermes() *before* remote
|
||||
* resolution, so no launch ever dials the fake gateway on its own.
|
||||
*/
|
||||
async function launchAgainst(sandbox: Sandbox): Promise<{ app: ElectronApplication; page: Page }> {
|
||||
const env = buildAppEnv(sandbox, {
|
||||
HERMES_DESKTOP_APP_NAME: STABLE_APP_NAME,
|
||||
HERMES_DESKTOP_BOOT_FAKE_ERROR: 'E2E at-rest storage spec: local backend intentionally not started',
|
||||
})
|
||||
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
// The capability bridge is what we drive; it lands with the preload, well
|
||||
// before the app would be "ready" in the boot sense.
|
||||
await page.waitForFunction(
|
||||
() => Boolean((window as unknown as { hermesDesktop?: Record<string, unknown> }).hermesDesktop?.saveConnectionConfig),
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
|
||||
return { app, page }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the running app where userData actually is, the same way the app does
|
||||
* (`app.getPath('userData')`). The fixtures point userData at a temp sandbox,
|
||||
* so a home-relative hardcoded path would test the wrong file — or no file.
|
||||
*/
|
||||
async function resolveUserDataDir(app: ElectronApplication): Promise<string> {
|
||||
return app.evaluate(({ app: electronApp }) => electronApp.getPath('userData'))
|
||||
}
|
||||
|
||||
interface SafeStorageCapability {
|
||||
available: boolean
|
||||
backend: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What secure storage is actually capable of on THIS host, asked after ready
|
||||
* (on Linux the answer is meaningless before then).
|
||||
*
|
||||
* `backend` matters for the honest reading of a green run: on Linux with no
|
||||
* keyring, Electron can still report encryption as available while selecting
|
||||
* the `basic_text` backend, which encrypts with a hardcoded password — the
|
||||
* bytes on disk are not the plaintext, but they are not meaningfully
|
||||
* protected either. We record it rather than assert on it, because which
|
||||
* posture Hermes should take there (refuse to save vs. accept basic_text) is
|
||||
* a product decision, not something this test should silently ratify.
|
||||
*/
|
||||
async function readSafeStorageCapability(app: ElectronApplication): Promise<SafeStorageCapability> {
|
||||
return app.evaluate(async ({ app: electronApp, safeStorage }) => {
|
||||
await electronApp.whenReady()
|
||||
|
||||
let available = false
|
||||
let backend = 'unavailable'
|
||||
|
||||
try {
|
||||
available = safeStorage.isEncryptionAvailable()
|
||||
} catch {
|
||||
available = false
|
||||
}
|
||||
|
||||
try {
|
||||
// Linux-oriented API; other platforms may not implement it.
|
||||
backend = safeStorage.getSelectedStorageBackend?.() ?? 'n/a'
|
||||
} catch {
|
||||
backend = 'n/a'
|
||||
}
|
||||
|
||||
return { available, backend }
|
||||
})
|
||||
}
|
||||
|
||||
interface SaveOutcome {
|
||||
config: { remoteTokenPreview?: null | string; remoteTokenSet?: boolean; remoteUrl?: string } | null
|
||||
error: null | string
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the app's REAL save surface: the same `saveConnectionConfig` payload
|
||||
* Settings → Gateway sends (see src/app/settings/gateway-settings.tsx). We use
|
||||
* save rather than apply so the app persists the credential without trying to
|
||||
* re-home onto the fake gateway.
|
||||
*/
|
||||
async function saveRemoteToken(page: Page, remoteUrl: string, remoteToken?: string): Promise<SaveOutcome> {
|
||||
return page.evaluate(
|
||||
async ([url, token]) => {
|
||||
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
||||
|
||||
try {
|
||||
const config = await desktop.saveConnectionConfig({
|
||||
mode: 'remote',
|
||||
remoteAuthMode: 'token',
|
||||
...(token ? { remoteToken: token } : {}),
|
||||
remoteUrl: url,
|
||||
})
|
||||
|
||||
return { config, error: null }
|
||||
} catch (error) {
|
||||
return { config: null, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
},
|
||||
[remoteUrl, remoteToken ?? ''] as const,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the app USE the stored credential. No token in the payload, so the main
|
||||
* process must read `connection.json`, decrypt what it stored, and put the
|
||||
* plaintext on the wire itself. `buildRemoteBlock` throws "Remote gateway
|
||||
* session token is required." when the stored blob no longer decrypts, so a
|
||||
* fix that dropped the token fails here instead of quietly passing the
|
||||
* absence assertion.
|
||||
*/
|
||||
async function exerciseStoredToken(page: Page, remoteUrl: string): Promise<{ error: null | string }> {
|
||||
return page.evaluate(async url => {
|
||||
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
||||
|
||||
try {
|
||||
await desktop.testConnectionConfig({ mode: 'remote', remoteUrl: url })
|
||||
|
||||
return { error: null }
|
||||
} catch (error) {
|
||||
// A failing WS leg is expected (the fake gateway refuses the upgrade).
|
||||
// The assertion is on what the gateway received, not on this result.
|
||||
return { error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}, remoteUrl)
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
let gateway: FakeGateway | null = null
|
||||
let sandbox: Sandbox | null = null
|
||||
let app: ElectronApplication | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
gateway = await startFakeGateway()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await gateway?.close()
|
||||
gateway = null
|
||||
})
|
||||
|
||||
test.beforeEach(() => {
|
||||
// Boot is intentionally failed in this spec (see launchAgainst), so the
|
||||
// boot-failure overlay's error banner is expected, not a failure.
|
||||
allowErrorBanners()
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await app?.close().catch(() => undefined)
|
||||
app = null
|
||||
sandbox?.cleanup()
|
||||
sandbox = null
|
||||
})
|
||||
|
||||
test.describe('remote gateway session token at rest', () => {
|
||||
test('with keychain encryption opted IN, a newly configured token is never written to userData in plaintext, and still works after restart', async () => {
|
||||
const fake = gateway!
|
||||
sandbox = createSandbox('at-rest-fresh')
|
||||
|
||||
// Keychain-backed encryption is opt-in (default OFF — see
|
||||
// electron/secret-storage-policy.ts). This test covers the opted-IN
|
||||
// posture, so seed the policy the way the Settings toggle writes it.
|
||||
fs.writeFileSync(
|
||||
path.join(sandbox.userDataDir, 'secure-token-storage.json'),
|
||||
JSON.stringify({ migrated: true, on: true }),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const first = await launchAgainst(sandbox)
|
||||
app = first.app
|
||||
|
||||
const capability = await readSafeStorageCapability(app)
|
||||
const userDataDir = await resolveUserDataDir(app)
|
||||
const connectionFile = path.join(userDataDir, 'connection.json')
|
||||
|
||||
test.info().annotations.push({
|
||||
description: `isEncryptionAvailable=${capability.available} backend=${capability.backend}`,
|
||||
type: 'safeStorage',
|
||||
})
|
||||
|
||||
const saved = await saveRemoteToken(first.page, fake.url, SENTINEL_TOKEN)
|
||||
|
||||
// Defined degradation, not a silent plaintext write. Where secure storage
|
||||
// works, the save must succeed. Where it genuinely does not (headless
|
||||
// Linux with no keyring, per Electron's safeStorage docs), refusing the
|
||||
// save with a loud error is an acceptable outcome — what is NEVER
|
||||
// acceptable is reporting success while leaving the secret readable on
|
||||
// disk. The absence assertion below runs in both branches.
|
||||
if (capability.available) {
|
||||
expect(
|
||||
saved.error,
|
||||
'secure storage is available on this host, so saving a remote gateway token must succeed',
|
||||
).toBeNull()
|
||||
expect(saved.config?.remoteTokenSet).toBe(true)
|
||||
} else {
|
||||
expect(
|
||||
saved.error,
|
||||
'secure storage is unavailable, so the save must fail loudly rather than persist a plaintext token',
|
||||
).not.toBeNull()
|
||||
}
|
||||
|
||||
// Guard against a vacuous pass: when the save succeeded, the artifact must
|
||||
// exist and must be the file the app really wrote for THIS connection.
|
||||
// Without this, "no plaintext on disk" would also be true if nothing had
|
||||
// been saved at all. Only asserted on the success branch — a refused save
|
||||
// legitimately leaves no file behind.
|
||||
const rawConnection = readIfExists(connectionFile)
|
||||
|
||||
if (capability.available) {
|
||||
expect(fs.existsSync(connectionFile), `expected the app to write ${connectionFile}`).toBe(true)
|
||||
expect(
|
||||
rawConnection.includes(Buffer.from(fake.url, 'utf8')),
|
||||
'connection.json should record the configured gateway URL (proves this is the real artifact)',
|
||||
).toBe(true)
|
||||
|
||||
// The write path's OTHER half of at-rest: opaque bytes AND owner-only
|
||||
// permissions. Deliberately here, on the file this test just proved the
|
||||
// app really wrote, rather than in a unit test — nothing in the repo
|
||||
// imports electron/main.ts (it imports electron), so this is the only
|
||||
// place that can witness the app's own write actually going out at 0600
|
||||
// instead of the 0644 umask default.
|
||||
expectOwnerOnlyMode(
|
||||
connectionFile,
|
||||
'connection.json is group/other-accessible, so the encrypted token blob, gateway URL and SSH fields are readable by other local accounts',
|
||||
)
|
||||
}
|
||||
|
||||
// ── The load-bearing assertion ─────────────────────────────────────
|
||||
const needles = secretNeedles(SENTINEL_TOKEN)
|
||||
|
||||
const connectionHits = needles.filter(needle => rawConnection.includes(needle.bytes)).map(needle => needle.label)
|
||||
expect(
|
||||
connectionHits,
|
||||
`the gateway session token must not be recoverable from ${connectionFile} ` +
|
||||
`(stored token encoding is "${storedTokenEncoding(connectionFile)}")`,
|
||||
).toEqual([])
|
||||
|
||||
// …and not in any sibling file the app writes alongside it, nor in
|
||||
// HERMES_HOME (desktop.log lives there).
|
||||
expect(
|
||||
scanTreeForSecret(userDataDir, needles),
|
||||
'the gateway session token leaked into a userData file',
|
||||
).toEqual([])
|
||||
expect(
|
||||
scanTreeForSecret(sandbox.hermesHome, needles),
|
||||
'the gateway session token leaked into a HERMES_HOME file (logs included)',
|
||||
).toEqual([])
|
||||
|
||||
if (!capability.available) {
|
||||
// Nothing was stored, so there is no round trip to verify. The refusal
|
||||
// itself was already asserted above.
|
||||
return
|
||||
}
|
||||
|
||||
// ── Secondary: the credential must still be USABLE ─────────────────
|
||||
// Restart against the same userData so the token comes off disk, not out
|
||||
// of a live process's memory.
|
||||
await app.close().catch(() => undefined)
|
||||
app = null
|
||||
|
||||
const second = await launchAgainst(sandbox)
|
||||
app = second.app
|
||||
|
||||
expect(
|
||||
await resolveUserDataDir(app),
|
||||
'the restarted app must resolve the same userData dir, or this is not a round trip',
|
||||
).toBe(userDataDir)
|
||||
|
||||
const reread = await second.page.evaluate(async () => {
|
||||
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
||||
|
||||
return desktop.getConnectionConfig()
|
||||
})
|
||||
|
||||
expect(reread.remoteTokenSet, 'the stored token must survive a restart').toBe(true)
|
||||
expect(reread.remoteUrl).toBe(fake.url)
|
||||
|
||||
const before = fake.sessionTokens.length
|
||||
await exerciseStoredToken(second.page, fake.url)
|
||||
|
||||
// The gateway is the witness: the app decrypted its stored blob and put
|
||||
// the original secret on the wire. A dropped, truncated, or re-encoded
|
||||
// token cannot produce this.
|
||||
expect(
|
||||
fake.sessionTokens.slice(before),
|
||||
'the app must send the exact stored token to the gateway after a restart',
|
||||
).toContain(SENTINEL_TOKEN)
|
||||
})
|
||||
|
||||
/**
|
||||
* The DEFAULT posture: keychain encryption opted out (no policy file at
|
||||
* all). Saving a token must (a) succeed without ever touching safeStorage
|
||||
* — this is the whole point of the opt-in: no macOS Keychain dialog on
|
||||
* machines with a broken login keychain — (b) store the token with a
|
||||
* non-safeStorage encoding at 0600, and (c) round-trip it across a
|
||||
* restart. The plaintext-on-disk trade-off is the user's chosen (default)
|
||||
* mode; owner-only file bits remain the at-rest boundary.
|
||||
*/
|
||||
test('with the default policy (no keychain), a token saves without secure storage, is owner-only on disk, and survives a restart', async () => {
|
||||
const fake = gateway!
|
||||
sandbox = createSandbox('at-rest-default')
|
||||
|
||||
const first = await launchAgainst(sandbox)
|
||||
app = first.app
|
||||
|
||||
const userDataDir = await resolveUserDataDir(app)
|
||||
const connectionFile = path.join(userDataDir, 'connection.json')
|
||||
|
||||
// Must succeed regardless of host keyring state — the default policy
|
||||
// never consults safeStorage, so "no keyring" cannot refuse the save.
|
||||
const saved = await saveRemoteToken(first.page, fake.url, SENTINEL_TOKEN)
|
||||
|
||||
expect(saved.error, 'the default (opted-out) policy must save without secure storage').toBeNull()
|
||||
expect(saved.config?.remoteTokenSet).toBe(true)
|
||||
|
||||
// Not a safeStorage blob, and owner-only on disk.
|
||||
expect(storedTokenEncoding(connectionFile)).not.toBe('safeStorage')
|
||||
expectOwnerOnlyMode(
|
||||
connectionFile,
|
||||
'connection.json is group/other-accessible; owner-only bits are the at-rest boundary for opted-out storage',
|
||||
)
|
||||
|
||||
// Round trip across a restart, same witness as the opted-in test.
|
||||
await app.close().catch(() => undefined)
|
||||
app = null
|
||||
|
||||
const second = await launchAgainst(sandbox)
|
||||
app = second.app
|
||||
|
||||
const reread = await second.page.evaluate(async () => {
|
||||
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
||||
|
||||
return desktop.getConnectionConfig()
|
||||
})
|
||||
|
||||
expect(reread.remoteTokenSet, 'the stored token must survive a restart').toBe(true)
|
||||
|
||||
const before = fake.sessionTokens.length
|
||||
await exerciseStoredToken(second.page, fake.url)
|
||||
|
||||
expect(
|
||||
fake.sessionTokens.slice(before),
|
||||
'the app must send the exact stored token to the gateway after a restart',
|
||||
).toContain(SENTINEL_TOKEN)
|
||||
})
|
||||
|
||||
/**
|
||||
* The read side of the same contract: an install written BEFORE the file was
|
||||
* owner-only keeps its 0644 bits until something chmods it, and the write
|
||||
* path cannot fix it — `fs.writeFileSync(path, data, { mode })` applies
|
||||
* `mode` only when it CREATES the file. Waiting for the user's next Settings
|
||||
* save would leave the file group/other-readable indefinitely, which is why
|
||||
* `readDesktopConnectionConfig` tightens on a cache miss.
|
||||
*
|
||||
* Scoped to the MODE, and deliberately independent of the deferred migration
|
||||
* below. The fixture's token is already safeStorage ciphertext (the app wrote
|
||||
* it), so nothing here re-encrypts anything, touches the #62319 opt-in
|
||||
* plaintext marker, or needs rotation guidance — the three prerequisites that
|
||||
* keep the next test fixme'd. Tightening a permission bit neither performs a
|
||||
* migration nor claims to, so it can be covered now while migration stays
|
||||
* deferred.
|
||||
*
|
||||
* The fixture is produced by the app itself rather than hand-written, so the
|
||||
* only difference from a real pre-fix install is the one bit under test.
|
||||
*/
|
||||
test('an install whose connection.json predates owner-only mode is tightened on read', async () => {
|
||||
const fake = gateway!
|
||||
sandbox = createSandbox('at-rest-tighten')
|
||||
|
||||
const first = await launchAgainst(sandbox)
|
||||
app = first.app
|
||||
|
||||
const capability = await readSafeStorageCapability(app)
|
||||
|
||||
test.info().annotations.push({
|
||||
description: `isEncryptionAvailable=${capability.available} backend=${capability.backend}`,
|
||||
type: 'safeStorage',
|
||||
})
|
||||
|
||||
if (!capability.available) {
|
||||
// Without secure storage the save is refused by design, so there is no
|
||||
// app-written artifact to loosen and re-read. The refusal itself is
|
||||
// already asserted in the first test.
|
||||
test.skip(true, 'secure storage unavailable on this host — no app-written connection.json to tighten')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const userDataDir = await resolveUserDataDir(app)
|
||||
const connectionFile = path.join(userDataDir, 'connection.json')
|
||||
|
||||
const saved = await saveRemoteToken(first.page, fake.url, SENTINEL_TOKEN)
|
||||
expect(saved.error, 'the fixture write must succeed, or there is nothing to tighten').toBeNull()
|
||||
|
||||
await app.close().catch(() => undefined)
|
||||
app = null
|
||||
|
||||
// Regress the file to what a pre-fix install has on disk. Everything else
|
||||
// about it — including the encrypted token — is exactly what the app wrote.
|
||||
fs.chmodSync(connectionFile, 0o644)
|
||||
expect(fs.statSync(connectionFile).mode & 0o077, 'the fixture must start group/other-accessible').not.toBe(0)
|
||||
|
||||
const seededMtimeMs = fs.statSync(connectionFile).mtimeMs
|
||||
|
||||
// A fresh process starts with an empty config cache, so the first read is a
|
||||
// miss and the tighten runs. `getConnectionConfig()` forces that read
|
||||
// through the app's own IPC surface.
|
||||
const second = await launchAgainst(sandbox)
|
||||
app = second.app
|
||||
|
||||
const reread = await second.page.evaluate(async () => {
|
||||
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
||||
|
||||
return desktop.getConnectionConfig()
|
||||
})
|
||||
|
||||
expectOwnerOnlyMode(
|
||||
connectionFile,
|
||||
'a pre-existing world-readable connection.json was not tightened when the app read it',
|
||||
)
|
||||
|
||||
// The tighten must be a chmod, not a rewrite. It sits INSIDE the function
|
||||
// whose cache keys on mtimeMs, so if it ever moved mtime it would
|
||||
// invalidate that cache on every read and re-tighten forever. chmod moves
|
||||
// ctime only, which is what makes the placement safe — this pins it.
|
||||
expect(
|
||||
Math.abs(fs.statSync(connectionFile).mtimeMs - seededMtimeMs),
|
||||
'tightening must not rewrite the file: mtime is the config cache key, so moving it would invalidate the cache the tighten sits inside',
|
||||
).toBeLessThan(1)
|
||||
|
||||
// And tightening must not have cost the user their credential — the whole
|
||||
// reason this happens on read instead of by deleting the file.
|
||||
expect(reread.remoteTokenSet, 'the stored token must survive being tightened').toBe(true)
|
||||
expect(reread.remoteUrl).toBe(fake.url)
|
||||
})
|
||||
|
||||
/**
|
||||
* The tighten must not be gated on the file being valid JSON.
|
||||
*
|
||||
* A truncated `connection.json` — an interrupted write on an older build, a
|
||||
* half-finished hand edit, a partially restored backup — still contains the
|
||||
* token bytes, and `JSON.parse` throws straight into the `catch` that falls
|
||||
* back to local mode. That fallback is never written back, so nothing
|
||||
* re-tightens the file later. With the chmod sequenced AFTER the parse,
|
||||
* exactly the file that is both corrupt AND world-readable would be the one
|
||||
* file never tightened, permanently.
|
||||
*
|
||||
* This is the only test that can tell the two orderings apart: every other
|
||||
* test here uses a parseable file, where either ordering tightens. Asserting
|
||||
* `mode === 'local'` is what makes it load-bearing — it proves the parse
|
||||
* really threw, so a green mode assertion cannot be explained by anything
|
||||
* downstream of the parse.
|
||||
*
|
||||
* Needs no secure storage: it is a chmod on a file that is never decrypted,
|
||||
* so it holds on the keyring-less CI runner too.
|
||||
*/
|
||||
test('a corrupt connection.json is tightened even though it never parses', async () => {
|
||||
sandbox = createSandbox('at-rest-tighten-corrupt')
|
||||
|
||||
const connectionFile = path.join(sandbox.userDataDir, 'connection.json')
|
||||
|
||||
// Truncated mid-token: unparseable, yet the secret bytes are right there.
|
||||
fs.writeFileSync(
|
||||
connectionFile,
|
||||
`{"mode":"remote","remote":{"authMode":"token","token":{"encoding":"plain","value":"${SENTINEL_TOKEN}`,
|
||||
{ encoding: 'utf8', mode: 0o644 },
|
||||
)
|
||||
fs.chmodSync(connectionFile, 0o644)
|
||||
expect(fs.statSync(connectionFile).mode & 0o077, 'the fixture must start group/other-accessible').not.toBe(0)
|
||||
|
||||
const seededMtimeMs = fs.statSync(connectionFile).mtimeMs
|
||||
|
||||
const launched = await launchAgainst(sandbox)
|
||||
app = launched.app
|
||||
|
||||
const reread = await launched.page.evaluate(async () => {
|
||||
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
||||
|
||||
return desktop.getConnectionConfig()
|
||||
})
|
||||
|
||||
expect(
|
||||
reread.mode,
|
||||
'the fixture must be unparseable, so the app falls back to local — otherwise this test proves nothing about ordering',
|
||||
).toBe('local')
|
||||
|
||||
expectOwnerOnlyMode(
|
||||
connectionFile,
|
||||
'a corrupt world-readable connection.json still holding token bytes was left group/other-accessible',
|
||||
)
|
||||
|
||||
// Same cache invariant as above: chmod, not rewrite.
|
||||
expect(
|
||||
Math.abs(fs.statSync(connectionFile).mtimeMs - seededMtimeMs),
|
||||
'tightening must not rewrite the file: mtime is the config cache key',
|
||||
).toBeLessThan(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* DEFERRED GAP — legacy plaintext payloads are not migrated.
|
||||
*
|
||||
* Held as `fixme` rather than deleted: the fixture below is the correct
|
||||
* fixture for the population that a migration must eventually cover, and
|
||||
* the harness (seed → boot-poll → authoritative re-save → raw-bytes scan →
|
||||
* wire check) is the harness such a migration needs. Keeping it typechecked
|
||||
* and listed makes the gap visible in `--list` and in every report; deleting
|
||||
* it would make the gap invisible and cost the next implementer this setup.
|
||||
*
|
||||
* It is NOT enabled because the migration it asserted was reviewed
|
||||
* DO NOT SHIP. Before this can be un-fixme'd, three prerequisites (see the
|
||||
* header, and the matching note in electron/main.ts readDesktopConnectionConfig):
|
||||
*
|
||||
* 1. Sequence with #62319's opt-in plaintext marker, so a user who
|
||||
* deliberately chose plaintext is not silently overridden. This
|
||||
* fixture has NO marker, so it stays in scope for migration — but the
|
||||
* implementation must be able to tell the two apart.
|
||||
* 2. Write through the config sanitizer, not around it.
|
||||
* 3. Surface ROTATION guidance. Re-encrypting cannot un-expose a secret
|
||||
* that is already in a backup; it only prevents future exposure.
|
||||
*
|
||||
* Un-fixme'ing this without (1) risks destroying a deliberate user choice,
|
||||
* and without (3) it reports a remediation it did not actually perform.
|
||||
*/
|
||||
test('an existing plaintext connection.json is migrated off plaintext and keeps working', async () => {
|
||||
test.fixme(
|
||||
true,
|
||||
'Deferred: legacy plaintext connection.json is intentionally NOT migrated. ' +
|
||||
'Affected population is pre-release bb/gui installs (incl. the desktop-pr20059-installers build) ' +
|
||||
'plus hand-edited configs — mainline never wrote a plaintext gateway token. ' +
|
||||
'Blocked on: (1) #62319 opt-in-marker coordination, (2) writing through the config sanitizer, ' +
|
||||
'(3) surfacing token-rotation guidance. Re-encrypting alone does not remediate an already-backed-up secret.',
|
||||
)
|
||||
|
||||
const fake = gateway!
|
||||
sandbox = createSandbox('at-rest-migrate')
|
||||
|
||||
// Seed the file an affected user has on disk. This is live, usable
|
||||
// plaintext rather than a strawman, because `decryptDesktopSecret` returns
|
||||
// `value` verbatim for any non-safeStorage encoding — the READ path
|
||||
// accepts it. Note what does NOT justify this fixture: mainline never
|
||||
// WROTE this shape to disk. `coerceDesktopConnectionConfig` does build it,
|
||||
// but only under `persistToken: false`, whose sole caller is the
|
||||
// connection-test handler, which never persists. The writers were
|
||||
// non-mainline pre-release builds and hand edits. There is deliberately no
|
||||
// opt-in marker here, so this payload is in scope for a future migration.
|
||||
fs.writeFileSync(
|
||||
path.join(sandbox.userDataDir, 'connection.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: 'remote',
|
||||
profiles: {},
|
||||
remote: {
|
||||
authMode: 'token',
|
||||
token: { encoding: 'plain', value: SENTINEL_TOKEN },
|
||||
url: fake.url,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const launched = await launchAgainst(sandbox)
|
||||
app = launched.app
|
||||
|
||||
const capability = await readSafeStorageCapability(app)
|
||||
|
||||
test.info().annotations.push({
|
||||
description: `isEncryptionAvailable=${capability.available} backend=${capability.backend}`,
|
||||
type: 'safeStorage',
|
||||
})
|
||||
|
||||
if (!capability.available) {
|
||||
// With no secure storage there is nowhere to migrate the secret TO, and
|
||||
// scrubbing it would silently sign the user out of a working gateway.
|
||||
// Asserting either outcome here would be inventing policy.
|
||||
test.skip(
|
||||
true,
|
||||
'secure storage unavailable on this host — the correct migration policy for an existing plaintext file is undecided',
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const userDataDir = await resolveUserDataDir(app)
|
||||
const connectionFile = path.join(userDataDir, 'connection.json')
|
||||
const needles = secretNeedles(SENTINEL_TOKEN)
|
||||
|
||||
// Two chances, so the test does not depend on WHERE the fix hooks the
|
||||
// migration: (a) on read at boot, (b) on the next authoritative write.
|
||||
// Poll for (a) first.
|
||||
const deadline = Date.now() + 15_000
|
||||
let stillPlaintext = true
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
stillPlaintext = readIfExists(connectionFile).includes(needles[0].bytes)
|
||||
|
||||
if (!stillPlaintext) {
|
||||
break
|
||||
}
|
||||
|
||||
await launched.page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
if (stillPlaintext) {
|
||||
// (b) A real save through the app's own surface, carrying no new token —
|
||||
// the stored blob is inherited. Re-persisting an inherited secret is the
|
||||
// other place plaintext must not survive.
|
||||
const resaved = await saveRemoteToken(launched.page, fake.url)
|
||||
expect(resaved.error, 'a re-save that inherits the stored token must not fail').toBeNull()
|
||||
}
|
||||
|
||||
expect(
|
||||
scanTreeForSecret(userDataDir, needles),
|
||||
'an existing plaintext gateway token must not remain readable under userData after the app has run ' +
|
||||
`(stored token encoding is still "${storedTokenEncoding(connectionFile)}")`,
|
||||
).toEqual([])
|
||||
|
||||
// And the migration must not have cost the user their credential.
|
||||
const before = fake.sessionTokens.length
|
||||
await exerciseStoredToken(launched.page, fake.url)
|
||||
|
||||
expect(
|
||||
fake.sessionTokens.slice(before),
|
||||
'the migrated token must still reach the gateway unchanged',
|
||||
).toContain(SENTINEL_TOKEN)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* E2E batch clarify test — the multi-question clarify card must mount ONCE.
|
||||
*
|
||||
* Regression coverage for the duplicated-card bug: `tool.start` carries the
|
||||
* model's tool_call_id while `clarify.request` carries a gateway-generated
|
||||
* request_id. A batch payload has no top-level `question`, so the two rows
|
||||
* only merge when the correlation key comes from the question list
|
||||
* (`batchClarifyMatchValue` in lib/chat-messages/tool-parts.ts). Before that
|
||||
* fix this exact flow rendered two identical interactive cards.
|
||||
*
|
||||
* The flow runs the real chain: composer → gateway → agent → clarify tool →
|
||||
* clarify.request event → renderer, against the mock inference server.
|
||||
*/
|
||||
|
||||
import { expect, test } from './test'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { BATCH_CLARIFY_QUESTIONS, BATCH_CLARIFY_TRIGGER } from './mock-server'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture!, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('batch clarify card', () => {
|
||||
test('renders exactly one card and completes via per-question locks', async () => {
|
||||
const page = fixture!.page
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
|
||||
await composer.click()
|
||||
await composer.type(BATCH_CLARIFY_TRIGGER, { delay: 20 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// The live batch form marks itself with data-clarify-batch=<count>.
|
||||
const batchCard = page.locator('form[data-clarify-batch]')
|
||||
await batchCard.first().waitFor({ state: 'visible', timeout: 60_000 })
|
||||
|
||||
// THE regression assertion: one card, not two.
|
||||
await expect(batchCard).toHaveCount(1)
|
||||
await expect(batchCard).toHaveAttribute('data-clarify-batch', String(BATCH_CLARIFY_QUESTIONS.length))
|
||||
|
||||
// Both questions render inside the single card.
|
||||
for (const entry of BATCH_CLARIFY_QUESTIONS) {
|
||||
await expect(batchCard.getByText(entry.question)).toHaveCount(1)
|
||||
}
|
||||
|
||||
// Each question text also appears exactly once in the whole transcript —
|
||||
// catches a duplicate that mounts outside a form[data-clarify-batch].
|
||||
for (const entry of BATCH_CLARIFY_QUESTIONS) {
|
||||
await expect(page.getByText(entry.question)).toHaveCount(1)
|
||||
}
|
||||
|
||||
// Answer both questions: stage picks locally (no server traffic yet).
|
||||
const confirmButton = batchCard.locator('button[type="submit"]')
|
||||
await expect(confirmButton).toContainText('Confirm and continue')
|
||||
await expect(confirmButton).toBeDisabled()
|
||||
|
||||
await batchCard.getByRole('button', { name: /Coffee/ }).click()
|
||||
await expect(confirmButton).toBeDisabled()
|
||||
|
||||
await batchCard.getByRole('button', { name: /Morning/ }).click()
|
||||
await expect(confirmButton).toBeEnabled()
|
||||
|
||||
// ONE confirm submits the whole batch.
|
||||
await confirmButton.click()
|
||||
|
||||
// The settled card lists both questions with their locked answers.
|
||||
const settled = page.locator('[data-clarify-settled]')
|
||||
await settled.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await expect(settled.getByText(BATCH_CLARIFY_QUESTIONS[0].question)).toBeVisible()
|
||||
await expect(settled.getByText('Coffee', { exact: true })).toBeVisible()
|
||||
await expect(settled.getByText(BATCH_CLARIFY_QUESTIONS[1].question)).toBeVisible()
|
||||
await expect(settled.getByText('Morning', { exact: true })).toBeVisible()
|
||||
|
||||
// And still no duplicate live card lingering after settle.
|
||||
await expect(page.locator('form[data-clarify-batch]')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* E2E boot-failure tests — verify the app shows an error overlay when the
|
||||
* backend can't start.
|
||||
*
|
||||
* Injects a fake boot error (HERMES_DESKTOP_BOOT_FAKE_ERROR) so the backend
|
||||
* resolution fails with a controlled error message. The app should show the
|
||||
* BootFailureOverlay with retry/repair actions.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { allowErrorBanners, test } from './test'
|
||||
|
||||
import {
|
||||
type DeadBackendFixture,
|
||||
setupDeadBackend,
|
||||
waitForBootFailure,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: DeadBackendFixture | null = null
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('boot failure with dead backend', () => {
|
||||
test.beforeEach(() => {
|
||||
// These tests deliberately trigger boot errors — error banners
|
||||
// (notifyError → [role="alert"]) are expected, not failures.
|
||||
allowErrorBanners()
|
||||
})
|
||||
|
||||
test('app shows error state', async () => {
|
||||
// Inject a fake boot error so the backend resolution "fails" with a
|
||||
// controlled error message. This is the only reliable way to trigger
|
||||
// BootFailureOverlay in dev mode.
|
||||
fixture = await setupDeadBackend({ fakeError: true })
|
||||
|
||||
await waitForBootFailure(fixture.page, 90_000)
|
||||
})
|
||||
|
||||
test('screenshot of error state', async () => {
|
||||
if (!fixture) {
|
||||
test.skip(true, 'Previous test failed — no app running')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'boot-failure-error-state', app: fixture.app })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 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:
|
||||
* npm exec playwright test e2e/boot.spec.ts --reporter=list
|
||||
*/
|
||||
import { expect, test } from './test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
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,
|
||||
})
|
||||
})
|
||||
|
||||
// A preload that throws never reaches contextBridge, so the renderer boots
|
||||
// into "Desktop IPC bridge is unavailable" and every test below it dies on a
|
||||
// 120s never-became-ready timeout instead. Checking the bridge by name makes
|
||||
// that failure legible. The sandbox lets preload require only electron,
|
||||
// events, timers and url — adding any other node builtin lands here.
|
||||
test('the preload bridge reaches the renderer', async () => {
|
||||
const bridge = await fixture!.page.evaluate(() => {
|
||||
const desktop = (window as unknown as { hermesDesktop?: Record<string, unknown> }).hermesDesktop
|
||||
|
||||
return {
|
||||
present: typeof desktop,
|
||||
glassSupported: typeof desktop?.glassSupported,
|
||||
translucencySupported: typeof desktop?.translucencySupported
|
||||
}
|
||||
})
|
||||
|
||||
expect(bridge).toEqual({ present: 'object', glassSupported: 'boolean', translucencySupported: 'boolean' })
|
||||
})
|
||||
|
||||
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!, 120_000)
|
||||
})
|
||||
|
||||
test('screenshot after boot', async () => {
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'boot-ready', app: fixture!.app })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
type MockBackendFixture,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig
|
||||
} from './fixtures'
|
||||
import { MOCK_REPLY, startMockServer } from './mock-server'
|
||||
import { RealSessionBuilder } from './real-session-builder'
|
||||
import { expect, test } from './test'
|
||||
|
||||
// A bot row previews the bot's canonical Bot Chat (the gateway resolves it by
|
||||
// name on every roster poll). Clicking the row must land on THAT conversation.
|
||||
// Before this fix a plain click fronted whatever bots-workspace tile the user
|
||||
// last had open for that bot — a `+` side thread outlived every restart in
|
||||
// Local Storage and won every click forever, while the row kept previewing the
|
||||
// Bot Chat. The user saw the sidebar and the center describe two different
|
||||
// conversations ("sessions not in sync"; support thread 1544460286084391043).
|
||||
|
||||
type Page = MockBackendFixture['page']
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
async function openBots(page: Page): Promise<void> {
|
||||
const tab = page
|
||||
.getByRole('button', { name: 'Bots', exact: true })
|
||||
.or(page.getByRole('tab', { name: 'Bots', exact: true }))
|
||||
.first()
|
||||
|
||||
await tab.click()
|
||||
await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
|
||||
}
|
||||
|
||||
async function settle(page: Page, timeout = 90_000): Promise<void> {
|
||||
await page
|
||||
.getByText(/Waking up/i)
|
||||
.first()
|
||||
.waitFor({ state: 'hidden', timeout })
|
||||
.catch(() => undefined)
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
async function openUntil(action: () => Promise<void>, expected: () => Promise<void>, attempts = 3): Promise<void> {
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
await action()
|
||||
|
||||
try {
|
||||
await expected()
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
if (attempt >= attempts) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise<void> {
|
||||
const dir = path.join(hermesHome, 'profiles', name)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
writeMockProviderConfig(dir, mockUrl)
|
||||
writeEnvFile(dir)
|
||||
|
||||
const builder = await RealSessionBuilder.start(dir)
|
||||
|
||||
try {
|
||||
await builder.createSession({ title: 'Bot Chat', turns: [`Hello ${name}`] })
|
||||
} finally {
|
||||
await builder.close()
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const mock = await startMockServer()
|
||||
const sandbox = createSandbox('bots-sync')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
await seedBot(sandbox.hermesHome, mock.url, 'alpha')
|
||||
await seedBot(sandbox.hermesHome, mock.url, 'beta')
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
|
||||
fixture = {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
}
|
||||
}
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('a bot row click lands on the Bot Chat the row previews, not a side thread', async () => {
|
||||
test.setTimeout(300_000)
|
||||
const page = fixture!.page
|
||||
|
||||
await openBots(page)
|
||||
|
||||
const alphaRow = page.getByRole('button', { name: /^alpha\b/i }).filter({ visible: true }).first()
|
||||
const betaRow = page.getByRole('button', { name: /^beta\b/i }).filter({ visible: true }).first()
|
||||
await expect(alphaRow).toBeVisible({ timeout: 30_000 })
|
||||
await expect(betaRow).toBeVisible({ timeout: 30_000 })
|
||||
const seededTurn = page.getByText('Hello alpha', { exact: true }).filter({ visible: true })
|
||||
|
||||
await openUntil(
|
||||
() => alphaRow.click(),
|
||||
() => expect(seededTurn.first()).toBeVisible({ timeout: 45_000 })
|
||||
)
|
||||
await settle(page, 15_000)
|
||||
|
||||
// A `+` side thread for alpha, with a real turn so it is a persisted tile.
|
||||
await page.keyboard.press('Control+t')
|
||||
const composer = page.locator('[data-slot="composer-root"] [contenteditable="true"]').filter({ visible: true }).first()
|
||||
await expect(composer).toBeVisible({ timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.fill('hello alpha thread')
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByText(MOCK_REPLY).filter({ visible: true }).first()).toBeVisible({ timeout: 60_000 })
|
||||
|
||||
// Leave alpha on the side thread, go to beta, come back via the row.
|
||||
await betaRow.click()
|
||||
await expect(page.getByText('Hello beta', { exact: true }).filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 60_000
|
||||
})
|
||||
await settle(page)
|
||||
|
||||
await alphaRow.click()
|
||||
// The row previews the Bot Chat; the click must front it.
|
||||
await expect(seededTurn.first()).toBeVisible({ timeout: 45_000 })
|
||||
// The side thread is still open beside it (scoped to alpha), not closed.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() =>
|
||||
[...document.querySelectorAll<HTMLElement>('[data-zone-tabstrip="grp-main"] [data-tree-tab]')]
|
||||
.map(element => element.getAttribute('data-tree-tab') ?? '')
|
||||
.filter(id => id.startsWith('session-tile:')).length
|
||||
),
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
.toBe(1)
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
type MockBackendFixture,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig
|
||||
} from './fixtures'
|
||||
import { MOCK_REPLY, startMockServer } from './mock-server'
|
||||
import { RealSessionBuilder } from './real-session-builder'
|
||||
import { expect, test } from './test'
|
||||
|
||||
// Every bot's canonical chat is STORED under the same title ("Bot Chat" — the
|
||||
// name the gateway resolves it by), so the main tab strip captioned every open
|
||||
// bot chat identically and two bots' tabs were indistinguishable (#99152). The
|
||||
// tab must read the bot's display name while the stored title stays canonical.
|
||||
|
||||
type Page = MockBackendFixture['page']
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
async function openBots(page: Page): Promise<void> {
|
||||
const tab = page
|
||||
.getByRole('button', { name: 'Bots', exact: true })
|
||||
.or(page.getByRole('tab', { name: 'Bots', exact: true }))
|
||||
.first()
|
||||
|
||||
await tab.click()
|
||||
await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
|
||||
}
|
||||
|
||||
async function openUntil(action: () => Promise<void>, expected: () => Promise<void>, attempts = 3): Promise<void> {
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
await action()
|
||||
|
||||
try {
|
||||
await expected()
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
if (attempt >= attempts) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise<void> {
|
||||
const dir = path.join(hermesHome, 'profiles', name)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
writeMockProviderConfig(dir, mockUrl)
|
||||
writeEnvFile(dir)
|
||||
|
||||
const builder = await RealSessionBuilder.start(dir)
|
||||
|
||||
try {
|
||||
await builder.createSession({ title: 'Bot Chat', turns: [`Hello ${name}`] })
|
||||
} finally {
|
||||
await builder.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Every tab caption in the main strip (the main `workspace` tab + tiles). */
|
||||
function mainStripTabTitles(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() =>
|
||||
[...document.querySelectorAll<HTMLElement>('[data-zone-tabstrip="grp-main"] [data-tree-tab]')].map(element =>
|
||||
(element.textContent ?? '').trim()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const mock = await startMockServer()
|
||||
const sandbox = createSandbox('bots-tabname')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
await seedBot(sandbox.hermesHome, mock.url, 'alpha')
|
||||
await seedBot(sandbox.hermesHome, mock.url, 'beta')
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
|
||||
fixture = {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
}
|
||||
}
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test("an open Bot Chat's tab reads the bot's name, not the canonical 'Bot Chat' title", async () => {
|
||||
test.setTimeout(300_000)
|
||||
const page = fixture!.page
|
||||
|
||||
await openBots(page)
|
||||
|
||||
const alphaRow = page.getByRole('button', { name: /^alpha\b/i }).filter({ visible: true }).first()
|
||||
await expect(alphaRow).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
await openUntil(
|
||||
() => alphaRow.click(),
|
||||
() =>
|
||||
expect(page.getByText('Hello alpha', { exact: true }).filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 45_000
|
||||
})
|
||||
)
|
||||
|
||||
// A `+` side thread beside the Bot Chat gives the main zone a tab strip —
|
||||
// the surface where every bot chat used to read "Bot Chat".
|
||||
await page.keyboard.press('Control+t')
|
||||
const composer = page.locator('[data-slot="composer-root"] [contenteditable="true"]').filter({ visible: true }).first()
|
||||
await expect(composer).toBeVisible({ timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.fill('hello alpha thread')
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByText(MOCK_REPLY).filter({ visible: true }).first()).toBeVisible({ timeout: 60_000 })
|
||||
|
||||
await expect.poll(() => mainStripTabTitles(page), { timeout: 15_000 }).toHaveLength(2)
|
||||
const captions = await mainStripTabTitles(page)
|
||||
expect(captions.some(caption => /alpha/i.test(caption))).toBe(true)
|
||||
expect(captions.some(caption => /bot chat/i.test(caption))).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,254 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
type MockBackendFixture,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig
|
||||
} from './fixtures'
|
||||
import { startMockServer } from './mock-server'
|
||||
import { RealSessionBuilder } from './real-session-builder'
|
||||
import { expect, test } from './test'
|
||||
|
||||
// User-made sections in the Bots roster: a bot is filed by dragging it onto a
|
||||
// section or through its row menu, the section is renamed through the same
|
||||
// dialog shape sessions use, and deleting a section returns its bots to
|
||||
// Unassigned (with an Undo toast, no confirmation). With no sections created
|
||||
// the roster is the plain list it always was.
|
||||
|
||||
type Page = MockBackendFixture['page']
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
// BOT_SECTIONS_SCREENSHOT_DIR=<dir> saves full-window captures at the key
|
||||
// states — handy for design review; never part of the assertions.
|
||||
async function capture(page: Page, name: string): Promise<void> {
|
||||
const dir = process.env.BOT_SECTIONS_SCREENSHOT_DIR
|
||||
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
await page.screenshot({ path: path.join(dir, `${name}.png`) })
|
||||
}
|
||||
|
||||
async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise<void> {
|
||||
const dir = path.join(hermesHome, 'profiles', name)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
writeMockProviderConfig(dir, mockUrl)
|
||||
writeEnvFile(dir)
|
||||
|
||||
const builder = await RealSessionBuilder.start(dir)
|
||||
|
||||
try {
|
||||
await builder.createSession({ title: 'Bot Chat', turns: [`Hello ${name}`] })
|
||||
} finally {
|
||||
await builder.close()
|
||||
}
|
||||
}
|
||||
|
||||
const roster = (page: Page) => page.locator('[data-slot="bots-roster"]')
|
||||
const botRow = (page: Page, name: string) => roster(page).locator(`[data-roster-key="local::${name}"]`)
|
||||
|
||||
/** A section's label span — the one node whose text is exactly the name. */
|
||||
const sectionLabel = (page: Page, name: string) =>
|
||||
page.locator('span.truncate', { hasText: new RegExp(`^${name}$`, 'i') })
|
||||
|
||||
/** The heading's fold button (label + count) — the ⋯ menu trigger is a sibling with no text. */
|
||||
const sectionHeading = (page: Page, name: string) =>
|
||||
roster(page).locator('[data-slot="bots-section"] button[aria-expanded]').filter({ has: sectionLabel(page, name) })
|
||||
|
||||
const sectionBlock = (page: Page, name: string) =>
|
||||
roster(page).locator('[data-slot="bots-section"]').filter({ has: sectionLabel(page, name) })
|
||||
|
||||
/** Section name → roster keys of the rows under it (the plain list has no sections). */
|
||||
async function layout(page: Page): Promise<Array<[string, string[]]>> {
|
||||
return roster(page).locator('[data-slot="bots-section"]').evaluateAll(blocks =>
|
||||
blocks.map(block => [
|
||||
block.querySelector('button[aria-expanded] span.truncate')?.textContent?.trim() ?? '',
|
||||
[...block.querySelectorAll<HTMLElement>('[data-roster-key]')].map(row => row.dataset.rosterKey ?? '')
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const mock = await startMockServer()
|
||||
const sandbox = createSandbox('bots-sections')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
for (const name of ['alpha', 'beta', 'gamma']) {
|
||||
await seedBot(sandbox.hermesHome, mock.url, name)
|
||||
}
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
|
||||
fixture = {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
}
|
||||
}
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('file bots into user sections by menu and drag; rename; delete returns them to Unassigned', async () => {
|
||||
test.setTimeout(300_000)
|
||||
const page = fixture!.page
|
||||
|
||||
const tab = page
|
||||
.getByRole('button', { name: 'Bots', exact: true })
|
||||
.or(page.getByRole('tab', { name: 'Bots', exact: true }))
|
||||
.first()
|
||||
|
||||
await tab.click()
|
||||
await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
|
||||
await expect(botRow(page, 'alpha')).toBeVisible({ timeout: 30_000 })
|
||||
await expect(botRow(page, 'beta')).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
// No sections yet: the plain list, no section chrome at all.
|
||||
await expect(roster(page).locator('[data-slot="bots-section"]')).toHaveCount(0)
|
||||
await capture(page, '1-plain-roster')
|
||||
|
||||
// Right-click alpha → Move to section → New section… → name it → alpha is filed.
|
||||
await botRow(page, 'alpha').click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Move to section' }).hover()
|
||||
await expect(page.getByRole('menuitem', { name: 'New section…' })).toBeVisible()
|
||||
await capture(page, '2-row-menu-move-to-section')
|
||||
await page.getByRole('menuitem', { name: 'New section…' }).click()
|
||||
const nameField = page.getByRole('textbox', { name: 'Section name' })
|
||||
await expect(nameField).toBeVisible()
|
||||
await nameField.fill('Clients')
|
||||
await capture(page, '3-new-section-dialog')
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
|
||||
await expect(sectionHeading(page, 'Clients')).toBeVisible()
|
||||
await expect(sectionBlock(page, 'Clients').locator('[data-roster-key="local::alpha"]')).toBeVisible()
|
||||
// The remainder is Unassigned, drawn last.
|
||||
await expect
|
||||
.poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length]))
|
||||
.toEqual([
|
||||
['Clients', 1],
|
||||
['Unassigned', 3]
|
||||
])
|
||||
await capture(page, '4-alpha-filed')
|
||||
|
||||
// Drag beta over the Clients block: the target highlights while over it.
|
||||
// Escape cancels — nothing moves, nothing stays highlighted or faded.
|
||||
const target = sectionBlock(page, 'Clients')
|
||||
const from = (await botRow(page, 'beta').boundingBox())!
|
||||
const to = (await sectionHeading(page, 'Clients').boundingBox())!
|
||||
|
||||
const dragBetaOverClients = async () => {
|
||||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2 - 10, { steps: 4 })
|
||||
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 })
|
||||
await expect(target).toHaveAttribute('data-drop-over', 'true')
|
||||
}
|
||||
|
||||
await dragBetaOverClients()
|
||||
await page.keyboard.press('Escape')
|
||||
await page.mouse.up()
|
||||
await expect(target).not.toHaveAttribute('data-drop-over', 'true')
|
||||
await expect(botRow(page, 'beta')).toHaveCSS('opacity', '1')
|
||||
expect((await layout(page)).map(([name, keys]) => [name, keys.length])).toEqual([
|
||||
['Clients', 1],
|
||||
['Unassigned', 3]
|
||||
])
|
||||
|
||||
// Drop it for real: the bot is filed.
|
||||
await dragBetaOverClients()
|
||||
await capture(page, '5-drag-over-clients')
|
||||
await page.mouse.up()
|
||||
|
||||
await expect(target.locator('[data-roster-key="local::beta"]')).toBeVisible()
|
||||
await expect(target).not.toHaveAttribute('data-drop-over', 'true')
|
||||
// The moved row remounts under its new section; it must not stay faded.
|
||||
await expect(botRow(page, 'beta')).toHaveCSS('opacity', '1')
|
||||
await expect
|
||||
.poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length]))
|
||||
.toEqual([
|
||||
['Clients', 2],
|
||||
['Unassigned', 2]
|
||||
])
|
||||
await capture(page, '6-beta-dropped')
|
||||
|
||||
// Rename through the heading's context menu — the same Dialog + Input
|
||||
// + Save shape as a session rename.
|
||||
await sectionHeading(page, 'Clients').click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Rename…' }).click()
|
||||
await expect(nameField).toHaveValue('Clients')
|
||||
await nameField.fill('Customers')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(sectionHeading(page, 'Customers')).toBeVisible()
|
||||
await expect(sectionHeading(page, 'Clients')).toHaveCount(0)
|
||||
await capture(page, '7-renamed')
|
||||
|
||||
// A second, empty section from the + menu shows its drop hint; collapsing
|
||||
// a section folds its rows like the gateway headings do.
|
||||
await page.getByRole('button', { name: 'New bot or group chat' }).click()
|
||||
await page.getByRole('menuitem', { name: 'New section' }).click()
|
||||
await nameField.fill('Team')
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(sectionBlock(page, 'Team').getByText('Drag bots here')).toBeVisible()
|
||||
await sectionHeading(page, 'Customers').click()
|
||||
await expect(sectionBlock(page, 'Customers').locator('[data-roster-key]')).toHaveCount(0)
|
||||
await capture(page, '8-empty-section-and-collapsed')
|
||||
await sectionHeading(page, 'Customers').click()
|
||||
await expect(sectionBlock(page, 'Customers').locator('[data-roster-key]')).toHaveCount(2)
|
||||
|
||||
// Delete Customers: no confirmation, its two bots return to Unassigned,
|
||||
// and the toast offers Undo.
|
||||
await sectionHeading(page, 'Customers').click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click()
|
||||
await expect(sectionHeading(page, 'Customers')).toHaveCount(0)
|
||||
const toast = page.getByRole('status').filter({ hasText: 'Deleted “Customers”' })
|
||||
await expect(toast).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length]))
|
||||
.toEqual([
|
||||
['Team', 0],
|
||||
['Unassigned', 4]
|
||||
])
|
||||
await capture(page, '9-deleted-with-undo-toast')
|
||||
|
||||
await toast.getByRole('button', { name: 'Undo' }).click()
|
||||
await expect(sectionHeading(page, 'Customers')).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length]))
|
||||
.toEqual([
|
||||
['Customers', 2],
|
||||
['Team', 0],
|
||||
['Unassigned', 2]
|
||||
])
|
||||
|
||||
// Membership rides the bot's profile ui_meta, so it follows profile sync.
|
||||
const alphaProfile = path.join(fixture!.sandbox.hermesHome, 'profiles', 'alpha', 'profile.yaml')
|
||||
await expect.poll(() => (fs.existsSync(alphaProfile) ? fs.readFileSync(alphaProfile, 'utf8') : '')).toMatch(/sectionId:\s*sec-/)
|
||||
|
||||
// Delete both sections: the roster is the plain list again.
|
||||
for (const name of ['Customers', 'Team']) {
|
||||
await sectionHeading(page, name).click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click()
|
||||
}
|
||||
|
||||
await expect(roster(page).locator('[data-slot="bots-section"]')).toHaveCount(0)
|
||||
await expect(botRow(page, 'alpha')).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 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 './test'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { BLOCKING_CLARIFY_QUESTION, BLOCKING_CLARIFY_TRIGGER } from './mock-server'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture!, 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?')
|
||||
},
|
||||
undefined,
|
||||
{ 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')
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
})
|
||||
|
||||
test('screenshot of chat with messages', async () => {
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app })
|
||||
})
|
||||
|
||||
test('offers stop, steer, and queue actions while busy', async ({}, testInfo) => {
|
||||
const page = fixture!.page
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
|
||||
const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]')
|
||||
const dictation = page.locator('[data-slot="composer-root"] button[aria-label="Voice dictation"]')
|
||||
const speakReplies = page.locator(
|
||||
'[data-slot="composer-root"] button[aria-label="Read replies aloud"], [data-slot="composer-root"] button[aria-label="Stop reading replies aloud"]'
|
||||
)
|
||||
|
||||
await composer.click()
|
||||
await composer.type(BLOCKING_CLARIFY_TRIGGER)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.getByText(BLOCKING_CLARIFY_QUESTION).waitFor({ state: 'visible', timeout: 30_000 })
|
||||
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Stop')
|
||||
await expect(primary.locator('span')).toHaveClass(/bg-current/)
|
||||
|
||||
await composer.click()
|
||||
await composer.type('please answer tersely')
|
||||
// Since "running is not busy" (3bc52fb9df) the primary keeps the Send
|
||||
// affordance mid-turn — steer is routed through the submit engine, not a
|
||||
// separate labeled button. Queue remains the explicit secondary action.
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Send')
|
||||
await expect(dictation).toBeVisible()
|
||||
await expect(speakReplies).toBeVisible()
|
||||
await expect(queue).toBeVisible()
|
||||
await expect(queue.locator('svg.tabler-icon-layers-intersect-2')).toBeVisible()
|
||||
const controlLabels = await page
|
||||
.locator('[data-slot="composer-root"] button')
|
||||
.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-label')))
|
||||
const speakRepliesIndex = controlLabels.findIndex(
|
||||
label => label === 'Read replies aloud' || label === 'Stop reading replies aloud'
|
||||
)
|
||||
expect(controlLabels.indexOf('Voice dictation')).toBeLessThan(speakRepliesIndex)
|
||||
expect(speakRepliesIndex).toBeLessThan(controlLabels.indexOf('Queue message'))
|
||||
expect(controlLabels.indexOf('Queue message')).toBeLessThan(controlLabels.indexOf('Send'))
|
||||
await page.screenshot({ path: testInfo.outputPath('busy-composer-steer.png') })
|
||||
await expect(primary.locator('.codicon-arrow-up')).toBeVisible()
|
||||
|
||||
await queue.click()
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Stop')
|
||||
await expect(queue).toHaveCount(0)
|
||||
await page.screenshot({ path: testInfo.outputPath('busy-composer-queue.png') })
|
||||
await expect(page.getByText('1 Queued')).toBeVisible()
|
||||
|
||||
await primary.click()
|
||||
await expect(page.getByText('1 Queued — paused')).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath('busy-composer-queue-paused.png') })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Context-menu edit verbs on real editables — the regressions jsdom cannot
|
||||
* catch, exercised against the real renderer (real radix focus trap, real
|
||||
* React unmount timing, real selection).
|
||||
*
|
||||
* The class under test: "Select all" from the app context menu must act on
|
||||
* the FIELD the menu was opened on, never on the surrounding transcript.
|
||||
* The first fix (focus-restore before dispatch) passed unit tests and still
|
||||
* failed live because the radix trap steals focus back; the second fix runs
|
||||
* selection renderer-side after the trap unmounts. These tests pin the
|
||||
* observable outcome, not the mechanism.
|
||||
*
|
||||
* Menu items are addressed by accessible-name PREFIX (`/^Copy/`): the name
|
||||
* includes the shortcut suffix ("Copy Ctrl+V" / "Copy ⌘V"), which is also
|
||||
* host-dependent.
|
||||
*/
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { expect, test } from './test'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('select all from the composer context menu selects the draft, not the chat', async () => {
|
||||
const page = fixture!.page
|
||||
const composer = page.locator('[data-slot="composer-rich-input"]').first()
|
||||
|
||||
// Put a message into the transcript so there is chat text a document-wide
|
||||
// select-all WOULD grab — the bug this test exists to catch. Wait for the
|
||||
// mock reply to COMPLETE: while the turn is busy the composer is in its
|
||||
// steer shape and a typed draft does not land in it.
|
||||
await composer.click()
|
||||
await composer.pressSequentially('transcript anchor message')
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForFunction(() => (document.body.textContent ?? '').includes('mock inference server'), undefined, {
|
||||
timeout: 60_000
|
||||
})
|
||||
|
||||
// Draft text in the composer, then right-click it.
|
||||
await composer.click()
|
||||
await composer.pressSequentially('draft under selection')
|
||||
await composer.click({ button: 'right' })
|
||||
|
||||
const selectAll = page.getByRole('menuitem', { name: /^Select all/ })
|
||||
|
||||
await selectAll.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await selectAll.click()
|
||||
|
||||
// The selection must live inside the composer and cover exactly the draft.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const selection = window.getSelection()
|
||||
const editable = document.querySelector('[data-slot="composer-rich-input"]')
|
||||
|
||||
if (!selection || selection.rangeCount === 0 || !editable) {
|
||||
return { inside: false, text: '' }
|
||||
}
|
||||
|
||||
return {
|
||||
inside: editable.contains(selection.getRangeAt(0).commonAncestorContainer),
|
||||
text: selection.toString()
|
||||
}
|
||||
}),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toEqual({ inside: true, text: 'draft under selection' })
|
||||
|
||||
// Clear the draft so later tests start clean.
|
||||
await page.keyboard.press('Delete')
|
||||
})
|
||||
|
||||
test('cut, copy, and select all gray out in an empty composer', async () => {
|
||||
const page = fixture!.page
|
||||
const composer = page.locator('[data-slot="composer-rich-input"]').first()
|
||||
|
||||
await composer.click()
|
||||
await composer.click({ button: 'right' })
|
||||
|
||||
const selectAll = page.getByRole('menuitem', { name: /^Select all/ })
|
||||
|
||||
await selectAll.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
|
||||
await expect(selectAll).toHaveAttribute('data-disabled', /.*/)
|
||||
await expect(page.getByRole('menuitem', { name: /^Cut/ })).toHaveAttribute('data-disabled', /.*/)
|
||||
await expect(page.getByRole('menuitem', { name: /^Copy/ })).toHaveAttribute('data-disabled', /.*/)
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
test('paste enables when the clipboard holds text', async () => {
|
||||
const page = fixture!.page
|
||||
const composer = page.locator('[data-slot="composer-rich-input"]').first()
|
||||
|
||||
// The empty-clipboard branch stays in the unit suite: the e2e app shares
|
||||
// the SYSTEM clipboard, and writeText('') does not reliably clear it.
|
||||
await page.evaluate(() =>
|
||||
(
|
||||
window as unknown as { hermesDesktop?: { writeClipboard?: (text: string) => Promise<boolean> } }
|
||||
).hermesDesktop?.writeClipboard?.('clipboard payload')
|
||||
)
|
||||
await composer.click()
|
||||
await composer.click({ button: 'right' })
|
||||
|
||||
const paste = page.getByRole('menuitem', { name: /^Paste/ })
|
||||
|
||||
await paste.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
|
||||
// The clipboard probe is an async IPC — the item enables when it lands.
|
||||
await expect.poll(() => paste.getAttribute('data-disabled'), { timeout: 10_000 }).toBeNull()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Regression coverage for a correction sent during a live response, then a
|
||||
* warm session switch away and back. The correction is an accepted user turn,
|
||||
* not an optimistic duplicate of the original prompt, and its relative place
|
||||
* in the transcript must survive the resume reconciliation.
|
||||
*/
|
||||
|
||||
import { type TestInfo } from '@playwright/test'
|
||||
|
||||
import { expect, test, type Page } from './test'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { CORRECTION_SWITCH_TRIGGER, MOCK_REPLY } from './mock-server'
|
||||
|
||||
const OTHER_SESSION_PROMPT = 'E2E persisted session used for a warm resume.'
|
||||
const ORIGINAL_PROMPT = `${CORRECTION_SWITCH_TRIGGER}: original prompt must remain singular after a correction.`
|
||||
const CORRECTION = 'E2E correction must stay after the original prompt.'
|
||||
const TOOL_STARTED = 'Checking the long-running task before I continue.'
|
||||
const CORRECTED_REPLY = 'The corrected task finished.'
|
||||
const INFERENCE_SWITCH_TRIGGER = 'E2E_INFERENCE_SWITCH_TRIGGER'
|
||||
const INFERENCE_PROMPT = `${INFERENCE_SWITCH_TRIGGER}: original inference prompt must remain singular.`
|
||||
const INFERENCE_CORRECTION = `${INFERENCE_SWITCH_TRIGGER}: correction sent while inference is live.`
|
||||
|
||||
// Inactive tabs stay mounted under a data-pane-hidden ancestor. Match the
|
||||
// renderer's keep-alive visibility policy instead of relying on DOM order.
|
||||
const SURFACE = '[data-composer-target]:not([data-pane-hidden] [data-composer-target])'
|
||||
|
||||
function activeSurface(page: Page) {
|
||||
return page.locator(SURFACE).last()
|
||||
}
|
||||
|
||||
async function send(page: Page, text: string): Promise<void> {
|
||||
const composer = activeSurface(page).locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function steer(page: Page, text: string): Promise<void> {
|
||||
const surface = activeSurface(page)
|
||||
const composer = surface.locator('[contenteditable="true"]').first()
|
||||
const primary = surface.locator('[data-slot="composer-root"] button[type="submit"]')
|
||||
|
||||
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
// Since "running is not busy" (3bc52fb9df) the primary keeps the Send label
|
||||
// mid-turn; the submit engine still routes a text payload to steer.
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Send')
|
||||
await primary.click()
|
||||
}
|
||||
|
||||
async function waitForTranscriptText(page: Page, text: string): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
([expected, surfaceSelector]: [string, string]) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const active = surfaces[surfaces.length - 1]
|
||||
|
||||
return (active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected)
|
||||
},
|
||||
[text, SURFACE] as [string, string],
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function textNodeOccurrences(page: Page, text: string): Promise<number> {
|
||||
return page.evaluate(
|
||||
([expected, surfaceSelector]: [string, string]) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return 0
|
||||
|
||||
const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT)
|
||||
let count = 0
|
||||
while (walker.nextNode()) {
|
||||
if (walker.currentNode.textContent?.includes(expected)) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
},
|
||||
[text, SURFACE] as [string, string],
|
||||
)
|
||||
}
|
||||
|
||||
async function transcriptTextOrder(page: Page): Promise<string[]> {
|
||||
return page.evaluate((surfaceSelector: string) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return []
|
||||
|
||||
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="message"], [data-message-id]'))
|
||||
.map(message => message.textContent?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
}, SURFACE)
|
||||
}
|
||||
|
||||
async function transcriptMessageOrder(page: Page): Promise<string[]> {
|
||||
return page.evaluate((surfaceSelector: string) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return []
|
||||
|
||||
return Array.from(
|
||||
viewport.querySelectorAll<HTMLElement>('[data-role="user"], [data-role="assistant"], [data-role="system"]'),
|
||||
)
|
||||
.map(message => message.textContent?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
}, SURFACE)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar "+" opens a NEW TAB beside the current chat rather than
|
||||
* replacing it, so the prior session stays mounted in its own surface. Wait
|
||||
* for the newly-mounted surface to show an empty transcript instead of waiting
|
||||
* for the old text to disappear from the page (it never will).
|
||||
*/
|
||||
async function openFreshDraft(page: Page, priorSessionText: string): Promise<void> {
|
||||
await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
|
||||
await page.waitForFunction(
|
||||
([priorText, surfaceSelector]: [string, string]) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const active = surfaces[surfaces.length - 1]
|
||||
const transcript = active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? ''
|
||||
|
||||
return surfaces.length > 0 && !transcript.includes(priorText)
|
||||
},
|
||||
[priorSessionText, SURFACE] as [string, string],
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function openSidebarSession(page: Page, sidebarText: string, expectedTranscriptText: string): Promise<void> {
|
||||
const row = page.locator('[data-slot="sidebar"] button').filter({ hasText: sidebarText }).first()
|
||||
await row.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await row.click()
|
||||
await waitForTranscriptText(page, expectedTranscriptText)
|
||||
}
|
||||
|
||||
async function reopenOriginalSession(page: Page): Promise<void> {
|
||||
// A still-running tool has not generated a final title yet, so the sidebar
|
||||
// retains the source prompt as its provisional session title.
|
||||
await openSidebarSession(page, ORIGINAL_PROMPT, ORIGINAL_PROMPT)
|
||||
}
|
||||
|
||||
async function reopenInferenceSession(page: Page): Promise<void> {
|
||||
const row = page.locator('[data-slot="sidebar"] button').filter({ hasText: INFERENCE_PROMPT }).first()
|
||||
await row.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await row.click()
|
||||
await waitForTranscriptText(page, INFERENCE_PROMPT)
|
||||
}
|
||||
|
||||
function relevantOrder(messages: string[]): string[] {
|
||||
return messages.flatMap(message => {
|
||||
if (message.includes(ORIGINAL_PROMPT)) return [ORIGINAL_PROMPT]
|
||||
if (message.includes(CORRECTION)) return [CORRECTION]
|
||||
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
function steerTurnOrder(messages: string[]): string[] {
|
||||
return messages.flatMap(message => {
|
||||
if (message.includes(ORIGINAL_PROMPT)) return [ORIGINAL_PROMPT]
|
||||
if (message.includes(CORRECTION)) return [CORRECTION]
|
||||
if (message.includes(CORRECTED_REPLY)) return [CORRECTED_REPLY]
|
||||
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('correction session switch', () => {
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeEach(async () => {
|
||||
fixture = await setupMockBackend({
|
||||
mockServer: { holdFirstStreamForPrompt: INFERENCE_SWITCH_TRIGGER },
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('keeps a live correction in place and does not duplicate its original prompt after switching sessions', async ({}, testInfo: TestInfo) => {
|
||||
const { page } = fixture!
|
||||
|
||||
// A blank draft does not exercise session hydration. Seed a real second
|
||||
// session first, matching the observed switch between two saved chats.
|
||||
await send(page, OTHER_SESSION_PROMPT)
|
||||
await waitForTranscriptText(page, MOCK_REPLY)
|
||||
await openFreshDraft(page, OTHER_SESSION_PROMPT)
|
||||
|
||||
await send(page, ORIGINAL_PROMPT)
|
||||
await waitForTranscriptText(page, TOOL_STARTED)
|
||||
await waitForTranscriptText(page, ORIGINAL_PROMPT)
|
||||
|
||||
// The historical session redirects while a foreground terminal task is
|
||||
// running. Use the visible Steer action to cover the real composer path.
|
||||
await steer(page, CORRECTION)
|
||||
await waitForTranscriptText(page, CORRECTION)
|
||||
|
||||
const orderBeforeSwitch = relevantOrder(await transcriptTextOrder(page))
|
||||
expect(orderBeforeSwitch).toEqual([ORIGINAL_PROMPT, CORRECTION])
|
||||
expect(await textNodeOccurrences(page, ORIGINAL_PROMPT)).toBe(1)
|
||||
expect(await textNodeOccurrences(page, CORRECTION)).toBe(1)
|
||||
await page.screenshot({ path: testInfo.outputPath('correction-before-session-switch.png') })
|
||||
|
||||
// Reproduce the observed race: switch to another persisted session while
|
||||
// the foreground tool is live, then return before its redirect settles.
|
||||
// Sidebar rows title by the session's first user prompt (auto-title is
|
||||
// disabled in the e2e fixture config).
|
||||
await openSidebarSession(page, OTHER_SESSION_PROMPT, OTHER_SESSION_PROMPT)
|
||||
await reopenOriginalSession(page)
|
||||
// The warm resume first paints the persisted history and then reconciles
|
||||
// the live turn (including a steer whose persistence may lag on a loaded
|
||||
// runner) back in. Poll to the converged order instead of sampling one
|
||||
// arbitrary mid-reconcile frame; the duplicate checks then pin the
|
||||
// regression (the prompt/correction must appear exactly once).
|
||||
await expect
|
||||
.poll(async () => relevantOrder(await transcriptTextOrder(page)), {
|
||||
message: 'correction should stay in place after the warm resume',
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toEqual(orderBeforeSwitch)
|
||||
await page.screenshot({ path: testInfo.outputPath('correction-after-warm-resume.png') })
|
||||
|
||||
expect(await textNodeOccurrences(page, ORIGINAL_PROMPT)).toBe(1)
|
||||
expect(await textNodeOccurrences(page, CORRECTION)).toBe(1)
|
||||
|
||||
await waitForTranscriptText(page, CORRECTED_REPLY)
|
||||
// The post-turn stored-history reconcile can momentarily repaint from a
|
||||
// snapshot in which the steer's user row hasn't been folded back in yet —
|
||||
// poll to the converged order instead of sampling one frame.
|
||||
await expect
|
||||
.poll(async () => steerTurnOrder(await transcriptMessageOrder(page)), {
|
||||
message: 'steered turn should settle as prompt → correction → corrected reply',
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toEqual([ORIGINAL_PROMPT, CORRECTION, CORRECTED_REPLY])
|
||||
})
|
||||
|
||||
test('keeps an inference-time correction visible through a warm session switch', async ({}, testInfo: TestInfo) => {
|
||||
const { mock, page } = fixture!
|
||||
|
||||
await send(page, OTHER_SESSION_PROMPT)
|
||||
await waitForTranscriptText(page, MOCK_REPLY)
|
||||
await openFreshDraft(page, OTHER_SESSION_PROMPT)
|
||||
|
||||
await send(page, INFERENCE_PROMPT)
|
||||
await mock.waitForHeldStream()
|
||||
await waitForTranscriptText(page, INFERENCE_PROMPT)
|
||||
|
||||
await send(page, INFERENCE_CORRECTION)
|
||||
await waitForTranscriptText(page, INFERENCE_CORRECTION)
|
||||
|
||||
await openSidebarSession(page, OTHER_SESSION_PROMPT, OTHER_SESSION_PROMPT)
|
||||
await reopenInferenceSession(page)
|
||||
|
||||
expect(await textNodeOccurrences(page, INFERENCE_PROMPT)).toBe(1)
|
||||
expect(await textNodeOccurrences(page, INFERENCE_CORRECTION)).toBe(1)
|
||||
await page.screenshot({ path: testInfo.outputPath('inference-correction-after-warm-resume.png') })
|
||||
|
||||
mock.releaseHeldStream()
|
||||
await waitForTranscriptText(page, MOCK_REPLY)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Locating the dev Electron binary for the e2e fixtures.
|
||||
*
|
||||
* Kept in its own module so the resolution rules can be unit-tested without
|
||||
* importing the Playwright runner (fixtures.ts pulls in `_electron`, the mock
|
||||
* server and the error-banner guard).
|
||||
*
|
||||
* Three rules the previous single-path probe got wrong:
|
||||
*
|
||||
* 1. The binary is not always under the REPO ROOT. This is an npm workspaces
|
||||
* repo, and npm only hoists a dependency to the root when nothing conflicts
|
||||
* — otherwise `electron` installs into `apps/desktop/node_modules`. Both
|
||||
* layouts are normal, so both have to be searched, nearest package first.
|
||||
* 2. The binary is `electron.exe` on Windows. A bare `electron` never exists
|
||||
* there, so the probe could only ever miss.
|
||||
* 3. `which` is not a command on Windows. The PATH fallback spawned it
|
||||
* unconditionally, so on Windows the fallback failed for the wrong reason
|
||||
* and the error message blamed a missing `npm install`.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import * as fs from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import * as path from 'node:path'
|
||||
|
||||
/** The dist file name: `electron.exe` on Windows, `electron` elsewhere. */
|
||||
export function electronBinaryName(platform: NodeJS.Platform = process.platform): string {
|
||||
return platform === 'win32' ? 'electron.exe' : 'electron'
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an npm install can leave the binary, in probe order: nearest package
|
||||
* first, so a workspace-local install wins over a stale hoisted one.
|
||||
*/
|
||||
export function electronDistCandidates(roots: string[], platform: NodeJS.Platform = process.platform): string[] {
|
||||
return roots.map((root) => path.join(root, 'node_modules', 'electron', 'dist', electronBinaryName(platform)))
|
||||
}
|
||||
|
||||
/** The PATH-lookup command for this platform. Windows has `where`, not `which`. */
|
||||
export function pathLookupCommand(platform: NodeJS.Platform = process.platform): string {
|
||||
return platform === 'win32' ? 'where' : 'which'
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the installed `electron` package where its own binary is.
|
||||
*
|
||||
* Its main export IS the absolute executable path, resolved from `path.txt`
|
||||
* and honouring `ELECTRON_OVERRIDE_DIST_PATH`, so this covers layouts and
|
||||
* overrides a hand-built path cannot know about. Returns null when the package
|
||||
* is not resolvable from `from`, or when it does not hand back a path (the
|
||||
* export is the Electron API object, not a path, when required from inside
|
||||
* Electron itself).
|
||||
*/
|
||||
export function electronPackagePath(from: string): null | string {
|
||||
try {
|
||||
const resolved = createRequire(path.join(from, 'package.json'))('electron') as unknown
|
||||
|
||||
return typeof resolved === 'string' && resolved ? resolved : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Electron binary, or throw with the layouts that were searched.
|
||||
*
|
||||
* `roots` are searched in order; pass the desktop package before the repo root.
|
||||
*/
|
||||
export function resolveElectronBinary(roots: string[]): string {
|
||||
for (const root of roots) {
|
||||
const declared = electronPackagePath(root)
|
||||
|
||||
if (declared && fs.existsSync(declared)) {
|
||||
return declared
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of electronDistCandidates(roots)) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
// Nix devshells put `electron` on PATH with no node_modules copy at all.
|
||||
const lookup = spawnSync(pathLookupCommand(), ['electron'], { encoding: 'utf8' })
|
||||
|
||||
if (lookup.status === 0 && lookup.stdout.trim()) {
|
||||
// `where` reports every match, one per line; take the first.
|
||||
const first = lookup.stdout.trim().split(/\r?\n/)[0].trim()
|
||||
|
||||
if (first) {
|
||||
return first
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Electron binary not found. Searched ${electronDistCandidates(roots).join(', ')} and PATH. ` +
|
||||
'Run "npm install" from the repo root to install devDependencies.',
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { electronBinaryName, electronDistCandidates, pathLookupCommand } from './electron-binary'
|
||||
|
||||
// Platform is a parameter everywhere below rather than read from
|
||||
// process.platform, so the Windows rules are pinned on the Linux CI runner too.
|
||||
// Reading the real platform would leave every Windows-only rule untested.
|
||||
|
||||
describe('electronBinaryName', () => {
|
||||
it('asks for electron.exe on Windows', () => {
|
||||
expect(electronBinaryName('win32')).toBe('electron.exe')
|
||||
})
|
||||
|
||||
it('asks for a bare electron everywhere else', () => {
|
||||
expect(electronBinaryName('linux')).toBe('electron')
|
||||
expect(electronBinaryName('darwin')).toBe('electron')
|
||||
})
|
||||
})
|
||||
|
||||
describe('electronDistCandidates', () => {
|
||||
const desktop = path.join('repo', 'apps', 'desktop')
|
||||
const repo = 'repo'
|
||||
|
||||
it('probes the workspace-local install before the hoisted one', () => {
|
||||
// npm only hoists `electron` to the repo root when nothing conflicts, so
|
||||
// apps/desktop/node_modules is an ordinary outcome of `npm install`, not a
|
||||
// broken tree. Probing only the repo root is what makes the suite refuse to
|
||||
// start with "run npm install" on a tree that has electron installed.
|
||||
expect(electronDistCandidates([desktop, repo], 'linux')).toEqual([
|
||||
path.join(desktop, 'node_modules', 'electron', 'dist', 'electron'),
|
||||
path.join(repo, 'node_modules', 'electron', 'dist', 'electron'),
|
||||
])
|
||||
})
|
||||
|
||||
it('carries the platform binary name into every candidate', () => {
|
||||
// A bare `electron` file never exists in a Windows dist, so a probe built
|
||||
// from a hardcoded name cannot match there no matter which root it walks.
|
||||
for (const candidate of electronDistCandidates([desktop, repo], 'win32')) {
|
||||
expect(path.basename(candidate)).toBe('electron.exe')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('pathLookupCommand', () => {
|
||||
it('uses where on Windows and which elsewhere', () => {
|
||||
// `which` is not a command on Windows; spawning it unconditionally made the
|
||||
// PATH fallback fail for a reason unrelated to whether electron is on PATH.
|
||||
expect(pathLookupCommand('win32')).toBe('where')
|
||||
expect(pathLookupCommand('linux')).toBe('which')
|
||||
expect(pathLookupCommand('darwin')).toBe('which')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Monkey-patch: playwright's test runner never calls tracing.start() on
|
||||
* Electron's internal BrowserContext because:
|
||||
* 1. Playwright._allContexts() only returns [chromium, firefox, webkit]
|
||||
* contexts — Electron's context is excluded.
|
||||
* 2. ArtifactsRecorder.didCreateBrowserContext runs in willStartTest, before
|
||||
* beforeAll launches the electron app.
|
||||
* 3. The runAfterCreateBrowserContext hook doesn't exist on the Electron
|
||||
* class (only on BrowserType).
|
||||
*
|
||||
* As a result, trace screenshots (screencast) and DOM snapshots are never
|
||||
* captured for electron tests.
|
||||
*
|
||||
* This patch:
|
||||
* 1. Patches _allContexts() to include electron contexts, so the test
|
||||
* runner's didFinishTest() cleanup calls _stopTracing() → stopChunk()
|
||||
* on the electron context (saving the trace chunk + merging it into
|
||||
* the final trace.zip).
|
||||
* 2. Manually calls tracing.start() + startChunk() after launch.
|
||||
* 3. Wraps tracing.start to become startChunk after the first call,
|
||||
* so the test runner's willStartTest doesn't throw "already started".
|
||||
*
|
||||
* Imported from playwright.config.ts so it runs before any test.
|
||||
*
|
||||
* Pinned dependency: this file reaches into Playwright internals (_playwright,
|
||||
* _allContexts, _context) that have no public contract. @playwright/test is
|
||||
* pinned exact (=1.58.2 in package.json) so a bump can't silently break the
|
||||
* monkeypatch. When bumping, re-verify these private symbols still exist on
|
||||
* the Electron / PlaywrightInternal classes and that tracing still merges.
|
||||
*/
|
||||
|
||||
import { _electron as electron, type BrowserContext } from '@playwright/test'
|
||||
import * as crypto from 'node:crypto'
|
||||
|
||||
const electronContexts = new Set<BrowserContext>()
|
||||
const originalLaunch = electron.launch.bind(electron)
|
||||
|
||||
electron.launch = async (options: any) => {
|
||||
const app = await originalLaunch(options)
|
||||
const ctx = (app as any)._context as BrowserContext
|
||||
electronContexts.add(ctx)
|
||||
ctx.once('close', () => electronContexts.delete(ctx))
|
||||
|
||||
// Patch _allContexts so the test runner sees the electron context
|
||||
// (didFinishTest cleanup → _stopTracing → stopChunk → merge into trace.zip).
|
||||
const pw = (electron as any)._playwright as any
|
||||
if (pw && !pw.__electronTracingPatched) {
|
||||
pw.__electronTracingPatched = true
|
||||
const original = pw._allContexts.bind(pw)
|
||||
pw._allContexts = () => [...original(), ...electronContexts]
|
||||
}
|
||||
|
||||
// Start tracing — mirrors ArtifactsRecorder.didCreateBrowserContext.
|
||||
const traceName = crypto.randomUUID()
|
||||
await ctx.tracing.start({
|
||||
screenshots: true,
|
||||
snapshots: true,
|
||||
sources: true,
|
||||
}).catch(() => {})
|
||||
await ctx.tracing.startChunk({ title: 'electron', name: traceName }).catch(() => {})
|
||||
|
||||
// Wrap tracing.start to redirect to startChunk after the first call.
|
||||
// The test runner's willStartTest calls tracing.start() on all contexts
|
||||
// in _allContexts(). Since we already started, redirect to startChunk
|
||||
// to avoid "Tracing has been already started" errors.
|
||||
const tracing = ctx.tracing as any
|
||||
tracing.start = async (opts: any) => {
|
||||
return tracing.startChunk(opts)
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
/**
|
||||
* 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 { _electron, type ElectronApplication, type Page } from '@playwright/test'
|
||||
|
||||
import { resolveElectronBinary } from './electron-binary'
|
||||
import { startMockServer, type MockServerOptions } from './mock-server'
|
||||
import { installErrorBannerGuard } from './test'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export 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 })
|
||||
|
||||
// Write a fixed window-state.json so the Electron window opens at a
|
||||
// consistent size — helps with visual regression screenshots. The
|
||||
// exact size is also enforced right before each screenshot (see
|
||||
// expectVisualSnapshot in visual-snapshot.ts) because window managers
|
||||
// may resize after launch.
|
||||
fs.writeFileSync(
|
||||
path.join(userDataDir, 'window-state.json'),
|
||||
JSON.stringify(
|
||||
{ x: 0, y: 0, width: 1220, height: 800, isMaximized: false },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
// Pin Chromium actual-size zoom (level 0) for the suite. Fresh installs
|
||||
// ship DEFAULT_ZOOM_LEVEL at the Appearance 90% preset, but Playwright
|
||||
// click hit-testing and the committed visual baselines were calibrated at
|
||||
// 100%. Without this file every sandbox would inherit the product default
|
||||
// and fail pointer interception + snapshot diffs.
|
||||
fs.writeFileSync(
|
||||
path.join(userDataDir, 'zoom-state.json'),
|
||||
JSON.stringify({ zoomLevel: 0 }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
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.
|
||||
*
|
||||
* @param extraDisplayConfig optional YAML lines appended to the `display:`
|
||||
* section, used by the interim-message e2e test.
|
||||
* @param extraConfig optional top-level YAML sections for a test scenario.
|
||||
* @param modelContextLength optional primary-model context limit.
|
||||
*/
|
||||
export function writeMockProviderConfig(
|
||||
hermesHome: string,
|
||||
mockUrl: string,
|
||||
extraDisplayConfig?: string,
|
||||
extraConfig?: string,
|
||||
modelContextLength?: number,
|
||||
): void {
|
||||
const configPath = path.join(hermesHome, 'config.yaml')
|
||||
|
||||
const displaySection = extraDisplayConfig
|
||||
? `\ndisplay:\n${extraDisplayConfig}\n`
|
||||
: ''
|
||||
|
||||
// Title generation rides the MAIN model since 87af576e60 (#83636), so every
|
||||
// completed turn fires an extra background /v1/chat/completions at the mock.
|
||||
// That request contains the whole conversation — trigger keywords included —
|
||||
// which advances the mock's scripted-turn indices and trips hold-for-prompt
|
||||
// matchers from a request no spec ever sent. Disable it by default (no e2e
|
||||
// spec asserts on session titles); a test that passes its own `auxiliary:`
|
||||
// section via extraConfig owns the whole section instead.
|
||||
const autoTitleDefault = extraConfig?.includes('auxiliary:')
|
||||
? ''
|
||||
: 'auxiliary:\n title_generation:\n enabled: false\n'
|
||||
|
||||
// The scripted turns run REAL terminal commands, and anything the guard
|
||||
// classifies as dangerous (e.g. the sidebar sentinel-wait loop) parks the
|
||||
// turn behind a Run/Reject approval card. The default 'smart' mode then
|
||||
// fires an aux LLM approval call at the SAME mock provider — consuming a
|
||||
// scripted-turn index and never resolving — so the turn stalls until the
|
||||
// spec times out (the CI failure mode for the sidebar-dot family). No e2e
|
||||
// spec asserts on the approval flow, so run gate-free by default; a test
|
||||
// that passes its own `approvals:` section via extraConfig owns it.
|
||||
const approvalsDefault = extraConfig?.includes('approvals:')
|
||||
? ''
|
||||
: 'approvals:\n mode: "off"\n'
|
||||
|
||||
const config = `# Auto-generated by E2E test fixtures
|
||||
model:
|
||||
default: mock-model
|
||||
provider: mock
|
||||
${modelContextLength ? ` context_length: ${modelContextLength}\n` : ''}providers:
|
||||
mock:
|
||||
api: ${mockUrl}/v1
|
||||
name: Mock
|
||||
api_mode: chat_completions
|
||||
key_env: MOCK_API_KEY
|
||||
models:
|
||||
mock-model: {}
|
||||
context_length: 4096
|
||||
${autoTitleDefault}${approvalsDefault}${displaySection}${extraConfig ? `\n${extraConfig.trim()}\n` : ''}`
|
||||
|
||||
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.
|
||||
*/
|
||||
export 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
|
||||
*/
|
||||
export 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()}`,
|
||||
// `app.close()` in teardown must exit even when a spec leaves a turn
|
||||
// mid-flight — otherwise the quit confirmation waits on a click that no
|
||||
// one is there to make, and the worker dies on a teardown timeout.
|
||||
HERMES_DESKTOP_SKIP_QUIT_CONFIRM: '1',
|
||||
// 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/electron install from either package.
|
||||
*/
|
||||
export 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.
|
||||
//
|
||||
// The desktop package is searched first: npm workspaces only hoist
|
||||
// `electron` to the repo root when nothing conflicts, so a workspace-local
|
||||
// install is just as ordinary an outcome as a hoisted one. The rules live in
|
||||
// ./electron-binary so they can be unit-tested per platform.
|
||||
return resolveElectronBinary([DESKTOP_ROOT, REPO_ROOT])
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export 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',
|
||||
],
|
||||
env,
|
||||
cwd: DESKTOP_ROOT,
|
||||
})
|
||||
|
||||
const page = await app.firstWindow()
|
||||
|
||||
// Install the error-banner guard so any [role="alert"] that appears
|
||||
// during a test is collected and surfaced in afterEach.
|
||||
installErrorBannerGuard(page)
|
||||
|
||||
return { app, page }
|
||||
}
|
||||
|
||||
// ─── Public fixtures ────────────────────────────────────────────────────
|
||||
|
||||
export interface MockBackendFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
mock: Awaited<ReturnType<typeof startMockServer>>
|
||||
mockUrl: string
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface MockBackendOptions {
|
||||
/**
|
||||
* Optional YAML lines to inject under the `display:` section of the
|
||||
* generated config.yaml. Used by the interim-message e2e test to toggle
|
||||
* `display.interim_assistant_messages`.
|
||||
*/
|
||||
extraDisplayConfig?: string
|
||||
/** Additional top-level config.yaml sections for an E2E scenario. */
|
||||
extraConfig?: string
|
||||
/** Override the mock model's context window for compression scenarios. */
|
||||
modelContextLength?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 interface MockBackendOptions {
|
||||
mockServer?: MockServerOptions
|
||||
}
|
||||
|
||||
export async function setupMockBackend(options: MockBackendOptions = {}): Promise<MockBackendFixture> {
|
||||
// 1. Start mock server
|
||||
const mock = await startMockServer(options.mockServer)
|
||||
|
||||
// 2. Create sandbox + write config
|
||||
const sandbox = createSandbox('mock')
|
||||
writeMockProviderConfig(
|
||||
sandbox.hermesHome,
|
||||
mock.url,
|
||||
options.extraDisplayConfig,
|
||||
options.extraConfig,
|
||||
options.modelContextLength,
|
||||
)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
// 3. Build env + launch
|
||||
const env = buildAppEnv(sandbox)
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
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>
|
||||
}
|
||||
|
||||
export interface DeadBackendOptions {
|
||||
/**
|
||||
* When true, inject a fake boot error via HERMES_DESKTOP_BOOT_FAKE_ERROR
|
||||
* so the backend resolution itself "fails" with a controlled error message.
|
||||
* This is the only reliable way to trigger BootFailureOverlay in dev mode
|
||||
* (the real backend always resolves via SOURCE_REPO_ROOT).
|
||||
*/
|
||||
fakeError?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the app with a provider pointing at a dead endpoint (port 1, which
|
||||
* nothing listens on). By default the backend still boots (`hermes serve`
|
||||
* starts fine — the dead endpoint only matters at chat time). Pass
|
||||
* `{ fakeError: true }` to inject a fake boot failure, triggering the
|
||||
* BootFailureOverlay.
|
||||
*/
|
||||
export async function setupDeadBackend(options: DeadBackendOptions = {}): 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:
|
||||
default: mock-model
|
||||
provider: mock
|
||||
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, options.fakeError ? { HERMES_DESKTOP_BOOT_FAKE_ERROR: 'Failed to connect to Hermes backend: connection refused' } : {})
|
||||
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 {
|
||||
if (process.platform === 'win32') {
|
||||
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'],
|
||||
env,
|
||||
})
|
||||
|
||||
const page = await app.firstWindow()
|
||||
installErrorBannerGuard(page)
|
||||
|
||||
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.
|
||||
*
|
||||
* Two things must both be true before we return:
|
||||
* 1. The composer (chat input) is visible — it's disabled until the
|
||||
* gateway is open.
|
||||
* 2. No full-screen overlay (onboarding Preparing, connecting overlay,
|
||||
* boot-failure) covers the viewport center. The composer can be
|
||||
* "visible" in Playwright's eyes (non-zero bounding box, not
|
||||
* display:none) even when a z-1300+ overlay is painted on top of it,
|
||||
* so checking the composer alone catches the app mid-boot at ~92%
|
||||
* with the loading bar still showing.
|
||||
*/
|
||||
export async function waitForAppReady(fixture: MockBackendFixture | NoProviderFixture | DeadBackendFixture, timeoutMs = 60_000): Promise<void> {
|
||||
const { page, app } = fixture
|
||||
|
||||
// Wait for the composer to exist in the DOM (not necessarily interactive yet).
|
||||
await page.waitForSelector('textarea, [contenteditable="true"]', {
|
||||
state: 'attached',
|
||||
timeout: timeoutMs,
|
||||
})
|
||||
|
||||
// Now poll until no full-screen overlay covers the viewport center.
|
||||
// elementFromPoint returns the topmost element at a point — if it's part
|
||||
// of a fixed inset-0 overlay (onboarding/connecting/boot-failure), the
|
||||
// app isn't ready yet.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
|
||||
|
||||
if (!el) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Walk up to the nearest positioned ancestor — overlays are
|
||||
// `position: fixed; inset: 0`. If the hit element or an ancestor
|
||||
// is a full-viewport fixed overlay, we're still covered.
|
||||
let node: Element | null = el
|
||||
while (node) {
|
||||
const cs = window.getComputedStyle(node)
|
||||
|
||||
if (cs.position === 'fixed') {
|
||||
const rect = node.getBoundingClientRect()
|
||||
|
||||
if (rect.left <= 0 && rect.top <= 0 && rect.right >= window.innerWidth && rect.bottom >= window.innerHeight) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
node = node.parentElement
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
undefined,
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
|
||||
// On Electron 40.x, ready-to-show may never fire (electron/electron#51972)
|
||||
// and the window stays hidden even though the DOM is rendered. The main
|
||||
// process reveals it anyway — immediately under TEST_WORKER_INDEX, and via
|
||||
// wireWindowReveal's post-load fallback in production — but the DOM can be
|
||||
// ready before that lands. Poll until the window is actually visible so
|
||||
// interactions (click, screenshot) don't hit a hidden surface.
|
||||
if (app) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const visible = await app.evaluate(({ BrowserWindow }) => {
|
||||
const w = BrowserWindow.getAllWindows()[0]
|
||||
|
||||
return w ? w.isVisible() : false
|
||||
}).catch(() => false)
|
||||
|
||||
if (visible) {break}
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')
|
||||
)
|
||||
},
|
||||
undefined,
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the boot failure overlay to appear.
|
||||
*/
|
||||
export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
// Boot failure is terminal: the backend gave up. The renderer shows
|
||||
// either BootFailureOverlay (z-1400, with Retry/Repair buttons) or
|
||||
// falls back to the onboarding picker (z-1300) as a recovery path.
|
||||
// We wait for the failure dialog itself — the Preparing component may
|
||||
// still paint its progress bar (recolored red) underneath the overlay,
|
||||
// which is harmless.
|
||||
const text = document.body.textContent ?? ''
|
||||
|
||||
// BootFailureOverlay buttons.
|
||||
const hasFailureUI =
|
||||
text.includes('Retry') ||
|
||||
text.includes('Repair') ||
|
||||
text.includes('Use local gateway') ||
|
||||
text.includes('Connection settings')
|
||||
|
||||
// The error toast / notification that fires on failDesktopBoot().
|
||||
const hasErrorToast = text.includes('Desktop boot failed')
|
||||
|
||||
return hasFailureUI || hasErrorToast
|
||||
},
|
||||
undefined,
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* E2E: the fleet profile rail with two registered gateways.
|
||||
*
|
||||
* "This device" is the Electron-managed local backend (mock inference). The
|
||||
* second gateway, "Homelab", is a REAL second `hermes serve` this spec spawns
|
||||
* with its own HERMES_HOME, profiles and session token, registered in the v2
|
||||
* connections.json as a remote URL connection. A click on an at-rest square
|
||||
* therefore performs the same dial → commit → re-home the statusbar switcher
|
||||
* does, against a real backend — not a stub.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists, and the
|
||||
* repo's Python venv (`.venv`) must exist for both backends.
|
||||
*/
|
||||
|
||||
import { type ChildProcess, spawn, spawnSync } from 'node:child_process'
|
||||
import * as fs from 'node:fs'
|
||||
import * as net from 'node:net'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
type MockBackendFixture,
|
||||
type Sandbox,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig,
|
||||
} from './fixtures'
|
||||
import { startMockServer } from './mock-server'
|
||||
import { type ElectronApplication, expect, type Page, test } from './test'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
|
||||
const REMOTE_LABEL = 'Homelab'
|
||||
const REMOTE_ID = 'homelab'
|
||||
const REMOTE_TOKEN = 'e2e-fleet-homelab-token'
|
||||
|
||||
interface RemoteGateway {
|
||||
url: string
|
||||
home: string
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
function findHermesBinary(): string {
|
||||
const venv = path.join(REPO_ROOT, '.venv', 'bin', 'hermes')
|
||||
|
||||
if (fs.existsSync(venv)) {
|
||||
return venv
|
||||
}
|
||||
|
||||
const result = spawnSync('which', ['hermes'], { encoding: 'utf8' })
|
||||
|
||||
if (result.status === 0 && result.stdout.trim()) {
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
throw new Error('hermes binary not found: create the repo venv (uv sync) or put hermes on PATH')
|
||||
}
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.unref()
|
||||
server.on('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address() as net.AddressInfo
|
||||
server.close(() => resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Seed `<home>/profiles/<name>/` so the backend's /api/profiles lists it. */
|
||||
function seedProfiles(home: string, names: string[]): void {
|
||||
for (const name of names) {
|
||||
const dir = path.join(home, 'profiles', name)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(path.join(dir, 'config.yaml'), '', 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a second, fully real `hermes serve` as the remote gateway. Its
|
||||
* session token is pinned through HERMES_DASHBOARD_SESSION_TOKEN so the
|
||||
* registry entry can carry a plaintext token envelope.
|
||||
*/
|
||||
async function startRemoteGateway(root: string, mockUrl: string, profiles: string[]): Promise<RemoteGateway> {
|
||||
const home = path.join(root, 'homelab-home')
|
||||
fs.mkdirSync(home, { recursive: true })
|
||||
writeMockProviderConfig(home, mockUrl)
|
||||
writeEnvFile(home)
|
||||
seedProfiles(home, profiles)
|
||||
|
||||
const port = await freePort()
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
|
||||
const child: ChildProcess = spawn(
|
||||
findHermesBinary(),
|
||||
['serve', '--host', '127.0.0.1', '--port', String(port), '--skip-build'],
|
||||
{
|
||||
cwd: REPO_ROOT,
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME: home,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: REMOTE_TOKEN,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
|
||||
let log = ''
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
log += chunk.toString()
|
||||
})
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
log += chunk.toString()
|
||||
})
|
||||
|
||||
const deadline = Date.now() + 90_000
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`remote hermes serve exited early (${child.exitCode}):\n${log}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${url}/api/status`, {
|
||||
headers: { 'X-Hermes-Session-Token': REMOTE_TOKEN },
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// not up yet
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
}
|
||||
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`remote hermes serve never became ready:\n${log}`)
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
home,
|
||||
close: async () => {
|
||||
if (child.pid && child.exitCode === null) {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM')
|
||||
} catch {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function writeConnectionsRegistry(sandbox: Sandbox, remoteUrl: string): void {
|
||||
fs.writeFileSync(
|
||||
path.join(sandbox.userDataDir, 'connections.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 2,
|
||||
primary: 'local',
|
||||
launchMode: 'primary',
|
||||
lastUsed: 'local',
|
||||
connections: [
|
||||
{ id: 'local', kind: 'local', label: 'This device' },
|
||||
{
|
||||
id: REMOTE_ID,
|
||||
kind: 'remote',
|
||||
label: REMOTE_LABEL,
|
||||
url: remoteUrl,
|
||||
authMode: 'token',
|
||||
token: { encoding: 'plain', value: REMOTE_TOKEN },
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
{ encoding: 'utf8', mode: 0o600 },
|
||||
)
|
||||
}
|
||||
|
||||
// FLEET_RAIL_SCREENSHOT_DIR=<dir> saves full-window captures at the key
|
||||
// states — handy for design review; never part of the assertions.
|
||||
async function capture(page: Page, name: string): Promise<void> {
|
||||
const dir = process.env.FLEET_RAIL_SCREENSHOT_DIR
|
||||
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
await page.screenshot({ path: path.join(dir, `${name}.png`) })
|
||||
}
|
||||
|
||||
const rail = (page: Page) => page.locator('[data-slot="profile-rail"]')
|
||||
const gatewayGroup = (page: Page, id: string) => rail(page).locator(`[data-slot="profile-rail-gateway"][data-connection-id="${id}"]`)
|
||||
const activeGatewayLabel = (page: Page) => page.getByRole('button', { name: /^Registered gateways: / })
|
||||
|
||||
async function groupOrder(page: Page): Promise<Array<[string, boolean]>> {
|
||||
return rail(page).locator('[data-slot="profile-rail-gateway"]').evaluateAll(nodes =>
|
||||
nodes.map(node => [node.getAttribute('data-connection-id') ?? '', node.getAttribute('data-active') === 'true'] as [string, boolean]),
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('fleet profile rail — two registered gateways', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let mock: Awaited<ReturnType<typeof startMockServer>>
|
||||
let sandbox: Sandbox
|
||||
let remote: RemoteGateway
|
||||
let app: ElectronApplication
|
||||
let page: Page
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.setTimeout(240_000)
|
||||
mock = await startMockServer()
|
||||
sandbox = createSandbox('fleet')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
// A named profile on This device too, so the active group has a square
|
||||
// beside its home pill. "research" exists on BOTH gateways on purpose: the
|
||||
// rail must keep the two apart by gateway, never by name alone.
|
||||
seedProfiles(sandbox.hermesHome, ['research'])
|
||||
|
||||
remote = await startRemoteGateway(sandbox.root, mock.url, ['inbox', 'research'])
|
||||
writeConnectionsRegistry(sandbox, remote.url)
|
||||
|
||||
;({ app, page } = await launchDesktop(buildAppEnv(sandbox)))
|
||||
await waitForAppReady({ app, page } as MockBackendFixture, 120_000)
|
||||
// Let boot settle fully (the gateway health item reports "ready" once the
|
||||
// primary socket is open) so the boot-time launch-mode restore has run
|
||||
// before any click — the rail must then hold whatever the user picks.
|
||||
await expect(page.locator('[data-slot="statusbar"]').getByText('ready', { exact: true })).toBeVisible({ timeout: 120_000 })
|
||||
await page.waitForTimeout(2_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await app?.close().catch(() => undefined)
|
||||
await remote?.close()
|
||||
await mock?.close()
|
||||
sandbox?.cleanup()
|
||||
})
|
||||
|
||||
test('lays both gateways on one strip, active gateway in its registry slot', async () => {
|
||||
// The statusbar readout names the gateway the workspace is on.
|
||||
await expect(activeGatewayLabel(page)).toHaveAttribute('aria-label', 'Registered gateways: This device', { timeout: 60_000 })
|
||||
|
||||
// The remote gateway's group appears once the roster has enumerated it.
|
||||
const homelab = gatewayGroup(page, REMOTE_ID)
|
||||
await expect(homelab).toBeVisible({ timeout: 60_000 })
|
||||
await expect(homelab.getByRole('button', { name: `default · ${REMOTE_LABEL}` })).toBeVisible()
|
||||
await expect(homelab.getByRole('button', { name: `inbox · ${REMOTE_LABEL}` })).toBeVisible()
|
||||
await expect(homelab.getByRole('button', { name: `research · ${REMOTE_LABEL}` })).toBeVisible()
|
||||
await expect(homelab).toHaveAttribute('data-reachable', 'true')
|
||||
|
||||
// Its marker carries the remote (network) glyph.
|
||||
await expect(
|
||||
rail(page).locator(`[data-slot="profile-rail-divider"][data-connection-id="${REMOTE_ID}"] [data-connection-kind="remote"]`),
|
||||
).toBeVisible()
|
||||
|
||||
// This device is the active group: its squares are unqualified, as before.
|
||||
const local = gatewayGroup(page, 'local')
|
||||
await expect(local).toHaveAttribute('data-active', 'true')
|
||||
await expect(local.getByRole('button', { name: 'research', exact: true })).toBeVisible()
|
||||
|
||||
// Registry order: This device first, Homelab second.
|
||||
expect(await groupOrder(page)).toEqual([
|
||||
['local', true],
|
||||
[REMOTE_ID, false],
|
||||
])
|
||||
|
||||
// Fleet pill replaces the default↔all toggle; the single-gateway plug is gone.
|
||||
await expect(rail(page).getByRole('button', { name: 'All profiles on this gateway' })).toBeVisible()
|
||||
await expect(rail(page).getByRole('button', { name: 'Manage gateways…' })).toHaveCount(0)
|
||||
|
||||
await gatewayGroup(page, REMOTE_ID).getByRole('button', { name: `inbox · ${REMOTE_LABEL}` }).hover()
|
||||
await capture(page, '1-on-this-device-hover-inbox-homelab')
|
||||
})
|
||||
|
||||
test('clicking an at-rest square re-homes onto that exact gateway and profile', async () => {
|
||||
test.setTimeout(180_000)
|
||||
await gatewayGroup(page, REMOTE_ID).getByRole('button', { name: `inbox · ${REMOTE_LABEL}` }).click()
|
||||
|
||||
// The workspace follows the agent: statusbar readout flips to Homelab…
|
||||
await expect(activeGatewayLabel(page)).toHaveAttribute('aria-label', `Registered gateways: ${REMOTE_LABEL}`, { timeout: 120_000 })
|
||||
|
||||
// …Homelab's group is now the active one, on the clicked profile…
|
||||
const homelab = gatewayGroup(page, REMOTE_ID)
|
||||
await expect(homelab).toHaveAttribute('data-active', 'true', { timeout: 30_000 })
|
||||
await expect(homelab.getByRole('button', { name: 'inbox', exact: true })).toHaveAttribute('aria-pressed', 'true', { timeout: 30_000 })
|
||||
|
||||
// …This device is at rest with qualified squares…
|
||||
const local = gatewayGroup(page, 'local')
|
||||
await expect(local).toHaveAttribute('data-active', 'false')
|
||||
await expect(local.getByRole('button', { name: 'research · This device' })).toBeVisible()
|
||||
|
||||
// …and nothing moved: the order is still This device, then Homelab.
|
||||
expect(await groupOrder(page)).toEqual([
|
||||
['local', false],
|
||||
[REMOTE_ID, true],
|
||||
])
|
||||
|
||||
await capture(page, '2-re-homed-on-homelab-inbox')
|
||||
})
|
||||
|
||||
test('an at-rest square offers gateway-scoped actions, never the legacy remote override', async () => {
|
||||
const square = gatewayGroup(page, 'local').getByRole('button', { name: 'research · This device' })
|
||||
await square.click({ button: 'right' })
|
||||
|
||||
const menu = page.getByRole('menu', { name: 'Actions' })
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(menu.getByRole('menuitem', { name: 'Switch to research on This device' })).toBeVisible()
|
||||
await expect(menu.getByRole('menuitem', { name: 'Rename…' })).toBeVisible()
|
||||
await expect(menu.getByRole('menuitem', { name: 'Edit SOUL.md…' })).toBeVisible()
|
||||
await expect(menu.getByRole('menuitem', { name: 'Delete' })).toBeVisible()
|
||||
await expect(menu.getByRole('menuitem', { name: 'Connect to a remote host…' })).toHaveCount(0)
|
||||
|
||||
await capture(page, '3-at-rest-square-context-menu')
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(menu).toBeHidden()
|
||||
})
|
||||
|
||||
test('editing SOUL.md on an at-rest square reads the owning gateway, not the foreground one', async () => {
|
||||
const square = gatewayGroup(page, 'local').getByRole('button', { name: 'research · This device' })
|
||||
await square.click({ button: 'right' })
|
||||
await page.getByRole('menu', { name: 'Actions' }).getByRole('menuitem', { name: 'Edit SOUL.md…' }).click()
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByText('research · This device · SOUL.md')).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('switching back lands on the clicked profile of This device and keeps the order', async () => {
|
||||
test.setTimeout(180_000)
|
||||
await gatewayGroup(page, 'local').getByRole('button', { name: 'research · This device' }).click()
|
||||
|
||||
await expect(activeGatewayLabel(page)).toHaveAttribute('aria-label', 'Registered gateways: This device', { timeout: 120_000 })
|
||||
const local = gatewayGroup(page, 'local')
|
||||
await expect(local).toHaveAttribute('data-active', 'true', { timeout: 30_000 })
|
||||
await expect(local.getByRole('button', { name: 'research', exact: true })).toHaveAttribute('aria-pressed', 'true', { timeout: 30_000 })
|
||||
await expect(gatewayGroup(page, REMOTE_ID).getByRole('button', { name: `inbox · ${REMOTE_LABEL}` })).toBeVisible()
|
||||
|
||||
expect(await groupOrder(page)).toEqual([
|
||||
['local', true],
|
||||
[REMOTE_ID, false],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* E2E contract for the compositor-only GlyphSpinner.
|
||||
*
|
||||
* The spinner's whole reason for existing in this shape is a CSS animation:
|
||||
* every frame is in the DOM from mount and a `transform` keyframes animation
|
||||
* scrolls between them, so there is no JS timer and no per-tick DOM mutation
|
||||
* scheduling document-scale style recalculation.
|
||||
*
|
||||
* None of that is observable in jsdom — it has no animation engine, no
|
||||
* cascade resolution for `steps()`, and no `Element.getAnimations()`. The
|
||||
* jsdom suite (src/components/ui/glyph-spinner.test.tsx) therefore pins the
|
||||
* DATA and WIRING, and this spec pins the RENDERED BEHAVIOUR in a real
|
||||
* browser, which is the only place the stylesheet actually runs.
|
||||
*
|
||||
* This replaces three tests that asserted on the TEXT of the stylesheet.
|
||||
* Reading source in a test is banned outright (AGENTS.md) and those tests
|
||||
* proved the point: a var()-fallback edit that changed no rendered pixel
|
||||
* broke one of them, while none of them had ever executed the CSS.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, type Page, test } from '@playwright/test'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
|
||||
/* Scope to a spinner that is actually RUNNING. Turns from earlier tests in
|
||||
* this file leave parked spinners mounted (kept-alive panes, swap overlays
|
||||
* hold them with data-paused='true'), and document.querySelector returns the
|
||||
* FIRST strip in the DOM — a stale parked one once two turns have run. */
|
||||
const STRIP = '.glyph-spinner:not([data-paused="true"]) .glyph-spinner__strip'
|
||||
|
||||
/** Prompt the mock server holds open so the spinner runs for the whole file. */
|
||||
const SPINNER_PROMPT = 'E2E_GLYPH_SPINNER_HOLD'
|
||||
|
||||
/**
|
||||
* Get a RUNNING frame strip into the DOM deterministically.
|
||||
*
|
||||
* A turn is sent so the app is genuinely busy (the mock server holds the
|
||||
* stream open), but which surface mounts a spinner mid-turn is app policy
|
||||
* that has changed before and will again — the transcript, status stack and
|
||||
* swap overlay all park/unmount theirs at different moments, which made this
|
||||
* spec racy. The contract under test is the STYLESHEET (steps() animation,
|
||||
* layer promotion, the data-paused and global pause gates), and that CSS is
|
||||
* driven entirely by the `data-paused` attribute — the same attribute the
|
||||
* parked assertions below already toggle. So: wait for any mounted spinner
|
||||
* (the ChatSwapOverlay keeps one mounted, parked, after boot), then unpark it
|
||||
* and assert against the running animation.
|
||||
*/
|
||||
async function mountSpinner(page: Page): Promise<void> {
|
||||
if (await page.locator(STRIP).count()) {
|
||||
return
|
||||
}
|
||||
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await composer.click()
|
||||
await composer.type(SPINNER_PROMPT, { delay: 10 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await page.waitForSelector('.glyph-spinner__strip', { state: 'attached', timeout: 20_000 })
|
||||
await page.evaluate(() => {
|
||||
for (const el of document.querySelectorAll('.glyph-spinner[data-paused]')) {
|
||||
el.removeAttribute('data-paused')
|
||||
}
|
||||
})
|
||||
await page.waitForSelector(STRIP, { state: 'attached', timeout: 20_000 })
|
||||
}
|
||||
|
||||
test.describe('GlyphSpinner (compositor animation)', () => {
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend({
|
||||
mockServer: { holdFirstStreamForPrompt: SPINNER_PROMPT },
|
||||
})
|
||||
await waitForAppReady(fixture)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
fixture?.mock.releaseHeldStream()
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('animates with a steps() transform keyframes animation, one step per frame', async () => {
|
||||
const { page } = fixture
|
||||
await mountSpinner(page)
|
||||
|
||||
const observed = await page.evaluate(strip => {
|
||||
const el = document.querySelector<HTMLElement>(strip)
|
||||
|
||||
if (!el) {
|
||||
throw new Error('no frame strip in the DOM')
|
||||
}
|
||||
|
||||
const style = getComputedStyle(el)
|
||||
const animations = el.getAnimations()
|
||||
|
||||
return {
|
||||
frameCount: el.querySelectorAll('.glyph-spinner__frame').length,
|
||||
timingFunction: style.animationTimingFunction,
|
||||
iterationCount: style.animationIterationCount,
|
||||
durationMs: animations[0]?.effect?.getTiming().duration ?? null,
|
||||
names: animations.map(a => (a as CSSAnimation).animationName),
|
||||
// A percentage translate makes the animation layout-dependent, which
|
||||
// Chromium refuses to composite. Read the engine's own keyframes: a
|
||||
// revert to translateY(-100%) shows up here, while the computed
|
||||
// `style.transform` always serializes to a matrix and can't tell.
|
||||
travel: ((animations[0]?.effect as KeyframeEffect | undefined)?.getKeyframes() ?? [])
|
||||
.map(k => String((k as Keyframe & { transform?: string }).transform ?? ''))
|
||||
.join(' | ')
|
||||
}
|
||||
}, STRIP)
|
||||
|
||||
// The strip carries every frame; `steps(N)` parks on each one in turn.
|
||||
expect(observed.frameCount).toBeGreaterThan(1)
|
||||
// Chromium has serialized jump-end as both `steps(N)` and `steps(N, end)`.
|
||||
expect(observed.timingFunction).toMatch(new RegExp(`^steps\\(${observed.frameCount}\\b`))
|
||||
expect(observed.iterationCount).toBe('infinite')
|
||||
expect(observed.names).toContain('glyph-spinner-advance')
|
||||
// One full cycle is frames x interval, so the duration must be a positive
|
||||
// multiple of the frame count — not the single-frame interval.
|
||||
expect(observed.durationMs).toBeGreaterThan(0)
|
||||
// Length-typed travel, never a percentage: `translateY(-100%)` would keep
|
||||
// the animation off the compositor. Chromium has serialized the resolved
|
||||
// keyframe both as the authored `calc(...)` and as an absolute `...px`
|
||||
// length depending on version — accept any length, reject percentages.
|
||||
expect(observed.travel).toMatch(/calc\(|px\)/)
|
||||
expect(observed.travel).not.toContain('%')
|
||||
})
|
||||
|
||||
test('is promoted to a layer while running, and neither animates nor holds a layer when parked', async () => {
|
||||
const { page } = fixture
|
||||
await mountSpinner(page)
|
||||
|
||||
const running = await page.evaluate(strip => {
|
||||
const el = document.querySelector<HTMLElement>(strip)!
|
||||
|
||||
return {
|
||||
playState: getComputedStyle(el).animationPlayState,
|
||||
willChange: getComputedStyle(el).willChange
|
||||
}
|
||||
}, STRIP)
|
||||
|
||||
expect(running.playState).toBe('running')
|
||||
// Scoped to active spinners — a permanently promoted layer per parked
|
||||
// spinner is pure memory at fan-out breadth.
|
||||
expect(running.willChange).toBe('transform')
|
||||
|
||||
// 1. The per-spinner gate: a kept-alive but inactive pane, or an explicit
|
||||
// `paused` prop (ChatSwapOverlay's fade-out).
|
||||
const parked = await page.evaluate(strip => {
|
||||
const el = document.querySelector<HTMLElement>(strip)!
|
||||
const viewport = el.closest<HTMLElement>('.glyph-spinner')!
|
||||
const previous = viewport.getAttribute('data-paused')
|
||||
|
||||
viewport.setAttribute('data-paused', 'true')
|
||||
|
||||
const state = {
|
||||
playState: getComputedStyle(el).animationPlayState,
|
||||
willChange: getComputedStyle(el).willChange
|
||||
}
|
||||
|
||||
if (previous === null) {
|
||||
viewport.removeAttribute('data-paused')
|
||||
} else {
|
||||
viewport.setAttribute('data-paused', previous)
|
||||
}
|
||||
|
||||
return state
|
||||
}, STRIP)
|
||||
|
||||
expect(parked.playState).toBe('paused')
|
||||
expect(parked.willChange).toBe('auto')
|
||||
|
||||
// 2. The global gate: window blur / minimize / document-hidden, which
|
||||
// main.tsx drives by arming this attribute on the root. The strip must
|
||||
// be named in that rule, or every spinner keeps animating behind an
|
||||
// inactive window — the CPU burn the original ticker's pause
|
||||
// controller existed to avoid.
|
||||
const globallyPaused = await page.evaluate(strip => {
|
||||
const root = document.documentElement
|
||||
const had = root.hasAttribute('data-renderer-animations-paused')
|
||||
|
||||
root.setAttribute('data-renderer-animations-paused', '')
|
||||
const playState = getComputedStyle(document.querySelector<HTMLElement>(strip)!).animationPlayState
|
||||
|
||||
if (!had) {
|
||||
root.removeAttribute('data-renderer-animations-paused')
|
||||
}
|
||||
|
||||
return playState
|
||||
}, STRIP)
|
||||
|
||||
expect(globallyPaused).toBe('paused')
|
||||
})
|
||||
|
||||
test('advances in discrete frames and creates no timer-driven DOM churn', async () => {
|
||||
const { page } = fixture
|
||||
await mountSpinner(page)
|
||||
|
||||
// Sample the resolved transform across one full cycle. A steps() animation
|
||||
// holds each value for a whole interval and jumps between them, so the
|
||||
// distinct values it visits must be bounded by the frame count — a linear
|
||||
// animation would produce a new value on every sample.
|
||||
const sampled = await page.evaluate(async strip => {
|
||||
const el = document.querySelector<HTMLElement>(strip)!
|
||||
const frames = el.querySelectorAll('.glyph-spinner__frame').length
|
||||
const duration = Number(el.getAnimations()[0]?.effect?.getTiming().duration ?? 0)
|
||||
const seen = new Set<string>()
|
||||
const textAtStart = el.textContent
|
||||
|
||||
const deadline = performance.now() + duration
|
||||
|
||||
while (performance.now() < deadline) {
|
||||
seen.add(getComputedStyle(el).transform)
|
||||
await new Promise(resolve => requestAnimationFrame(() => resolve(null)))
|
||||
}
|
||||
|
||||
return { distinct: seen.size, frames, textUnchanged: el.textContent === textAtStart }
|
||||
}, STRIP)
|
||||
|
||||
expect(sampled.distinct).toBeGreaterThan(1)
|
||||
expect(sampled.distinct).toBeLessThanOrEqual(sampled.frames + 1)
|
||||
// The old implementation rewrote textContent ~12x/second. Nothing may
|
||||
// mutate the DOM as this animates — that mutation is the whole incident.
|
||||
expect(sampled.textUnchanged).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { expect, test } from './test'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
async function openBots(page: MockBackendFixture['page']): Promise<void> {
|
||||
const tab = page.getByRole('button', { name: 'Bots', exact: true }).or(page.getByRole('tab', { name: 'Bots', exact: true })).first()
|
||||
await tab.click()
|
||||
await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
|
||||
}
|
||||
|
||||
async function createAgent(page: MockBackendFixture['page'], name: string, title: string): Promise<void> {
|
||||
await page.getByRole('button', { name: 'New bot or group chat' }).click()
|
||||
await page.getByRole('menuitem', { name: 'New Bot' }).click()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'New Bot' })
|
||||
await dialog.getByPlaceholder('inbox-triage').fill(name)
|
||||
await dialog.getByPlaceholder('Inbox Triage').fill(title)
|
||||
await dialog.getByRole('button', { name: 'Create Bot' }).click()
|
||||
await expect(dialog).toBeHidden({ timeout: 30_000 })
|
||||
await expect(page.getByRole('button', { name: new RegExp(`^${title}\\b`) }).first()).toBeVisible({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('local bot replaces an open group main workspace', async () => {
|
||||
test.setTimeout(240_000)
|
||||
const page = fixture!.page
|
||||
|
||||
await openBots(page)
|
||||
await createAgent(page, 'programmer', 'Programmer')
|
||||
await createAgent(page, 'reviewer', 'Reviewer')
|
||||
|
||||
await page.getByRole('button', { name: 'New bot or group chat' }).click()
|
||||
await page.getByRole('menuitem', { name: 'New Group Chat' }).click()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'New Group Chat' })
|
||||
|
||||
for (const title of ['Programmer', 'Reviewer']) {
|
||||
await dialog.getByText(title, { exact: true }).locator('xpath=ancestor::label').getByRole('checkbox').click()
|
||||
}
|
||||
|
||||
await dialog.getByRole('textbox', { name: 'Group name' }).fill('Programmer, Reviewer')
|
||||
await dialog.getByRole('button', { name: 'Create Group (2)' }).click()
|
||||
|
||||
const groupTab = page.getByRole('tab', { name: /Programmer, Reviewer Close/ })
|
||||
const groupComposer = page.getByRole('textbox', { name: 'Message Programmer, Reviewer' }).filter({ visible: true })
|
||||
await expect(groupTab).toBeVisible({ timeout: 20_000 })
|
||||
await expect(groupTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(groupComposer).toBeVisible()
|
||||
|
||||
const programmer = page.getByRole('button', { name: /^Programmer\b/ }).filter({ visible: true }).first()
|
||||
await programmer.click()
|
||||
|
||||
// The bot's canonical chat opens INTO the main workspace pane (post
|
||||
// design-system rework); as the lone pane in the zone it renders chromeless
|
||||
// — no "Bot Chat" tab exists until a second pane joins the strip. The
|
||||
// handoff is observed by the group surfaces leaving and the bot's chat
|
||||
// (here a fresh one: its empty-state splash asks for a first message)
|
||||
// taking the main workspace. The first open also spawns the bot's own
|
||||
// backend, so give the "Loading session" phase a real chance to clear.
|
||||
await expect(page.getByText('Say something to get started.').filter({ visible: true })).toBeVisible({
|
||||
timeout: 120_000
|
||||
})
|
||||
await expect(groupTab).toHaveCount(0)
|
||||
await expect(groupComposer).toHaveCount(0)
|
||||
// No "Waking up…" assertion: the mock backend can keep a bot's wake notice
|
||||
// around indefinitely (see bot-mode-row-click-mirrors-registry's settle()),
|
||||
// so its presence no longer distinguishes a stranded handoff. The splash
|
||||
// and composer above are the proof the bot's chat took the workspace.
|
||||
await expect(page.locator('[data-slot="composer-root"] [contenteditable="true"]').filter({ visible: true }).first()).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* E2E regression: desktop resume must hide agent-only transcript rows.
|
||||
*
|
||||
* Compaction handoffs are active user rows because the model needs them for
|
||||
* context continuity. They are not authored chat content, so the desktop
|
||||
* transcript must never display them after a real compressor-generated resume.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
type MockBackendFixture,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig,
|
||||
} from './fixtures'
|
||||
import {
|
||||
MOCK_REPLY,
|
||||
startMockServer,
|
||||
VERIFICATION_STOP_TEXT,
|
||||
VERIFICATION_STOP_TRIGGER,
|
||||
} from './mock-server'
|
||||
import { RealSessionBuilder } from './real-session-builder'
|
||||
import { expect, test } from './test'
|
||||
|
||||
const SESSION_TITLE = 'E2E Hidden History Messages'
|
||||
const VISIBLE_USER_TEXT = 'E2E_VISIBLE_USER_HISTORY'
|
||||
const VISIBLE_POST_COMPACTION_TEXT = 'E2E_VISIBLE_POST_COMPACTION_HISTORY'
|
||||
const COMPACTION_TRIGGER_PADDING = ' force real context compression'.repeat(600)
|
||||
|
||||
async function setupSeededMockBackend(): Promise<MockBackendFixture> {
|
||||
const mock = await startMockServer()
|
||||
const sandbox = createSandbox('hidden-history')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
fs.appendFileSync(
|
||||
path.join(sandbox.hermesHome, 'config.yaml'),
|
||||
'\ncompression:\n threshold_tokens: 1\n',
|
||||
'utf8',
|
||||
)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
|
||||
|
||||
try {
|
||||
await builder.createSession({
|
||||
title: SESSION_TITLE,
|
||||
turns: [
|
||||
`${VISIBLE_USER_TEXT}${COMPACTION_TRIGGER_PADDING}`,
|
||||
VISIBLE_POST_COMPACTION_TEXT,
|
||||
],
|
||||
})
|
||||
} finally {
|
||||
await builder.close()
|
||||
}
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('resume hides real context-compaction handoffs', async ({}, testInfo) => {
|
||||
const fixture = await setupSeededMockBackend()
|
||||
|
||||
try {
|
||||
const { page } = fixture
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
const sessionRow = page
|
||||
.locator('[data-slot="sidebar"] button')
|
||||
.filter({ hasText: SESSION_TITLE })
|
||||
.first()
|
||||
|
||||
await sessionRow.click()
|
||||
|
||||
const transcript = page.locator('[data-slot="aui_thread-viewport"]')
|
||||
await expect(transcript).toContainText(VISIBLE_USER_TEXT)
|
||||
await expect(transcript).toContainText(VISIBLE_POST_COMPACTION_TEXT)
|
||||
await expect(transcript).toContainText(MOCK_REPLY)
|
||||
await expect(transcript).not.toContainText('[CONTEXT COMPACTION — REFERENCE ONLY]')
|
||||
await page.screenshot({ path: testInfo.outputPath('hidden-history-resume.png') })
|
||||
} finally {
|
||||
await fixture.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('live verify-on-stop continuations stay out of the transcript', async ({}, testInfo) => {
|
||||
const sandbox = createSandbox('live-verification-nudge')
|
||||
const projectRoot = path.join(sandbox.root, 'project')
|
||||
const changedFile = path.join(projectRoot, 'e2e-verification-target.py')
|
||||
fs.mkdirSync(projectRoot)
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'pyproject.toml'),
|
||||
'[project]\nname = "e2e-verification-project"\nversion = "0.0.0"\n',
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const mock = await startMockServer({ verificationWritePath: changedFile })
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
fs.appendFileSync(path.join(sandbox.hermesHome, 'config.yaml'), '\nagent:\n verify_on_stop: true\n', 'utf8')
|
||||
// Auto session titling (feat f726090d48) fires an auxiliary title_generation
|
||||
// LLM call whose user snippet CONTAINS the trigger keyword, so the mock's
|
||||
// isVerificationStopTrigger matches it and the title call steals a scripted
|
||||
// verify-on-stop turn (the transcript then ends on 'The code edit is
|
||||
// complete.' instead of the exhausted-verifier final). Disable the
|
||||
// model-backed title upgrade so script indices track real chat turns.
|
||||
fs.appendFileSync(
|
||||
path.join(sandbox.hermesHome, 'config.yaml'),
|
||||
'\nauxiliary:\n title_generation:\n enabled: false\n',
|
||||
'utf8',
|
||||
)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
|
||||
const fixture: MockBackendFixture = {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type(VERIFICATION_STOP_TRIGGER)
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
const transcript = page.locator('[data-slot="aui_thread-viewport"]')
|
||||
await expect(transcript).toContainText(VERIFICATION_STOP_TEXT, { timeout: 60_000 })
|
||||
await expect.poll(
|
||||
() => mock.receivedPrompts.some(prompt => prompt.includes('[System: You edited code in this turn')),
|
||||
{ timeout: 30_000 },
|
||||
).toBe(true)
|
||||
expect(fs.existsSync(changedFile), 'The scripted write_file call should edit only the sandbox project').toBe(true)
|
||||
await expect(transcript).not.toContainText('[System: You edited code in this turn')
|
||||
await page.screenshot({ path: testInfo.outputPath('live-verification-nudge.png') })
|
||||
} finally {
|
||||
await fixture.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Regression coverage for an attached image in a durable session. The gateway
|
||||
* persists the turn, the builder exits, and desktop renders it from SessionDB
|
||||
* for the first time — the "quit and relaunch" case, where the transcript used
|
||||
* to come back as vision-enrichment prose instead of a thumbnail.
|
||||
*
|
||||
* The fixture pins `image_input_mode: native` because that is the majority
|
||||
* routing path (any vision-capable model) and the one where a text-only
|
||||
* persist override is silently dropped. The image also sits behind directory
|
||||
* and file names containing spaces, mirroring the macOS composer's
|
||||
* `~/Library/Application Support/...` staging path.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
type Sandbox,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig,
|
||||
} from './fixtures'
|
||||
import { type MockServer, startMockServer } from './mock-server'
|
||||
import { RealSessionBuilder } from './real-session-builder'
|
||||
import { type ElectronApplication, expect, type Page, test } from './test'
|
||||
|
||||
// The builder-provided title now labels the sidebar row directly (seeded
|
||||
// sessions no longer fall back to the first-user-message preview).
|
||||
const SESSION_TITLE = 'E2E attached image session'
|
||||
const CAPTION = 'E2E attached image must survive a relaunch'
|
||||
const IMAGE_DIR = 'Application Support/e2e shots'
|
||||
const IMAGE_NAME = 'e2e capture.png'
|
||||
const NATIVE_IMAGE_CONFIG = 'agent:\n image_input_mode: native'
|
||||
|
||||
/** A 160x100 framed magenta block — small, but visible in the screenshots. */
|
||||
const PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAKAAAABkCAIAAACO1KzYAAAA30lEQVR42u3dwQ2AIBAAQTAWAx1iBXYI7diCuWhEMvP2dZsj+CL30hLr2oxAYARGYARGYARGYIERGIH53n7nozpOk5rQqIcNdkQjMAIjMNPeomP3N54V+5exwY5oBEZgBEZgBEZggREYgREYgREYgQVGYARGYARGYARGYIERGIERGIERGIEFRmAERmAERmAEFhiBERiBERiBERiBBUZgBEZgBEZgBBYYgREYgREYgRFYYCMQGIERmPSj94Njb9ligxEYgRFYYJaQe2mmYIMRGIERGIERGIEFRmAERmDedAFtjAtAGWDnoAAAAABJRU5ErkJggg=='
|
||||
|
||||
interface SeededFixture {
|
||||
app: ElectronApplication
|
||||
mock: MockServer
|
||||
page: Page
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
function writeImage(sandbox: Sandbox): string {
|
||||
const dir = path.join(sandbox.root, IMAGE_DIR)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
|
||||
const imagePath = path.join(dir, IMAGE_NAME)
|
||||
fs.writeFileSync(imagePath, Buffer.from(PNG_BASE64, 'base64'))
|
||||
|
||||
return imagePath
|
||||
}
|
||||
|
||||
async function setupSeededDesktop(): Promise<SeededFixture> {
|
||||
const mock = await startMockServer()
|
||||
const sandbox = createSandbox('image-attachment')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url, undefined, NATIVE_IMAGE_CONFIG)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
|
||||
|
||||
try {
|
||||
await builder.createSession({
|
||||
title: SESSION_TITLE,
|
||||
turns: [{ images: [writeImage(sandbox)], text: CAPTION }],
|
||||
})
|
||||
} finally {
|
||||
await builder.close()
|
||||
}
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
|
||||
return {
|
||||
app,
|
||||
mock,
|
||||
page,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function sessionRow(page: Page) {
|
||||
return page.locator('[data-slot="sidebar"] button').filter({ hasText: SESSION_TITLE }).first()
|
||||
}
|
||||
|
||||
// Inactive tabs stay mounted under a data-pane-hidden ancestor. Match the
|
||||
// renderer's keep-alive visibility policy instead of relying on DOM order.
|
||||
const SURFACE = '[data-composer-target]:not([data-pane-hidden] [data-composer-target])'
|
||||
|
||||
function activeViewportText(surfaceSelector: string): string {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
|
||||
return surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? ''
|
||||
}
|
||||
|
||||
async function openSeededSession(page: Page): Promise<void> {
|
||||
const row = sessionRow(page)
|
||||
await row.waitFor({ state: 'visible', timeout: 60_000 })
|
||||
await row.click()
|
||||
await page.waitForFunction(
|
||||
([expected, surfaceSelector]: [string, string]) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const text = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? ''
|
||||
|
||||
return text.includes(expected)
|
||||
},
|
||||
[CAPTION, SURFACE] as [string, string],
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar "+" opens a NEW TAB beside the current chat instead of replacing
|
||||
* it, so the seeded session stays mounted in its own surface. Assert the new
|
||||
* surface is empty rather than waiting for the old caption to leave the page.
|
||||
*/
|
||||
async function openNewSession(page: Page): Promise<void> {
|
||||
await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
|
||||
await page.waitForFunction(
|
||||
([expected, surfaceSelector]: [string, string]) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const text = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? ''
|
||||
|
||||
return surfaces.length > 0 && !text.includes(expected)
|
||||
},
|
||||
[CAPTION, SURFACE] as [string, string],
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function transcriptText(page: Page): Promise<string> {
|
||||
return page.evaluate(activeViewportText, SURFACE)
|
||||
}
|
||||
|
||||
async function assertRendersThumbnail(page: Page, label: string): Promise<void> {
|
||||
const thumbnail = page.locator('[data-slot="aui_directive-image"] img')
|
||||
await expect(thumbnail, `${label}: the attachment should render as an image`).toHaveCount(1)
|
||||
await expect(thumbnail, `${label}: the thumbnail should resolve off disk`).toHaveAttribute('src', /^data:image\//)
|
||||
|
||||
const text = await transcriptText(page)
|
||||
expect(text, `${label}: the caption should survive alongside the image`).toContain(CAPTION)
|
||||
// A broken ref falls back to a chip whose label leaks the path, and a
|
||||
// flattened multimodal turn leaves the agent's placeholder behind.
|
||||
expect(text, `${label}: the raw image path should not leak into the transcript`).not.toContain(IMAGE_NAME)
|
||||
expect(text, `${label}: the image directive should not render literally`).not.toContain('@image:')
|
||||
expect(text, `${label}: the flattening placeholder should not render`).not.toContain('[screenshot]')
|
||||
}
|
||||
|
||||
test.describe('attached image resume', () => {
|
||||
let fixture: SeededFixture | null = null
|
||||
|
||||
test.afterEach(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('renders a persisted attachment as a thumbnail on first open and after a cold reload', async ({}, testInfo) => {
|
||||
// Seeding through the real gateway plus two full app boots does not fit the
|
||||
// default per-test budget on a cold runner.
|
||||
test.slow()
|
||||
|
||||
fixture = await setupSeededDesktop()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
// The sidebar labels a seeded session by its title. Whatever the label
|
||||
// source, an attachment directive must never leak into it as a file path.
|
||||
const row = sessionRow(fixture.page)
|
||||
await row.waitFor({ state: 'visible', timeout: 60_000 })
|
||||
|
||||
const label = (await row.textContent())?.trim() ?? ''
|
||||
expect(label.startsWith(SESSION_TITLE), `sidebar label should open with the title: ${label}`).toBe(true)
|
||||
expect(label, `sidebar label should not leak the image path: ${label}`).not.toContain(IMAGE_NAME)
|
||||
expect(label, `sidebar label should not render the directive: ${label}`).not.toContain('@image:')
|
||||
|
||||
await openSeededSession(fixture.page)
|
||||
await assertRendersThumbnail(fixture.page, 'first open')
|
||||
await fixture.page.screenshot({ path: testInfo.outputPath('attachment-first-open.png') })
|
||||
|
||||
// A reload drops every cached attachment ref, so the transcript has to come
|
||||
// back from the persisted turn alone.
|
||||
await fixture.page.reload()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
await openNewSession(fixture.page)
|
||||
|
||||
await openSeededSession(fixture.page)
|
||||
await assertRendersThumbnail(fixture.page, 'cold reload')
|
||||
await fixture.page.screenshot({ path: testInfo.outputPath('attachment-cold-reload.png') })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* E2E test for the interim-assistant-message preservation fix (#65919).
|
||||
*
|
||||
* Reproduces the bug across all three layers (agent core → tui_gateway →
|
||||
* desktop renderer): when the agent emits assistant text alongside a tool
|
||||
* call, then completes the turn with a *different* final answer, the
|
||||
* interim text must survive in the transcript — not be wiped when
|
||||
* message.complete replaces the streaming bubble.
|
||||
*
|
||||
* The mock server walks through a multi-turn script when it sees the
|
||||
* trigger keyword:
|
||||
*
|
||||
* Turn 1: "Let me start by planning the approach." + todo tool_call
|
||||
* Turn 2: "Now checking the details before answering." + todo tool_call
|
||||
* Turn 3: (no text) + todo tool_call → NO interim (no visible text)
|
||||
* Turn 4: "Found something interesting worth noting." + todo tool_call
|
||||
* Turn 5: "All done! Here is the complete summary..." (final, stop)
|
||||
*
|
||||
* Two describe blocks exercise the config flag both ways:
|
||||
*
|
||||
* display.interim_assistant_messages: true (default)
|
||||
* → ALL interim texts AND the final text must be visible in the
|
||||
* settled transcript.
|
||||
*
|
||||
* display.interim_assistant_messages: false
|
||||
* → no message.interim events are emitted, so no sealed interim bubbles
|
||||
* are created while streaming. Since the post-turn stored-history
|
||||
* reconcile (sessions.changed → reconcileActiveTranscript, commit
|
||||
* 1a2b0ca8cb) converges the visible transcript to the persisted
|
||||
* transcript — which has ALWAYS contained the mid-turn commentary as
|
||||
* real assistant rows (that is what a resume shows, flag or no flag) —
|
||||
* the settled DOM shows the whole turn as ONE assistant message
|
||||
* containing commentary + final. The flag governs live sealing only.
|
||||
* The test pins that converged single-message shape: every text
|
||||
* appears exactly once, inside a single assistant message root.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, type Page, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { INTERIM_TEXTS, restartMockServer } from './mock-server'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Auto session titling (feat f726090d48, 2026-08-08) issues an auxiliary
|
||||
* `title_generation` LLM call against the SAME provider as the chat turn.
|
||||
* The mock server counts every completion request as a script turn, so the
|
||||
* title call races the chat turn and steals a scripted interim turn (the
|
||||
* stolen turn's text then never streams to the transcript). Disable the
|
||||
* model-backed title upgrade — the instant derived title needs no LLM call —
|
||||
* so the mock's script indices line up with real chat turns again.
|
||||
*/
|
||||
const DISABLE_AUTO_TITLE = 'auxiliary:\n title_generation:\n enabled: false'
|
||||
|
||||
/** Unique trigger keyword the mock server detects to switch to the script. */
|
||||
const TRIGGER = 'E2E_INTERIM_TRIGGER'
|
||||
|
||||
/**
|
||||
* Send a message and wait for BOTH the user's message and the agent's
|
||||
* final response to appear in the transcript. Returns when the final text
|
||||
* is visible, which means message.complete has fired and the transcript
|
||||
* has settled.
|
||||
*/
|
||||
async function sendInterimMessage(page: Page): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await composer.click()
|
||||
await composer.type(TRIGGER, { delay: 20 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Wait for the user's trigger message to appear.
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('E2E_INTERIM_TRIGGER'),
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
// Wait for the agent's FINAL response (last turn). This means
|
||||
// message.complete has fired and the transcript is settled.
|
||||
await page.waitForFunction(
|
||||
(finalText) => (document.body.textContent ?? '').includes(finalText),
|
||||
INTERIM_TEXTS.finalText,
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
|
||||
// Give the renderer a moment to settle any final state updates
|
||||
// (hydration, stored-history reconcile, session refresh) before asserting.
|
||||
await page.waitForTimeout(2000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many times `text` appears as distinct text in the chat transcript
|
||||
* (excluding the session sidebar, whose session-preview label shows the
|
||||
* first streamed text as a title).
|
||||
*
|
||||
* The desktop app renders the transcript inside a
|
||||
* `[data-slot="aui_thread-viewport"]` container (from @assistant-ui/react).
|
||||
* The session sidebar's preview labels live outside that container, so
|
||||
* scoping the DOM walk to the viewport cleanly excludes them.
|
||||
*/
|
||||
async function countTranscriptMessagesContaining(page: Page, text: string): Promise<number> {
|
||||
return page.evaluate(
|
||||
(search) => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
|
||||
if (!viewport) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let count = 0
|
||||
|
||||
const walker = document.createTreeWalker(
|
||||
viewport,
|
||||
NodeFilter.SHOW_ELEMENT,
|
||||
{
|
||||
acceptNode: (node) => {
|
||||
const el = node as HTMLElement
|
||||
const directText = el.textContent ?? ''
|
||||
|
||||
if (!directText.includes(search)) {
|
||||
return NodeFilter.FILTER_SKIP
|
||||
}
|
||||
|
||||
// Only count leaf-ish elements to avoid double-counting.
|
||||
const hasChildWithText = Array.from(el.children).some(
|
||||
(child) => (child.textContent ?? '').includes(search),
|
||||
)
|
||||
|
||||
if (hasChildWithText) {
|
||||
return NodeFilter.FILTER_SKIP
|
||||
}
|
||||
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
while (walker.nextNode()) {
|
||||
count++
|
||||
}
|
||||
|
||||
return count
|
||||
},
|
||||
text,
|
||||
)
|
||||
}
|
||||
|
||||
/** Count assistant message roots in the settled transcript. */
|
||||
async function countAssistantMessageRoots(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
|
||||
return viewport
|
||||
? viewport.querySelectorAll('[data-slot="aui_assistant-message-root"]').length
|
||||
: 0
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Flag ON: interim_assistant_messages = true (default) ─────────────
|
||||
|
||||
test.describe('interim assistant messages — flag ON (default)', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend({ extraConfig: DISABLE_AUTO_TITLE })
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('all interim texts survive alongside the final response', async () => {
|
||||
const page = fixture.page
|
||||
await sendInterimMessage(page)
|
||||
|
||||
// Every interim text (turns with visible text + tool calls) must be
|
||||
// present in the settled transcript — NOT wiped by message.complete.
|
||||
// (Live, each seals as its own bubble; the post-turn stored-history
|
||||
// reconcile then converges the turn into one assistant message that
|
||||
// still carries all of them.)
|
||||
for (const interimText of INTERIM_TEXTS.interims) {
|
||||
await expect
|
||||
.poll(
|
||||
() => countTranscriptMessagesContaining(page, interimText),
|
||||
{ timeout: 15_000, message: `interim text "${interimText}" should be visible` },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
|
||||
// The final text must also be visible.
|
||||
await expect
|
||||
.poll(
|
||||
() => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText),
|
||||
{ timeout: 15_000, message: 'final text should be visible' },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
|
||||
// No duplicates: the reconcile must CONVERGE (replace the sealed live
|
||||
// bubbles), never render a stored copy alongside a live one.
|
||||
for (const text of [...INTERIM_TEXTS.interims, INTERIM_TEXTS.finalText]) {
|
||||
const count = await countTranscriptMessagesContaining(page, text)
|
||||
expect(count, `"${text}" must not be duplicated after reconcile`).toBe(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Flag OFF: interim_assistant_messages = false ────────────────────
|
||||
|
||||
test.describe('interim assistant messages — flag OFF', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend({
|
||||
extraDisplayConfig: ' interim_assistant_messages: false',
|
||||
extraConfig: DISABLE_AUTO_TITLE,
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('settled transcript converges to stored history as a single turn message', async () => {
|
||||
const page = fixture.page
|
||||
await sendInterimMessage(page)
|
||||
|
||||
// The final text must be visible.
|
||||
await expect
|
||||
.poll(
|
||||
() => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText),
|
||||
{ timeout: 15_000, message: 'final text should be visible' },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
|
||||
// With the flag off, the tui_gateway never installs
|
||||
// interim_assistant_callback, so no message.interim events fire and no
|
||||
// sealed interim bubbles are created while streaming. After
|
||||
// message.complete, the stored-history reconcile (sessions.changed →
|
||||
// reconcileActiveTranscript) converges the view to the persisted
|
||||
// transcript, which contains the mid-turn commentary as real assistant
|
||||
// rows — exactly what a resume of this session would show. Pin that
|
||||
// converged shape: ONE assistant message root for the whole turn…
|
||||
await expect
|
||||
.poll(
|
||||
() => countAssistantMessageRoots(page),
|
||||
{ timeout: 15_000, message: 'the settled turn should render as one assistant message' },
|
||||
)
|
||||
.toBe(1)
|
||||
|
||||
// …containing every commentary text and the final text exactly once.
|
||||
for (const text of [...INTERIM_TEXTS.interims, INTERIM_TEXTS.finalText]) {
|
||||
await expect
|
||||
.poll(
|
||||
() => countTranscriptMessagesContaining(page, text),
|
||||
{ timeout: 15_000, message: `"${text}" should appear exactly once in the converged turn` },
|
||||
)
|
||||
.toBe(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { type TestInfo } from '@playwright/test'
|
||||
|
||||
import { expect, test, type ElectronApplication, type Page } from './test'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
type Sandbox,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig,
|
||||
} from './fixtures'
|
||||
import { MOCK_REPLY, startMockServer, type MockServer, type MockServerOptions } from './mock-server'
|
||||
import { RealSessionBuilder } from './real-session-builder'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const SESSION_TITLE = 'E2E large persisted session'
|
||||
const EXPECTED_TEXT = 'E2E persisted user message 52'
|
||||
// The oldest seeded turn (HISTORY_TURNS[0]). The transcript first paints only
|
||||
// the newest turns (FIRST_PAINT_BUDGET) and backfills the rest in a rAF; a
|
||||
// baseline count taken before that backfill sees a clipped transcript and
|
||||
// falsely reports duplicates once the full list mounts. Waiting for this
|
||||
// oldest row means the baseline reflects the fully-mounted transcript.
|
||||
const OLDEST_SEEDED_TEXT = 'E2E persisted user message 0: audit the compatibility matrix'
|
||||
const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume'
|
||||
const HISTORY_TURNS = Array.from(
|
||||
{ length: 27 },
|
||||
(_, index) => `E2E persisted user message ${index * 2}: audit the compatibility matrix`,
|
||||
)
|
||||
|
||||
interface SeededFixture {
|
||||
app: ElectronApplication
|
||||
mock: MockServer
|
||||
mockUrl: string
|
||||
page: Page
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
interface PaintState {
|
||||
bursts: number
|
||||
timeline: Array<{ mutations: number; time: number }>
|
||||
}
|
||||
|
||||
async function setupSeededDesktop(mockServer?: MockServerOptions): Promise<SeededFixture> {
|
||||
const mock = await startMockServer(mockServer)
|
||||
const sandbox = createSandbox('large-session')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
|
||||
try {
|
||||
await builder.createSession({ title: SESSION_TITLE, turns: HISTORY_TURNS })
|
||||
} finally {
|
||||
await builder.close()
|
||||
}
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
|
||||
return {
|
||||
app,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
page,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function sessionRow(page: Page) {
|
||||
return page.locator('[data-slot="sidebar"] button').filter({ hasText: SESSION_TITLE }).first()
|
||||
}
|
||||
|
||||
async function openSeededSession(page: Page): Promise<void> {
|
||||
const row = sessionRow(page)
|
||||
await row.waitFor({ state: 'visible', timeout: 60_000 })
|
||||
await row.click()
|
||||
await page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
EXPECTED_TEXT,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function openNewSession(page: Page): Promise<void> {
|
||||
const button = page.locator('[data-slot="sidebar"] button').filter({ hasText: 'New session' }).first()
|
||||
await button.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await button.click()
|
||||
await page.waitForFunction(
|
||||
expected => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
EXPECTED_TEXT,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function submitPrompt(page: Page, prompt: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.type(prompt, { delay: 2 })
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
prompt,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function startPaintObserver(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
const state = { bursts: 0, timeline: [] as Array<{ mutations: number; time: number }> }
|
||||
;(window as Window & { __largeSessionPaints?: typeof state }).__largeSessionPaints = state
|
||||
if (!viewport) return
|
||||
|
||||
let additions = 0
|
||||
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
||||
new MutationObserver(records => {
|
||||
additions += records.reduce(
|
||||
(count, record) => count + (record.type === 'childList' && record.addedNodes.length > 0 ? 1 : 0),
|
||||
0,
|
||||
)
|
||||
if (additions === 0) return
|
||||
if (flushTimer) clearTimeout(flushTimer)
|
||||
flushTimer = setTimeout(() => {
|
||||
state.bursts += 1
|
||||
state.timeline.push({ mutations: additions, time: Date.now() })
|
||||
additions = 0
|
||||
}, 30)
|
||||
}).observe(viewport, { childList: true, subtree: true })
|
||||
})
|
||||
}
|
||||
|
||||
async function paintState(page: Page): Promise<PaintState> {
|
||||
const state = await page.evaluate(() => (window as Window & { __largeSessionPaints?: PaintState }).__largeSessionPaints)
|
||||
expect(state, 'paint observer should attach to the thread viewport').toBeDefined()
|
||||
return state!
|
||||
}
|
||||
|
||||
async function textNodeOccurrences(page: Page, expected: string): Promise<number> {
|
||||
return page.evaluate(text => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return 0
|
||||
|
||||
const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT)
|
||||
let count = 0
|
||||
while (walker.nextNode()) {
|
||||
if (walker.currentNode.textContent?.includes(text)) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}, expected)
|
||||
}
|
||||
|
||||
async function reloadIntoColdRenderer(fixture: SeededFixture): Promise<void> {
|
||||
await fixture.page.reload()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
await openNewSession(fixture.page)
|
||||
}
|
||||
|
||||
async function assertUnchangedResume(page: Page, testInfo: TestInfo): Promise<void> {
|
||||
await openSeededSession(page)
|
||||
await page.waitForTimeout(1_000)
|
||||
await page.screenshot({ path: testInfo.outputPath('unchanged-session-resume.png'), fullPage: false })
|
||||
|
||||
const paints = await paintState(page)
|
||||
expect(await textNodeOccurrences(page, EXPECTED_TEXT), 'the resumed user message should appear once').toBe(1)
|
||||
// A warm session first restores its retained view, then reconciles it with the
|
||||
// authoritative transcript. That is bounded at two builds; a third paint was
|
||||
// the old eager-prefetch + runtime-rebuild regression. A cold restore has one.
|
||||
expect(paints.bursts, `unexpected transcript paint count: ${JSON.stringify(paints.timeline)}`).toBeLessThanOrEqual(2)
|
||||
}
|
||||
|
||||
test.describe('large session resume', () => {
|
||||
let fixture: SeededFixture | null = null
|
||||
|
||||
test.afterEach(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('cold resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => {
|
||||
fixture = await setupSeededDesktop()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
await startPaintObserver(fixture.page)
|
||||
await assertUnchangedResume(fixture.page, testInfo)
|
||||
})
|
||||
|
||||
test('fast resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => {
|
||||
// Known RED: a rapid warm resume rebuilds the transcript three times
|
||||
// (28 → 53 → 53 DOM additions) instead of the two-paint budget. Keep the
|
||||
// regression visible without making unrelated desktop work fail CI.
|
||||
test.fixme(true, 'Fast warm resume has an unresolved third transcript rebuild')
|
||||
|
||||
fixture = await setupSeededDesktop()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
await openSeededSession(fixture.page)
|
||||
await openNewSession(fixture.page)
|
||||
await startPaintObserver(fixture.page)
|
||||
await assertUnchangedResume(fixture.page, testInfo)
|
||||
})
|
||||
|
||||
for (const resumeKind of ['fast', 'cold'] as const) {
|
||||
test(`${resumeKind} resume keeps background inference attached without duplicate messages`, async ({}, testInfo) => {
|
||||
fixture = await setupSeededDesktop({ holdFirstStreamForPrompt: BACKGROUND_PROMPT })
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
await openSeededSession(fixture.page)
|
||||
// The transcript first paints only the newest turns (FIRST_PAINT_BUDGET)
|
||||
// and backfills older turns in a rAF. Wait for the oldest seeded row to
|
||||
// mount before taking the baseline so it reflects the full transcript —
|
||||
// otherwise a clipped baseline makes the backfilled rows look like
|
||||
// duplicates of the completed reply.
|
||||
await fixture.page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
OLDEST_SEEDED_TEXT,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
const initialMockReplyCount = await textNodeOccurrences(fixture.page, MOCK_REPLY)
|
||||
await submitPrompt(fixture.page, BACKGROUND_PROMPT)
|
||||
await fixture.mock.waitForHeldStream()
|
||||
await openNewSession(fixture.page)
|
||||
|
||||
if (resumeKind === 'cold') {
|
||||
await reloadIntoColdRenderer(fixture)
|
||||
}
|
||||
|
||||
await openSeededSession(fixture.page)
|
||||
fixture.mock.releaseHeldStream()
|
||||
await fixture.page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
MOCK_REPLY,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
await fixture.page.waitForTimeout(300)
|
||||
await fixture.page.screenshot({ path: testInfo.outputPath(`${resumeKind}-background-inference-resume.png`), fullPage: false })
|
||||
|
||||
expect(await textNodeOccurrences(fixture.page, BACKGROUND_PROMPT), 'the running user prompt should appear once').toBe(1)
|
||||
expect(
|
||||
await textNodeOccurrences(fixture.page, MOCK_REPLY),
|
||||
'the completed assistant reply should add exactly one transcript row',
|
||||
).toBe(initialMockReplyCount + 1)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { expect, test } from './test'
|
||||
|
||||
import {
|
||||
PACKAGED_BINARY_PATH,
|
||||
type PackagedAppFixture,
|
||||
packagedBinaryExists,
|
||||
setupPackagedApp,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
/**
|
||||
* 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('boots to the app UI, not the QueryClient error boundary (#95560)', async () => {
|
||||
const page = fixture!.page
|
||||
await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 })
|
||||
|
||||
// Wait until the root has real content (boot overlay fades, app paints) —
|
||||
// the error boundary also paints, so assert on its absence explicitly.
|
||||
await page.waitForFunction(
|
||||
() => (document.getElementById('root')?.textContent ?? '').trim().length > 0,
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
|
||||
const text = await page.locator('#root').textContent()
|
||||
// The #95560 crash: a duplicate @tanstack/react-query runtime made the
|
||||
// QueryClientProvider's context invisible to useQuery, so the app hit the
|
||||
// error boundary at launch. Neither the boundary headline nor the throw
|
||||
// message may appear on a healthy boot.
|
||||
expect(text).not.toContain('No QueryClient set')
|
||||
expect(text).not.toContain('Something broke in the interface')
|
||||
})
|
||||
|
||||
test('HUD composer remains fully inside the transparent window', async () => {
|
||||
const hudPagePromise = fixture!.app.waitForEvent('window')
|
||||
|
||||
await fixture!.page.evaluate(() =>
|
||||
(window as typeof window & {
|
||||
hermesDesktop?: { hud?: { open: (options: { sessionId: null }) => Promise<void> } }
|
||||
}).hermesDesktop?.hud?.open({ sessionId: null })
|
||||
)
|
||||
|
||||
const hudPage = await hudPagePromise
|
||||
await hudPage.waitForSelector('[data-slot="composer-rich-input"]', { state: 'visible' })
|
||||
|
||||
const geometry = await hudPage.evaluate(() => {
|
||||
const dock = document.querySelector<HTMLElement>('[data-slot="composer-dock"]')
|
||||
const input = document.querySelector<HTMLElement>('[data-slot="composer-rich-input"]')
|
||||
|
||||
if (!dock || !input) {
|
||||
throw new Error('HUD composer did not render')
|
||||
}
|
||||
|
||||
const dockRect = dock.getBoundingClientRect()
|
||||
const inputRect = input.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
dockLeft: dockRect.left,
|
||||
dockRight: dockRect.right,
|
||||
dockTop: dockRect.top,
|
||||
dockBottom: dockRect.bottom,
|
||||
inputLeft: inputRect.left,
|
||||
inputRight: inputRect.right,
|
||||
inputTop: inputRect.top,
|
||||
inputBottom: inputRect.bottom,
|
||||
// The bug class this guards: a build-time CSS optimization folding the
|
||||
// dock's identity `translate` override into `transform`, leaving
|
||||
// Tailwind's standalone `translate: -50%` live and shifting the dock
|
||||
// half a window off-screen. Surface the computed value so a failure
|
||||
// says WHY the dock moved, not just that it did.
|
||||
dockTranslate: getComputedStyle(dock).translate,
|
||||
}
|
||||
})
|
||||
|
||||
// Horizontal containment — the composer shifted half a window left when the
|
||||
// standalone `translate: -50%` survived optimization (#82214, #82233).
|
||||
expect(geometry.dockLeft).toBeGreaterThanOrEqual(0)
|
||||
expect(geometry.inputLeft).toBeGreaterThanOrEqual(0)
|
||||
expect(geometry.dockRight).toBeLessThanOrEqual(geometry.viewportWidth)
|
||||
expect(geometry.inputRight).toBeLessThanOrEqual(geometry.viewportWidth)
|
||||
|
||||
// Vertical containment — the toolbar/transcript clipping reported on
|
||||
// Windows (#82203) and macOS (#82214) is the same "composer escapes the
|
||||
// window" class on the other axis.
|
||||
expect(geometry.dockTop).toBeGreaterThanOrEqual(0)
|
||||
expect(geometry.inputTop).toBeGreaterThanOrEqual(0)
|
||||
expect(geometry.dockBottom).toBeLessThanOrEqual(geometry.viewportHeight)
|
||||
expect(geometry.inputBottom).toBeLessThanOrEqual(geometry.viewportHeight)
|
||||
|
||||
// The dock's centering translate must be fully neutralized. Any live
|
||||
// percentage translate means the HUD override lost to the app's centering.
|
||||
// (Computed `translate` keeps percentages as-is, so this is assertable;
|
||||
// computed `transform` resolves to a matrix and is covered by the
|
||||
// geometric containment checks above.)
|
||||
expect(geometry.dockTranslate ?? 'none').not.toContain('%')
|
||||
|
||||
await hudPage.close()
|
||||
})
|
||||
|
||||
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))
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
})
|
||||
|
||||
test('can capture a screenshot for the CI artifact', async () => {
|
||||
if (!fixture) {
|
||||
test.skip(true, 'Previous test failed — no app running')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Visual snapshot — won't fail on diff, just logs + generates diff image
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'packaged-app-booted', timeout: 10_000, app: fixture!.app })
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* E2E tests asserting the mock backend gets the app past the setup/onboarding
|
||||
* screen.
|
||||
*
|
||||
* The mock backend fixture writes a config.yaml with a pre-configured mock
|
||||
* provider pointing at a mock inference server. When the app boots, the
|
||||
* runtime readiness check should detect the working provider and dismiss the
|
||||
* onboarding overlay — landing straight on the chat UI without ever showing
|
||||
* the "Let's get you setup with Hermes Agent" screen.
|
||||
*
|
||||
* If these tests fail, the mock backend config isn't getting the app past
|
||||
* onboarding — the chat interaction tests (chat.spec.ts) will also fail
|
||||
* because the composer is blocked by the setup overlay.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from './test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture!, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('mock backend gets past setup screen', () => {
|
||||
test('onboarding overlay is not shown', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
// The onboarding overlay renders "Let's get you setup with Hermes Agent"
|
||||
// when the runtime check fails to find a working provider. With the mock
|
||||
// backend configured, the runtime check should pass and the overlay
|
||||
// returns null — this text should NOT be present in the DOM.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const text = document.body.textContent ?? ''
|
||||
|
||||
return !text.includes("Let's get you setup")
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
})
|
||||
|
||||
test('chat composer is visible', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
// The composer (contenteditable div) should be visible and not blocked
|
||||
// by the onboarding overlay. If the first test passed, the overlay is
|
||||
// gone and the composer is the primary interactive surface.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await expect(composer).toBeVisible()
|
||||
})
|
||||
|
||||
test('can type into the composer', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
// If the setup overlay is truly gone, the composer accepts input.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type('hello mock backend', { delay: 20 })
|
||||
|
||||
// Verify the typed text appears in the DOM.
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('hello mock backend'),
|
||||
undefined,
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
})
|
||||
|
||||
test('screenshot shows chat UI without setup screen', async () => {
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'mock-backend-chat-ready', app: fixture!.app })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,984 @@
|
||||
/**
|
||||
* 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 fs from 'node:fs'
|
||||
import http from 'node:http'
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import os from 'node:os'
|
||||
import nodePath from 'node:path'
|
||||
|
||||
/** A canned assistant reply used for every chat completion request. */
|
||||
export const MOCK_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
|
||||
|
||||
export interface MockServerOptions {
|
||||
/** Pause the matching stream after its first token for session-switch E2E coverage. */
|
||||
holdFirstStreamForPrompt?: string
|
||||
/** Pause the first completion whose request JSON contains this text. */
|
||||
holdFirstCompletionContaining?: string
|
||||
/** Absolute sandbox path written by the verify-on-stop scripted tool call. */
|
||||
verificationWritePath?: string
|
||||
/**
|
||||
* Sentinel path that ends the E2E_SIDEBAR_CROSS background process.
|
||||
*
|
||||
* Without it that process is a bare `sleep 5`, which races the agent turn and
|
||||
* the 4s auto-dismiss linger — see `createBackgroundReleaseHandle`. Pass a
|
||||
* handle's `path` to let the test decide when the process exits.
|
||||
*/
|
||||
backgroundReleasePath?: string
|
||||
}
|
||||
|
||||
export interface MockServer {
|
||||
port: number
|
||||
url: string
|
||||
receivedPrompts: string[]
|
||||
waitForHeldStream: () => Promise<void>
|
||||
waitForHeldCompletion: () => Promise<void>
|
||||
releaseHeldStream: () => void
|
||||
heldCompletionCount: () => number
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
// ─── Multi-turn interim script ─────────────────────────────────────────
|
||||
//
|
||||
// When the user's message contains the trigger keyword, the mock server
|
||||
// walks through a scripted sequence of responses that exercise the
|
||||
// interim-assistant-message fix (#65919) across several patterns:
|
||||
//
|
||||
// 1. text + single tool_call → should produce an interim message
|
||||
// 2. text + single tool_call → another interim message
|
||||
// 3. no text + tool_call → NO interim (no visible text alongside tools)
|
||||
// 4. text + single tool_call → another interim message
|
||||
// 5. final answer (stop) → message.complete, different from all interims
|
||||
//
|
||||
// Each "turn" is one API call. The agent executes the tool after each
|
||||
// tool_calls response, then re-calls the API, advancing to the next turn.
|
||||
|
||||
export interface ScriptedTurn {
|
||||
/** Assistant text content to stream. Empty string = no visible text. */
|
||||
text: string
|
||||
/** Tool calls to emit. Empty array = final turn (finish_reason: stop). */
|
||||
toolCalls?: Array<{
|
||||
name: string
|
||||
args: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
|
||||
const INTERIM_SCRIPT: ScriptedTurn[] = [
|
||||
{
|
||||
text: 'Let me start by planning the approach.',
|
||||
toolCalls: [{ name: 'todo', args: { todos: [{ id: '1', content: 'Plan', status: 'in_progress' }] } }],
|
||||
},
|
||||
{
|
||||
text: 'Now checking the details before answering.',
|
||||
toolCalls: [{ name: 'todo', args: { todos: [{ id: '2', content: 'Check details', status: 'in_progress' }] } }],
|
||||
},
|
||||
{
|
||||
// No visible text alongside this tool call — should NOT produce an
|
||||
// interim message. The agent fires _emit_interim_assistant_message
|
||||
// but _interim_assistant_visible_text returns "" so it's a no-op.
|
||||
text: '',
|
||||
toolCalls: [{ name: 'todo', args: { todos: [{ id: '3', content: 'Silent step', status: 'completed' }] } }],
|
||||
},
|
||||
{
|
||||
text: 'Found something interesting worth noting.',
|
||||
toolCalls: [{ name: 'todo', args: { todos: [{ id: '4', content: 'Note finding', status: 'completed' }] } }],
|
||||
},
|
||||
{
|
||||
// Final answer — different from all interim texts.
|
||||
text: 'All done! Here is the complete summary of what I found.',
|
||||
},
|
||||
]
|
||||
|
||||
/** Per-server request counter so we can walk through the script turns. */
|
||||
let _scriptIndex = 0
|
||||
|
||||
/** Per-server counter for the sidebar-states script (independent from _scriptIndex). */
|
||||
let _sidebarScriptIndex = 0
|
||||
|
||||
/** Per-server counter for the cross-session sidebar script. */
|
||||
let _sidebarCrossIndex = 0
|
||||
|
||||
/** Per-server counter for the queue-stop script. */
|
||||
let _queueStopIndex = 0
|
||||
|
||||
/** Per-server counter for the correction/session-switch script. */
|
||||
let _correctionSwitchIndex = 0
|
||||
|
||||
/** Per-server counter for the verify-on-stop script. */
|
||||
let _verificationStopIndex = 0
|
||||
|
||||
/** Per-server counter for the task-panel warm-resume script. */
|
||||
let _taskPanelResumeIndex = 0
|
||||
|
||||
/** User messages received by the mock, for E2E assertions on real submits. */
|
||||
const _receivedUserTexts: string[] = []
|
||||
|
||||
/** Reset the script indices (called between tests via restartMockServer). */
|
||||
function resetScriptIndex(): void {
|
||||
_scriptIndex = 0
|
||||
_sidebarScriptIndex = 0
|
||||
_sidebarCrossIndex = 0
|
||||
_queueStopIndex = 0
|
||||
_correctionSwitchIndex = 0
|
||||
_verificationStopIndex = 0
|
||||
_taskPanelResumeIndex = 0
|
||||
_receivedUserTexts.length = 0
|
||||
}
|
||||
|
||||
/** Return the user prompts the real backend submitted to this mock server. */
|
||||
export function receivedUserTexts(): readonly string[] {
|
||||
return _receivedUserTexts
|
||||
}
|
||||
|
||||
// ─── Sidebar-states script ─────────────────────────────────────────────
|
||||
//
|
||||
// A separate trigger (E2E_SIDEBAR_TRIGGER) exercises the desktop sidebar's
|
||||
// background-process and subagent states. The mock returns tool_calls that
|
||||
// the agent executes for real — `terminal(background=true)` spawns a real
|
||||
// (but trivial) background process, and `delegate_task` spawns a real
|
||||
// subagent that calls the mock server and gets the canned reply.
|
||||
//
|
||||
// Turn 1: text + terminal(bg=true) + delegate_task → tools execute
|
||||
// Turn 2: final answer → message.complete, dot transitions
|
||||
|
||||
const SIDEBAR_SCRIPT: ScriptedTurn[] = [
|
||||
{
|
||||
text: 'Let me run a background task and delegate some work.',
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'terminal',
|
||||
args: {
|
||||
command: 'echo "background process output" && sleep 1 && echo "done"',
|
||||
background: true,
|
||||
notify_on_complete: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'delegate_task',
|
||||
args: {
|
||||
goal: 'Summarize the test results',
|
||||
context: 'This is a test subagent for the sidebar states E2E test.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'All tasks complete. The background process finished and the subagent returned its summary.',
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Sidebar cross-session script ──────────────────────────────────────
|
||||
//
|
||||
// E2E_SIDEBAR_CROSS starts a long background process plus a subagent so the
|
||||
// tests can:
|
||||
// 1. See the background dot while the subagent runs.
|
||||
// 2. Open a different session and see session A's dot transition to
|
||||
// "finished unread" when the background process completes.
|
||||
//
|
||||
// The background process must outlive the agent turn — the whole point is a
|
||||
// dot that is still "running" after the final answer lands. A fixed `sleep`
|
||||
// cannot guarantee that: on a loaded CI runner the turn (two model round
|
||||
// trips + a real subagent delegation) can take longer than the sleep, the
|
||||
// process exits early, the 4s success linger elapses, and the dot is gone
|
||||
// before the test looks. That is a wall-clock race between three independent
|
||||
// timers, and it made this the flakiest spec in the suite.
|
||||
//
|
||||
// When `backgroundReleasePath` is set the process instead blocks until the
|
||||
// test creates that sentinel file, so the test — not the clock — decides when
|
||||
// the dot clears. `sleep 5` remains the fallback for callers that don't pass
|
||||
// a handle.
|
||||
function sidebarCrossBgCommand(releasePath?: string): string {
|
||||
if (!releasePath) {
|
||||
return 'echo "long bg output" && sleep 5 && echo "finished"'
|
||||
}
|
||||
// Bounded wait (60s): if a test forgets to release (or crashes mid-way),
|
||||
// the process still exits instead of hanging the worker until the suite
|
||||
// times out.
|
||||
const quoted = JSON.stringify(releasePath)
|
||||
return [
|
||||
'echo "long bg output"',
|
||||
`for _ in $(seq 1 600); do [ -e ${quoted} ] && break; sleep 0.1; done`,
|
||||
'echo "finished"',
|
||||
].join(' && ')
|
||||
}
|
||||
|
||||
function sidebarCrossScript(releasePath?: string): ScriptedTurn[] {
|
||||
return [
|
||||
{
|
||||
text: 'Starting a long background task and delegating work.',
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'terminal',
|
||||
args: {
|
||||
command: sidebarCrossBgCommand(releasePath),
|
||||
background: true,
|
||||
notify_on_complete: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'delegate_task',
|
||||
args: {
|
||||
goal: 'Analyze cross-session state',
|
||||
context: 'Testing that the background dot updates across sessions.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Both tasks are running in the background now.',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const SIDEBAR_CROSS_SCRIPT: ScriptedTurn[] = sidebarCrossScript()
|
||||
|
||||
const QUEUE_STOP_SCRIPT: ScriptedTurn[] = [
|
||||
{
|
||||
text: 'Starting a task that will keep this turn active.',
|
||||
toolCalls: [{ name: 'clarify', args: { question: 'Keep working?', choices: ['Yes', 'No'] } }],
|
||||
},
|
||||
{ text: 'The paused task completed.' },
|
||||
]
|
||||
|
||||
// The reported correction arrived while a foreground tool was still running.
|
||||
// Keep that boundary open long enough for the renderer to redirect the turn,
|
||||
// then let the next model request complete normally.
|
||||
const CORRECTION_SWITCH_SCRIPT: ScriptedTurn[] = [
|
||||
{
|
||||
text: 'Checking the long-running task before I continue.',
|
||||
toolCalls: [{ name: 'terminal', args: { command: 'sleep 5' } }],
|
||||
},
|
||||
{ text: 'The corrected task finished.' },
|
||||
]
|
||||
|
||||
export const CORRECTION_SWITCH_TRIGGER = 'E2E_CORRECTION_SWITCH_TRIGGER'
|
||||
|
||||
/**
|
||||
* Drives a real code edit followed by two finish attempts. Hermes should add
|
||||
* its synthetic verify-on-stop continuation after each finish attempt until
|
||||
* the bounded verifier gives up. The mock's request capture proves the nudge
|
||||
* reached the model; desktop must never render it as chat content.
|
||||
*/
|
||||
function verificationStopScript(writePath: string): ScriptedTurn[] {
|
||||
return [
|
||||
{
|
||||
text: 'I will make the requested code change.',
|
||||
toolCalls: [{
|
||||
name: 'write_file',
|
||||
args: {
|
||||
path: writePath,
|
||||
content: 'def changed_by_e2e():\n return "changed"\n',
|
||||
},
|
||||
}],
|
||||
},
|
||||
{ text: 'The code edit is complete.' },
|
||||
{ text: 'I cannot provide fresh verification evidence for that edit.' },
|
||||
]
|
||||
}
|
||||
|
||||
export const VERIFICATION_STOP_TRIGGER = 'E2E_VERIFY_ON_STOP_TRIGGER'
|
||||
export const VERIFICATION_STOP_TEXT = 'I cannot provide fresh verification evidence for that edit.'
|
||||
|
||||
/**
|
||||
* A marker that makes the mock emit a real blocking clarify tool call. Tests
|
||||
* use it to hold a turn open while exercising busy-composer interactions.
|
||||
*/
|
||||
export const BLOCKING_CLARIFY_TRIGGER = 'E2E_BLOCKING_CLARIFY_TRIGGER'
|
||||
export const BLOCKING_CLARIFY_QUESTION = 'Keep this test turn running?'
|
||||
|
||||
/**
|
||||
* A long live response with a five-row todo card, held open by a foreground tool.
|
||||
* The transcript is deliberately taller than the viewport so warm-session
|
||||
* tests can detect when re-opening the session leaves it above the true bottom.
|
||||
*/
|
||||
export const TASK_PANEL_RESUME_TRIGGER = 'E2E_TASK_PANEL_RESUME_TRIGGER'
|
||||
export const TASK_PANEL_RESUME_TEXT = Array.from(
|
||||
{ length: 24 },
|
||||
(_, index) => `Task-panel clearance line ${index + 1}: inspect the restored working session geometry.`,
|
||||
).join('\n\n')
|
||||
|
||||
const TASK_PANEL_RESUME_SCRIPT: ScriptedTurn[] = [
|
||||
{
|
||||
text: TASK_PANEL_RESUME_TEXT,
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'todo',
|
||||
args: {
|
||||
todos: [
|
||||
{ id: 'design', content: 'Design the restored layout', status: 'completed' },
|
||||
{ id: 'implement', content: 'Implement the measured clearance', status: 'in_progress' },
|
||||
{ id: 'verify', content: 'Verify the latest message stays visible', status: 'pending' },
|
||||
{ id: 'review', content: 'Review the visual regression', status: 'pending' },
|
||||
{ id: 'ship', content: 'Ship the focused fix', status: 'pending' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'terminal',
|
||||
args: { command: 'sleep 60' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const BLOCKING_CLARIFY_TURN: ScriptedTurn = {
|
||||
text: '',
|
||||
toolCalls: [{ name: 'clarify', args: { question: BLOCKING_CLARIFY_QUESTION, choices: ['Yes', 'No'] } }],
|
||||
}
|
||||
|
||||
/**
|
||||
* A marker that makes the mock emit a blocking BATCH clarify tool call
|
||||
* (multi-question form). Regression coverage for the duplicated-card bug:
|
||||
* the tool.start row and the clarify.request row carry different ids and a
|
||||
* batch payload has no top-level question, so the correlation key must come
|
||||
* from the question list or the card mounts twice.
|
||||
*/
|
||||
export const BATCH_CLARIFY_TRIGGER = 'E2E_BATCH_CLARIFY_TRIGGER'
|
||||
export const BATCH_CLARIFY_QUESTIONS = [
|
||||
{ question: 'Pick a batch drink?', choices: ['Coffee', 'Tea'] },
|
||||
{ question: 'Pick a batch time?', choices: ['Morning', 'Night'] },
|
||||
]
|
||||
|
||||
const BATCH_CLARIFY_TURN: ScriptedTurn = {
|
||||
text: '',
|
||||
toolCalls: [{ name: 'clarify', args: { questions: BATCH_CLARIFY_QUESTIONS } }],
|
||||
}
|
||||
|
||||
function includesBatchClarifyTrigger(value: unknown): boolean {
|
||||
if (typeof value === 'string') {
|
||||
return value.includes(BATCH_CLARIFY_TRIGGER)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(includesBatchClarifyTrigger)
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.values(value).some(includesBatchClarifyTrigger)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function includesBlockingClarifyTrigger(value: unknown): boolean {
|
||||
if (typeof value === 'string') {
|
||||
return value.includes(BLOCKING_CLARIFY_TRIGGER)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(includesBlockingClarifyTrigger)
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.values(value).some(includesBlockingClarifyTrigger)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the mock server on an ephemeral port.
|
||||
*
|
||||
* @returns a handle with `port`, `url`, received user prompts, and `close()`.
|
||||
*/
|
||||
export function startMockServer(options: MockServerOptions = {}): Promise<MockServer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const receivedPrompts: string[] = []
|
||||
let resolveHeldStreamStarted: (() => void) | null = null
|
||||
let releaseHeldStream: (() => void) | null = null
|
||||
let heldCompletionCount = 0
|
||||
const heldStreamStarted = new Promise<void>(resolveHeld => {
|
||||
resolveHeldStreamStarted = resolveHeld
|
||||
})
|
||||
const heldStreamReleased = new Promise<void>(resolveRelease => {
|
||||
releaseHeldStream = resolveRelease
|
||||
})
|
||||
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 lastUserMessage = [...(parsed.messages ?? [])]
|
||||
.reverse()
|
||||
.find((message: { role?: unknown }) => message?.role === 'user')
|
||||
|
||||
if (typeof lastUserMessage?.content === 'string') {
|
||||
receivedPrompts.push(lastUserMessage.content)
|
||||
}
|
||||
|
||||
const stream = parsed.stream === true
|
||||
const model = parsed.model || 'mock-model'
|
||||
const holdThisCompletion = Boolean(
|
||||
options.holdFirstCompletionContaining &&
|
||||
heldCompletionCount === 0 &&
|
||||
JSON.stringify(parsed).includes(options.holdFirstCompletionContaining),
|
||||
)
|
||||
|
||||
// Detect the interim-message test trigger: the user's message
|
||||
// contains a specific keyword. The mock walks through the
|
||||
// INTERIM_SCRIPT turns in sequence.
|
||||
//
|
||||
// The trigger keyword is chosen so normal chat tests (which send
|
||||
// "Hello, can you hear me?" etc.) never hit this path.
|
||||
const messages: any[] = Array.isArray(parsed.messages) ? parsed.messages : []
|
||||
const lastUserMsg = [...messages].reverse().find(m => m?.role === 'user')
|
||||
const userText = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : ''
|
||||
if (userText) {
|
||||
_receivedUserTexts.push(userText)
|
||||
}
|
||||
const isInterimTrigger = userText.includes('E2E_INTERIM_TRIGGER')
|
||||
const isSidebarTrigger = userText.includes('E2E_SIDEBAR_TRIGGER')
|
||||
const isSidebarCrossTrigger = userText.includes('E2E_SIDEBAR_CROSS')
|
||||
const isQueueStopTrigger = userText.includes('E2E_QUEUE_STOP_TRIGGER')
|
||||
const isTaskPanelResumeTrigger = userText.includes(TASK_PANEL_RESUME_TRIGGER)
|
||||
const isVerificationStopTrigger = messages.some(
|
||||
message => typeof message?.content === 'string' && message.content.includes(VERIFICATION_STOP_TRIGGER),
|
||||
)
|
||||
const isCorrectionSwitchTrigger = messages.some(
|
||||
message => typeof message?.content === 'string' && message.content.includes(CORRECTION_SWITCH_TRIGGER),
|
||||
)
|
||||
|
||||
if (isTaskPanelResumeTrigger) {
|
||||
const turn =
|
||||
TASK_PANEL_RESUME_SCRIPT[_taskPanelResumeIndex] ??
|
||||
TASK_PANEL_RESUME_SCRIPT[TASK_PANEL_RESUME_SCRIPT.length - 1]
|
||||
_taskPanelResumeIndex++
|
||||
const respond = () => {
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, turn)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, turn)
|
||||
}
|
||||
}
|
||||
|
||||
if (holdThisCompletion) {
|
||||
heldCompletionCount++
|
||||
resolveHeldStreamStarted?.()
|
||||
void heldStreamReleased.then(respond)
|
||||
} else {
|
||||
respond()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (includesBatchClarifyTrigger(parsed.messages)) {
|
||||
// Only the FIRST completion of the conversation scripts the batch
|
||||
// clarify. The trigger text stays in message history, so once the
|
||||
// answered tool result is present the turn falls through to the
|
||||
// canned reply — otherwise the mock loops the quiz forever.
|
||||
const hasToolResult = Array.isArray(parsed.messages)
|
||||
&& parsed.messages.some((message: { role?: string }) => message?.role === 'tool')
|
||||
|
||||
if (!hasToolResult) {
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, BATCH_CLARIFY_TURN)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, BATCH_CLARIFY_TURN)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (includesBlockingClarifyTrigger(parsed.messages)) {
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, BLOCKING_CLARIFY_TURN)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, BLOCKING_CLARIFY_TURN)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isQueueStopTrigger) {
|
||||
const turn = QUEUE_STOP_SCRIPT[_queueStopIndex] ?? QUEUE_STOP_SCRIPT[QUEUE_STOP_SCRIPT.length - 1]
|
||||
_queueStopIndex++
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, turn)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, turn)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isVerificationStopTrigger) {
|
||||
const script = verificationStopScript(options.verificationWritePath ?? 'e2e-verification-target.py')
|
||||
const turn = script[_verificationStopIndex] ?? script[script.length - 1]
|
||||
_verificationStopIndex++
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, turn)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, turn)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isCorrectionSwitchTrigger) {
|
||||
const turn = CORRECTION_SWITCH_SCRIPT[_correctionSwitchIndex] ?? CORRECTION_SWITCH_SCRIPT[CORRECTION_SWITCH_SCRIPT.length - 1]
|
||||
_correctionSwitchIndex++
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, turn)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, turn)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isSidebarCrossTrigger) {
|
||||
const script = sidebarCrossScript(options.backgroundReleasePath)
|
||||
const turn = script[_sidebarCrossIndex] ?? script[script.length - 1]
|
||||
_sidebarCrossIndex++
|
||||
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, turn)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, turn)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isSidebarTrigger) {
|
||||
const turn = SIDEBAR_SCRIPT[_sidebarScriptIndex] ?? SIDEBAR_SCRIPT[SIDEBAR_SCRIPT.length - 1]
|
||||
_sidebarScriptIndex++
|
||||
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, turn)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, turn)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isInterimTrigger) {
|
||||
const turn = INTERIM_SCRIPT[_scriptIndex] ?? INTERIM_SCRIPT[INTERIM_SCRIPT.length - 1]
|
||||
_scriptIndex++
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, turn)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, turn)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
const holdThisStream = Boolean(
|
||||
options.holdFirstStreamForPrompt && typeof lastUserMessage?.content === 'string' &&
|
||||
lastUserMessage.content.includes(options.holdFirstStreamForPrompt),
|
||||
)
|
||||
streamTextResponse(res, model, MOCK_REPLY, holdThisStream || holdThisCompletion ? () => {
|
||||
if (holdThisCompletion) {
|
||||
heldCompletionCount++
|
||||
}
|
||||
resolveHeldStreamStarted?.()
|
||||
return heldStreamReleased
|
||||
} : undefined)
|
||||
} else {
|
||||
if (holdThisCompletion) {
|
||||
heldCompletionCount++
|
||||
resolveHeldStreamStarted?.()
|
||||
void heldStreamReleased.then(() => nonStreamingTextResponse(res, model, MOCK_REPLY))
|
||||
} else {
|
||||
nonStreamingTextResponse(res, model, MOCK_REPLY)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
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,
|
||||
receivedPrompts,
|
||||
waitForHeldStream: () => heldStreamStarted,
|
||||
waitForHeldCompletion: () => heldStreamStarted,
|
||||
releaseHeldStream: () => releaseHeldStream?.(),
|
||||
heldCompletionCount: () => heldCompletionCount,
|
||||
close: () =>
|
||||
new Promise((resolveClose, rejectClose) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
rejectClose(err)
|
||||
} else {
|
||||
resolveClose()
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Response helpers ──────────────────────────────────────────────────
|
||||
|
||||
/** SSE chunk shape for a streaming chat completion. */
|
||||
function sseChunk(model: string, delta: Record<string, unknown>, finishReason: string | null = null): string {
|
||||
return `data: ${JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
||||
})}\n\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a plain text response (no tool calls) as SSE, finishing with
|
||||
* `finish_reason: "stop"`. This is the default canned-reply path.
|
||||
*/
|
||||
function streamTextResponse(
|
||||
res: ServerResponse,
|
||||
model: string,
|
||||
text: string,
|
||||
waitForRelease?: () => Promise<void>,
|
||||
): void {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
})
|
||||
|
||||
const words = text.split(' ')
|
||||
let i = 0
|
||||
|
||||
const sendChunk = (): void => {
|
||||
if (i >= words.length) {
|
||||
res.write(sseChunk(model, {}, 'stop'))
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const word = i === 0 ? words[i] : ' ' + words[i]
|
||||
res.write(sseChunk(model, { content: word }))
|
||||
i++
|
||||
if (waitForRelease && i === 1) {
|
||||
waitForRelease().then(() => setTimeout(sendChunk, 20))
|
||||
return
|
||||
}
|
||||
setTimeout(sendChunk, 20)
|
||||
}
|
||||
|
||||
sendChunk()
|
||||
}
|
||||
|
||||
/** Non-streaming plain text response. */
|
||||
function nonStreamingTextResponse(res: ServerResponse, model: string, text: string): void {
|
||||
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: text },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a single scripted turn: first the text content (word by word),
|
||||
* then a chunk carrying the tool_calls (if any), with the appropriate
|
||||
* finish_reason.
|
||||
*
|
||||
* If the turn has no text and no tool calls, it's an empty final response.
|
||||
* If it has text but no tool calls, it's a final answer (finish_reason: stop).
|
||||
* If it has tool calls (with or without text), finish_reason is "tool_calls".
|
||||
*/
|
||||
function streamScriptedTurn(
|
||||
res: ServerResponse,
|
||||
model: string,
|
||||
turn: ScriptedTurn,
|
||||
): void {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
})
|
||||
|
||||
const hasToolCalls = turn.toolCalls && turn.toolCalls.length > 0
|
||||
const finishReason = hasToolCalls ? 'tool_calls' : 'stop'
|
||||
|
||||
// If there's no text to stream, go straight to the tool_calls / finish.
|
||||
if (!turn.text) {
|
||||
if (hasToolCalls) {
|
||||
res.write(
|
||||
sseChunk(model, {
|
||||
tool_calls: turn.toolCalls!.map((tc, idx) => ({
|
||||
index: idx,
|
||||
id: `call_e2e_${_scriptIndex}_${idx}`,
|
||||
type: 'function',
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||
})),
|
||||
}, finishReason),
|
||||
)
|
||||
} else {
|
||||
res.write(sseChunk(model, {}, finishReason))
|
||||
}
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
// Stream the text word by word, then emit tool_calls if present.
|
||||
const words = turn.text.split(' ')
|
||||
let i = 0
|
||||
|
||||
const sendChunk = (): void => {
|
||||
if (i >= words.length) {
|
||||
// All text streamed — emit tool_calls if present, then finish.
|
||||
if (hasToolCalls) {
|
||||
res.write(
|
||||
sseChunk(model, {
|
||||
tool_calls: turn.toolCalls!.map((tc, idx) => ({
|
||||
index: idx,
|
||||
id: `call_e2e_${_scriptIndex}_${idx}`,
|
||||
type: 'function',
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||
})),
|
||||
}, finishReason),
|
||||
)
|
||||
} else {
|
||||
res.write(sseChunk(model, {}, finishReason))
|
||||
}
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const word = i === 0 ? words[i] : ' ' + words[i]
|
||||
res.write(sseChunk(model, { content: word }))
|
||||
i++
|
||||
setTimeout(sendChunk, 20)
|
||||
}
|
||||
|
||||
sendChunk()
|
||||
}
|
||||
|
||||
/** Non-streaming version of a scripted turn. */
|
||||
function nonStreamingScriptedTurn(
|
||||
res: ServerResponse,
|
||||
model: string,
|
||||
turn: ScriptedTurn,
|
||||
): void {
|
||||
const hasToolCalls = turn.toolCalls && turn.toolCalls.length > 0
|
||||
const finishReason = hasToolCalls ? 'tool_calls' : 'stop'
|
||||
|
||||
const message: Record<string, unknown> = { role: 'assistant' }
|
||||
if (turn.text) {
|
||||
message.content = turn.text
|
||||
}
|
||||
if (hasToolCalls) {
|
||||
message.tool_calls = turn.toolCalls!.map((tc, idx) => ({
|
||||
id: `call_e2e_${_scriptIndex}_${idx}`,
|
||||
type: 'function',
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||
}))
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [{ index: 0, message, finish_reason: finishReason }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the mock server's script index so each test starts from turn 0.
|
||||
* Call this between tests that use the interim trigger.
|
||||
*/
|
||||
export function restartMockServer(): void {
|
||||
resetScriptIndex()
|
||||
}
|
||||
|
||||
/** Test-controlled lifetime for the E2E_SIDEBAR_CROSS background process. */
|
||||
export interface BackgroundReleaseHandle {
|
||||
/** Sentinel path — pass as `backgroundReleasePath` to `startMockServer`. */
|
||||
path: string
|
||||
/** End the background process now (creates the sentinel). */
|
||||
release: () => void
|
||||
/** Remove the sentinel if it still exists. Safe to call twice. */
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a sentinel that keeps the E2E_SIDEBAR_CROSS background process alive
|
||||
* until the test explicitly releases it.
|
||||
*
|
||||
* The cross-session sidebar tests need a background process that is still
|
||||
* RUNNING after the agent turn finishes — that is the state under test (a
|
||||
* session whose turn is done but whose background work is not). With a fixed
|
||||
* `sleep`, three independent clocks race: the sleep, the agent turn (two model
|
||||
* round trips plus a real subagent delegation), and the 4s success linger
|
||||
* before a finished task auto-dismisses. When a loaded CI runner makes the
|
||||
* turn slower than the sleep, the process is already gone and the assertion
|
||||
* samples an empty sidebar. Observed on CI 2026-07-26 across two unrelated
|
||||
* PRs: the "should appear" poll needed 7.5s to see the dot, by which point
|
||||
* `sleep 5` had exited.
|
||||
*
|
||||
* With a sentinel there is one clock and the test owns it:
|
||||
*
|
||||
* ```ts
|
||||
* const release = createBackgroundReleaseHandle()
|
||||
* const mock = await startMockServer({ backgroundReleasePath: release.path })
|
||||
* // ... assert the dot is visible; it cannot vanish on its own ...
|
||||
* release.release() // now, and only now, the process exits
|
||||
* ```
|
||||
*/
|
||||
export function createBackgroundReleaseHandle(): BackgroundReleaseHandle {
|
||||
const path = nodePath.join(
|
||||
os.tmpdir(),
|
||||
`hermes-e2e-bg-release-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
)
|
||||
return {
|
||||
path,
|
||||
release: () => {
|
||||
try {
|
||||
fs.writeFileSync(path, 'release')
|
||||
} catch {
|
||||
// The process also has a bounded fallback wait; a failed write must
|
||||
// not crash the test before its real assertions run.
|
||||
}
|
||||
},
|
||||
cleanup: () => {
|
||||
try {
|
||||
fs.rmSync(path, { force: true })
|
||||
} catch {
|
||||
// Best-effort — the sentinel lives in the OS temp dir.
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The interim script's text constants, exported for test assertions.
|
||||
* Each entry is the visible text of one turn. Turns with empty text
|
||||
* produce no interim message and are excluded from this list.
|
||||
*/
|
||||
export const INTERIM_TEXTS = {
|
||||
/** All interim texts that should appear as sealed messages when the flag is ON. */
|
||||
interims: INTERIM_SCRIPT
|
||||
.filter((t) => t.text && t.toolCalls)
|
||||
.map((t) => t.text),
|
||||
/** The final answer text. */
|
||||
finalText: INTERIM_SCRIPT[INTERIM_SCRIPT.length - 1].text,
|
||||
/** Text that should NOT produce an interim (empty-text tool turn). */
|
||||
silentTurnIndex: INTERIM_SCRIPT.findIndex((t) => !t.text && t.toolCalls),
|
||||
} as const
|
||||
|
||||
/** The sidebar-states script's text constants, exported for test assertions. */
|
||||
export const SIDEBAR_TEXTS = {
|
||||
/** The interim text from turn 1 (alongside tool calls). */
|
||||
interimText: SIDEBAR_SCRIPT[0].text,
|
||||
/** The final answer text. */
|
||||
finalText: SIDEBAR_SCRIPT[SIDEBAR_SCRIPT.length - 1].text,
|
||||
/** The background process command (for asserting process.list entries). */
|
||||
bgCommand: 'echo "background process output" && sleep 1 && echo "done"',
|
||||
/** The subagent's goal (for asserting subagent panel state). */
|
||||
subagentGoal: 'Summarize the test results',
|
||||
} as const
|
||||
|
||||
/** The cross-session sidebar script's text constants. */
|
||||
export const SIDEBAR_CROSS_TEXTS = {
|
||||
/** The interim text from turn 1. */
|
||||
interimText: SIDEBAR_CROSS_SCRIPT[0].text,
|
||||
/** The final answer text. */
|
||||
finalText: SIDEBAR_CROSS_SCRIPT[SIDEBAR_CROSS_SCRIPT.length - 1].text,
|
||||
/**
|
||||
* The default (unheld) background process command. Tests that pass a
|
||||
* `backgroundReleasePath` get a sentinel-waiting command instead — see
|
||||
* `createBackgroundReleaseHandle`.
|
||||
*/
|
||||
bgCommand: sidebarCrossBgCommand(),
|
||||
/** The subagent's goal. */
|
||||
subagentGoal: 'Analyze cross-session state',
|
||||
} as const
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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 './test'
|
||||
|
||||
import {
|
||||
type NoProviderFixture,
|
||||
setupNoProvider,
|
||||
waitForOnboarding,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
await expectVisualSnapshot(fixture.page, { name: 'onboarding-overlay', app: fixture.app })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* A queued prompt must remain local until the current inference turn settles.
|
||||
*
|
||||
* Hold the first streamed reply open after its first token. This gives the
|
||||
* composer a live, busy turn while the user queues a follow-up, then lets us
|
||||
* assert against the mock provider's real request log before and after the
|
||||
* held turn completes.
|
||||
*/
|
||||
|
||||
import { expect, test, type Page } from './test'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { MOCK_REPLY } from './mock-server'
|
||||
|
||||
const ACTIVE_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_ACTIVE'
|
||||
const QUEUED_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_QUEUED'
|
||||
const STEER_PROMPT = 'E2E_STEER_TURN_BOUNDARY_CORRECTION'
|
||||
|
||||
async function send(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function steer(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function queue(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await page.keyboard.press('Control+Enter')
|
||||
}
|
||||
|
||||
async function transcriptMessageOrder(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return []
|
||||
|
||||
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="user"], [data-role="assistant"]'))
|
||||
.map(message => message.textContent?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
})
|
||||
}
|
||||
|
||||
function steerTurnOrder(messages: string[]): string[] {
|
||||
return messages.flatMap(message => {
|
||||
if (message.includes(ACTIVE_PROMPT)) return [ACTIVE_PROMPT]
|
||||
if (message.includes(STEER_PROMPT)) return [STEER_PROMPT]
|
||||
if (message.includes(MOCK_REPLY)) return [MOCK_REPLY]
|
||||
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('queued prompt turn boundary', () => {
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeEach(async () => {
|
||||
fixture = await setupMockBackend({
|
||||
mockServer: { holdFirstStreamForPrompt: ACTIVE_PROMPT }
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('submits a queued prompt only after the active turn completes', async () => {
|
||||
const { mock, page } = fixture!
|
||||
|
||||
await send(page, ACTIVE_PROMPT)
|
||||
await mock.waitForHeldStream()
|
||||
await queue(page, QUEUED_PROMPT)
|
||||
await expect(page.getByText('1 Queued')).toBeVisible()
|
||||
|
||||
// The mock keeps the active SSE stream open, so a queued prompt has no
|
||||
// completed-turn boundary that could legitimately drain it. Wait past the
|
||||
// queue retry interval and assert the provider saw only the active turn.
|
||||
await page.waitForTimeout(1_000)
|
||||
expect(mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(0)
|
||||
await expect(page.locator('[data-slot="aui_thread-viewport"]')).not.toContainText(QUEUED_PROMPT)
|
||||
|
||||
mock.releaseHeldStream()
|
||||
await page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
MOCK_REPLY,
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('places a steer prompt before the reply it redirects', async () => {
|
||||
const { mock, page } = fixture!
|
||||
|
||||
await send(page, ACTIVE_PROMPT)
|
||||
await mock.waitForHeldStream()
|
||||
await steer(page, STEER_PROMPT)
|
||||
mock.releaseHeldStream()
|
||||
|
||||
await page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
MOCK_REPLY,
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
|
||||
expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ACTIVE_PROMPT, STEER_PROMPT, MOCK_REPLY])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'
|
||||
import * as path from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
const DEFAULT_TIMEOUT_MS = 60_000
|
||||
|
||||
interface JsonRpcError {
|
||||
code?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface JsonRpcFrame {
|
||||
error?: JsonRpcError
|
||||
id?: number
|
||||
method?: string
|
||||
params?: {
|
||||
payload?: unknown
|
||||
session_id?: string
|
||||
type?: string
|
||||
}
|
||||
result?: unknown
|
||||
}
|
||||
|
||||
interface CreatedSession {
|
||||
session_id: string
|
||||
stored_session_id: string
|
||||
}
|
||||
|
||||
export interface RealSessionTurn {
|
||||
/** Local image paths attached before the prompt, as the composer would. */
|
||||
images?: readonly string[]
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface RealSessionSpec {
|
||||
/** Session label. The durable row stores no title, so clients fall back to
|
||||
* the preview (the first 60 characters of the first user message). */
|
||||
title: string
|
||||
/** Each item becomes one real user prompt followed by the mock provider's reply. */
|
||||
turns: readonly (RealSessionTurn | string)[]
|
||||
}
|
||||
|
||||
export interface RealSession {
|
||||
/** Runtime-only TUI session id, valid only while the builder process is alive. */
|
||||
runtimeId: string
|
||||
/** Durable SessionDB id that desktop resumes after the builder exits. */
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates durable desktop session history through the real TUI gateway and
|
||||
* AIAgent loop, using the E2E mock provider configured in `hermesHome`.
|
||||
*
|
||||
* This intentionally uses the shipped stdio JSON-RPC transport instead of
|
||||
* importing SessionDB or launching Electron. The desktop's WebSocket backend
|
||||
* dispatches the same `tui_gateway.server` methods.
|
||||
*/
|
||||
export class RealSessionBuilder {
|
||||
private readonly child: ChildProcessWithoutNullStreams
|
||||
private nextRequestId = 0
|
||||
private readonly pending = new Map<number, { reject: (reason: Error) => void; resolve: (value: unknown) => void }>()
|
||||
private readonly events: JsonRpcFrame[] = []
|
||||
private readonly eventWaiters: Array<{
|
||||
predicate: (frame: JsonRpcFrame) => boolean
|
||||
reject: (reason: Error) => void
|
||||
resolve: (frame: JsonRpcFrame) => void
|
||||
}> = []
|
||||
private readonly stderr: string[] = []
|
||||
private closed = false
|
||||
|
||||
private constructor(hermesHome: string) {
|
||||
this.child = spawn('uv', ['run', '--active', '--no-sync', 'python', '-m', 'tui_gateway.entry'], {
|
||||
cwd: REPO_ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME: hermesHome,
|
||||
PYTHONPATH: REPO_ROOT,
|
||||
},
|
||||
stdio: 'pipe',
|
||||
})
|
||||
|
||||
createInterface({ input: this.child.stdout }).on('line', line => this.handleLine(line))
|
||||
createInterface({ input: this.child.stderr }).on('line', line => {
|
||||
this.stderr.push(line)
|
||||
if (this.stderr.length > 80) this.stderr.shift()
|
||||
})
|
||||
this.child.once('error', error => this.failAll(new Error(`real-session gateway failed to start: ${error.message}`)))
|
||||
this.child.once('exit', (code, signal) => {
|
||||
if (!this.closed) {
|
||||
this.failAll(new Error(`real-session gateway exited unexpectedly (${signal ?? code ?? 'unknown'}):\n${this.stderr.join('\n')}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static async start(hermesHome: string): Promise<RealSessionBuilder> {
|
||||
const builder = new RealSessionBuilder(hermesHome)
|
||||
await builder.waitForEvent(frame => frame.params?.type === 'gateway.ready')
|
||||
return builder
|
||||
}
|
||||
|
||||
async createSession(spec: RealSessionSpec): Promise<RealSession> {
|
||||
if (spec.turns.length === 0) {
|
||||
throw new Error('RealSessionBuilder requires at least one turn so the real agent creates a durable session row')
|
||||
}
|
||||
|
||||
const created = await this.request<CreatedSession>('session.create', {
|
||||
cols: 120,
|
||||
cwd: REPO_ROOT,
|
||||
source: 'desktop',
|
||||
title: spec.title,
|
||||
})
|
||||
const runtimeId = requireString(created, 'session_id')
|
||||
const sessionId = requireString(created, 'stored_session_id')
|
||||
|
||||
for (const turn of spec.turns) {
|
||||
const { images = [], text } = typeof turn === 'string' ? { text: turn } : turn
|
||||
|
||||
for (const image of images) {
|
||||
await this.request('image.attach', { session_id: runtimeId, path: image })
|
||||
}
|
||||
|
||||
const completion = this.waitForEvent(
|
||||
frame => frame.params?.type === 'message.complete' && frame.params.session_id === runtimeId,
|
||||
)
|
||||
await this.request('prompt.submit', { session_id: runtimeId, text })
|
||||
const frame = await completion
|
||||
const status = readString(frame.params?.payload, 'status')
|
||||
if (status !== 'complete') {
|
||||
throw new Error(`real session turn failed with status ${status ?? 'unknown'}: ${JSON.stringify(frame.params?.payload)}`)
|
||||
}
|
||||
}
|
||||
|
||||
await this.request('session.close', { session_id: runtimeId })
|
||||
return { runtimeId, sessionId }
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.child.stdin.end()
|
||||
await new Promise<void>(resolve => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.child.kill('SIGTERM')
|
||||
resolve()
|
||||
}, 5_000)
|
||||
this.child.once('exit', () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private request<T = unknown>(method: string, params: Record<string, unknown>): Promise<T> {
|
||||
const id = ++this.nextRequestId
|
||||
return this.withTimeout(new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve: value => resolve(value as T), reject })
|
||||
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, error => {
|
||||
if (error) {
|
||||
this.pending.delete(id)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}), `request ${method}`)
|
||||
}
|
||||
|
||||
private waitForEvent(predicate: (frame: JsonRpcFrame) => boolean): Promise<JsonRpcFrame> {
|
||||
const index = this.events.findIndex(predicate)
|
||||
if (index >= 0) {
|
||||
return Promise.resolve(this.events.splice(index, 1)[0])
|
||||
}
|
||||
return this.withTimeout(new Promise<JsonRpcFrame>((resolve, reject) => {
|
||||
this.eventWaiters.push({ predicate, resolve, reject })
|
||||
}), 'gateway event')
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
let frame: JsonRpcFrame
|
||||
try {
|
||||
frame = JSON.parse(line) as JsonRpcFrame
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof frame.id === 'number') {
|
||||
const pending = this.pending.get(frame.id)
|
||||
if (!pending) return
|
||||
this.pending.delete(frame.id)
|
||||
if (frame.error) {
|
||||
pending.reject(new Error(`JSON-RPC error ${frame.error.code ?? 'unknown'}: ${frame.error.message ?? 'unknown error'}`))
|
||||
} else {
|
||||
pending.resolve(frame.result)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (frame.method !== 'event') return
|
||||
const waiter = this.eventWaiters.find(candidate => candidate.predicate(frame))
|
||||
if (!waiter) {
|
||||
this.events.push(frame)
|
||||
return
|
||||
}
|
||||
this.eventWaiters.splice(this.eventWaiters.indexOf(waiter), 1)
|
||||
waiter.resolve(frame)
|
||||
}
|
||||
|
||||
private withTimeout<T>(promise: Promise<T>, operation: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Timed out after ${DEFAULT_TIMEOUT_MS / 1000}s waiting for ${operation}:\n${this.stderr.join('\n')}`)), DEFAULT_TIMEOUT_MS)
|
||||
promise.then(value => {
|
||||
clearTimeout(timer)
|
||||
resolve(value)
|
||||
}, error => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private failAll(error: Error): void {
|
||||
for (const pending of this.pending.values()) pending.reject(error)
|
||||
this.pending.clear()
|
||||
for (const waiter of this.eventWaiters) waiter.reject(error)
|
||||
this.eventWaiters.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
function readString(value: unknown, key: string): string | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const candidate = (value as Record<string, unknown>)[key]
|
||||
return typeof candidate === 'string' ? candidate : undefined
|
||||
}
|
||||
|
||||
function requireString(value: unknown, key: string): string {
|
||||
const candidate = readString(value, key)
|
||||
if (!candidate) throw new Error(`Gateway response omitted required ${key}: ${JSON.stringify(value)}`)
|
||||
return candidate
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { test, expect } from './test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('persistent terminal overlay follows the pane after split dragging', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
await page.keyboard.press('Control+`')
|
||||
await page.locator('[data-terminal-slot]').waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await page.locator('[data-persistent-terminal] .xterm').waitFor({ state: 'visible', timeout: 30_000 })
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const slot = document.querySelector('[data-terminal-slot]')
|
||||
const overlay = document.querySelector('[data-persistent-terminal]')
|
||||
|
||||
if (!slot || !overlay) {
|
||||
return { drift: -1, moved: 0, target: false }
|
||||
}
|
||||
|
||||
const before = slot.getBoundingClientRect()
|
||||
const target = [...document.querySelectorAll<HTMLElement>('[role="separator"]')]
|
||||
.map(element => {
|
||||
const box = element.getBoundingClientRect()
|
||||
const horizontal = box.width > box.height
|
||||
const center = horizontal
|
||||
? (box.top + box.bottom) / 2
|
||||
: (box.left + box.right) / 2
|
||||
const sides = horizontal
|
||||
? [before.top, before.bottom]
|
||||
: [before.left, before.right]
|
||||
|
||||
return {
|
||||
element,
|
||||
box,
|
||||
horizontal,
|
||||
score: Math.min(...sides.map(side => Math.abs(center - side))),
|
||||
}
|
||||
})
|
||||
.filter(item => item.box.width > 0 && item.box.height > 0)
|
||||
.sort((a, b) => a.score - b.score)[0]
|
||||
|
||||
if (!target) {
|
||||
return { drift: -1, moved: 0, target: false }
|
||||
}
|
||||
|
||||
const x = target.box.left + target.box.width / 2
|
||||
const y0 = target.box.top + target.box.height / 2
|
||||
const nearestSide = target.horizontal
|
||||
? Math.abs(y0 - before.top) < Math.abs(y0 - before.bottom)
|
||||
? 'top'
|
||||
: 'bottom'
|
||||
: Math.abs(x - before.left) < Math.abs(x - before.right)
|
||||
? 'left'
|
||||
: 'right'
|
||||
const deltaX = nearestSide === 'left' ? -1 : nearestSide === 'right' ? 1 : 0
|
||||
const deltaY = nearestSide === 'top' ? -1 : nearestSide === 'bottom' ? 1 : 0
|
||||
let currentX = x
|
||||
let y = y0
|
||||
const pointer = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId: 71,
|
||||
pointerType: 'mouse',
|
||||
isPrimary: true,
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
}
|
||||
|
||||
target.element.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { ...pointer, clientX: x, clientY: y }),
|
||||
)
|
||||
|
||||
for (let index = 0; index < 24; index += 1) {
|
||||
currentX += deltaX
|
||||
y += deltaY
|
||||
window.dispatchEvent(
|
||||
new PointerEvent('pointermove', {
|
||||
...pointer,
|
||||
clientX: currentX,
|
||||
clientY: y,
|
||||
}),
|
||||
)
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
...pointer,
|
||||
buttons: 0,
|
||||
clientX: currentX,
|
||||
clientY: y,
|
||||
}),
|
||||
)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 350))
|
||||
await new Promise<void>(resolve =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
)
|
||||
|
||||
const next = slot.getBoundingClientRect()
|
||||
const fixed = overlay.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
drift: Math.max(
|
||||
Math.abs(next.top - fixed.top),
|
||||
Math.abs(next.left - fixed.left),
|
||||
Math.abs(next.width - fixed.width),
|
||||
Math.abs(next.height - fixed.height),
|
||||
),
|
||||
moved: Math.max(
|
||||
Math.abs(next.top - before.top),
|
||||
Math.abs(next.left - before.left),
|
||||
Math.abs(next.width - before.width),
|
||||
Math.abs(next.height - before.height),
|
||||
),
|
||||
target: true,
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.target).toBe(true)
|
||||
expect(result.moved).toBeGreaterThan(10)
|
||||
expect(result.drift).toBeLessThanOrEqual(1)
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* E2E coverage for session compression, which rotates a live backend session.
|
||||
*/
|
||||
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { MOCK_REPLY, receivedUserTexts, restartMockServer } from './mock-server'
|
||||
|
||||
async function send(page: Page, text: string, delay = 15): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type(text, { delay })
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function pasteAndSend(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await page.keyboard.insertText(text)
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
expected => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(expected) ?? false,
|
||||
text,
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('session compression', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('compresses an existing session and accepts a follow-up turn on its continuation', async () => {
|
||||
const { page } = fixture
|
||||
const reply = 'Hello from the mock inference server! The full boot chain is working.'
|
||||
|
||||
// Three completed exchanges leave a compressible middle after the
|
||||
// compressor's protected head/tail boundaries.
|
||||
await send(page, 'E2E_COMPRESSION_FIRST')
|
||||
await waitForTranscript(page, reply)
|
||||
await send(page, 'E2E_COMPRESSION_SECOND')
|
||||
await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_SECOND').length).toBe(1)
|
||||
await send(page, 'E2E_COMPRESSION_THIRD')
|
||||
await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_THIRD').length).toBe(1)
|
||||
|
||||
// The mock receiving the third prompt does not mean the TURN is over —
|
||||
// /compress on a busy session errors with "session busy — /interrupt the
|
||||
// current turn before /compress". Wait for the third reply to render and
|
||||
// for the composer to leave its busy state (no Stop affordance) first.
|
||||
await page.waitForFunction(
|
||||
expected =>
|
||||
((document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').split(expected).length - 1) >= 3,
|
||||
reply,
|
||||
{ timeout: 90_000 }
|
||||
)
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator('[data-slot="composer-root"] button[aria-label="Stop"]').count(),
|
||||
{ timeout: 30_000, message: 'turn should settle before /compress' }
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// This test covers compression and continuation, not slash completion.
|
||||
// Insert the complete command atomically and click Send so an async
|
||||
// completion response cannot consume Enter as a picker acceptance.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await page.keyboard.insertText('/compress preserve the three test turns')
|
||||
await expect.poll(() => composer.textContent()).toContain('preserve the three test turns')
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click()
|
||||
await expect
|
||||
.poll(() => page.locator('[data-slot="aui_thread-viewport"]').textContent(), { timeout: 90_000 })
|
||||
.toMatch(/Compressed|No changes from compression/)
|
||||
|
||||
// Compression rotates the agent's live session id. A post-compression
|
||||
// ordinary turn proves the desktop's runtime binding followed that child.
|
||||
await send(page, 'E2E_COMPRESSION_FOLLOW_UP')
|
||||
await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_FOLLOW_UP').length).toBe(1)
|
||||
await waitForTranscript(page, reply)
|
||||
await page.screenshot({ path: 'test-results/session-compression-continuation.png' })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('session compression in progress', () => {
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend({
|
||||
modelContextLength: 64_000,
|
||||
extraConfig: `compression:
|
||||
threshold_tokens: 22000
|
||||
protect_first_n: 0
|
||||
protect_last_n: 1
|
||||
auxiliary:
|
||||
title_generation:
|
||||
enabled: false
|
||||
compression:
|
||||
provider: custom
|
||||
model: mock-model`,
|
||||
mockServer: {
|
||||
holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.'
|
||||
}
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('queues an Enter-submitted draft instead of steering while compaction is active', async ({}, testInfo) => {
|
||||
const { page } = fixture
|
||||
const queued = 'E2E_QUEUED_DURING_COMPACTION'
|
||||
|
||||
// A normal message crosses the tiny configured context budget. The mock
|
||||
// blocks only the resulting summary request, so these assertions run
|
||||
// during automatic compaction rather than a slash-command path.
|
||||
// The payload must cross threshold_tokens (22k) on its OWN weight
|
||||
// (~12k tokens) on top of the system prompt. Do not shrink it: at
|
||||
// repeat(500) the trigger only worked because the ambient system prompt
|
||||
// (skills index + tool schemas) happened to carry it over the line, and
|
||||
// a 160-token skills-index cleanup on main broke the test for a day.
|
||||
await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_ONE '.repeat(5))
|
||||
await waitForTranscript(page, MOCK_REPLY)
|
||||
await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_TWO '.repeat(5))
|
||||
await waitForTranscript(page, MOCK_REPLY)
|
||||
await pasteAndSend(page, 'E2E_TRIGGER_AUTOMATIC_COMPACTION '.repeat(1500))
|
||||
await fixture.mock.waitForHeldCompletion()
|
||||
await expect(page.getByRole('status', { name: 'Summarizing thread' }).last()).toBeVisible()
|
||||
|
||||
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
|
||||
// Since "running is not busy" (3bc52fb9df) an empty composer mid-turn
|
||||
// shows Stop — the Queue affordance appears once a payload is typed, and
|
||||
// the Enter path below still queues instead of steering while compaction
|
||||
// holds the turn.
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Stop')
|
||||
|
||||
await send(page, queued)
|
||||
await expect(page.getByText('1 Queued')).toBeVisible()
|
||||
expect(fixture.mock.heldCompletionCount()).toBe(1)
|
||||
expect(receivedUserTexts()).not.toContain(queued)
|
||||
await page.screenshot({ path: testInfo.outputPath('queued-during-compaction.png') })
|
||||
|
||||
fixture.mock.releaseHeldStream()
|
||||
await expect.poll(() => receivedUserTexts().filter(text => text === queued).length).toBe(1)
|
||||
expect(fixture.mock.heldCompletionCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* E2E tests for desktop sidebar states — background processes, subagents,
|
||||
* and session dot transitions.
|
||||
*
|
||||
* The mock server returns scripted tool_calls that the agent executes for
|
||||
* real (trivial commands + real subagent delegations). The tests assert the
|
||||
* sidebar states driven by real gateway events.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import {
|
||||
createBackgroundReleaseHandle,
|
||||
restartMockServer,
|
||||
SIDEBAR_CROSS_TEXTS,
|
||||
SIDEBAR_TEXTS,
|
||||
} from './mock-server'
|
||||
|
||||
/** Background-running dot aria-label (from i18n en.ts). */
|
||||
const BG_DOT_LABEL = 'Background task running'
|
||||
/** Foreground turn-running dot aria-label. */
|
||||
const SESSION_RUNNING_DOT_LABEL = 'Session running'
|
||||
/** Finished-unread dot aria-label. */
|
||||
const UNREAD_DOT_LABEL = 'Finished — unread'
|
||||
|
||||
/**
|
||||
* The auto-title auxiliary call hits the SAME mock provider as the chat turn,
|
||||
* and its request carries the user's message — trigger keyword included. The
|
||||
* mock's trigger matching is text-based, so the title call consumes a script
|
||||
* index: the real chat turn then gets turn 2 (final answer, NO tool calls),
|
||||
* the background process is never spawned, and the bg dot never appears.
|
||||
* Whether that happens depends on which request lands first — the CI flake
|
||||
* these specs had. Disable auto-title so script indices line up with real
|
||||
* chat turns (same fix as interim-messages.spec.ts).
|
||||
*/
|
||||
const DISABLE_AUTO_TITLE = 'auxiliary:\n title_generation:\n enabled: false'
|
||||
|
||||
/** Send a message and wait for the final response to appear. */
|
||||
async function sendMessageAndWait(
|
||||
page: Page,
|
||||
trigger: string,
|
||||
finalText: string,
|
||||
timeout = 90_000,
|
||||
): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await composer.click()
|
||||
await composer.type(trigger, { delay: 20 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('E2E_'),
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
await page.waitForFunction(
|
||||
(text) => (document.body.textContent ?? '').includes(text),
|
||||
finalText,
|
||||
{ timeout },
|
||||
)
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Test 1: background process + subagent appear in sidebar during turn
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('sidebar states — background process and subagent', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend({ extraConfig: DISABLE_AUTO_TITLE })
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('background process dot appears and disappears, subagent runs, final answer visible', async () => {
|
||||
const page = fixture.page
|
||||
|
||||
await sendMessageAndWait(page, 'E2E_SIDEBAR_TRIGGER', SIDEBAR_TEXTS.finalText)
|
||||
|
||||
// The background process (sleep 1) should have shown a "Background task
|
||||
// running" dot at some point during the turn. We try to catch it; if
|
||||
// the process was too fast, that's OK — the real assertion is that the
|
||||
// final answer appeared and the dot is gone afterward.
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 15_000, message: 'background dot should appear' },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
} catch {
|
||||
// sleep 1 may have finished before we polled — not a failure.
|
||||
}
|
||||
|
||||
// After the turn completes and auto-dismiss fires, the background dot
|
||||
// should be gone.
|
||||
await page.waitForTimeout(8000)
|
||||
const bgCount = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count()
|
||||
expect(bgCount, 'background dot should be gone after auto-dismiss').toBe(0)
|
||||
|
||||
// Evidence: capture the final state — no background dot, final answer visible.
|
||||
await page.screenshot({ path: 'test-results/bg-dot-gone-after-dismiss.png' })
|
||||
|
||||
// The final answer text must be in the transcript.
|
||||
const viewportText = await page
|
||||
.locator('[data-slot="aui_thread-viewport"]')
|
||||
.textContent()
|
||||
expect(viewportText).toContain(SIDEBAR_TEXTS.finalText)
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Test 2: subagent running shows background dot too (longer bg process)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('sidebar states — subagent and background dot coexist', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
// Hold the background process open until the test releases it. Without the
|
||||
// sentinel the process is a bare `sleep 5` racing the agent turn (two model
|
||||
// trips + a real subagent spawn): on a loaded runner the turn outlives the
|
||||
// sleep, the process is reaped mid-turn, and the dot never appears at all —
|
||||
// the CI flake this spec had.
|
||||
const bgRelease = createBackgroundReleaseHandle()
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend({
|
||||
extraConfig: DISABLE_AUTO_TITLE,
|
||||
mockServer: { backgroundReleasePath: bgRelease.path },
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
bgRelease.release()
|
||||
await fixture?.cleanup()
|
||||
bgRelease.cleanup()
|
||||
})
|
||||
|
||||
test('background dot visible while subagent runs', async () => {
|
||||
const page = fixture.page
|
||||
|
||||
// Start the turn — a held background process plus a real subagent.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await composer.click()
|
||||
await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Wait for the user's message to appear.
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'),
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
// While the turn is busy the dot-state priority paints the session as
|
||||
// "working" ('Session running') — that claim OUTRANKS 'background', so
|
||||
// polling for the bg dot mid-turn races the turn length against the poll
|
||||
// budget. Wait for the turn to END (final text + running dot cleared),
|
||||
// then assert the background dot as a stable, sentinel-held state.
|
||||
await page.waitForFunction(
|
||||
(text) => (document.body.textContent ?? '').includes(text),
|
||||
SIDEBAR_CROSS_TEXTS.finalText,
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${SESSION_RUNNING_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'session running dot should disappear after turn completes' },
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// The background process is held open by the sentinel, so the bg dot is
|
||||
// a stable state — poll only to absorb the event-driven flip landing a
|
||||
// tick after the running dot clears.
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'background dot should be visible after turn completes' },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// Evidence: the background dot is visible while the process runs.
|
||||
await page.screenshot({ path: 'test-results/bg-dot-while-subagent-runs.png' })
|
||||
|
||||
// Release the process; the dot should clear on the completion event —
|
||||
// event-driven, not a fixed sleep.
|
||||
bgRelease.release()
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'background dot should be gone after process exits' },
|
||||
)
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Test 3: cross-session — dot updates when viewing a different session
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('sidebar states — cross-session dot transition', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
// Keeps the background process alive until this test releases it, so the
|
||||
// "still running after the turn finished" state can't expire on its own.
|
||||
const bgRelease = createBackgroundReleaseHandle()
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend({
|
||||
extraConfig: DISABLE_AUTO_TITLE,
|
||||
mockServer: { backgroundReleasePath: bgRelease.path },
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Release first so the process exits even if the test failed early,
|
||||
// then drop the sentinel file.
|
||||
bgRelease.release()
|
||||
await fixture?.cleanup()
|
||||
bgRelease.cleanup()
|
||||
})
|
||||
|
||||
test('background dot transitions to finished when viewing another session', async () => {
|
||||
const page = fixture.page
|
||||
|
||||
// Start a turn whose background process runs until we release it.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await composer.click()
|
||||
await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// While the turn is busy the dot-state priority paints the session as
|
||||
// "working" ('Session running') — that claim OUTRANKS 'background', so
|
||||
// polling for the bg dot mid-turn races the turn length (two model trips
|
||||
// + a real subagent spawn) against the poll budget: the CI flake this
|
||||
// spec had. Wait for the turn to END first, then assert the bg dot as a
|
||||
// stable, sentinel-held state.
|
||||
//
|
||||
// The final answer text streams before message.complete, so text visibility
|
||||
// alone is not a completion barrier. Wait for the foreground-running state
|
||||
// to clear before asserting the background-process state.
|
||||
await page.waitForFunction(
|
||||
(text) => (document.body.textContent ?? '').includes(text),
|
||||
SIDEBAR_CROSS_TEXTS.finalText,
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${SESSION_RUNNING_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'session running dot should disappear after turn completes' },
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// The background dot must be visible now: the turn is done but the
|
||||
// process is held open by the sentinel, so this is a stable state rather
|
||||
// than a window we have to catch in time. Poll to absorb the event-driven
|
||||
// flip landing a tick after the running dot clears.
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'background dot should be visible after turn completes' },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// Evidence: bg dot visible on session A while its turn is done but the
|
||||
// background process hasn't exited yet.
|
||||
await page.screenshot({ path: 'test-results/cross-session-bg-dot-before-switch.png' })
|
||||
|
||||
// Create a new session (click "New session" button).
|
||||
await page.locator('button:has-text("New session")').first().click()
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Now let the background process finish. The session A dot should
|
||||
// transition away from "background running".
|
||||
bgRelease.release()
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'background dot should disappear after process finishes' },
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// The original session should show a "finished unread" indicator (green dot)
|
||||
// since its turn completed while we were in a different session. This is an
|
||||
// event-driven transition, so wait for it instead of sampling the DOM right
|
||||
// after the running dot disappears.
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'original session should show finished-unread dot' },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// Evidence: the green "finished unread" dot on the original session after
|
||||
// switching to a new session — the cross-session dot transition.
|
||||
await page.screenshot({ path: 'test-results/cross-session-unread-dot-after-switch.png' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Regression coverage for #69578: harmless route-token churn during a send
|
||||
* must not make the desktop silently drop the prompt before prompt.submit.
|
||||
*/
|
||||
|
||||
import { test, expect } from './test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
|
||||
const PROMPT = 'E2E route token drift must still submit this prompt.'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture!, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('submits while same-chat search tokens churn during new-session creation', async ({}, testInfo) => {
|
||||
const { page, mock } = fixture!
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
|
||||
await composer.click()
|
||||
await composer.type(PROMPT, { delay: 10 })
|
||||
|
||||
// The submit pipeline snapshots the route synchronously, then awaits session
|
||||
// creation. Keep changing only the query string of whichever chat route is
|
||||
// current. Before #69578, comparing the raw route token treated this as a
|
||||
// user chat switch and aborted before prompt.submit.
|
||||
await page.evaluate(() => {
|
||||
let revision = 0
|
||||
const interval = window.setInterval(() => {
|
||||
const pathname = window.location.hash.slice(1).split(/[?#]/, 1)[0] || '/new'
|
||||
window.location.hash = `${pathname}?e2e-route-churn=${revision++}`
|
||||
}, 1)
|
||||
|
||||
;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn = () => {
|
||||
window.clearInterval(interval)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect
|
||||
.poll(() => mock.receivedPrompts.includes(PROMPT), { timeout: 60_000 })
|
||||
.toBe(true)
|
||||
|
||||
await page.waitForFunction(
|
||||
prompt => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(prompt) ?? false,
|
||||
PROMPT,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes('mock inference server') ?? false,
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
} finally {
|
||||
await page.evaluate(() => {
|
||||
;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn?.()
|
||||
})
|
||||
}
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('same-chat-route-churn-submitted.png') })
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Regression coverage for returning to a working session as its task panel
|
||||
* expands. The transcript must reconcile to the composer's full measured
|
||||
* height without needing a manual scroll to repair the position.
|
||||
*/
|
||||
|
||||
import { expect, test, type Page } from './test'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { TASK_PANEL_RESUME_TRIGGER } from './mock-server'
|
||||
|
||||
const SURFACE = '[data-composer-target]:visible'
|
||||
const PROMPT = `${TASK_PANEL_RESUME_TRIGGER}: keep the task panel expanded while this session is reopened.`
|
||||
|
||||
function activeSurface(page: Page) {
|
||||
return page.locator(SURFACE).last()
|
||||
}
|
||||
|
||||
async function send(page: Page, text: string): Promise<void> {
|
||||
const composer = activeSurface(page).locator('[contenteditable="true"]').first()
|
||||
|
||||
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function openFreshDraft(page: Page): Promise<void> {
|
||||
await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
|
||||
await expect(activeSurface(page).locator('[data-slot="aui_thread-viewport"]')).not.toContainText(PROMPT)
|
||||
await page.waitForTimeout(1_000)
|
||||
}
|
||||
|
||||
async function reopenWorkingSession(page: Page): Promise<void> {
|
||||
const sidebar = page.locator('[data-slot="sidebar"]')
|
||||
const row = sidebar.getByRole('button', { name: /^(?:Session running|Needs your input|Working)\b/ }).first()
|
||||
|
||||
await row.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await row.click()
|
||||
await expect(activeSurface(page).locator('[data-slot="aui_thread-viewport"]')).toContainText(
|
||||
'Task-panel clearance line 24',
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
}
|
||||
|
||||
interface ClearanceMetrics {
|
||||
composerHeight: number
|
||||
distanceFromBottom: number
|
||||
latestMessageBottom: number
|
||||
statusPanelTop: number
|
||||
viewportHeight: number
|
||||
}
|
||||
|
||||
async function clearanceMetrics(page: Page): Promise<ClearanceMetrics> {
|
||||
return activeSurface(page).evaluate(surface => {
|
||||
const chatSurface = surface.closest<HTMLElement>('[data-chat-surface]')!
|
||||
const viewport = surface.querySelector<HTMLElement>('[data-slot="aui_thread-viewport"]')!
|
||||
const latest = Array.from(surface.querySelectorAll<HTMLElement>('[data-role="assistant"]')).at(-1)!
|
||||
const status = surface.querySelector<HTMLElement>('[data-slot="composer-status-stack"]')!
|
||||
const styles = getComputedStyle(chatSurface)
|
||||
|
||||
return {
|
||||
composerHeight: Number.parseFloat(styles.getPropertyValue('--composer-measured-height')),
|
||||
distanceFromBottom: viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop,
|
||||
latestMessageBottom: latest.getBoundingClientRect().bottom,
|
||||
statusPanelTop: status.getBoundingClientRect().top,
|
||||
viewportHeight: viewport.clientHeight,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('working-session task-panel clearance', () => {
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeEach(async () => {
|
||||
fixture = await setupMockBackend({
|
||||
mockServer: { holdFirstCompletionContaining: TASK_PANEL_RESUME_TRIGGER },
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('window focus reanchors a working session above the expanded task panel', async ({}, testInfo) => {
|
||||
const page = fixture!.page
|
||||
|
||||
await send(page, PROMPT)
|
||||
await fixture!.mock.waitForHeldCompletion()
|
||||
await openFreshDraft(page)
|
||||
|
||||
// Re-open while the long response is still streaming. Its todo call lands
|
||||
// afterward, so the already-visible composer grows only after the initial
|
||||
// session-load scroll settle has finished.
|
||||
fixture!.mock.releaseHeldStream()
|
||||
await page.waitForTimeout(1_000)
|
||||
await reopenWorkingSession(page)
|
||||
await expect(activeSurface(page).getByText('Tasks 1/5')).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
// Reproduce the stale geometry at the foreground boundary. Active turns
|
||||
// disable Chromium's background throttling, so visibility can stay `visible`
|
||||
// and window focus is the only foreground edge that can repair it.
|
||||
await page.waitForTimeout(750)
|
||||
const staleState = await activeSurface(page)
|
||||
.locator('[data-slot="aui_thread-viewport"]')
|
||||
.evaluate(viewport => {
|
||||
// Grow scrollHeight before the observed thread-content node. This
|
||||
// shifts the transcript behind the dock without resizing the observed
|
||||
// node or synthesizing a user scroll (which must escape the lock).
|
||||
const staleClearance = document.createElement('div')
|
||||
staleClearance.style.height = '160px'
|
||||
staleClearance.setAttribute('aria-hidden', 'true')
|
||||
viewport.prepend(staleClearance)
|
||||
|
||||
const distance = viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop
|
||||
const surface = viewport.closest<HTMLElement>('[data-composer-target]')!
|
||||
const latest = Array.from(surface.querySelectorAll<HTMLElement>('[data-role="assistant"]')).at(-1)!
|
||||
const status = surface.querySelector<HTMLElement>('[data-slot="composer-status-stack"]')!
|
||||
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
|
||||
return {
|
||||
distance,
|
||||
following: viewport.dataset.following,
|
||||
latestMessageBottom: latest.getBoundingClientRect().bottom,
|
||||
statusPanelTop: status.getBoundingClientRect().top,
|
||||
visibility: document.visibilityState,
|
||||
}
|
||||
})
|
||||
|
||||
expect(staleState.visibility, JSON.stringify(staleState)).toBe('visible')
|
||||
expect(staleState.following, JSON.stringify(staleState)).toBe('true')
|
||||
expect(staleState.distance).toBeGreaterThan(100)
|
||||
expect(staleState.latestMessageBottom, JSON.stringify(staleState)).toBeGreaterThan(staleState.statusPanelTop)
|
||||
await page.waitForTimeout(1_000)
|
||||
const metrics = await clearanceMetrics(page)
|
||||
await page.screenshot({ path: testInfo.outputPath('task-panel-after-resume.png') })
|
||||
|
||||
expect(metrics.composerHeight, JSON.stringify(metrics)).toBeGreaterThanOrEqual(190)
|
||||
expect(metrics.distanceFromBottom, JSON.stringify(metrics)).toBeLessThan(staleState.distance / 2)
|
||||
expect(metrics.latestMessageBottom, JSON.stringify(metrics)).toBeLessThanOrEqual(metrics.statusPanelTop)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Extended Playwright test fixture that auto-fails any test if an error
|
||||
* banner (notification toast with role="alert") appears in the DOM.
|
||||
*
|
||||
* The desktop app surfaces errors as `[data-slot="alert"][role="alert"]`
|
||||
* elements (see components/notifications.tsx). When one appears during a
|
||||
* test, it means something went wrong (resume failed, boot error, etc.)
|
||||
* — the test should fail with the error message, not silently pass while
|
||||
* an error toast is visible on screen.
|
||||
*
|
||||
* Usage: import { test, expect } from './test' instead of
|
||||
* '@playwright/test'. The guard is auto-installed on every page — no
|
||||
* per-spec setup needed.
|
||||
*/
|
||||
|
||||
import { test as base, expect, type Page, type ElectronApplication, _electron } from '@playwright/test'
|
||||
|
||||
// Track error messages per test so afterEach can assert + report.
|
||||
const seenErrors: string[] = []
|
||||
let activePage: Page | null = null
|
||||
// When true, the afterEach guard skips the error-banner check.
|
||||
// Set by tests that deliberately trigger error states (e.g. boot-failure).
|
||||
let errorBannersAllowed = false
|
||||
|
||||
/**
|
||||
* Opt out of the error-banner guard for the current test. Call in
|
||||
* test.beforeEach or at the top of a test body when error banners are
|
||||
* expected (e.g. boot-failure tests that deliberately trigger errors).
|
||||
*/
|
||||
export function allowErrorBanners(): void {
|
||||
errorBannersAllowed = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the error-banner guard on a page. Watches for `[role="alert"]`
|
||||
* elements appearing in the DOM. When one is found, records its text
|
||||
* content for the afterEach assertion.
|
||||
*
|
||||
* Exported so e2e fixture functions (which create pages via _electron.launch)
|
||||
* can install the guard on their custom pages — the default Playwright `page`
|
||||
* fixture override only catches pages created by Playwright itself, not
|
||||
* pages created by the test's own Electron launch.
|
||||
*/
|
||||
export function installErrorBannerGuard(page: Page): void {
|
||||
activePage = page
|
||||
|
||||
// Clear any errors from a previous test when a new page is created.
|
||||
seenErrors.length = 0
|
||||
|
||||
// Use a MutationObserver to catch error banners as they appear.
|
||||
// We inject this via addInitScript so it runs before any app code.
|
||||
page.addInitScript(() => {
|
||||
const seen: string[] = []
|
||||
;(window as unknown as { __ERROR_BANNER_GUARD__?: string[] }).__ERROR_BANNER_GUARD__ = seen
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const alerts = document.querySelectorAll('[role="alert"]')
|
||||
|
||||
for (const alert of alerts) {
|
||||
const text = (alert.textContent ?? '').trim()
|
||||
|
||||
if (text && !seen.includes(text)) {
|
||||
seen.push(text)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Start observing once the DOM is ready.
|
||||
if (document.body) {
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Also poll via evaluate — MutationObserver via addInitScript can miss
|
||||
// elements that appear during the Electron renderer's initial mount
|
||||
// (before the observer is installed). A periodic poll catches those.
|
||||
page.on('console', () => {
|
||||
// Console messages are not errors — but we keep the listener to
|
||||
// ensure the page context is active for our evaluate calls.
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for error banners that appeared during the test. Called in
|
||||
* afterEach via the custom fixture below. Also exported so specs that
|
||||
* manage their own page lifecycle can call it directly.
|
||||
*/
|
||||
export async function collectErrorBanners(page: Page | null): Promise<string[]> {
|
||||
if (!page) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
// Read errors collected by the MutationObserver in the page context.
|
||||
const pageErrors = await page.evaluate(() => {
|
||||
const w = window as unknown as { __ERROR_BANNER_GUARD__?: string[] }
|
||||
|
||||
return [...(w.__ERROR_BANNER_GUARD__ ?? [])]
|
||||
})
|
||||
|
||||
// Also do a final DOM scan for any alert elements still visible.
|
||||
const domAlerts = await page
|
||||
.locator('[role="alert"]')
|
||||
.allTextContents()
|
||||
.catch(() => [] as string[])
|
||||
|
||||
const all = [...new Set([...pageErrors, ...domAlerts.map(t => t.trim()).filter(Boolean)])]
|
||||
seenErrors.push(...all)
|
||||
|
||||
return [...new Set(seenErrors)]
|
||||
} catch {
|
||||
// Page might be closed — return whatever we have.
|
||||
return [...new Set(seenErrors)]
|
||||
}
|
||||
}
|
||||
|
||||
// Extended test fixture: wraps the default page with the error guard.
|
||||
export const test = base.extend({
|
||||
// Override the page fixture to auto-install the guard.
|
||||
page: async ({ page }, use) => {
|
||||
installErrorBannerGuard(page)
|
||||
await use(page)
|
||||
},
|
||||
})
|
||||
|
||||
// afterEach: fail the test if any error banners appeared.
|
||||
// Always fires — even if the test already failed for another reason.
|
||||
// An error banner often IS the root cause (e.g. "resume failed" from a
|
||||
// backend bug), and suppressing it when the test also fails on an
|
||||
// assertion hides the real problem.
|
||||
//
|
||||
// Uses `activePage` (set by installErrorBannerGuard) instead of the
|
||||
// default `page` fixture — Electron tests create their own page via
|
||||
// app.firstWindow(), so the default `page` fixture is undefined.
|
||||
base.afterEach(async ({}, testInfo) => {
|
||||
const wasAllowed = errorBannersAllowed
|
||||
// Reset for the next test.
|
||||
errorBannersAllowed = false
|
||||
|
||||
if (wasAllowed) {
|
||||
// Test opted out — clear any collected errors without asserting.
|
||||
seenErrors.length = 0
|
||||
return
|
||||
}
|
||||
|
||||
const errors = await collectErrorBanners(activePage)
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
`Error banner(s) appeared during test "${testInfo.title}":\n` +
|
||||
errors.map(e => ` • ${e}`).join('\n'),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Reset for the next test file.
|
||||
base.afterAll(async () => {
|
||||
seenErrors.length = 0
|
||||
activePage = null
|
||||
})
|
||||
|
||||
export { expect, type Page, type ElectronApplication, _electron }
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* E2E tests for the tile-unread bug — two scenarios:
|
||||
*
|
||||
* 1. TAB (stacked, not visible) — a session opened as a tab via ⌃-click is
|
||||
* NOT visible on screen. When it finishes, the green "unread" dot IS
|
||||
* correct — the user isn't looking at it. This test PASSES.
|
||||
*
|
||||
* 2. SPLIT (side-by-side, visible) — a session dragged to the edge of the
|
||||
* workspace zone opens as a split tile, visible on screen at the same time
|
||||
* as the main session. When it finishes, it should NOT get the green
|
||||
* "unread" dot — the user is looking right at it. This test FAILS until
|
||||
* the fix in session-states.ts:174 lands (the unread check only compares
|
||||
* against $selectedStoredSessionId and ignores $sessionTiles).
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import {
|
||||
type BackgroundReleaseHandle,
|
||||
createBackgroundReleaseHandle,
|
||||
restartMockServer,
|
||||
SIDEBAR_CROSS_TEXTS,
|
||||
} from './mock-server'
|
||||
|
||||
/** Finished-unread dot aria-label. */
|
||||
const UNREAD_DOT_LABEL = 'Finished — unread'
|
||||
/** Background-running dot aria-label. */
|
||||
const BG_DOT_LABEL = 'Background task running'
|
||||
/** Foreground turn-running dot aria-label. */
|
||||
const SESSION_RUNNING_DOT_LABEL = 'Session running'
|
||||
|
||||
/**
|
||||
* The auto-title auxiliary call hits the SAME mock provider as the chat turn,
|
||||
* and its request carries the user's message — trigger keyword included. The
|
||||
* mock's trigger matching is text-based, so the title call consumes a script
|
||||
* index: the real chat turn then gets turn 2 (final answer, NO tool calls),
|
||||
* the background process is never spawned, and the bg dot never appears.
|
||||
* Whether that happens depends on which request lands first — the CI flake
|
||||
* this spec had. Disable auto-title so script indices line up with real chat
|
||||
* turns (same fix as interim-messages.spec.ts).
|
||||
*/
|
||||
const DISABLE_AUTO_TITLE = 'auxiliary:\n title_generation:\n enabled: false'
|
||||
|
||||
/** Locate a session's sidebar row by its preview text. */
|
||||
function sessionRow(page: import('@playwright/test').Page, text: string) {
|
||||
return page.locator('[data-slot="sidebar"] button').filter({ hasText: text }).first()
|
||||
}
|
||||
|
||||
/** Common setup: start a turn with a held bg process + subagent, wait for
|
||||
* the turn to complete, then switch to a new session so the first session is
|
||||
* no longer $selectedStoredSessionId (required before opening a tile). */
|
||||
async function startTurnAndSwitchAway(page: import('@playwright/test').Page) {
|
||||
// Send E2E_SIDEBAR_CROSS — starts a turn with sleep 5 + subagent.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await composer.click()
|
||||
await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Wait for the user's message to appear.
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'),
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
// NOTE: while the turn is busy the dot-state priority paints the session as
|
||||
// "working" ('Session running'), which OUTRANKS the background claim — the
|
||||
// 'Background task running' dot only appears once the turn completes while
|
||||
// the (sentinel-held) process is still alive. Polling for the bg dot mid-turn
|
||||
// races the turn length (two model trips + a real subagent spawn) against
|
||||
// the poll budget, which is exactly the flake this spec had on CI. So: wait
|
||||
// for the turn to END first, then assert the bg dot as a stable state.
|
||||
|
||||
// The final answer text streams before message.complete, so text visibility
|
||||
// alone is not a completion barrier. Wait for the foreground-running state
|
||||
// to clear before asserting the background-process state.
|
||||
await page.waitForFunction(
|
||||
(text) => (document.body.textContent ?? '').includes(text),
|
||||
SIDEBAR_CROSS_TEXTS.finalText,
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${SESSION_RUNNING_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'session running dot should disappear after turn completes' },
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// The background dot must be visible now: the turn is done but the process
|
||||
// is held open by the sentinel, so this is a stable state rather than a
|
||||
// window we have to catch in time. Poll rather than sampling once — the
|
||||
// dot flip is event-driven off the busy=false publish and can land a tick
|
||||
// after the running dot clears.
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'background dot should be visible after turn completes' },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// Switch to a new session — session A is no longer $selectedStoredSessionId.
|
||||
// This is required: openSessionTile bails if the session is already selected.
|
||||
await page.locator('button:has-text("New session")').first().click()
|
||||
await page.waitForTimeout(2000)
|
||||
}
|
||||
|
||||
/** Release the held background process, then wait for its dot to clear. */
|
||||
async function waitForBgProcessToFinish(
|
||||
page: import('@playwright/test').Page,
|
||||
release?: BackgroundReleaseHandle,
|
||||
) {
|
||||
release?.release()
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'background dot should disappear after process finishes' },
|
||||
)
|
||||
.toBe(0)
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Test 1: TAB (not visible) — unread dot IS correct (PASSES)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('sidebar states — tab (hidden) unread is correct', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
const bgRelease = createBackgroundReleaseHandle()
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend({
|
||||
extraConfig: DISABLE_AUTO_TITLE,
|
||||
mockServer: { backgroundReleasePath: bgRelease.path },
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
bgRelease.release()
|
||||
await fixture?.cleanup()
|
||||
bgRelease.cleanup()
|
||||
})
|
||||
|
||||
test('session opened as a tab (not visible) correctly gets unread dot', async () => {
|
||||
const page = fixture.page
|
||||
|
||||
await startTurnAndSwitchAway(page)
|
||||
|
||||
// Evidence: session A is in the background (bg dot in sidebar).
|
||||
await page.screenshot({ path: 'test-results/tile-bug-tab-switched-away.png' })
|
||||
|
||||
// ⌃-click opens the session as a TAB (center dock = stacked, not visible
|
||||
// unless it's the active tab). The session is NOT on screen.
|
||||
//
|
||||
// With auto-title disabled the sidebar row is titled by the user's
|
||||
// message (the trigger keyword), not the assistant's final text.
|
||||
const row = sessionRow(page, 'E2E_SIDEBAR_CROSS')
|
||||
await row.click({ modifiers: ['Control'] })
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Evidence: the tab is open but the session is not visible on screen.
|
||||
await page.screenshot({ path: 'test-results/tile-bug-tab-opened.png' })
|
||||
|
||||
await waitForBgProcessToFinish(page, bgRelease)
|
||||
|
||||
// A tab that's not the active tab IS hidden — the unread dot is correct.
|
||||
// The user is NOT looking at it, so marking it "unread" is right.
|
||||
//
|
||||
// Poll rather than sampling once: "finished-unread" is an event-driven
|
||||
// transition that lands slightly after the running dot clears, and with a
|
||||
// released (rather than slowly-expiring) process there is no incidental
|
||||
// slack between the two. Same reasoning as the cross-session spec.
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'hidden tab should be marked unread' },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await page.screenshot({ path: 'test-results/tile-bug-tab-unread-correct.png' })
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Test 2: SPLIT (visible) — unread dot is WRONG (FAILS until fix)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.skip('sidebar states — split (visible) unread bug (RED)', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
const bgRelease = createBackgroundReleaseHandle()
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend({
|
||||
extraConfig: DISABLE_AUTO_TITLE,
|
||||
mockServer: { backgroundReleasePath: bgRelease.path },
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
bgRelease.release()
|
||||
await fixture?.cleanup()
|
||||
bgRelease.cleanup()
|
||||
})
|
||||
|
||||
test('session visible in a split tile does NOT get unread dot when it finishes', async () => {
|
||||
const page = fixture.page
|
||||
|
||||
await startTurnAndSwitchAway(page)
|
||||
|
||||
// Evidence: session A is in the background (bg dot in sidebar).
|
||||
await page.screenshot({ path: 'test-results/tile-bug-split-switched-away.png' })
|
||||
|
||||
// Drag the session row from the sidebar to the right edge of the workspace
|
||||
// zone to create a SPLIT (side-by-side) tile. This triggers the real
|
||||
// startSessionDrag → onCommit → openSessionTile(id, 'right', anchor) path.
|
||||
// With auto-title disabled the sidebar row is titled by the user's message.
|
||||
const row = sessionRow(page, 'E2E_SIDEBAR_CROSS')
|
||||
const rowBox = await row.boundingBox()
|
||||
expect(rowBox, 'session row must be visible').not.toBeNull()
|
||||
|
||||
// Find the workspace zone — the main chat area. We drop on its right edge.
|
||||
const workspace = page.locator('[data-session-anchor="workspace"]')
|
||||
const wsBox = await workspace.boundingBox()
|
||||
expect(wsBox, 'workspace zone must be visible').not.toBeNull()
|
||||
|
||||
// Drag from the session row to the right edge of the workspace.
|
||||
// The drag-session's subZonePosition resolves a right-edge drop as 'right'
|
||||
// (a split dock), not 'center' (which would be a composer link).
|
||||
await page.mouse.move(rowBox!.x + rowBox!.width / 2, rowBox!.y + rowBox!.height / 2)
|
||||
await page.mouse.down()
|
||||
// Move in steps so the drag-session's pointermove handler tracks the
|
||||
// position and resolves the drop zone (a single jump can miss the
|
||||
// threshold/engage logic).
|
||||
const targetX = wsBox!.x + wsBox!.width - 20
|
||||
const targetY = wsBox!.y + wsBox!.height / 2
|
||||
const steps = 10
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const x = rowBox!.x + rowBox!.width / 2 + (targetX - (rowBox!.x + rowBox!.width / 2)) * (i / steps)
|
||||
const y = rowBox!.y + rowBox!.height / 2 + (targetY - (rowBox!.y + rowBox!.height / 2)) * (i / steps)
|
||||
await page.mouse.move(x, y)
|
||||
await page.waitForTimeout(30)
|
||||
}
|
||||
await page.mouse.up()
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Evidence: the split tile is now open side-by-side — both sessions visible.
|
||||
await page.screenshot({ path: 'test-results/tile-bug-split-opened.png' })
|
||||
|
||||
await waitForBgProcessToFinish(page, bgRelease)
|
||||
|
||||
// THE BUG: the session visible in the split tile should NOT have the green
|
||||
// "finished unread" dot — the user is looking right at it. This assertion
|
||||
// FAILS until the fix in session-states.ts:174 lands.
|
||||
const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count()
|
||||
expect(unreadCount, 'session visible in a split tile should NOT be marked unread').toBe(0)
|
||||
|
||||
// Evidence: the green dot should NOT be here — this screenshot shows the bug.
|
||||
await page.screenshot({ path: 'test-results/tile-bug-split-unread-should-not-exist.png' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* E2E test for PERSISTED unread state — the green "Finished — unread" dot
|
||||
* must survive an app restart.
|
||||
*
|
||||
* Regression coverage for the reset-on-restart bug: the unread flag used to
|
||||
* live only in the in-memory `$unreadFinishedSessionIds` atom, so closing and
|
||||
* reopening the desktop app grayed out every green dot. The persisted layer
|
||||
* (src/store/session-unread.ts) now rebuilds the dot from localStorage-backed
|
||||
* finish markers + seen-count watermarks.
|
||||
*
|
||||
* The scenario uses TWO sessions on purpose: with a single session the app
|
||||
* can reopen straight into it after a restart, which acks the session (the
|
||||
* user is looking at it) and would mask the dot. Session A finishes in the
|
||||
* background while session B is the selected one; the restart reopens into
|
||||
* B, so A's dot is observable.
|
||||
*
|
||||
* 1. Session A: start a turn, hold its stream open.
|
||||
* 2. Session B: new session, send a message, let it finish while SELECTED.
|
||||
* 3. Release A's stream → its background finish paints A's green dot.
|
||||
* 4. QUIT the app, relaunch on the SAME sandbox → A's dot must still be
|
||||
* there (previously it was lost).
|
||||
* 5. Open session A → dot clears.
|
||||
* 6. Restart once more → the cleared state must persist too (no zombie dot).
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, type Page, test } from '@playwright/test'
|
||||
import { type ElectronApplication } from '@playwright/test'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
launchDesktop,
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { restartMockServer } from './mock-server'
|
||||
|
||||
/** Finished-unread dot aria-label (from i18n en.ts). */
|
||||
const UNREAD_DOT_LABEL = 'Finished — unread'
|
||||
|
||||
/** Held prompt — the mock pauses this stream until we release it, so the
|
||||
* turn is deterministically still running when we switch away. */
|
||||
const HELD_PROMPT = 'E2E unread restart: hold this stream until released.'
|
||||
/** Second session's prompt — completes normally while selected. */
|
||||
const SECOND_PROMPT = 'E2E unread restart: second session, read while open.'
|
||||
|
||||
const unreadDots = (page: Page) => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`)
|
||||
|
||||
async function sendMessage(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
test.describe('unread dot survives app restart', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
// Three full app boots in one scenario — give it more than the global 90s.
|
||||
test.setTimeout(300_000)
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
let app: ElectronApplication
|
||||
let page: Page
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend({
|
||||
mockServer: { holdFirstStreamForPrompt: HELD_PROMPT },
|
||||
})
|
||||
app = fixture.app
|
||||
page = fixture.page
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
// The fixture's own app handle may already be closed by the restart
|
||||
// steps — close whatever is current, then drop the sandbox + mock.
|
||||
try {
|
||||
await app?.close()
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
|
||||
fixture?.mock.close()
|
||||
fixture?.sandbox.cleanup()
|
||||
})
|
||||
|
||||
/** Relaunch the desktop app against the SAME sandbox (same userData →
|
||||
* same localStorage, same HERMES_HOME → same session store). */
|
||||
async function restartApp(): Promise<void> {
|
||||
await app.close()
|
||||
|
||||
const relaunched = await launchDesktop(buildAppEnv(fixture.sandbox))
|
||||
app = relaunched.app
|
||||
page = relaunched.page
|
||||
await waitForAppReady({ ...fixture, app, page }, 120_000)
|
||||
}
|
||||
|
||||
test('green dot persists across restart and its clear persists too', async () => {
|
||||
// ── 1. Session A: start a turn whose stream the mock holds open ────
|
||||
await sendMessage(page, HELD_PROMPT)
|
||||
await fixture.mock.waitForHeldStream()
|
||||
|
||||
// ── 2. Session B: complete a turn while SELECTED (stays read) ──────
|
||||
await page.locator('button:has-text("New session")').first().click()
|
||||
await sendMessage(page, SECOND_PROMPT)
|
||||
|
||||
// B's reply lands in the open transcript — this session is "read".
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('Hello from the mock inference server'),
|
||||
undefined,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
|
||||
// ── 3. Release A's stream → background finish paints A's dot ──────
|
||||
fixture.mock.releaseHeldStream()
|
||||
|
||||
await expect
|
||||
.poll(() => unreadDots(page).count(), {
|
||||
timeout: 30_000,
|
||||
message: 'green unread dot should appear after the background finish',
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// ── 4. Restart the app — the dot must survive ──────────────────────
|
||||
// The app reopens into session B (or a fresh draft), NOT session A, so
|
||||
// A's dot is observable rather than being acked by the route restore.
|
||||
await restartApp()
|
||||
|
||||
await expect
|
||||
.poll(() => unreadDots(page).count(), {
|
||||
timeout: 60_000,
|
||||
message: 'green unread dot should be rebuilt from persisted state after restart',
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// ── 5. Open session A — the dot clears ─────────────────────────────
|
||||
// The dot sits inside A's sidebar row button; click that row.
|
||||
await unreadDots(page)
|
||||
.first()
|
||||
.locator('xpath=ancestor::button[1]')
|
||||
.click()
|
||||
|
||||
await expect
|
||||
.poll(() => unreadDots(page).count(), {
|
||||
timeout: 15_000,
|
||||
message: 'opening the session should clear its unread dot',
|
||||
})
|
||||
.toBe(0)
|
||||
|
||||
// ── 6. Restart again — the CLEARED state must persist as well ──────
|
||||
await restartApp()
|
||||
|
||||
// Give the sidebar a moment to load rows, then assert no dot returns.
|
||||
await page.waitForSelector('[data-slot="sidebar"]', { timeout: 60_000 })
|
||||
await page.waitForTimeout(5_000)
|
||||
expect(await unreadDots(page).count(), 'acked session must stay read after restart').toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Visual snapshot helper — wraps `toHaveScreenshot` so visual diffs are
|
||||
* reported without failing the test suite.
|
||||
*
|
||||
* On CI, the JSON reporter + post-test script parse the results and post a
|
||||
* summary to the GitHub Actions step output, and diff images are uploaded
|
||||
* as artifacts. This keeps visual regressions visible without gating PRs
|
||||
* on pixel-perfect matches.
|
||||
*
|
||||
* The actual screenshot is always written to the test output dir so CI
|
||||
* artifacts include every screenshot — not just the ones that diffed.
|
||||
* When it differs, this helper also writes expected and diff images:
|
||||
* <name>-actual.png, <name>-expected.png, <name>-diff.png
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { type ElectronApplication, type Page, test } from '@playwright/test'
|
||||
|
||||
/** Fixed window dimensions for visual regression screenshots. */
|
||||
export const VISUAL_WINDOW_WIDTH = 1220
|
||||
export const VISUAL_WINDOW_HEIGHT = 800
|
||||
|
||||
export interface VisualSnapshotOptions {
|
||||
/** Snapshot name — defaults to the test title. */
|
||||
name?: string
|
||||
/** Full page screenshot vs. viewport-only (default). */
|
||||
fullPage?: boolean
|
||||
/** Timeout in ms. */
|
||||
timeout?: number
|
||||
/** The Electron app handle — used to size and decode screenshots. */
|
||||
app: ElectronApplication
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the Electron window to a fixed size so screenshots are comparable
|
||||
* across runs and CI environments. Window managers (Hyprland, etc.) may
|
||||
* auto-tile or resize windows after launch; calling this right before the
|
||||
* screenshot ensures the viewport is always the expected size.
|
||||
*/
|
||||
async function forceFixedSize(app: ElectronApplication): Promise<void> {
|
||||
await app.evaluate(({ BrowserWindow }, { width, height }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0]
|
||||
|
||||
if (win) {
|
||||
win.unmaximize()
|
||||
// setMinimumSize must be ≤ the target, otherwise setSize is clamped.
|
||||
win.setMinimumSize(width, height)
|
||||
win.setSize(width, height, false)
|
||||
win.setBounds({ x: 0, y: 0, width, height })
|
||||
}
|
||||
}, { width: VISUAL_WINDOW_WIDTH, height: VISUAL_WINDOW_HEIGHT })
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot and compare it against the baseline.
|
||||
*
|
||||
* If the baseline doesn't exist yet (first run), Playwright creates it.
|
||||
* If it differs, the test logs a soft warning but does NOT fail — the diff
|
||||
* images are still generated for CI to surface.
|
||||
*/
|
||||
export async function expectVisualSnapshot(
|
||||
page: Page,
|
||||
options: VisualSnapshotOptions,
|
||||
): Promise<void> {
|
||||
const { name, fullPage = false, timeout = 30_000, app } = options
|
||||
|
||||
// Force the window to a fixed size right before the screenshot so it's
|
||||
// always comparable, regardless of WM resizing during the test.
|
||||
await forceFixedSize(app)
|
||||
// Give the renderer a moment to relayout after the resize.
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Playwright appends a platform suffix (e.g. "-linux") and requires
|
||||
// a .png extension on the name argument. Auto-append it if missing.
|
||||
const snapshotName = name ? (name.endsWith('.png') ? name : `${name}.png`) : undefined
|
||||
|
||||
const info = test.info()
|
||||
const actual = await page.screenshot({ animations: 'disabled', caret: 'hide', fullPage, timeout })
|
||||
const baselinePath = info.snapshotPath(snapshotName ?? `${info.title}.png`)
|
||||
const outputName = (snapshotName ?? 'snapshot.png').replace(/\.png$/, '')
|
||||
|
||||
if (info.config.updateSnapshots === 'all' || info.config.updateSnapshots === 'changed') {
|
||||
fs.mkdirSync(path.dirname(baselinePath), { recursive: true })
|
||||
fs.writeFileSync(baselinePath, actual)
|
||||
// Also write to the output dir so CI artifacts include the screenshot.
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
|
||||
console.log(`[visual-baseline] updated ${baselinePath}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(baselinePath)) {
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
|
||||
console.log(`[visual-diff] ${name ?? '(unnamed)'} — no baseline available`)
|
||||
return
|
||||
}
|
||||
|
||||
const expected = fs.readFileSync(baselinePath)
|
||||
const comparison = await app.evaluate(
|
||||
({ nativeImage }, images) => {
|
||||
const actualImage = nativeImage.createFromBuffer(Buffer.from(images.actual, 'base64'))
|
||||
const expectedImage = nativeImage.createFromBuffer(Buffer.from(images.expected, 'base64'))
|
||||
const actualSize = actualImage.getSize()
|
||||
const expectedSize = expectedImage.getSize()
|
||||
|
||||
if (actualSize.width !== expectedSize.width || actualSize.height !== expectedSize.height) {
|
||||
return { mismatchRatio: 1, diff: images.actual }
|
||||
}
|
||||
|
||||
const actualPixels = actualImage.toBitmap()
|
||||
const expectedPixels = expectedImage.toBitmap()
|
||||
const diffPixels = Buffer.alloc(actualPixels.length)
|
||||
let mismatched = 0
|
||||
|
||||
for (let i = 0; i < actualPixels.length; i += 4) {
|
||||
const different =
|
||||
Math.abs(actualPixels[i] - expectedPixels[i]) > 51 ||
|
||||
Math.abs(actualPixels[i + 1] - expectedPixels[i + 1]) > 51 ||
|
||||
Math.abs(actualPixels[i + 2] - expectedPixels[i + 2]) > 51 ||
|
||||
Math.abs(actualPixels[i + 3] - expectedPixels[i + 3]) > 51
|
||||
|
||||
if (different) {
|
||||
mismatched++
|
||||
diffPixels[i + 2] = 255
|
||||
}
|
||||
diffPixels[i + 3] = 255
|
||||
}
|
||||
|
||||
return {
|
||||
mismatchRatio: mismatched / (actualPixels.length / 4),
|
||||
diff: nativeImage.createFromBitmap(diffPixels, actualSize).toPNG().toString('base64'),
|
||||
}
|
||||
},
|
||||
{ actual: actual.toString('base64'), expected: expected.toString('base64') },
|
||||
)
|
||||
|
||||
// Always write the actual screenshot to the output dir so CI artifacts
|
||||
// include every screenshot — not just the ones that diffed.
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
|
||||
|
||||
if (comparison.mismatchRatio <= 0.01) {
|
||||
return
|
||||
}
|
||||
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-expected.png`), expected)
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-diff.png`), Buffer.from(comparison.diff, 'base64'))
|
||||
console.log(
|
||||
`[visual-diff] ${name ?? '(unnamed)'} — ${(comparison.mismatchRatio * 100).toFixed(2)}% of pixels differ`,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* E2E regression: warm-route resume must not re-render the transcript more
|
||||
* than once.
|
||||
*
|
||||
* When a session is already in the runtime-id cache (the "warm" path in
|
||||
* `resumeSession()`), clicking its sidebar row should paint the transcript
|
||||
* exactly once. Before the fix, the warm cache painted via
|
||||
* `syncSessionStateToView`, then the `session.activate` RPC returned a
|
||||
* reconciled message list with different message object references, causing
|
||||
* `syncSessionStateToView` to fire a second `setMessages` — a visual
|
||||
* flicker as the transcript DOM was updated.
|
||||
*
|
||||
* This test pre-seeds a session into state.db, boots the app,
|
||||
* clicks the session (cold resume — populates the warm cache), navigates
|
||||
* away to a new chat, then clicks back (warm resume). Two detectors run:
|
||||
*
|
||||
* 1. A MutationObserver counts additive DOM mutation bursts (childList
|
||||
* additions). More than 1 burst = the transcript was repainted.
|
||||
*
|
||||
* 2. A 2ms innerHTML-length poll counts "reconciles" — DOM content changes
|
||||
* that happen AFTER the initial paint, while messages are already on
|
||||
* screen. This catches the case where React reconciles by key without
|
||||
* adding/removing nodes (same keys → in-place prop update → no
|
||||
* MutationObserver burst), but `$messages` was still set twice.
|
||||
*
|
||||
* The test passes when bursts === 1 AND reconciles === 0.
|
||||
* The sidebar "+" keeps the session warm in another tab. Its reactivation
|
||||
* follows the same contract: one additive paint and zero reconciles.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from './test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
waitForAppReady,
|
||||
createSandbox,
|
||||
writeMockProviderConfig,
|
||||
writeEnvFile,
|
||||
buildAppEnv,
|
||||
launchDesktop,
|
||||
} from './fixtures'
|
||||
import { startMockServer } from './mock-server'
|
||||
import { RealSessionBuilder } from './real-session-builder'
|
||||
|
||||
const SESSION_TITLE = 'E2E Warm Resume Jitter Test'
|
||||
|
||||
// Inactive tabs stay mounted under a data-pane-hidden ancestor. Match the
|
||||
// renderer's keep-alive visibility policy instead of relying on DOM order.
|
||||
const SURFACE = '[data-composer-target]:not([data-pane-hidden] [data-composer-target])'
|
||||
const ALL_SURFACES = '[data-composer-target]'
|
||||
/**
|
||||
* 16 messages (8 user/assistant pairs) — enough DOM churn for detection while
|
||||
* still fitting a hot-hidden pane's retention budget. A kept-alive pane keeps
|
||||
* only its live tail (HIDDEN_TRANSCRIPT_RENDER_BUDGET = 40 weight units in
|
||||
* thread/list.tsx); 16 short messages ≈ 32 units, so the whole transcript
|
||||
* survives hiding. Above the budget, reveal legitimately backfills trimmed
|
||||
* turns (additive DOM bursts) — that is paging, not the repaint bug this
|
||||
* suite hunts, and it would drown the detectors.
|
||||
*/
|
||||
const MESSAGE_COUNT = 16
|
||||
/** Seeded PRNG so the generated content is deterministic across runs. */
|
||||
const RNG_SEED = 42
|
||||
|
||||
/** Mulberry32 — tiny deterministic PRNG. */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed
|
||||
return () => {
|
||||
a |= 0
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate ~40 chars of gibberish from a seeded PRNG. */
|
||||
function gibberish(rng: () => number): string {
|
||||
const len = 30 + Math.floor(rng() * 20)
|
||||
let s = ''
|
||||
for (let i = 0; i < len; i++) {
|
||||
s += String.fromCharCode(97 + Math.floor(rng() * 26))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
/** First user message — used as a wait target in the test. */
|
||||
const FIRST_USER_MSG = gibberish(mulberry32(RNG_SEED))
|
||||
|
||||
/**
|
||||
* Generate the user turns for a real session. The mock provider produces the
|
||||
* assistant side of each pair through the normal AIAgent persistence path.
|
||||
*/
|
||||
function generateSessionTurns(): string[] {
|
||||
const rng = mulberry32(RNG_SEED)
|
||||
const turns: string[] = []
|
||||
|
||||
for (let i = 0; i < MESSAGE_COUNT / 2; i++) {
|
||||
turns.push(gibberish(rng))
|
||||
gibberish(rng)
|
||||
}
|
||||
|
||||
return turns
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock-backend sandbox with a real persisted session in state.db.
|
||||
*
|
||||
* Unlike the shared `setupMockBackend()`, this variant creates the session
|
||||
* through the real stdio gateway before launching desktop so the session is
|
||||
* visible in the sidebar on first load.
|
||||
*/
|
||||
async function setupSeededMockBackend(): Promise<MockBackendFixture> {
|
||||
// 1. Start mock server
|
||||
const mock = await startMockServer()
|
||||
|
||||
// 2. Create sandbox + write config
|
||||
const sandbox = createSandbox('warm-seed')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
// 3. Produce all 16 user/assistant pairs through the real TUI gateway,
|
||||
// AIAgent, mock provider, and SessionDB persistence path before desktop starts.
|
||||
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
|
||||
try {
|
||||
await builder.createSession({ title: SESSION_TITLE, turns: generateSessionTurns() })
|
||||
} finally {
|
||||
await builder.close()
|
||||
}
|
||||
|
||||
// 4. Build env + launch
|
||||
const env = buildAppEnv(sandbox)
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupSeededMockBackend()
|
||||
await waitForAppReady(fixture!, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
/**
|
||||
* Install a MutationObserver + text-content poll on the thread viewport
|
||||
* to detect re-renders after the initial paint. Returns nothing — call
|
||||
* `readRenderCount` to stop and collect results.
|
||||
*
|
||||
* - MutationObserver: counts additive childList bursts (5ms coalescing).
|
||||
* - Text-content poll: counts "reconciles" — first-message text changes
|
||||
* after the initial paint, catching key-based reconciles that don't
|
||||
* add/remove nodes.
|
||||
*/
|
||||
async function installRenderCounter(
|
||||
page: import('@playwright/test').Page,
|
||||
transcriptText?: string,
|
||||
): Promise<void> {
|
||||
await page.evaluate(([visibleSelector, allSelector, expected]: [string, string, string | undefined]) => {
|
||||
const surfaces = [...document.querySelectorAll(expected ? allSelector : visibleSelector)]
|
||||
const surface = expected
|
||||
? surfaces.find(candidate =>
|
||||
(candidate.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
)
|
||||
: surfaces.at(-1)
|
||||
const viewport = surface?.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) {
|
||||
const diag = [...document.querySelectorAll(allSelector)].map(s => ({
|
||||
hidden: Boolean(s.closest('[data-pane-hidden]')),
|
||||
target: s.getAttribute('data-composer-target'),
|
||||
hasViewport: Boolean(s.querySelector('[data-slot="aui_thread-viewport"]')),
|
||||
textLen: (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').length,
|
||||
head: (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').slice(0, 80),
|
||||
tail: (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').slice(-80),
|
||||
includesExpected: expected ? (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected) : null,
|
||||
}))
|
||||
throw new Error('Thread viewport not found before warm resume DIAG=' + JSON.stringify(diag) + ' expected=' + expected)
|
||||
}
|
||||
|
||||
const state = { bursts: 0, mutations: 0, timeline: [] as number[], stopped: false, reconciles: 0 }
|
||||
const debugWindow = window as unknown as {
|
||||
__RENDER_COUNT__: typeof state
|
||||
__RENDER_VIEWPORT__: Element
|
||||
}
|
||||
debugWindow.__RENDER_COUNT__ = state
|
||||
debugWindow.__RENDER_VIEWPORT__ = viewport
|
||||
|
||||
let currentBatch = 0
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const flush = () => {
|
||||
flushTimer = null
|
||||
if (currentBatch > 0 && !state.stopped) {
|
||||
state.bursts += 1
|
||||
state.timeline.push(currentBatch)
|
||||
currentBatch = 0
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(records => {
|
||||
if (state.stopped) return
|
||||
let batchAdded = 0
|
||||
for (const record of records) {
|
||||
state.mutations += 1
|
||||
if (record.type === 'childList' && record.addedNodes.length > 0) {
|
||||
batchAdded += 1
|
||||
}
|
||||
}
|
||||
if (batchAdded > 0) {
|
||||
currentBatch += batchAdded
|
||||
if (flushTimer) clearTimeout(flushTimer)
|
||||
flushTimer = setTimeout(flush, 5)
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(viewport, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: false,
|
||||
characterData: false,
|
||||
})
|
||||
|
||||
// Poll the first message's text content every 2ms. The MutationObserver
|
||||
// only catches childList additions; React may reconcile by key without
|
||||
// adding/removing nodes (same keys → in-place prop update → no childList
|
||||
// mutation). The poll catches this by detecting text content changes in
|
||||
// the first message after the initial paint. Metadata-only changes (model
|
||||
// name, busy indicator) don't affect message text, so they don't produce
|
||||
// false positives.
|
||||
const contentEl = viewport.querySelector('[data-slot="aui_thread-content"]') ?? viewport
|
||||
let lastFirstMsgText = ''
|
||||
let hasMessages = false
|
||||
const pollInterval = setInterval(() => {
|
||||
if (state.stopped) {
|
||||
clearInterval(pollInterval)
|
||||
return
|
||||
}
|
||||
const firstMsg = contentEl.querySelector('[data-role="message"], [data-message-id]')
|
||||
const firstMsgText = firstMsg?.textContent ?? ''
|
||||
if (firstMsgText && firstMsgText !== lastFirstMsgText) {
|
||||
if (hasMessages) {
|
||||
state.reconciles = (state.reconciles ?? 0) + 1
|
||||
}
|
||||
lastFirstMsgText = firstMsgText
|
||||
hasMessages = true
|
||||
}
|
||||
}, 2)
|
||||
}, [SURFACE, ALL_SURFACES, transcriptText] as [string, string, string | undefined])
|
||||
}
|
||||
|
||||
/** Wait until the ACTIVE chat surface's transcript contains `text`. */
|
||||
async function waitForActiveTranscriptText(
|
||||
page: import('@playwright/test').Page,
|
||||
text: string,
|
||||
timeout = 30_000,
|
||||
): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
([expected, surfaceSelector]: [string, string]) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const active = surfaces[surfaces.length - 1]
|
||||
|
||||
return (active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected)
|
||||
},
|
||||
[text, SURFACE] as [string, string],
|
||||
{ timeout },
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForActiveTranscriptWithoutText(
|
||||
page: import('@playwright/test').Page,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
([expected, surfaceSelector]: [string, string]) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const active = surfaces[surfaces.length - 1]
|
||||
|
||||
return !(active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected)
|
||||
},
|
||||
[text, SURFACE] as [string, string],
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
/** Replace the primary surface with a draft while retaining its warm cache. */
|
||||
async function openFreshDraft(page: import('@playwright/test').Page, priorText: string): Promise<void> {
|
||||
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+N' : 'Control+N')
|
||||
await waitForActiveTranscriptWithoutText(page, priorText)
|
||||
}
|
||||
|
||||
/** Stack an empty tab while leaving the current transcript mounted and warm. */
|
||||
async function openNewSessionTab(page: import('@playwright/test').Page, priorText: string): Promise<void> {
|
||||
await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
|
||||
await waitForActiveTranscriptWithoutText(page, priorText)
|
||||
}
|
||||
|
||||
/** Stop the render counter and return the recorded burst/reconcile counts. */
|
||||
async function readRenderCount(page: import('@playwright/test').Page): Promise<{
|
||||
bursts: number
|
||||
mutations: number
|
||||
timeline: number[]
|
||||
reconciles: number
|
||||
} | null> {
|
||||
return page.evaluate(() => {
|
||||
type RenderCount = { bursts: number; mutations: number; timeline: number[]; stopped: boolean; reconciles: number }
|
||||
const w = window as unknown as { __RENDER_COUNT__?: RenderCount }
|
||||
const rc = w.__RENDER_COUNT__
|
||||
if (rc) {
|
||||
rc.stopped = true
|
||||
}
|
||||
return rc ? { bursts: rc.bursts, mutations: rc.mutations, timeline: rc.timeline, reconciles: rc.reconciles } : null
|
||||
})
|
||||
}
|
||||
|
||||
async function observedViewportIsActive(page: import('@playwright/test').Page): Promise<boolean> {
|
||||
return page.evaluate((surfaceSelector: string) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const activeViewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
const observedViewport = (window as unknown as { __RENDER_VIEWPORT__?: Element }).__RENDER_VIEWPORT__
|
||||
|
||||
return activeViewport === observedViewport
|
||||
}, SURFACE)
|
||||
}
|
||||
|
||||
/** A kept-alive tab must become visible without rebuilding its transcript. */
|
||||
function assertNoRepaint(result: { bursts: number; mutations: number; timeline: number[]; reconciles: number } | null): void {
|
||||
expect(result, 'MutationObserver should have recorded render data').toBeTruthy()
|
||||
expect(
|
||||
result!.bursts,
|
||||
`Expected no additive render bursts for a kept-alive tab, but got ${result!.bursts}. ` +
|
||||
`Mutation timeline: ${JSON.stringify(result!.timeline)}.`,
|
||||
).toBe(0)
|
||||
expect(
|
||||
result!.reconciles,
|
||||
`Expected no transcript reconciles for a kept-alive tab, but got ${result!.reconciles}.`,
|
||||
).toBe(0)
|
||||
}
|
||||
|
||||
/** Assert the render counter shows exactly one paint with no re-renders. */
|
||||
function assertNoJitter(result: { bursts: number; mutations: number; timeline: number[]; reconciles: number } | null): void {
|
||||
expect(result, 'MutationObserver should have recorded render data').toBeTruthy()
|
||||
expect(
|
||||
result!.bursts,
|
||||
`Expected 1 additive render burst (single paint), but got ${result!.bursts} bursts. ` +
|
||||
`Mutation timeline: ${JSON.stringify(result!.timeline)}.`,
|
||||
).toBe(1)
|
||||
expect(
|
||||
result!.reconciles,
|
||||
`Expected 0 reconciles (no re-render after initial paint), but got ${result!.reconciles}. ` +
|
||||
`This means the warm-route resume re-rendered the transcript after the initial paint ` +
|
||||
`— the "warm resume jitter" bug is present.`,
|
||||
).toBe(0)
|
||||
}
|
||||
|
||||
test('tab reactivation preserves the mounted transcript without repainting', async ({}, testInfo) => {
|
||||
const page = fixture!.page
|
||||
|
||||
// Wait for the sidebar to populate with our seeded session.
|
||||
const sessionRow = page
|
||||
.locator('[data-slot="sidebar"] button')
|
||||
.filter({ hasText: SESSION_TITLE })
|
||||
.first()
|
||||
await sessionRow.waitFor({ state: 'visible', timeout: 60_000 })
|
||||
|
||||
// Step 1: Cold resume — click the session row to load it.
|
||||
// This populates the warm cache (runtimeIdByStoredSessionId + sessionStateByRuntimeId).
|
||||
await sessionRow.click()
|
||||
|
||||
// Wait for the transcript to appear — the first user message text confirms
|
||||
// the cold-path prefetch painted.
|
||||
await waitForActiveTranscriptText(page, FIRST_USER_MSG)
|
||||
|
||||
// Wait for the session to fully settle (cold-path RPC + reconciliation).
|
||||
await page.waitForTimeout(2_000)
|
||||
|
||||
// Stack a new tab, then observe the seeded transcript while it is hidden.
|
||||
// Installing after the switch isolates reactivation from mutations caused
|
||||
// while the new tab was being created.
|
||||
await openNewSessionTab(page, FIRST_USER_MSG)
|
||||
await page.waitForTimeout(500)
|
||||
await installRenderCounter(page, FIRST_USER_MSG)
|
||||
|
||||
// Step 3: Click back and verify the same kept-alive viewport becomes active
|
||||
// without rebuilding or reconciling its transcript.
|
||||
await sessionRow.click()
|
||||
|
||||
await waitForActiveTranscriptText(page, FIRST_USER_MSG)
|
||||
await page.waitForTimeout(2_000)
|
||||
expect(await observedViewportIsActive(page), 'Reactivation should reveal the observed kept-alive viewport').toBe(true)
|
||||
|
||||
const result = await readRenderCount(page)
|
||||
await page.screenshot({ path: testInfo.outputPath('warm-resume-idle.png') })
|
||||
assertNoRepaint(result)
|
||||
})
|
||||
|
||||
test('warm-route resume after background inference completes (no jitter)', async ({}, testInfo) => {
|
||||
test.fixme(
|
||||
true,
|
||||
'Warm resume repaints after inference: expected one additive burst, got two ([18,1]).',
|
||||
)
|
||||
|
||||
const page = fixture!.page
|
||||
const { mock } = fixture!
|
||||
|
||||
// Wait for the sidebar to populate with our seeded session.
|
||||
const sessionRow = page
|
||||
.locator('[data-slot="sidebar"] button')
|
||||
.filter({ hasText: SESSION_TITLE })
|
||||
.first()
|
||||
await sessionRow.waitFor({ state: 'visible', timeout: 60_000 })
|
||||
|
||||
// Step 1: Cold resume — populate the warm cache.
|
||||
await sessionRow.click()
|
||||
await waitForActiveTranscriptText(page, FIRST_USER_MSG)
|
||||
await page.waitForTimeout(2_000)
|
||||
|
||||
// Step 2: Send a message — triggers inference via the mock server.
|
||||
const PROMPT = 'E2E post-inference warm resume test prompt'
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type(PROMPT, { delay: 10 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Wait for the mock response to appear in the transcript, confirming
|
||||
// the turn completed and message.complete fired (which updates the warm
|
||||
// cache via updateSessionState).
|
||||
await waitForActiveTranscriptText(page, 'mock inference server', 60_000)
|
||||
// Extra settle for message.complete → updateSessionState → cache write.
|
||||
await page.waitForTimeout(2_000)
|
||||
|
||||
// Verify the prompt was received by the mock server.
|
||||
expect(mock.receivedPrompts).toContain(PROMPT)
|
||||
|
||||
// Step 3: Replace the primary chat; the warm cache retains the updated messages.
|
||||
await openFreshDraft(page, PROMPT)
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Step 4: Install render counter, click back (warm resume), wait, assert.
|
||||
await installRenderCounter(page)
|
||||
await sessionRow.click()
|
||||
|
||||
// Wait for the transcript to reappear — the warm cache should already
|
||||
// have the completed turn (updated by message.complete events).
|
||||
await waitForActiveTranscriptText(page, FIRST_USER_MSG)
|
||||
|
||||
// Wait for at least 1 burst, then settle.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const w = window as unknown as { __RENDER_COUNT__?: { bursts: number } }
|
||||
return Boolean(w.__RENDER_COUNT__ && w.__RENDER_COUNT__.bursts > 0)
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
await page.waitForTimeout(2_000)
|
||||
|
||||
const result = await readRenderCount(page)
|
||||
await page.screenshot({ path: testInfo.outputPath('warm-resume-post-inference.png') })
|
||||
assertNoJitter(result)
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
type MockBackendFixture,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig,
|
||||
} from './fixtures'
|
||||
import { startMockServer } from './mock-server'
|
||||
import { expect, test } from './test'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
const BRANCH_NAME = 'e2e-composer-branch'
|
||||
|
||||
/**
|
||||
* Enough branches to make both the base-branch popover and the convert-branch
|
||||
* list taller than their default height. That is the condition in which the
|
||||
* dialog's own scroll box clips the popover, and that regression is what the
|
||||
* visual snapshots here guard against.
|
||||
*/
|
||||
const EXTRA_BRANCHES = [
|
||||
'feature/alpha-one',
|
||||
'feature/beta-two',
|
||||
'feature/gamma-three',
|
||||
'fix/delta-four',
|
||||
'fix/epsilon-five',
|
||||
'chore/zeta-six',
|
||||
'chore/eta-seven',
|
||||
'spike/theta-eight',
|
||||
'spike/iota-nine',
|
||||
'release/kappa-ten',
|
||||
]
|
||||
|
||||
function createGitRepo(root: string): string {
|
||||
const repo = path.join(root, 'repo')
|
||||
|
||||
fs.mkdirSync(repo, { recursive: true })
|
||||
execFileSync('git', ['init', '--initial-branch=main'], { cwd: repo })
|
||||
execFileSync('git', ['config', 'user.email', 'e2e@example.com'], { cwd: repo })
|
||||
execFileSync('git', ['config', 'user.name', 'Hermes E2E'], { cwd: repo })
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), '# E2E repo\n', 'utf8')
|
||||
execFileSync('git', ['add', 'README.md'], { cwd: repo })
|
||||
execFileSync('git', ['commit', '-m', 'initial'], { cwd: repo })
|
||||
|
||||
for (const branch of EXTRA_BRANCHES) {
|
||||
execFileSync('git', ['branch', branch], { cwd: repo })
|
||||
}
|
||||
|
||||
return repo
|
||||
}
|
||||
|
||||
function configureRepoCwd(hermesHome: string, mockUrl: string, repo: string): void {
|
||||
writeMockProviderConfig(hermesHome, mockUrl)
|
||||
fs.appendFileSync(path.join(hermesHome, 'config.yaml'), `\nterminal:\n cwd: ${repo}\n`, 'utf8')
|
||||
writeEnvFile(hermesHome)
|
||||
}
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
/** A dialog renders as `[data-slot="dialog-content"]` (components/ui/dialog.tsx). */
|
||||
const DIALOG = '[data-slot="dialog-content"]'
|
||||
|
||||
/** Open the worktree dialog with the global ⌘⇧B / ctrl+shift+B hotkey. */
|
||||
async function openWorktreeDialog(): Promise<void> {
|
||||
const page = fixture!.page
|
||||
await page.keyboard.press('Control+Shift+B')
|
||||
await expect(page.locator(DIALOG)).toBeVisible()
|
||||
}
|
||||
|
||||
/** Close the open dialog and wait until it leaves the DOM. */
|
||||
async function closeDialog(): Promise<void> {
|
||||
const page = fixture!.page
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator(DIALOG)).toHaveCount(0)
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const sandbox = createSandbox('worktree-branch-status')
|
||||
const repo = createGitRepo(sandbox.root)
|
||||
const mock = await startMockServer()
|
||||
|
||||
configureRepoCwd(sandbox.hermesHome, mock.url, repo)
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
fixture = {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
// The coding rail, and thus the ⌘⇧B worktree dialog, mounts only after the
|
||||
// session resolves a cwd that holds a repo. This happens on the first turn.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type('create a repo-backed e2e session', { delay: 2 })
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForFunction(
|
||||
prompt => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(prompt),
|
||||
'create a repo-backed e2e session',
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
await expect(page.locator('.coding-status-bar')).toContainText('main')
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('worktree dialog renders the base-branch picker over the dialog, not clipped by it', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
await openWorktreeDialog()
|
||||
|
||||
// Open the base-branch combobox. With 11 branches, the list is taller than
|
||||
// the space below the trigger. A popover that portals into the dialog's
|
||||
// `overflow-y-auto` box is therefore cut off. This snapshot catches that bug.
|
||||
await page.getByRole('button', { name: /branch off/i }).click()
|
||||
await expect(page.getByPlaceholder('Search branches…')).toBeVisible()
|
||||
await expect(page.getByRole('option', { name: 'feature/alpha-one' })).toBeVisible()
|
||||
|
||||
await expectVisualSnapshot(page, { name: 'worktree-dialog-base-branch-picker', app: fixture!.app })
|
||||
|
||||
// This check does not depend on pixels: the dialog's scroll box must not crop
|
||||
// the painted box of the popover. Measure the geometry, so a headless run
|
||||
// fails on this regression before a person looks at a diff image.
|
||||
const clipped = await page.evaluate(() => {
|
||||
const popover = document.querySelector('[data-slot="popover-content"]')
|
||||
const dialog = document.querySelector('[data-slot="dialog-content"]')
|
||||
|
||||
if (!popover || !dialog) {
|
||||
return { reason: 'missing', clipped: true }
|
||||
}
|
||||
|
||||
const p = popover.getBoundingClientRect()
|
||||
const d = dialog.getBoundingClientRect()
|
||||
const scrolls = window.getComputedStyle(dialog).overflowY
|
||||
|
||||
return {
|
||||
reason: 'measured',
|
||||
// Only a clipping ancestor can crop the popover. The popover is cut when
|
||||
// the dialog scrolls its overflow AND the popover goes past the box of
|
||||
// the dialog.
|
||||
clipped: (scrolls === 'auto' || scrolls === 'scroll' || scrolls === 'hidden') &&
|
||||
(p.bottom > d.bottom + 1 || p.top < d.top - 1 || p.right > d.right + 1 || p.left < d.left - 1),
|
||||
}
|
||||
})
|
||||
|
||||
expect(clipped.clipped, `base-branch popover is clipped by the dialog (${clipped.reason})`).toBe(false)
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await closeDialog()
|
||||
})
|
||||
|
||||
test('worktree dialog convert-an-existing-branch sub-view lists the repo branches', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
await openWorktreeDialog()
|
||||
await page.getByRole('button', { name: 'Convert an existing branch' }).click()
|
||||
|
||||
await expect(page.getByPlaceholder('Search branches…')).toBeVisible()
|
||||
await expect(page.getByRole('option', { name: /feature\/alpha-one/ })).toBeVisible()
|
||||
|
||||
await expectVisualSnapshot(page, { name: 'worktree-dialog-convert-branch', app: fixture!.app })
|
||||
|
||||
await closeDialog()
|
||||
})
|
||||
|
||||
test('creating a branch with ctrl-shift-b updates the composer git-status branch and leaves no dialog behind', async ({}, testInfo) => {
|
||||
const page = fixture!.page
|
||||
const codingRow = page.locator('.coding-status-bar')
|
||||
|
||||
await openWorktreeDialog()
|
||||
// Exactly one dialog instance. A second dialog here, hidden or empty, is the
|
||||
// symptom of the double-open bug.
|
||||
await expect(page.locator(DIALOG)).toHaveCount(1)
|
||||
|
||||
const branchInput = page.locator('input[placeholder="e.g. my-feature"]').first()
|
||||
await expect(branchInput).toBeVisible()
|
||||
await branchInput.fill(BRANCH_NAME)
|
||||
// Select a base branch, so this test uses the same path as the user: open the
|
||||
// picker, select a branch, then submit. It does not use the default value.
|
||||
// The keyboard drives this step. The dialog still clips the popover, so a
|
||||
// mouse click on an option is not reliable until that bug is corrected. The
|
||||
// double-open check below is therefore independent of the clipping bug.
|
||||
await page.getByRole('button', { name: /branch off/i }).click()
|
||||
await page.getByPlaceholder('Search branches…').fill('main')
|
||||
await expect(page.getByRole('option', { name: 'main' }).first()).toBeVisible()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0)
|
||||
|
||||
await page.getByRole('button', { name: 'New worktree' }).click()
|
||||
|
||||
await expect(codingRow).toContainText(BRANCH_NAME, { timeout: 15_000 })
|
||||
|
||||
// The dialog must close and stay closed. No empty second dialog can remain
|
||||
// after the new worktree session starts.
|
||||
await expect(page.locator(DIALOG)).toHaveCount(0)
|
||||
await page.waitForTimeout(2000)
|
||||
await expect(page.locator(DIALOG)).toHaveCount(0)
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('composer-branch-after-create.png') })
|
||||
})
|
||||
|
||||
test('ctrl-shift-b opens exactly one worktree dialog when a second composer is on screen', async ({}, testInfo) => {
|
||||
const page = fixture!.page
|
||||
|
||||
// ⌘T / ctrl+T stacks a second session tile. That gives a second live composer
|
||||
// and therefore a second coding rail. Each rail mounted its own
|
||||
// WorktreeDialog, and each rail subscribed to the same global token. One
|
||||
// keypress therefore opened two stacked dialogs, and the dialog the user
|
||||
// dismissed showed an identical empty one behind it. One mount in the sidebar
|
||||
// makes that impossible by structure.
|
||||
await page.keyboard.press('Control+T')
|
||||
await expect(page.locator('.coding-status-bar')).toHaveCount(2, { timeout: 20_000 })
|
||||
|
||||
await page.keyboard.press('Control+Shift+B')
|
||||
await expect(page.locator(DIALOG).first()).toBeVisible()
|
||||
// Wait: let the effect of every subscriber flush before the count.
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const count = await page.locator(DIALOG).count()
|
||||
await page.screenshot({ path: testInfo.outputPath('worktree-dialog-two-composers.png') })
|
||||
|
||||
expect(count, 'one hotkey press must open exactly one worktree dialog').toBe(1)
|
||||
|
||||
// A dismissal then leaves nothing behind.
|
||||
await closeDialog()
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* E2E regression: in-page route navigation must preserve the chosen UI scale.
|
||||
*
|
||||
* Desktop is a HashRouter over one file:// document, so every route is a
|
||||
* distinct URL to Chromium's per-URL zoom store, and a route with no record of
|
||||
* its own resolves to the host default (100%). In-page navigation fires no load
|
||||
* or window event, so nothing re-asserted the persisted level: switching
|
||||
* sessions dropped the window to 100% while Appearance kept reading the chosen
|
||||
* scale (#48658, #38854, #79863).
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { expect, test } from './test'
|
||||
|
||||
const SCALE = 110
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
async function readZoomPercent(): Promise<number> {
|
||||
return fixture!.page.evaluate(async () => {
|
||||
const desktop = window as unknown as {
|
||||
hermesDesktop: { zoom: { get: () => Promise<{ percent: number }> } }
|
||||
}
|
||||
|
||||
return (await desktop.hermesDesktop.zoom.get()).percent
|
||||
})
|
||||
}
|
||||
|
||||
async function setZoomPercent(percent: number): Promise<void> {
|
||||
await fixture!.page.evaluate(target => {
|
||||
const desktop = window as unknown as {
|
||||
hermesDesktop: { zoom: { setPercent: (percent: number) => void } }
|
||||
}
|
||||
|
||||
desktop.hermesDesktop.zoom.setPercent(target)
|
||||
}, percent)
|
||||
await expect.poll(readZoomPercent).toBe(percent)
|
||||
}
|
||||
|
||||
async function gotoRoute(route: string): Promise<void> {
|
||||
const page = fixture!.page
|
||||
|
||||
await page.evaluate(target => {
|
||||
window.location.hash = target
|
||||
}, route)
|
||||
await page.waitForFunction(target => window.location.hash === `#${target}`, route)
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('a non-default UI scale survives navigation to never-zoomed routes', async () => {
|
||||
await setZoomPercent(SCALE)
|
||||
|
||||
// Routes Chromium has no zoom record for — what opening a new session looks
|
||||
// like to the per-URL store. Pre-fix, the first hop reports 100%.
|
||||
const fresh = `/e2e-zoom-${Date.now()}`
|
||||
|
||||
for (const route of [`${fresh}-one`, `${fresh}-two`, '/settings?tab=config%3Aappearance']) {
|
||||
await gotoRoute(route)
|
||||
await expect.poll(readZoomPercent, { message: `UI scale after navigating to ${route}` }).toBe(SCALE)
|
||||
}
|
||||
})
|
||||
|
||||
test('Cmd/Ctrl+N preserves a non-default UI scale', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
await gotoRoute('/settings')
|
||||
await setZoomPercent(SCALE)
|
||||
|
||||
await page.evaluate(() => {
|
||||
;(document.activeElement as HTMLElement | null)?.blur()
|
||||
})
|
||||
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+N' : 'Control+N')
|
||||
await page.waitForFunction(() => window.location.hash === '#/')
|
||||
|
||||
await expect.poll(readZoomPercent).toBe(SCALE)
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { classifyActiveRuntime, hasValidBootstrapMarker } from './active-runtime-state'
|
||||
|
||||
const VALID_MARKER = {
|
||||
pinnedCommit: '1234567890abcdef1234567890abcdef12345678',
|
||||
schemaVersion: 1
|
||||
}
|
||||
|
||||
test('hasValidBootstrapMarker accepts the current schema with a real-looking commit', () => {
|
||||
assert.equal(hasValidBootstrapMarker(VALID_MARKER, 1), true)
|
||||
})
|
||||
|
||||
test('hasValidBootstrapMarker rejects missing, wrong-schema, and too-short markers', () => {
|
||||
assert.equal(hasValidBootstrapMarker(null, 1), false)
|
||||
assert.equal(hasValidBootstrapMarker({ schemaVersion: 2, pinnedCommit: VALID_MARKER.pinnedCommit }, 1), false)
|
||||
assert.equal(hasValidBootstrapMarker({ schemaVersion: 1, pinnedCommit: 'abc123' }, 1), false)
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime uses a healthy active runtime even when the bootstrap marker is missing', () => {
|
||||
assert.deepEqual(classifyActiveRuntime(null, 1, true), {
|
||||
hasValidMarker: false,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
})
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime uses a healthy active runtime even when the marker is stale or malformed', () => {
|
||||
assert.deepEqual(classifyActiveRuntime({ schemaVersion: 999, pinnedCommit: 'abc1234' }, 1, true), {
|
||||
hasValidMarker: false,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
})
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime refuses an unusable runtime even if a valid marker exists', () => {
|
||||
assert.deepEqual(classifyActiveRuntime(VALID_MARKER, 1, false), {
|
||||
hasValidMarker: true,
|
||||
shouldUseActiveRuntime: false,
|
||||
usabilityReason: 'unusable'
|
||||
})
|
||||
})
|
||||
|
||||
test('a CLI-installed runtime with no marker launches instead of re-running bootstrap', () => {
|
||||
// The reported symptom (#60721): install.sh / install.ps1 produced a healthy
|
||||
// repo+venv, no desktop-managed marker was ever written, and every launch
|
||||
// dropped the user back into the first-run installer.
|
||||
const state = classifyActiveRuntime(null, 1, true)
|
||||
|
||||
assert.equal(state.shouldUseActiveRuntime, true, 'a usable runtime must launch')
|
||||
assert.equal(state.hasValidMarker, false, 'marker provenance stays honest')
|
||||
})
|
||||
|
||||
test('a repair that deleted the marker does not strand a healthy install', () => {
|
||||
// #72166: the repair handler clears the marker unconditionally. Runtime
|
||||
// usability, not marker presence, must decide the next boot.
|
||||
assert.equal(classifyActiveRuntime(null, 1, true).shouldUseActiveRuntime, true)
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
export interface BootstrapMarkerLike {
|
||||
pinnedCommit?: unknown
|
||||
schemaVersion?: unknown
|
||||
}
|
||||
|
||||
export interface ActiveRuntimeState {
|
||||
hasValidMarker: boolean
|
||||
shouldUseActiveRuntime: boolean
|
||||
usabilityReason: 'usable' | 'unusable'
|
||||
}
|
||||
|
||||
export function hasValidBootstrapMarker(
|
||||
marker: BootstrapMarkerLike | null | undefined,
|
||||
schemaVersion: number
|
||||
): boolean {
|
||||
if (!marker || typeof marker !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (marker.schemaVersion !== schemaVersion) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// The active install at ~/.hermes/hermes-agent can be real and runnable even if
|
||||
// Desktop never wrote its first-run bootstrap marker (for example when Hermes
|
||||
// was installed by the CLI first, or when a past desktop build forgot the
|
||||
// marker). Runtime usability is authoritative for "can we launch local Hermes
|
||||
// right now?"; the marker is only provenance about how that install was
|
||||
// created. A missing/stale marker must never force a healthy local install into
|
||||
// the first-run bootstrap UI.
|
||||
export function classifyActiveRuntime(
|
||||
marker: BootstrapMarkerLike | null | undefined,
|
||||
schemaVersion: number,
|
||||
runtimeUsable: boolean
|
||||
): ActiveRuntimeState {
|
||||
const hasValidMarker = hasValidBootstrapMarker(marker, schemaVersion)
|
||||
|
||||
if (!runtimeUsable) {
|
||||
return {
|
||||
hasValidMarker,
|
||||
shouldUseActiveRuntime: false,
|
||||
usabilityReason: 'unusable'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasValidMarker,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveAiturkHome } from './aiturk-product'
|
||||
|
||||
describe('AITURK state isolation', () => {
|
||||
it('does not adopt the original Hermes account, even when HERMES_HOME is inherited', () => {
|
||||
expect(resolveAiturkHome({ platform: 'win32', home: 'C:\\Users\\tester',
|
||||
env: { LOCALAPPDATA: 'C:\\Users\\tester\\AppData\\Local', HERMES_HOME: 'D:\\private-hermes' }
|
||||
})).toBe('C:\\Users\\tester\\AppData\\Local\\TurkServis\\AITURK-IDE\\agent')
|
||||
})
|
||||
it('keeps a disposable app profile and its agent together', () => {
|
||||
expect(resolveAiturkHome({ platform: 'win32', home: 'C:\\Users\\tester', env: {}, userDataOverride: 'C:\\temp\\aiturk-test' }))
|
||||
.toBe('C:\\temp\\aiturk-test\\agent-home')
|
||||
})
|
||||
it('honors an explicit AITURK home and preserves platform path semantics', () => {
|
||||
expect(resolveAiturkHome({ platform: 'linux', home: '/home/tester', env: { AITURK_IDE_HOME: '/data/aiturk' } })).toBe('/data/aiturk')
|
||||
expect(resolveAiturkHome({ platform: 'linux', home: '/home/tester', env: { HERMES_HOME: '/data/hermes' } })).toBe('/home/tester/.aiturk-ide/agent')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export const AITURK_PRODUCT = Object.freeze({
|
||||
name: 'AITURK IDE',
|
||||
appId: 'online.turkservis.aiturk.hermes',
|
||||
protocol: 'aiturk-ide',
|
||||
website: 'https://turkservis.online',
|
||||
downloads: 'https://turkservis.online/ide',
|
||||
repository: 'https://gitea.twinpay.one/yilsem/aiturk-hermes-ide',
|
||||
apiBaseUrl: 'https://ai.turkservis.online/v1'
|
||||
})
|
||||
|
||||
/** Never inherit another product's HERMES_HOME or migrate its state implicitly. */
|
||||
export function resolveAiturkHome({ env, platform, home, userDataOverride }: {
|
||||
env: Record<string, string | undefined>
|
||||
platform: string
|
||||
home: string
|
||||
userDataOverride?: string
|
||||
}): string {
|
||||
const paths = platform === 'win32' ? path.win32 : path.posix
|
||||
if (env.AITURK_IDE_HOME) return paths.resolve(env.AITURK_IDE_HOME)
|
||||
if (userDataOverride) return paths.join(paths.resolve(userDataOverride), 'agent-home')
|
||||
if (platform === 'win32' && env.LOCALAPPDATA) {
|
||||
return paths.join(env.LOCALAPPDATA, 'TurkServis', 'AITURK-IDE', 'agent')
|
||||
}
|
||||
return paths.join(home, '.aiturk-ide', 'agent')
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* Unit + live-transport tests for the Electron main process's Hermes REST
|
||||
* retry policy (#92976 / PR #92977 salvage).
|
||||
*
|
||||
* The live tests run REAL node http servers that misbehave the way the
|
||||
* reported backend does (closing sockets under burst keep-alive traffic) and
|
||||
* prove two things end to end:
|
||||
*
|
||||
* - idempotent GETs that die with ECONNRESET are retried and succeed, where
|
||||
* a single bare attempt (pre-PR behavior) surfaces the raw reset;
|
||||
* - a POST whose socket is reset AFTER the server processed it is NOT
|
||||
* retried: the server-side hit counter stays at 1 and the error surfaces.
|
||||
*/
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
destroyKeepaliveAgents,
|
||||
downloadAgentFor,
|
||||
isIdempotentMethod,
|
||||
isTransientTransportError,
|
||||
jsonAgentFor,
|
||||
shouldRetryRequest,
|
||||
withRetry
|
||||
} from './api-transport'
|
||||
|
||||
function errWithCode(code: string, message = code): NodeJS.ErrnoException {
|
||||
const e: NodeJS.ErrnoException = new Error(message)
|
||||
e.code = code
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
destroyKeepaliveAgents()
|
||||
})
|
||||
|
||||
describe('isTransientTransportError', () => {
|
||||
it('accepts transient socket-level codes and messages', () => {
|
||||
for (const code of ['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN']) {
|
||||
expect(isTransientTransportError(errWithCode(code))).toBe(true)
|
||||
}
|
||||
|
||||
expect(isTransientTransportError(new Error('socket hang up'))).toBe(true)
|
||||
expect(isTransientTransportError(new Error('read ECONNRESET'))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-transport errors', () => {
|
||||
expect(isTransientTransportError(new Error('404: not found'))).toBe(false)
|
||||
expect(isTransientTransportError(new Error('Invalid JSON from http://x'))).toBe(false)
|
||||
expect(isTransientTransportError(null)).toBe(false)
|
||||
expect(isTransientTransportError(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isIdempotentMethod', () => {
|
||||
it.each([
|
||||
['GET', true],
|
||||
['get', true],
|
||||
['HEAD', true],
|
||||
['OPTIONS', true],
|
||||
['POST', false],
|
||||
['PUT', false],
|
||||
['PATCH', false],
|
||||
['DELETE', false],
|
||||
[undefined, true] // node http defaults omitted method to GET
|
||||
])('%s -> %s', (method, expected) => {
|
||||
expect(isIdempotentMethod(method)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldRetryRequest truth table', () => {
|
||||
const reset = () => errWithCode('ECONNRESET', 'read ECONNRESET')
|
||||
const refused = () => errWithCode('ECONNREFUSED', 'connect ECONNREFUSED 127.0.0.1:1')
|
||||
const hangUp = () => new Error('socket hang up')
|
||||
|
||||
it('GET: retries any transient error regardless of body state', () => {
|
||||
expect(shouldRetryRequest(reset(), 'GET', { bodySent: true })).toBe(true)
|
||||
expect(shouldRetryRequest(reset(), 'GET', { bodySent: false })).toBe(true)
|
||||
expect(shouldRetryRequest(hangUp(), 'HEAD', { bodySent: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('GET: never retries non-transport errors (HTTP 4xx/5xx surfaced as Error)', () => {
|
||||
expect(shouldRetryRequest(new Error('500: boom'), 'GET', { bodySent: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('POST: retries when the connection provably never happened', () => {
|
||||
expect(shouldRetryRequest(refused(), 'POST', { bodySent: false })).toBe(true)
|
||||
expect(shouldRetryRequest(refused(), 'POST', { bodySent: true })).toBe(true) // refused == nothing sent
|
||||
expect(shouldRetryRequest(errWithCode('ENOTFOUND'), 'PUT', { bodySent: false })).toBe(true)
|
||||
})
|
||||
|
||||
it('POST: retries transient errors thrown before the body was flushed', () => {
|
||||
expect(shouldRetryRequest(reset(), 'POST', { bodySent: false })).toBe(true)
|
||||
expect(shouldRetryRequest(hangUp(), 'DELETE', { bodySent: false })).toBe(true)
|
||||
})
|
||||
|
||||
it('POST: does NOT retry ambiguous resets after the body went out', () => {
|
||||
expect(shouldRetryRequest(reset(), 'POST', { bodySent: true })).toBe(false)
|
||||
expect(shouldRetryRequest(hangUp(), 'POST', { bodySent: true })).toBe(false)
|
||||
expect(shouldRetryRequest(errWithCode('EPIPE'), 'PUT', { bodySent: true })).toBe(false)
|
||||
expect(shouldRetryRequest(errWithCode('ETIMEDOUT'), 'DELETE', { bodySent: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('POST: conservative when request state is unknown', () => {
|
||||
// No bodySent flag at all — treat as "may have been sent", don't retry.
|
||||
expect(shouldRetryRequest(reset(), 'POST', {})).toBe(false)
|
||||
expect(shouldRetryRequest(reset(), 'POST')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('withRetry', () => {
|
||||
const noDelay = { delayFn: () => Promise.resolve() }
|
||||
|
||||
it('retries a GET through transient failures and resolves', async () => {
|
||||
let attempts = 0
|
||||
|
||||
const result = await withRetry(
|
||||
() => {
|
||||
attempts += 1
|
||||
|
||||
if (attempts < 3) {
|
||||
return Promise.reject(errWithCode('ECONNRESET'))
|
||||
}
|
||||
|
||||
return Promise.resolve('ok')
|
||||
},
|
||||
{ method: 'GET', ...noDelay }
|
||||
)
|
||||
|
||||
expect(result).toBe('ok')
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
|
||||
it('gives each attempt a fresh requestState', async () => {
|
||||
const seen: boolean[] = []
|
||||
let attempts = 0
|
||||
await withRetry(
|
||||
(state: any) => {
|
||||
seen.push(state.bodySent)
|
||||
state.bodySent = true
|
||||
attempts += 1
|
||||
|
||||
if (attempts < 2) {
|
||||
return Promise.reject(errWithCode('ECONNREFUSED'))
|
||||
}
|
||||
|
||||
return Promise.resolve(null)
|
||||
},
|
||||
{ method: 'POST', ...noDelay }
|
||||
)
|
||||
expect(seen).toEqual([false, false])
|
||||
})
|
||||
|
||||
it('does not retry a POST that failed after the body was flushed', async () => {
|
||||
let attempts = 0
|
||||
await expect(
|
||||
withRetry(
|
||||
(state: any) => {
|
||||
attempts += 1
|
||||
state.bodySent = true
|
||||
|
||||
return Promise.reject(errWithCode('ECONNRESET', 'read ECONNRESET'))
|
||||
},
|
||||
{ method: 'POST', ...noDelay }
|
||||
)
|
||||
).rejects.toThrow('read ECONNRESET')
|
||||
expect(attempts).toBe(1)
|
||||
})
|
||||
|
||||
it('retries a POST on ECONNREFUSED (never reached the server)', async () => {
|
||||
let attempts = 0
|
||||
await expect(
|
||||
withRetry(
|
||||
() => {
|
||||
attempts += 1
|
||||
|
||||
return Promise.reject(errWithCode('ECONNREFUSED'))
|
||||
},
|
||||
{ method: 'POST', maxRetries: 2, ...noDelay }
|
||||
)
|
||||
).rejects.toThrow('ECONNREFUSED')
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
|
||||
it('bounds retries at maxRetries even for GET', async () => {
|
||||
let attempts = 0
|
||||
await expect(
|
||||
withRetry(
|
||||
() => {
|
||||
attempts += 1
|
||||
|
||||
return Promise.reject(errWithCode('ECONNRESET'))
|
||||
},
|
||||
{ method: 'GET', maxRetries: 2, ...noDelay }
|
||||
)
|
||||
).rejects.toThrow()
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
|
||||
it('never retries non-transient errors', async () => {
|
||||
let attempts = 0
|
||||
await expect(
|
||||
withRetry(
|
||||
() => {
|
||||
attempts += 1
|
||||
|
||||
return Promise.reject(new Error('500: internal'))
|
||||
},
|
||||
{ method: 'GET', ...noDelay }
|
||||
)
|
||||
).rejects.toThrow('500')
|
||||
expect(attempts).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('keep-alive agent pools', () => {
|
||||
it('separates JSON and download pools per protocol', () => {
|
||||
expect(jsonAgentFor('http:')).not.toBe(jsonAgentFor('https:'))
|
||||
expect(jsonAgentFor('http:')).not.toBe(downloadAgentFor('http:'))
|
||||
expect(jsonAgentFor('https:')).not.toBe(downloadAgentFor('https:'))
|
||||
// Stable across calls (a real pool, not a factory).
|
||||
expect(jsonAgentFor('http:')).toBe(jsonAgentFor('http:'))
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LIVE transport tests against real misbehaving HTTP servers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimal single-attempt GET mirroring the pre-PR fetchJson (no retry). */
|
||||
function bareJsonGet(url: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(new URL(url), { agent: jsonAgentFor('http:'), method: 'GET' }, res => {
|
||||
const chunks: Buffer[] = []
|
||||
res.on('error', reject)
|
||||
res.on('data', c => chunks.push(c))
|
||||
res.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))))
|
||||
})
|
||||
|
||||
req.on('error', reject)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
/** The head behavior: same request under the verb-gated retry policy. */
|
||||
function retriedJsonGet(url: string): Promise<any> {
|
||||
return withRetry(() => bareJsonGet(url), { method: 'GET', delayFn: () => Promise.resolve() })
|
||||
}
|
||||
|
||||
function listen(server: http.Server): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve(`http://127.0.0.1:${(server.address() as AddressInfo).port}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('live: GET burst against a server that resets keep-alive sockets', () => {
|
||||
it('bare attempts fail with ECONNRESET/hang-up; retried GETs all succeed', async () => {
|
||||
// Deterministic misbehavior: every other request gets its socket
|
||||
// destroyed instead of a response — the observable client-side effect of
|
||||
// a backend killing idle keep-alive sockets mid-burst.
|
||||
let hits = 0
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
hits += 1
|
||||
|
||||
if (hits % 2 === 1) {
|
||||
req.socket.destroy()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify({ n: hits }))
|
||||
})
|
||||
|
||||
const base = await listen(server)
|
||||
|
||||
try {
|
||||
// BASE (pre-PR, single attempt): the burst surfaces raw transport errors.
|
||||
let baseFailures = 0
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
try {
|
||||
await bareJsonGet(`${base}/api/sessions`)
|
||||
} catch (error: any) {
|
||||
baseFailures += 1
|
||||
expect(isTransientTransportError(error)).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
expect(baseFailures).toBeGreaterThan(0)
|
||||
|
||||
// HEAD (retry policy): the same burst fully succeeds. Sequential so the
|
||||
// server's alternating destroy/respond pattern is deterministic per
|
||||
// request (first attempt reset, retry served).
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const r = await retriedJsonGet(`${base}/api/sessions`)
|
||||
expect(r).toHaveProperty('n')
|
||||
}
|
||||
} finally {
|
||||
server.close()
|
||||
}
|
||||
}, 20_000)
|
||||
})
|
||||
|
||||
describe('live: POST reset after server-side processing', () => {
|
||||
it('does not double-submit: server hit count stays 1, error surfaces', async () => {
|
||||
// The server fully receives and "processes" the POST (counter increments),
|
||||
// then RSTs the socket before responding — the dangerous ambiguous case.
|
||||
let posts = 0
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', c => chunks.push(c))
|
||||
req.on('end', () => {
|
||||
posts += 1 // processed: prompt submitted / session created
|
||||
req.socket.resetAndDestroy()
|
||||
void res
|
||||
})
|
||||
})
|
||||
|
||||
const base = await listen(server)
|
||||
|
||||
const postOnce = () =>
|
||||
withRetry(
|
||||
(state: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const body = Buffer.from(JSON.stringify({ prompt: 'hello' }))
|
||||
|
||||
const req = http.request(
|
||||
new URL(`${base}/api/prompt`),
|
||||
{
|
||||
agent: jsonAgentFor('http:'),
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'content-length': String(body.length) }
|
||||
},
|
||||
res => {
|
||||
res.resume()
|
||||
res.on('end', () => resolve(null))
|
||||
}
|
||||
)
|
||||
|
||||
req.on('error', reject)
|
||||
state.bodySent = true
|
||||
req.write(body)
|
||||
req.end()
|
||||
}),
|
||||
{ method: 'POST', delayFn: () => Promise.resolve() }
|
||||
)
|
||||
|
||||
try {
|
||||
await expect(postOnce()).rejects.toSatisfy((error: any) => isTransientTransportError(error))
|
||||
expect(posts).toBe(1) // exactly one server-side submission — no retry
|
||||
} finally {
|
||||
server.close()
|
||||
}
|
||||
}, 20_000)
|
||||
|
||||
it('sanity: an identical GET-shaped retry WOULD have re-hit the server', async () => {
|
||||
// Companion proof that the verb gate (not luck) is what kept posts === 1:
|
||||
// the same reset-after-processing server sees multiple hits under GET.
|
||||
let gets = 0
|
||||
|
||||
const server = http.createServer(req => {
|
||||
gets += 1
|
||||
req.socket.resetAndDestroy()
|
||||
})
|
||||
|
||||
const base = await listen(server)
|
||||
|
||||
try {
|
||||
await expect(retriedJsonGet(`${base}/api/thing`)).rejects.toThrow()
|
||||
expect(gets).toBeGreaterThan(1) // retried — proves the machinery fires
|
||||
} finally {
|
||||
server.close()
|
||||
}
|
||||
}, 20_000)
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Shared HTTP transport policy for the Electron main process's Hermes REST
|
||||
* helpers (fetchJson / fetchPublicJson / downloadViaTokenToFile).
|
||||
*
|
||||
* Two concerns live here so they can be unit-tested without Electron:
|
||||
*
|
||||
* 1. Connection-pooled keep-alive agents. Opening a fresh TCP socket per call
|
||||
* is what produced the burst-traffic ECONNRESET storms (#92976): the
|
||||
* backend closes idle keep-alive sockets and the next write on a reused
|
||||
* raw socket dies with 'socket hang up'. JSON calls and streaming
|
||||
* downloads get SEPARATE pools so a handful of long-lived download
|
||||
* streams can never starve the small, latency-sensitive JSON calls out of
|
||||
* the socket pool.
|
||||
*
|
||||
* 2. A retry policy that is safe for non-idempotent verbs. A transient
|
||||
* transport error does NOT mean the server didn't process the request —
|
||||
* an ECONNRESET can arrive after the backend already handled a POST
|
||||
* (created the session, submitted the prompt) and merely lost the socket
|
||||
* before the response was read. Blindly retrying every verb double-submits.
|
||||
*
|
||||
* The rule implemented by shouldRetryRequest():
|
||||
* - Idempotent verbs (GET / HEAD / OPTIONS) retry on any transient
|
||||
* transport error — replaying them is harmless by definition.
|
||||
* - Non-idempotent verbs (POST / PUT / PATCH / DELETE) retry ONLY when
|
||||
* the request provably never reached the server:
|
||||
* a) connection-establishment failures (ECONNREFUSED, ENOTFOUND,
|
||||
* EAI_AGAIN, EHOSTUNREACH, ENETUNREACH) — no connection means no
|
||||
* request; or
|
||||
* b) a transient error thrown before we started flushing the
|
||||
* request (requestState.bodySent === false).
|
||||
* Anything ambiguous — ECONNRESET / EPIPE / 'socket hang up' after
|
||||
* the body went out — is NOT retried; the error surfaces to the
|
||||
* caller. When in doubt, don't retry a non-idempotent request.
|
||||
*/
|
||||
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
|
||||
// JSON pool: many small concurrent calls (session lists, config, prompts).
|
||||
const HTTP_JSON_AGENT = new http.Agent({ keepAlive: true, maxSockets: 50 })
|
||||
const HTTPS_JSON_AGENT = new https.Agent({ keepAlive: true, maxSockets: 50 })
|
||||
|
||||
// Download pool: few long-lived streaming bodies. Isolated from the JSON pool
|
||||
// so saturating it with large file downloads can't block interactive calls.
|
||||
const HTTP_DOWNLOAD_AGENT = new http.Agent({ keepAlive: true, maxSockets: 8 })
|
||||
const HTTPS_DOWNLOAD_AGENT = new https.Agent({ keepAlive: true, maxSockets: 8 })
|
||||
|
||||
function jsonAgentFor(protocol) {
|
||||
return protocol === 'https:' ? HTTPS_JSON_AGENT : HTTP_JSON_AGENT
|
||||
}
|
||||
|
||||
function downloadAgentFor(protocol) {
|
||||
return protocol === 'https:' ? HTTPS_DOWNLOAD_AGENT : HTTP_DOWNLOAD_AGENT
|
||||
}
|
||||
|
||||
// Close pooled sockets so lingering keep-alive connections can't hold the
|
||||
// process open (or leak FDs) across quit. Wired to app 'will-quit' in main.ts.
|
||||
function destroyKeepaliveAgents() {
|
||||
for (const agent of [HTTP_JSON_AGENT, HTTPS_JSON_AGENT, HTTP_DOWNLOAD_AGENT, HTTPS_DOWNLOAD_AGENT]) {
|
||||
agent.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// Transient transport errors: retry MAY be safe (subject to verb gating).
|
||||
const TRANSIENT_CODES = new Set([
|
||||
'ECONNRESET',
|
||||
'ECONNREFUSED',
|
||||
'EPIPE',
|
||||
'ETIMEDOUT',
|
||||
'EAI_AGAIN',
|
||||
'ENOTFOUND',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH'
|
||||
])
|
||||
|
||||
// Errors that prove the request never reached the server: the TCP connection
|
||||
// (or name resolution) failed outright, so nothing was submitted.
|
||||
const NEVER_SENT_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH'])
|
||||
|
||||
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
||||
|
||||
function isIdempotentMethod(method) {
|
||||
return IDEMPOTENT_METHODS.has(String(method || 'GET').toUpperCase())
|
||||
}
|
||||
|
||||
function isTransientTransportError(error) {
|
||||
if (!error) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (TRANSIENT_CODES.has(error.code)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const msg = String(error.message || '')
|
||||
|
||||
return msg.includes('socket hang up') || msg.includes('read ECONNRESET')
|
||||
}
|
||||
|
||||
/**
|
||||
* The verb-gated retry decision.
|
||||
*
|
||||
* @param error the transport error from the failed attempt
|
||||
* @param method HTTP verb of the request ('GET', 'POST', ...)
|
||||
* @param requestState per-attempt state; requestState.bodySent is set true by
|
||||
* the caller just BEFORE the first byte of the request is
|
||||
* flushed, so a `false` here proves nothing went out.
|
||||
*/
|
||||
function shouldRetryRequest(error, method, requestState: any = {}) {
|
||||
if (!isTransientTransportError(error)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isIdempotentMethod(method)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Non-idempotent: only when the request provably never reached the server.
|
||||
if (NEVER_SENT_CODES.has(error && error.code)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (requestState.bodySent === false) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Ambiguous (reset/hang-up after the body was flushed): the server may have
|
||||
// processed it. Surface the error rather than risk a double submit.
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `makeAttempt` with bounded retries under the policy above.
|
||||
*
|
||||
* `makeAttempt(requestState)` must return a Promise and should set
|
||||
* `requestState.bodySent = true` immediately before flushing the request
|
||||
* (before the first req.write()/req.end()). Each attempt gets a fresh state
|
||||
* object initialized to { bodySent: false }.
|
||||
*/
|
||||
async function withRetry(makeAttempt, options: any = {}) {
|
||||
const method = String(options.method || 'GET').toUpperCase()
|
||||
const maxRetries = Number.isInteger(options.maxRetries) ? options.maxRetries : 2
|
||||
|
||||
const delayFn =
|
||||
options.delayFn || (attempt => new Promise(r => setTimeout(r, Math.min(200 * Math.pow(2, attempt), 2000))))
|
||||
|
||||
let lastError
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
const requestState = { bodySent: false }
|
||||
|
||||
try {
|
||||
return await makeAttempt(requestState)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
|
||||
if (attempt < maxRetries && shouldRetryRequest(error, method, requestState)) {
|
||||
await delayFn(attempt)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
export {
|
||||
destroyKeepaliveAgents,
|
||||
downloadAgentFor,
|
||||
isIdempotentMethod,
|
||||
isTransientTransportError,
|
||||
jsonAgentFor,
|
||||
shouldRetryRequest,
|
||||
withRetry
|
||||
}
|
||||