Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
yilsem committed 2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 files changed
+3442369

No files matched your search

+210
View File
@@ -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.
+364
View File
@@ -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.
+244
View File
@@ -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).
+21
View File
@@ -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.
+32
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+21
View File
@@ -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)
})
})
+86
View File
@@ -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)
})
})
+53
View File
@@ -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 })
})
})
+82
View File
@@ -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()
})
+139
View File
@@ -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)
})
})
+100
View File
@@ -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')
})
})
+72
View File
@@ -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
}
+746
View File
@@ -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 },
)
}
+360
View File
@@ -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],
])
})
})
+229
View File
@@ -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') })
})
})
+275
View File
@@ -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 })
})
})
+984
View File
@@ -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
+76
View File
@@ -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])
})
})
+239
View File
@@ -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
}
+138
View File
@@ -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)
})
})
+321
View File
@@ -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' })
})
})
+75
View File
@@ -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)
})
})
+166
View File
@@ -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 }
+274
View File
@@ -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' })
})
})
+162
View File
@@ -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)
})
})
+150
View File
@@ -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`,
)
}
+477
View File
@@ -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')
})
})
+27
View File
@@ -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')
}
+383
View File
@@ -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)
})
+178
View File
@@ -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
}
+112
View File
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import { appIconCandidates, decodingFileProbe, resolveAppIcon } from './app-icon'
// Regression: a packaged app.asar can contain a TRUNCATED apple-touch-icon.png
// (interrupted electron-builder run, partial copy). Electron's
// BrowserWindow({ icon }) / app.dock.setIcon() decode synchronously and THROW
// on undecodable bytes, which killed the main process inside createWindow()
// and took the app down mid-session. Icon resolution must fail soft: skip a
// candidate that exists but does not decode, exactly like a missing one.
test('resolveAppIcon skips an existing but undecodable candidate', () => {
// First candidate "exists" (probe says true) but does not decode; second
// decodes. The resolver must return the second, not the first.
const probeCalls: string[] = []
const probe = (p: string) => {
probeCalls.push(p)
return p !== '/packaged/app.asar/public/apple-touch-icon.png'
}
const picked = resolveAppIcon(
['/packaged/app.asar/public/apple-touch-icon.png', '/packaged/app.asar/dist/apple-touch-icon.png'],
probe
)
assert.equal(picked, '/packaged/app.asar/dist/apple-touch-icon.png')
assert.deepEqual(probeCalls, [
'/packaged/app.asar/public/apple-touch-icon.png',
'/packaged/app.asar/dist/apple-touch-icon.png'
])
})
test('resolveAppIcon returns undefined when every candidate fails the probe', () => {
const picked = resolveAppIcon(['/a.png', '/b.ico'], () => false)
assert.equal(picked, undefined)
})
test('resolveAppIcon returns the first candidate that passes the probe', () => {
const picked = resolveAppIcon(['/a.png', '/b.png'], () => true)
assert.equal(picked, '/a.png')
})
test('decodingFileProbe rejects a missing file', () => {
const missing = path.join(os.tmpdir(), `hermes-icon-missing-${process.pid}.png`)
assert.equal(decodingFileProbe(missing), false)
})
test('decodingFileProbe rejects an existing but empty (0-byte) file', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-icon-'))
const empty = path.join(dir, 'apple-touch-icon.png')
fs.writeFileSync(empty, Buffer.alloc(0))
try {
// 0 bytes exist but decode to an empty image — and without electron in
// the test runtime the require itself fails. Both paths must be false.
assert.equal(decodingFileProbe(empty), false)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('decodingFileProbe rejects a directory', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-icon-dir-'))
try {
assert.equal(decodingFileProbe(dir), false)
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('appIconCandidates keeps the documented precedence ladder', () => {
const mac = appIconCandidates({
isWindows: false,
appRoot: '/Applications/Hermes.app/Contents/Resources',
unpackedPathFor: p => `${p}.unpacked`
})
assert.deepEqual(mac, [
path.join('/Applications/Hermes.app/Contents/Resources', 'public', 'apple-touch-icon.png'),
path.join('/Applications/Hermes.app/Contents/Resources', 'dist', 'apple-touch-icon.png'),
path.join('/Applications/Hermes.app/Contents/Resources.unpacked', 'dist', 'apple-touch-icon.png')
])
// Windows prepends the two full-bleed .ico rungs ahead of the PNG ladder.
const win = appIconCandidates({
isWindows: true,
appRoot: 'C:\\app',
resourcesPath: 'C:\\resources',
unpackedPathFor: p => `${p}\\unpacked`
})
assert.equal(win.length, 5)
assert.equal(win.filter(c => c.endsWith('.ico')).length, 2)
assert.equal(
win[0],
path.join('C:\\resources', 'icon.ico'),
'resources/ icon.ico is the highest-precedence Windows rung'
)
assert.equal(
win.filter(c => c.endsWith('apple-touch-icon.png')).length,
3,
'all three PNG rungs remain after the ico rungs'
)
})
+85
View File
@@ -0,0 +1,85 @@
import fs from 'node:fs'
import path from 'node:path'
import { nativeImage } from 'electron'
/**
* Validate that a candidate app-icon file exists and decodes as an image.
*
* Electron's `new BrowserWindow({ icon })` and `app.dock.setIcon()` decode the
* file synchronously on the main process and THROW when the bytes are not a
* decodable image — `statSync().isFile()` only proves the file exists, not that
* it decodes. A truncated or zero-byte PNG inside a packaged `app.asar` (e.g.
* interrupted electron-builder run) therefore killed the main process inside
* `createWindow()` and took the whole app down mid-session: the window never
* appeared, running turns lost their renderer, and the desktop log showed
* `Uncaught exception: Error: Failed to load image from path
* '.../app.asar/public/apple-touch-icon.png' at createWindow`.
*
* This helper makes icon resolution fail-soft: a candidate that exists but does
* not decode is skipped like a missing one, so the app falls through to the
* next candidate (or starts with the platform default icon) instead of dying.
* `nativeImage.createFromPath` is Electron's own decoder with the same failure
* mode, so callers can inject a probe matching their environment; the shipped
* probe decodes eagerly and treats a thrown error OR an empty image as invalid.
*/
export type IconProbe = (filePath: string) => boolean
/** Eager-decoding default probe: the file must decode to a non-empty image. */
export function decodingFileProbe(filePath: string): boolean {
try {
if (!fs.statSync(filePath).isFile()) {
return false
}
} catch {
return false
}
try {
return !nativeImage.createFromPath(filePath).isEmpty()
} catch {
return false
}
}
/**
* Pick the first app-icon candidate that exists AND decodes; `undefined` when
* none do (callers already treat a missing icon as optional — `if (icon)`).
*
* Pure over `(candidates, probe)` so the precedence ladder is unit-testable
* without a running Electron app; the shipped probe injects the real decoder.
*/
export function resolveAppIcon(
candidates: readonly string[],
probe: IconProbe = decodingFileProbe
): string | undefined {
for (const candidate of candidates) {
if (probe(candidate)) {
return candidate
}
}
return undefined
}
/**
* Build the platform-aware candidate ladder shared by every window factory.
* Kept next to the resolver so precedence has one home; `appRoot` is injected
* (packaged `APP_ROOT` vs dev tree) and `unpackedPathFor` maps into
* `app.asar.unpacked` for builds that leave assets outside the archive.
*/
export function appIconCandidates(opts: {
isWindows: boolean
appRoot: string
resourcesPath?: string
unpackedPathFor: (p: string) => string
}): string[] {
const { isWindows, appRoot, resourcesPath, unpackedPathFor } = opts
return [
...(isWindows ? [path.join(resourcesPath ?? '', 'icon.ico'), path.join(appRoot, 'assets', 'icon.ico')] : []),
path.join(appRoot, 'public', 'apple-touch-icon.png'),
path.join(appRoot, 'dist', 'apple-touch-icon.png'),
path.join(unpackedPathFor(appRoot), 'dist', 'apple-touch-icon.png')
]
}
+103
View File
@@ -0,0 +1,103 @@
/**
* backend-child.ts
*
* Windows-aware teardown for the desktop's managed backend child process.
*
* Node's `child.kill()` only signals the direct child. On Windows a backend
* that spawned its own grandchildren (a `hermes` REPL, a pty terminal
* session, the gateway) survives a plain SIGTERM and keeps files (e.g. the
* venv shim) locked. So on Windows we tree-kill via `forceKillProcessTree`.
*
* On POSIX the backend IS spawned into its own session/process-group
* (start_new_session=True), so `child.kill('SIGTERM')` would only reach the
* backend and orphan its MCP grandchildren (the leak in #serve-orphans). We
* signal the whole group via `process.kill(-pid, ...)` instead, falling back
* to the direct child if the group send fails.
*
* Extracted into its own dependency-free module (no electron import) so the
* tree-kill / group-kill branching can be asserted directly with a fake child
* object and spy kill functions, instead of grepping main.ts source text for
* the function body.
*/
export interface StopBackendChildDeps {
/** Defaults to the real platform check; injectable for tests. */
isWindows?: boolean
/** Windows tree-kill implementation (real: taskkill /T /F via execFileSync). */
forceKillProcessTree: (pid: number) => void
/**
* POSIX group-signal implementation. Real: process.kill(-pgid, signal).
* Injectable so the negative-pid group send is asserted in tests without a
* live process group. Defaults to process.kill.
*/
killGroup?: (pgid: number, signal: string) => void
}
export interface StopBackendTreesForUpdateDeps {
/** Synchronous Windows taskkill /T /F implementation. */
forceKillProcessTree: (pid: number) => void
/** Clears and stops the desktop's pooled backends. */
stopAllPoolBackends: () => void
}
export interface BackendProcessRoot {
pid?: number | null
}
export interface KillableChild extends BackendProcessRoot {
killed?: boolean
kill: (signal: string) => void
}
/**
* Stop a managed child process, choosing the right strategy for the platform.
* No-ops silently if `child` is falsy, already killed, or the kill attempt
* throws (the process may already be gone) -- mirrors the original inline
* best-effort semantics in main.ts.
*/
export function stopBackendChild(child: KillableChild | null | undefined, deps: StopBackendChildDeps) {
if (!child || child.killed) {
return
}
const isWindows = deps.isWindows ?? process.platform === 'win32'
const killGroup = deps.killGroup ?? ((pgid: number, signal: string) => process.kill(pgid, signal))
try {
if (isWindows && Number.isInteger(child.pid)) {
deps.forceKillProcessTree(child.pid as number)
} else if (Number.isInteger(child.pid)) {
// POSIX: pgid == pid (start_new_session). Signal the whole group so MCP
// grandchildren die too; fall back to the direct child on failure.
try {
killGroup(-(child.pid as number), 'SIGTERM')
} catch {
child.kill('SIGTERM')
}
} else {
child.kill('SIGTERM')
}
} catch {
// Already gone.
}
}
/**
* Stop every backend tree owned by a Windows Desktop update hand-off.
*
* Tree-kill the primary root while its PID is still live, then delegate pool
* teardown to the existing routine that tree-kills each pooled root exactly
* once before mutating its registry. In particular, do not signal the primary
* first: if that root exits before taskkill /T runs, Windows can no longer
* enumerate its MCP grandchildren and they survive with the venv locked.
*/
export function stopBackendTreesForUpdate(
primary: BackendProcessRoot | null | undefined,
deps: StopBackendTreesForUpdateDeps
): void {
if (primary && Number.isInteger(primary.pid)) {
deps.forceKillProcessTree(primary.pid as number)
}
deps.stopAllPoolBackends()
}
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import { test } from 'vitest'
import {
claimDecision,
createBackendOutputTail,
DEFAULT_OUTPUT_TAIL_LIMIT,
isPidOnlyStartMarker,
pidOnlyStartMarker,
probeStartMarker,
processStartMarker
} from './backend-claim'
// --- claimDecision: the #93608 policy ---------------------------------------
test('probe success claims with the full start marker (unchanged behavior)', () => {
const decision = claimDecision(true, { ok: true, startMarker: 'linux:12345' })
assert.deepEqual(decision, { action: 'claim', startMarker: 'linux:12345' })
})
test('probe success claims even when the child already exited (ownership records the incarnation)', () => {
// The claim itself must not invent a failure: the exit handler owns cleanup.
const decision = claimDecision(false, { ok: true, startMarker: 'win:99' })
assert.deepEqual(decision, { action: 'claim', startMarker: 'win:99' })
})
test('probe failure on a LIVE child degrades to PID-only identity — never kills a healthy backend (#93608)', () => {
const decision = claimDecision(true, { ok: false, reason: 'powershell.exe timed out after 30000ms' })
assert.equal(decision.action, 'degrade')
assert.match((decision as { reason: string }).reason, /timed out/)
})
test('probe failure on a DEAD child fails closed so the caller can attach the stderr tail', () => {
const decision = claimDecision(false, { ok: false, reason: 'Get-Process: no process with ID 4242' })
assert.equal(decision.action, 'fail')
assert.match((decision as { reason: string }).reason, /4242/)
})
// --- probeStartMarker: throw → value ----------------------------------------
test('probeStartMarker converts a probe throw into { ok: false, reason }', async () => {
const probe = await probeStartMarker(4242, async () => {
throw new Error('PowerShell 5.1 cold start exceeded budget')
})
assert.deepEqual(probe, { ok: false, reason: 'PowerShell 5.1 cold start exceeded budget' })
})
test('probeStartMarker passes a successful marker through', async () => {
const probe = await probeStartMarker(4242, async pid => `linux:${pid}`)
assert.deepEqual(probe, { ok: true, startMarker: 'linux:4242' })
})
// --- real probe: drives the actual OS helper (PowerShell on the Windows lane) ---
test('processStartMarker resolves a real marker for the current process', async () => {
const marker = await processStartMarker(process.pid)
assert.match(marker, /^(linux|win|winms|ps):.+/)
})
test('a missing PID is classified as ESRCH so reapOrphans can drop the record', async () => {
// Largest PIDs are bounded well below this on every supported platform.
// Windows Get-Process / macOS `ps -p` used to surface exit code 1, which
// the identity matchers treated as "unknown" and kept forever. The native
// gate throws ESRCH — the errno those catch blocks already map to gone.
await assert.rejects(processStartMarker(2 ** 30 + 12345), (error: NodeJS.ErrnoException) => error?.code === 'ESRCH')
})
// --- PID-only marker helpers --------------------------------------------------
test('pidOnlyStartMarker round-trips through isPidOnlyStartMarker', () => {
const marker = pidOnlyStartMarker(4242)
assert.equal(marker, 'pid-only:4242')
assert.equal(isPidOnlyStartMarker(marker), true)
assert.equal(isPidOnlyStartMarker('linux:12345'), false)
assert.equal(isPidOnlyStartMarker(undefined), false)
})
// --- output tail ring buffer ----------------------------------------------------
test('output tail keeps only the most recent bytes once past the limit', () => {
const tail = createBackendOutputTail(16)
tail.append('0123456789')
tail.append('abcdefghij')
assert.equal(tail.text(), '456789abcdefghij')
assert.equal(tail.text().length, 16)
})
test('output tail default limit is ~8KB', () => {
const tail = createBackendOutputTail()
tail.append('x'.repeat(DEFAULT_OUTPUT_TAIL_LIMIT + 500))
assert.equal(tail.text().length, DEFAULT_OUTPUT_TAIL_LIMIT)
assert.equal(DEFAULT_OUTPUT_TAIL_LIMIT, 8192)
})
test('output tail interleaves stdout and stderr attached from spawn time', () => {
const child = { stderr: new EventEmitter(), stdout: new EventEmitter() }
const tail = createBackendOutputTail(64)
tail.attach(child)
child.stdout.emit('data', Buffer.from('booting\n'))
child.stderr.emit('data', Buffer.from("ModuleNotFoundError: No module named 'hermes_cli'\n"))
assert.match(tail.text(), /booting/)
assert.match(tail.text(), /ModuleNotFoundError/)
})
test('describe() is empty when nothing was captured, formatted when output exists', () => {
const tail = createBackendOutputTail(64)
assert.equal(tail.describe(), '')
tail.append('Traceback (most recent call last):\n')
assert.match(tail.describe(), /^\nRecent backend output:\nTraceback/)
})
test('attach tolerates a child with missing stdio streams', () => {
const tail = createBackendOutputTail(64)
tail.attach({ stderr: null, stdout: null })
assert.equal(tail.text(), '')
})
+221
View File
@@ -0,0 +1,221 @@
/**
* backend-claim.ts
*
* The start-marker probe and the claim decision for a freshly spawned local
* backend child, extracted from main.ts so the policy is testable without
* booting Electron — including on a Windows CI lane that drives the probe
* with REAL PowerShell (`processStartMarker` shells out to powershell.exe).
*
* Why this exists (#93608): `claimBackendChild` used to hard-fail on ANY
* probe error — `Get-Process` timing out on a PowerShell 5.1 cold start
* (see #87169) killed a perfectly healthy backend, the renderer "repaired"
* by respawning, and the next probe timeout killed that one too. The rule is
* now the same one `createParentStartMarkerResolver` already applies to the
* parent marker: a failed probe against a LIVE child degrades to PID-only
* identity instead of killing the child; only a child that actually DIED
* keeps the fail-closed throw (now carrying its stderr tail, so the real
* exit reason reaches desktop.log and the boot UI).
*/
import { execFile } from 'node:child_process'
import fs from 'node:fs'
import { electronProcessStartMarker } from './parent-process-identity'
import { isPidAlive } from './update-marker'
import { hiddenWindowsChildOptions } from './windows-child-options'
export function execText(command: string, args: string[], { timeout = 3000 } = {}): Promise<string> {
return new Promise<string>((resolve, reject) => {
execFile(command, args, hiddenWindowsChildOptions({ encoding: 'utf8', timeout }), (error, stdout) => {
if (error) {
reject(error)
} else {
resolve(String(stdout || '').trim())
}
})
})
}
/**
* Probe budget for the ORPHAN-REAP path (matchesParent / matchesIdentity /
* stopOwnedBackend). The claim path keeps the full 30s headroom — a freshly
* spawned backend's marker is load-bearing and a slow probe must not kill a
* healthy child (#93608). Reap only needs to tell "same process" from "gone
* or reused" for OLD records, and the ownership file can accumulate dozens of
* them (one per profile per launch), so a 30s budget per record would let a
* cold PowerShell 5.1 stall boot for minutes (#87169). 5s is plenty for a
* warm probe; a timeout degrades to "unknown" and the record is preserved for
* the next launch instead of blocking boot.
*/
export const REAP_PROBE_TIMEOUT_MS = 5_000
/**
* Cross-platform process start marker: a value that changes when a PID is
* reused, so `pid + marker` identifies one specific process incarnation.
* Throws when the probe fails — callers decide what a failure means (see
* `claimDecision` / `probeStartMarker`).
*/
export async function processStartMarker(pid: number, timeoutMs: number = 30_000): Promise<string> {
// Cheap native dead-PID gate. Windows Get-Process / macOS `ps -p` exit 1
// on a missing PID (not ESRCH), so the identity matchers used to keep the
// orphan and re-probe it every launch (#92875). ESRCH is the code those
// catch blocks already map to "gone". Alive or uninspectable (EPERM) PIDs
// still fall through to the platform probe.
if (!isPidAlive(pid)) {
throw Object.assign(new Error(`PID ${pid} no longer exists`), { code: 'ESRCH' })
}
if (process.platform === 'linux') {
const stat = await fs.promises.readFile(`/proc/${pid}/stat`, 'utf8')
const fields = stat
.slice(stat.lastIndexOf(')') + 1)
.trim()
.split(/\s+/)
if (!/^\d+$/.test(fields[19] || '')) {
throw new Error(`Invalid /proc start marker for PID ${pid}`)
}
return `linux:${fields[19]}`
}
if (process.platform === 'win32') {
const electronMarker =
pid === process.pid ? electronProcessStartMarker(pid, process.pid, process.getCreationTime?.()) : null
if (electronMarker) {
return electronMarker
}
const ticks = await execText(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`$p = Get-Process -Id ${pid} -ErrorAction Stop; $p.StartTime.ToUniversalTime().Ticks`
],
// PowerShell 5.1 cold starts routinely exceed the default 3s execText
// budget (2.4-8s observed in #87169); give the marker probe headroom.
// The claim path keeps this 30s budget; the orphan-reap path passes
// REAP_PROBE_TIMEOUT_MS so a slow probe cannot stall boot.
{ timeout: timeoutMs }
)
if (!/^\d+$/.test(ticks)) {
throw new Error(`Invalid Windows start marker for PID ${pid}`)
}
return `win:${ticks}`
}
const started = await execText('ps', ['-p', String(pid), '-o', 'lstart='])
if (!started) {
throw new Error(`Missing process start marker for PID ${pid}`)
}
return `ps:${started}`
}
export type StartMarkerProbe = { ok: true; startMarker: string } | { ok: false; reason: string }
/** Run the marker probe, converting a throw into a value the pure decision can consume. */
export async function probeStartMarker(
pid: number,
probe: (pid: number) => Promise<string> = processStartMarker
): Promise<StartMarkerProbe> {
try {
return { ok: true, startMarker: await probe(pid) }
} catch (error) {
return { ok: false, reason: error instanceof Error ? error.message : String(error) }
}
}
const PID_ONLY_MARKER_PREFIX = 'pid-only:'
/**
* Degraded identity marker recorded when the start-marker probe failed but
* the child was verifiably alive. It satisfies the ownership schema (a
* non-empty startMarker) while telling identity matchers that only PID
* liveness — plus the command-line check layered on top — can be verified.
*/
export function pidOnlyStartMarker(pid: number): string {
return `${PID_ONLY_MARKER_PREFIX}${pid}`
}
export function isPidOnlyStartMarker(startMarker: unknown): boolean {
return typeof startMarker === 'string' && startMarker.startsWith(PID_ONLY_MARKER_PREFIX)
}
export type ClaimDecision =
{ action: 'claim'; startMarker: string } | { action: 'degrade'; reason: string } | { action: 'fail'; reason: string }
/**
* Pure claim policy for a freshly spawned backend child:
*
* - probe succeeded → claim with the full start marker (unchanged).
* - probe failed, child ALIVE → degrade to PID-only identity; NEVER kill a
* healthy backend over a flaky identity probe.
* - probe failed, child DEAD → fail closed; the child's death is the real
* story and the caller attaches its stderr tail.
*/
export function claimDecision(childAlive: boolean, probe: StartMarkerProbe): ClaimDecision {
if (probe.ok === true) {
return { action: 'claim', startMarker: probe.startMarker }
}
const { reason } = probe
return childAlive ? { action: 'degrade', reason } : { action: 'fail', reason }
}
export interface BackendOutputTail {
/** Attach stdout/stderr data listeners to a just-spawned child. */
attach(child: {
stdout?: { on: (event: 'data', listener: (chunk: unknown) => void) => unknown } | null
stderr?: { on: (event: 'data', listener: (chunk: unknown) => void) => unknown } | null
}): void
append(chunk: unknown): void
/** The buffered tail (most recent `limit` characters), or ''. */
text(): string
/** Human-readable suffix for error messages, or '' when nothing buffered. */
describe(): string
}
export const DEFAULT_OUTPUT_TAIL_LIMIT = 8192
/**
* Ring-buffered tail of a child's combined stdout+stderr, attached at SPAWN
* time — before the claim, before the READY wait — so an early crash's real
* stderr (traceback, missing module, bad config) survives into the ownership
* error and the before-ready exit messages instead of a bare exit code.
*/
export function createBackendOutputTail(limit: number = DEFAULT_OUTPUT_TAIL_LIMIT): BackendOutputTail {
let buffer = ''
const append = (chunk: unknown) => {
buffer += String(chunk)
if (buffer.length > limit) {
buffer = buffer.slice(buffer.length - limit)
}
}
return {
append,
attach(child) {
child.stdout?.on('data', append)
child.stderr?.on('data', append)
},
text() {
return buffer
},
describe() {
const text = buffer.trim()
return text ? `\nRecent backend output:\n${text}` : ''
}
}
}
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { dashboardFallbackArgs, serveBackendArgs, sourceDeclaresServe } from './backend-command'
test('serveBackendArgs builds a headless serve invocation', () => {
assert.deepEqual(serveBackendArgs(), ['serve', '--host', '127.0.0.1', '--port', '0'])
})
test('serveBackendArgs pins a profile when provided', () => {
assert.deepEqual(serveBackendArgs('worker'), ['--profile', 'worker', 'serve', '--host', '127.0.0.1', '--port', '0'])
})
test('dashboardFallbackArgs rewrites serve -> dashboard --no-open, keeping the -m prefix', () => {
const serve = ['-m', 'hermes_cli.main', 'serve', '--host', '127.0.0.1', '--port', '0']
assert.deepEqual(dashboardFallbackArgs(serve), [
'-m',
'hermes_cli.main',
'dashboard',
'--no-open',
'--host',
'127.0.0.1',
'--port',
'0'
])
})
test('dashboardFallbackArgs preserves a --profile flag ahead of serve', () => {
const serve = ['-m', 'hermes_cli.main', '--profile', 'worker', 'serve', '--host', '127.0.0.1', '--port', '0']
assert.deepEqual(dashboardFallbackArgs(serve), [
'-m',
'hermes_cli.main',
'--profile',
'worker',
'dashboard',
'--no-open',
'--host',
'127.0.0.1',
'--port',
'0'
])
})
test('dashboardFallbackArgs is a no-op (copy) when there is no serve token', () => {
const args = ['-m', 'hermes_cli.main', 'dashboard', '--no-open']
const out = dashboardFallbackArgs(args)
assert.deepEqual(out, args)
assert.notEqual(out, args, 'should return a copy, not the same reference')
})
test('sourceDeclaresServe detects the serve subparser registration', () => {
assert.equal(sourceDeclaresServe('subparsers.add_parser("serve", help="...")'), true)
assert.equal(sourceDeclaresServe("subparsers.add_parser('serve')"), true)
assert.equal(sourceDeclaresServe('subparsers.add_parser(\n "serve",\n)'), true)
})
test('sourceDeclaresServe does not false-positive on the substring "server"', () => {
const oldSource = `
dashboard_parser = subparsers.add_parser("dashboard", help="Start the web UI dashboard")
from hermes_cli.web_server import start_server # web server
`
assert.equal(sourceDeclaresServe(oldSource), false)
})
+48
View File
@@ -0,0 +1,48 @@
// Backend subcommand routing for the desktop-managed Hermes process.
//
// The desktop app launches its own headless backend via `hermes serve` — it
// must NEVER depend on or launch the browser `dashboard`. But `serve` is a
// newer subcommand: a runtime that predates it (an older managed install the
// app hasn't updated yet, or an older `hermes` resolved from PATH) only knows
// `dashboard --no-open`. To avoid bricking those users mid-upgrade we detect
// whether the resolved runtime understands `serve` and, only when it does not,
// fall back to the legacy `dashboard --no-open` invocation. Both produce the
// exact same headless gateway; `serve` is just the decoupled name.
//
// These helpers are pure so they can be unit-tested without Electron.
/**
* Build the canonical headless backend argv (always `serve`).
* @param {string} [profile] optional Hermes profile to pin via `--profile`.
*/
export function serveBackendArgs(profile?: string) {
const head = profile ? ['--profile', profile] : []
return [...head, 'serve', '--host', '127.0.0.1', '--port', '0']
}
/**
* Rewrite a resolved backend argv from `serve` to the legacy
* `dashboard --no-open` form, preserving every other argument (incl. a leading
* `-m hermes_cli.main` and any `--profile <name>`). Returns a copy; if there is
* no `serve` token the argv is returned unchanged.
*/
export function dashboardFallbackArgs(args) {
const i = args.indexOf('serve')
if (i === -1) {
return args.slice()
}
return [...args.slice(0, i), 'dashboard', '--no-open', ...args.slice(i + 1)]
}
/**
* True when a runtime's `hermes_cli/subcommands/dashboard.py` source registers
* the `serve` subcommand. Matches `add_parser("serve"` / `add_parser('serve'`
* specifically so the substring "server" (e.g. "start_server", "web server")
* never produces a false positive.
*/
export function sourceDeclaresServe(dashboardPySource) {
return /add_parser\(\s*["']serve["']/.test(String(dashboardPySource || ''))
}
@@ -0,0 +1,107 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { createBackendConnectionState } from './backend-connection-state'
type FakeProcess = { id: string }
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(next => {
resolve = next
})
return { promise, resolve }
}
test('an invalidated remote attempt cannot publish a late descriptor', async () => {
const state = createBackendConnectionState<FakeProcess, string>()
const oldProbe = deferred<string>()
const oldAttempt = state.startAttempt()
const oldResult = oldProbe.promise.then(descriptor => {
if (!state.isCurrentAttempt(oldAttempt)) {
throw new Error('Hermes backend start was superseded by a newer connection attempt.')
}
return descriptor
})
state.setPromise(oldAttempt, oldResult)
state.invalidate()
const newAttempt = state.startAttempt()
const newResult = Promise.resolve('https://new.example')
state.setPromise(newAttempt, newResult)
assert.equal(await newResult, 'https://new.example')
oldProbe.resolve('https://old.example')
await assert.rejects(oldResult, /superseded by a newer connection attempt/)
assert.equal(state.getPromise(), newResult)
})
test('a stale backend exit cannot clear a newer connection attempt', () => {
const state = createBackendConnectionState<FakeProcess, string>()
const oldAttempt = state.startAttempt()
const oldPromise = Promise.resolve('old')
state.setPromise(oldAttempt, oldPromise)
const oldOwner = state.attachProcess(oldAttempt, { id: 'old' })
assert.ok(oldOwner)
state.invalidate()
const newAttempt = state.startAttempt()
const newPromise = Promise.resolve('new')
const newProcess = { id: 'new' }
state.setPromise(newAttempt, newPromise)
assert.ok(state.attachProcess(newAttempt, newProcess))
assert.equal(state.clearForCurrentProcess(oldOwner), false)
assert.equal(state.getProcess(), newProcess)
assert.equal(state.getPromise(), newPromise)
})
test('the current backend exit clears its process and connection promise', () => {
const state = createBackendConnectionState<FakeProcess, string>()
const attempt = state.startAttempt()
state.setPromise(attempt, Promise.resolve('current'))
const owner = state.attachProcess(attempt, { id: 'current' })
assert.ok(owner)
assert.equal(state.clearForCurrentProcess(owner), true)
assert.equal(state.clearPromiseForAttempt(attempt), true)
assert.equal(state.getProcess(), null)
assert.equal(state.getPromise(), null)
})
test('a stale rejected attempt cannot clear a newer connection promise', () => {
const state = createBackendConnectionState<FakeProcess, string>()
const oldAttempt = state.startAttempt()
state.setPromise(oldAttempt, Promise.resolve('old'))
state.invalidate()
const newAttempt = state.startAttempt()
const newPromise = Promise.resolve('new')
state.setPromise(newAttempt, newPromise)
assert.equal(state.clearPromiseForAttempt(oldAttempt), false)
assert.equal(state.getPromise(), newPromise)
})
test('an invalidated attempt cannot attach a late-spawned process', () => {
const state = createBackendConnectionState<FakeProcess, string>()
const staleAttempt = state.startAttempt()
state.invalidate()
assert.equal(state.attachProcess(staleAttempt, { id: 'late' }), null)
assert.equal(state.getProcess(), null)
})
@@ -0,0 +1,88 @@
export type BackendConnectionAttempt<TConnection> = {
generation: number
promise: Promise<TConnection> | null
}
export type BackendProcessOwner<TProcess> = {
generation: number
process: TProcess
}
export function createBackendConnectionState<TProcess, TConnection>() {
let generation = 0
let process: TProcess | null = null
let promise: Promise<TConnection> | null = null
return {
startAttempt(): BackendConnectionAttempt<TConnection> {
return { generation, promise: null }
},
setPromise(attempt: BackendConnectionAttempt<TConnection>, nextPromise: Promise<TConnection>): boolean {
if (attempt.generation !== generation) {
return false
}
attempt.promise = nextPromise
promise = nextPromise
return true
},
isCurrentAttempt(attempt: BackendConnectionAttempt<TConnection>): boolean {
return attempt.generation === generation
},
attachProcess(
attempt: BackendConnectionAttempt<TConnection>,
nextProcess: TProcess
): BackendProcessOwner<TProcess> | null {
if (attempt.generation !== generation) {
return null
}
process = nextProcess
return { generation, process: nextProcess }
},
clearForCurrentProcess(owner: BackendProcessOwner<TProcess>): boolean {
if (owner.generation !== generation || owner.process !== process) {
return false
}
process = null
promise = null
return true
},
clearPromiseForAttempt(attempt: BackendConnectionAttempt<TConnection>): boolean {
if (attempt.generation !== generation || (promise !== null && attempt.promise !== promise)) {
return false
}
promise = null
return true
},
getProcess(): TProcess | null {
return process
},
getPromise(): Promise<TConnection> | null {
return promise
},
invalidate(): TProcess | null {
const currentProcess = process
generation += 1
process = null
promise = null
return currentProcess
}
}
}
@@ -0,0 +1,202 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it, vi } from 'vitest'
import { BackendDialClaims } from './backend-dial-claim'
import { parseBackendScopeKey } from './connection-registry'
const here = path.dirname(fileURLToPath(import.meta.url))
const mainSource = fs.readFileSync(path.join(here, 'main.ts'), 'utf8').replace(/\r\n/g, '\n')
describe('BackendDialClaims (#90812)', () => {
it('coalesces two concurrent dials for the same (connectionId, profile) onto ONE backend spawn', async () => {
const claims = new BackendDialClaims()
let spawns = 0
let resolveSpawn: ((value: { baseUrl: string }) => void) | undefined
const dial = vi.fn(() => {
spawns += 1
return new Promise<{ baseUrl: string }>(resolve => {
resolveSpawn = resolve
})
})
// Two renderer windows race the same reconnect: reconnectGateway()'s
// in-flight lock is per-renderer, so BOTH invoke the main-process dial.
const first = claims.run('conn:office-ssh::default', dial)
const second = claims.run('conn:office-ssh::default', dial)
expect(spawns).toBe(1)
resolveSpawn?.({ baseUrl: 'http://127.0.0.1:53150' })
const [firstResult, secondResult] = await Promise.all([first, second])
// The second caller receives the FIRST dial's result, not its own spawn.
expect(firstResult).toBe(secondResult)
expect(firstResult).toEqual({ baseUrl: 'http://127.0.0.1:53150' })
expect(dial).toHaveBeenCalledTimes(1)
})
it('scopes claims by key: different (connectionId, profile) pairs dial independently', async () => {
const claims = new BackendDialClaims()
const dialA = vi.fn(async () => 'a')
const dialB = vi.fn(async () => 'b')
const [a, b] = await Promise.all([
claims.run('conn:office-ssh::default', dialA),
claims.run('conn:office-ssh::work', dialB)
])
expect(a).toBe('a')
expect(b).toBe('b')
expect(dialA).toHaveBeenCalledTimes(1)
expect(dialB).toHaveBeenCalledTimes(1)
})
it('releases the claim once the dial settles so a later reconnect can dial again (bounded, not latched)', async () => {
const claims = new BackendDialClaims()
const dial = vi.fn(async () => 'fresh')
await claims.run('default', dial)
expect(claims.inFlight('default')).toBe(false)
await claims.run('default', dial)
expect(dial).toHaveBeenCalledTimes(2)
})
it('propagates a failed dial to every coalesced waiter and never caches the rejection', async () => {
const claims = new BackendDialClaims()
let rejectSpawn: ((error: Error) => void) | undefined
const failingDial = vi.fn(
() =>
new Promise<never>((_resolve, reject) => {
rejectSpawn = reject
})
)
const first = claims.run('conn:office-ssh::default', failingDial)
const second = claims.run('conn:office-ssh::default', failingDial)
expect(failingDial).toHaveBeenCalledTimes(1)
rejectSpawn?.(new Error('ssh dial failed'))
await expect(first).rejects.toThrow('ssh dial failed')
await expect(second).rejects.toThrow('ssh dial failed')
// Fail closed but not latched: the NEXT dial attempt runs fresh.
const recovered = vi.fn(async () => 'recovered')
await expect(claims.run('conn:office-ssh::default', recovered)).resolves.toBe('recovered')
expect(recovered).toHaveBeenCalledTimes(1)
})
it('a synchronously-throwing dial rejects the claim instead of escaping the coalescing seam', async () => {
const claims = new BackendDialClaims()
await expect(
claims.run('default', () => {
throw new Error('spawn refused')
})
).rejects.toThrow('spawn refused')
expect(claims.inFlight('default')).toBe(false)
})
})
describe('parseBackendScopeKey (#90812/#93910)', () => {
it('round-trips the composite pool key back to (connectionId, profile)', () => {
expect(parseBackendScopeKey('conn:office-ssh::default')).toEqual({
connectionId: 'office-ssh',
profile: 'default'
})
expect(parseBackendScopeKey('conn:office-ssh::work')).toEqual({ connectionId: 'office-ssh', profile: 'work' })
})
it('treats a bare profile key as the local/primary scope', () => {
expect(parseBackendScopeKey('default')).toEqual({ connectionId: null, profile: 'default' })
expect(parseBackendScopeKey('work')).toEqual({ connectionId: null, profile: 'work' })
})
})
describe('main.ts wiring for #90812', () => {
it('routes the profile-scoped dial IPC through the single-owner claim', () => {
const handlerStart = mainSource.indexOf("ipcMain.handle('hermes:connection', ")
expect(handlerStart).toBeGreaterThan(-1)
const body = mainSource.slice(handlerStart, handlerStart + 900)
expect(body).toContain('backendDialClaims.run(')
expect(body).toContain('ensureBackend(profile)')
})
it('routes the registry-scoped dial IPC through the claim keyed by backendScopeKey(connectionId, profile)', () => {
const handlerStart = mainSource.indexOf("ipcMain.handle('hermes:connection:for', ")
expect(handlerStart).toBeGreaterThan(-1)
const body = mainSource.slice(handlerStart, handlerStart + 1_200)
expect(body).toContain('backendDialClaims.run(backendScopeKey(id, profile)')
expect(body).toContain('ensureRegistryBackend(id, profile)')
})
// The four IPC/probe surfaces below call ensureRegistryBackend()/ensureBackend()
// directly, bypassing backendDialClaims entirely — so a renderer's guarded
// reconnect dial and one of these can independently race the SAME
// ensureRegistryBackend() await-before-pool-check window (main.ts) and each
// bootstrap its own SSH tunnel / remote dashboard for the same
// (connectionId, profile) scope.
it('routes a media-stream connection resolve through the single-owner claim', () => {
const handlerStart = mainSource.indexOf('resolveRemoteConnection: ({ connectionId, profile }) =>')
expect(handlerStart).toBeGreaterThan(-1)
const body = mainSource.slice(handlerStart, handlerStart + 300)
expect(body).toContain('backendDialClaims.run(backendScopeKey(connectionId, profile)')
expect(body).toContain('ensureRegistryBackend(connectionId, profile)')
expect(body).toContain('ensureBackend(profile)')
})
it('routes a terminal-pane backend resolve through the single-owner claim on both the registry and local branches', () => {
const handlerStart = mainSource.indexOf('async function ensureTerminalBackend(webContentsId: number) {')
expect(handlerStart).toBeGreaterThan(-1)
const body = mainSource.slice(handlerStart, handlerStart + 900)
expect(body).toContain('backendDialClaims.run(backendScopeKey(windowRoute.connectionId, windowRoute.profile)')
expect(body).toContain('ensureRegistryBackend(windowRoute.connectionId, windowRoute.profile)')
expect(body).toContain('backendDialClaims.run(backendScopeKey(null, profile)')
expect(body).toContain('ensureBackend(profile)')
})
it('routes the roster-enumeration probe through the single-owner claim', () => {
const handlerStart = mainSource.indexOf('async function enumerateRegistryAgentSources')
expect(handlerStart).toBeGreaterThan(-1)
const body = mainSource.slice(handlerStart, handlerStart + 3_700)
expect(body).toContain('backendDialClaims.run(backendScopeKey(connection.id, null)')
expect(body).toContain('ensureRegistryBackend(connection.id, null)')
expect(body).toContain("getJsonForBackend(descriptor, '/api/profiles'")
})
it('routes the connections update-all dispatch through the single-owner claim', () => {
const handlerStart = mainSource.indexOf("ipcMain.handle('hermes:connections:update-all',")
expect(handlerStart).toBeGreaterThan(-1)
// The handler grew on main (renderer-side exclusions + the managed-SSH
// dispatch branch) — keep the scan window comfortably past the dial.
const body = mainSource.slice(handlerStart, handlerStart + 3_000)
expect(body).toContain('backendDialClaims.run(backendScopeKey(connection.id, null)')
expect(body).toContain('ensureRegistryBackend(connection.id, null)')
expect(body).toContain("postJsonForBackend(descriptor, '/api/hermes/update'")
})
it('routes every registry-scoped REST dispatch (hermes:api) through the single-owner claim', () => {
const handlerStart = mainSource.indexOf('async function dispatchRegistryApiRequest(')
expect(handlerStart).toBeGreaterThan(-1)
const body = mainSource.slice(handlerStart, handlerStart + 900)
expect(body).toContain('backendDialClaims.run(backendScopeKey(registryConnectionId, routeProfile)')
expect(body).toContain('ensureRegistryBackend(registryConnectionId, routeProfile)')
})
})
@@ -0,0 +1,58 @@
/**
* backend-dial-claim.ts
*
* Single-owner reconnect/dial claim for backend spawns, keyed by the pool
* scope key from backendScopeKey(connectionId, profile) (#90812).
*
* Why this exists: reconnectGateway()'s in-flight lock lives at renderer
* module scope, so it only dedupes reconnects INSIDE one window. Two windows
* (main + a session pop-out) racing the same wake both invoke the main-process
* dial IPC, and for a pooled SSH connection the loser of the pool-entry race
* could bootstrap a duplicate remote backend. Electron main is the single
* owner of backend lifecycles, so the claim belongs here: the first dial for a
* (connectionId, profile) key runs; every concurrent caller for the same key
* awaits and receives that first dial's result.
*
* Bounded by construction: a claim exists only while its dial promise is
* unsettled — both outcomes release it, so a failed dial is never cached and
* the next reconnect attempt runs fresh (fail closed, not latched).
*/
export class BackendDialClaims {
readonly #inflightByKey = new Map<string, Promise<unknown>>()
/** Whether a dial for this key is currently in flight (test/diagnostic seam). */
inFlight(key: string): boolean {
return this.#inflightByKey.has(key)
}
run<T>(key: string, dial: () => Promise<T> | T): Promise<T> {
const existing = this.#inflightByKey.get(key) as Promise<T> | undefined
if (existing) {
return existing
}
// Start the dial eagerly so the first caller's spawn is already in flight
// when a concurrent caller arrives; a synchronously-throwing dial is
// converted into a rejection of THIS claim so it cannot bypass the seam.
let pending: Promise<T>
try {
pending = Promise.resolve(dial())
} catch (error) {
pending = Promise.reject(error)
}
const release = () => {
if (this.#inflightByKey.get(key) === pending) {
this.#inflightByKey.delete(key)
}
}
this.#inflightByKey.set(key, pending)
// Release on both outcomes without creating an unhandled rejected branch.
void pending.then(release, release)
return pending
}
}
+191
View File
@@ -0,0 +1,191 @@
import assert from 'node:assert/strict'
import path from 'node:path'
import { test } from 'vitest'
import {
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
hermesManagedNodePathEntries,
normalizeHermesHomeRoot,
pathEnvKey,
POSIX_SANE_PATH_ENTRIES
} from './backend-env'
test('desktop backend PATH adds Hermes-managed bins and missing POSIX sane entries', () => {
const result = buildDesktopBackendPath({
hermesHome: '/Users/test/.hermes',
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
currentPath: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin',
platform: 'darwin',
pathModule: path.posix
})
const entries = result.split(':')
// Both managed-Node layouts lead, POSIX-native shape first, then the venv.
assert.deepEqual(entries.slice(0, 3), [
'/Users/test/.hermes/node/bin',
'/Users/test/.hermes/node',
'/Users/test/.hermes/hermes-agent/venv/bin'
])
assert.ok(entries.includes('/opt/homebrew/bin'), 'Apple Silicon Homebrew bin is added')
assert.ok(entries.includes('/opt/homebrew/sbin'), 'Apple Silicon Homebrew sbin is added')
assert.ok(entries.includes('/usr/local/sbin'), 'missing standard sbin is added')
for (const expected of POSIX_SANE_PATH_ENTRIES) {
assert.ok(entries.includes(expected), `${expected} should be present`)
}
})
test('managed Node dirs lead with the platform-native layout but always offer both', () => {
const posix = hermesManagedNodePathEntries('/Users/test/.hermes', {
platform: 'darwin',
pathModule: path.posix
})
const windows = hermesManagedNodePathEntries('C:\\Users\\test\\AppData\\Local\\hermes', {
platform: 'win32',
pathModule: path.win32
})
// install.sh uses node/bin; install.ps1 unpacks node.exe into node\ itself.
// Both shapes are always emitted so migrated installs keep resolving.
assert.deepEqual(posix, ['/Users/test/.hermes/node/bin', '/Users/test/.hermes/node'])
assert.deepEqual(windows, [
'C:\\Users\\test\\AppData\\Local\\hermes\\node',
'C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin'
])
})
test('managed Node dirs are empty without a Hermes home', () => {
assert.deepEqual(hermesManagedNodePathEntries(undefined, { platform: 'darwin', pathModule: path.posix }), [])
assert.deepEqual(hermesManagedNodePathEntries('', { platform: 'win32', pathModule: path.win32 }), [])
})
test('every managed Node dir outranks the inherited PATH on both platforms', () => {
for (const [platform, pathModule, home, inherited, delimiter] of [
['darwin', path.posix, '/Users/test/.hermes', '/usr/local/bin:/usr/bin', ':'],
['win32', path.win32, 'C:\\hermes', 'C:\\Program Files\\nodejs;C:\\Windows\\System32', ';']
] as const) {
const entries = buildDesktopBackendPath({
hermesHome: home,
venvRoot: null,
currentPath: inherited,
platform,
pathModule
}).split(delimiter)
const managed = hermesManagedNodePathEntries(home, { platform, pathModule })
const firstInherited = Math.min(...inherited.split(delimiter).map(entry => entries.indexOf(entry)))
for (const dir of managed) {
assert.ok(
entries.indexOf(dir) >= 0 && entries.indexOf(dir) < firstInherited,
`${dir} must precede the inherited PATH on ${platform}`
)
}
}
})
test('desktop backend PATH preserves first occurrence and avoids duplicates', () => {
const result = buildDesktopBackendPath({
hermesHome: '/Users/test/.hermes',
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
currentPath: '/opt/homebrew/bin:/usr/bin:/opt/homebrew/bin:/bin',
platform: 'darwin',
pathModule: path.posix
})
const entries = result.split(':')
assert.equal(entries.filter(entry => entry === '/opt/homebrew/bin').length, 1)
assert.ok(
entries.indexOf('/opt/homebrew/bin') < entries.indexOf('/opt/homebrew/sbin'),
'existing Homebrew bin keeps its precedence over appended missing sane entries'
)
})
test('buildDesktopBackendEnv extends PYTHONPATH and backend PATH together', () => {
const env = buildDesktopBackendEnv({
hermesHome: '/Users/test/.hermes',
pythonPathEntries: ['/repo/hermes-agent'],
venvRoot: '/Users/test/.hermes/hermes-agent/venv',
currentEnv: {
PATH: '/usr/bin:/bin',
PYTHONPATH: '/existing/pythonpath'
},
platform: 'darwin',
pathModule: path.posix
})
assert.equal(env.PYTHONPATH, '/repo/hermes-agent:/existing/pythonpath')
assert.ok(
env.PATH.startsWith(
'/Users/test/.hermes/node/bin:/Users/test/.hermes/node:/Users/test/.hermes/hermes-agent/venv/bin:'
)
)
assert.ok(env.PATH.includes('/opt/homebrew/bin'))
})
test('buildDesktopBackendEnv forces PYTHONUTF8 unless the user set it explicitly', () => {
const defaulted = buildDesktopBackendEnv({
hermesHome: '/Users/test/.hermes',
currentEnv: { PATH: '/usr/bin' },
platform: 'darwin',
pathModule: path.posix
})
assert.equal(defaulted.PYTHONUTF8, '1')
const optedOut = buildDesktopBackendEnv({
hermesHome: '/Users/test/.hermes',
currentEnv: { PATH: '/usr/bin', PYTHONUTF8: '0' },
platform: 'darwin',
pathModule: path.posix
})
assert.equal(optedOut.PYTHONUTF8, '0')
})
test('normalizeHermesHomeRoot maps profile homes back to the global Hermes root', () => {
assert.equal(
normalizeHermesHomeRoot('/Users/test/.hermes/profiles/oracle', { pathModule: path.posix }),
'/Users/test/.hermes'
)
assert.equal(
normalizeHermesHomeRoot('C:\\Users\\test\\AppData\\Local\\hermes\\profiles\\oracle', { pathModule: path.win32 }),
'C:\\Users\\test\\AppData\\Local\\hermes'
)
assert.equal(normalizeHermesHomeRoot('/Users/test/.hermes', { pathModule: path.posix }), '/Users/test/.hermes')
})
test('Windows PATH casing and delimiter are preserved without POSIX sane entries', () => {
const env = buildDesktopBackendEnv({
hermesHome: 'C:\\Users\\test\\AppData\\Local\\hermes',
pythonPathEntries: ['C:\\repo\\hermes-agent'],
venvRoot: 'C:\\Users\\test\\AppData\\Local\\hermes\\hermes-agent\\venv',
currentEnv: {
Path: 'C:\\Windows\\System32;C:\\Windows',
PYTHONPATH: 'C:\\existing\\pythonpath'
},
platform: 'win32',
pathModule: path.win32
})
assert.equal(pathEnvKey({ Path: 'x' }, 'win32'), 'Path')
assert.equal(env.PATH, undefined)
// Windows leads with the portable layout (install.ps1 unpacks node.exe
// straight into node\, no bin\), then the POSIX shape for migrated installs.
assert.ok(
env.Path.startsWith(
'C:\\Users\\test\\AppData\\Local\\hermes\\node;C:\\Users\\test\\AppData\\Local\\hermes\\node\\bin;'
)
)
assert.ok(env.Path.includes('\\venv\\Scripts;'))
assert.ok(env.Path.includes(';C:\\Windows\\System32;C:\\Windows'))
assert.equal(env.Path.includes('/opt/homebrew/bin'), false)
})
test('appendUniquePathEntries drops empty entries and keeps first occurrence', () => {
assert.equal(appendUniquePathEntries([':/a::/b', ['/a', '/c']], { delimiter: ':' }), '/a:/b:/c')
})
+161
View File
@@ -0,0 +1,161 @@
import path from 'node:path'
// Match the POSIX fallback surface used by the Python terminal environment.
// macOS apps launched from Finder/Dock often inherit only /usr/bin:/bin:/usr/sbin:/sbin,
// which misses Apple Silicon Homebrew and user-installed CLI tools such as codex.
const POSIX_SANE_PATH_ENTRIES = Object.freeze([
'/opt/homebrew/bin',
'/opt/homebrew/sbin',
'/usr/local/sbin',
'/usr/local/bin',
'/usr/sbin',
'/usr/bin',
'/sbin',
'/bin'
])
function delimiterForPlatform(platform = process.platform) {
return platform === 'win32' ? ';' : ':'
}
function pathModuleForPlatform(platform = process.platform) {
return platform === 'win32' ? path.win32 : path.posix
}
function pathEnvKey(env = process.env, platform = process.platform) {
if (platform !== 'win32') {
return 'PATH'
}
return Object.keys(env || {}).find(key => key.toUpperCase() === 'PATH') || 'PATH'
}
function currentPathValue(env = process.env, platform = process.platform) {
const key = pathEnvKey(env, platform)
return env?.[key] || ''
}
function appendUniquePathEntries(entries, { delimiter = path.delimiter } = {}) {
const seen = new Set()
const ordered = []
for (const entry of entries) {
if (!entry) {
continue
}
const parts = Array.isArray(entry) ? entry : String(entry).split(delimiter)
for (const part of parts) {
if (!part || seen.has(part)) {
continue
}
seen.add(part)
ordered.push(part)
}
}
return ordered.join(delimiter)
}
/**
* Hermes-managed Node.js directories, in preferred lookup order.
*
* There are two on-disk layouts. `scripts/install.ps1` unpacks portable Node
* straight into `%LOCALAPPDATA%\hermes\node` (node.exe at the root, no `bin\`);
* `scripts/install.sh` and the node-bootstrap helper use the POSIX
* `$HERMES_HOME/node/bin`. Emit BOTH on every platform so mixed and migrated
* installs resolve, leading with the layout native to the current platform.
*
* This is the single source of truth for the ordering rule on the Node side —
* `main.ts` imports it rather than keeping its own copy. Mirrors
* `iter_hermes_node_dirs()` in hermes_constants.py, which the Electron main
* process cannot import.
*/
function hermesManagedNodePathEntries(
hermesHome,
{ platform = process.platform, pathModule = pathModuleForPlatform(platform) }: any = {}
) {
if (!hermesHome) {
return []
}
const root = pathModule.join(hermesHome, 'node')
const bin = pathModule.join(root, 'bin')
return platform === 'win32' ? [root, bin] : [bin, root]
}
function buildDesktopBackendPath({
hermesHome,
venvRoot,
currentPath = '',
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
}: any = {}) {
const delimiter = delimiterForPlatform(platform)
const hermesNodeDirs = hermesManagedNodePathEntries(hermesHome, { platform, pathModule })
const venvBin = venvRoot ? pathModule.join(venvRoot, platform === 'win32' ? 'Scripts' : 'bin') : null
const saneEntries = platform === 'win32' ? [] : POSIX_SANE_PATH_ENTRIES
return appendUniquePathEntries([hermesNodeDirs, venvBin, currentPath, saneEntries], { delimiter })
}
function normalizeHermesHomeRoot(hermesHome, { pathModule = pathModuleForPlatform(process.platform) }: any = {}) {
if (!hermesHome) {
return hermesHome
}
const resolved = pathModule.resolve(String(hermesHome))
const parent = pathModule.dirname(resolved)
if (pathModule.basename(parent).toLowerCase() === 'profiles') {
return pathModule.dirname(parent)
}
return resolved
}
function buildDesktopBackendEnv({
hermesHome,
pythonPathEntries = [],
venvRoot,
currentEnv = process.env,
platform = process.platform,
pathModule = pathModuleForPlatform(platform)
}: any = {}) {
const delimiter = delimiterForPlatform(platform)
const currentPythonPath = currentEnv?.PYTHONPATH || ''
const key = pathEnvKey(currentEnv, platform)
return {
PYTHONPATH: appendUniquePathEntries([...pythonPathEntries, currentPythonPath], { delimiter }),
// Force PEP 540 UTF-8 mode in the spawned Python backend so its stdio and
// subprocess defaults are UTF-8 even on non-UTF-8 Windows locales (GBK,
// cp1252, ...). hermes_bootstrap sets this inside the child too, but only
// after import — anything emitted earlier (interpreter startup errors,
// pre-bootstrap tracebacks) still decodes with the locale default without
// this. User's explicit setting wins. Re-port of PR #56499 (echoriver89).
PYTHONUTF8: currentEnv?.PYTHONUTF8 ?? '1',
[key]: buildDesktopBackendPath({
hermesHome,
venvRoot,
currentPath: currentPathValue(currentEnv, platform),
platform,
pathModule
})
}
}
export {
appendUniquePathEntries,
buildDesktopBackendEnv,
buildDesktopBackendPath,
delimiterForPlatform,
hermesManagedNodePathEntries,
normalizeHermesHomeRoot,
pathEnvKey,
POSIX_SANE_PATH_ENTRIES
}
@@ -0,0 +1,541 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import {
DEFAULT_HEALTH_PROBE_TIMEOUT_MS,
isAuthRejectionError,
isGatedMissingHealthError,
isMissingHealthEndpointError,
isNousCloudAgentUrl,
isReauthRequiredError,
isServerSideHttpError,
makeNousCloudBackendDownError,
makeUnsignedOauthError,
waitForHermesReady
} from './backend-health'
const GATE_401 = '401: {"error":"unauthenticated","detail":"Unauthorized","reason":"no_cookie","login_url":"/login"}'
test('uses lightweight /api/health for current backends', async () => {
const calls: string[][] = []
await waitForHermesReady('http://127.0.0.1:9000/', {
token: 'secret-token',
fetchPublicJson: async url => {
calls.push(['public', url])
return { ok: true }
},
fetchJson: async url => {
calls.push(['token', url])
throw new Error('status should not be called')
},
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
})
assert.deepEqual(calls, [['public', 'http://127.0.0.1:9000/api/health']])
})
test('falls back to /api/status only for old backends without /api/health', async () => {
const calls: string[][] = []
await waitForHermesReady('http://127.0.0.1:9000', {
token: 'secret-token',
fetchPublicJson: async url => {
calls.push(['public', url])
throw new Error('404: {"detail":"Not Found"}')
},
fetchJson: async (url, token) => {
calls.push(['token', url, token ?? ''])
return { version: 'old' }
},
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
})
assert.deepEqual(calls, [
['public', 'http://127.0.0.1:9000/api/health'],
['token', 'http://127.0.0.1:9000/api/status', 'secret-token']
])
})
test('does not fall back to heavyweight /api/status for transient health failures', async () => {
const calls: string[][] = []
let currentTime = 0
await assert.rejects(
waitForHermesReady('http://127.0.0.1:9000', {
fetchPublicJson: async url => {
calls.push(['public', url])
throw new Error('Timed out connecting to Hermes backend after 15000ms')
},
fetchJson: async url => {
calls.push(['token', url])
},
sleep: async () => {},
now: () => {
currentTime += 20
return currentTime
},
timeoutMs: 50,
pollMs: 1
}),
/Timed out connecting/
)
assert.ok(calls.length > 0)
assert.ok(calls.every(call => call[0] === 'public' && call[1].endsWith('/api/health')))
})
test('probes health on a short timeout but leaves the legacy fallback its own', async () => {
const timeouts: (number | undefined)[] = []
await waitForHermesReady('http://127.0.0.1:9000', {
fetchPublicJson: async (_url, options) => {
timeouts.push(options?.timeoutMs)
throw new Error('404: {"detail":"Not Found"}')
},
fetchJson: async (_url, _token, options) => {
timeouts.push(options?.timeoutMs)
return { version: 'old' }
},
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
})
assert.deepEqual(timeouts, [DEFAULT_HEALTH_PROBE_TIMEOUT_MS, undefined])
})
test('aborts as superseded when the bootstrap signal fires', async () => {
const controller = new AbortController()
controller.abort()
await assert.rejects(
waitForHermesReady('http://127.0.0.1:9000', {
signal: controller.signal,
fetchPublicJson: async () => {
throw new Error('should not probe after abort')
},
fetchJson: async () => {
throw new Error('should not probe after abort')
},
timeoutMs: 100,
pollMs: 1
}),
(error: any) => error.kind === 'superseded'
)
})
test('recognizes missing-route shapes only', () => {
assert.equal(isMissingHealthEndpointError(new Error('404: {"detail":"Not Found"}')), true)
assert.equal(
isMissingHealthEndpointError(
new Error('Expected JSON from /api/health but got HTML. The endpoint is likely missing on the Hermes backend.')
),
true
)
assert.equal(isMissingHealthEndpointError(new Error('Timed out connecting to Hermes backend after 15000ms')), false)
assert.equal(isMissingHealthEndpointError(new Error('500: boom')), false)
})
// --- Gated backends that predate /api/health (release 0.19.0 and earlier) ---
//
// The dashboard auth gate runs ahead of the SPA catch-all, so on a backend
// without the route an ANONYMOUS probe is rejected as unauthenticated rather
// than 404 — verified against a simulated 0.19.0 backend:
// credential-free: /api/health -> 401 no_cookie, /api/status -> 200
// credentialed: /api/health -> 404, /api/sessions -> 200
test('anonymous gate-shaped 401 falls back to /api/status (backend predates /api/health)', async () => {
const calls: string[][] = []
await waitForHermesReady('http://192.168.1.132:9119', {
token: null,
fetchPublicJson: async url => {
calls.push(['public', url])
throw new Error(GATE_401)
},
fetchJson: async (url, token) => {
calls.push(['token', url, token == null ? 'null' : token])
return { version: '0.19.0', auth_required: true }
},
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
})
assert.deepEqual(calls, [
['public', 'http://192.168.1.132:9119/api/health'],
['token', 'http://192.168.1.132:9119/api/status', 'null']
])
})
test('a credentialed 401 fails fast for reauth instead of reporting a dead session ready', async () => {
// The regression a blanket 401->fallback introduces: /api/status is public,
// so an expired session would answer 200 and boot would report "ready",
// deferring the no_cookie to the first real API call.
const calls: string[][] = []
await assert.rejects(
waitForHermesReady('https://gateway.example', {
token: 'session-token',
fetchPublicJson: async () => {
throw new Error('public probe must not be used when credentialed')
},
fetchJson: async url => {
calls.push(['status', url])
return { version: '0.19.0' }
},
probeHealth: async url => {
calls.push(['probe', url])
throw new Error(GATE_401)
},
probeIsCredentialed: true,
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
}),
(error: any) => {
assert.equal(isReauthRequiredError(error), true)
assert.equal(error.needsOauthLogin, true)
assert.match(error.message, /remote gateway session has expired/i)
return true
}
)
// Fail fast: never reached the public /api/status leg.
assert.deepEqual(calls, [['probe', 'https://gateway.example/api/health']])
})
test('unsigned OAuth is a terminal reauth failure; needsOauthLogin alone is not', () => {
// The unsigned-in throw must set isReauthRequired so startHermes latches.
// needsOauthLogin alone (ticket 401/403) stays a Sign-in hint, not a latch —
// a lapsed AT cookie can still rotate from a live RT on the next mint.
const unsigned = makeUnsignedOauthError() as any
assert.equal(unsigned.needsOauthLogin, true)
assert.equal(unsigned.isReauthRequired, true)
assert.equal(isReauthRequiredError(unsigned), true)
assert.match(unsigned.message, /not signed in/i)
assert.equal(isReauthRequiredError({ needsOauthLogin: true }), false)
assert.equal(isReauthRequiredError(new Error('Could not reach the remote Hermes gateway')), false)
})
test('a credentialed 403 is also a terminal reauth failure', async () => {
await assert.rejects(
waitForHermesReady('https://gateway.example', {
fetchPublicJson: async () => ({}),
fetchJson: async () => ({}),
probeHealth: async () => {
throw new Error('403: {"detail":"Forbidden"}')
},
probeIsCredentialed: true,
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
}),
(error: any) => isReauthRequiredError(error)
)
})
test('a credentialed probe still uses the 404 fallback for a genuinely missing route', async () => {
// With credentials the gate lets the request through to the SPA catch-all,
// so an old backend answers a real 404 — that must still fall back, not be
// mistaken for a rejected session.
const calls: string[][] = []
await waitForHermesReady('https://gateway.example', {
token: 'session-token',
fetchPublicJson: async () => {
throw new Error('public probe must not be used when credentialed')
},
fetchJson: async url => {
calls.push(['status', url])
return { version: '0.19.0' }
},
probeHealth: async url => {
calls.push(['probe', url])
throw new Error('404: {"detail":"Not Found"}')
},
probeIsCredentialed: true,
sleep: async () => {},
timeoutMs: 100,
pollMs: 1
})
assert.deepEqual(calls, [
['probe', 'https://gateway.example/api/health'],
['status', 'https://gateway.example/api/status']
])
})
test('a non-gate 401 keeps polling rather than skipping a misconfigured health route', async () => {
const calls: string[][] = []
let currentTime = 0
await assert.rejects(
waitForHermesReady('http://127.0.0.1:9000', {
fetchPublicJson: async url => {
calls.push(['public', url])
throw new Error('401: {"detail":"Unauthorized"}')
},
fetchJson: async url => {
calls.push(['token', url])
},
sleep: async () => {},
now: () => {
currentTime += 20
return currentTime
},
timeoutMs: 50,
pollMs: 1
}),
/401: \{"detail":"Unauthorized"\}/
)
assert.ok(calls.length > 0)
assert.ok(calls.every(call => call[0] === 'public' && call[1].endsWith('/api/health')))
})
test('credentialed 5xx and 429 keep polling — only 401/403 are terminal', async () => {
for (const transient of ['500: boom', '429: {"detail":"Too Many Requests"}']) {
let attempts = 0
let currentTime = 0
await assert.rejects(
waitForHermesReady('https://gateway.example', {
fetchPublicJson: async () => ({}),
fetchJson: async () => ({}),
probeHealth: async () => {
attempts += 1
throw new Error(transient)
},
probeIsCredentialed: true,
sleep: async () => {},
now: () => {
currentTime += 20
return currentTime
},
timeoutMs: 100,
pollMs: 1
}),
(error: any) => isReauthRequiredError(error) === false
)
assert.ok(attempts > 1, `${transient} should have retried, got ${attempts} attempt(s)`)
}
})
test('error-shape predicates', () => {
assert.equal(isGatedMissingHealthError(new Error(GATE_401)), true)
assert.equal(isGatedMissingHealthError(new Error('401: {"detail":"Unauthorized"}')), false)
assert.equal(isGatedMissingHealthError(new Error('404: {"detail":"Not Found"}')), false)
assert.equal(isAuthRejectionError(new Error(GATE_401)), true)
assert.equal(isAuthRejectionError(new Error('403: {"detail":"Forbidden"}')), true)
assert.equal(isAuthRejectionError(new Error('404: {"detail":"Not Found"}')), false)
assert.equal(isAuthRejectionError(new Error('429: slow down')), false)
assert.equal(isAuthRejectionError(new Error('500: boom')), false)
// A gated 401 must NOT be conflated with a missing route by the 404 predicate.
assert.equal(isMissingHealthEndpointError(new Error(GATE_401)), false)
})
test('isServerSideHttpError detects 502/503/504', () => {
// 503 — server-side fault
const result503 = isServerSideHttpError(new Error('503: Service Unavailable'))
assert.ok(result503, 'should detect 503')
assert.equal(result503?.statusCode, 503)
assert.equal(result503?.detail, '503: Service Unavailable')
// 502
const result502 = isServerSideHttpError(new Error('502: Bad Gateway'))
assert.ok(result502, 'should detect 502')
assert.equal(result502?.statusCode, 502)
// 504
const result504 = isServerSideHttpError(new Error('504: Gateway Timeout'))
assert.ok(result504, 'should detect 504')
assert.equal(result504?.statusCode, 504)
// 500 is NOT a server-side HTTP error per our definition (keeps polling)
const result500 = isServerSideHttpError(new Error('500: Internal Server Error'))
assert.equal(result500, null)
// 401/403/404/429 are not server-side faults
assert.equal(isServerSideHttpError(new Error('401: Unauthorized')), null)
assert.equal(isServerSideHttpError(new Error('403: Forbidden')), null)
assert.equal(isServerSideHttpError(new Error('404: Not Found')), null)
assert.equal(isServerSideHttpError(new Error('429: Too Many Requests')), null)
// Non-HTTP errors (timeouts, network failures) don't match the pattern
assert.equal(isServerSideHttpError(new Error('connect ECONNREFUSED')), null)
assert.equal(isServerSideHttpError(null), null)
assert.equal(isServerSideHttpError('503: something'), null) // not an Error
})
test('isNousCloudAgentUrl detects cloud agent hosts', () => {
// Positive cases
assert.equal(isNousCloudAgentUrl('https://ares-3009.agents.nousresearch.com'), true)
assert.equal(isNousCloudAgentUrl('https://ares-3009.agents.nousresearch.com/api/health'), true)
assert.equal(isNousCloudAgentUrl('http://test.agents.nousresearch.com'), true)
// Negative cases
assert.equal(isNousCloudAgentUrl('http://127.0.0.1:9000'), false)
assert.equal(isNousCloudAgentUrl('https://gateway.example.com'), false)
assert.equal(isNousCloudAgentUrl('https://nousresearch.com'), false)
assert.equal(isNousCloudAgentUrl('not-a-url'), false)
})
test('waitForHermesReady surfaces actionable error for cloud agent 503', async () => {
let attempts = 0
const currentTime = { value: 0 }
try {
await waitForHermesReady('https://ares-3009.agents.nousresearch.com', {
fetchPublicJson: async () => {
attempts++
// Always return 503
throw new Error('503: Service Unavailable')
},
fetchJson: async () => {
throw new Error('503: Service Unavailable')
},
sleep: async () => {},
// Advance the mock clock per poll — a frozen now() never crosses the
// deadline and the readiness loop spins forever (hung the whole vitest
// electron project for 20m in CI).
now: () => {
currentTime.value += 20
return currentTime.value
},
timeoutMs: 100,
pollMs: 1
})
assert.fail('should have thrown')
} catch (error: any) {
assert.ok(error.message.includes('Nous Cloud agent'), `unexpected message: ${error.message}`)
assert.ok(error.message.includes('503'), `should mention status code: ${error.message}`)
assert.ok(error.message.includes('portal.nousresearch.com'), `should mention portal: ${error.message}`)
assert.ok(error.message.includes('discord.gg/NousResearch'), `should mention Discord: ${error.message}`)
assert.equal(error.isCloudBackendDown, true)
assert.equal(error.statusCode, 503)
assert.ok(attempts > 1, 'should have retried before failing')
}
})
test('waitForHermesReady does not cloud-wrap non-cloud 503 errors', async () => {
const currentTime = { value: 0 }
try {
await waitForHermesReady('http://127.0.0.1:9000', {
fetchPublicJson: async () => {
throw new Error('503: Service Unavailable')
},
fetchJson: async () => {
throw new Error('503: Service Unavailable')
},
sleep: async () => {},
// Same advancing clock as above — frozen now() = infinite loop.
now: () => {
currentTime.value += 20
return currentTime.value
},
timeoutMs: 100,
pollMs: 1
})
assert.fail('should have thrown')
} catch (error: any) {
// Non-cloud URLs get the generic message
assert.ok(error.message.includes('did not become ready'), `unexpected message: ${error.message}`)
assert.equal(error.isCloudBackendDown, undefined)
}
})
test('isServerSideHttpError detects structured statusCode even when the message is opaque', () => {
const err = new Error('upstream unavailable') as any
err.statusCode = 503
const result = isServerSideHttpError(err)
assert.ok(result)
assert.equal(result?.statusCode, 503)
assert.equal(result?.detail, 'upstream unavailable')
const err502 = new Error('bad gateway') as any
err502.statusCode = 502
assert.equal(isServerSideHttpError(err502)?.statusCode, 502)
const err504 = new Error('gateway timeout') as any
err504.statusCode = 504
assert.equal(isServerSideHttpError(err504)?.statusCode, 504)
})
test('isServerSideHttpError rejects non-Error inputs even with a 503-shaped value', () => {
// The structured path requires an actual Error (the fetch layer attaches
// statusCode to an Error instance); a bare string/null/number must not be
// misclassified by the legacy prefix fallback.
assert.equal(isServerSideHttpError('503: something'), null)
assert.equal(isServerSideHttpError({ statusCode: 503 }), null)
assert.equal(isServerSideHttpError(null), null)
assert.equal(isServerSideHttpError(503), null)
})
test('isServerSideHttpError structured path excludes 500/401/403/404/429 even when statusCode is attached', () => {
for (const code of [500, 401, 403, 404, 429]) {
const err = new Error(`HTTP ${code}`) as any
err.statusCode = code
assert.equal(isServerSideHttpError(err), null, `should reject statusCode ${code}`)
}
})
test('makeNousCloudBackendDownError produces the Cloud shape and preserves cause', () => {
const err = new Error('upstream unavailable') as any
err.statusCode = 503
const result = makeNousCloudBackendDownError('https://ares-3009.agents.nousresearch.com', err)
assert.ok(result)
assert.equal((result as any).isCloudBackendDown, true)
assert.equal((result as any).statusCode, 503)
assert.equal((result as any).cause, err)
assert.ok(result?.message.includes('Nous Cloud agent ares-3009.agents.nousresearch.com is down'))
})
test('makeNousCloudBackendDownError returns null for a Cloud 401 (routes to reauth)', () => {
const err = new Error('Unauthorized') as any
err.statusCode = 401
assert.equal(makeNousCloudBackendDownError('https://ares-3009.agents.nousresearch.com', err), null)
})
test('makeNousCloudBackendDownError returns null for a non-Cloud 503 (generic remote failure)', () => {
const err = new Error('Service Unavailable') as any
err.statusCode = 503
assert.equal(makeNousCloudBackendDownError('https://gateway.example.com', err), null)
assert.equal(makeNousCloudBackendDownError('http://127.0.0.1:9000', err), null)
})
test('makeNousCloudBackendDownError preserves legacy string-prefix compatibility', () => {
const result = makeNousCloudBackendDownError(
'https://ares-3009.agents.nousresearch.com',
new Error('503: Service Unavailable')
)
assert.ok(result)
assert.equal((result as any).isCloudBackendDown, true)
assert.equal((result as any).statusCode, 503)
})
+317
View File
@@ -0,0 +1,317 @@
export const DEFAULT_BACKEND_READY_TIMEOUT_MS = 45_000
export const DEFAULT_BACKEND_READY_POLL_MS = 500
// A cold backend can stall its event loop for tens of seconds while Windows
// scans and byte-compiles the gateway import tree. At the default 15s socket
// timeout only three probes fit in the budget; a short one keeps retrying
// across the stall. Health only — the legacy /api/status fallback is genuinely
// slow to answer and keeps the caller's default timeout.
export const DEFAULT_HEALTH_PROBE_TIMEOUT_MS = 5_000
type FetchPublicJson = (url: string, options?: { timeoutMs?: number }) => Promise<unknown>
type FetchJson = (url: string, token?: string | null, options?: { timeoutMs?: number }) => Promise<unknown>
export interface HermesReadyOptions {
fetchPublicJson: FetchPublicJson
fetchJson: FetchJson
token?: string | null
signal?: AbortSignal
timeoutMs?: number
pollMs?: number
healthProbeTimeoutMs?: number
sleep?: (ms: number) => Promise<void>
now?: () => number
/**
* Credentialed health probe. When supplied, readiness is probed with the
* connection's own credentials instead of anonymously — which is what lets
* a gated backend answer 404 for a genuinely missing /api/health, and what
* makes a 401 from this probe mean "session rejected" rather than "route
* behind a gate". Defaults to the credential-free `fetchPublicJson`.
*/
probeHealth?: (url: string, options?: { timeoutMs?: number }) => Promise<unknown>
/**
* Whether `probeHealth` actually presents credentials. Distinguishes the
* two very different meanings of a 401 (see `waitForHermesReady`).
*/
probeIsCredentialed?: boolean
}
export const REMOTE_SESSION_EXPIRED_MESSAGE =
'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.'
export const REMOTE_UNSIGNED_OAUTH_MESSAGE =
'Remote Hermes gateway uses OAuth, but you are not signed in. ' +
'Open Settings → Gateway and click "Sign in", or switch back to Local.'
/**
* True for HTTP 502/503/504 from the backend — a server-side fault, not a
* connectivity or auth issue. These keep polling in the readiness loop but,
* when they exhaust the budget, the user needs to know it is the remote
* server that is down, not their local config.
*/
export function isServerSideHttpError(error: unknown): {
statusCode: number
detail: string
} | null {
// Reject non-Error inputs, as before. The fetch layer attaches statusCode to
// an actual Error instance (err.statusCode = statusCode), so requiring an
// Error is compatible with structured detection and keeps plain strings /
// null / numbers from being misclassified by the legacy prefix.
if (!(error instanceof Error)) {
return null
}
// Structured-first: the real fetch layer attaches err.statusCode = statusCode
// (see fetchJson). That is the strongest transport contract, so inspect it
// before falling back to the legacy "503: ..." string prefix.
if ('statusCode' in error) {
const structured = Number((error as { statusCode?: unknown }).statusCode)
if (Number.isInteger(structured) && (structured === 502 || structured === 503 || structured === 504)) {
const detail = error.message
return { statusCode: structured, detail }
}
}
// Compatibility fallback: the legacy leading "503: ..." prefix. Only reached
// when no structured statusCode matched (or was absent).
const message = error.message
const match = /^(\d{3}):/.exec(message)
if (!match) {
return null
}
const code = parseInt(match[1], 10)
if (code === 502 || code === 503 || code === 504) {
return { statusCode: code, detail: message }
}
return null
}
/**
* The one factory for the actionable Nous Cloud agent-is-down error, shared by
* both startup boundaries that can observe a server-side HTTP fault:
*
* - OAuth WS-ticket mint (buildRemoteConnection → mintGatewayWsTicket), which
* runs BEFORE the readiness loop; and
* - readiness-probe exhaustion in waitForHermesReady().
*
* Returns null unless the backend is a *.agents.nousresearch.com host AND the
* error classifies as 502/503/504. When it matches, returns an error carrying:
* isCloudBackendDown, statusCode, detail, and the original cause. The renderer
* overlay keys on isCloudBackendDown/statusCode; main owns the classification.
*/
export function makeNousCloudBackendDownError(baseUrl: string, error: unknown): Error | null {
if (!isNousCloudAgentUrl(baseUrl)) {
return null
}
const serverError = isServerSideHttpError(error)
if (serverError === null) {
return null
}
let hostname = baseUrl
try {
hostname = new URL(baseUrl).hostname
} catch {
// baseUrl is known to parse (isNousCloudAgentUrl already did); keep the raw
// value as a last resort rather than throwing.
}
const detail = error instanceof Error ? error.message : String(error ?? '')
const err = new Error(
`Nous Cloud agent ${hostname} is down ` +
`(HTTP ${serverError.statusCode}: server-side fault). ` +
'Check https://portal.nousresearch.com for backend status, ' +
'or switch to Local mode in Settings → Gateway. ' +
'You can also reach out on Discord at discord.gg/NousResearch ' +
'for immediate assistance. ' +
`Original detail: ${detail}`
) as any
err.isCloudBackendDown = true
err.statusCode = serverError.statusCode
err.detail = detail
err.cause = error
return err
}
/**
* True when the backend URL points at a Nous-managed Hermes Cloud instance
* (e.g. ares-3009.agents.nousresearch.com). These are Fly.io-hosted machines
* the user cannot restart themselves — a 503 from one means the server is down
* and the recovery path is Portal/Discord/wait.
*/
export function isNousCloudAgentUrl(baseUrl: string): boolean {
try {
const host = new URL(baseUrl).hostname
return host.endsWith('.agents.nousresearch.com')
} catch {
return false
}
}
export function isMissingHealthEndpointError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error ?? '')
return /^404:/.test(message) || message.includes('endpoint is likely missing')
}
/**
* True for a hard auth rejection (401/403) as opposed to a transient failure.
* Deliberately shape-based: 429 is a throttle and 5xx is a server fault, and
* both must keep polling.
*/
export function isAuthRejectionError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error ?? '')
return /^40[13]:/.test(message)
}
/**
* True for an auth rejection carrying the dashboard gate's "no session at all"
* shape. On a backend that predates `/api/health`, the gate runs ahead of the
* SPA catch-all, so an unknown `/api/*` path is rejected as unauthenticated
* instead of 404 — this is the signal that an ANONYMOUS probe cannot reach the
* route, and the reason a credential-free 401 must fall back to `/api/status`
* rather than be reported as a boot failure.
*/
export function isGatedMissingHealthError(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error ?? '')
return isAuthRejectionError(error) && message.includes('no_cookie')
}
/** Tag a terminal reauth failure the main process latches and the overlay keys on. */
export function makeReauthRequiredError(detail?: string): Error {
const error = new Error(REMOTE_SESSION_EXPIRED_MESSAGE) as any
error.needsOauthLogin = true
error.isReauthRequired = true
if (detail) {
error.detail = detail
}
return error
}
/**
* No native token and no live cookie: boot cannot self-heal. Must carry
* `isReauthRequired` so startHermes latches; `needsOauthLogin` alone only
* drives Sign in copy and would retry after #88070, hiding the overlay.
*/
export function makeUnsignedOauthError(): Error {
const error = new Error(REMOTE_UNSIGNED_OAUTH_MESSAGE) as any
error.needsOauthLogin = true
error.isReauthRequired = true
return error
}
export function isReauthRequiredError(error: unknown): boolean {
return Boolean((error as any)?.isReauthRequired)
}
function supersededError() {
const error: any = new Error('SSH bootstrap was superseded by newer connection settings.')
error.kind = 'superseded'
return error
}
export async function waitForHermesReady(baseUrl: string, options: HermesReadyOptions): Promise<void> {
const timeoutMs = options.timeoutMs ?? DEFAULT_BACKEND_READY_TIMEOUT_MS
const pollMs = options.pollMs ?? DEFAULT_BACKEND_READY_POLL_MS
const healthProbeTimeoutMs = options.healthProbeTimeoutMs ?? DEFAULT_HEALTH_PROBE_TIMEOUT_MS
const now = options.now ?? Date.now
const signal = options.signal
const sleep =
options.sleep ??
(ms =>
new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, ms)
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer)
reject(supersededError())
},
{ once: true }
)
}))
const base = baseUrl.replace(/\/+$/, '')
const deadline = now() + timeoutMs
const probeHealth = options.probeHealth ?? options.fetchPublicJson
const probeIsCredentialed = Boolean(options.probeIsCredentialed)
let lastError: unknown = null
let useStatusFallback = false
while (now() < deadline) {
if (signal?.aborted) {
throw supersededError()
}
try {
if (useStatusFallback) {
await options.fetchJson(`${base}/api/status`, options.token)
} else {
await probeHealth(`${base}/api/health`, { timeoutMs: healthProbeTimeoutMs })
}
return
} catch (error) {
lastError = error
// A confirmed 401/403 from a CREDENTIALED probe means the session was
// rejected, not that the route is missing. Fail fast into a reauth
// state: falling back to the public /api/status would answer 200 and
// report a dead session as "ready", deferring the failure to the first
// real API call. Applies to the /api/status leg too — it is routed
// through the same credentials.
if (probeIsCredentialed && isAuthRejectionError(error)) {
throw makeReauthRequiredError(error instanceof Error ? error.message : String(error))
}
// An explicitly missing route means the backend predates /api/health.
// So does a gate-shaped 401 on an ANONYMOUS probe: the dashboard auth
// gate runs ahead of the SPA catch-all, so a pre-/api/health backend
// rejects the unknown path as unauthenticated instead of 404 and a
// credential-free probe can never observe the 404. Timeouts, 5xx, 429,
// and non-gate 401s keep polling health.
if (!useStatusFallback && (isMissingHealthEndpointError(error) || isGatedMissingHealthError(error))) {
useStatusFallback = true
continue
}
await sleep(pollMs)
}
}
const detail = lastError instanceof Error ? lastError.message : 'timeout'
// When a Nous-managed cloud agent returns a server-side HTTP error
// (502/503/504), the backend server itself is down — the user cannot
// restart it and the generic "did not become ready" message is opaque.
// Surface an actionable error instead (#85335). This is the SAME factory
// buildRemoteConnection uses at the OAuth WS-ticket-mint boundary, so both
// startup paths produce the identical Cloud-down shape.
const cloudError = makeNousCloudBackendDownError(baseUrl, lastError)
if (cloudError !== null) {
throw cloudError
}
throw new Error(`Hermes backend did not become ready: ${detail}`)
}
@@ -0,0 +1,400 @@
import assert from 'node:assert/strict'
import { test, vi } from 'vitest'
import {
backendCommandMatches,
type BackendIdentity,
createBackendOwnership,
createBackendShutdownCoordinator,
parseBackendOwnership
} from './backend-ownership'
function memoryStore(initial = '') {
let contents = initial
return {
read: () => contents,
value: () => contents,
write: (next: string) => {
contents = next
}
}
}
function identity(overrides: Partial<BackendIdentity> = {}): BackendIdentity {
return {
nonce: 'nonce-42',
pid: 42,
profile: 'default',
startMarker: 'os-start-123',
...overrides
}
}
function ownershipEntry(overrides: Partial<BackendIdentity> = {}) {
return { command: 'hermes serve --port 0', ...identity(overrides) }
}
function stored(entries: object[]): string {
return JSON.stringify({ backends: entries })
}
function deferred() {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
function createOwnership(store = memoryStore(), overrides: Partial<Parameters<typeof createBackendOwnership>[0]> = {}) {
return createBackendOwnership({
matchesIdentity: async () => true,
// Unknown parent (no record / legacy) preserves the pre-parent behaviour.
matchesParent: async () => undefined,
stop: () => {},
store,
...overrides
})
}
test('claim persists the caller-supplied exact identity before resolving', async () => {
const store = memoryStore()
const ownership = createOwnership(store)
const claim = ownershipEntry()
assert.deepEqual(await ownership.claim(claim), claim)
assert.deepEqual(parseBackendOwnership(store.value()), [claim])
})
test('incomplete claims and persisted records are rejected', async () => {
const store = memoryStore(
stored([
ownershipEntry(),
{ ...ownershipEntry({ pid: 43 }), startMarker: '' },
{ ...ownershipEntry({ pid: 44 }), nonce: undefined },
{ ...ownershipEntry({ pid: 45 }), profile: undefined }
])
)
const ownership = createOwnership(store)
await assert.rejects(ownership.claim({ ...ownershipEntry(), startMarker: '' }), /complete process identity/)
assert.deepEqual(parseBackendOwnership(store.value()), [ownershipEntry()])
})
test('failed persistence awaits asynchronous cleanup of the exact identity', async () => {
const cleanup = deferred()
const stop = vi.fn(() => cleanup.promise)
const expected = new Error('disk full')
const claim = ownershipEntry({ pid: 43 })
const ownership = createOwnership(memoryStore(), {
stop,
store: {
read: () => null,
write: () => {
throw expected
}
}
})
let rejected = false
const result = ownership.claim(claim).catch(error => {
rejected = true
throw error
})
await Promise.resolve()
assert.equal(rejected, false)
assert.deepEqual(stop.mock.calls, [[claim]])
cleanup.resolve()
await assert.rejects(result, expected)
assert.equal(rejected, true)
})
test('startup reap drops a confirmed PID reuse mismatch without stopping it', async () => {
const entry = ownershipEntry()
const store = memoryStore(stored([entry]))
const matchesIdentity = vi.fn(async () => false)
const stop = vi.fn()
const ownership = createOwnership(store, { matchesIdentity, stop })
assert.deepEqual(await ownership.reapOrphans(), [])
assert.deepEqual(matchesIdentity.mock.calls, [[entry]])
assert.equal(stop.mock.calls.length, 0)
assert.deepEqual(parseBackendOwnership(store.value()), [])
})
test('startup reap preserves records when exact identity probing is uncertain or fails', async () => {
const uncertain = ownershipEntry({ pid: 50, nonce: 'uncertain' })
const failed = ownershipEntry({ pid: 51, nonce: 'failed' })
const store = memoryStore(stored([uncertain, failed]))
const stop = vi.fn()
const ownership = createOwnership(store, {
matchesIdentity: async entry => {
if (entry.pid === failed.pid) {
throw new Error('process table unavailable')
}
return undefined
},
stop
})
assert.deepEqual(await ownership.reapOrphans(), [])
assert.equal(stop.mock.calls.length, 0)
assert.deepEqual(parseBackendOwnership(store.value()), [uncertain, failed])
})
test('startup reap passes the full confirmed identity to stop', async () => {
const entry = ownershipEntry({ pid: 52 })
const store = memoryStore(stored([entry]))
const stop = vi.fn()
const ownership = createOwnership(store, { stop })
assert.deepEqual(await ownership.reapOrphans(), [52])
assert.deepEqual(stop.mock.calls, [[entry]])
assert.deepEqual(parseBackendOwnership(store.value()), [])
})
test('startup reap preserves failed stops for the next launch', async () => {
const entry = ownershipEntry({ pid: 53 })
const store = memoryStore(stored([entry]))
const ownership = createOwnership(store, {
stop: () => {
throw new Error('permission denied')
}
})
assert.deepEqual(await ownership.reapOrphans(), [])
assert.deepEqual(parseBackendOwnership(store.value()), [entry])
})
test('startup reap stops at the deadline and preserves the unprocessed records', async () => {
const first = ownershipEntry({ pid: 60 })
const second = ownershipEntry({ pid: 61 })
const store = memoryStore(stored([first, second]))
const stop = vi.fn()
const ownership = createOwnership(store, {
// Each probe is slow enough to blow a 1ms budget after the first entry.
matchesIdentity: async () => {
await new Promise(resolve => setTimeout(resolve, 10))
return false
},
stop,
reapDeadlineMs: 1
})
assert.deepEqual(await ownership.reapOrphans(), [])
// The first entry was processed (dropped); the second was preserved for the
// next launch instead of stalling boot on a slow identity probe.
assert.deepEqual(parseBackendOwnership(store.value()), [second])
})
test('startup reap preserves would-be-reaped records when the budget runs out', async () => {
const first = ownershipEntry({ pid: 62 })
const second = ownershipEntry({ pid: 63 })
const store = memoryStore(stored([first, second]))
const stop = vi.fn()
const ownership = createOwnership(store, {
matchesIdentity: async () => {
await new Promise(resolve => setTimeout(resolve, 10))
return true
},
stop,
reapDeadlineMs: 1
})
assert.deepEqual(await ownership.reapOrphans(), [62])
// The second would have been reaped too, but the budget ran out — it is
// preserved so a later launch retries it.
assert.deepEqual(parseBackendOwnership(store.value()), [second])
})
test('startup reap never stops a backend whose parent Electron is still alive', async () => {
const entry = { ...ownershipEntry({ pid: 54 }), parentPid: 100, parentStartMarker: 'os-start-parent' }
const store = memoryStore(stored([entry]))
const stop = vi.fn()
const ownership = createOwnership(store, {
matchesParent: async () => true,
stop
})
assert.deepEqual(await ownership.reapOrphans(), [])
assert.equal(stop.mock.calls.length, 0)
assert.deepEqual(parseBackendOwnership(store.value()), [entry])
})
test('startup reap still reaps a backend whose parent is gone or reused', async () => {
const gone = { ...ownershipEntry({ pid: 55 }), parentPid: 200, parentStartMarker: 'os-start-dead' }
const reused = { ...ownershipEntry({ pid: 56 }), parentPid: 201, parentStartMarker: 'os-start-old' }
const store = memoryStore(stored([gone, reused]))
const stop = vi.fn()
const ownership = createOwnership(store, {
matchesParent: async entry => (entry.parentPid === 201 ? true : false),
stop
})
assert.deepEqual(await ownership.reapOrphans(), [55])
assert.deepEqual(stop.mock.calls, [[gone]])
assert.deepEqual(parseBackendOwnership(store.value()), [reused])
})
test('startup reap preserves a record when parent liveness probing fails', async () => {
const entry = { ...ownershipEntry({ pid: 57 }), parentPid: 300, parentStartMarker: 'os-start-parent' }
const store = memoryStore(stored([entry]))
const stop = vi.fn()
const ownership = createOwnership(store, {
matchesParent: async () => {
throw new Error('process table unavailable')
},
stop
})
assert.deepEqual(await ownership.reapOrphans(), [])
assert.equal(stop.mock.calls.length, 0)
assert.deepEqual(parseBackendOwnership(store.value()), [entry])
})
test('claim persists the parent identity so a later reap can see it', async () => {
const store = memoryStore()
const ownership = createOwnership(store)
const claim = { ...ownershipEntry(), parentPid: 42, parentStartMarker: 'os-start-parent' }
const entry = await ownership.claim(claim)
assert.equal(entry.parentPid, 42)
assert.equal(entry.parentStartMarker, 'os-start-parent')
assert.deepEqual(parseBackendOwnership(store.value())[0].parentPid, 42)
assert.deepEqual(parseBackendOwnership(store.value())[0].parentStartMarker, 'os-start-parent')
})
test('release removes only the exact identity rather than every record for its PID', () => {
const oldProcess = ownershipEntry({ nonce: 'old', startMarker: 'start-old' })
const reusedPid = ownershipEntry({ nonce: 'new', startMarker: 'start-new' })
const store = memoryStore(stored([oldProcess, reusedPid]))
const ownership = createOwnership(store)
ownership.release(oldProcess)
assert.deepEqual(parseBackendOwnership(store.value()), [reusedPid])
})
test('backend identity check matches only serve and dashboard invocation shapes', () => {
assert.equal(backendCommandMatches('/venv/bin/hermes serve --port 0'), true)
assert.equal(backendCommandMatches('python -m hermes_cli.main dashboard --no-open'), true)
assert.equal(backendCommandMatches('/venv/bin/hermes --profile work serve --port 0'), true)
assert.equal(backendCommandMatches('"C:\\Hermes Runtime\\hermes.exe" dashboard --no-open'), true)
assert.equal(backendCommandMatches('hermes chat --query serve'), false)
assert.equal(backendCommandMatches('unrelated dashboard'), false)
})
test('shutdown coordinator returns one promise and awaits teardown exactly once', async () => {
const completion = deferred()
const teardown = vi.fn(() => completion.promise)
const coordinator = createBackendShutdownCoordinator(teardown)
const first = coordinator.run()
const second = coordinator.run()
assert.equal(first, second)
assert.equal(coordinator.hasStarted(), true)
await Promise.resolve()
assert.equal(teardown.mock.calls.length, 1)
let finished = false
first.then(() => {
finished = true
})
await Promise.resolve()
assert.equal(finished, false)
completion.resolve()
await second
assert.equal(finished, true)
assert.equal(coordinator.run(), first)
})
// #89298: a corrupt ownership file must never be silently rewritten as [] —
// that permanently erases the only record of still-running backends. The reap
// sweep quarantines the file and skips; a later healthy write recreates it.
test('reapOrphans on a corrupt file quarantines and does not rewrite', async () => {
let contents = '{ this is not json'
let quarantined = 0
const writes: string[] = []
const stopped: number[] = []
const store = {
read: () => contents,
value: () => contents,
write: (next: string) => {
writes.push(next)
contents = next
},
quarantine: () => {
quarantined += 1
}
}
const ownership = createOwnership(store, {
stop: identityArg => {
stopped.push(identityArg.pid)
}
})
assert.deepEqual(await ownership.reapOrphans(), [])
assert.equal(quarantined, 1)
assert.deepEqual(writes, [])
assert.deepEqual(stopped, [])
})
test('reapOrphans on a corrupt file without a quarantine hook still skips the rewrite', async () => {
const writes: string[] = []
const store = {
read: () => 'garbage{{{',
value: () => 'garbage{{{',
write: (next: string) => {
writes.push(next)
}
}
const ownership = createOwnership(store)
assert.deepEqual(await ownership.reapOrphans(), [])
assert.deepEqual(writes, [])
})
test('an empty or missing ownership file is NOT corrupt — reap sweeps normally', async () => {
const writes: string[] = []
const store = {
read: () => '',
value: () => '',
write: (next: string) => {
writes.push(next)
},
quarantine: () => assert.fail('empty file must not be quarantined')
}
const ownership = createOwnership(store)
assert.deepEqual(await ownership.reapOrphans(), [])
// Empty roster: rewriting [] is harmless and keeps the legacy behavior.
assert.equal(writes.length, 1)
})
+336
View File
@@ -0,0 +1,336 @@
export interface BackendIdentity {
nonce: string
pid: number
profile: string
startMarker: string
}
export interface BackendOwnershipEntry extends BackendIdentity {
command?: string
/** PID of the Electron parent that spawned this backend, when known. */
parentPid?: number
/** Start marker of that parent, so a reused PID is not mistaken for it. */
parentStartMarker?: string
}
export interface BackendOwnershipStore {
read: () => string | null
write: (contents: string) => void
/** Move an unreadable ownership file aside (e.g. rename to `.corrupt`) so
* its contents survive for inspection instead of being rewritten away.
* Optional: stores that can't quarantine simply skip the sweep. */
quarantine?: () => void
}
export interface BackendOwnershipDeps {
matchesIdentity: (identity: BackendIdentity) => Promise<boolean | undefined>
/** True when the recorded parent is still running; undefined when unknown. */
matchesParent: (entry: BackendOwnershipEntry) => Promise<boolean | undefined>
stop: (identity: BackendIdentity) => Promise<void> | void
store: BackendOwnershipStore
/**
* Overall time budget for one reap sweep. The ownership file legitimately
* accumulates one record per profile per launch, and each record can cost
* up to two identity probes (parent + backend) plus a stop — on Windows
* those shell out to PowerShell, whose 5.1 cold starts are slow (#87169).
* Without a bound, a large roster could stall boot for minutes while the
* renderer's 45s backend-boot budget expires and the user stares at the
* connecting screen. When the budget is exhausted the sweep preserves the
* unprocessed records for the next launch and returns what it reaped.
*/
reapDeadlineMs?: number
}
/** Default budget for one reap sweep (see `reapDeadlineMs`). */
export const REAP_ORPHANS_DEADLINE_MS = 5_000
export interface BackendClaim extends BackendIdentity {
command?: string
parentPid?: number
parentStartMarker?: string
}
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.length > 0
}
function isCompleteIdentity(value: unknown): value is BackendIdentity {
if (!value || typeof value !== 'object') {
return false
}
const candidate = value as Partial<BackendIdentity>
return (
Number.isInteger(candidate.pid) &&
Number(candidate.pid) > 0 &&
isNonEmptyString(candidate.startMarker) &&
isNonEmptyString(candidate.nonce) &&
isNonEmptyString(candidate.profile)
)
}
function identitiesMatch(left: BackendIdentity, right: BackendIdentity): boolean {
return (
left.pid === right.pid &&
left.startMarker === right.startMarker &&
left.nonce === right.nonce &&
left.profile === right.profile
)
}
export function parseBackendOwnership(contents: unknown): BackendOwnershipEntry[] {
return parseBackendOwnershipDetailed(contents).entries
}
/** Parse result that distinguishes "empty/valid" from "unreadable". A corrupt
* ownership file must NOT read as an empty roster: `reapOrphans` rewrites the
* file with its survivors, so treating garbage as `[]` permanently erased the
* records of still-running backends — the exact shape of the #89298 report
* (ownership file gone, 28 leaked serve processes nothing will ever reap). */
export function parseBackendOwnershipDetailed(contents: unknown): {
corrupt: boolean
entries: BackendOwnershipEntry[]
} {
const text = String(contents ?? '')
if (!text.trim()) {
return { corrupt: false, entries: [] }
}
let parsed: unknown
try {
parsed = JSON.parse(text)
} catch {
return { corrupt: true, entries: [] }
}
const values = Array.isArray(parsed)
? parsed
: parsed && typeof parsed === 'object' && Array.isArray((parsed as { backends?: unknown }).backends)
? (parsed as { backends: unknown[] }).backends
: []
const entries: BackendOwnershipEntry[] = []
for (const value of values) {
if (!isCompleteIdentity(value)) {
continue
}
const candidate = value as BackendOwnershipEntry
const entry: BackendOwnershipEntry = {
nonce: candidate.nonce,
pid: candidate.pid,
profile: candidate.profile,
startMarker: candidate.startMarker
}
if (typeof candidate.command === 'string') {
entry.command = candidate.command
}
if (Number.isInteger(candidate.parentPid) && Number(candidate.parentPid) > 0) {
entry.parentPid = candidate.parentPid
}
if (isNonEmptyString(candidate.parentStartMarker)) {
entry.parentStartMarker = candidate.parentStartMarker
}
if (!entries.some(existing => identitiesMatch(existing, entry))) {
entries.push(entry)
}
}
return { corrupt: false, entries }
}
export function serializeBackendOwnership(entries: BackendOwnershipEntry[]): string {
return `${JSON.stringify({ backends: entries }, null, 2)}\n`
}
/**
* Persistent ownership for local backend roots.
*
* Claiming is asynchronous so a failed persistence transaction can await child
* cleanup before reporting failure to the caller.
*/
export function createBackendOwnership(deps: BackendOwnershipDeps) {
const readDetailed = () => parseBackendOwnershipDetailed(deps.store.read())
const read = () => readDetailed().entries
const write = (entries: BackendOwnershipEntry[]) => deps.store.write(serializeBackendOwnership(entries))
return {
async claim(claim: BackendClaim): Promise<BackendOwnershipEntry> {
if (!isCompleteIdentity(claim)) {
throw new Error('Cannot own a backend without a complete process identity.')
}
const entry: BackendOwnershipEntry = {
nonce: claim.nonce,
pid: claim.pid,
profile: claim.profile,
startMarker: claim.startMarker
}
if (typeof claim.command === 'string') {
entry.command = claim.command
}
if (Number.isInteger(claim.parentPid) && Number(claim.parentPid) > 0) {
entry.parentPid = claim.parentPid
}
if (isNonEmptyString(claim.parentStartMarker)) {
entry.parentStartMarker = claim.parentStartMarker
}
try {
const entries = read().filter(candidate => candidate.pid !== entry.pid)
write([...entries, entry])
} catch (error) {
try {
await deps.stop(entry)
} catch {
// Persistence remains the claim failure even if cleanup also fails.
}
throw error
}
return entry
},
release(identity: BackendIdentity): void {
if (!isCompleteIdentity(identity)) {
throw new Error('Cannot release a backend without a complete process identity.')
}
const entries = read()
const next = entries.filter(entry => !identitiesMatch(entry, identity))
if (next.length !== entries.length) {
write(next)
}
},
async reapOrphans(): Promise<number[]> {
const { corrupt, entries } = readDetailed()
// An unreadable ownership file yields zero parsed entries — rewriting
// survivors ([]) here would DESTROY the only record of any backends the
// corrupt file described, guaranteeing they leak forever (#89298).
// Preserve the evidence for inspection and skip the sweep.
if (corrupt) {
try {
deps.store.quarantine?.()
} catch {
// Quarantine is best-effort; the important part is not rewriting.
}
return []
}
const survivors: BackendOwnershipEntry[] = []
const reaped: number[] = []
const deadline = Date.now() + (deps.reapDeadlineMs ?? REAP_ORPHANS_DEADLINE_MS)
for (let i = 0; i < entries.length; i += 1) {
// Budget exhausted: preserve the unprocessed records so a later launch
// can retry them. A slow identity probe must never stall boot — the
// renderer's backend-boot budget is 45s and the spawn itself needs
// most of it.
if (Date.now() >= deadline) {
survivors.push(...entries.slice(i))
break
}
const entry = entries[i]
// A backend whose Electron parent is still running is NOT an orphan:
// reaping it would kill a live instance's session. This is what stops
// a second launch from SIGTERMing the running instance's backend even
// if it reaches reapOrphans (see main.ts startHermes + #87295).
let parentAlive: boolean | undefined
try {
parentAlive = await deps.matchesParent(entry)
} catch {
survivors.push(entry)
continue
}
if (parentAlive === true) {
survivors.push(entry)
continue
}
let matches: boolean | undefined
try {
matches = await deps.matchesIdentity(entry)
} catch {
survivors.push(entry)
continue
}
if (matches === false) {
continue
}
if (matches !== true) {
survivors.push(entry)
continue
}
try {
await deps.stop(entry)
reaped.push(entry.pid)
} catch {
// Preserve failed ownership so a later startup can retry it.
survivors.push(entry)
}
}
write(survivors)
return reaped
},
clear(): void {
write([])
}
}
}
export function backendCommandMatches(command: unknown): boolean {
return /(?:^|[\s/\\"])(?:hermes(?:\.exe)?|hermes_cli\.main|hermes_cli[/\\]main\.py)"?(?:\s+(?:--profile|-p)\s+\S+)?\s+(?:serve|dashboard)(?:\s|$)/i.test(
String(command ?? '')
)
}
/** Coordinates all quit paths so asynchronous backend teardown runs once. */
export function createBackendShutdownCoordinator(teardown: () => Promise<void> | void) {
let completion: Promise<void> | undefined
return {
run(): Promise<void> {
if (!completion) {
completion = Promise.resolve().then(teardown)
}
return completion
},
hasStarted(): boolean {
return completion !== undefined
}
}
}
@@ -0,0 +1,127 @@
/**
* Tests for electron/backend-probes.ts.
*
* Run with: node --test electron/backend-probes.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
*/
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import {
canImportHermesCli,
DEFAULT_PROBE_TIMEOUT_MS,
hermesRuntimeImportProbe,
PROBE_TIMEOUT_MS,
resolveProbeTimeoutMs,
shouldTrustHermesOverride,
verifyHermesCli
} from './backend-probes'
// Resolve the host's own Node binary -- guaranteed to be on disk and
// runnable. We use it as both a stand-in for "a python that doesn't
// have hermes_cli" (since `node -c "import hermes_cli"` will exit
// non-zero) and as a way to script verifyHermesCli's success path
// (a tiny script we write to disk that exits 0 on --version).
const NODE_BIN = process.execPath
test('canImportHermesCli returns false when path is falsy', () => {
assert.equal(canImportHermesCli(''), false)
assert.equal(canImportHermesCli(null), false)
assert.equal(canImportHermesCli(undefined), false)
})
test('canImportHermesCli returns false when interpreter cannot run -c', () => {
// node IS an interpreter, but `node -c "import hermes_cli"` is a
// SyntaxError -- different exit reason from a real Python's
// ModuleNotFoundError, but the predicate is "exit 0 or not" and
// both land on "not", which is exactly what we want for the
// resolver fall-through.
assert.equal(canImportHermesCli(NODE_BIN), false)
})
test('canImportHermesCli returns false when binary does not exist', () => {
const ghost = path.join(os.tmpdir(), 'hermes-probes-ghost-' + Date.now() + '.exe')
assert.equal(canImportHermesCli(ghost), false)
})
test('hermes runtime import probe checks config dependencies', () => {
const probe = hermesRuntimeImportProbe()
assert.match(probe, /\bimport yaml\b/)
// dotenv is the first third-party import on the CLI boot path
// (hermes_cli/env_loader.py); a mid-update venv missing python-dotenv
// passed the old probe and produced an unrecoverable boot loop.
assert.match(probe, /\bimport dotenv\b/)
assert.match(probe, /\bimport hermes_cli\.config\b/)
})
test('explicit Hermes override is authoritative', () => {
assert.equal(shouldTrustHermesOverride('/nix/store/abc/bin/hermes'), true)
})
test('empty Hermes override is not authoritative', () => {
assert.equal(shouldTrustHermesOverride(''), false)
assert.equal(shouldTrustHermesOverride(undefined), false)
})
test('verifyHermesCli returns false when command is falsy', () => {
assert.equal(verifyHermesCli(''), false)
assert.equal(verifyHermesCli(null), false)
assert.equal(verifyHermesCli(undefined), false)
})
test('verifyHermesCli returns false when binary does not exist', () => {
const ghost = path.join(os.tmpdir(), 'hermes-probes-ghost-' + Date.now() + '.exe')
assert.equal(verifyHermesCli(ghost), false)
})
test('verifyHermesCli returns true when --version exits 0', () => {
// Write a tiny script that exits 0 regardless of args, then invoke
// it through node. This stands in for a working hermes binary --
// verifyHermesCli only cares about the exit code.
const scriptPath = path.join(os.tmpdir(), `hermes-probes-ok-${Date.now()}-${process.pid}.cjs`)
fs.writeFileSync(scriptPath, 'process.exit(0)\n')
try {
// Use node as the launcher and our script as the "command". Pass
// shell:false (default) -- node is a real binary, no shim.
// execFileSync passes ['--version'] as args, which node ignores
// gracefully (well, it prints its version and exits 0, which is
// perfect -- exit code 0 is the only signal we read).
assert.equal(verifyHermesCli(NODE_BIN), true)
} finally {
try {
fs.unlinkSync(scriptPath)
} catch {
void 0
}
}
})
test('verifyHermesCli swallows timeouts (does not throw)', () => {
// We can't easily provoke a real hang in CI without slowing the
// suite, but we CAN confirm that an invocation that DOES throw
// (because the binary is missing) returns false rather than
// propagating. Same code path the timeout case takes.
assert.equal(verifyHermesCli('/definitely/not/a/real/binary/anywhere'), false)
})
test('default probe timeout is 15s (not the old 5s death-loop value)', () => {
assert.equal(DEFAULT_PROBE_TIMEOUT_MS, 15_000)
// Module constant uses process.env at load time; with no override it
// matches the default (tests run without HERMES_PROBE_TIMEOUT_MS).
assert.equal(PROBE_TIMEOUT_MS, DEFAULT_PROBE_TIMEOUT_MS)
})
test('resolveProbeTimeoutMs honours HERMES_PROBE_TIMEOUT_MS', () => {
assert.equal(resolveProbeTimeoutMs({}), DEFAULT_PROBE_TIMEOUT_MS)
assert.equal(resolveProbeTimeoutMs({ HERMES_PROBE_TIMEOUT_MS: '30000' }), 30_000)
assert.equal(resolveProbeTimeoutMs({ HERMES_PROBE_TIMEOUT_MS: '0' }), DEFAULT_PROBE_TIMEOUT_MS)
assert.equal(resolveProbeTimeoutMs({ HERMES_PROBE_TIMEOUT_MS: 'nope' }), DEFAULT_PROBE_TIMEOUT_MS)
// Cap runaway values
assert.equal(resolveProbeTimeoutMs({ HERMES_PROBE_TIMEOUT_MS: '999999' }), 120_000)
})
+221
View File
@@ -0,0 +1,221 @@
/**
* backend-probes.ts
*
* Cheap "does this candidate backend actually work" checks used by
* resolveHermesBackend (main.ts). The resolver walks a ladder of
* candidates -- bootstrap marker, `hermes` on PATH, system Python with
* hermes_cli installed -- and historically returned the first candidate
* whose binary existed on disk. That assumption breaks when a user has
* a pre-installed Python 3.11-3.13 (so findSystemPython() returns a
* path) but no hermes_cli in its site-packages: the resolver hands back
* a backend the spawn step can't actually run, and the user gets a
* dead-on-arrival "ModuleNotFoundError: No module named 'hermes_cli'"
* instead of the first-launch installer.
*
* These probes give the resolver a way to verify a candidate before
* trusting it. Failure (non-zero exit, exception, timeout) means "skip
* this rung, try the next one"; success means "spawn this for real."
* Falling off the bottom of the ladder lands on the bootstrap-needed
* sentinel, which is exactly what we want when nothing pre-existing
* actually works.
*
* Both probes are deliberately fast and forgiving:
* - default 15s timeout (5s was too short on cold Windows disks / AV;
* issue #61764 death-loop) with HERMES_PROBE_TIMEOUT_MS override
* - one automatic retry after a timeout before declaring the runtime dead
* - stdio ignored (we only care about exit code; stdout/stderr are
* not surfaced to the user, just to recentHermesLog for forensics
* via the caller's catch block if it chooses)
* - any throw -> false (never propagate -- resolver wants a boolean)
*
* Kept in a standalone ts module so it can be unit-tested with
* `node --test` without dragging in the electron runtime (same pattern
* as bootstrap-platform.ts and hardening.ts).
*/
import { execFileSync } from 'node:child_process'
/** Default probe budget. 5s false-negativeed healthy Windows cold starts (#61764). */
const DEFAULT_PROBE_TIMEOUT_MS = 15_000
/**
* Resolve the backend probe timeout (ms).
* Honours HERMES_PROBE_TIMEOUT_MS when it parses as a positive integer.
*/
function resolveProbeTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
const raw = env.HERMES_PROBE_TIMEOUT_MS
if (raw == null || raw === '') {
return DEFAULT_PROBE_TIMEOUT_MS
}
const n = Number.parseInt(String(raw), 10)
if (!Number.isFinite(n) || n <= 0) {
return DEFAULT_PROBE_TIMEOUT_MS
}
// Clamp absurd values (ms) so a typo can't hang startup forever.
return Math.min(n, 120_000)
}
const PROBE_TIMEOUT_MS = resolveProbeTimeoutMs()
function isTimeoutError(err: unknown): boolean {
if (!err || typeof err !== 'object') {
return false
}
const e = err as { code?: string; killed?: boolean; signal?: string }
if (e.killed === true) {
return true
}
if (e.code === 'ETIMEDOUT') {
return true
}
// Node marks timed-out execFileSync with SIGTERM on some platforms.
if (e.signal === 'SIGTERM') {
return true
}
return false
}
/**
* Run execFileSync; on timeout only, retry once before failing.
* Non-timeout failures (ENOENT, non-zero exit) fail immediately.
*/
function execProbeSync(
command: string,
args: string[],
options: {
cwd?: string
env?: NodeJS.ProcessEnv
stdio: 'ignore'
timeout: number
shell?: boolean
windowsHide?: boolean
}
): void {
try {
execFileSync(command, args, options)
} catch (err) {
if (!isTimeoutError(err)) {
throw err
}
// One cold-cache / AV miss should not force hermes-setup --update (#61764).
execFileSync(command, args, options)
}
}
/**
* Return the Python snippet used to verify Hermes can import far enough to
* launch the CLI. Kept exported for tests so dependency regressions are
* caught without needing a real broken venv fixture.
*
* @returns {string}
*/
function hermesRuntimeImportProbe() {
return 'import yaml; import dotenv; import hermes_cli.config'
}
/**
* Return true iff the Hermes runtime import probe exits 0.
*
* Used to gate the "fallback to system Python with hermes_cli installed"
* rung of resolveHermesBackend. Without this, a system Python 3.11-3.13
* registered in PEP 514 makes findSystemPython() succeed regardless of
* whether hermes_cli has actually been pip-installed into its
* site-packages -- and the resolver returns a backend that immediately
* dies on spawn.
*
* The probe intentionally imports hermes_cli.config, not just the top-level
* package: a broken/empty Windows launcher venv can still see the source tree
* through PYTHONPATH but lack PyYAML, then die on the first real CLI import.
*
* @param {string} pythonPath - Absolute path to a python.exe / python.
* @param {object} [opts.env] - Additional environment for the probe.
* @returns {boolean}
*/
function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, string> } = {}) {
if (!pythonPath) {
return false
}
try {
execProbeSync(pythonPath, ['-c', hermesRuntimeImportProbe()], {
env: { ...process.env, ...(opts.env || {}) },
stdio: 'ignore',
timeout: PROBE_TIMEOUT_MS,
windowsHide: true
})
return true
} catch {
return false
}
}
/**
* Return true iff `<hermesCommand> --version` exits 0.
*
* Used to gate the "existing `hermes` on PATH" rung. Without this, a
* stale hermes.cmd shim left behind by an uninstalled pip install (or
* a half-built venv whose `hermes` entry-point points at a deleted
* Python) survives findOnPath() and gets selected as the backend.
*
* We intentionally avoid invoking the command with the dashboard args
* here -- `--version` is the cheapest "is this binary alive" smoke
* test that every hermes_cli entry-point has supported since 0.1.
*
* @param {string} hermesCommand - Resolved absolute path to a hermes
* executable (or an interpreter+script wrapper).
* @param {boolean} [opts.shell] - Whether to run through a shell. For
* .cmd/.bat shims on Windows execFileSync needs shell:true to find
* the cmd interpreter; mirrors the same flag isCommandScript() drives
* in resolveHermesBackend.
* @returns {boolean}
*/
/**
* An explicit desktop backend command is a deployment contract, not a PATH
* discovery candidate. In particular, the Nix desktop wrapper points this at
* its immutable, matching Hermes package; it must never fall through to the
* mutable install-script bootstrap path if a best-effort probe is slow.
*/
function shouldTrustHermesOverride(hermesOverride?: string) {
return typeof hermesOverride === 'string' && hermesOverride.trim().length > 0
}
function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
if (!hermesCommand) {
return false
}
try {
execProbeSync(hermesCommand, ['--version'], {
stdio: 'ignore',
timeout: PROBE_TIMEOUT_MS,
shell: Boolean(opts?.shell),
windowsHide: true
})
return true
} catch {
return false
}
}
export {
canImportHermesCli,
DEFAULT_PROBE_TIMEOUT_MS,
execProbeSync,
hermesRuntimeImportProbe,
PROBE_TIMEOUT_MS,
resolveProbeTimeoutMs,
shouldTrustHermesOverride,
verifyHermesCli
}
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { test } from 'vitest'
// ── Regression guard: backend interpreter / site-packages coherence ─────────
//
// Live repro (2026-08-23, macOS dev run): the checkout had BOTH venvs —
// .venv/ → Python 3.12 (dev tooling)
// venv/ → Python 3.11 (the CLI install venv, owns the real deps)
//
// findPythonForRoot() prefers `.venv/bin/python` (3.12), but
// createPythonBackend() hardcodes `venvRoot = path.join(root, 'venv')` and
// puts venv/lib/python3.11/site-packages on PYTHONPATH. The 3.12 interpreter
// then imports 3.11-compiled native wheels and dies on the FIRST import:
// ImportError: No module named 'pydantic_core._pydantic_core'
// → backend exits(1) before ready → "Gateway offline" → renderer falls back
// to a dead 127.0.0.1:9119 and every profile fails to activate.
//
// The invariant these tests pin down: the venv whose interpreter is selected
// and the venv whose site-packages go on PYTHONPATH must be THE SAME venv.
// One resolver must own that decision (AGENTS.md "observable ladder" rule 6).
const here = path.dirname(fileURLToPath(import.meta.url))
const mainTsSource = fs.readFileSync(path.join(here, 'main.ts'), 'utf8')
function extractFunction(source: string, name: string): string {
const start = source.indexOf(`function ${name}(`)
assert.notEqual(start, -1, `function ${name} not found in main.ts`)
// Slice to the next top-level `function ` declaration — crude but stable
// for the flat function layout main.ts uses.
const rest = source.slice(start)
const next = rest.slice(1).search(/\nfunction /)
return next === -1 ? rest : rest.slice(0, next + 1)
}
test('findPythonForRoot preference order includes .venv before venv (context for the coherence tests)', () => {
const fn = extractFunction(mainTsSource, 'findPythonForRoot')
const venvIdx = fn.indexOf("'.venv'")
const plainIdx = fn.indexOf("'venv'")
assert.notEqual(venvIdx, -1, 'expected findPythonForRoot to probe .venv')
assert.notEqual(plainIdx, -1, 'expected findPythonForRoot to probe venv')
assert.ok(
venvIdx < plainIdx,
'.venv is probed before venv — this ordering is what the hardcoded venvRoot below disagrees with'
)
})
// Fixed: createPythonBackend derives venvRoot from the selected interpreter
// via venvRootForPython(python, root), falling back to root/venv only for a
// system python. This test guards against re-hardcoding the venv path.
test('createPythonBackend derives venvRoot from the selected interpreter, not a hardcoded venv path', () => {
const fn = extractFunction(mainTsSource, 'createPythonBackend')
// The buggy shape: interpreter picked by findPythonForRoot (may be .venv),
// while venvRoot/PYTHONPATH is unconditionally root/venv.
const hardcodesVenv = /venvRoot\s*=\s*path\.join\(root,\s*'venv'\)/.test(fn)
const derivesFromPython = /findPythonForRoot|python/.test(fn) && !hardcodesVenv
assert.ok(
derivesFromPython,
'createPythonBackend hardcodes venvRoot=path.join(root, "venv") while findPythonForRoot may select .venv/bin/python — ' +
'a root with both venvs gets a 3.12 interpreter with 3.11 site-packages on PYTHONPATH and crashes on the first native import'
)
})
// Pure-logic mirror of the same invariant, testable without main.ts exports:
// given a root where BOTH .venv and venv exist, whatever venv the interpreter
// came from must be the venv used for site-packages. This encodes the fix's
// contract so the implementation can be extracted against it later.
export function coherentVenvRootForPython(pythonPath: string, root: string): string | null {
// The venv root is the directory two levels up from <venv>/bin/python
// (Scripts/python.exe on Windows). A system interpreter (outside `root`)
// is NOT a venv — pairing it with any venv's site-packages needs the same
// version check, so it returns null here.
const posix = pythonPath.match(/^(.*)\/bin\/python[0-9.]*$/)
const win = pythonPath.match(/^(.*)[\\/]Scripts[\\/]python\.exe$/i)
const candidate = posix?.[1] ?? win?.[1] ?? null
if (!candidate) {
return null
}
const normalizedRoot = root.replace(/\\/g, '/').replace(/\/+$/, '')
const normalizedCandidate = candidate.replace(/\\/g, '/')
return normalizedCandidate.startsWith(`${normalizedRoot}/`) ? candidate : null
}
test('coherentVenvRootForPython maps a selected interpreter back to ITS venv root', () => {
assert.equal(coherentVenvRootForPython('/repo/.venv/bin/python', '/repo'), '/repo/.venv')
assert.equal(coherentVenvRootForPython('/repo/venv/bin/python', '/repo'), '/repo/venv')
assert.equal(coherentVenvRootForPython('C:\\repo\\venv\\Scripts\\python.exe', 'C:\\repo'), 'C:\\repo\\venv')
assert.equal(coherentVenvRootForPython('/usr/bin/python3', '/repo'), null)
})
test('dual-venv root: site-packages must come from the venv that owns the selected interpreter', () => {
// Simulates the live repro: interpreter resolved to .venv (3.12), so the
// ONLY coherent venvRoot for PYTHONPATH is .venv — never the sibling venv.
const selected = '/repo/.venv/bin/python'
const venvRoot = coherentVenvRootForPython(selected, '/repo')
assert.equal(venvRoot, '/repo/.venv')
assert.notEqual(
venvRoot,
'/repo/venv',
'pairing a .venv interpreter with venv/ site-packages is the crash from the live repro'
)
})
+321
View File
@@ -0,0 +1,321 @@
/**
* Tests for electron/backend-ready.ts.
*
* Run with: node --test electron/backend-ready.test.ts
* (Wired into npm test:desktop:platforms in package.json.)
*
* Covers the cold-start port-announcement deadline (issue #50209): the clock
* starts before the backend binds its port, so a tight 45s deadline killed a
* healthy-but-still-compiling backend on cold Windows installs. The default is
* now cold-start tolerant and overridable via
* HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS, clamped to a 45s floor.
*/
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import {
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile
} from './backend-ready'
type FakeChildProcess = EventEmitter & {
stdout: EventEmitter
}
// A minimal stand-in for a spawned child process: an EventEmitter with a
// stdout EventEmitter, matching the surface waitForDashboardPort consumes
// (child.stdout.on('data'), child.on('exit'|'error') + the .off() teardown).
function makeFakeChild(): FakeChildProcess {
const child = new EventEmitter() as FakeChildProcess
child.stdout = new EventEmitter()
return child
}
// ---------------------------------------------------------------------------
// resolvePortAnnounceTimeoutMs
// ---------------------------------------------------------------------------
test('default is cold-start tolerant (> the historical 45s floor)', () => {
assert.equal(resolvePortAnnounceTimeoutMs({}), DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS)
assert.ok(
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS > MIN_PORT_ANNOUNCE_TIMEOUT_MS,
'cold-start default must exceed the warm-start floor'
)
})
test('honors a valid HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS override', () => {
const env = { HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS: '120000' }
assert.equal(resolvePortAnnounceTimeoutMs(env), 120_000)
})
test('clamps an override below the floor up to the 45s minimum', () => {
const env = { HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS: '1000' }
assert.equal(resolvePortAnnounceTimeoutMs(env), MIN_PORT_ANNOUNCE_TIMEOUT_MS)
})
test('rounds a fractional override', () => {
const env = { HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS: '60000.7' }
assert.equal(resolvePortAnnounceTimeoutMs(env), 60_001)
})
test('falls back to the default for malformed / non-positive overrides', () => {
for (const bad of ['', 'abc', '0', '-5', 'NaN', undefined]) {
const env = bad === undefined ? {} : { HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS: bad }
assert.equal(
resolvePortAnnounceTimeoutMs(env),
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
`override ${JSON.stringify(bad)} should fall through to the default`
)
}
})
// ---------------------------------------------------------------------------
// waitForDashboardPort
// ---------------------------------------------------------------------------
test('resolves with the announced port', async () => {
const child = makeFakeChild()
const p = waitForDashboardPort(child, 1000)
child.stdout.emit('data', 'noise before\nHERMES_DASHBOARD_READY port=54321\n')
assert.equal(await p, 54321)
})
test('resolves with a HERMES_BACKEND_READY port (headless `serve`)', async () => {
const child = makeFakeChild()
const p = waitForDashboardPort(child, 1000)
child.stdout.emit('data', 'HERMES_BACKEND_READY port=43210\n')
assert.equal(await p, 43210)
})
test('parses the port even when the line arrives split across chunks', async () => {
const child = makeFakeChild()
const p = waitForDashboardPort(child, 1000)
child.stdout.emit('data', 'HERMES_DASHBOARD_READY po')
child.stdout.emit('data', 'rt=8080\n')
assert.equal(await p, 8080)
})
test('rejects when the child exits before announcing', async () => {
const child = makeFakeChild()
const p = waitForDashboardPort(child, 1000)
child.emit('exit', 1, null)
await assert.rejects(p, /exited before port announcement/)
})
test('rejects on a child error event', async () => {
const child = makeFakeChild()
const p = waitForDashboardPort(child, 1000)
child.emit('error', new Error('spawn ENOENT'))
await assert.rejects(p, /spawn ENOENT/)
})
test('rejects with the timeout message after the deadline', async () => {
const child = makeFakeChild()
await assert.rejects(
waitForDashboardPort(child, 20),
/Timed out waiting for Hermes backend port announcement \(20ms\)/
)
})
test('a late announcement after timeout does not throw (listeners torn down)', async () => {
const child = makeFakeChild()
await assert.rejects(waitForDashboardPort(child, 20), /Timed out/)
// The orphaned backend may still print its READY line later; the watcher
// must have detached so this emit is a no-op rather than a double-settle.
assert.doesNotThrow(() => {
child.stdout.emit('data', 'HERMES_DASHBOARD_READY port=9999\n')
})
})
// ---------------------------------------------------------------------------
// ready-file port announcement
// ---------------------------------------------------------------------------
function mkTmpReadyFile() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-ready-test-'))
return {
dir,
file: path.join(dir, 'ready.json'),
cleanup: () => fs.rmSync(dir, { recursive: true, force: true })
}
}
test('readDashboardReadyFile returns a valid port from JSON', () => {
const tmp = mkTmpReadyFile()
try {
fs.writeFileSync(tmp.file, JSON.stringify({ port: 4567 }))
assert.equal(readDashboardReadyFile(tmp.file), 4567)
} finally {
tmp.cleanup()
}
})
test('readDashboardReadyFile ignores missing, malformed, or invalid files', () => {
const tmp = mkTmpReadyFile()
try {
assert.equal(readDashboardReadyFile(tmp.file), null)
fs.writeFileSync(tmp.file, '{')
assert.equal(readDashboardReadyFile(tmp.file), null)
fs.writeFileSync(tmp.file, JSON.stringify({ port: 0 }))
assert.equal(readDashboardReadyFile(tmp.file), null)
} finally {
tmp.cleanup()
}
})
test('waitForDashboardReadyFile resolves when the ready file appears', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 8765 })), 20)
assert.equal(await p, 8765)
} finally {
tmp.cleanup()
}
})
test('waitForDashboardPortAnnouncement uses ready file when provided', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardPortAnnouncement(child, { readyFile: tmp.file, timeoutMs: 1000 })
setTimeout(() => fs.writeFileSync(tmp.file, JSON.stringify({ port: 9876 })), 20)
assert.equal(await p, 9876)
} finally {
tmp.cleanup()
}
})
test('waitForDashboardReadyFile rejects when the child exits before file readiness', async () => {
const tmp = mkTmpReadyFile()
const child = makeFakeChild()
try {
const p = waitForDashboardReadyFile(tmp.file, child, 1000)
child.emit('exit', 1, null)
await assert.rejects(p, /exited before port announcement/)
} finally {
tmp.cleanup()
}
})
// ---------------------------------------------------------------------------
// describeOutputTail (#93608): the child's real stderr reaches the exit error
// ---------------------------------------------------------------------------
test('exit-before-announcement error carries the buffered output tail (stdout path)', async () => {
const child = makeFakeChild()
const wait = waitForDashboardPortAnnouncement(child, {
describeOutputTail: () => '\nRecent backend output:\nModuleNotFoundError: hermes_cli'
})
child.emit('exit', 1, null)
await assert.rejects(wait, /exited before port announcement \(1\)[\s\S]*ModuleNotFoundError: hermes_cli/)
})
test('exit-before-announcement error carries the buffered output tail (ready-file path)', async () => {
const child = makeFakeChild()
const readyFile = path.join(os.tmpdir(), `hermes-ready-${process.pid}-${Date.now()}.json`)
const wait = waitForDashboardPortAnnouncement(child, {
describeOutputTail: () => '\nRecent backend output:\nTraceback (most recent call last)',
readyFile
})
child.emit('exit', null, 'SIGSEGV')
await assert.rejects(wait, /exited before port announcement \(SIGSEGV\)[\s\S]*Traceback/)
})
test('exit-before-announcement error stays clean when no output was buffered', async () => {
const child = makeFakeChild()
const wait = waitForDashboardPortAnnouncement(child, {})
child.emit('exit', 137, null)
await assert.rejects(wait, error => {
assert.match((error as Error).message, /exited before port announcement \(137\)$/)
return true
})
})
// ---------------------------------------------------------------------------
// bufferedOutput (#60323): a sentinel consumed BEFORE the wait attaches must
// still resolve. main.ts attaches an output tail at spawn, then awaits
// claimBackendChild/advanceBootProgress before calling this wait; flowing-mode
// stdout never replays consumed chunks to late listeners.
// ---------------------------------------------------------------------------
test('resolves from bufferedOutput when the sentinel was consumed before the wait attached (#60323)', async () => {
const child = makeFakeChild()
// Simulate the spawn-time output tail: it consumed the READY line already,
// and no further stdout data will ever arrive.
const alreadyConsumed = 'boot noise\nHERMES_BACKEND_READY port=43211\n'
const port = await waitForDashboardPortAnnouncement(child, {
bufferedOutput: () => alreadyConsumed,
timeoutMs: 500
})
assert.equal(port, 43211)
})
test('bufferedOutput accepts the legacy HERMES_DASHBOARD_READY sentinel too', async () => {
const child = makeFakeChild()
const port = await waitForDashboardPortAnnouncement(child, {
bufferedOutput: () => 'HERMES_DASHBOARD_READY port=43212\n',
timeoutMs: 500
})
assert.equal(port, 43212)
})
test('bufferedOutput without a sentinel still resolves from later live stdout', async () => {
const child = makeFakeChild()
const wait = waitForDashboardPortAnnouncement(child, {
bufferedOutput: () => 'uvicorn still importing...\n',
timeoutMs: 1000
})
child.stdout.emit('data', Buffer.from('HERMES_BACKEND_READY port=43213\n'))
assert.equal(await wait, 43213)
})
test('bufferedOutput without a sentinel still times out (no false positive)', async () => {
const child = makeFakeChild()
const wait = waitForDashboardPort(
child,
50,
() => '',
() => 'no sentinel here\n'
)
await assert.rejects(wait, /Timed out waiting/)
})
+248
View File
@@ -0,0 +1,248 @@
import fs from 'node:fs'
// `hermes serve` announces HERMES_BACKEND_READY; the legacy `hermes dashboard`
// backend announces HERMES_DASHBOARD_READY. Accept either so the desktop spawn
// works against both the headless backend and old/dashboard runtimes.
const _READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/m
// The announcement clock starts the instant the backend process is spawned —
// before uvicorn binds its socket. On a cold install the child must first
// compile and import the whole `hermes_cli.main` → `web_server` → FastAPI/
// uvicorn chain, and on Windows real-time AV (Defender) scans every freshly
// written `.pyc`. That pre-bind cost can run 30-60s on a slow disk, so a tight
// 45s deadline kills a *healthy but still-starting* backend and respawns it,
// piling up orphaned processes (issue #50209). A roomier default absorbs the
// cold-start cost; a warm start still announces in well under a second.
const DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS = 90_000
// Never trust a deadline tighter than the warm-start path needs; floor at 45s
// (the historical default) so a malformed override can't reintroduce the loop.
const MIN_PORT_ANNOUNCE_TIMEOUT_MS = 45_000
/**
* Resolve the port-announcement deadline. Honors the
* HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS env override (for users on slow
* disks / aggressive AV who need an even longer cold-start window), clamped
* to a sane floor so a bad value can't make boot flakier than the default.
*/
function resolvePortAnnounceTimeoutMs(env = process.env) {
const parsed = Number(env.HERMES_DESKTOP_PORT_ANNOUNCE_TIMEOUT_MS)
if (Number.isFinite(parsed) && parsed > 0) {
return Math.max(MIN_PORT_ANNOUNCE_TIMEOUT_MS, Math.round(parsed))
}
return DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS
}
/**
* Watch a child process's stdout for the `HERMES_(BACKEND|DASHBOARD)_READY
* port=<N>` line that web_server.py prints after uvicorn binds its socket.
*
* Returns the parsed port. Rejects if:
* - the child exits before emitting the line
* - the child emits an `error` event
* - no line arrives within the timeout
*
* The default timeout is cold-start tolerant (see
* DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS) because the clock starts before the
* backend has even bound its port. Pass an explicit `timeoutMs` to override.
*
* A single `cleanup()` tears down every listener (data/exit/error/timeout)
* on every terminal path — resolve, reject, or timeout — so repeated
* backend spawns don't leak listener slots on the child.
*/
function waitForDashboardPort(
child,
timeoutMs = resolvePortAnnounceTimeoutMs(),
describeOutputTail = () => '',
bufferedOutput: () => string = () => ''
) {
return new Promise((resolve, reject) => {
// Seed the line buffer with any output the spawn-time tail already
// consumed (#60323): main.ts attaches its output tail at spawn, then
// awaits claimBackendChild + advanceBootProgress BEFORE this listener
// attaches. child.stdout is in flowing mode from the tail's listener, so
// a READY line flushed during that window is emitted once and never
// replayed to late listeners — the wait then times out at 90s and a
// healthy backend is killed. Scanning the tail's buffer (and seeding any
// trailing partial line) makes the listener-attach ordering irrelevant.
let buf = ''
let done = false
function cleanup() {
if (done) {
return
}
done = true
clearTimeout(timer)
child.stdout.off('data', onData)
child.off('exit', onExit)
child.off('error', onError)
}
function onData(chunk) {
buf += chunk.toString()
let nl
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl)
buf = buf.slice(nl + 1)
const m = line.match(_READY_RE)
if (m) {
cleanup()
resolve(parseInt(m[1], 10))
return
}
}
}
function onExit(code, signal) {
cleanup()
reject(new Error(`Hermes backend: exited before port announcement (${signal || code})${describeOutputTail()}`))
}
function onError(err) {
cleanup()
reject(err)
}
const timer = setTimeout(() => {
cleanup()
reject(new Error(`Timed out waiting for Hermes backend port announcement (${timeoutMs}ms)`))
}, timeoutMs)
child.stdout.on('data', onData)
child.on('exit', onExit)
child.on('error', onError)
// Listener is live — now recover a sentinel that was already flushed and
// consumed before this promise existed. The snapshot is taken AFTER the
// listener attaches, so no chunk can fall between snapshot and listener.
if (!done) {
const alreadyBuffered = bufferedOutput()
const m = alreadyBuffered ? alreadyBuffered.match(_READY_RE) : null
if (m) {
cleanup()
resolve(parseInt(m[1], 10))
}
}
})
}
function readDashboardReadyFile(readyFile: fs.PathOrFileDescriptor) {
if (!readyFile) {
return null
}
try {
const parsed = JSON.parse(fs.readFileSync(readyFile, 'utf8'))
const port = Number(parsed?.port)
return Number.isInteger(port) && port > 0 ? port : null
} catch {
return null
}
}
function waitForDashboardReadyFile(
readyFile,
child,
timeoutMs = resolvePortAnnounceTimeoutMs(),
describeOutputTail = () => ''
) {
return new Promise((resolve, reject) => {
let done = false
let interval = null
function cleanup() {
if (done) {
return
}
done = true
clearTimeout(timer)
if (interval) {
clearInterval(interval)
}
child.off('exit', onExit)
child.off('error', onError)
}
function check() {
const port = readDashboardReadyFile(readyFile)
if (port) {
cleanup()
resolve(port)
}
}
function onExit(code, signal) {
cleanup()
reject(new Error(`Hermes backend: exited before port announcement (${signal || code})${describeOutputTail()}`))
}
function onError(err) {
cleanup()
reject(err)
}
const timer = setTimeout(() => {
cleanup()
reject(new Error(`Timed out waiting for Hermes backend port announcement (${timeoutMs}ms)`))
}, timeoutMs)
child.on('exit', onExit)
child.on('error', onError)
interval = setInterval(check, 50)
if (typeof interval.unref === 'function') {
interval.unref()
}
check()
})
}
function waitForDashboardPortAnnouncement(
child,
options: {
/**
* Returns the child's output buffered since SPAWN (the output tail's
* accumulated text, #60323). Scanned for an already-emitted READY
* sentinel so attaching this wait AFTER other awaits (backend claim,
* boot-progress IPC) can never lose the announcement: flowing-mode
* stdout never replays chunks to late listeners.
*/
bufferedOutput?: () => string
/** Returns a formatted stdout/stderr tail suffix for exit errors (#93608). */
describeOutputTail?: () => string
readyFile?: fs.PathOrFileDescriptor | null
timeoutMs?: number
} = {}
) {
const timeoutMs = options.timeoutMs ?? resolvePortAnnounceTimeoutMs()
const describeOutputTail = options.describeOutputTail ?? (() => '')
if (options.readyFile) {
return waitForDashboardReadyFile(options.readyFile, child, timeoutMs, describeOutputTail)
}
return waitForDashboardPort(child, timeoutMs, describeOutputTail, options.bufferedOutput ?? (() => ''))
}
export {
DEFAULT_PORT_ANNOUNCE_TIMEOUT_MS,
MIN_PORT_ANNOUNCE_TIMEOUT_MS,
readDashboardReadyFile,
resolvePortAnnounceTimeoutMs,
waitForDashboardPort,
waitForDashboardPortAnnouncement,
waitForDashboardReadyFile
}
@@ -0,0 +1,92 @@
import { describe, expect, it, vi } from 'vitest'
import { recycleOwnedBackend, recycleOwnedBackendTarget } from './backend-recycle'
describe('recycleOwnedBackendTarget', () => {
it('treats an empty or matching profile as the primary backend', () => {
expect(recycleOwnedBackendTarget(undefined, 'default')).toBe('primary')
expect(recycleOwnedBackendTarget('', 'default')).toBe('primary')
expect(recycleOwnedBackendTarget('default', 'default')).toBe('primary')
})
it('treats any other named profile as a pooled backend', () => {
expect(recycleOwnedBackendTarget('paid-ads', 'default')).toBe('pool')
})
})
describe('recycleOwnedBackend', () => {
it('kills the owned SSH serve before the primary child, then notifies apply', async () => {
const events: string[] = []
const target = await recycleOwnedBackend({
notifyApplied: () => events.push('applied'),
primaryProfile: 'default',
profile: undefined,
teardownPool: async () => {
events.push('pool')
},
teardownPrimary: async () => {
events.push('primary')
},
teardownSsh: async profile => {
events.push(`ssh:${profile}`)
}
})
expect(target).toBe('primary')
expect(events).toEqual(['ssh:', 'primary', 'applied'])
})
it('recycles a pooled profile without tearing down the primary', async () => {
const events: string[] = []
const target = await recycleOwnedBackend({
notifyApplied: () => events.push('applied'),
primaryProfile: 'default',
profile: 'paid-ads',
teardownPool: async profile => {
events.push(`pool:${profile}`)
},
teardownPrimary: async () => {
events.push('primary')
},
teardownSsh: async profile => {
events.push(`ssh:${profile}`)
}
})
expect(target).toBe('pool')
expect(events).toEqual(['ssh:paid-ads', 'pool:paid-ads'])
})
it('awaits SSH teardown before the local child even when SSH is slow', async () => {
const events: string[] = []
let releaseSsh!: () => void
const sshGate = new Promise<void>(resolve => {
releaseSsh = resolve
})
const run = recycleOwnedBackend({
notifyApplied: () => events.push('applied'),
primaryProfile: 'default',
teardownPool: vi.fn(),
teardownPrimary: async () => {
events.push('primary')
},
teardownSsh: async () => {
events.push('ssh-start')
await sshGate
events.push('ssh-done')
}
})
await Promise.resolve()
expect(events).toEqual(['ssh-start'])
releaseSsh()
await run
expect(events).toEqual(['ssh-start', 'ssh-done', 'primary', 'applied'])
})
})
+47
View File
@@ -0,0 +1,47 @@
/**
* Recycle a Desktop-owned backend after a code-skew 503.
*
* Closing the local tunnel/child is not enough for SSH: `serve --isolated`
* detaches with setsid/nohup, so a reconnect would reuse the still-alive
* stale process via the lockfile. Kill the owned remote serve first (while
* the SSH channel can still exec), then tear down the local child — the
* same order as connection apply (#97046, #91668).
*/
export type RecycleOwnedBackendTarget = 'pool' | 'primary'
export interface RecycleOwnedBackendDeps {
notifyApplied: () => void
primaryProfile: string
profile?: null | string
teardownPool: (profile: string) => Promise<void>
teardownPrimary: () => Promise<void>
teardownSsh: (profile: string) => Promise<void>
}
export function recycleOwnedBackendTarget(
profile: null | string | undefined,
primaryProfile: string
): RecycleOwnedBackendTarget {
const key = String(profile ?? '').trim()
return !key || key === primaryProfile ? 'primary' : 'pool'
}
export async function recycleOwnedBackend(deps: RecycleOwnedBackendDeps): Promise<RecycleOwnedBackendTarget> {
const target = recycleOwnedBackendTarget(deps.profile, deps.primaryProfile)
const profile = String(deps.profile ?? '').trim()
if (target === 'primary') {
await deps.teardownSsh('')
await deps.teardownPrimary()
deps.notifyApplied()
return target
}
await deps.teardownSsh(profile)
await deps.teardownPool(profile)
return target
}
@@ -0,0 +1,164 @@
/**
* backend-release-gate.test.ts
*
* The #74805 first-attempt race, pinned as a contract on the extracted gate:
* the desktop must not hand off to the updater while PIDs it signalled are
* still in the process table, even when the venv shim probe reads unlocked
* (the backend `python.exe -m hermes_cli.main serve` need not hold the shim
* at all). On merge-base main.ts the gate was shim-only and passed on its
* first iteration with zero dwell — the sabotage A/B run proves these tests
* bite on that behavior.
*/
import { describe, expect, it } from 'vitest'
import { RELEASE_GATE_POLL_MS, type ReleaseGateDeps, waitForBackendRelease } from './backend-release-gate'
/** A fake clock where sleep() advances time instantly. */
function fakeClock() {
let t = 0
return {
now: () => t,
sleep: async (ms: number) => {
t += ms
},
advance: (ms: number) => {
t += ms
}
}
}
function makeDeps(overrides: Partial<ReleaseGateDeps> = {}): ReleaseGateDeps & {
logs: string[]
kills: number[]
} {
const clock = fakeClock()
const logs: string[] = []
const kills: number[] = []
return {
isShimLocked: () => false,
isPidAlive: () => false,
collectStragglerPids: () => [],
killProcessTree: pid => kills.push(pid),
sleep: clock.sleep,
now: clock.now,
log: line => logs.push(line),
logs,
kills,
...overrides
}
}
describe('waitForBackendRelease (#74805 first-attempt race)', () => {
it('does NOT pass while a signalled PID is still in the process table, even with the shim unlocked', async () => {
// The exact #74805 shape: shim unlocked from tick 0 (serve backend never
// held it), but the killed python is still tearing down for ~1.2s.
let aliveUntil = 4 * RELEASE_GATE_POLL_MS
const clock = fakeClock()
const deps = makeDeps({
now: clock.now,
sleep: clock.sleep,
isShimLocked: () => false,
isPidAlive: () => clock.now() < aliveUntil
})
const result = await waitForBackendRelease([4021], deps, 'test')
expect(result.unlocked).toBe(true)
expect(result.lingeringPids).toEqual([])
// The gate must have dwelled at least until the PID actually exited —
// on merge-base (shim-only gate) it would have returned at t=0.
expect(clock.now()).toBeGreaterThanOrEqual(aliveUntil)
})
it('passes immediately when the shim is unlocked and no signalled PID lingers', async () => {
const deps = makeDeps()
const result = await waitForBackendRelease([4021, 4022], deps, 'test')
expect(result.unlocked).toBe(true)
expect(deps.now()).toBe(0) // no dwell needed — everything already gone
})
it('keeps waiting while the shim is locked and fails closed at the deadline', async () => {
const deps = makeDeps({ isShimLocked: () => true })
const result = await waitForBackendRelease([], deps, 'test', 3 * RELEASE_GATE_POLL_MS)
expect(result.unlocked).toBe(false)
})
it('proceeds at the deadline when the shim is unlocked but PIDs still linger (pre-#74805 escape hatch)', async () => {
// Lingering PIDs past the deadline are the venv-blocker re-scan's job —
// the gate must not invent a new failure mode for them.
const deps = makeDeps({ isPidAlive: () => true })
const result = await waitForBackendRelease([4021], deps, 'test', 3 * RELEASE_GATE_POLL_MS)
expect(result.unlocked).toBe(true)
expect(result.lingeringPids).toEqual([4021])
})
it('kills and then waits out stragglers that respawn mid-teardown', async () => {
// A pool entry registered mid-teardown appears on pass 2; the gate must
// signal it AND add it to the exit-wait set.
const clock = fakeClock()
let stragglerServed = false
let stragglerKilledAt: number | null = null
const kills: number[] = []
const deps = makeDeps({
now: clock.now,
sleep: clock.sleep,
collectStragglerPids: () => {
if (!stragglerServed) {
stragglerServed = true
return [7777]
}
return []
},
killProcessTree: pid => {
stragglerKilledAt = clock.now()
kills.push(pid)
},
// Primary PID 4021 lingers for one poll (forcing a straggler-collect
// pass); the straggler stays alive for two polls after being killed.
isPidAlive: pid => {
if (pid === 4021) {
return clock.now() < RELEASE_GATE_POLL_MS
}
return pid === 7777 && stragglerKilledAt !== null && clock.now() < stragglerKilledAt + 2 * RELEASE_GATE_POLL_MS
}
})
const result = await waitForBackendRelease([4021], deps, 'test')
expect(kills).toContain(7777)
expect(result.unlocked).toBe(true)
expect(result.lingeringPids).toEqual([])
// The gate must have dwelled until the straggler actually exited.
expect(clock.now()).toBeGreaterThanOrEqual((stragglerKilledAt ?? 0) + 2 * RELEASE_GATE_POLL_MS)
})
it('ignores invalid PIDs in the seed and straggler sets', async () => {
const deps = makeDeps({
collectStragglerPids: () => [0, -4, NaN as unknown as number]
})
const result = await waitForBackendRelease(
[0, -1, 2.5, NaN as unknown as number],
deps,
'test',
2 * RELEASE_GATE_POLL_MS
)
expect(result.unlocked).toBe(true)
expect(deps.kills).toEqual([])
})
})
@@ -0,0 +1,127 @@
/**
* backend-release-gate.ts
*
* The Windows pre-update unlock gate: after the desktop tree-kills its own
* backends, decide when it is actually safe to hand off to the updater.
*
* Why this exists (#74805): `taskkill /T /F` returns once termination is
* INITIATED, not completed. A dying `python.exe -m hermes_cli.main serve`
* stays in the process table while it unmaps .pyd files (AV / NTFS filter
* drivers stretch this out), and it need not hold the venv `hermes.exe` shim
* at all — so a gate that only probes the shim can pass on its very first
* iteration, with zero dwell, while the killed pythons are still
* terminating. The venv-blocker scan downstream has no liveness filter; it
* enumerates those dying processes as holders and aborts the hand-off.
* Result: the FIRST update attempt from the footbar always failed, and the
* manual retry (by which time the table had settled) succeeded.
*
* The gate therefore requires BOTH: the shim unlocked AND every PID we have
* ever signalled to have actually left the process table. On deadline, the
* old shim-only criterion is kept as the escape hatch — lingering PIDs past
* 15s are the venv-blocker re-scan's job, not a new failure mode.
*
* Extracted into its own dependency-free module (no electron import) so the
* gate's decision logic can be asserted directly with fake clocks and fake
* process tables, following the backend-child.ts pattern.
*/
export interface ReleaseGateDeps {
/** Probe the venv hermes.exe shim (real: O_RDWR open attempt). */
isShimLocked: () => boolean
/** True while `pid` is still enumerable in the process table. */
isPidAlive: (pid: number) => boolean
/**
* Re-collect PIDs that may have (re)spawned since the initial sweep —
* the supervised primary backend and pool entries. Called every pass.
*/
collectStragglerPids: () => number[]
/** Tree-kill (real: taskkill /PID n /T /F). */
killProcessTree: (pid: number) => void
/** Async sleep; injectable so tests run on a fake clock. */
sleep: (ms: number) => Promise<void>
/** Monotonic-enough clock; injectable for tests. */
now: () => number
/** Log sink (real: rememberLog). */
log: (line: string) => void
}
export interface ReleaseGateResult {
unlocked: boolean
/** PIDs we signalled that were still enumerable when the gate resolved. */
lingeringPids: number[]
}
export const RELEASE_GATE_DEADLINE_MS = 15000
export const RELEASE_GATE_POLL_MS = 300
/**
* Wait until the install is genuinely releasable: shim unlocked AND every
* signalled PID gone — or the deadline passes.
*
* `initialPids` are the PIDs the caller already signalled (primary backend +
* pool) before invoking the gate; stragglers collected on each pass are
* killed and added to the same watch set.
*/
export async function waitForBackendRelease(
initialPids: number[],
deps: ReleaseGateDeps,
tag: string,
deadlineMs: number = RELEASE_GATE_DEADLINE_MS
): Promise<ReleaseGateResult> {
const killedPids = new Set<number>(initialPids.filter(pid => Number.isInteger(pid) && pid > 0))
const deadline = deps.now() + deadlineMs
while (deps.now() < deadline) {
const lingering = [...killedPids].filter(pid => deps.isPidAlive(pid))
if (!deps.isShimLocked() && lingering.length === 0) {
deps.log(`[${tag}] venv shim unlocked and ${killedPids.size} signalled backend PID(s) exited; safe to proceed`)
return { unlocked: true, lingeringPids: [] }
}
// A supervised backend can respawn between kill and check (grandchildren,
// pool entries registered mid-teardown). Re-collect and re-kill each pass
// instead of trusting the initial sweep.
for (const pid of deps.collectStragglerPids()) {
if (Number.isInteger(pid) && pid > 0) {
killedPids.add(pid)
deps.killProcessTree(pid)
}
}
await deps.sleep(RELEASE_GATE_POLL_MS)
}
// Deadline reached. Keep the pre-#74805 success criterion — an unlocked
// shim — rather than inventing a new failure mode for PIDs that linger
// past the deadline; the venv-blocker re-scan downstream covers that
// residue (and a REAL foreign holder still fails the shim probe).
const lingering = [...killedPids].filter(pid => deps.isPidAlive(pid))
if (!deps.isShimLocked()) {
deps.log(
`[${tag}] proceeding after deadline: venv shim unlocked, but ${lingering.length} signalled PID(s) still enumerable`
)
return { unlocked: true, lingeringPids: lingering }
}
return { unlocked: false, lingeringPids: lingering }
}
/**
* Liveness probe for a PID on Windows. `process.kill(pid, 0)` delivers
* nothing; it only probes existence: EPERM ⇒ exists but inaccessible (still
* alive), ESRCH ⇒ gone.
*/
export function isPidAliveWindows(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (err: any) {
return Boolean(err) && err.code === 'EPERM'
}
}
@@ -0,0 +1,146 @@
/**
* backend-release-gate.windows-live.test.ts
*
* LIVE Windows E2E for the #74805 unlock gate: real spawned processes, the
* REAL isPidAliveWindows probe against the live process table, real
* taskkill — no fake clocks, no fake tables. Runs only on win32 (the
* ephemeral wine2e lane); skipped everywhere else.
*
* This is the platform half of the proof: the unit suite pins the gate's
* decision logic on a fake table; this file proves the two real-world
* premises the fix rests on:
* 1. taskkill /T /F returns while the killed process is still enumerable
* (the race window exists), and
* 2. the gate, wired to the real probes, dwells through that window and
* only passes once the PID has genuinely left the table.
*/
import { execFileSync, spawn } from 'node:child_process'
import { describe, expect, it } from 'vitest'
import { isPidAliveWindows, waitForBackendRelease } from './backend-release-gate'
const isWindows = process.platform === 'win32'
function spawnSleeper(): { pid: number; kill: () => void } {
// A real python if available (mirrors the backend shape), else powershell.
const child = spawn('powershell', ['-NoProfile', '-Command', 'Start-Sleep -Seconds 300'], { stdio: 'ignore' })
if (!child.pid) {
throw new Error('sleeper failed to spawn')
}
return {
pid: child.pid,
kill: () => {
try {
child.kill()
} catch {
/* already gone */
}
}
}
}
function taskkillTree(pid: number): void {
try {
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
} catch {
/* already gone */
}
}
describe.skipIf(!isWindows)('waitForBackendRelease — live Windows (#74805)', () => {
it('isPidAliveWindows tracks a real process through spawn and exit', async () => {
const sleeper = spawnSleeper()
expect(isPidAliveWindows(sleeper.pid)).toBe(true)
taskkillTree(sleeper.pid)
// Poll until the table retires the PID (bounded).
const deadline = Date.now() + 10000
while (isPidAliveWindows(sleeper.pid) && Date.now() < deadline) {
await new Promise(r => setTimeout(r, 100))
}
expect(isPidAliveWindows(sleeper.pid)).toBe(false)
})
it('the gate dwells until a real killed PID leaves the live process table', async () => {
const sleeper = spawnSleeper()
const logs: string[] = []
let firstAliveCheck: boolean | null = null
// Fire the real taskkill and IMMEDIATELY enter the gate — the #74805
// shape. The shim probe reads unlocked throughout (the serve backend
// never held it); only the PID exit-wait can hold the gate closed.
taskkillTree(sleeper.pid)
const result = await waitForBackendRelease(
[sleeper.pid],
{
isShimLocked: () => false,
isPidAlive: pid => {
const alive = isPidAliveWindows(pid)
if (firstAliveCheck === null) {
firstAliveCheck = alive
}
return alive
},
collectStragglerPids: () => [],
killProcessTree: taskkillTree,
sleep: ms => new Promise(r => setTimeout(r, ms)),
now: () => Date.now(),
log: line => logs.push(line)
},
'live-e2e'
)
expect(result.unlocked).toBe(true)
// The gate resolved only after the real PID left the real table:
expect(isPidAliveWindows(sleeper.pid)).toBe(false)
expect(result.lingeringPids).toEqual([])
// Record whether the race window was observable on this runner (taskkill
// returned while the PID was still enumerable). Informational: fast
// runners can retire tiny process trees before our first check, but the
// gate's correctness (above) does not depend on winning that race.
logs.push(`race-window-observed=${firstAliveCheck}`)
expect(logs.some(l => l.includes('safe to proceed'))).toBe(true)
})
it('a live foreign holder keeps the gate closed until the deadline', async () => {
const holder = spawnSleeper()
try {
const result = await waitForBackendRelease(
[holder.pid],
{
// Simulates the shim held by a process we did NOT kill — the gate
// must fail closed rather than hand off over a live holder.
isShimLocked: () => true,
isPidAlive: isPidAliveWindows,
collectStragglerPids: () => [],
killProcessTree: () => {
/* nothing else to kill */
},
sleep: ms => new Promise(r => setTimeout(r, ms)),
now: () => Date.now(),
log: () => {}
},
'live-e2e',
2000
)
expect(result.unlocked).toBe(false)
expect(result.lingeringPids).toEqual([holder.pid])
} finally {
taskkillTree(holder.pid)
}
})
})
@@ -0,0 +1,149 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { isReauthRequiredError, makeUnsignedOauthError } from './backend-health'
import {
isHostKeyChangedBootFailure,
isRetryableRemoteBootFailure,
shouldLatchBackendStartFailure,
shouldLatchHostKeyChangedFailure,
shouldLatchRemoteReauthFailure
} from './backend-start-failure'
test('latches a LOCAL backend failure so the install-retry loop is broken', () => {
assert.equal(shouldLatchBackendStartFailure({ attemptedRemote: false }), true)
})
test('never latches a REMOTE failure so recovery stays retryable without a restart', () => {
// A lapsed OAuth session / mint timeout / host briefly unreachable across a
// laptop sleep must not wedge the app: the next connect has to re-attempt and
// re-mint against the refreshed session.
assert.equal(shouldLatchBackendStartFailure({ attemptedRemote: true }), false)
})
test('the two branches are mutually exclusive (a failure either latches or stays retryable)', () => {
for (const attemptedRemote of [true, false]) {
const latched = shouldLatchBackendStartFailure({ attemptedRemote })
assert.equal(latched, !attemptedRemote)
}
})
test('latches a CONFIRMED remote reauth failure so the overlay stays clickable', () => {
// Without this the non-latching remote path re-runs startHermes on every
// getConnection/api call, re-emits running:true, and the overlay hides
// itself — the "Sign in" button flickers away before it can be clicked.
assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth: true }), true)
})
test('does not latch a transient remote failure as reauth', () => {
// A mint timeout or a host unreachable across sleep must still self-heal.
assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth: false }), false)
})
test('never latches a LOCAL failure as reauth (that is backendStartFailure job)', () => {
assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: false, isReauth: true }), false)
assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: false, isReauth: false }), false)
})
test('the two latches never fire for the same failure', () => {
// They are complementary, not overlapping: local failures latch via
// backendStartFailure, confirmed remote reauth latches via its own flag.
for (const attemptedRemote of [true, false]) {
for (const isReauth of [true, false]) {
const start = shouldLatchBackendStartFailure({ attemptedRemote })
const reauth = shouldLatchRemoteReauthFailure({ attemptedRemote, isReauth })
assert.ok(!(start && reauth), `both latched for remote=${attemptedRemote} reauth=${isReauth}`)
}
}
})
test('FIX #82679: a transient remote failure is retryable so a dropped SSH/HTTP connection self-heals', () => {
// The dropped-registered-connection class: "Could not verify the existing
// SSH backend", ERR_CONNECTION_RESET on an HTTP remote, mint timeouts. All
// surface as non-reauth remote boot failures and must enter the bounded
// renderer retry loop instead of parking on "Desktop boot failed".
assert.equal(isRetryableRemoteBootFailure({ attemptedRemote: true, isReauth: false }), true)
})
test('a CONFIRMED reauth rejection is never auto-retried (missing capability, not transient failure)', () => {
assert.equal(isRetryableRemoteBootFailure({ attemptedRemote: true, isReauth: true }), false)
})
test('unsigned OAuth latches and is never auto-retried; needsOauthLogin alone still retries', () => {
// Production composition in startHermes: isReauth = isReauthRequiredError(error).
const unsigned = isReauthRequiredError(makeUnsignedOauthError())
const ticketHint = isReauthRequiredError({ needsOauthLogin: true })
assert.equal(unsigned, true)
assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth: unsigned }), true)
assert.equal(isRetryableRemoteBootFailure({ attemptedRemote: true, isReauth: unsigned }), false)
assert.equal(ticketHint, false)
assert.equal(shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth: ticketHint }), false)
assert.equal(isRetryableRemoteBootFailure({ attemptedRemote: true, isReauth: ticketHint }), true)
})
test('local failures are never auto-retried by the remote self-heal loop', () => {
assert.equal(isRetryableRemoteBootFailure({ attemptedRemote: false, isReauth: false }), false)
assert.equal(isRetryableRemoteBootFailure({ attemptedRemote: false, isReauth: true }), false)
})
test('retryable and reauth-latch are mutually exclusive for remote failures', () => {
// Every remote failure either self-heals (transient) or latches for sign-in
// (confirmed reauth) — never both, never neither.
for (const isReauth of [true, false]) {
const retry = isRetryableRemoteBootFailure({ attemptedRemote: true, isReauth })
const latch = shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth })
assert.equal(retry !== latch, true, `remote failure with reauth=${isReauth} must pick exactly one path`)
}
})
test('FIX host-key change: classified from the kind tag and from stringified ssh banners', () => {
// classifySshError tags the Error it built; errors that crossed an IPC or
// string boundary only keep the message. Both shapes must classify.
const tagged = Object.assign(new Error('SSH refused to connect.'), { kind: 'host-key-changed' })
assert.equal(isHostKeyChangedBootFailure(tagged), true)
assert.equal(
isHostKeyChangedBootFailure(new Error('@@@@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @@@@')),
true
)
assert.equal(isHostKeyChangedBootFailure(new Error('Host key verification failed.')), true)
assert.equal(
isHostKeyChangedBootFailure(new Error('The host key for root@203.0.113.7 has CHANGED since you last connected.')),
true
)
assert.equal(isHostKeyChangedBootFailure(new Error('Connection refused')), false)
assert.equal(isHostKeyChangedBootFailure(null), false)
})
test('FIX host-key change: latches and is never auto-retried (157-failure loop, Aug 2026 bundle)', () => {
// SSH fails closed on a changed host key: every retry re-drives the same
// doomed boot until the user clears known_hosts. Terminal, like reauth.
const context = { attemptedRemote: true, isReauth: false, isHostKeyChanged: true }
assert.equal(shouldLatchHostKeyChangedFailure(context), true)
assert.equal(isRetryableRemoteBootFailure(context), false)
})
test('host-key latch never fires for local failures or ordinary remote faults', () => {
assert.equal(
shouldLatchHostKeyChangedFailure({ attemptedRemote: false, isReauth: false, isHostKeyChanged: true }),
false
)
assert.equal(
shouldLatchHostKeyChangedFailure({ attemptedRemote: true, isReauth: false, isHostKeyChanged: false }),
false
)
assert.equal(shouldLatchHostKeyChangedFailure({ attemptedRemote: true, isReauth: false }), false)
})
test('every remote failure picks exactly one path: retry, reauth latch, or host-key latch', () => {
for (const isReauth of [true, false]) {
for (const isHostKeyChanged of [true, false]) {
const retry = isRetryableRemoteBootFailure({ attemptedRemote: true, isReauth, isHostKeyChanged })
const reauth = shouldLatchRemoteReauthFailure({ attemptedRemote: true, isReauth })
const hostKey = shouldLatchHostKeyChangedFailure({ attemptedRemote: true, isReauth, isHostKeyChanged })
const picked = [retry, reauth, hostKey].filter(Boolean).length
assert.ok(picked >= 1, `remote failure reauth=${isReauth} hostKey=${isHostKeyChanged} fell through every path`)
}
}
})
@@ -0,0 +1,140 @@
/**
* backend-start-failure.ts
*
* Decides whether a failed primary-backend boot should *latch* into
* `backendStartFailure`. A latched failure makes every subsequent
* startHermes() re-throw the cached error without re-attempting the connect —
* the right behavior for a LOCAL backend so the renderer's retry loop can't
* restart a broken install over and over.
*
* It is the WRONG behavior for a REMOTE backend. A remote connect can fail for
* transient reasons — a lapsed OAuth access-token cookie (the gateway rotates a
* fresh one from the live refresh-token cookie on the next request), a
* ws-ticket mint that timed out mid sleep/wake, or a host that was briefly
* unreachable across a laptop sleep. There is no child process whose 'exit'
* handler would clear the cache, so a latched remote failure sticks until the
* whole app is quit and relaunched: reconnect, "Sign out & sign in" (which only
* reloads the renderer), and the wake-recovery revalidate path all keep hitting
* the same stale error. Not latching lets the very next connect re-mint a
* ticket against the (now refreshed) session and self-heal.
*
* Extracted as a dependency-free pure predicate so the invariant is testable
* without booting Electron or reading main.ts source text.
*/
export interface BackendStartFailureContext {
/**
* True when the boot that just failed was resolving/dialing a REMOTE (or
* cloud) primary backend rather than spawning a local child.
*/
attemptedRemote: boolean
}
/**
* Whether a startHermes() failure should latch into `backendStartFailure`.
* Latch local failures (prevent install-restart loops); never latch remote
* failures (they are transient and must stay retryable so recovery paths work
* without an app restart).
*/
export function shouldLatchBackendStartFailure(context: BackendStartFailureContext): boolean {
return !context.attemptedRemote
}
export interface RemoteReauthFailureContext {
/** True when the boot that just failed was dialing a REMOTE (or cloud) backend. */
attemptedRemote: boolean
/**
* True when the failure was a CONFIRMED auth rejection (a credentialed
* probe got 401/403), not a transient connectivity fault.
*/
isReauth: boolean
}
/**
* Whether a failed remote boot should latch as a reauth failure.
*
* This is the deliberate counterpart to `shouldLatchBackendStartFailure`,
* which never latches a remote failure because remote faults are usually
* transient and must stay retryable. A *confirmed* reauth rejection is the
* exception: it cannot self-heal, because nothing will change until the user
* signs in again.
*
* Without a latch, the non-latching remote path actively prevents recovery.
* Every subsequent `getConnection`/`api` call re-runs `startHermes`, re-emits
* `running: true`, and the boot-failure overlay (`visible = Boolean(boot.error)
* && !boot.running`) hides itself — so the "Sign in" button flickers out from
* under the user before they can click it. Latching holds the overlay still
* and clickable. Cleared on every recovery path (reset, repair, apply-config,
* and a confirmed sign-in) so a fresh session boots normally.
*/
export function shouldLatchRemoteReauthFailure(context: RemoteReauthFailureContext): boolean {
return context.attemptedRemote && context.isReauth
}
export interface RemoteBootRetryContext {
/** True when the boot that just failed was dialing a REMOTE (or cloud/SSH) backend. */
attemptedRemote: boolean
/**
* True when the failure was a CONFIRMED auth rejection (401/403), which can
* never self-heal without the user signing in again.
*/
isReauth: boolean
/**
* True when SSH refused to connect because the host's key CHANGED
* (StrictHostKeyChecking fails closed). Retrying cannot succeed until the
* user verifies the change and removes the stale known_hosts entry, so this
* is terminal like a reauth rejection — not connectivity.
*/
isHostKeyChanged?: boolean
}
/**
* A host-key-change refusal is identifiable both by the `kind` tag
* classifySshError puts on the error and — for errors that crossed a
* stringifying boundary — by the stable phrases ssh/our own message carry.
* One user hit 157 consecutive boot-retry failures over 2.5h against a
* reinstalled VPS (Aug 2026 bundle) because this was classified as transient.
*/
export function isHostKeyChangedBootFailure(error: unknown): boolean {
if ((error as { kind?: string } | null | undefined)?.kind === 'host-key-changed') {
return true
}
const message = error instanceof Error ? error.message : String(error ?? '')
return /REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|host key for .+ has CHANGED/i.test(
message
)
}
/**
* Whether a failed remote boot should latch (into `backendStartFailure`)
* because the host key changed. Same rationale as the reauth latch: the
* failure cannot self-heal, and an unlatched terminal failure makes every
* recovery surface re-drive the identical doomed boot. The latch is released
* by the existing reset/repair/apply-config paths once the user has run
* `ssh-keygen -R <host>`.
*/
export function shouldLatchHostKeyChangedFailure(context: RemoteBootRetryContext): boolean {
return context.attemptedRemote && context.isHostKeyChanged === true
}
/**
* Whether a failed primary-backend boot is a TRANSIENT remote failure the
* renderer may retry automatically (bounded, with backoff).
*
* This closes the self-heal gap of issue #82679: a dropped SSH/HTTP remote
* connection surfaces at the next boot as a transient transport failure
* ("Could not verify the existing SSH backend", ERR_CONNECTION_RESET, mint
* timeouts). Those never latch (see shouldLatchBackendStartFailure), but
* nothing ever RE-ATTEMPTED the boot either — the renderer's reconnect loop
* only arms after a completed boot, so the app sat on "Desktop boot failed"
* until the user manually re-entered the same connection details (which just
* forced a fresh bootstrap). A missing capability differs from a transient
* failure: confirmed reauth rejections, host-key changes, and local failures
* stay out of the retry path; everything else remote is connectivity and
* should retry.
*/
export function isRetryableRemoteBootFailure(context: RemoteBootRetryContext): boolean {
return context.attemptedRemote && !context.isReauth && context.isHostKeyChanged !== true
}
@@ -0,0 +1,125 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import {
bundledRuntimeImportCheck,
detectRemoteDisplay,
isWindowsBinaryPathInWsl,
isWslEnvironment,
resolveLinuxPasswordStore
} from './bootstrap-platform'
test('isWslEnvironment detects WSL2 env vars on linux', () => {
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'linux'), true)
assert.equal(isWslEnvironment({ WSL_INTEROP: '/run/WSL/123_interop' }, 'linux'), true)
assert.equal(isWslEnvironment({}, 'linux', '6.6.87.2-microsoft-standard-WSL2'), true)
assert.equal(isWslEnvironment({}, 'linux', '6.6.87-generic'), false)
assert.equal(isWslEnvironment({ WSL_DISTRO_NAME: 'Ubuntu' }, 'darwin'), false)
})
test('isWindowsBinaryPathInWsl blocks Windows binary types on WSL', () => {
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.exe', { isWsl: true }), true)
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.cmd', { isWsl: true }), true)
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.bat', { isWsl: true }), true)
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/install.ps1', { isWsl: true }), true)
assert.equal(isWindowsBinaryPathInWsl('/usr/local/bin/hermes', { isWsl: true }), false)
assert.equal(isWindowsBinaryPathInWsl('/mnt/c/Tools/hermes.exe', { isWsl: false }), false)
})
test('bundledRuntimeImportCheck selects platform-specific import checks', () => {
assert.equal(bundledRuntimeImportCheck('win32'), 'import fastapi, uvicorn, winpty')
assert.equal(bundledRuntimeImportCheck('darwin'), 'import fastapi, uvicorn, ptyprocess')
assert.equal(bundledRuntimeImportCheck('linux'), 'import fastapi, uvicorn, ptyprocess')
})
test('detectRemoteDisplay keeps GPU on for local sessions', () => {
// Plain local X11, Wayland, native Windows, native macOS — no remote signal.
assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':0' }, platform: 'linux' }), null)
assert.equal(detectRemoteDisplay({ env: { WAYLAND_DISPLAY: 'wayland-0' }, platform: 'linux' }), null)
assert.equal(detectRemoteDisplay({ env: { SESSIONNAME: 'Console' }, platform: 'win32' }), null)
assert.equal(detectRemoteDisplay({ env: {}, platform: 'darwin' }), null)
})
test('detectRemoteDisplay does not treat WSLg as remote', () => {
// WSLg renders locally via vGPU and doesn't show the flicker, so a WSL
// session with a local DISPLAY keeps hardware acceleration on.
assert.equal(detectRemoteDisplay({ env: { WSL_DISTRO_NAME: 'Ubuntu', DISPLAY: ':0' }, platform: 'linux' }), null)
assert.equal(
detectRemoteDisplay({ env: { WSL_INTEROP: '/run/WSL/1_interop', DISPLAY: ':0' }, platform: 'linux' }),
null
)
})
test('detectRemoteDisplay flags SSH sessions on any platform', () => {
assert.equal(
detectRemoteDisplay({ env: { SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' }, platform: 'linux' }),
'ssh-session'
)
assert.equal(detectRemoteDisplay({ env: { SSH_CLIENT: '1.2.3.4 5 22' }, platform: 'darwin' }), 'ssh-session')
assert.equal(detectRemoteDisplay({ env: { SSH_TTY: '/dev/pts/0' }, platform: 'win32' }), 'ssh-session')
})
test('detectRemoteDisplay flags forwarded X11 displays but not local ones', () => {
assert.match(String(detectRemoteDisplay({ env: { DISPLAY: 'localhost:10.0' }, platform: 'linux' })), /x11-forwarding/)
assert.match(String(detectRemoteDisplay({ env: { DISPLAY: '192.168.1.5:0' }, platform: 'linux' })), /x11-forwarding/)
assert.equal(detectRemoteDisplay({ env: { DISPLAY: ':1' }, platform: 'linux' }), null)
})
test('detectRemoteDisplay flags RDP sessions', () => {
assert.match(String(detectRemoteDisplay({ env: { SESSIONNAME: 'RDP-Tcp#7' }, platform: 'win32' })), /^rdp/)
})
test('detectRemoteDisplay honors the HERMES_DESKTOP_DISABLE_GPU override both ways', () => {
// Force-on even on a local display.
assert.match(
String(detectRemoteDisplay({ env: { HERMES_DESKTOP_DISABLE_GPU: '1', DISPLAY: ':0' }, platform: 'linux' })),
/override/
)
// Force-off even over SSH (escape hatch when a remote display has working accel).
assert.equal(
detectRemoteDisplay({
env: { HERMES_DESKTOP_DISABLE_GPU: 'false', SSH_CONNECTION: '1.2.3.4 5 6.7.8.9 22' },
platform: 'linux'
}),
null
)
})
test('resolveLinuxPasswordStore applies known backends on linux', () => {
for (const store of ['gnome-libsecret', 'kwallet', 'kwallet5', 'kwallet6', 'basic']) {
assert.deepEqual(resolveLinuxPasswordStore({ env: { HERMES_DESKTOP_PASSWORD_STORE: store }, platform: 'linux' }), {
store,
warning: null
})
}
})
test('resolveLinuxPasswordStore is a no-op when the env var is unset', () => {
assert.deepEqual(resolveLinuxPasswordStore({ env: {}, platform: 'linux' }), { store: null, warning: null })
assert.deepEqual(resolveLinuxPasswordStore({ env: { HERMES_DESKTOP_PASSWORD_STORE: ' ' }, platform: 'linux' }), {
store: null,
warning: null
})
})
test('resolveLinuxPasswordStore ignores the env var off linux', () => {
assert.deepEqual(
resolveLinuxPasswordStore({ env: { HERMES_DESKTOP_PASSWORD_STORE: 'gnome-libsecret' }, platform: 'darwin' }),
{ store: null, warning: null }
)
assert.deepEqual(
resolveLinuxPasswordStore({ env: { HERMES_DESKTOP_PASSWORD_STORE: 'kwallet6' }, platform: 'win32' }),
{ store: null, warning: null }
)
})
test('resolveLinuxPasswordStore warns on unknown values instead of applying them', () => {
const result = resolveLinuxPasswordStore({
env: { HERMES_DESKTOP_PASSWORD_STORE: 'keychain-of-wonders' },
platform: 'linux'
})
assert.equal(result.store, null)
assert.match(String(result.warning), /keychain-of-wonders/)
})
+150
View File
@@ -0,0 +1,150 @@
import fs from 'node:fs'
function isWslEnvironment(env = process.env, platform = process.platform, kernelRelease = null) {
if (platform !== 'linux') {
return false
}
if (env.WSL_DISTRO_NAME || env.WSL_INTEROP) {
return true
}
try {
const release = kernelRelease ?? fs.readFileSync('/proc/sys/kernel/osrelease', 'utf8')
return /microsoft|wsl/i.test(release)
} catch {
return false
}
}
function isWindowsBinaryPathInWsl(
filePath,
options: { isWsl?: boolean; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}
) {
const isWsl = options.isWsl ?? isWslEnvironment(options.env, options.platform)
if (!isWsl) {
return false
}
const normalized = String(filePath || '')
.replace(/\\/g, '/')
.toLowerCase()
return (
normalized.endsWith('.exe') ||
normalized.endsWith('.cmd') ||
normalized.endsWith('.bat') ||
normalized.endsWith('.ps1')
)
}
function bundledRuntimeImportCheck(platform = process.platform) {
return platform === 'win32' ? 'import fastapi, uvicorn, winpty' : 'import fastapi, uvicorn, ptyprocess'
}
const GPU_OVERRIDE_ON = new Set(['1', 'true', 'yes', 'on'])
const GPU_OVERRIDE_OFF = new Set(['0', 'false', 'no', 'off'])
/**
* Decide whether the app is being shown over a remote/forwarded display, where
* Chromium's GPU compositor produces an unstable, flickering surface (it can't
* present accelerated layers cleanly over the wire). Native local Windows/macOS
* sessions composite locally and never hit this, so we only fall back to
* software rendering when a remote display is detected.
*
* Returns a short reason string when GPU acceleration should be disabled, or
* null to keep it enabled. `HERMES_DESKTOP_DISABLE_GPU` overrides detection
* both ways (1/true/yes/on → always disable, 0/false/no/off → never disable).
*
* Pure + dependency-free so it can be unit-tested and called before app ready.
*/
function detectRemoteDisplay(options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}) {
const env = options.env ?? process.env
const platform = options.platform ?? process.platform
const override = String(env.HERMES_DESKTOP_DISABLE_GPU || '')
.trim()
.toLowerCase()
if (GPU_OVERRIDE_ON.has(override)) {
return 'override (HERMES_DESKTOP_DISABLE_GPU)'
}
if (GPU_OVERRIDE_OFF.has(override)) {
return null
}
// Launched from an SSH session → the display is X11-forwarded or otherwise
// remote. Covers the common `ssh user@box` + GUI-forwarding case.
if (env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY) {
return 'ssh-session'
}
if (platform === 'linux') {
// X11 forwarding sets DISPLAY to "<host>:N" (e.g. "localhost:10.0"); a
// local X server is ":0"/":1" with no host part before the colon.
// NB: WSLg deliberately isn't treated as remote — it reports
// GPU-accelerated vGPU surfaces locally and doesn't show the flicker.
const display = String(env.DISPLAY || '')
if (display.includes(':') && display.split(':')[0]) {
return `x11-forwarding (DISPLAY=${display})`
}
}
if (platform === 'win32') {
// RDP sessions report SESSIONNAME like "RDP-Tcp#7"; the local console is
// "Console".
const sessionName = String(env.SESSIONNAME || '')
if (/^rdp-/i.test(sessionName)) {
return `rdp (SESSIONNAME=${sessionName})`
}
}
return null
}
const LINUX_PASSWORD_STORES = new Set(['gnome-libsecret', 'kwallet', 'kwallet5', 'kwallet6', 'basic'])
/**
* Resolve the Chromium `--password-store` switch for Linux safeStorage.
*
* Without the switch Chromium often fails to pick a keychain backend when the
* app is launched outside a full desktop session, safeStorage reports
* encryption as unavailable, and hardening.ts refuses to persist remote
* gateway tokens. The `hermes desktop` launcher detects the session keychain
* (or reads `desktop.password_store` from config.yaml) and bridges the value
* in via HERMES_DESKTOP_PASSWORD_STORE.
*
* Returns `{ store, warning }`: `store` is the validated backend to apply (or
* null to leave Chromium's default), `warning` is a message to log for
* unrecognized values. Pure + dependency-free so it can be unit-tested and
* called before app ready.
*/
function resolveLinuxPasswordStore(options: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform } = {}) {
const env = options.env ?? process.env
const platform = options.platform ?? process.platform
const requested = String(env.HERMES_DESKTOP_PASSWORD_STORE || '').trim()
if (platform !== 'linux' || !requested) {
return { store: null, warning: null }
}
if (!LINUX_PASSWORD_STORES.has(requested)) {
return { store: null, warning: `ignoring unknown HERMES_DESKTOP_PASSWORD_STORE value: ${requested}` }
}
return { store: requested, warning: null }
}
export {
bundledRuntimeImportCheck,
detectRemoteDisplay,
isWindowsBinaryPathInWsl,
isWslEnvironment,
resolveLinuxPasswordStore
}
@@ -0,0 +1,109 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { decideBootstrapRepair } from './bootstrap-repair-guard'
test('first soft attempt with alive backend returns soft restart', () => {
const decision = decideBootstrapRepair({
attempt: 1,
primaryBackendAlive: true
})
assert.equal(decision.hardReinstall, false)
assert.equal(decision.attempt, 1)
assert.match(decision.reason, /still alive/)
assert.match(decision.reason, /1\/3/)
})
test('first attempt with dead backend still returns soft restart', () => {
const decision = decideBootstrapRepair({
attempt: 1,
primaryBackendAlive: false
})
assert.equal(decision.hardReinstall, false)
assert.match(decision.reason, /has exited/)
})
test('soft restart budget exhausts at maxSoftAttempts+1 and escalates', () => {
const decision = decideBootstrapRepair({
attempt: 4,
maxSoftAttempts: 3,
primaryBackendAlive: true
})
assert.equal(decision.hardReinstall, true)
assert.equal(decision.attempt, 4)
assert.match(decision.reason, /exceeds soft-restart budget/)
})
test('attempt exactly at maxSoftAttempts is still soft', () => {
const decision = decideBootstrapRepair({
attempt: 3,
maxSoftAttempts: 3,
primaryBackendAlive: true
})
assert.equal(decision.hardReinstall, false)
assert.equal(decision.attempt, 3)
})
test('custom maxSoftAttempts is honored', () => {
const soft = decideBootstrapRepair({
attempt: 5,
maxSoftAttempts: 10,
primaryBackendAlive: true
})
assert.equal(soft.hardReinstall, false)
const hard = decideBootstrapRepair({
attempt: 11,
maxSoftAttempts: 10,
primaryBackendAlive: false
})
assert.equal(hard.hardReinstall, true)
})
test('default maxSoftAttempts is 3', () => {
// Probe the default indirectly: attempt 4 with no override must escalate.
const decision = decideBootstrapRepair({
attempt: 4,
primaryBackendAlive: true
})
assert.equal(decision.hardReinstall, true)
})
test('fractional or zero attempts are clamped to 1', () => {
const zeroDecision = decideBootstrapRepair({
attempt: 0,
primaryBackendAlive: true
})
assert.equal(zeroDecision.attempt, 1)
assert.equal(zeroDecision.hardReinstall, false)
const fractionalDecision = decideBootstrapRepair({
attempt: 2.7,
primaryBackendAlive: true
})
assert.equal(fractionalDecision.attempt, 2)
assert.equal(fractionalDecision.hardReinstall, false)
})
test('alive=false on a high attempt number still escalates (defense in depth)', () => {
// A dead backend should normally be handled by the renderer before it
// reaches the repair path, but if it does reach us with a high attempt
// count we still escalate — never silently keep soft-restarting.
const decision = decideBootstrapRepair({
attempt: 5,
maxSoftAttempts: 3,
primaryBackendAlive: false
})
assert.equal(decision.hardReinstall, true)
})
@@ -0,0 +1,121 @@
/**
* Repair-loop guard for the desktop bootstrap.
*
* Why this exists
* ───────────────
* Hermes desktop can request a "repair" of its bundled backend when the
* renderer observes a transient backend failure (see issue #74874). The
* classic failure fingerprint:
*
* 1. Backend Python process hits a transient GIL stall (e.g. heavy
* import, MCP discovery, a long-running agent turn).
* 2. The renderer's WebSocket can't deliver the `gateway.ready` frame
* in time and treats the socket as dead.
* 3. Renderer calls `hermes:bootstrap:repair`.
* 4. Bootstrap unconditionally force-reinstalls the venv, restarting
* the backend — which stalls again for the same reason.
* 5. Renderer reports dead backend → another repair → infinite loop.
*
* The desktop should distinguish:
* - "the venv/install is genuinely broken" → hard reinstall is correct
* - "the runtime is healthy but temporarily stalled" → restart only,
* NOT a destructive reinstall that drops the venv
*
* What this module does
* ─────────────────────
* A pure decision helper. Given the current repair attempt count and a
* hint about whether the live backend process still looks alive, return
* whether the next repair should:
* - `hardReinstall: true` → run the installer, recreate the venv
* - `hardReinstall: false` → restart the existing backend, keep the venv
*
* Cap on soft restarts is bounded so an actually-corrupted install still
* eventually escalates to a hard reinstall after repeated stalls — the
* guard prevents the *unbounded* reinstall loop, not all reinstalls.
*
* The module is intentionally pure (no I/O, no logging, no global state)
* so it is unit-testable in isolation. Wiring into `main.ts` lives there.
*/
export type RepairDecision =
| {
/** Run the installer (recreate venv). Caller bypasses the active runtime. */
hardReinstall: true
/** Human-readable rationale for the desktop log. */
reason: string
/** 1-indexed repair attempt number for diagnostics. */
attempt: number
}
| {
/** Skip the installer; restart the existing backend only. */
hardReinstall: false
reason: string
attempt: number
}
export type RepairDecisionInput = {
/**
* 1-indexed count of how many repair attempts have happened in this
* failure episode. The first repair is `attempt === 1`; a successful
* boot resets the counter (see `main.ts`'s bootstrap completion path).
*/
attempt: number
/**
* Soft-restart budget before escalation to a hard reinstall. Defaults
* to 3: three "just restart" attempts, then a real reinstall. Bounded
* so a corrupt install still gets fixed; high enough that a GIL
* stall no longer loops the user into a 30-minute reinstall cycle.
*/
maxSoftAttempts?: number
/**
* Whether the live backend process (the one we are about to tear down
* to honour the repair request) still looks alive. A process whose
* `exitCode !== null` or `signalCode !== null` has actually exited;
* a process with both null is either still running or stalled — and a
* stall is exactly the case the soft-restart path is for.
*/
primaryBackendAlive: boolean
}
/**
* Decide the next repair action.
*
* Decision matrix:
* attempt ≤ maxSoftAttempts AND alive → soft restart (don't reinstall)
* attempt ≤ maxSoftAttempts AND dead → soft restart (process exited,
* but we don't yet trust that
* the install is corrupt; restart
* once to confirm)
* attempt > maxSoftAttempts → hard reinstall (give up on the
* current install)
*
* "Alive" being true does NOT force a soft restart on every call: the
* attempt counter still increments, so an actually-broken install that
* keeps respawning a child but never announces READY still escalates
* after `maxSoftAttempts` cycles.
*/
export function decideBootstrapRepair(input: RepairDecisionInput): RepairDecision {
const maxSoftAttempts = input.maxSoftAttempts ?? 3
const attempt = Math.max(1, Math.floor(input.attempt))
const alive = Boolean(input.primaryBackendAlive)
if (attempt > maxSoftAttempts) {
return {
hardReinstall: true,
attempt,
reason:
`repair attempt ${attempt} exceeds soft-restart budget ` + `(${maxSoftAttempts}); escalating to hard reinstall`
}
}
return {
hardReinstall: false,
attempt,
reason: alive
? `repair attempt ${attempt}/${maxSoftAttempts}: primary backend process ` +
`still alive (likely transient stall, see #74874); restarting only, ` +
`skipping installer`
: `repair attempt ${attempt}/${maxSoftAttempts}: primary backend process ` +
`has exited; restarting before escalating to reinstall`
}
}
@@ -0,0 +1,274 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import {
buildPinArgs,
buildPosixPinArgs,
cachedScriptPath,
hasExistingGitCheckout,
installedAgentInstallScript,
installRefForStamp,
isPinnedCommit,
resolveInstallScript,
resolveMarkerPinnedCommit,
runBootstrap
} from './bootstrap-runner'
const SCRIPT_NAME = process.platform === 'win32' ? 'install.ps1' : 'install.sh'
const ZERO_COMMIT = '0000000000000000000000000000000000000000'
function mkTmpHome() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-bootstrap-test-'))
}
test('runBootstrap bails immediately when the signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
const events = []
const result = await runBootstrap({
installStamp: null,
activeRoot: '/tmp/hermes-runner-test',
sourceRepoRoot: null,
hermesHome: '/tmp/hermes-runner-test',
logRoot: '/tmp/hermes-runner-test',
onEvent: ev => events.push(ev),
abortSignal: controller.signal
})
// Cancelled before any install script is spawned.
assert.deepEqual(result, { ok: false, cancelled: true })
assert.ok(
events.some(ev => ev.type === 'failed' && /cancelled/i.test(ev.error)),
'should emit a cancelled failure event'
)
})
test('installedAgentInstallScript resolves the installer in the agent checkout', () => {
const home = mkTmpHome()
try {
assert.equal(installedAgentInstallScript(home), null, 'absent before the checkout exists')
const scriptsDir = path.join(home, 'hermes-agent', 'scripts')
fs.mkdirSync(scriptsDir, { recursive: true })
const scriptPath = path.join(scriptsDir, SCRIPT_NAME)
fs.writeFileSync(scriptPath, '#!/bin/sh\necho hi\n')
assert.equal(installedAgentInstallScript(home), scriptPath)
assert.equal(installedAgentInstallScript(null), null, 'null home -> null')
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
test('existing checkout detection requires git metadata', () => {
const home = mkTmpHome()
try {
const activeRoot = path.join(home, 'hermes-agent')
assert.equal(hasExistingGitCheckout(activeRoot), false)
fs.mkdirSync(path.join(activeRoot, '.git'), { recursive: true })
assert.equal(hasExistingGitCheckout(activeRoot), true)
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
test('fresh bootstrap args include the packaged commit pin', () => {
const installStamp = { commit: 'a'.repeat(40), branch: 'main' }
assert.deepEqual(buildPinArgs(installStamp), ['-Commit', installStamp.commit, '-Branch', 'main'])
assert.deepEqual(
buildPosixPinArgs({
installStamp,
activeRoot: '/tmp/hermes-agent',
hermesHome: '/tmp/hermes'
}),
['--dir', '/tmp/hermes-agent', '--hermes-home', '/tmp/hermes', '--branch', 'main', '--commit', installStamp.commit]
)
})
test('existing-checkout bootstrap args keep branch but skip the packaged commit pin', () => {
const installStamp = { commit: 'a'.repeat(40), branch: 'main' }
assert.deepEqual(buildPinArgs(installStamp, { pinCommit: false }), ['-Branch', 'main'])
assert.deepEqual(
buildPosixPinArgs({
installStamp,
activeRoot: '/tmp/hermes-agent',
hermesHome: '/tmp/hermes',
pinCommit: false
}),
['--dir', '/tmp/hermes-agent', '--hermes-home', '/tmp/hermes', '--branch', 'main']
)
})
test('fallback install stamps use an unpinned branch ref', () => {
const stamp = { commit: ZERO_COMMIT, branch: 'main' }
assert.equal(isPinnedCommit(ZERO_COMMIT), false)
assert.deepEqual(installRefForStamp(stamp), {
ref: 'main',
cacheKey: 'fallback-main',
pinned: false
})
// Must NOT pass -Commit / --commit for the all-zero placeholder.
assert.deepEqual(buildPinArgs(stamp), ['-Branch', 'main'])
assert.deepEqual(
buildPosixPinArgs({
installStamp: stamp,
activeRoot: '/tmp/hermes',
hermesHome: '/tmp/home'
}),
['--dir', '/tmp/hermes', '--hermes-home', '/tmp/home', '--branch', 'main']
)
})
test('resolveMarkerPinnedCommit prefers real HEAD over fallback stamp zeros', () => {
const realHead = 'c'.repeat(40)
assert.equal(
resolveMarkerPinnedCommit({ commit: ZERO_COMMIT, branch: 'main' }, '/tmp/checkout', {
resolveHead: () => realHead
}),
realHead
)
assert.equal(
resolveMarkerPinnedCommit({ commit: 'd'.repeat(40), branch: 'main' }, '/tmp/checkout', {
resolveHead: () => realHead
}),
'd'.repeat(40),
'packaged real pin wins over checkout HEAD'
)
assert.equal(
resolveMarkerPinnedCommit({ commit: ZERO_COMMIT, branch: 'main' }, '/tmp/missing', {
resolveHead: () => null
}),
null
)
})
test('resolveInstallScript downloads fallback stamps by branch instead of zero commit', async () => {
const home = mkTmpHome()
try {
const logs = []
const refs = []
const result = await resolveInstallScript({
installStamp: { commit: ZERO_COMMIT, branch: 'main' },
sourceRepoRoot: null,
hermesHome: home,
emit: ev => logs.push(ev),
_download: async (ref, destPath) => {
refs.push(ref)
fs.mkdirSync(path.dirname(destPath), { recursive: true })
fs.writeFileSync(destPath, '#!/bin/sh\necho fallback branch\n')
return destPath
}
})
assert.deepEqual(refs, ['main'])
assert.equal(result.source, 'download')
assert.equal(result.commit, null)
assert.equal(result.path, cachedScriptPath(home, 'fallback-main'))
assert.ok(
logs.some(ev => /fallback, unpinned/.test(ev.line || '')),
'emits an unpinned fallback log line'
)
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
test('resolveInstallScript prefers a cached script without touching the network', async () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
const cached = cachedScriptPath(home, commit)
fs.mkdirSync(path.dirname(cached), { recursive: true })
fs.writeFileSync(cached, '#!/bin/sh\necho cached\n')
const logs = []
const result = await resolveInstallScript({
installStamp: { commit },
sourceRepoRoot: null,
hermesHome: home,
emit: ev => logs.push(ev)
})
assert.equal(result.source, 'cache')
assert.equal(result.path, cached)
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
test('resolveInstallScript falls back to the installed agent checkout on a 404', async () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
// Seed the installed agent checkout so the fallback has something to resolve.
const scriptsDir = path.join(home, 'hermes-agent', 'scripts')
fs.mkdirSync(scriptsDir, { recursive: true })
const installed = path.join(scriptsDir, SCRIPT_NAME)
fs.writeFileSync(installed, '#!/bin/sh\necho fallback\n')
const logs = []
const result = await resolveInstallScript({
installStamp: { commit },
sourceRepoRoot: null,
hermesHome: home,
emit: ev => logs.push(ev),
// Simulate GitHub returning a 404 for the pinned commit.
_download: async () => {
throw new Error('Failed to download install.sh: HTTP 404')
}
})
assert.equal(result.source, 'installed-agent')
// It should have copied the installer into the bootstrap cache.
assert.equal(result.path, cachedScriptPath(home, commit))
assert.ok(fs.existsSync(result.path), 'fallback script copied into cache')
assert.ok(
logs.some(ev => /falling back to installed agent/.test(ev.line || '')),
'emits a fallback log line'
)
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
test('resolveInstallScript rethrows when the 404 fallback is unavailable', async () => {
const home = mkTmpHome()
try {
const commit = 'a'.repeat(40)
// No installed agent checkout seeded -> nothing to fall back to.
await assert.rejects(
resolveInstallScript({
installStamp: { commit },
sourceRepoRoot: null,
hermesHome: home,
emit: () => {},
_download: async () => {
throw new Error('Failed to download install.sh: HTTP 404')
}
}),
/HTTP 404|Failed to download/
)
} finally {
fs.rmSync(home, { recursive: true, force: true })
}
})
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { buildBrowserWindowUrl } from './browser-windows'
test('buildBrowserWindowUrl puts win=browser before the hash (dev server)', () => {
const url = buildBrowserWindowUrl('url:browser-1', { devServer: 'http://localhost:5173' })
assert.equal(url, 'http://localhost:5173/?win=browser&tab=url%3Abrowser-1#/')
assert.ok(url.indexOf('?win=browser') < url.indexOf('#'))
})
test('buildBrowserWindowUrl encodes the tab id', () => {
const url = buildBrowserWindowUrl('url:browser a/b', { devServer: 'http://localhost:5173' })
assert.equal(url, 'http://localhost:5173/?win=browser&tab=url%3Abrowser%20a%2Fb#/')
})
test('buildBrowserWindowUrl avoids a double slash when the dev server has a trailing slash', () => {
const url = buildBrowserWindowUrl('t', { devServer: 'http://localhost:5173/' })
assert.equal(url, 'http://localhost:5173/?win=browser&tab=t#/')
})
test('buildBrowserWindowUrl omits a blank tab', () => {
assert.equal(
buildBrowserWindowUrl(' ', { devServer: 'http://localhost:5173' }),
'http://localhost:5173/?win=browser#/'
)
assert.equal(
buildBrowserWindowUrl(null, { devServer: 'http://localhost:5173' }),
'http://localhost:5173/?win=browser#/'
)
})
test('buildBrowserWindowUrl builds a packaged file URL with the flag before the hash', () => {
const url = buildBrowserWindowUrl('abc', { rendererIndexPath: '/opt/app/index.html' })
assert.match(url, /^file:\/\/.*index\.html\?win=browser&tab=abc#\/$/)
})
+31
View File
@@ -0,0 +1,31 @@
// Popped-out in-app Browser windows. Same query-before-hash contract as
// session-windows / hud-url: `?win=browser` MUST sit in the search string
// before the '#', or HashRouter swallows it as part of the route.
import { pathToFileURL } from 'node:url'
export const BROWSER_WINDOW_WIDTH = 960
export const BROWSER_WINDOW_HEIGHT = 720
export const BROWSER_WINDOW_MIN_WIDTH = 480
export const BROWSER_WINDOW_MIN_HEIGHT = 400
/**
* Renderer URL for a popped-out Browser. `tab` is the `$previewTabs` id the
* window should show — the tab stays in storage so closing the window can
* dock it again. Absent/blank tab is still a valid Browser window (blank page).
*/
export function buildBrowserWindowUrl(
tabId: null | string | undefined,
{ devServer, rendererIndexPath }: { devServer?: null | string; rendererIndexPath?: string } = {}
): string {
const tab = typeof tabId === 'string' ? tabId.trim() : ''
const query = `?win=browser${tab ? `&tab=${encodeURIComponent(tab)}` : ''}`
if (devServer) {
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
return `${base}/${query}#/`
}
return `${pathToFileURL(rendererIndexPath!).toString()}${query}#/`
}
+320
View File
@@ -0,0 +1,320 @@
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { detectBundleSkew, isFallbackCommit, type RunGit, RUNTIME_PATHS } from './bundle-skew'
const REPO = '/repo'
const STAMP = { commit: 'a'.repeat(40), source: 'ci' }
function gitReturning(stdout: string, code = 0): RunGit {
return async () => ({ code, stderr: '', stdout })
}
/**
* A git fake that answers per subcommand, so a test can say "ancestry fails,
* but the count would have claimed skew" — which is the shape of #92233.
*/
function gitAnswering(answers: Record<string, { code?: number; stderr?: string; stdout?: string }>): {
calls: string[][]
git: RunGit
} {
const calls: string[][] = []
const git: RunGit = async args => {
calls.push(args)
const answer = answers[args[0]] ?? {}
return {
code: answer.code ?? 0,
stderr: answer.stderr ?? '',
stdout: answer.stdout ?? ''
}
}
return { calls, git }
}
/** Every subcommand succeeds; rev-list reports `count`. */
function gitCounting(count: string): RunGit {
return gitAnswering({ 'merge-base': { code: 0 }, 'rev-list': { stdout: count } }).git
}
describe('isFallbackCommit', () => {
it('matches the all-zero placeholder at any stamp length', () => {
expect(isFallbackCommit('0'.repeat(40))).toBe(true)
expect(isFallbackCommit('0'.repeat(7))).toBe(true)
expect(isFallbackCommit('a'.repeat(40))).toBe(false)
})
})
describe('detectBundleSkew', () => {
it('reports stale when desktop commits landed after the stamp', async () => {
const result = await detectBundleSkew(STAMP, gitCounting('3\n'), REPO)
expect(result).toEqual({ desktopCommitsBehind: 3, outOfSync: true })
})
it('counts only commits that touch runtime desktop paths', async () => {
const { calls, git } = gitAnswering({ 'merge-base': { code: 0 }, 'rev-list': { stdout: '0' } })
await detectBundleSkew(STAMP, git, REPO)
expect(calls[1]).toEqual(['rev-list', '--count', `${STAMP.commit}..HEAD`, '--', ...RUNTIME_PATHS])
})
it('is quiet when no desktop commits follow the stamp', async () => {
const result = await detectBundleSkew(STAMP, gitCounting('0\n'), REPO)
expect(result).toEqual({ desktopCommitsBehind: 0, outOfSync: false })
})
it('is quiet without a stamp (dev runs)', async () => {
expect(await detectBundleSkew(null, gitReturning('9'), REPO)).toEqual({
desktopCommitsBehind: null,
outOfSync: false
})
})
it('is quiet on a fallback stamp (non-git build)', async () => {
const fallback = { commit: '0'.repeat(40), source: 'fallback' }
expect(await detectBundleSkew(fallback, gitReturning('9'), REPO)).toEqual({
desktopCommitsBehind: null,
outOfSync: false
})
})
it('is quiet when git fails (unknown commit, shallow clone, no git)', async () => {
expect(await detectBundleSkew(STAMP, gitReturning('', 128), REPO)).toEqual({
desktopCommitsBehind: null,
outOfSync: false
})
})
it('is quiet when git throws', async () => {
const git: RunGit = async () => {
throw new Error('spawn ENOENT')
}
expect(await detectBundleSkew(STAMP, git, REPO)).toEqual({
desktopCommitsBehind: null,
outOfSync: false
})
})
it('is quiet on unparsable rev-list output', async () => {
expect(await detectBundleSkew(STAMP, gitCounting('fatal: bad object'), REPO)).toEqual({
desktopCommitsBehind: null,
outOfSync: false
})
})
// #92233: a ZIP-fallback update rewrites the tree into a synthetic root, so
// the stamp commit still RESOLVES but is unreachable from HEAD. `A..HEAD`
// then counts HEAD's own history instead of measuring skew, and reports a
// permanent 1 even though apps/desktop is byte-identical. The user gets an
// "App build out of date" warning that cannot go off, so no remedy clears it.
it('is quiet when the stamp is not an ancestor of HEAD', async () => {
const { git } = gitAnswering({
'merge-base': { code: 1 },
'rev-list': { stdout: '1\n' }
})
expect(await detectBundleSkew(STAMP, git, REPO)).toEqual({
desktopCommitsBehind: null,
outOfSync: false
})
})
it('does not consult the commit count once ancestry is refused', async () => {
const { calls, git } = gitAnswering({
'merge-base': { code: 1 },
'rev-list': { stdout: '9999\n' }
})
await detectBundleSkew(STAMP, git, REPO)
expect(calls.map(args => args[0])).toEqual(['merge-base'])
})
it('asks about ancestry before counting, against the same stamp', async () => {
const { calls, git } = gitAnswering({
'merge-base': { code: 0 },
'rev-list': { stdout: '2\n' }
})
const result = await detectBundleSkew(STAMP, git, REPO)
expect(calls[0]).toEqual(['merge-base', '--is-ancestor', STAMP.commit, 'HEAD'])
expect(calls[1]?.[0]).toBe('rev-list')
expect(result).toEqual({ desktopCommitsBehind: 2, outOfSync: true })
})
it('is quiet when git cannot answer the ancestry question at all', async () => {
const { git } = gitAnswering({
'merge-base': { code: 128 },
'rev-list': { stdout: '4\n' }
})
expect(await detectBundleSkew(STAMP, git, REPO)).toEqual({
desktopCommitsBehind: null,
outOfSync: false
})
})
// Shallow clones, measured against git 2.55 rather than assumed. A stamp
// commit from BEFORE the graft boundary is not an object the clone has, so
// `--is-ancestor` exits 128 with "Not a valid object name" — the same
// unknowable bucket as any other missing commit, not a shallow-specific
// failure. A stamp INSIDE the shallow graph is answered normally, so
// `--fetch-depth`-limited CI checkouts do not lose skew detection wholesale;
// only builds stamped deeper than the checkout goes do.
it('is quiet on a shallow clone whose stamp predates the graft boundary', async () => {
const { calls, git } = gitAnswering({
'merge-base': {
code: 128,
stderr: `fatal: Not a valid object name ${STAMP.commit}`
},
'rev-list': { stdout: '7\n' }
})
expect(await detectBundleSkew(STAMP, git, REPO)).toEqual({
desktopCommitsBehind: null,
outOfSync: false
})
expect(calls).toHaveLength(1)
})
it('still detects skew on a shallow clone when the stamp is in the graph', async () => {
const { git } = gitAnswering({
'merge-base': { code: 0 },
'rev-list': { stdout: '2\n' }
})
expect(await detectBundleSkew(STAMP, git, REPO)).toEqual({
desktopCommitsBehind: 2,
outOfSync: true
})
})
})
// Real-git integration: proves the pathspec discriminates docs/e2e-only
// commits from runtime commits, and that a disconnected stamp goes quiet, in
// an actual repository rather than against a hand-written fake.
const scratchRepos: string[] = []
afterAll(() => {
for (const dir of scratchRepos) {
rmSync(dir, { force: true, recursive: true })
}
})
function scratchGit(repoRoot: string) {
return (...args: string[]) =>
execFileSync('git', ['-c', 'user.email=skew@test', '-c', 'user.name=skew', ...args], {
cwd: repoRoot,
stdio: ['ignore', 'pipe', 'pipe']
})
.toString()
.trim()
}
function makeScratchRepo(): { base: string; repoRoot: string } {
const repoRoot = mkdtempSync(join(tmpdir(), 'bundle-skew-'))
scratchRepos.push(repoRoot)
const git = scratchGit(repoRoot)
git('init', '-q', '-b', 'main')
git('commit', '-q', '--allow-empty', '-m', 'base')
return { base: git('rev-parse', 'HEAD'), repoRoot }
}
function writeFiles(repoRoot: string, files: string[]) {
for (const file of files) {
const target = join(repoRoot, file)
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, '')
}
}
function realGitRun(root: string): RunGit {
return async (args, options) => {
try {
const stdout = execFileSync('git', args, {
cwd: options.cwd || root,
stdio: ['ignore', 'pipe', 'pipe']
}).toString()
return { code: 0, stderr: '', stdout }
} catch (error) {
const e = error as { status?: number; stderr?: Buffer; stdout?: Buffer }
return {
code: e.status ?? 1,
stderr: e.stderr?.toString() ?? '',
stdout: e.stdout?.toString() ?? ''
}
}
}
}
describe('detectBundleSkew against a real git repo', () => {
it('is quiet when only docs and e2e specs changed under apps/desktop', async () => {
const { base, repoRoot } = makeScratchRepo()
const git = scratchGit(repoRoot)
writeFiles(repoRoot, ['apps/desktop/AGENTS.md', 'apps/desktop/e2e/boot.spec.ts'])
git('add', '.')
git('commit', '-q', '-m', 'docs and e2e only')
const result = await detectBundleSkew({ commit: base, source: 'local' }, realGitRun(repoRoot), repoRoot)
expect(result).toEqual({ desktopCommitsBehind: 0, outOfSync: false })
})
it('warns when a renderer file changed under apps/desktop', async () => {
const { base, repoRoot } = makeScratchRepo()
const git = scratchGit(repoRoot)
writeFiles(repoRoot, ['apps/desktop/src/app/new-feature.tsx', 'apps/desktop/README.md'])
git('add', '.')
git('commit', '-q', '-m', 'renderer change')
const result = await detectBundleSkew({ commit: base, source: 'local' }, realGitRun(repoRoot), repoRoot)
expect(result).toEqual({ desktopCommitsBehind: 1, outOfSync: true })
})
// The #92233 install, reproduced: the update rewrote the tree onto a fresh
// orphan root, so the stamp resolves but is unreachable. Real git answers
// `rev-list` with a positive count here — ancestry is the only thing that
// keeps the banner off.
it('is quiet when the stamp sits on a disconnected root', async () => {
const { base, repoRoot } = makeScratchRepo()
const git = scratchGit(repoRoot)
git('checkout', '-q', '--orphan', 'rewritten')
writeFiles(repoRoot, ['apps/desktop/src/app/shell.tsx'])
git('add', '.')
git('commit', '-q', '-m', 'synthetic root after a ZIP-fallback update')
const runGit = realGitRun(repoRoot)
// Precondition: the raw count this function used to trust is nonzero.
const raw = await runGit(['rev-list', '--count', `${base}..HEAD`, '--', ...RUNTIME_PATHS], { cwd: repoRoot })
expect(Number.parseInt(raw.stdout.trim(), 10)).toBeGreaterThan(0)
const result = await detectBundleSkew({ commit: base, source: 'local' }, runGit, repoRoot)
expect(result).toEqual({ desktopCommitsBehind: null, outOfSync: false })
})
})
+133
View File
@@ -0,0 +1,133 @@
/**
* Renderer-bundle skew detection.
*
* The desktop UI (including bundled plugins like Bot Mode) is compiled into
* the app binary at build time, while `hermes update` only moves the source
* tree. A user who updates from the terminal — or whose in-app update failed
* on the bundle-swap leg — ends up running a NEW runtime under an OLD
* renderer: About proudly reports the new Hermes version while the sidebar
* is missing the features that version shipped (the "no Bots tab after the
* Bot Mode update" reports).
*
* Detection: the packaged build carries install-stamp.json with the commit
* it was built from. If commits touching the RUNTIME paths of apps/desktop
* exist in the source tree AFTER that stamp commit, the running renderer is
* provably missing desktop changes the installed runtime has:
*
* git merge-base --is-ancestor <stampCommit> HEAD
* git rev-list --count <stampCommit>..HEAD -- <RUNTIME_PATHS>
*
* Ancestry has to come first, because `A..HEAD` only means "how far HEAD is
* ahead of A" when A is an ancestor of HEAD. When it is not, the range
* degenerates to HEAD's own history and the count stops describing skew at
* all: an update that rewrote the tree into a synthetic root leaves a stamp
* commit that still resolves but sits on a disconnected graph, so the count
* is a permanent >= 1 even when apps/desktop is byte-identical (#92233).
* Resolving the stamp is not enough — an unknown commit already exits
* non-zero below, but a merely *unrelated* one exits 0 with a positive count.
*
* Scoping to runtime paths keeps this quiet for the common cases where the
* repo advances without user-visible desktop changes: agent-only commits
* elsewhere in the repo, and docs / e2e spec / dev-script churn under
* apps/desktop that never reaches the shipped renderer or main process
* (#99832).
*
* Fail-quiet by design: no stamp (dev runs), a fallback all-zero stamp
* (non-git build), an unknown commit (stamp predates a shallow clone's
* history), a stamp that is not an ancestor of HEAD, or any git failure all
* report "not stale". This warning must never false-positive — it tells
* users their install is torn.
*
* Pure + injectable so it is testable without booting Electron or git.
*/
export interface BundleSkewStamp {
commit: string
/** write-build-stamp.mjs source tag — 'fallback' means the commit is fake. */
source?: null | string
}
export interface BundleSkewResult {
/** Runtime-path commits between the build stamp and HEAD (null = unknowable). */
desktopCommitsBehind: null | number
/** True only on positive proof that the renderer predates desktop changes in the tree. */
outOfSync: boolean
}
export type RunGit = (
args: string[],
options: { cwd: string }
) => Promise<{ code: number; stderr: string; stdout: string }>
/**
* The apps/desktop paths that actually reach the user: renderer sources,
* main-process sources, the HTML entry, the public/ assets Vite copies into
* the bundle, app icons, and the packaging config. Docs, e2e specs, scratch
* scripts, and dev tooling never reach the shipped app, so a delta confined
* to them is not a torn install in any way the user can see.
*/
export const RUNTIME_PATHS = [
'apps/desktop/src',
'apps/desktop/electron',
'apps/desktop/index.html',
'apps/desktop/public',
'apps/desktop/assets',
'apps/desktop/package.json',
'apps/desktop/vite.config.ts'
] as const
const NOT_STALE: BundleSkewResult = { desktopCommitsBehind: null, outOfSync: false }
/** Matches write-build-stamp.mjs's all-zero placeholder for non-git builds. */
export function isFallbackCommit(commit: string): boolean {
return /^0{7,40}$/.test(commit)
}
export async function detectBundleSkew(
stamp: BundleSkewStamp | null,
runGit: RunGit,
repoRoot: string
): Promise<BundleSkewResult> {
if (!stamp?.commit || stamp.source === 'fallback' || isFallbackCommit(stamp.commit)) {
return NOT_STALE
}
try {
// Exit 0 = ancestor, 1 = unrelated or diverged, anything else = git could
// not answer (unknown object, shallow clone, not a repo). Only the first
// makes the commit count below a statement about skew, and the other two
// are the same "unknowable" the branches above already answer quietly.
//
// Deliberately not falling back to comparing apps/desktop CONTENT here.
// Differing content would prove the build and the tree disagree, but not
// which way round: a user sitting on an older checkout than their build
// would be told "app build out of date" backwards. Ancestry is what makes
// this a proof that the renderer PREDATES the tree, which is the claim the
// warning actually makes.
const ancestry = await runGit(['merge-base', '--is-ancestor', stamp.commit, 'HEAD'], {
cwd: repoRoot
})
if (ancestry.code !== 0) {
return NOT_STALE
}
const result = await runGit(['rev-list', '--count', `${stamp.commit}..HEAD`, '--', ...RUNTIME_PATHS], {
cwd: repoRoot
})
if (result.code !== 0) {
return NOT_STALE
}
const count = Number.parseInt(result.stdout.trim(), 10)
if (!Number.isFinite(count) || count <= 0) {
return { desktopCommitsBehind: Number.isFinite(count) ? count : null, outOfSync: false }
}
return { desktopCommitsBehind: count, outOfSync: true }
} catch {
return NOT_STALE
}
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { detectBundleSwap } from './bundle-swap'
const RUNNING = { builtAt: '2026-08-29T04:00:00.000Z', commit: 'a'.repeat(40), source: 'local' }
describe('detectBundleSwap', () => {
it('reports a swap when the on-disk stamp carries a different commit', () => {
const onDisk = { ...RUNNING, commit: 'b'.repeat(40) }
expect(detectBundleSwap(RUNNING, onDisk)).toBe(true)
})
it('reports a swap when the same commit was rebuilt (builtAt moved)', () => {
const onDisk = { ...RUNNING, builtAt: '2026-08-31T23:55:41.149Z' }
expect(detectBundleSwap(RUNNING, onDisk)).toBe(true)
})
// The Windows locked-binary case (#92233): the swap leg failed, so the
// bundle on disk is still the one we are running. A relaunch would repair
// nothing and cost the user their window.
it('is quiet when the on-disk stamp matches the running one', () => {
expect(detectBundleSwap(RUNNING, { ...RUNNING })).toBe(false)
})
it('is quiet without a running stamp (dev runs)', () => {
expect(detectBundleSwap(null, { ...RUNNING })).toBe(false)
})
it('is quiet without an on-disk stamp (unreadable resources)', () => {
expect(detectBundleSwap(RUNNING, null)).toBe(false)
})
it('is quiet on a fallback stamp on either side (non-git build)', () => {
const fallbackTagged = { ...RUNNING, source: 'fallback' }
const fallbackCommit = { ...RUNNING, commit: '0'.repeat(40) }
expect(detectBundleSwap(fallbackTagged, { ...RUNNING, commit: 'b'.repeat(40) })).toBe(false)
expect(detectBundleSwap(RUNNING, fallbackCommit)).toBe(false)
})
it('treats a missing builtAt on either side as unprovable at the same commit', () => {
const noBuiltAt = { commit: RUNNING.commit, source: 'local' }
expect(detectBundleSwap(noBuiltAt, { ...RUNNING })).toBe(false)
expect(detectBundleSwap(RUNNING, noBuiltAt)).toBe(false)
})
})
+61
View File
@@ -0,0 +1,61 @@
/**
* Swapped-bundle detection.
*
* The detached updater (scripts/desktop-update/posix.sh mac_swap /
* windows.ps1) rebuilds and swaps the packaged app on disk AFTER
* `hermes update` exits. An instance that was launched from the PRE-swap
* bundle — the user reopened Hermes mid-update, the #50238 gesture the boot
* gate exists for — would otherwise proceed to run the NEW runtime under the
* OLD renderer. The updater's own `open`/relaunch leg cannot rescue it: the
* single-instance lock turns that into a focus of the parked process, so no
* process ever loads the new build.
*
* That is the stale-renderer tail of a FULLY SUCCESSFUL update: the "App
* build out of date" banner appears right after the update, while the Updates
* card says "You're on the latest version" and so offers nothing that would
* clear it.
*
* Detection: compare the install stamp this process loaded at boot with the
* one on disk now. A different commit — or a different builtAt at the same
* commit (a dirty-tree or content-hash rebuild) — means the bundle under our
* feet is not the one we are running, and a plain relaunch loads it.
*
* Fail-quiet like bundle-skew: a missing stamp on either side (dev runs,
* unreadable resources) or a fallback all-zero commit reports "not swapped".
* This must never false-positive — a positive triggers an automatic relaunch.
*
* Pure so it is testable without booting Electron.
*/
import { isFallbackCommit } from './bundle-skew'
export interface BundleSwapStamp {
/** write-build-stamp.mjs build timestamp — differs on every rebuild. */
builtAt?: null | string
commit: string
/** write-build-stamp.mjs source tag — 'fallback' means the commit is fake. */
source?: null | string
}
/** True only on positive proof that the bundle on disk is not the running one. */
export function detectBundleSwap(running: BundleSwapStamp | null, onDisk: BundleSwapStamp | null): boolean {
if (!running?.commit || !onDisk?.commit) {
return false
}
if (running.source === 'fallback' || isFallbackCommit(running.commit)) {
return false
}
if (onDisk.source === 'fallback' || isFallbackCommit(onDisk.commit)) {
return false
}
if (running.commit !== onDisk.commit) {
return true
}
// Same commit: only a builtAt PRESENT ON BOTH sides can prove a rebuild —
// a missing timestamp (older stamp schema) proves nothing.
return Boolean(running.builtAt && onDisk.builtAt && running.builtAt !== onDisk.builtAt)
}
@@ -0,0 +1,181 @@
import { describe, expect, it, vi } from 'vitest'
import {
applyConnectionChange,
commitConnectionFailure,
resolveTerminalConnection,
sshQuitShouldBlock,
teardownSshState
} from './connection-apply'
function deferred() {
let resolve!: () => void
const promise = new Promise<void>(done => {
resolve = done
})
return { promise, resolve }
}
describe('applyConnectionChange', () => {
it.each([['SSH A to SSH B'], ['SSH to Cloud'], ['Cloud to SSH']])(
'serializes %s behind bootstrap rollback before teardown and apply',
async () => {
const gate = deferred()
const events: string[] = []
const run = applyConnectionChange({
cancelAndWait: async () => {
events.push('cancel')
await gate.promise
events.push('drained')
},
isPrimary: true,
scope: '',
sendApplied: () => events.push('applied'),
stopPool: vi.fn(),
teardownPrimary: async () => {
events.push('primary')
},
teardownSsh: async () => {
events.push('ssh')
}
})
await Promise.resolve()
expect(events).toEqual(['cancel'])
gate.resolve()
await run
expect(events).toEqual(['cancel', 'drained', 'ssh', 'primary', 'applied'])
}
)
it('tears down only a non-primary scope without applying the primary connection', async () => {
const events: string[] = []
await applyConnectionChange({
cancelAndWait: async scope => {
events.push(`cancel:${scope}`)
},
isPrimary: false,
scope: 'worker',
sendApplied: () => events.push('applied'),
stopPool: scope => events.push(`pool:${scope}`),
teardownPrimary: async () => {
events.push('primary')
},
teardownSsh: async scope => {
events.push(`ssh:${scope}`)
}
})
expect(events).toEqual(['cancel:worker', 'ssh:worker', 'pool:worker'])
})
})
describe('resolveTerminalConnection', () => {
it('joins an in-flight backend before resolving the SSH terminal target', async () => {
const target = { ssh: {}, scope: '' }
const getTarget = vi.fn().mockReturnValueOnce('pending').mockReturnValueOnce(target)
const ensureBackend = vi.fn(async () => undefined)
await expect(resolveTerminalConnection(getTarget, ensureBackend)).resolves.toBe(target)
expect(ensureBackend).toHaveBeenCalledOnce()
})
it('does not start a local terminal while configured SSH remains unavailable', async () => {
await expect(
resolveTerminalConnection(
() => 'pending',
async () => undefined
)
).rejects.toThrow('not ready')
})
})
describe('sshQuitShouldBlock', () => {
it('waits when connections exist and teardown has not finished', () => {
expect(sshQuitShouldBlock({ teardownDone: false, connectionCount: 1, bootstrapPending: 0, inFlight: null })).toBe(
true
)
})
it('waits when bootstrap is still running', () => {
expect(sshQuitShouldBlock({ teardownDone: false, connectionCount: 0, bootstrapPending: 1, inFlight: null })).toBe(
true
)
})
it('waits when the map is empty but a remote kill is already in flight', () => {
expect(
sshQuitShouldBlock({
teardownDone: false,
connectionCount: 0,
bootstrapPending: 0,
inFlight: Promise.resolve()
})
).toBe(true)
})
it('does not block a second quit after teardown finished', () => {
expect(
sshQuitShouldBlock({
teardownDone: true,
connectionCount: 1,
bootstrapPending: 1,
inFlight: Promise.resolve()
})
).toBe(false)
})
it('does not block quit when there is nothing to tear down', () => {
expect(sshQuitShouldBlock({ teardownDone: false, connectionCount: 0, bootstrapPending: 0, inFlight: null })).toBe(
false
)
})
})
describe('teardownSshState', () => {
it('terminates the owned remote backend before closing its tunnel and SSH transport', async () => {
const events: string[] = []
const ssh = {
cancelForward: async () => events.push('forward'),
close: async () => events.push('ssh')
}
await teardownSshState(
{ ssh, ownershipId: 'owner', localPort: 1234, remotePort: 5678 },
{ cleanupRemote: async () => events.push('remote') }
)
expect(events).toEqual(['remote', 'forward', 'ssh'])
})
it('still closes the SSH transport when remote cleanup fails', async () => {
const close = vi.fn(async () => undefined)
await teardownSshState(
{ ssh: { cancelForward: vi.fn(async () => undefined), close }, ownershipId: 'owner' },
{
cleanupRemote: async () => {
throw new Error('remote unavailable')
}
}
)
expect(close).toHaveBeenCalledOnce()
})
})
describe('commitConnectionFailure', () => {
it('prevents a stale bootstrap from publishing failure state', () => {
const stale = Promise.resolve('stale')
const current = Promise.resolve('current')
const commit = vi.fn()
expect(commitConnectionFailure(current, stale, commit)).toBe(false)
expect(commit).not.toHaveBeenCalled()
expect(commitConnectionFailure(current, current, commit)).toBe(true)
expect(commit).toHaveBeenCalledOnce()
})
})
+111
View File
@@ -0,0 +1,111 @@
async function applyConnectionChange({
cancelAndWait,
isPrimary,
rehomePrimary = null,
scope,
sendApplied,
stopPool,
teardownPrimary,
teardownSsh
}) {
await cancelAndWait(scope)
await teardownSsh(scope)
if (!isPrimary) {
stopPool(scope)
return
}
if (rehomePrimary) {
await rehomePrimary()
return
}
await teardownPrimary()
sendApplied()
}
function commitConnectionFailure(current, starting, commit) {
if (current !== starting) {
return false
}
commit()
return true
}
async function resolveTerminalConnection(getTarget, ensureBackend) {
let target = getTarget()
if (target !== 'pending') {
return target
}
await ensureBackend()
target = getTarget()
if (target === 'pending') {
throw new Error('Remote connection is not ready yet. Try again in a moment.')
}
return target
}
async function resolveTerminalConnectionForSender(webContentsId, getTarget, ensureBackend) {
return resolveTerminalConnection(
() => getTarget(webContentsId),
() => ensureBackend(webContentsId)
)
}
/** A second before-quit must still wait for an in-flight remote kill.
*
* teardownSshConnection deletes the sshConnections entry first, then
* SSH-execs kill. backendShutdown's finally() calls app.quit() and
* re-enters before-quit with an empty map. Without `inFlight`, Electron
* exits while disconnect is running and the detached serve --isolated
* stays at pid 1 (post-#95085 leftover on #91668: window X on Windows). */
function sshQuitShouldBlock({ teardownDone, connectionCount, bootstrapPending, inFlight }) {
if (teardownDone) {
return false
}
return connectionCount > 0 || bootstrapPending > 0 || Boolean(inFlight)
}
async function teardownSshState(state, { cleanupRemote }) {
// Remote process first, while the SSH channel can still exec kill.
// Then drop the local forward and close the transport. Each step is
// best-effort so a failed remote cleanup cannot trap Cmd+Q (#91668).
try {
await cleanupRemote(state.ssh, state.ownershipId)
} catch {
// Remote teardown is best-effort; always release the local tunnel and SSH transport.
}
try {
if (state.localPort && state.remotePort) {
await state.ssh.cancelForward(state.localPort, state.remotePort)
}
} catch {
// Best effort; closing the transport below drops any remaining forwards.
}
try {
await state.ssh.close()
} catch {
// The app must still be able to quit when SSH teardown fails.
}
}
export {
applyConnectionChange,
commitConnectionFailure,
resolveTerminalConnection,
resolveTerminalConnectionForSender,
sshQuitShouldBlock,
teardownSshState
}
@@ -0,0 +1,118 @@
import { describe, expect, it, vi } from 'vitest'
import { applyConnectionConfigAtomically } from './connection-config-apply'
describe('applyConnectionConfigAtomically', () => {
it('commits legacy and registry state before activation', async () => {
const events: string[] = []
await applyConnectionConfigAtomically({
previousConfig: 'old-config',
previousRegistry: 'old-registry',
nextConfig: 'remote-config',
nextRegistry: 'remote-registry',
writeConfig: value => events.push(`config:${value}`),
writeRegistry: value => events.push(`registry:${value}`),
apply: async () => {
events.push('activate')
}
})
expect(events).toEqual(['config:remote-config', 'registry:remote-registry', 'activate'])
})
it('rolls both stores back when activation fails', async () => {
const writeConfig = vi.fn()
const writeRegistry = vi.fn()
await expect(
applyConnectionConfigAtomically({
previousConfig: 'local-config',
previousRegistry: 'local-registry',
nextConfig: 'remote-config',
nextRegistry: 'remote-registry',
writeConfig,
writeRegistry,
apply: async () => {
throw new Error('activation failed')
}
})
).rejects.toThrow('activation failed')
expect(writeConfig.mock.calls).toEqual([['remote-config'], ['local-config']])
expect(writeRegistry.mock.calls).toEqual([['remote-registry'], ['local-registry']])
})
it('rolls legacy state back when the registry write fails', async () => {
const writes: string[] = []
let registryWrites = 0
await expect(
applyConnectionConfigAtomically({
previousConfig: 'local-config',
previousRegistry: 'local-registry',
nextConfig: 'remote-config',
nextRegistry: 'remote-registry',
writeConfig: value => writes.push(`config:${value}`),
writeRegistry: value => {
registryWrites += 1
if (registryWrites === 1) {
throw new Error('disk full')
}
writes.push(`registry:${value}`)
},
apply: vi.fn()
})
).rejects.toThrow('disk full')
expect(writes).toEqual(['config:remote-config', 'config:local-config', 'registry:local-registry'])
})
it('preflights before writing either store', async () => {
const events: string[] = []
await applyConnectionConfigAtomically({
previousConfig: 'local-config',
previousRegistry: 'local-registry',
nextConfig: 'remote-config',
nextRegistry: 'remote-registry',
preflight: async () => {
events.push('preflight')
},
writeConfig: value => events.push(`config:${value}`),
writeRegistry: value => events.push(`registry:${value}`),
apply: async () => {
events.push('activate')
}
})
expect(events).toEqual(['preflight', 'config:remote-config', 'registry:remote-registry', 'activate'])
})
it('leaves both stores untouched when the preflight rejects', async () => {
const writeConfig = vi.fn()
const writeRegistry = vi.fn()
const apply = vi.fn()
await expect(
applyConnectionConfigAtomically({
previousConfig: 'local-config',
previousRegistry: 'local-registry',
nextConfig: 'remote-config',
nextRegistry: 'remote-registry',
preflight: async () => {
throw new Error('gateway unreachable')
},
writeConfig,
writeRegistry,
apply
})
).rejects.toThrow('gateway unreachable')
expect(writeConfig).not.toHaveBeenCalled()
expect(writeRegistry).not.toHaveBeenCalled()
expect(apply).not.toHaveBeenCalled()
})
})
Loaded 100 of 11340 files, more files were not shown because too many files have changed in this diff. Show more