Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
commit
03634b1ca3
11340 files changed
+3442369
No files matched your search
Whitespace-only changes.
Whitespace-only changes.
@@ -0,0 +1,210 @@
|
||||
# Desktop Engineering Guide
|
||||
|
||||
How to build Hermes Desktop well. This is a judgment guide, not an inventory —
|
||||
it teaches the invariants and the reasoning behind them so a change fits the app
|
||||
even as files move. Read it with the repository `AGENTS.md` (root rules still
|
||||
apply) and [`DESIGN.md`](./DESIGN.md) for the visual and interaction contract.
|
||||
|
||||
When a rule here and the code disagree, trust the code and fix whichever is
|
||||
wrong — but never break an invariant to make a change easier.
|
||||
|
||||
## What this app is
|
||||
|
||||
Desktop is its own native chat surface. It is not the browser dashboard and it
|
||||
does not embed the TUI. Three parties, each authoritative for one thing:
|
||||
|
||||
- **Electron** owns the machine: process lifecycle, native filesystem/git/
|
||||
windows, install/update, and a narrow, typed capability bridge.
|
||||
- **The renderer** owns the experience: navigation, presentation, and ephemeral
|
||||
interaction state.
|
||||
- **The agent backend** owns the work: sessions, tools, model calls, streaming.
|
||||
|
||||
Keep the seams clean. The renderer never reaches for Node or Electron directly;
|
||||
native power arrives through a deliberate capability, not a general escape hatch.
|
||||
Agent behavior lives behind the gateway, never reimplemented in React. When a
|
||||
change blurs a seam, that is the smell — fix the seam, don't widen it.
|
||||
|
||||
## Decide state by authority
|
||||
|
||||
The first question for any piece of state is *who is allowed to be right about
|
||||
it*, not where it is convenient to store it. Put state with its authority:
|
||||
|
||||
- The **backend** is authoritative for anything another Hermes surface can also
|
||||
change. Treat the renderer's copy as a cache of that truth.
|
||||
- **Electron** is authoritative for machine and runtime facts.
|
||||
- The **renderer** owns only what is purely about this window's presentation.
|
||||
|
||||
From that, everything else follows: shared renderer state lives in small stores
|
||||
owned by the feature that owns the concern; request-shaped server data that wants
|
||||
invalidation lives in the query layer; short-lived interaction detail stays in
|
||||
the component; hot coordination that must not paint stays in a ref. Reach for the
|
||||
narrowest home that still lets the state be correct. A new global store is a
|
||||
claim that many distant surfaces need it — earn that claim.
|
||||
|
||||
Persisted state must declare its scope in its own key: is this global, or does it
|
||||
belong to a connection, a profile, a stored session, a project, or a window?
|
||||
Getting the scope wrong is how one profile's setting bleeds into another.
|
||||
|
||||
## Identity is not incidental
|
||||
|
||||
Sessions have more than one identity, and conflating them is a recurring source
|
||||
of "session not found" and vanishing history. Reason about which identity a
|
||||
surface needs: durable navigation and anything the user pins or persists key off
|
||||
the stable/durable identity; live streaming keys off the runtime identity; state
|
||||
that must outlive compression keys off the lineage root. Keep the mapping between
|
||||
them explicit and translate at the boundary rather than passing the wrong id
|
||||
inward.
|
||||
|
||||
## Server truth is cached, not owned
|
||||
|
||||
The renderer paints from a cache of backend truth, so it must reconcile, not
|
||||
assume:
|
||||
|
||||
- **Merge, don't clobber.** A refresh is new information layered over what you
|
||||
already know, not a replacement that can drop live or pinned rows.
|
||||
- **Be optimistic, then honest.** Direct manipulation should paint immediately
|
||||
from a snapshot; a failed write rolls back visibly and an authoritative
|
||||
refresh gets the last word.
|
||||
- **Guard against the past.** Async results can arrive out of order; a stale
|
||||
response must never overwrite newer intent. Generation counters and request
|
||||
tokens exist for this.
|
||||
- **Isolate the foreground.** Only the surface the user is looking at may publish
|
||||
into the shared view; background work updates its own cache quietly.
|
||||
- **Coalesce noise, flush signal.** Batch high-frequency cosmetic updates, but
|
||||
let terminal transitions (a turn finishing, needing input, failing) reach the
|
||||
user immediately.
|
||||
- **Preserve reference identity on no-ops.** Handing React a fresh array that
|
||||
contains the same data re-renders expensive trees for nothing.
|
||||
|
||||
## Switching context is a re-home, not a reboot
|
||||
|
||||
Changing profile, connection, or mode is a workspace switch, not a cold start.
|
||||
The shell and whatever the user was doing stay put; only the gateway-bound view
|
||||
is cleared and repopulated, and the previous context must not leak into the next
|
||||
one. Reserve the full-screen boot/connecting experience for a genuinely unusable
|
||||
backend.
|
||||
|
||||
There are three distinct switch shapes, and conflating them is the classic bug:
|
||||
|
||||
- A **connection/mode apply** (local ↔ remote ↔ cloud) is the soft re-home:
|
||||
shell mounted, gateway-bound stores explicitly wiped, then reconnect. Query
|
||||
invalidation alone cannot evict live session stores — wipe them.
|
||||
- A **runtime home change** (switching the underlying `HERMES_HOME` profile) is
|
||||
a hard re-home: the window legitimately reloads and state resets by remount.
|
||||
- A **live profile swap** in the same window activates another profile's socket
|
||||
while background profiles keep streaming; lists merge rather than wipe, and
|
||||
only an explicit user selection starts a fresh foreground draft.
|
||||
|
||||
Treating a soft switch as hard flickers the app; treating a hard one as soft
|
||||
strands stale rows. After any swap, the active socket, active profile, and
|
||||
connection atoms must agree, or REST and filesystem calls route to the wrong
|
||||
backend.
|
||||
|
||||
## Cross everything as an observable ladder
|
||||
|
||||
Desktop lives at the seams: versions, profiles, local vs remote vs cloud,
|
||||
partially installed runtimes, stale caches, older backends. The durable technique
|
||||
for all of it is the same — an ordered ladder of candidates:
|
||||
|
||||
1. Precedence is written down, in one place, as data or a pure function.
|
||||
2. A candidate is trusted only after it is validated at the right boundary.
|
||||
Existence is not proof; probe what you're about to rely on.
|
||||
3. A failed *read* falls to the next rung; a failed *authoritative write*
|
||||
surfaces or rolls back rather than silently retargeting.
|
||||
4. A missing capability and a transient failure are different: the first may
|
||||
enable a compatibility path or a disabled state; the second should retry.
|
||||
5. Retries are bounded and end in a real recovery affordance — never an infinite
|
||||
spinner or a hot loop.
|
||||
6. One resolver owns each policy so every caller gets the same answer. Scatter is
|
||||
how two call sites drift apart.
|
||||
|
||||
This is the shape of backend discovery, command/version fallbacks, connection and
|
||||
auth resolution, workspace-cwd selection, capability detection, and preview
|
||||
normalization alike. Learn the shape, not a snapshot of the current rungs.
|
||||
|
||||
Two auth-flavored corollaries worth naming because they are easy to get wrong:
|
||||
|
||||
- **One-time credentials are never reused.** An OAuth gateway connection mints a
|
||||
fresh WebSocket ticket on every dial and never falls back to the cached URL.
|
||||
Only a confirmed 401/403 (or an explicitly tagged auth rejection) means
|
||||
reauthentication; timeout, network, malformed-response, and server failures
|
||||
remain connectivity errors. Only long-lived token/local auth may reuse a
|
||||
cached URL as a lower rung.
|
||||
- **A connection test must exercise the leg you'll actually use.** An HTTP
|
||||
status probe passing while the WebSocket/auth leg fails is a false positive
|
||||
that ships as "it said connected but nothing works."
|
||||
|
||||
## Compatibility without carrying the past forever
|
||||
|
||||
Desktop and its runtime update on separate clocks, so a change can meet an older
|
||||
backend. Keep those users working: preserve the current feature, keep the
|
||||
fallback narrow and tied to an identified older runtime, and cover it with a
|
||||
test. A fallback that quietly degrades the feature it's meant to protect is worse
|
||||
than the crash it replaced.
|
||||
|
||||
## Keep the waist narrow, grow at the edges
|
||||
|
||||
The root contribution rubric governs here too. New capability should arrive at
|
||||
the smallest surface that solves it: extend what exists, add a feature locally,
|
||||
lean on an existing seam — before you invent a framework. The shell's internal
|
||||
registries are composition seams, not a public plugin ABI; do not build a
|
||||
universal extension system, a manifest, or a plugin adapter for a single
|
||||
consumer. Design a shared contract only once more than one real consumer proves
|
||||
its shape. "Plugin" means several unrelated things across Hermes — do not assume
|
||||
one surface's extension model runs in another.
|
||||
|
||||
When the new capability is an **agent-callable** one — a tool that acts on this
|
||||
renderer (open a pane, read the in-app browser, react to a message) — it is a
|
||||
property of the SESSION's client, not of the backend host. Wire its
|
||||
availability off the session source the app already sends on `session.create`
|
||||
(`source: 'desktop'`), never off an env var on the backend process: that
|
||||
process might be a remote or cloud gateway this app merely connected to. See
|
||||
the root AGENTS.md, "Surface capability is a property of the SESSION."
|
||||
|
||||
## Respect the person using it
|
||||
|
||||
Design and engineering meet at intent. The user's attention and context are
|
||||
sacred:
|
||||
|
||||
- Never navigate, move focus, or open a surface because something *happened* in
|
||||
the background. Offer; don't hijack.
|
||||
- The states around loading are distinct experiences — empty, loading,
|
||||
reconnecting, degraded/stale, and exhausted-recovery each deserve their own
|
||||
honest copy and their own way out.
|
||||
- Keyboard ownership follows focus. The focused surface wins its keys; one
|
||||
cancel gesture does exactly one thing.
|
||||
- Expensive, stateful surfaces (terminals, live tools) stay alive when hidden.
|
||||
Visibility is not lifecycle.
|
||||
|
||||
## Make it feel instant
|
||||
|
||||
Performance is a feature the user feels, especially in drag, resize, scroll,
|
||||
typing, streaming, and terminals. The principles are timeless even as the code
|
||||
changes: keep hot-path state local or narrowly derived; don't subscribe heavy
|
||||
trees to per-frame updates; coalesce pointer work; avoid reading layout right
|
||||
after writing style; and don't mount expensive content mid-gesture. Prove speed
|
||||
against realistic content — a fast empty demo proves nothing about a long
|
||||
transcript. If motion is masking latency, remove the motion, don't tune it.
|
||||
|
||||
## Testing as a habit of proof
|
||||
|
||||
Test the behavior that would actually break a user, not a snapshot of today's
|
||||
data. Favor invariants over frozen values. Exercise the real path for anything
|
||||
at a seam — resolver precedence and its failure rungs, identity and scope
|
||||
boundaries, optimistic rollback and stale-response ordering, and both sides of a
|
||||
local/remote adapter with its profile routing intact. Match how the suite is
|
||||
actually run rather than inventing a command; when in doubt, read the scripts.
|
||||
|
||||
## The taste test before you hand off
|
||||
|
||||
- Does every piece of state live with its authority, at the narrowest scope?
|
||||
- Would a background event ever steal the foreground or the user's focus?
|
||||
- Does each resolver have one home, a validated ladder, and a bounded, recoverable
|
||||
end?
|
||||
- Do local, remote, and profile routing still agree?
|
||||
- Does async failure leave a usable UI and a way forward?
|
||||
- Do hot interactions stay cheap under realistic load?
|
||||
- Does the change pass the [`DESIGN.md`](./DESIGN.md) checklist and update all
|
||||
locales?
|
||||
|
||||
If any answer is "not sure," that's the part to go verify.
|
||||
@@ -0,0 +1,364 @@
|
||||
# Desktop Design System
|
||||
|
||||
AITURK distribution: `aiturk` is the default first-party theme. Its turquoise
|
||||
accent is defined in `src/themes/presets.ts`; components consume theme tokens.
|
||||
`BrandMark` renders `public/aiturk.svg`. Keep the original theme presets intact.
|
||||
|
||||
Conventions for the Electron desktop app (`apps/desktop`). Read this before
|
||||
adding a component, overlay, or style. The rule of thumb: **one source per
|
||||
concern, tokens over literals, flat over boxed.** If you reach for a raw color,
|
||||
a one-off shadow, a bespoke button, or a hardcoded `px-*` on a control — stop,
|
||||
there's already a primitive for it.
|
||||
|
||||
This file owns the visual and interaction contract. Read
|
||||
[`AGENTS.md`](./AGENTS.md) for architecture, state, resolver, transport, and
|
||||
testing rules.
|
||||
|
||||
This doc contains two kinds of content, maintained differently:
|
||||
|
||||
- **Principles** (flatness, intent, feedback, motion, cancellation) are durable.
|
||||
They hold as components come and go.
|
||||
- **Named contracts** (tokens, `Button` variants, primitive names) are the
|
||||
design system's current API. They are maintained *with* the code: if you
|
||||
change a primitive, token, or variant, update its entry here **in the same
|
||||
change** — a stale name in this file is a bug, exactly like a stale type.
|
||||
|
||||
When a rule and the code disagree, fix whichever is wrong rather than forking a
|
||||
one-off at the call site.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Flat, not boxed.** No card-in-card, no divider borders inside a panel.
|
||||
Group with whitespace and a single hairline, never nested rounded boxes.
|
||||
2. **Borderless elevation for floating panels.** Overlays float on
|
||||
`shadow-nous` + a `--stroke-nous` hairline, not thick framed boxes. In-panel
|
||||
structure may use token hairlines sparingly.
|
||||
3. **One primitive per concern.** One `Button`, one set of control variants,
|
||||
one `SearchField`, one `Loader`, one `ErrorState`. Migrate onto them; don't
|
||||
fork.
|
||||
4. **Tokens, not literals.** Reference CSS vars (`--ui-*`, `--shadow-nous`,
|
||||
`--theme-*`), never raw hex / ad-hoc rgba in components.
|
||||
5. **Style lives in the primitive.** Variants and sizes own padding, radius,
|
||||
color, chrome. Call sites pass a `variant`/`size`, not `className` overrides
|
||||
that re-specify those.
|
||||
6. **Intent before automation.** Surface useful actions and previews, but do not
|
||||
open panes, move focus, or navigate because a tool happened to produce
|
||||
something.
|
||||
7. **Immediate feedback.** Direct manipulation updates the view first. Network
|
||||
or disk persistence reconciles afterward and rolls back visibly on failure.
|
||||
|
||||
## Information architecture
|
||||
|
||||
- **Chat is the home surface.** The transcript and composer stay primary; tools,
|
||||
previews, files, review, and terminal complement the conversation.
|
||||
- **Pages are durable destinations.** Chat, Skills, Messaging, and Artifacts
|
||||
remain in shell chrome. Do not hide a distinct product noun inside an
|
||||
unrelated page.
|
||||
- **Route overlays are short tasks.** Settings, Command Center, Cron, Profiles,
|
||||
Agents, and Starmap render as `OverlayView` cards and return to the previous
|
||||
route on close. Model/session pickers and dialogs layer above the current
|
||||
surface; they are not navigation stacks.
|
||||
- **Panes are working context.** Preview, files, review, and terminal remain
|
||||
attached to the current task. Their state survives temporary hiding and chat
|
||||
switches where the underlying tool is meant to persist.
|
||||
- **One action, one home.** A command may have keyboard, palette, and visible
|
||||
affordances, but they invoke the same action and state. Do not fork behavior
|
||||
per entry point.
|
||||
- **Projects own workspace cwd.** Use Sidebar → Projects for local folders and
|
||||
worktrees; do not reintroduce a per-session/right-sidebar folder-picker flow.
|
||||
|
||||
Navigation must preserve context. A background session finishing, a tool result
|
||||
arriving, or a project refresh may update badges and cached data; it must not
|
||||
replace the foreground transcript or steal focus.
|
||||
|
||||
## Surfaces & elevation
|
||||
|
||||
Floating panels (base `Dialog`, route overlays, boot/install/update surfaces,
|
||||
model-picker, onboarding, prompt overlays, notifications) use:
|
||||
|
||||
```
|
||||
shadow-nous /* downward-weighted, layered contact→ambient falloff */
|
||||
border-(--stroke-nous) /* currentColor hairline, theme-adaptive */
|
||||
```
|
||||
|
||||
Both are CSS vars in `src/styles.css` — tune in one place, everything inherits.
|
||||
Don't add per-overlay `shadow-[…]` or `border-(--ui-stroke-secondary)`
|
||||
one-offs; if elevation needs to change, change the token.
|
||||
|
||||
Menus and popovers use their own shared `shadow-md` +
|
||||
`--ui-stroke-secondary` primitive treatment. Drag affordances may use tokenized
|
||||
dashed targets and local blur. These are semantic surface classes, not licenses
|
||||
for call-site shadow or border inventions.
|
||||
|
||||
## Stroke & color tokens
|
||||
|
||||
| Token | Use |
|
||||
| --- | --- |
|
||||
| `--ui-stroke-primary…quaternary` | hairlines, in descending strength |
|
||||
| `--ui-stroke-tertiary` | the default in-panel divider / list hairline — and every bordered surface in the transcript |
|
||||
| `--stroke-nous` | the overlay hairline (pairs with `shadow-nous`) |
|
||||
| `--ui-text-primary / -secondary / -tertiary` | text hierarchy |
|
||||
| `--ui-bg-quaternary` | soft control fill (secondary button) |
|
||||
| `--ui-widget-surface-background` | fill for inline chat widgets (`WIDGET_SHELL_CLASS`) |
|
||||
| `--chrome-action-hover` | hover fill for quiet controls |
|
||||
| `--theme-primary`, `--ui-accent` | brand/accent |
|
||||
|
||||
Never hardcode `border-gray-*`, `bg-white`, `text-black`, etc. The white tile in
|
||||
`BrandMark` is the one sanctioned literal (the mark needs a fixed backdrop).
|
||||
|
||||
## Buttons — one component
|
||||
|
||||
`src/components/ui/button.tsx` is the single source. Pick a `variant` + `size`;
|
||||
do **not** pass `h-*`, `px-*`, `py-*`, or icon-size overrides.
|
||||
|
||||
**Variants:** `default` (primary), `destructive`, `secondary` (soft fill —
|
||||
the default non-primary look), `outline` (transparent + 1px inset ring, no
|
||||
fill/shadow), `ghost`, `link`, `text` (boxless quiet inline — "Cancel",
|
||||
"Clear"), `textStrong` (bold underlined inline affordance — "Change",
|
||||
"Open logs").
|
||||
|
||||
**Sizes:** `default`, `xs`, `sm`, `lg`, `inline` (flush, zero box — for buttons
|
||||
that sit inside a heading/sentence; replaces `h-auto px-0 py-0`), `micro`
|
||||
(status-stack/table-footers), and the icon family `icon` / `icon-xs` /
|
||||
`icon-sm` / `icon-lg` / `icon-titlebar`.
|
||||
|
||||
**Tooltips only when hover teaches something new.** `<Tip>` is for discovery,
|
||||
not a tax on every icon. Ask: does hover reveal something the user cannot
|
||||
already see or infer? If not, skip the tip; keep an `aria-label` for a11y.
|
||||
|
||||
Tip unlabeled chrome when the job (or a keybind / truncated path / host /
|
||||
other detail) is not already on screen — toolbar / titlebar / statusbar icons,
|
||||
`TipKeybindLabel` shortcuts, ownership chips, unlabeled icon grids.
|
||||
|
||||
Do **not** tip:
|
||||
|
||||
- Menu triggers (kebabs / ⋯ / `ActionsMenu` / `DropdownMenuTrigger`) — the
|
||||
affordance is "open menu"; verbs live in the menu. Never tip
|
||||
`"Actions for ${row title}"` / `"Project actions"` / `"Actions"`.
|
||||
- Close / dismiss X buttons — the glyph is the label (`aria-label` only).
|
||||
- Controls whose visible label already says what the tip would ("click to…",
|
||||
paraphrases of the same words, timer labels restating "Running").
|
||||
|
||||
Never use native HTML `title=` on buttons — unstyled, ~500ms OS delay, clashes
|
||||
with the themed `Tip`. `src/components/ui/__tests__/no-native-title.test.ts`
|
||||
fails on any `<button>` / `<Button>` that still carries `title=`.
|
||||
|
||||
**Tooltip timing.** A hover is not a click — the cursor crosses triggers on
|
||||
the way somewhere else. `Tip` waits 200ms before the first open so a sweep
|
||||
does not flash a trail. After a tip has opened the page is warm: the next
|
||||
trigger within 300ms opens instantly. The cooldown starts on close, so a
|
||||
hover a second later waits again. Close is immediate. `OverflowTip` stays
|
||||
on its own longer delay (list titles must not trail while scanning).
|
||||
|
||||
**Keybind hints in tooltips.** On a tipped button bound to a rebindable hotkey,
|
||||
use `<TipKeybindLabel actionId="..." />` — it reads the i18n label and the
|
||||
current combo from `$bindings`. Pass `text={...}` only when the label is
|
||||
context-dependent (e.g. "Show" / "Hide"). Never hardcode combos; always use
|
||||
`useKeybindHint` or `TipKeybindLabel`.
|
||||
|
||||
Notes:
|
||||
- Text buttons are square (no radius) and sized by padding + line-height (no
|
||||
fixed heights). Only icon buttons carry the shared 4px radius.
|
||||
- SVGs inherit `size-3.5` (`size-3` at `xs`). Don't re-set icon size.
|
||||
- Polymorph with `asChild` when the button must render as a link/Slot.
|
||||
|
||||
## Badges — one component
|
||||
|
||||
`src/components/ui/badge.tsx`. Variants: `default` (tinted primary), `muted`,
|
||||
`warn`, `destructive`, `outline`, `solid` (primary fill — icon-corner counts).
|
||||
Sizes: `default`, `xs`, `overlay` (titlebar glyph counts).
|
||||
|
||||
## Form controls
|
||||
|
||||
- **`controlVariants`** (`src/components/ui/control.ts`) is the shared shape for
|
||||
`Input` / `Textarea` / `SelectTrigger`. New text-entry controls compose it.
|
||||
- **`SearchField`** — borderless, underline-on-focus, auto-width. The only
|
||||
search input. Don't build boxed search bars; don't wrap it in a bordered tile.
|
||||
Empty lists hide their search field.
|
||||
- **`SegmentedControl`** — the choice control for small mutually-exclusive sets
|
||||
(color mode, tool-call display, usage period). Replaces radio piles and
|
||||
pill rows.
|
||||
- **`Switch`** (`size="xs"`) — bare, with `aria-label`. No bordered text wrapper.
|
||||
|
||||
## Layout
|
||||
|
||||
- **Gutters:** `PAGE_INSET_X` (`src/app/layout-constants.ts`) for page side
|
||||
padding; `PAGE_INSET_NEG_X` to bleed a child to the edge. Don't hardcode
|
||||
`px-6`/`px-8` on pages.
|
||||
- **Master/detail overlays:** `OverlaySplitLayout` + `OverlaySidebar` /
|
||||
`OverlayMain`. Cron, profiles, etc. ride this — don't rebuild a titlebar
|
||||
shell.
|
||||
- **Rows:** `ListRow` (settings `primitives.tsx`) for label/description/action
|
||||
rows. Flat, flush-left; no per-row indentation that fights flush headers.
|
||||
- **No dividers between rows** unless the list genuinely needs them; prefer
|
||||
spacing. When you do need one, it's a single `--ui-stroke-tertiary` hairline.
|
||||
|
||||
## Feedback & empty/error/loading states
|
||||
|
||||
- **Loading:** `Loader` (`src/components/ui/loader.tsx`) — animated math/ascii
|
||||
curves (`lemniscate-bloom` for long ops). Never ship the literal text
|
||||
"Loading…".
|
||||
- **Errors:** `ErrorState` + the canonical `ErrorIcon` (no bg chip). One look
|
||||
for the React boundary, in-dialog errors, and the boot-failure banner. Pass
|
||||
nodes for title/description so Radix `DialogTitle`/`Description` can flow
|
||||
through for a11y.
|
||||
- **Logs:** `LogView` — no bg, hairline border, tight padding, small mono.
|
||||
Every place we surface raw logs uses it.
|
||||
- **Empty:** `EmptyState` for plain page bodies; `PanelEmpty` for overlay
|
||||
master/detail empties with an icon and action. Don't hand-roll a third
|
||||
centered empty.
|
||||
- **Confirmation:** `ConfirmDialog` is the only way we ask "are you sure". It
|
||||
opens focused on Confirm, so `Enter` confirms and `Esc` cancels, and it owns
|
||||
the pending → done → close beat and the inline error — a call site passes an
|
||||
async `onConfirm` and nothing else. A third way out (e.g. "Remove from
|
||||
sidebar" beside "Delete worktree") goes in the one `secondaryAction` slot.
|
||||
Never `window.confirm`: it's an unstyled blocking Chromium modal. A handler
|
||||
that wants the answer inline instead of a mounted dialog calls `confirm()`
|
||||
from `src/store/confirm.ts`, which renders this same primitive through the
|
||||
single `ConfirmHost` at the shell — the way `notify()` backs notifications.
|
||||
|
||||
## Chat, tools & boot surfaces
|
||||
|
||||
- The transcript and composer are built on `@assistant-ui/react`. Extend the
|
||||
existing components under `src/components/assistant-ui` and
|
||||
`src/app/chat/composer`; do not fork a second markdown, message, tool-call, or
|
||||
approval renderer for one feature.
|
||||
- **Inline widgets** — a tool result that renders as a panel the user reads or
|
||||
acts on (clarify, artifact card) wears `WIDGET_SHELL_CLASS`
|
||||
(`src/components/chat/widget-shell.ts`): shared radius, the
|
||||
`--ui-widget-surface-background` fill, no border. Its actions sit *outside*
|
||||
the panel, below it. Don't give one widget its own radius or fill.
|
||||
- Bordered surfaces in the transcript (tables, fences, callouts, attachments)
|
||||
use `--ui-stroke-tertiary`. Not `border-border` — that's the app-wide
|
||||
default and reads too hot against the thread.
|
||||
- A tool result may expose an inline action that opens a preview. It must not
|
||||
open the rail automatically.
|
||||
- Install, onboarding, connecting, boot failure, and reauthentication are
|
||||
distinct states with shared visual primitives. Preserve their recovery
|
||||
semantics when unifying appearance.
|
||||
- Respect `AppShell` overlay ownership. Persistent terminal/content layers,
|
||||
route overlays, dialogs, and boot surfaces must not compete through ad-hoc
|
||||
z-index literals. Pick a rung of the ladder in `styles.css` instead —
|
||||
`--z-modal-backdrop` / `--z-modal` / `--z-modal-popover`, `--z-over-modal`
|
||||
(toasts, tooltips, command surfaces) and `--z-over-modal-content`,
|
||||
`--z-switcher-backdrop` / `--z-switcher`, then the boot chain
|
||||
`--z-connecting` → `--z-onboarding` → `--z-setup` → `--z-crash`. Plain
|
||||
`z-10`/`z-20` are still right for stacking *within* one component.
|
||||
|
||||
## Iconography & brand
|
||||
|
||||
- **Tabler** is the default component/chrome set. Import its curated aliases and
|
||||
`iconSize` scale from `src/lib/icons.ts`; do not import icon packages directly
|
||||
in feature code.
|
||||
- **`Codicon`** is the compact editor/tool/status vocabulary. Use
|
||||
`src/components/ui/codicon.tsx`, including `codiconIcon()` where a
|
||||
Tabler-shaped component is required.
|
||||
- Pick the vocabulary by semantic context and reuse the existing icon for an
|
||||
action. Do not introduce a third icon set or mix styles within one control
|
||||
group.
|
||||
- **`BrandMark`** (`src/components/brand-mark.tsx`) is the brand glyph — the
|
||||
`nous-girl` mark on a white tile, softly rounded, identical in light/dark.
|
||||
It replaced scattered Sparkles glyphs in updates / onboarding / about. Use it
|
||||
for hero/brand moments; don't reintroduce decorative star/sparkle icons.
|
||||
|
||||
## Motion
|
||||
|
||||
- Quick, functional transitions (~100ms on controls). Respect
|
||||
`prefers-reduced-motion` for anything beyond a fade.
|
||||
- Choreographed exits (e.g. onboarding's "matrix" fade-down) stagger per-element
|
||||
then settle the surface — the outer container's fade is *delayed* so it
|
||||
doesn't swallow the inner animation. Don't let a global fade race the detail.
|
||||
- Motion follows state; it never delays state. Selection, drag targets, cancel,
|
||||
and pressed feedback paint in the current frame.
|
||||
- Do not animate layout geometry with `transition-all` on a hot interaction.
|
||||
Name the properties, avoid backdrop-filter repaints during movement, and
|
||||
remove animation before masking a performance problem.
|
||||
|
||||
## Direct manipulation & performance
|
||||
|
||||
The app should feel instant under real load — long transcripts, several panes,
|
||||
live streams. Design toward that:
|
||||
|
||||
- Direct manipulation paints first; persistence reconciles after and rolls back
|
||||
visibly on failure.
|
||||
- Keep interaction feedback cheap: hot-path state stays local or narrowly
|
||||
derived, not wired into heavy trees; pointer work coalesces per frame.
|
||||
- One drop region has one visual owner, and drop targets speak one affordance
|
||||
language across files, sessions, tabs, and panes. Overlapping targets resolve
|
||||
to the active one instead of stacking overlays.
|
||||
- Forgiving geometry beats pixel-perfect triggers; edge actions live near their
|
||||
edge, not clustered in the center.
|
||||
- Expensive stateful surfaces stay mounted when hidden. Visibility is not
|
||||
lifecycle.
|
||||
|
||||
Prove speed with realistic content. A fast empty-state demo says nothing about a
|
||||
long transcript or a busy terminal.
|
||||
|
||||
## Keyboard & cancellation
|
||||
|
||||
- Keyboard ownership follows focus. The focused surface wins its keys; shell
|
||||
shortcuts must not steal a terminal's or editor's bindings.
|
||||
- Register global shortcuts through the shared layer, not ad-hoc listeners.
|
||||
- One cancel gesture does one thing: cancel the active interaction, or close the
|
||||
topmost dismissable surface — never both, never the control underneath.
|
||||
- Cancellation is synchronous in the UI even if cleanup is async: overlays,
|
||||
cursors, and pending gesture state clear at once.
|
||||
- Flows that deliberately cannot be dismissed (install/onboarding, destructive
|
||||
confirmation) must make that explicit.
|
||||
|
||||
## i18n
|
||||
|
||||
- Every user-facing string goes through `useI18n()` (`src/i18n/context.tsx`).
|
||||
No literals in JSX.
|
||||
- **Update all locales together** — `en`, `ja`, `zh`, `zh-hant`. A string change
|
||||
in `en.ts` that skips the others is a regression (drifted punctuation,
|
||||
stale labels). Keep trailing-punctuation and tone consistent across all four.
|
||||
|
||||
## State (TypeScript)
|
||||
|
||||
The detailed state contract lives in the scoped
|
||||
[`AGENTS.md`](./AGENTS.md). Visual code follows these essentials:
|
||||
|
||||
- Shared/cross-component state → small **nanostores**, not prop-drilling.
|
||||
Each feature owns its atoms; shared atoms live in `src/store`.
|
||||
- Rendering components subscribe with `useStore`; non-render actions read with
|
||||
`$atom.get()`.
|
||||
- Subscribe to derived coarse facts instead of high-frequency source atoms when
|
||||
the component does not render the full value.
|
||||
- Colocated action modules over god hooks. A hook owns one narrow job.
|
||||
- Keep persistence beside the atom that owns it. Route roots stay thin.
|
||||
- Prefer `interface` for public props; extend React primitives
|
||||
(`React.ComponentProps<'button'>`, `Omit<…>`).
|
||||
|
||||
## Affordances
|
||||
|
||||
- `cursor-pointer` at the primitive level (Button, dropdown/select) — don't
|
||||
hardcode it per call site.
|
||||
- Global focus-ring reset; titlebar actions have no active-background state.
|
||||
- `Esc` closes every dismissable overlay/dialog (install/onboarding excluded);
|
||||
close is an x-icon, not the word "Close".
|
||||
|
||||
## Before you add something — checklist
|
||||
|
||||
- [ ] Reuse a primitive (`Button`, `SearchField`, `SegmentedControl`,
|
||||
`ListRow`, `Loader`, `ErrorState`, `LogView`, `ConfirmDialog`) instead of
|
||||
forking one?
|
||||
- [ ] Tokens (`--ui-*`, `shadow-nous`, `--stroke-nous`) — zero raw colors /
|
||||
one-off shadows?
|
||||
- [ ] No `className` overriding a primitive's padding / size / radius / chrome?
|
||||
- [ ] Tips only where hover teaches something new (no kebab / menu-trigger
|
||||
tips; unlabeled chrome that needs discovery gets `<Tip>` + `aria-label`)?
|
||||
- [ ] No native `title=` on buttons?
|
||||
- [ ] Keybind hints on tipped buttons use `useKeybindHint` / `TipKeybindLabel`?
|
||||
- [ ] Overlay uses `shadow-nous` + `border-(--stroke-nous)`, no hard border?
|
||||
- [ ] Flat — no card-in-card, no gratuitous row dividers?
|
||||
- [ ] No automatic navigation, focus steal, or pane opening from background
|
||||
events?
|
||||
- [ ] Direct manipulation paints immediately and rolls back cleanly on failure?
|
||||
- [ ] Hot interactions avoid broad subscriptions, layout thrash, and
|
||||
`transition-all`?
|
||||
- [ ] Keyboard ownership and single-action `Esc` behavior are correct?
|
||||
- [ ] All four locales updated for any new/changed string?
|
||||
- [ ] `cursor-pointer`, focus ring, and `Esc`-to-close behave?
|
||||
- [ ] Touched a primitive, token, or variant? Its named-contract entry in this
|
||||
file is updated in the same change.
|
||||
@@ -0,0 +1,244 @@
|
||||
# Hermes Desktop ☤
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/NousResearch/hermes-agent/releases"><img src="https://img.shields.io/badge/Download-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux-FFD700?style=for-the-badge" alt="Download"></a>
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
|
||||
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://github.com/NousResearch/hermes-agent/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
|
||||
</p>
|
||||
|
||||
**The native desktop app for [Hermes Agent](../../README.md) — the self-improving AI agent from [Nous Research](https://nousresearch.com).** Same agent, same skills, same memory as the CLI and gateway, in a polished native window — chat with streaming tool output, side-by-side previews, a file browser, voice, and settings, no terminal required. Available for **macOS, Windows, and Linux**.
|
||||
|
||||
<table>
|
||||
<tr><td><b>Chat with the full agent</b></td><td>Streaming responses, live tool activity, structured tool summaries, and the same conversation history as every other Hermes surface.</td></tr>
|
||||
<tr><td><b>Side-by-side previews</b></td><td>Render web pages, files, and tool outputs in a right-hand pane while you keep chatting.</td></tr>
|
||||
<tr><td><b>File browser</b></td><td>Explore and preview the working directory without leaving the app.</td></tr>
|
||||
<tr><td><b>Voice</b></td><td>Talk to Hermes and hear it back.</td></tr>
|
||||
<tr><td><b>Settings & onboarding</b></td><td>Manage providers, models, tools, and credentials from a real UI. First-run setup gets you to your first message in seconds.</td></tr>
|
||||
<tr><td><b>Stays current</b></td><td>Built-in updates pull the latest agent and rebuild the app in place.</td></tr>
|
||||
</table>
|
||||
|
||||
---
|
||||
|
||||
## Install
|
||||
|
||||
### Install with Hermes (recommended)
|
||||
|
||||
Already have the Hermes CLI? Just run:
|
||||
|
||||
```bash
|
||||
hermes desktop
|
||||
```
|
||||
|
||||
It builds and launches the GUI against your existing install — same config, keys, sessions, and skills. If Desktop cannot find a usable runtime or saved remote connection, first launch lets you connect to an existing Hermes gateway or install Hermes locally. Local onboarding then walks you through choosing a provider and model.
|
||||
|
||||
### Prebuilt installers
|
||||
|
||||
Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/).
|
||||
|
||||
---
|
||||
|
||||
## Updating
|
||||
|
||||
The app checks for updates in the background and offers a one-click update when one is ready. You can also update any time from the CLI:
|
||||
|
||||
```bash
|
||||
hermes update
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
The installer handles everything for you (Python 3.11+, a portable Git, ripgrep).
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
Want to hack on the app itself? Install workspace deps from the repo root once, then run the dev server from this directory:
|
||||
|
||||
```bash
|
||||
npm install # from repo root — links apps/desktop, web, apps/shared
|
||||
cd apps/desktop
|
||||
npm run dev # Vite renderer + Electron, which boots the Python backend
|
||||
```
|
||||
|
||||
Point the app at a specific source checkout, or sandbox it away from your real config:
|
||||
|
||||
```bash
|
||||
# throwaway HERMES_HOME, separate Electron userData, distinct app name to avoid the single-instance lock
|
||||
../scripts/dev-sandbox.sh npm run dev
|
||||
HERMES_DESKTOP_HERMES_ROOT=/path/to/clone npm run dev
|
||||
HERMES_HOME=/tmp/throwaway npm run dev
|
||||
npm run dev:fake-boot # exercise the startup overlay with deterministic delays
|
||||
```
|
||||
|
||||
### Building installers
|
||||
|
||||
```bash
|
||||
npm run dist:mac # DMG + zip
|
||||
npm run dist:win # NSIS + MSI
|
||||
npm run dist:linux # AppImage + deb + rpm
|
||||
npm run pack # unpacked app under release/ (no installer)
|
||||
```
|
||||
|
||||
Installers are built and uploaded to GitHub Releases manually. macOS/Windows signing & notarization happen automatically when the relevant credentials are present in the environment (`CSC_LINK` / `CSC_KEY_PASSWORD` / `APPLE_*` for macOS, `WIN_CSC_*` for Windows).
|
||||
|
||||
### How it works
|
||||
|
||||
The packaged app ships the Electron shell and a native React chat surface. On
|
||||
first launch it can install the Hermes Agent runtime into `HERMES_HOME`
|
||||
(`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows), using the same layout as a
|
||||
CLI install.
|
||||
|
||||
The app has three boundaries:
|
||||
|
||||
- **Electron** resolves and validates a runnable backend, owns native
|
||||
filesystem/git/window capabilities, and exposes a narrow preload bridge.
|
||||
- **React** owns the Desktop routes, panes, interaction state, and
|
||||
`@assistant-ui/react` transcript.
|
||||
- **Hermes Agent** runs as a headless `hermes serve` process and exposes the
|
||||
`tui_gateway` JSON-RPC/WebSocket API. The renderer connects through
|
||||
[`apps/shared`](../shared/), which is also used by the browser dashboard.
|
||||
|
||||
Backend resolution is an ordered ladder:
|
||||
|
||||
1. `HERMES_DESKTOP_HERMES_ROOT`
|
||||
2. the current source checkout during development
|
||||
3. a completed managed install
|
||||
4. `HERMES_DESKTOP_HERMES`, or `hermes` on `PATH`
|
||||
5. a system Python that can import the Hermes runtime
|
||||
6. the first-launch bootstrap installer
|
||||
|
||||
Candidates are probed before use; an existing shim or interpreter is not enough.
|
||||
A runtime that predates `serve` falls back to headless
|
||||
`dashboard --no-open`. This is compatibility for the backend command only and
|
||||
does not launch or embed the dashboard UI.
|
||||
|
||||
The Electron orchestration entry point is `electron/main.ts`; pure resolution,
|
||||
probe, hardening, and platform policies live in focused modules beside it. The
|
||||
renderer is under `src/`, with shared atoms in `src/store` and transport/native
|
||||
adapters in `src/lib`.
|
||||
|
||||
Before changing the app, read:
|
||||
|
||||
- [`AGENTS.md`](./AGENTS.md): architecture, state ownership, resolver/fallback,
|
||||
transport, performance, and testing rules.
|
||||
- [`DESIGN.md`](./DESIGN.md): visual system, information architecture, motion,
|
||||
direct manipulation, and keyboard behavior.
|
||||
|
||||
### Connections, projects, and switching
|
||||
|
||||
Desktop supports a managed local backend, explicit remote gateways, and Hermes
|
||||
Cloud connections. Remote and cloud modes use the same remote-capability path;
|
||||
authentication and discovery differ, not the renderer feature model.
|
||||
|
||||
When no usable local runtime or saved remote connection exists, the first-run
|
||||
screen offers **Connect to existing Hermes** before starting the local installer.
|
||||
Desktop probes the gateway to discover token or OAuth authentication, requires a
|
||||
successful HTTP and WebSocket connection test, and saves the connection using
|
||||
the same encrypted Desktop configuration used by Settings. A saved remote
|
||||
connection bypasses this choice on later launches. The regular Desktop build
|
||||
still includes the local-install option; this is a remote operating mode, not a
|
||||
separate client-only application.
|
||||
|
||||
In remote mode the gateway host is the execution boundary: agent tools,
|
||||
terminal commands, and file operations run against the remote Hermes host, not
|
||||
the computer displaying the Desktop UI.
|
||||
|
||||
Remote gateways that sit behind an access proxy may require extra headers on
|
||||
every HTTP and WebSocket request. Configure them per connection in Settings →
|
||||
Connections (Extra gateway headers), or add a `headers` object to Desktop's
|
||||
Electron `userData/connection.json` remote block:
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "remote",
|
||||
"remote": {
|
||||
"url": "https://hermes.example.com",
|
||||
"authMode": "token",
|
||||
"token": { "encoding": "safeStorage", "value": "..." },
|
||||
"headers": {
|
||||
"CF-Access-Client-Id": { "encoding": "safeStorage", "value": "..." },
|
||||
"CF-Access-Client-Secret": { "encoding": "safeStorage", "value": "..." }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Per-profile remote entries under `profiles[name].headers` use the same shape.
|
||||
Desktop applies these headers only to matching remote gateway requests, treats
|
||||
`https` and `wss` as the same gateway origin for WebSocket upgrades, and drops
|
||||
transport- or Hermes-managed header names such as `Authorization`, `Cookie`,
|
||||
`Host`, `Origin`, `Referer`, and `X-Hermes-Session-Token`.
|
||||
|
||||
Projects are the workspace abstraction. A project may own multiple folders,
|
||||
repositories, worktrees, and sessions; a bare new chat remains detached unless
|
||||
the user enters a project or configures a default project directory. Use the
|
||||
Projects UI rather than adding a second per-session folder-picker workflow.
|
||||
|
||||
Changing profiles or connection modes is a soft workspace switch, not another
|
||||
cold boot. The shell and current management overlay remain mounted while
|
||||
gateway-bound nanostores are wiped, query-backed data is invalidated, and the
|
||||
new connection repopulates skeletons. This prevents rows or transcripts from
|
||||
the previous gateway bleeding into the next one. Switching changes only the
|
||||
foreground view and request route: it does not cancel turns or stop a backend,
|
||||
and retained background sockets continue receiving events from running jobs.
|
||||
|
||||
### Verification
|
||||
|
||||
Run before opening a PR (lint may surface pre-existing warnings but must exit cleanly):
|
||||
|
||||
```bash
|
||||
npm run fix
|
||||
npm run typecheck
|
||||
npm run lint
|
||||
npm run test:ui
|
||||
npm run test:desktop:platforms
|
||||
```
|
||||
|
||||
Run `npm run test:desktop:all` for install, boot, update, packaging, or other
|
||||
release-path changes.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Boot logs land in `HERMES_HOME/logs/desktop.log` (includes backend output and recent Python tracebacks) — check it first if the app reports a boot failure.
|
||||
|
||||
**macOS / Linux:**
|
||||
|
||||
```bash
|
||||
# Force a clean first-launch setup
|
||||
rm "$HOME/.hermes/hermes-agent/.hermes-bootstrap-complete"
|
||||
# Rebuild a broken Python venv
|
||||
rm -rf "$HOME/.hermes/hermes-agent/venv"
|
||||
# Reset a stuck macOS microphone prompt (macOS only)
|
||||
tccutil reset Microphone com.nousresearch.hermes
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
|
||||
```powershell
|
||||
# Force a clean first-launch setup
|
||||
Remove-Item "$env:LOCALAPPDATA\hermes\hermes-agent\.hermes-bootstrap-complete"
|
||||
# Rebuild a broken Python venv
|
||||
Remove-Item -Recurse -Force "$env:LOCALAPPDATA\hermes\hermes-agent\venv"
|
||||
```
|
||||
|
||||
> The default Hermes home on Windows is `%LOCALAPPDATA%\hermes`. Set the `HERMES_HOME` env var if you've relocated it.
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
- 💬 [Discord](https://discord.gg/NousResearch)
|
||||
- 📖 [Documentation](https://hermes-agent.nousresearch.com/docs/)
|
||||
- 🐛 [Issues](https://github.com/NousResearch/hermes-agent/issues)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](../../LICENSE).
|
||||
|
||||
Built by [Nous Research](https://nousresearch.com).
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Nous Research
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,32 @@
|
||||
AITURK IDE — TurkServis
|
||||
|
||||
AITURK IDE is a TurkServis distribution derived from Hermes Agent / Hermes Desktop.
|
||||
Turkish localization and AITURK-specific modifications: Copyright (c) 2026 AITURK.
|
||||
Upstream: https://github.com/NousResearch/hermes-agent
|
||||
Upstream revision: 63279301bcbdc185c1b07b98a9312eb0c862f26d
|
||||
|
||||
This distribution is maintained by TurkServis. Nous Research does not publish,
|
||||
endorse, or provide support for the AITURK-branded distribution.
|
||||
Third-party provider names identify their respective services.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Nous Research
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
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 |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "tabler"
|
||||
}
|
||||
@@ -0,0 +1,999 @@
|
||||
/**
|
||||
* E2E at-rest contract for the remote-gateway session token (issue #77486).
|
||||
*
|
||||
* The reported bug: configuring a remote gateway persisted the dashboard
|
||||
* session token as PLAINTEXT into `connection.json` under the app's userData
|
||||
* dir (macOS `~/Library/Application Support/Hermes/connection.json`, Windows
|
||||
* `AppData\Roaming\Hermes\connection.json`). Anything that can read the file
|
||||
* — a backup, a sync client, another local process, a support bundle — got a
|
||||
* live gateway credential.
|
||||
*
|
||||
* The contract these tests encode is deliberately stated WITHOUT naming a
|
||||
* storage strategy:
|
||||
*
|
||||
* 1. ABSENT FROM DISK. After the app has been configured with a remote
|
||||
* gateway token, the token's plaintext value must not appear anywhere in
|
||||
* `connection.json`, in any sibling file the app writes under userData,
|
||||
* or in HERMES_HOME (logs included).
|
||||
* 2. STILL FUNCTIONAL. After a restart, the app must still be able to USE
|
||||
* that credential — it decrypts the stored blob and puts the exact
|
||||
* original token on the wire.
|
||||
* 3. UNREADABLE BY OTHER LOCAL ACCOUNTS. `connection.json` must not be
|
||||
* group/other-accessible, whether the app just wrote it or inherited it
|
||||
* from an older install.
|
||||
*
|
||||
* All three matter and none is sufficient alone. (1) alone is trivially
|
||||
* satisfied by a "fix" that drops the token on the floor; (2) alone is
|
||||
* satisfied by the bug itself. So (2) is verified through the app's own
|
||||
* connection test against a fake gateway that records the
|
||||
* `X-Hermes-Session-Token` header it receives — a dropped or mangled token
|
||||
* cannot produce that header.
|
||||
*
|
||||
* (3) is orthogonal to (1) and invisible to it: safeStorage keeps the token
|
||||
* opaque no matter what the file's mode is, so a 0644 `connection.json` passes
|
||||
* the raw-bytes scan every time while still exposing the ciphertext blob, the
|
||||
* gateway URL and the SSH host/user/keyPath to any other local account. It is
|
||||
* asserted explicitly (see `expectOwnerOnlyMode`) because no amount of
|
||||
* encryption evidence implies it.
|
||||
*
|
||||
* We deliberately do NOT assert `encoding === 'safeStorage'` or any other
|
||||
* shape of the stored blob. That would be a change-detector: a fix that moved
|
||||
* to the OS keychain proper, to an async safeStorage provider, or to a
|
||||
* separate credential file would break the test while being *more* correct.
|
||||
* The load-bearing assertion is the raw-bytes absence of the secret.
|
||||
*
|
||||
* Four at-rest paths, hence four tests — three enforced, one a documented gap:
|
||||
*
|
||||
* 1. A NEWLY configured token (ACTIVE). The app's own write path routes
|
||||
* through the strict `encryptDesktopSecret`; this test holds it there
|
||||
* against regression, and pins the mode of the file it actually wrote.
|
||||
* 2. An EXISTING `connection.json` at the old 0644 (ACTIVE). Covers the
|
||||
* read-side tighten, and ONLY the mode — its token is already ciphertext,
|
||||
* which is what keeps it independent of the migration test 4 defers.
|
||||
* 3. A CORRUPT `connection.json` at 0644 (ACTIVE). The tighten must not be
|
||||
* gated on the parse succeeding: a truncated file still holds the token
|
||||
* bytes, and the parse failure is swallowed, so nothing would ever come
|
||||
* back for it.
|
||||
* 4. An EXISTING plaintext `connection.json` (`test.fixme`). Legacy payloads
|
||||
* are deliberately NOT migrated yet. The test is kept, disabled, with a
|
||||
* precise reason — see the block comment above it.
|
||||
*
|
||||
* ── Correcting the record on test 4 ─────────────────────────────────────
|
||||
*
|
||||
* An earlier revision of this file asserted that migration and justified it by
|
||||
* claiming the first implementation (`d3d177283`) fell back to
|
||||
* `{ encoding: 'plain', value }` when `isEncryptionAvailable()` was false.
|
||||
* That citation is FALSE for this codebase. What is actually true:
|
||||
*
|
||||
* git merge-base --is-ancestor d3d1772837a7b0552940b55455ae734c72e0a8f1 HEAD -> 1 (NOT an ancestor)
|
||||
* git merge-base --is-ancestor 51c68d4ab1a9e3c62fb1048fccb84144c409f0e7 HEAD -> 0 (IS an ancestor)
|
||||
* git log -S 'Fall through to plaintext' upstream/main -- apps/desktop -> (no commits)
|
||||
*
|
||||
* `d3d177283` exists only on `upstream/bb/gui-mainmerge-tmp`,
|
||||
* `brooklyn/gui-installer-prereqs`, and the `desktop-pr20059-installers`
|
||||
* pre-release tag. Mainline NEVER shipped a code path that wrote a plaintext
|
||||
* gateway token: `51c68d4ab` ("Add Hermes desktop app (#20059)"), the commit
|
||||
* that brought the desktop app to mainline, already contained the strict
|
||||
* throw ("Secure token storage is unavailable, …") in `hardening.cjs`.
|
||||
*
|
||||
* One `{ encoding: 'plain', value }` literal does remain on mainline
|
||||
* (`electron/main.ts`, in `coerceDesktopConnectionConfig`), but it is
|
||||
* unreachable as an at-rest write: it is gated on `persistToken === false`,
|
||||
* whose only caller is the connection-TEST handler, which never calls
|
||||
* `writeDesktopConnectionConfig`. That token stays in memory for the duration
|
||||
* of one probe.
|
||||
*
|
||||
* So the affected population is not "anyone who configured a gateway before
|
||||
* the fix". It is narrow and non-mainline: pre-release `bb/gui` installs
|
||||
* (including the `desktop-pr20059-installers` build) plus hand-edited or
|
||||
* hand-migrated `connection.json` files. Those files DO still work, because
|
||||
* `decryptDesktopSecret` returns any non-safeStorage `value` verbatim on read
|
||||
* — the read path is intentionally unchanged, so nobody is signed out. That
|
||||
* read-path acceptance, not a mainline writer, is what makes the fixme'd
|
||||
* fixture realistic.
|
||||
*
|
||||
* Migration is DEFERRED, not forgotten. An adversarial review of the
|
||||
* migration that briefly lived here returned DO NOT SHIP, having reproduced
|
||||
* two token-loss scenarios: it silently reverts and then destroys the opt-in
|
||||
* plaintext choice that open upstream PR #62319 deliberately adds; and it
|
||||
* converts a portable credential into a keychain-bound one with no consent,
|
||||
* destroying the only recoverable copy while not actually remediating the
|
||||
* exposure (the plaintext is already in backups, so the real remedy is
|
||||
* ROTATION). The prerequisites are enumerated above test 2.
|
||||
*
|
||||
* Environment limits are encoded rather than papered over. Electron's
|
||||
* safeStorage is unavailable on Linux with no keyring, which is the shape of
|
||||
* this suite's CI runner (ubuntu-latest, see .github/workflows/e2e-desktop.yml).
|
||||
* The absence assertion is unconditional there — it is the security
|
||||
* requirement, and it must hold in every environment. Only the *other* half is
|
||||
* conditional: with secure storage the save must succeed, and without it the
|
||||
* save must fail loudly (which is what the current strict `encryptDesktopSecret`
|
||||
* does) instead of quietly writing plaintext. See the branch comments in each
|
||||
* test for the reasoning, including the one case this spec refuses to invent a
|
||||
* policy for.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { buildAppEnv, createSandbox, launchDesktop, type Sandbox } from './fixtures'
|
||||
import { allowErrorBanners, type ElectronApplication, expect, type Page, test } from './test'
|
||||
|
||||
/**
|
||||
* The secret under test. Long, random-looking, and unique to this spec so a
|
||||
* raw-bytes scan cannot produce a false negative by colliding with ordinary
|
||||
* config content. Kept to `[A-Za-z0-9-]` on purpose: encodeURIComponent() is
|
||||
* the identity function over this alphabet, so the raw-bytes needle also
|
||||
* covers the URL-encoded form the WS dialer builds (`?token=…`).
|
||||
*/
|
||||
const SENTINEL_TOKEN = 'hermes-e2e-at-rest-sentinel-Zq7Z4hV9nX2pL8sK3tB6wR1yM5jD0fG'
|
||||
|
||||
/** Skip absurdly large files during the leak scan (Chromium caches). */
|
||||
const MAX_SCAN_BYTES = 16 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* One fixed Electron app name for this spec, instead of the timestamped one
|
||||
* `buildAppEnv` generates. On macOS the safeStorage keychain item is derived
|
||||
* from the app name, so a per-launch name would (a) make the post-restart
|
||||
* decrypt fail for the wrong reason and (b) leave a fresh keychain entry on
|
||||
* the developer's login keychain on every run. Safe because the suite runs
|
||||
* one worker at a time and both launches here are sequential; the
|
||||
* single-instance lock keys off userData, which is per-sandbox.
|
||||
*/
|
||||
const STABLE_APP_NAME = 'HermesE2EAtRestStorage'
|
||||
|
||||
// ─── Fake gateway ───────────────────────────────────────────────────────
|
||||
|
||||
interface FakeGateway {
|
||||
url: string
|
||||
/** Every `X-Hermes-Session-Token` value the app has sent us. */
|
||||
sessionTokens: string[]
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* A minimal stand-in for a remote Hermes gateway. It serves the public
|
||||
* `/api/status` probe (which the desktop connection test hits first, with the
|
||||
* session token in a header) and refuses the WebSocket upgrade immediately so
|
||||
* the second leg of the connection test fails fast instead of burning the
|
||||
* probe's 10s connect timeout. We only care about the header it captured.
|
||||
*
|
||||
* The e2e mock-server is an OpenAI-compatible *inference* mock, not a gateway,
|
||||
* so it cannot answer /api/status — hence this small local server.
|
||||
*/
|
||||
async function startFakeGateway(): Promise<FakeGateway> {
|
||||
const sessionTokens: string[] = []
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const token = req.headers['x-hermes-session-token']
|
||||
|
||||
if (typeof token === 'string' && token) {
|
||||
sessionTokens.push(token)
|
||||
}
|
||||
|
||||
if (req.url?.startsWith('/api/status')) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ auth_required: false, ok: true, version: '0.0.0-e2e-fake' }))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ detail: 'not found' }))
|
||||
})
|
||||
|
||||
// Refuse the WS leg at once: the connection test's WS probe should return a
|
||||
// fast failure rather than hang. The status header is already captured.
|
||||
server.on('upgrade', (req, socket) => {
|
||||
const token = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('token')
|
||||
|
||||
if (token) {
|
||||
sessionTokens.push(token)
|
||||
}
|
||||
|
||||
socket.destroy()
|
||||
})
|
||||
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
|
||||
const { port } = server.address() as AddressInfo
|
||||
|
||||
return {
|
||||
close: () =>
|
||||
new Promise<void>(resolve => {
|
||||
server.closeAllConnections?.()
|
||||
server.close(() => resolve())
|
||||
}),
|
||||
sessionTokens,
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── On-disk leak scanning ──────────────────────────────────────────────
|
||||
|
||||
interface Needle {
|
||||
bytes: Buffer
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The forms a leak could take. Raw bytes, not JSON.parse + field inspection:
|
||||
* the point is that the secret is nowhere in the file — including inside a
|
||||
* nested field, a cached WS URL, or a field name nobody thought to check.
|
||||
*
|
||||
* The base64 needle catches the cheapest wrong "fix": base64 is an encoding,
|
||||
* not encryption, so a token that is merely base64'd is still plaintext at
|
||||
* rest. A real ciphertext will contain neither needle.
|
||||
*/
|
||||
function secretNeedles(secret: string): Needle[] {
|
||||
return [
|
||||
{ bytes: Buffer.from(secret, 'utf8'), label: 'plaintext' },
|
||||
{ bytes: Buffer.from(Buffer.from(secret, 'utf8').toString('base64'), 'utf8'), label: 'base64' },
|
||||
]
|
||||
}
|
||||
|
||||
/** Relative paths of every file under `root` whose bytes contain a needle. */
|
||||
function scanTreeForSecret(root: string, needles: Needle[]): string[] {
|
||||
const hits: string[] = []
|
||||
|
||||
const walk = (dir: string): void => {
|
||||
let entries: fs.Dirent[]
|
||||
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
walk(full)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (!entry.isFile()) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
if (fs.statSync(full).size > MAX_SCAN_BYTES) {
|
||||
continue
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
let buf: Buffer
|
||||
|
||||
try {
|
||||
buf = fs.readFileSync(full)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const needle of needles) {
|
||||
if (buf.includes(needle.bytes)) {
|
||||
hits.push(`${path.relative(root, full)} [${needle.label}]`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root)
|
||||
|
||||
return hits
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file's bytes, or an empty buffer when it does not exist. A correct
|
||||
* fix is allowed to delete/replace `connection.json` rather than rewrite it,
|
||||
* and the refusal path may never create it at all — neither should crash the
|
||||
* scan before its assertion runs.
|
||||
*/
|
||||
function readIfExists(filePath: string): Buffer {
|
||||
try {
|
||||
return fs.readFileSync(filePath)
|
||||
} catch {
|
||||
return Buffer.alloc(0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored token's `encoding` tag, for diagnostics only — never its value.
|
||||
* Reported on failure so a red run says *why* (e.g. still `plain`) instead of
|
||||
* only that a scan matched. Deliberately NOT an assertion: which encoding a
|
||||
* correct fix chooses is its own business.
|
||||
*/
|
||||
function storedTokenEncoding(connectionFile: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(readIfExists(connectionFile).toString('utf8'))
|
||||
|
||||
return String(parsed?.remote?.token?.encoding ?? '<none>')
|
||||
} catch {
|
||||
return '<unparsable>'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert a credential file is not readable or writable by group/other.
|
||||
*
|
||||
* This is the one contract the raw-bytes scan above structurally cannot see:
|
||||
* safeStorage keeps the token opaque regardless of the file's mode, so a
|
||||
* world-readable `connection.json` passes every absence assertion in this file
|
||||
* while still handing the URL, the SSH host/user/keyPath, and the ciphertext
|
||||
* blob to any other local account. Encryption and permissions are independent
|
||||
* halves of "at rest", and only one of them was covered here.
|
||||
*
|
||||
* Asserted as `mode & 0o077 === 0` rather than `=== 0o600`: the requirement is
|
||||
* that nobody else can reach the file, and pinning the exact bits would make
|
||||
* this a change-detector against a future 0400 or a setgid-dir umask.
|
||||
*
|
||||
* POSIX only. `tightenSecretFileMode` no-ops on Windows deliberately (Node maps
|
||||
* chmod to the read-only bit there, and userData is already ACL'd to the user
|
||||
* profile — see the docstring in electron/hardening.ts, and PR #77527 for the
|
||||
* one place ACLs are being handled). Mode bits are advisory on Windows, so
|
||||
* asserting them would go red for behaviour the fix never claimed. The suite
|
||||
* runs ubuntu-latest today (.github/workflows/e2e-desktop.yml); nothing else in
|
||||
* this spec is platform-specific, and this assertion should not be what
|
||||
* changes that.
|
||||
*/
|
||||
function expectOwnerOnlyMode(filePath: string, why: string): void {
|
||||
if (process.platform === 'win32') {
|
||||
return
|
||||
}
|
||||
|
||||
const mode = fs.statSync(filePath).mode & 0o777
|
||||
|
||||
expect(mode & 0o077, `${why} (mode ${mode.toString(8)})`).toBe(0)
|
||||
}
|
||||
|
||||
// ─── App helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Launch the desktop app against `sandbox` with a fake boot failure injected.
|
||||
*
|
||||
* The credential path we are testing is entirely main-process (IPC handler →
|
||||
* coerce → safeStorage → userData write) and does not need a live agent
|
||||
* backend, so we skip spawning `hermes serve` (no Python needed, ~3s launch,
|
||||
* hermetic). This is also a real user situation rather than an artificial one:
|
||||
* the boot-failure overlay's own recovery affordance is "Connection settings",
|
||||
* i.e. pointing the app at a remote gateway is exactly what a user does from
|
||||
* this state. BOOT_FAKE_ERROR short-circuits startHermes() *before* remote
|
||||
* resolution, so no launch ever dials the fake gateway on its own.
|
||||
*/
|
||||
async function launchAgainst(sandbox: Sandbox): Promise<{ app: ElectronApplication; page: Page }> {
|
||||
const env = buildAppEnv(sandbox, {
|
||||
HERMES_DESKTOP_APP_NAME: STABLE_APP_NAME,
|
||||
HERMES_DESKTOP_BOOT_FAKE_ERROR: 'E2E at-rest storage spec: local backend intentionally not started',
|
||||
})
|
||||
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
// The capability bridge is what we drive; it lands with the preload, well
|
||||
// before the app would be "ready" in the boot sense.
|
||||
await page.waitForFunction(
|
||||
() => Boolean((window as unknown as { hermesDesktop?: Record<string, unknown> }).hermesDesktop?.saveConnectionConfig),
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
|
||||
return { app, page }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the running app where userData actually is, the same way the app does
|
||||
* (`app.getPath('userData')`). The fixtures point userData at a temp sandbox,
|
||||
* so a home-relative hardcoded path would test the wrong file — or no file.
|
||||
*/
|
||||
async function resolveUserDataDir(app: ElectronApplication): Promise<string> {
|
||||
return app.evaluate(({ app: electronApp }) => electronApp.getPath('userData'))
|
||||
}
|
||||
|
||||
interface SafeStorageCapability {
|
||||
available: boolean
|
||||
backend: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What secure storage is actually capable of on THIS host, asked after ready
|
||||
* (on Linux the answer is meaningless before then).
|
||||
*
|
||||
* `backend` matters for the honest reading of a green run: on Linux with no
|
||||
* keyring, Electron can still report encryption as available while selecting
|
||||
* the `basic_text` backend, which encrypts with a hardcoded password — the
|
||||
* bytes on disk are not the plaintext, but they are not meaningfully
|
||||
* protected either. We record it rather than assert on it, because which
|
||||
* posture Hermes should take there (refuse to save vs. accept basic_text) is
|
||||
* a product decision, not something this test should silently ratify.
|
||||
*/
|
||||
async function readSafeStorageCapability(app: ElectronApplication): Promise<SafeStorageCapability> {
|
||||
return app.evaluate(async ({ app: electronApp, safeStorage }) => {
|
||||
await electronApp.whenReady()
|
||||
|
||||
let available = false
|
||||
let backend = 'unavailable'
|
||||
|
||||
try {
|
||||
available = safeStorage.isEncryptionAvailable()
|
||||
} catch {
|
||||
available = false
|
||||
}
|
||||
|
||||
try {
|
||||
// Linux-oriented API; other platforms may not implement it.
|
||||
backend = safeStorage.getSelectedStorageBackend?.() ?? 'n/a'
|
||||
} catch {
|
||||
backend = 'n/a'
|
||||
}
|
||||
|
||||
return { available, backend }
|
||||
})
|
||||
}
|
||||
|
||||
interface SaveOutcome {
|
||||
config: { remoteTokenPreview?: null | string; remoteTokenSet?: boolean; remoteUrl?: string } | null
|
||||
error: null | string
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the app's REAL save surface: the same `saveConnectionConfig` payload
|
||||
* Settings → Gateway sends (see src/app/settings/gateway-settings.tsx). We use
|
||||
* save rather than apply so the app persists the credential without trying to
|
||||
* re-home onto the fake gateway.
|
||||
*/
|
||||
async function saveRemoteToken(page: Page, remoteUrl: string, remoteToken?: string): Promise<SaveOutcome> {
|
||||
return page.evaluate(
|
||||
async ([url, token]) => {
|
||||
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
||||
|
||||
try {
|
||||
const config = await desktop.saveConnectionConfig({
|
||||
mode: 'remote',
|
||||
remoteAuthMode: 'token',
|
||||
...(token ? { remoteToken: token } : {}),
|
||||
remoteUrl: url,
|
||||
})
|
||||
|
||||
return { config, error: null }
|
||||
} catch (error) {
|
||||
return { config: null, error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
},
|
||||
[remoteUrl, remoteToken ?? ''] as const,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the app USE the stored credential. No token in the payload, so the main
|
||||
* process must read `connection.json`, decrypt what it stored, and put the
|
||||
* plaintext on the wire itself. `buildRemoteBlock` throws "Remote gateway
|
||||
* session token is required." when the stored blob no longer decrypts, so a
|
||||
* fix that dropped the token fails here instead of quietly passing the
|
||||
* absence assertion.
|
||||
*/
|
||||
async function exerciseStoredToken(page: Page, remoteUrl: string): Promise<{ error: null | string }> {
|
||||
return page.evaluate(async url => {
|
||||
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
||||
|
||||
try {
|
||||
await desktop.testConnectionConfig({ mode: 'remote', remoteUrl: url })
|
||||
|
||||
return { error: null }
|
||||
} catch (error) {
|
||||
// A failing WS leg is expected (the fake gateway refuses the upgrade).
|
||||
// The assertion is on what the gateway received, not on this result.
|
||||
return { error: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}, remoteUrl)
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
let gateway: FakeGateway | null = null
|
||||
let sandbox: Sandbox | null = null
|
||||
let app: ElectronApplication | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
gateway = await startFakeGateway()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await gateway?.close()
|
||||
gateway = null
|
||||
})
|
||||
|
||||
test.beforeEach(() => {
|
||||
// Boot is intentionally failed in this spec (see launchAgainst), so the
|
||||
// boot-failure overlay's error banner is expected, not a failure.
|
||||
allowErrorBanners()
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await app?.close().catch(() => undefined)
|
||||
app = null
|
||||
sandbox?.cleanup()
|
||||
sandbox = null
|
||||
})
|
||||
|
||||
test.describe('remote gateway session token at rest', () => {
|
||||
test('with keychain encryption opted IN, a newly configured token is never written to userData in plaintext, and still works after restart', async () => {
|
||||
const fake = gateway!
|
||||
sandbox = createSandbox('at-rest-fresh')
|
||||
|
||||
// Keychain-backed encryption is opt-in (default OFF — see
|
||||
// electron/secret-storage-policy.ts). This test covers the opted-IN
|
||||
// posture, so seed the policy the way the Settings toggle writes it.
|
||||
fs.writeFileSync(
|
||||
path.join(sandbox.userDataDir, 'secure-token-storage.json'),
|
||||
JSON.stringify({ migrated: true, on: true }),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const first = await launchAgainst(sandbox)
|
||||
app = first.app
|
||||
|
||||
const capability = await readSafeStorageCapability(app)
|
||||
const userDataDir = await resolveUserDataDir(app)
|
||||
const connectionFile = path.join(userDataDir, 'connection.json')
|
||||
|
||||
test.info().annotations.push({
|
||||
description: `isEncryptionAvailable=${capability.available} backend=${capability.backend}`,
|
||||
type: 'safeStorage',
|
||||
})
|
||||
|
||||
const saved = await saveRemoteToken(first.page, fake.url, SENTINEL_TOKEN)
|
||||
|
||||
// Defined degradation, not a silent plaintext write. Where secure storage
|
||||
// works, the save must succeed. Where it genuinely does not (headless
|
||||
// Linux with no keyring, per Electron's safeStorage docs), refusing the
|
||||
// save with a loud error is an acceptable outcome — what is NEVER
|
||||
// acceptable is reporting success while leaving the secret readable on
|
||||
// disk. The absence assertion below runs in both branches.
|
||||
if (capability.available) {
|
||||
expect(
|
||||
saved.error,
|
||||
'secure storage is available on this host, so saving a remote gateway token must succeed',
|
||||
).toBeNull()
|
||||
expect(saved.config?.remoteTokenSet).toBe(true)
|
||||
} else {
|
||||
expect(
|
||||
saved.error,
|
||||
'secure storage is unavailable, so the save must fail loudly rather than persist a plaintext token',
|
||||
).not.toBeNull()
|
||||
}
|
||||
|
||||
// Guard against a vacuous pass: when the save succeeded, the artifact must
|
||||
// exist and must be the file the app really wrote for THIS connection.
|
||||
// Without this, "no plaintext on disk" would also be true if nothing had
|
||||
// been saved at all. Only asserted on the success branch — a refused save
|
||||
// legitimately leaves no file behind.
|
||||
const rawConnection = readIfExists(connectionFile)
|
||||
|
||||
if (capability.available) {
|
||||
expect(fs.existsSync(connectionFile), `expected the app to write ${connectionFile}`).toBe(true)
|
||||
expect(
|
||||
rawConnection.includes(Buffer.from(fake.url, 'utf8')),
|
||||
'connection.json should record the configured gateway URL (proves this is the real artifact)',
|
||||
).toBe(true)
|
||||
|
||||
// The write path's OTHER half of at-rest: opaque bytes AND owner-only
|
||||
// permissions. Deliberately here, on the file this test just proved the
|
||||
// app really wrote, rather than in a unit test — nothing in the repo
|
||||
// imports electron/main.ts (it imports electron), so this is the only
|
||||
// place that can witness the app's own write actually going out at 0600
|
||||
// instead of the 0644 umask default.
|
||||
expectOwnerOnlyMode(
|
||||
connectionFile,
|
||||
'connection.json is group/other-accessible, so the encrypted token blob, gateway URL and SSH fields are readable by other local accounts',
|
||||
)
|
||||
}
|
||||
|
||||
// ── The load-bearing assertion ─────────────────────────────────────
|
||||
const needles = secretNeedles(SENTINEL_TOKEN)
|
||||
|
||||
const connectionHits = needles.filter(needle => rawConnection.includes(needle.bytes)).map(needle => needle.label)
|
||||
expect(
|
||||
connectionHits,
|
||||
`the gateway session token must not be recoverable from ${connectionFile} ` +
|
||||
`(stored token encoding is "${storedTokenEncoding(connectionFile)}")`,
|
||||
).toEqual([])
|
||||
|
||||
// …and not in any sibling file the app writes alongside it, nor in
|
||||
// HERMES_HOME (desktop.log lives there).
|
||||
expect(
|
||||
scanTreeForSecret(userDataDir, needles),
|
||||
'the gateway session token leaked into a userData file',
|
||||
).toEqual([])
|
||||
expect(
|
||||
scanTreeForSecret(sandbox.hermesHome, needles),
|
||||
'the gateway session token leaked into a HERMES_HOME file (logs included)',
|
||||
).toEqual([])
|
||||
|
||||
if (!capability.available) {
|
||||
// Nothing was stored, so there is no round trip to verify. The refusal
|
||||
// itself was already asserted above.
|
||||
return
|
||||
}
|
||||
|
||||
// ── Secondary: the credential must still be USABLE ─────────────────
|
||||
// Restart against the same userData so the token comes off disk, not out
|
||||
// of a live process's memory.
|
||||
await app.close().catch(() => undefined)
|
||||
app = null
|
||||
|
||||
const second = await launchAgainst(sandbox)
|
||||
app = second.app
|
||||
|
||||
expect(
|
||||
await resolveUserDataDir(app),
|
||||
'the restarted app must resolve the same userData dir, or this is not a round trip',
|
||||
).toBe(userDataDir)
|
||||
|
||||
const reread = await second.page.evaluate(async () => {
|
||||
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
||||
|
||||
return desktop.getConnectionConfig()
|
||||
})
|
||||
|
||||
expect(reread.remoteTokenSet, 'the stored token must survive a restart').toBe(true)
|
||||
expect(reread.remoteUrl).toBe(fake.url)
|
||||
|
||||
const before = fake.sessionTokens.length
|
||||
await exerciseStoredToken(second.page, fake.url)
|
||||
|
||||
// The gateway is the witness: the app decrypted its stored blob and put
|
||||
// the original secret on the wire. A dropped, truncated, or re-encoded
|
||||
// token cannot produce this.
|
||||
expect(
|
||||
fake.sessionTokens.slice(before),
|
||||
'the app must send the exact stored token to the gateway after a restart',
|
||||
).toContain(SENTINEL_TOKEN)
|
||||
})
|
||||
|
||||
/**
|
||||
* The DEFAULT posture: keychain encryption opted out (no policy file at
|
||||
* all). Saving a token must (a) succeed without ever touching safeStorage
|
||||
* — this is the whole point of the opt-in: no macOS Keychain dialog on
|
||||
* machines with a broken login keychain — (b) store the token with a
|
||||
* non-safeStorage encoding at 0600, and (c) round-trip it across a
|
||||
* restart. The plaintext-on-disk trade-off is the user's chosen (default)
|
||||
* mode; owner-only file bits remain the at-rest boundary.
|
||||
*/
|
||||
test('with the default policy (no keychain), a token saves without secure storage, is owner-only on disk, and survives a restart', async () => {
|
||||
const fake = gateway!
|
||||
sandbox = createSandbox('at-rest-default')
|
||||
|
||||
const first = await launchAgainst(sandbox)
|
||||
app = first.app
|
||||
|
||||
const userDataDir = await resolveUserDataDir(app)
|
||||
const connectionFile = path.join(userDataDir, 'connection.json')
|
||||
|
||||
// Must succeed regardless of host keyring state — the default policy
|
||||
// never consults safeStorage, so "no keyring" cannot refuse the save.
|
||||
const saved = await saveRemoteToken(first.page, fake.url, SENTINEL_TOKEN)
|
||||
|
||||
expect(saved.error, 'the default (opted-out) policy must save without secure storage').toBeNull()
|
||||
expect(saved.config?.remoteTokenSet).toBe(true)
|
||||
|
||||
// Not a safeStorage blob, and owner-only on disk.
|
||||
expect(storedTokenEncoding(connectionFile)).not.toBe('safeStorage')
|
||||
expectOwnerOnlyMode(
|
||||
connectionFile,
|
||||
'connection.json is group/other-accessible; owner-only bits are the at-rest boundary for opted-out storage',
|
||||
)
|
||||
|
||||
// Round trip across a restart, same witness as the opted-in test.
|
||||
await app.close().catch(() => undefined)
|
||||
app = null
|
||||
|
||||
const second = await launchAgainst(sandbox)
|
||||
app = second.app
|
||||
|
||||
const reread = await second.page.evaluate(async () => {
|
||||
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
||||
|
||||
return desktop.getConnectionConfig()
|
||||
})
|
||||
|
||||
expect(reread.remoteTokenSet, 'the stored token must survive a restart').toBe(true)
|
||||
|
||||
const before = fake.sessionTokens.length
|
||||
await exerciseStoredToken(second.page, fake.url)
|
||||
|
||||
expect(
|
||||
fake.sessionTokens.slice(before),
|
||||
'the app must send the exact stored token to the gateway after a restart',
|
||||
).toContain(SENTINEL_TOKEN)
|
||||
})
|
||||
|
||||
/**
|
||||
* The read side of the same contract: an install written BEFORE the file was
|
||||
* owner-only keeps its 0644 bits until something chmods it, and the write
|
||||
* path cannot fix it — `fs.writeFileSync(path, data, { mode })` applies
|
||||
* `mode` only when it CREATES the file. Waiting for the user's next Settings
|
||||
* save would leave the file group/other-readable indefinitely, which is why
|
||||
* `readDesktopConnectionConfig` tightens on a cache miss.
|
||||
*
|
||||
* Scoped to the MODE, and deliberately independent of the deferred migration
|
||||
* below. The fixture's token is already safeStorage ciphertext (the app wrote
|
||||
* it), so nothing here re-encrypts anything, touches the #62319 opt-in
|
||||
* plaintext marker, or needs rotation guidance — the three prerequisites that
|
||||
* keep the next test fixme'd. Tightening a permission bit neither performs a
|
||||
* migration nor claims to, so it can be covered now while migration stays
|
||||
* deferred.
|
||||
*
|
||||
* The fixture is produced by the app itself rather than hand-written, so the
|
||||
* only difference from a real pre-fix install is the one bit under test.
|
||||
*/
|
||||
test('an install whose connection.json predates owner-only mode is tightened on read', async () => {
|
||||
const fake = gateway!
|
||||
sandbox = createSandbox('at-rest-tighten')
|
||||
|
||||
const first = await launchAgainst(sandbox)
|
||||
app = first.app
|
||||
|
||||
const capability = await readSafeStorageCapability(app)
|
||||
|
||||
test.info().annotations.push({
|
||||
description: `isEncryptionAvailable=${capability.available} backend=${capability.backend}`,
|
||||
type: 'safeStorage',
|
||||
})
|
||||
|
||||
if (!capability.available) {
|
||||
// Without secure storage the save is refused by design, so there is no
|
||||
// app-written artifact to loosen and re-read. The refusal itself is
|
||||
// already asserted in the first test.
|
||||
test.skip(true, 'secure storage unavailable on this host — no app-written connection.json to tighten')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const userDataDir = await resolveUserDataDir(app)
|
||||
const connectionFile = path.join(userDataDir, 'connection.json')
|
||||
|
||||
const saved = await saveRemoteToken(first.page, fake.url, SENTINEL_TOKEN)
|
||||
expect(saved.error, 'the fixture write must succeed, or there is nothing to tighten').toBeNull()
|
||||
|
||||
await app.close().catch(() => undefined)
|
||||
app = null
|
||||
|
||||
// Regress the file to what a pre-fix install has on disk. Everything else
|
||||
// about it — including the encrypted token — is exactly what the app wrote.
|
||||
fs.chmodSync(connectionFile, 0o644)
|
||||
expect(fs.statSync(connectionFile).mode & 0o077, 'the fixture must start group/other-accessible').not.toBe(0)
|
||||
|
||||
const seededMtimeMs = fs.statSync(connectionFile).mtimeMs
|
||||
|
||||
// A fresh process starts with an empty config cache, so the first read is a
|
||||
// miss and the tighten runs. `getConnectionConfig()` forces that read
|
||||
// through the app's own IPC surface.
|
||||
const second = await launchAgainst(sandbox)
|
||||
app = second.app
|
||||
|
||||
const reread = await second.page.evaluate(async () => {
|
||||
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
||||
|
||||
return desktop.getConnectionConfig()
|
||||
})
|
||||
|
||||
expectOwnerOnlyMode(
|
||||
connectionFile,
|
||||
'a pre-existing world-readable connection.json was not tightened when the app read it',
|
||||
)
|
||||
|
||||
// The tighten must be a chmod, not a rewrite. It sits INSIDE the function
|
||||
// whose cache keys on mtimeMs, so if it ever moved mtime it would
|
||||
// invalidate that cache on every read and re-tighten forever. chmod moves
|
||||
// ctime only, which is what makes the placement safe — this pins it.
|
||||
expect(
|
||||
Math.abs(fs.statSync(connectionFile).mtimeMs - seededMtimeMs),
|
||||
'tightening must not rewrite the file: mtime is the config cache key, so moving it would invalidate the cache the tighten sits inside',
|
||||
).toBeLessThan(1)
|
||||
|
||||
// And tightening must not have cost the user their credential — the whole
|
||||
// reason this happens on read instead of by deleting the file.
|
||||
expect(reread.remoteTokenSet, 'the stored token must survive being tightened').toBe(true)
|
||||
expect(reread.remoteUrl).toBe(fake.url)
|
||||
})
|
||||
|
||||
/**
|
||||
* The tighten must not be gated on the file being valid JSON.
|
||||
*
|
||||
* A truncated `connection.json` — an interrupted write on an older build, a
|
||||
* half-finished hand edit, a partially restored backup — still contains the
|
||||
* token bytes, and `JSON.parse` throws straight into the `catch` that falls
|
||||
* back to local mode. That fallback is never written back, so nothing
|
||||
* re-tightens the file later. With the chmod sequenced AFTER the parse,
|
||||
* exactly the file that is both corrupt AND world-readable would be the one
|
||||
* file never tightened, permanently.
|
||||
*
|
||||
* This is the only test that can tell the two orderings apart: every other
|
||||
* test here uses a parseable file, where either ordering tightens. Asserting
|
||||
* `mode === 'local'` is what makes it load-bearing — it proves the parse
|
||||
* really threw, so a green mode assertion cannot be explained by anything
|
||||
* downstream of the parse.
|
||||
*
|
||||
* Needs no secure storage: it is a chmod on a file that is never decrypted,
|
||||
* so it holds on the keyring-less CI runner too.
|
||||
*/
|
||||
test('a corrupt connection.json is tightened even though it never parses', async () => {
|
||||
sandbox = createSandbox('at-rest-tighten-corrupt')
|
||||
|
||||
const connectionFile = path.join(sandbox.userDataDir, 'connection.json')
|
||||
|
||||
// Truncated mid-token: unparseable, yet the secret bytes are right there.
|
||||
fs.writeFileSync(
|
||||
connectionFile,
|
||||
`{"mode":"remote","remote":{"authMode":"token","token":{"encoding":"plain","value":"${SENTINEL_TOKEN}`,
|
||||
{ encoding: 'utf8', mode: 0o644 },
|
||||
)
|
||||
fs.chmodSync(connectionFile, 0o644)
|
||||
expect(fs.statSync(connectionFile).mode & 0o077, 'the fixture must start group/other-accessible').not.toBe(0)
|
||||
|
||||
const seededMtimeMs = fs.statSync(connectionFile).mtimeMs
|
||||
|
||||
const launched = await launchAgainst(sandbox)
|
||||
app = launched.app
|
||||
|
||||
const reread = await launched.page.evaluate(async () => {
|
||||
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
||||
|
||||
return desktop.getConnectionConfig()
|
||||
})
|
||||
|
||||
expect(
|
||||
reread.mode,
|
||||
'the fixture must be unparseable, so the app falls back to local — otherwise this test proves nothing about ordering',
|
||||
).toBe('local')
|
||||
|
||||
expectOwnerOnlyMode(
|
||||
connectionFile,
|
||||
'a corrupt world-readable connection.json still holding token bytes was left group/other-accessible',
|
||||
)
|
||||
|
||||
// Same cache invariant as above: chmod, not rewrite.
|
||||
expect(
|
||||
Math.abs(fs.statSync(connectionFile).mtimeMs - seededMtimeMs),
|
||||
'tightening must not rewrite the file: mtime is the config cache key',
|
||||
).toBeLessThan(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* DEFERRED GAP — legacy plaintext payloads are not migrated.
|
||||
*
|
||||
* Held as `fixme` rather than deleted: the fixture below is the correct
|
||||
* fixture for the population that a migration must eventually cover, and
|
||||
* the harness (seed → boot-poll → authoritative re-save → raw-bytes scan →
|
||||
* wire check) is the harness such a migration needs. Keeping it typechecked
|
||||
* and listed makes the gap visible in `--list` and in every report; deleting
|
||||
* it would make the gap invisible and cost the next implementer this setup.
|
||||
*
|
||||
* It is NOT enabled because the migration it asserted was reviewed
|
||||
* DO NOT SHIP. Before this can be un-fixme'd, three prerequisites (see the
|
||||
* header, and the matching note in electron/main.ts readDesktopConnectionConfig):
|
||||
*
|
||||
* 1. Sequence with #62319's opt-in plaintext marker, so a user who
|
||||
* deliberately chose plaintext is not silently overridden. This
|
||||
* fixture has NO marker, so it stays in scope for migration — but the
|
||||
* implementation must be able to tell the two apart.
|
||||
* 2. Write through the config sanitizer, not around it.
|
||||
* 3. Surface ROTATION guidance. Re-encrypting cannot un-expose a secret
|
||||
* that is already in a backup; it only prevents future exposure.
|
||||
*
|
||||
* Un-fixme'ing this without (1) risks destroying a deliberate user choice,
|
||||
* and without (3) it reports a remediation it did not actually perform.
|
||||
*/
|
||||
test('an existing plaintext connection.json is migrated off plaintext and keeps working', async () => {
|
||||
test.fixme(
|
||||
true,
|
||||
'Deferred: legacy plaintext connection.json is intentionally NOT migrated. ' +
|
||||
'Affected population is pre-release bb/gui installs (incl. the desktop-pr20059-installers build) ' +
|
||||
'plus hand-edited configs — mainline never wrote a plaintext gateway token. ' +
|
||||
'Blocked on: (1) #62319 opt-in-marker coordination, (2) writing through the config sanitizer, ' +
|
||||
'(3) surfacing token-rotation guidance. Re-encrypting alone does not remediate an already-backed-up secret.',
|
||||
)
|
||||
|
||||
const fake = gateway!
|
||||
sandbox = createSandbox('at-rest-migrate')
|
||||
|
||||
// Seed the file an affected user has on disk. This is live, usable
|
||||
// plaintext rather than a strawman, because `decryptDesktopSecret` returns
|
||||
// `value` verbatim for any non-safeStorage encoding — the READ path
|
||||
// accepts it. Note what does NOT justify this fixture: mainline never
|
||||
// WROTE this shape to disk. `coerceDesktopConnectionConfig` does build it,
|
||||
// but only under `persistToken: false`, whose sole caller is the
|
||||
// connection-test handler, which never persists. The writers were
|
||||
// non-mainline pre-release builds and hand edits. There is deliberately no
|
||||
// opt-in marker here, so this payload is in scope for a future migration.
|
||||
fs.writeFileSync(
|
||||
path.join(sandbox.userDataDir, 'connection.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: 'remote',
|
||||
profiles: {},
|
||||
remote: {
|
||||
authMode: 'token',
|
||||
token: { encoding: 'plain', value: SENTINEL_TOKEN },
|
||||
url: fake.url,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const launched = await launchAgainst(sandbox)
|
||||
app = launched.app
|
||||
|
||||
const capability = await readSafeStorageCapability(app)
|
||||
|
||||
test.info().annotations.push({
|
||||
description: `isEncryptionAvailable=${capability.available} backend=${capability.backend}`,
|
||||
type: 'safeStorage',
|
||||
})
|
||||
|
||||
if (!capability.available) {
|
||||
// With no secure storage there is nowhere to migrate the secret TO, and
|
||||
// scrubbing it would silently sign the user out of a working gateway.
|
||||
// Asserting either outcome here would be inventing policy.
|
||||
test.skip(
|
||||
true,
|
||||
'secure storage unavailable on this host — the correct migration policy for an existing plaintext file is undecided',
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const userDataDir = await resolveUserDataDir(app)
|
||||
const connectionFile = path.join(userDataDir, 'connection.json')
|
||||
const needles = secretNeedles(SENTINEL_TOKEN)
|
||||
|
||||
// Two chances, so the test does not depend on WHERE the fix hooks the
|
||||
// migration: (a) on read at boot, (b) on the next authoritative write.
|
||||
// Poll for (a) first.
|
||||
const deadline = Date.now() + 15_000
|
||||
let stillPlaintext = true
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
stillPlaintext = readIfExists(connectionFile).includes(needles[0].bytes)
|
||||
|
||||
if (!stillPlaintext) {
|
||||
break
|
||||
}
|
||||
|
||||
await launched.page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
if (stillPlaintext) {
|
||||
// (b) A real save through the app's own surface, carrying no new token —
|
||||
// the stored blob is inherited. Re-persisting an inherited secret is the
|
||||
// other place plaintext must not survive.
|
||||
const resaved = await saveRemoteToken(launched.page, fake.url)
|
||||
expect(resaved.error, 'a re-save that inherits the stored token must not fail').toBeNull()
|
||||
}
|
||||
|
||||
expect(
|
||||
scanTreeForSecret(userDataDir, needles),
|
||||
'an existing plaintext gateway token must not remain readable under userData after the app has run ' +
|
||||
`(stored token encoding is still "${storedTokenEncoding(connectionFile)}")`,
|
||||
).toEqual([])
|
||||
|
||||
// And the migration must not have cost the user their credential.
|
||||
const before = fake.sessionTokens.length
|
||||
await exerciseStoredToken(launched.page, fake.url)
|
||||
|
||||
expect(
|
||||
fake.sessionTokens.slice(before),
|
||||
'the migrated token must still reach the gateway unchanged',
|
||||
).toContain(SENTINEL_TOKEN)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* E2E batch clarify test — the multi-question clarify card must mount ONCE.
|
||||
*
|
||||
* Regression coverage for the duplicated-card bug: `tool.start` carries the
|
||||
* model's tool_call_id while `clarify.request` carries a gateway-generated
|
||||
* request_id. A batch payload has no top-level `question`, so the two rows
|
||||
* only merge when the correlation key comes from the question list
|
||||
* (`batchClarifyMatchValue` in lib/chat-messages/tool-parts.ts). Before that
|
||||
* fix this exact flow rendered two identical interactive cards.
|
||||
*
|
||||
* The flow runs the real chain: composer → gateway → agent → clarify tool →
|
||||
* clarify.request event → renderer, against the mock inference server.
|
||||
*/
|
||||
|
||||
import { expect, test } from './test'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { BATCH_CLARIFY_QUESTIONS, BATCH_CLARIFY_TRIGGER } from './mock-server'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture!, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('batch clarify card', () => {
|
||||
test('renders exactly one card and completes via per-question locks', async () => {
|
||||
const page = fixture!.page
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
|
||||
await composer.click()
|
||||
await composer.type(BATCH_CLARIFY_TRIGGER, { delay: 20 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// The live batch form marks itself with data-clarify-batch=<count>.
|
||||
const batchCard = page.locator('form[data-clarify-batch]')
|
||||
await batchCard.first().waitFor({ state: 'visible', timeout: 60_000 })
|
||||
|
||||
// THE regression assertion: one card, not two.
|
||||
await expect(batchCard).toHaveCount(1)
|
||||
await expect(batchCard).toHaveAttribute('data-clarify-batch', String(BATCH_CLARIFY_QUESTIONS.length))
|
||||
|
||||
// Both questions render inside the single card.
|
||||
for (const entry of BATCH_CLARIFY_QUESTIONS) {
|
||||
await expect(batchCard.getByText(entry.question)).toHaveCount(1)
|
||||
}
|
||||
|
||||
// Each question text also appears exactly once in the whole transcript —
|
||||
// catches a duplicate that mounts outside a form[data-clarify-batch].
|
||||
for (const entry of BATCH_CLARIFY_QUESTIONS) {
|
||||
await expect(page.getByText(entry.question)).toHaveCount(1)
|
||||
}
|
||||
|
||||
// Answer both questions: stage picks locally (no server traffic yet).
|
||||
const confirmButton = batchCard.locator('button[type="submit"]')
|
||||
await expect(confirmButton).toContainText('Confirm and continue')
|
||||
await expect(confirmButton).toBeDisabled()
|
||||
|
||||
await batchCard.getByRole('button', { name: /Coffee/ }).click()
|
||||
await expect(confirmButton).toBeDisabled()
|
||||
|
||||
await batchCard.getByRole('button', { name: /Morning/ }).click()
|
||||
await expect(confirmButton).toBeEnabled()
|
||||
|
||||
// ONE confirm submits the whole batch.
|
||||
await confirmButton.click()
|
||||
|
||||
// The settled card lists both questions with their locked answers.
|
||||
const settled = page.locator('[data-clarify-settled]')
|
||||
await settled.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await expect(settled.getByText(BATCH_CLARIFY_QUESTIONS[0].question)).toBeVisible()
|
||||
await expect(settled.getByText('Coffee', { exact: true })).toBeVisible()
|
||||
await expect(settled.getByText(BATCH_CLARIFY_QUESTIONS[1].question)).toBeVisible()
|
||||
await expect(settled.getByText('Morning', { exact: true })).toBeVisible()
|
||||
|
||||
// And still no duplicate live card lingering after settle.
|
||||
await expect(page.locator('form[data-clarify-batch]')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* E2E boot-failure tests — verify the app shows an error overlay when the
|
||||
* backend can't start.
|
||||
*
|
||||
* Injects a fake boot error (HERMES_DESKTOP_BOOT_FAKE_ERROR) so the backend
|
||||
* resolution fails with a controlled error message. The app should show the
|
||||
* BootFailureOverlay with retry/repair actions.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { allowErrorBanners, test } from './test'
|
||||
|
||||
import {
|
||||
type DeadBackendFixture,
|
||||
setupDeadBackend,
|
||||
waitForBootFailure,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: DeadBackendFixture | null = null
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('boot failure with dead backend', () => {
|
||||
test.beforeEach(() => {
|
||||
// These tests deliberately trigger boot errors — error banners
|
||||
// (notifyError → [role="alert"]) are expected, not failures.
|
||||
allowErrorBanners()
|
||||
})
|
||||
|
||||
test('app shows error state', async () => {
|
||||
// Inject a fake boot error so the backend resolution "fails" with a
|
||||
// controlled error message. This is the only reliable way to trigger
|
||||
// BootFailureOverlay in dev mode.
|
||||
fixture = await setupDeadBackend({ fakeError: true })
|
||||
|
||||
await waitForBootFailure(fixture.page, 90_000)
|
||||
})
|
||||
|
||||
test('screenshot of error state', async () => {
|
||||
if (!fixture) {
|
||||
test.skip(true, 'Previous test failed — no app running')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'boot-failure-error-state', app: fixture.app })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* E2E smoke tests for the dev-mode desktop app.
|
||||
*
|
||||
* These tests launch the Electron app from the built dist/ (not the
|
||||
* packaged binary) with a real `hermes serve` backend pointed at a mock
|
||||
* inference server. The full chain is exercised:
|
||||
*
|
||||
* electron → hermes serve (python) → mock provider → renderer
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
* Run from the nix devshell:
|
||||
* npm exec playwright test e2e/boot.spec.ts --reporter=list
|
||||
*/
|
||||
import { expect, test } from './test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('dev-mode boot with mock backend', () => {
|
||||
test('window opens with Hermes title', async () => {
|
||||
const title = await fixture!.page.title()
|
||||
expect(title).toContain('Hermes')
|
||||
})
|
||||
|
||||
test('renderer mounts and shows DOM content', async () => {
|
||||
const page = fixture!.page
|
||||
// Wait for the React root to mount. The app renders into #root
|
||||
// (see src/main.tsx), but content may arrive through portals — so
|
||||
// check the body for any interactive content instead.
|
||||
await page.waitForSelector('body', { state: 'attached' })
|
||||
// Wait for the main app shell — the composer is always present.
|
||||
await page.waitForSelector('textarea, [contenteditable="true"]', {
|
||||
state: 'attached',
|
||||
timeout: 30_000,
|
||||
})
|
||||
})
|
||||
|
||||
// A preload that throws never reaches contextBridge, so the renderer boots
|
||||
// into "Desktop IPC bridge is unavailable" and every test below it dies on a
|
||||
// 120s never-became-ready timeout instead. Checking the bridge by name makes
|
||||
// that failure legible. The sandbox lets preload require only electron,
|
||||
// events, timers and url — adding any other node builtin lands here.
|
||||
test('the preload bridge reaches the renderer', async () => {
|
||||
const bridge = await fixture!.page.evaluate(() => {
|
||||
const desktop = (window as unknown as { hermesDesktop?: Record<string, unknown> }).hermesDesktop
|
||||
|
||||
return {
|
||||
present: typeof desktop,
|
||||
glassSupported: typeof desktop?.glassSupported,
|
||||
translucencySupported: typeof desktop?.translucencySupported
|
||||
}
|
||||
})
|
||||
|
||||
expect(bridge).toEqual({ present: 'object', glassSupported: 'boolean', translucencySupported: 'boolean' })
|
||||
})
|
||||
|
||||
test('backend boots and app becomes ready', async () => {
|
||||
// This is the big one — wait for the full boot chain to complete:
|
||||
// electron starts → hermes serve is spawned → WS connects → config
|
||||
// loaded → sessions loaded → boot overlay dismissed → composer visible.
|
||||
await waitForAppReady(fixture!, 120_000)
|
||||
})
|
||||
|
||||
test('screenshot after boot', async () => {
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'boot-ready', app: fixture!.app })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,158 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
type MockBackendFixture,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig
|
||||
} from './fixtures'
|
||||
import { MOCK_REPLY, startMockServer } from './mock-server'
|
||||
import { RealSessionBuilder } from './real-session-builder'
|
||||
import { expect, test } from './test'
|
||||
|
||||
// A bot row previews the bot's canonical Bot Chat (the gateway resolves it by
|
||||
// name on every roster poll). Clicking the row must land on THAT conversation.
|
||||
// Before this fix a plain click fronted whatever bots-workspace tile the user
|
||||
// last had open for that bot — a `+` side thread outlived every restart in
|
||||
// Local Storage and won every click forever, while the row kept previewing the
|
||||
// Bot Chat. The user saw the sidebar and the center describe two different
|
||||
// conversations ("sessions not in sync"; support thread 1544460286084391043).
|
||||
|
||||
type Page = MockBackendFixture['page']
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
async function openBots(page: Page): Promise<void> {
|
||||
const tab = page
|
||||
.getByRole('button', { name: 'Bots', exact: true })
|
||||
.or(page.getByRole('tab', { name: 'Bots', exact: true }))
|
||||
.first()
|
||||
|
||||
await tab.click()
|
||||
await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
|
||||
}
|
||||
|
||||
async function settle(page: Page, timeout = 90_000): Promise<void> {
|
||||
await page
|
||||
.getByText(/Waking up/i)
|
||||
.first()
|
||||
.waitFor({ state: 'hidden', timeout })
|
||||
.catch(() => undefined)
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
async function openUntil(action: () => Promise<void>, expected: () => Promise<void>, attempts = 3): Promise<void> {
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
await action()
|
||||
|
||||
try {
|
||||
await expected()
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
if (attempt >= attempts) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise<void> {
|
||||
const dir = path.join(hermesHome, 'profiles', name)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
writeMockProviderConfig(dir, mockUrl)
|
||||
writeEnvFile(dir)
|
||||
|
||||
const builder = await RealSessionBuilder.start(dir)
|
||||
|
||||
try {
|
||||
await builder.createSession({ title: 'Bot Chat', turns: [`Hello ${name}`] })
|
||||
} finally {
|
||||
await builder.close()
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const mock = await startMockServer()
|
||||
const sandbox = createSandbox('bots-sync')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
await seedBot(sandbox.hermesHome, mock.url, 'alpha')
|
||||
await seedBot(sandbox.hermesHome, mock.url, 'beta')
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
|
||||
fixture = {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
}
|
||||
}
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('a bot row click lands on the Bot Chat the row previews, not a side thread', async () => {
|
||||
test.setTimeout(300_000)
|
||||
const page = fixture!.page
|
||||
|
||||
await openBots(page)
|
||||
|
||||
const alphaRow = page.getByRole('button', { name: /^alpha\b/i }).filter({ visible: true }).first()
|
||||
const betaRow = page.getByRole('button', { name: /^beta\b/i }).filter({ visible: true }).first()
|
||||
await expect(alphaRow).toBeVisible({ timeout: 30_000 })
|
||||
await expect(betaRow).toBeVisible({ timeout: 30_000 })
|
||||
const seededTurn = page.getByText('Hello alpha', { exact: true }).filter({ visible: true })
|
||||
|
||||
await openUntil(
|
||||
() => alphaRow.click(),
|
||||
() => expect(seededTurn.first()).toBeVisible({ timeout: 45_000 })
|
||||
)
|
||||
await settle(page, 15_000)
|
||||
|
||||
// A `+` side thread for alpha, with a real turn so it is a persisted tile.
|
||||
await page.keyboard.press('Control+t')
|
||||
const composer = page.locator('[data-slot="composer-root"] [contenteditable="true"]').filter({ visible: true }).first()
|
||||
await expect(composer).toBeVisible({ timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.fill('hello alpha thread')
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByText(MOCK_REPLY).filter({ visible: true }).first()).toBeVisible({ timeout: 60_000 })
|
||||
|
||||
// Leave alpha on the side thread, go to beta, come back via the row.
|
||||
await betaRow.click()
|
||||
await expect(page.getByText('Hello beta', { exact: true }).filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 60_000
|
||||
})
|
||||
await settle(page)
|
||||
|
||||
await alphaRow.click()
|
||||
// The row previews the Bot Chat; the click must front it.
|
||||
await expect(seededTurn.first()).toBeVisible({ timeout: 45_000 })
|
||||
// The side thread is still open beside it (scoped to alpha), not closed.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() =>
|
||||
[...document.querySelectorAll<HTMLElement>('[data-zone-tabstrip="grp-main"] [data-tree-tab]')]
|
||||
.map(element => element.getAttribute('data-tree-tab') ?? '')
|
||||
.filter(id => id.startsWith('session-tile:')).length
|
||||
),
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
.toBe(1)
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
type MockBackendFixture,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig
|
||||
} from './fixtures'
|
||||
import { MOCK_REPLY, startMockServer } from './mock-server'
|
||||
import { RealSessionBuilder } from './real-session-builder'
|
||||
import { expect, test } from './test'
|
||||
|
||||
// Every bot's canonical chat is STORED under the same title ("Bot Chat" — the
|
||||
// name the gateway resolves it by), so the main tab strip captioned every open
|
||||
// bot chat identically and two bots' tabs were indistinguishable (#99152). The
|
||||
// tab must read the bot's display name while the stored title stays canonical.
|
||||
|
||||
type Page = MockBackendFixture['page']
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
async function openBots(page: Page): Promise<void> {
|
||||
const tab = page
|
||||
.getByRole('button', { name: 'Bots', exact: true })
|
||||
.or(page.getByRole('tab', { name: 'Bots', exact: true }))
|
||||
.first()
|
||||
|
||||
await tab.click()
|
||||
await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
|
||||
}
|
||||
|
||||
async function openUntil(action: () => Promise<void>, expected: () => Promise<void>, attempts = 3): Promise<void> {
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
await action()
|
||||
|
||||
try {
|
||||
await expected()
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
if (attempt >= attempts) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise<void> {
|
||||
const dir = path.join(hermesHome, 'profiles', name)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
writeMockProviderConfig(dir, mockUrl)
|
||||
writeEnvFile(dir)
|
||||
|
||||
const builder = await RealSessionBuilder.start(dir)
|
||||
|
||||
try {
|
||||
await builder.createSession({ title: 'Bot Chat', turns: [`Hello ${name}`] })
|
||||
} finally {
|
||||
await builder.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Every tab caption in the main strip (the main `workspace` tab + tiles). */
|
||||
function mainStripTabTitles(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() =>
|
||||
[...document.querySelectorAll<HTMLElement>('[data-zone-tabstrip="grp-main"] [data-tree-tab]')].map(element =>
|
||||
(element.textContent ?? '').trim()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const mock = await startMockServer()
|
||||
const sandbox = createSandbox('bots-tabname')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
await seedBot(sandbox.hermesHome, mock.url, 'alpha')
|
||||
await seedBot(sandbox.hermesHome, mock.url, 'beta')
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
|
||||
fixture = {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
}
|
||||
}
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test("an open Bot Chat's tab reads the bot's name, not the canonical 'Bot Chat' title", async () => {
|
||||
test.setTimeout(300_000)
|
||||
const page = fixture!.page
|
||||
|
||||
await openBots(page)
|
||||
|
||||
const alphaRow = page.getByRole('button', { name: /^alpha\b/i }).filter({ visible: true }).first()
|
||||
await expect(alphaRow).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
await openUntil(
|
||||
() => alphaRow.click(),
|
||||
() =>
|
||||
expect(page.getByText('Hello alpha', { exact: true }).filter({ visible: true }).first()).toBeVisible({
|
||||
timeout: 45_000
|
||||
})
|
||||
)
|
||||
|
||||
// A `+` side thread beside the Bot Chat gives the main zone a tab strip —
|
||||
// the surface where every bot chat used to read "Bot Chat".
|
||||
await page.keyboard.press('Control+t')
|
||||
const composer = page.locator('[data-slot="composer-root"] [contenteditable="true"]').filter({ visible: true }).first()
|
||||
await expect(composer).toBeVisible({ timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.fill('hello alpha thread')
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.getByText(MOCK_REPLY).filter({ visible: true }).first()).toBeVisible({ timeout: 60_000 })
|
||||
|
||||
await expect.poll(() => mainStripTabTitles(page), { timeout: 15_000 }).toHaveLength(2)
|
||||
const captions = await mainStripTabTitles(page)
|
||||
expect(captions.some(caption => /alpha/i.test(caption))).toBe(true)
|
||||
expect(captions.some(caption => /bot chat/i.test(caption))).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,254 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
type MockBackendFixture,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig
|
||||
} from './fixtures'
|
||||
import { startMockServer } from './mock-server'
|
||||
import { RealSessionBuilder } from './real-session-builder'
|
||||
import { expect, test } from './test'
|
||||
|
||||
// User-made sections in the Bots roster: a bot is filed by dragging it onto a
|
||||
// section or through its row menu, the section is renamed through the same
|
||||
// dialog shape sessions use, and deleting a section returns its bots to
|
||||
// Unassigned (with an Undo toast, no confirmation). With no sections created
|
||||
// the roster is the plain list it always was.
|
||||
|
||||
type Page = MockBackendFixture['page']
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
// BOT_SECTIONS_SCREENSHOT_DIR=<dir> saves full-window captures at the key
|
||||
// states — handy for design review; never part of the assertions.
|
||||
async function capture(page: Page, name: string): Promise<void> {
|
||||
const dir = process.env.BOT_SECTIONS_SCREENSHOT_DIR
|
||||
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
await page.screenshot({ path: path.join(dir, `${name}.png`) })
|
||||
}
|
||||
|
||||
async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise<void> {
|
||||
const dir = path.join(hermesHome, 'profiles', name)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
writeMockProviderConfig(dir, mockUrl)
|
||||
writeEnvFile(dir)
|
||||
|
||||
const builder = await RealSessionBuilder.start(dir)
|
||||
|
||||
try {
|
||||
await builder.createSession({ title: 'Bot Chat', turns: [`Hello ${name}`] })
|
||||
} finally {
|
||||
await builder.close()
|
||||
}
|
||||
}
|
||||
|
||||
const roster = (page: Page) => page.locator('[data-slot="bots-roster"]')
|
||||
const botRow = (page: Page, name: string) => roster(page).locator(`[data-roster-key="local::${name}"]`)
|
||||
|
||||
/** A section's label span — the one node whose text is exactly the name. */
|
||||
const sectionLabel = (page: Page, name: string) =>
|
||||
page.locator('span.truncate', { hasText: new RegExp(`^${name}$`, 'i') })
|
||||
|
||||
/** The heading's fold button (label + count) — the ⋯ menu trigger is a sibling with no text. */
|
||||
const sectionHeading = (page: Page, name: string) =>
|
||||
roster(page).locator('[data-slot="bots-section"] button[aria-expanded]').filter({ has: sectionLabel(page, name) })
|
||||
|
||||
const sectionBlock = (page: Page, name: string) =>
|
||||
roster(page).locator('[data-slot="bots-section"]').filter({ has: sectionLabel(page, name) })
|
||||
|
||||
/** Section name → roster keys of the rows under it (the plain list has no sections). */
|
||||
async function layout(page: Page): Promise<Array<[string, string[]]>> {
|
||||
return roster(page).locator('[data-slot="bots-section"]').evaluateAll(blocks =>
|
||||
blocks.map(block => [
|
||||
block.querySelector('button[aria-expanded] span.truncate')?.textContent?.trim() ?? '',
|
||||
[...block.querySelectorAll<HTMLElement>('[data-roster-key]')].map(row => row.dataset.rosterKey ?? '')
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const mock = await startMockServer()
|
||||
const sandbox = createSandbox('bots-sections')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
for (const name of ['alpha', 'beta', 'gamma']) {
|
||||
await seedBot(sandbox.hermesHome, mock.url, name)
|
||||
}
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
|
||||
fixture = {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
}
|
||||
}
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('file bots into user sections by menu and drag; rename; delete returns them to Unassigned', async () => {
|
||||
test.setTimeout(300_000)
|
||||
const page = fixture!.page
|
||||
|
||||
const tab = page
|
||||
.getByRole('button', { name: 'Bots', exact: true })
|
||||
.or(page.getByRole('tab', { name: 'Bots', exact: true }))
|
||||
.first()
|
||||
|
||||
await tab.click()
|
||||
await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
|
||||
await expect(botRow(page, 'alpha')).toBeVisible({ timeout: 30_000 })
|
||||
await expect(botRow(page, 'beta')).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
// No sections yet: the plain list, no section chrome at all.
|
||||
await expect(roster(page).locator('[data-slot="bots-section"]')).toHaveCount(0)
|
||||
await capture(page, '1-plain-roster')
|
||||
|
||||
// Right-click alpha → Move to section → New section… → name it → alpha is filed.
|
||||
await botRow(page, 'alpha').click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Move to section' }).hover()
|
||||
await expect(page.getByRole('menuitem', { name: 'New section…' })).toBeVisible()
|
||||
await capture(page, '2-row-menu-move-to-section')
|
||||
await page.getByRole('menuitem', { name: 'New section…' }).click()
|
||||
const nameField = page.getByRole('textbox', { name: 'Section name' })
|
||||
await expect(nameField).toBeVisible()
|
||||
await nameField.fill('Clients')
|
||||
await capture(page, '3-new-section-dialog')
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
|
||||
await expect(sectionHeading(page, 'Clients')).toBeVisible()
|
||||
await expect(sectionBlock(page, 'Clients').locator('[data-roster-key="local::alpha"]')).toBeVisible()
|
||||
// The remainder is Unassigned, drawn last.
|
||||
await expect
|
||||
.poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length]))
|
||||
.toEqual([
|
||||
['Clients', 1],
|
||||
['Unassigned', 3]
|
||||
])
|
||||
await capture(page, '4-alpha-filed')
|
||||
|
||||
// Drag beta over the Clients block: the target highlights while over it.
|
||||
// Escape cancels — nothing moves, nothing stays highlighted or faded.
|
||||
const target = sectionBlock(page, 'Clients')
|
||||
const from = (await botRow(page, 'beta').boundingBox())!
|
||||
const to = (await sectionHeading(page, 'Clients').boundingBox())!
|
||||
|
||||
const dragBetaOverClients = async () => {
|
||||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2 - 10, { steps: 4 })
|
||||
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 })
|
||||
await expect(target).toHaveAttribute('data-drop-over', 'true')
|
||||
}
|
||||
|
||||
await dragBetaOverClients()
|
||||
await page.keyboard.press('Escape')
|
||||
await page.mouse.up()
|
||||
await expect(target).not.toHaveAttribute('data-drop-over', 'true')
|
||||
await expect(botRow(page, 'beta')).toHaveCSS('opacity', '1')
|
||||
expect((await layout(page)).map(([name, keys]) => [name, keys.length])).toEqual([
|
||||
['Clients', 1],
|
||||
['Unassigned', 3]
|
||||
])
|
||||
|
||||
// Drop it for real: the bot is filed.
|
||||
await dragBetaOverClients()
|
||||
await capture(page, '5-drag-over-clients')
|
||||
await page.mouse.up()
|
||||
|
||||
await expect(target.locator('[data-roster-key="local::beta"]')).toBeVisible()
|
||||
await expect(target).not.toHaveAttribute('data-drop-over', 'true')
|
||||
// The moved row remounts under its new section; it must not stay faded.
|
||||
await expect(botRow(page, 'beta')).toHaveCSS('opacity', '1')
|
||||
await expect
|
||||
.poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length]))
|
||||
.toEqual([
|
||||
['Clients', 2],
|
||||
['Unassigned', 2]
|
||||
])
|
||||
await capture(page, '6-beta-dropped')
|
||||
|
||||
// Rename through the heading's context menu — the same Dialog + Input
|
||||
// + Save shape as a session rename.
|
||||
await sectionHeading(page, 'Clients').click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Rename…' }).click()
|
||||
await expect(nameField).toHaveValue('Clients')
|
||||
await nameField.fill('Customers')
|
||||
await page.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(sectionHeading(page, 'Customers')).toBeVisible()
|
||||
await expect(sectionHeading(page, 'Clients')).toHaveCount(0)
|
||||
await capture(page, '7-renamed')
|
||||
|
||||
// A second, empty section from the + menu shows its drop hint; collapsing
|
||||
// a section folds its rows like the gateway headings do.
|
||||
await page.getByRole('button', { name: 'New bot or group chat' }).click()
|
||||
await page.getByRole('menuitem', { name: 'New section' }).click()
|
||||
await nameField.fill('Team')
|
||||
await page.getByRole('button', { name: 'Create' }).click()
|
||||
await expect(sectionBlock(page, 'Team').getByText('Drag bots here')).toBeVisible()
|
||||
await sectionHeading(page, 'Customers').click()
|
||||
await expect(sectionBlock(page, 'Customers').locator('[data-roster-key]')).toHaveCount(0)
|
||||
await capture(page, '8-empty-section-and-collapsed')
|
||||
await sectionHeading(page, 'Customers').click()
|
||||
await expect(sectionBlock(page, 'Customers').locator('[data-roster-key]')).toHaveCount(2)
|
||||
|
||||
// Delete Customers: no confirmation, its two bots return to Unassigned,
|
||||
// and the toast offers Undo.
|
||||
await sectionHeading(page, 'Customers').click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click()
|
||||
await expect(sectionHeading(page, 'Customers')).toHaveCount(0)
|
||||
const toast = page.getByRole('status').filter({ hasText: 'Deleted “Customers”' })
|
||||
await expect(toast).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length]))
|
||||
.toEqual([
|
||||
['Team', 0],
|
||||
['Unassigned', 4]
|
||||
])
|
||||
await capture(page, '9-deleted-with-undo-toast')
|
||||
|
||||
await toast.getByRole('button', { name: 'Undo' }).click()
|
||||
await expect(sectionHeading(page, 'Customers')).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length]))
|
||||
.toEqual([
|
||||
['Customers', 2],
|
||||
['Team', 0],
|
||||
['Unassigned', 2]
|
||||
])
|
||||
|
||||
// Membership rides the bot's profile ui_meta, so it follows profile sync.
|
||||
const alphaProfile = path.join(fixture!.sandbox.hermesHome, 'profiles', 'alpha', 'profile.yaml')
|
||||
await expect.poll(() => (fs.existsSync(alphaProfile) ? fs.readFileSync(alphaProfile, 'utf8') : '')).toMatch(/sectionId:\s*sec-/)
|
||||
|
||||
// Delete both sections: the roster is the plain list again.
|
||||
for (const name of ['Customers', 'Team']) {
|
||||
await sectionHeading(page, name).click({ button: 'right' })
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click()
|
||||
}
|
||||
|
||||
await expect(roster(page).locator('[data-slot="bots-section"]')).toHaveCount(0)
|
||||
await expect(botRow(page, 'alpha')).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* E2E chat tests — send a message and verify a response appears.
|
||||
*
|
||||
* Requires the full boot chain to complete (hermes serve + mock inference
|
||||
* provider). The mock server returns a canned reply, so we verify the
|
||||
* response text shows up in the chat transcript.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from './test'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { BLOCKING_CLARIFY_QUESTION, BLOCKING_CLARIFY_TRIGGER } from './mock-server'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture!, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('chat interaction with mock backend', () => {
|
||||
test('send a message and receive a response', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
// Find the composer — it's a contenteditable textbox.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
|
||||
// Click to focus, then type the message character by character.
|
||||
// Using `type` instead of `fill` because the composer is a
|
||||
// contenteditable div with custom keydown handling that tracks
|
||||
// IME composition state — `fill` bypasses the event chain.
|
||||
await composer.click()
|
||||
await composer.type('Hello, can you hear me?', { delay: 20 })
|
||||
|
||||
// Submit with Enter — the composer's keydown handler intercepts
|
||||
// plain Enter (without Shift) and calls submitDraft().
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Wait for the user's message to appear in the transcript.
|
||||
// The message renders as an assistant-ui message in the chat view.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const body = document.body
|
||||
|
||||
if (!body) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (body.textContent ?? '').includes('Hello, can you hear me?')
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
|
||||
// Wait for the mock response to appear. The canned reply is:
|
||||
// "Hello from the mock inference server! The full boot chain is working."
|
||||
// Give it a generous timeout — the inference request goes through the
|
||||
// gateway → hermes serve → mock server → streaming SSE back.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const body = document.body
|
||||
|
||||
if (!body) {
|
||||
return false
|
||||
}
|
||||
|
||||
const text = body.textContent ?? ''
|
||||
|
||||
return text.includes('mock inference server') || text.includes('boot chain is working')
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
})
|
||||
|
||||
test('screenshot of chat with messages', async () => {
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app })
|
||||
})
|
||||
|
||||
test('offers stop, steer, and queue actions while busy', async ({}, testInfo) => {
|
||||
const page = fixture!.page
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
|
||||
const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]')
|
||||
const dictation = page.locator('[data-slot="composer-root"] button[aria-label="Voice dictation"]')
|
||||
const speakReplies = page.locator(
|
||||
'[data-slot="composer-root"] button[aria-label="Read replies aloud"], [data-slot="composer-root"] button[aria-label="Stop reading replies aloud"]'
|
||||
)
|
||||
|
||||
await composer.click()
|
||||
await composer.type(BLOCKING_CLARIFY_TRIGGER)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.getByText(BLOCKING_CLARIFY_QUESTION).waitFor({ state: 'visible', timeout: 30_000 })
|
||||
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Stop')
|
||||
await expect(primary.locator('span')).toHaveClass(/bg-current/)
|
||||
|
||||
await composer.click()
|
||||
await composer.type('please answer tersely')
|
||||
// Since "running is not busy" (3bc52fb9df) the primary keeps the Send
|
||||
// affordance mid-turn — steer is routed through the submit engine, not a
|
||||
// separate labeled button. Queue remains the explicit secondary action.
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Send')
|
||||
await expect(dictation).toBeVisible()
|
||||
await expect(speakReplies).toBeVisible()
|
||||
await expect(queue).toBeVisible()
|
||||
await expect(queue.locator('svg.tabler-icon-layers-intersect-2')).toBeVisible()
|
||||
const controlLabels = await page
|
||||
.locator('[data-slot="composer-root"] button')
|
||||
.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-label')))
|
||||
const speakRepliesIndex = controlLabels.findIndex(
|
||||
label => label === 'Read replies aloud' || label === 'Stop reading replies aloud'
|
||||
)
|
||||
expect(controlLabels.indexOf('Voice dictation')).toBeLessThan(speakRepliesIndex)
|
||||
expect(speakRepliesIndex).toBeLessThan(controlLabels.indexOf('Queue message'))
|
||||
expect(controlLabels.indexOf('Queue message')).toBeLessThan(controlLabels.indexOf('Send'))
|
||||
await page.screenshot({ path: testInfo.outputPath('busy-composer-steer.png') })
|
||||
await expect(primary.locator('.codicon-arrow-up')).toBeVisible()
|
||||
|
||||
await queue.click()
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Stop')
|
||||
await expect(queue).toHaveCount(0)
|
||||
await page.screenshot({ path: testInfo.outputPath('busy-composer-queue.png') })
|
||||
await expect(page.getByText('1 Queued')).toBeVisible()
|
||||
|
||||
await primary.click()
|
||||
await expect(page.getByText('1 Queued — paused')).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath('busy-composer-queue-paused.png') })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Context-menu edit verbs on real editables — the regressions jsdom cannot
|
||||
* catch, exercised against the real renderer (real radix focus trap, real
|
||||
* React unmount timing, real selection).
|
||||
*
|
||||
* The class under test: "Select all" from the app context menu must act on
|
||||
* the FIELD the menu was opened on, never on the surrounding transcript.
|
||||
* The first fix (focus-restore before dispatch) passed unit tests and still
|
||||
* failed live because the radix trap steals focus back; the second fix runs
|
||||
* selection renderer-side after the trap unmounts. These tests pin the
|
||||
* observable outcome, not the mechanism.
|
||||
*
|
||||
* Menu items are addressed by accessible-name PREFIX (`/^Copy/`): the name
|
||||
* includes the shortcut suffix ("Copy Ctrl+V" / "Copy ⌘V"), which is also
|
||||
* host-dependent.
|
||||
*/
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { expect, test } from './test'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('select all from the composer context menu selects the draft, not the chat', async () => {
|
||||
const page = fixture!.page
|
||||
const composer = page.locator('[data-slot="composer-rich-input"]').first()
|
||||
|
||||
// Put a message into the transcript so there is chat text a document-wide
|
||||
// select-all WOULD grab — the bug this test exists to catch. Wait for the
|
||||
// mock reply to COMPLETE: while the turn is busy the composer is in its
|
||||
// steer shape and a typed draft does not land in it.
|
||||
await composer.click()
|
||||
await composer.pressSequentially('transcript anchor message')
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForFunction(() => (document.body.textContent ?? '').includes('mock inference server'), undefined, {
|
||||
timeout: 60_000
|
||||
})
|
||||
|
||||
// Draft text in the composer, then right-click it.
|
||||
await composer.click()
|
||||
await composer.pressSequentially('draft under selection')
|
||||
await composer.click({ button: 'right' })
|
||||
|
||||
const selectAll = page.getByRole('menuitem', { name: /^Select all/ })
|
||||
|
||||
await selectAll.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await selectAll.click()
|
||||
|
||||
// The selection must live inside the composer and cover exactly the draft.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(() => {
|
||||
const selection = window.getSelection()
|
||||
const editable = document.querySelector('[data-slot="composer-rich-input"]')
|
||||
|
||||
if (!selection || selection.rangeCount === 0 || !editable) {
|
||||
return { inside: false, text: '' }
|
||||
}
|
||||
|
||||
return {
|
||||
inside: editable.contains(selection.getRangeAt(0).commonAncestorContainer),
|
||||
text: selection.toString()
|
||||
}
|
||||
}),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toEqual({ inside: true, text: 'draft under selection' })
|
||||
|
||||
// Clear the draft so later tests start clean.
|
||||
await page.keyboard.press('Delete')
|
||||
})
|
||||
|
||||
test('cut, copy, and select all gray out in an empty composer', async () => {
|
||||
const page = fixture!.page
|
||||
const composer = page.locator('[data-slot="composer-rich-input"]').first()
|
||||
|
||||
await composer.click()
|
||||
await composer.click({ button: 'right' })
|
||||
|
||||
const selectAll = page.getByRole('menuitem', { name: /^Select all/ })
|
||||
|
||||
await selectAll.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
|
||||
await expect(selectAll).toHaveAttribute('data-disabled', /.*/)
|
||||
await expect(page.getByRole('menuitem', { name: /^Cut/ })).toHaveAttribute('data-disabled', /.*/)
|
||||
await expect(page.getByRole('menuitem', { name: /^Copy/ })).toHaveAttribute('data-disabled', /.*/)
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
|
||||
test('paste enables when the clipboard holds text', async () => {
|
||||
const page = fixture!.page
|
||||
const composer = page.locator('[data-slot="composer-rich-input"]').first()
|
||||
|
||||
// The empty-clipboard branch stays in the unit suite: the e2e app shares
|
||||
// the SYSTEM clipboard, and writeText('') does not reliably clear it.
|
||||
await page.evaluate(() =>
|
||||
(
|
||||
window as unknown as { hermesDesktop?: { writeClipboard?: (text: string) => Promise<boolean> } }
|
||||
).hermesDesktop?.writeClipboard?.('clipboard payload')
|
||||
)
|
||||
await composer.click()
|
||||
await composer.click({ button: 'right' })
|
||||
|
||||
const paste = page.getByRole('menuitem', { name: /^Paste/ })
|
||||
|
||||
await paste.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
|
||||
// The clipboard probe is an async IPC — the item enables when it lands.
|
||||
await expect.poll(() => paste.getAttribute('data-disabled'), { timeout: 10_000 }).toBeNull()
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
})
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Regression coverage for a correction sent during a live response, then a
|
||||
* warm session switch away and back. The correction is an accepted user turn,
|
||||
* not an optimistic duplicate of the original prompt, and its relative place
|
||||
* in the transcript must survive the resume reconciliation.
|
||||
*/
|
||||
|
||||
import { type TestInfo } from '@playwright/test'
|
||||
|
||||
import { expect, test, type Page } from './test'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { CORRECTION_SWITCH_TRIGGER, MOCK_REPLY } from './mock-server'
|
||||
|
||||
const OTHER_SESSION_PROMPT = 'E2E persisted session used for a warm resume.'
|
||||
const ORIGINAL_PROMPT = `${CORRECTION_SWITCH_TRIGGER}: original prompt must remain singular after a correction.`
|
||||
const CORRECTION = 'E2E correction must stay after the original prompt.'
|
||||
const TOOL_STARTED = 'Checking the long-running task before I continue.'
|
||||
const CORRECTED_REPLY = 'The corrected task finished.'
|
||||
const INFERENCE_SWITCH_TRIGGER = 'E2E_INFERENCE_SWITCH_TRIGGER'
|
||||
const INFERENCE_PROMPT = `${INFERENCE_SWITCH_TRIGGER}: original inference prompt must remain singular.`
|
||||
const INFERENCE_CORRECTION = `${INFERENCE_SWITCH_TRIGGER}: correction sent while inference is live.`
|
||||
|
||||
// Inactive tabs stay mounted under a data-pane-hidden ancestor. Match the
|
||||
// renderer's keep-alive visibility policy instead of relying on DOM order.
|
||||
const SURFACE = '[data-composer-target]:not([data-pane-hidden] [data-composer-target])'
|
||||
|
||||
function activeSurface(page: Page) {
|
||||
return page.locator(SURFACE).last()
|
||||
}
|
||||
|
||||
async function send(page: Page, text: string): Promise<void> {
|
||||
const composer = activeSurface(page).locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function steer(page: Page, text: string): Promise<void> {
|
||||
const surface = activeSurface(page)
|
||||
const composer = surface.locator('[contenteditable="true"]').first()
|
||||
const primary = surface.locator('[data-slot="composer-root"] button[type="submit"]')
|
||||
|
||||
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
// Since "running is not busy" (3bc52fb9df) the primary keeps the Send label
|
||||
// mid-turn; the submit engine still routes a text payload to steer.
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Send')
|
||||
await primary.click()
|
||||
}
|
||||
|
||||
async function waitForTranscriptText(page: Page, text: string): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
([expected, surfaceSelector]: [string, string]) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const active = surfaces[surfaces.length - 1]
|
||||
|
||||
return (active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected)
|
||||
},
|
||||
[text, SURFACE] as [string, string],
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function textNodeOccurrences(page: Page, text: string): Promise<number> {
|
||||
return page.evaluate(
|
||||
([expected, surfaceSelector]: [string, string]) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return 0
|
||||
|
||||
const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT)
|
||||
let count = 0
|
||||
while (walker.nextNode()) {
|
||||
if (walker.currentNode.textContent?.includes(expected)) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
},
|
||||
[text, SURFACE] as [string, string],
|
||||
)
|
||||
}
|
||||
|
||||
async function transcriptTextOrder(page: Page): Promise<string[]> {
|
||||
return page.evaluate((surfaceSelector: string) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return []
|
||||
|
||||
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="message"], [data-message-id]'))
|
||||
.map(message => message.textContent?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
}, SURFACE)
|
||||
}
|
||||
|
||||
async function transcriptMessageOrder(page: Page): Promise<string[]> {
|
||||
return page.evaluate((surfaceSelector: string) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return []
|
||||
|
||||
return Array.from(
|
||||
viewport.querySelectorAll<HTMLElement>('[data-role="user"], [data-role="assistant"], [data-role="system"]'),
|
||||
)
|
||||
.map(message => message.textContent?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
}, SURFACE)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar "+" opens a NEW TAB beside the current chat rather than
|
||||
* replacing it, so the prior session stays mounted in its own surface. Wait
|
||||
* for the newly-mounted surface to show an empty transcript instead of waiting
|
||||
* for the old text to disappear from the page (it never will).
|
||||
*/
|
||||
async function openFreshDraft(page: Page, priorSessionText: string): Promise<void> {
|
||||
await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
|
||||
await page.waitForFunction(
|
||||
([priorText, surfaceSelector]: [string, string]) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const active = surfaces[surfaces.length - 1]
|
||||
const transcript = active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? ''
|
||||
|
||||
return surfaces.length > 0 && !transcript.includes(priorText)
|
||||
},
|
||||
[priorSessionText, SURFACE] as [string, string],
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function openSidebarSession(page: Page, sidebarText: string, expectedTranscriptText: string): Promise<void> {
|
||||
const row = page.locator('[data-slot="sidebar"] button').filter({ hasText: sidebarText }).first()
|
||||
await row.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await row.click()
|
||||
await waitForTranscriptText(page, expectedTranscriptText)
|
||||
}
|
||||
|
||||
async function reopenOriginalSession(page: Page): Promise<void> {
|
||||
// A still-running tool has not generated a final title yet, so the sidebar
|
||||
// retains the source prompt as its provisional session title.
|
||||
await openSidebarSession(page, ORIGINAL_PROMPT, ORIGINAL_PROMPT)
|
||||
}
|
||||
|
||||
async function reopenInferenceSession(page: Page): Promise<void> {
|
||||
const row = page.locator('[data-slot="sidebar"] button').filter({ hasText: INFERENCE_PROMPT }).first()
|
||||
await row.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await row.click()
|
||||
await waitForTranscriptText(page, INFERENCE_PROMPT)
|
||||
}
|
||||
|
||||
function relevantOrder(messages: string[]): string[] {
|
||||
return messages.flatMap(message => {
|
||||
if (message.includes(ORIGINAL_PROMPT)) return [ORIGINAL_PROMPT]
|
||||
if (message.includes(CORRECTION)) return [CORRECTION]
|
||||
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
function steerTurnOrder(messages: string[]): string[] {
|
||||
return messages.flatMap(message => {
|
||||
if (message.includes(ORIGINAL_PROMPT)) return [ORIGINAL_PROMPT]
|
||||
if (message.includes(CORRECTION)) return [CORRECTION]
|
||||
if (message.includes(CORRECTED_REPLY)) return [CORRECTED_REPLY]
|
||||
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('correction session switch', () => {
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeEach(async () => {
|
||||
fixture = await setupMockBackend({
|
||||
mockServer: { holdFirstStreamForPrompt: INFERENCE_SWITCH_TRIGGER },
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('keeps a live correction in place and does not duplicate its original prompt after switching sessions', async ({}, testInfo: TestInfo) => {
|
||||
const { page } = fixture!
|
||||
|
||||
// A blank draft does not exercise session hydration. Seed a real second
|
||||
// session first, matching the observed switch between two saved chats.
|
||||
await send(page, OTHER_SESSION_PROMPT)
|
||||
await waitForTranscriptText(page, MOCK_REPLY)
|
||||
await openFreshDraft(page, OTHER_SESSION_PROMPT)
|
||||
|
||||
await send(page, ORIGINAL_PROMPT)
|
||||
await waitForTranscriptText(page, TOOL_STARTED)
|
||||
await waitForTranscriptText(page, ORIGINAL_PROMPT)
|
||||
|
||||
// The historical session redirects while a foreground terminal task is
|
||||
// running. Use the visible Steer action to cover the real composer path.
|
||||
await steer(page, CORRECTION)
|
||||
await waitForTranscriptText(page, CORRECTION)
|
||||
|
||||
const orderBeforeSwitch = relevantOrder(await transcriptTextOrder(page))
|
||||
expect(orderBeforeSwitch).toEqual([ORIGINAL_PROMPT, CORRECTION])
|
||||
expect(await textNodeOccurrences(page, ORIGINAL_PROMPT)).toBe(1)
|
||||
expect(await textNodeOccurrences(page, CORRECTION)).toBe(1)
|
||||
await page.screenshot({ path: testInfo.outputPath('correction-before-session-switch.png') })
|
||||
|
||||
// Reproduce the observed race: switch to another persisted session while
|
||||
// the foreground tool is live, then return before its redirect settles.
|
||||
// Sidebar rows title by the session's first user prompt (auto-title is
|
||||
// disabled in the e2e fixture config).
|
||||
await openSidebarSession(page, OTHER_SESSION_PROMPT, OTHER_SESSION_PROMPT)
|
||||
await reopenOriginalSession(page)
|
||||
// The warm resume first paints the persisted history and then reconciles
|
||||
// the live turn (including a steer whose persistence may lag on a loaded
|
||||
// runner) back in. Poll to the converged order instead of sampling one
|
||||
// arbitrary mid-reconcile frame; the duplicate checks then pin the
|
||||
// regression (the prompt/correction must appear exactly once).
|
||||
await expect
|
||||
.poll(async () => relevantOrder(await transcriptTextOrder(page)), {
|
||||
message: 'correction should stay in place after the warm resume',
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toEqual(orderBeforeSwitch)
|
||||
await page.screenshot({ path: testInfo.outputPath('correction-after-warm-resume.png') })
|
||||
|
||||
expect(await textNodeOccurrences(page, ORIGINAL_PROMPT)).toBe(1)
|
||||
expect(await textNodeOccurrences(page, CORRECTION)).toBe(1)
|
||||
|
||||
await waitForTranscriptText(page, CORRECTED_REPLY)
|
||||
// The post-turn stored-history reconcile can momentarily repaint from a
|
||||
// snapshot in which the steer's user row hasn't been folded back in yet —
|
||||
// poll to the converged order instead of sampling one frame.
|
||||
await expect
|
||||
.poll(async () => steerTurnOrder(await transcriptMessageOrder(page)), {
|
||||
message: 'steered turn should settle as prompt → correction → corrected reply',
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toEqual([ORIGINAL_PROMPT, CORRECTION, CORRECTED_REPLY])
|
||||
})
|
||||
|
||||
test('keeps an inference-time correction visible through a warm session switch', async ({}, testInfo: TestInfo) => {
|
||||
const { mock, page } = fixture!
|
||||
|
||||
await send(page, OTHER_SESSION_PROMPT)
|
||||
await waitForTranscriptText(page, MOCK_REPLY)
|
||||
await openFreshDraft(page, OTHER_SESSION_PROMPT)
|
||||
|
||||
await send(page, INFERENCE_PROMPT)
|
||||
await mock.waitForHeldStream()
|
||||
await waitForTranscriptText(page, INFERENCE_PROMPT)
|
||||
|
||||
await send(page, INFERENCE_CORRECTION)
|
||||
await waitForTranscriptText(page, INFERENCE_CORRECTION)
|
||||
|
||||
await openSidebarSession(page, OTHER_SESSION_PROMPT, OTHER_SESSION_PROMPT)
|
||||
await reopenInferenceSession(page)
|
||||
|
||||
expect(await textNodeOccurrences(page, INFERENCE_PROMPT)).toBe(1)
|
||||
expect(await textNodeOccurrences(page, INFERENCE_CORRECTION)).toBe(1)
|
||||
await page.screenshot({ path: testInfo.outputPath('inference-correction-after-warm-resume.png') })
|
||||
|
||||
mock.releaseHeldStream()
|
||||
await waitForTranscriptText(page, MOCK_REPLY)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Locating the dev Electron binary for the e2e fixtures.
|
||||
*
|
||||
* Kept in its own module so the resolution rules can be unit-tested without
|
||||
* importing the Playwright runner (fixtures.ts pulls in `_electron`, the mock
|
||||
* server and the error-banner guard).
|
||||
*
|
||||
* Three rules the previous single-path probe got wrong:
|
||||
*
|
||||
* 1. The binary is not always under the REPO ROOT. This is an npm workspaces
|
||||
* repo, and npm only hoists a dependency to the root when nothing conflicts
|
||||
* — otherwise `electron` installs into `apps/desktop/node_modules`. Both
|
||||
* layouts are normal, so both have to be searched, nearest package first.
|
||||
* 2. The binary is `electron.exe` on Windows. A bare `electron` never exists
|
||||
* there, so the probe could only ever miss.
|
||||
* 3. `which` is not a command on Windows. The PATH fallback spawned it
|
||||
* unconditionally, so on Windows the fallback failed for the wrong reason
|
||||
* and the error message blamed a missing `npm install`.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import * as fs from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import * as path from 'node:path'
|
||||
|
||||
/** The dist file name: `electron.exe` on Windows, `electron` elsewhere. */
|
||||
export function electronBinaryName(platform: NodeJS.Platform = process.platform): string {
|
||||
return platform === 'win32' ? 'electron.exe' : 'electron'
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an npm install can leave the binary, in probe order: nearest package
|
||||
* first, so a workspace-local install wins over a stale hoisted one.
|
||||
*/
|
||||
export function electronDistCandidates(roots: string[], platform: NodeJS.Platform = process.platform): string[] {
|
||||
return roots.map((root) => path.join(root, 'node_modules', 'electron', 'dist', electronBinaryName(platform)))
|
||||
}
|
||||
|
||||
/** The PATH-lookup command for this platform. Windows has `where`, not `which`. */
|
||||
export function pathLookupCommand(platform: NodeJS.Platform = process.platform): string {
|
||||
return platform === 'win32' ? 'where' : 'which'
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the installed `electron` package where its own binary is.
|
||||
*
|
||||
* Its main export IS the absolute executable path, resolved from `path.txt`
|
||||
* and honouring `ELECTRON_OVERRIDE_DIST_PATH`, so this covers layouts and
|
||||
* overrides a hand-built path cannot know about. Returns null when the package
|
||||
* is not resolvable from `from`, or when it does not hand back a path (the
|
||||
* export is the Electron API object, not a path, when required from inside
|
||||
* Electron itself).
|
||||
*/
|
||||
export function electronPackagePath(from: string): null | string {
|
||||
try {
|
||||
const resolved = createRequire(path.join(from, 'package.json'))('electron') as unknown
|
||||
|
||||
return typeof resolved === 'string' && resolved ? resolved : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Electron binary, or throw with the layouts that were searched.
|
||||
*
|
||||
* `roots` are searched in order; pass the desktop package before the repo root.
|
||||
*/
|
||||
export function resolveElectronBinary(roots: string[]): string {
|
||||
for (const root of roots) {
|
||||
const declared = electronPackagePath(root)
|
||||
|
||||
if (declared && fs.existsSync(declared)) {
|
||||
return declared
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of electronDistCandidates(roots)) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
// Nix devshells put `electron` on PATH with no node_modules copy at all.
|
||||
const lookup = spawnSync(pathLookupCommand(), ['electron'], { encoding: 'utf8' })
|
||||
|
||||
if (lookup.status === 0 && lookup.stdout.trim()) {
|
||||
// `where` reports every match, one per line; take the first.
|
||||
const first = lookup.stdout.trim().split(/\r?\n/)[0].trim()
|
||||
|
||||
if (first) {
|
||||
return first
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Electron binary not found. Searched ${electronDistCandidates(roots).join(', ')} and PATH. ` +
|
||||
'Run "npm install" from the repo root to install devDependencies.',
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { electronBinaryName, electronDistCandidates, pathLookupCommand } from './electron-binary'
|
||||
|
||||
// Platform is a parameter everywhere below rather than read from
|
||||
// process.platform, so the Windows rules are pinned on the Linux CI runner too.
|
||||
// Reading the real platform would leave every Windows-only rule untested.
|
||||
|
||||
describe('electronBinaryName', () => {
|
||||
it('asks for electron.exe on Windows', () => {
|
||||
expect(electronBinaryName('win32')).toBe('electron.exe')
|
||||
})
|
||||
|
||||
it('asks for a bare electron everywhere else', () => {
|
||||
expect(electronBinaryName('linux')).toBe('electron')
|
||||
expect(electronBinaryName('darwin')).toBe('electron')
|
||||
})
|
||||
})
|
||||
|
||||
describe('electronDistCandidates', () => {
|
||||
const desktop = path.join('repo', 'apps', 'desktop')
|
||||
const repo = 'repo'
|
||||
|
||||
it('probes the workspace-local install before the hoisted one', () => {
|
||||
// npm only hoists `electron` to the repo root when nothing conflicts, so
|
||||
// apps/desktop/node_modules is an ordinary outcome of `npm install`, not a
|
||||
// broken tree. Probing only the repo root is what makes the suite refuse to
|
||||
// start with "run npm install" on a tree that has electron installed.
|
||||
expect(electronDistCandidates([desktop, repo], 'linux')).toEqual([
|
||||
path.join(desktop, 'node_modules', 'electron', 'dist', 'electron'),
|
||||
path.join(repo, 'node_modules', 'electron', 'dist', 'electron'),
|
||||
])
|
||||
})
|
||||
|
||||
it('carries the platform binary name into every candidate', () => {
|
||||
// A bare `electron` file never exists in a Windows dist, so a probe built
|
||||
// from a hardcoded name cannot match there no matter which root it walks.
|
||||
for (const candidate of electronDistCandidates([desktop, repo], 'win32')) {
|
||||
expect(path.basename(candidate)).toBe('electron.exe')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('pathLookupCommand', () => {
|
||||
it('uses where on Windows and which elsewhere', () => {
|
||||
// `which` is not a command on Windows; spawning it unconditionally made the
|
||||
// PATH fallback fail for a reason unrelated to whether electron is on PATH.
|
||||
expect(pathLookupCommand('win32')).toBe('where')
|
||||
expect(pathLookupCommand('linux')).toBe('which')
|
||||
expect(pathLookupCommand('darwin')).toBe('which')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Monkey-patch: playwright's test runner never calls tracing.start() on
|
||||
* Electron's internal BrowserContext because:
|
||||
* 1. Playwright._allContexts() only returns [chromium, firefox, webkit]
|
||||
* contexts — Electron's context is excluded.
|
||||
* 2. ArtifactsRecorder.didCreateBrowserContext runs in willStartTest, before
|
||||
* beforeAll launches the electron app.
|
||||
* 3. The runAfterCreateBrowserContext hook doesn't exist on the Electron
|
||||
* class (only on BrowserType).
|
||||
*
|
||||
* As a result, trace screenshots (screencast) and DOM snapshots are never
|
||||
* captured for electron tests.
|
||||
*
|
||||
* This patch:
|
||||
* 1. Patches _allContexts() to include electron contexts, so the test
|
||||
* runner's didFinishTest() cleanup calls _stopTracing() → stopChunk()
|
||||
* on the electron context (saving the trace chunk + merging it into
|
||||
* the final trace.zip).
|
||||
* 2. Manually calls tracing.start() + startChunk() after launch.
|
||||
* 3. Wraps tracing.start to become startChunk after the first call,
|
||||
* so the test runner's willStartTest doesn't throw "already started".
|
||||
*
|
||||
* Imported from playwright.config.ts so it runs before any test.
|
||||
*
|
||||
* Pinned dependency: this file reaches into Playwright internals (_playwright,
|
||||
* _allContexts, _context) that have no public contract. @playwright/test is
|
||||
* pinned exact (=1.58.2 in package.json) so a bump can't silently break the
|
||||
* monkeypatch. When bumping, re-verify these private symbols still exist on
|
||||
* the Electron / PlaywrightInternal classes and that tracing still merges.
|
||||
*/
|
||||
|
||||
import { _electron as electron, type BrowserContext } from '@playwright/test'
|
||||
import * as crypto from 'node:crypto'
|
||||
|
||||
const electronContexts = new Set<BrowserContext>()
|
||||
const originalLaunch = electron.launch.bind(electron)
|
||||
|
||||
electron.launch = async (options: any) => {
|
||||
const app = await originalLaunch(options)
|
||||
const ctx = (app as any)._context as BrowserContext
|
||||
electronContexts.add(ctx)
|
||||
ctx.once('close', () => electronContexts.delete(ctx))
|
||||
|
||||
// Patch _allContexts so the test runner sees the electron context
|
||||
// (didFinishTest cleanup → _stopTracing → stopChunk → merge into trace.zip).
|
||||
const pw = (electron as any)._playwright as any
|
||||
if (pw && !pw.__electronTracingPatched) {
|
||||
pw.__electronTracingPatched = true
|
||||
const original = pw._allContexts.bind(pw)
|
||||
pw._allContexts = () => [...original(), ...electronContexts]
|
||||
}
|
||||
|
||||
// Start tracing — mirrors ArtifactsRecorder.didCreateBrowserContext.
|
||||
const traceName = crypto.randomUUID()
|
||||
await ctx.tracing.start({
|
||||
screenshots: true,
|
||||
snapshots: true,
|
||||
sources: true,
|
||||
}).catch(() => {})
|
||||
await ctx.tracing.startChunk({ title: 'electron', name: traceName }).catch(() => {})
|
||||
|
||||
// Wrap tracing.start to redirect to startChunk after the first call.
|
||||
// The test runner's willStartTest calls tracing.start() on all contexts
|
||||
// in _allContexts(). Since we already started, redirect to startChunk
|
||||
// to avoid "Tracing has been already started" errors.
|
||||
const tracing = ctx.tracing as any
|
||||
tracing.start = async (opts: any) => {
|
||||
return tracing.startChunk(opts)
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
/**
|
||||
* Shared E2E fixtures for the Hermes desktop Playwright suite.
|
||||
*
|
||||
* Two fixture modes:
|
||||
*
|
||||
* 1. `mockBackend` — starts a mock inference server, writes a config.yaml
|
||||
* that points at it, and launches the desktop app so the full chain
|
||||
* (electron → hermes serve → provider → inference → renderer) is
|
||||
* exercised with a real backend but a fake LLM.
|
||||
*
|
||||
* 2. `noProvider` — launches the app with an empty config (no provider
|
||||
* configured). The onboarding overlay should appear. Used to test the
|
||||
* first-run flow without real credentials.
|
||||
*
|
||||
* Both modes launch the *dev* Electron app (`electron .` against the built
|
||||
* `dist/`), not the packaged binary. This avoids the multi-minute
|
||||
* `electron-builder --dir` step and matches `hermes desktop --source`. The
|
||||
* packaged-binary path is already covered by `launch.spec.ts`.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so that `dist/` exists.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
|
||||
|
||||
import { resolveElectronBinary } from './electron-binary'
|
||||
import { startMockServer, type MockServerOptions } from './mock-server'
|
||||
import { installErrorBannerGuard } from './test'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
|
||||
|
||||
// ─── Credential stripping (matches launch.spec.ts) ──────────────────────
|
||||
|
||||
const CREDENTIAL_SUFFIXES: string[] = [
|
||||
'_API_KEY',
|
||||
'_TOKEN',
|
||||
'_SECRET',
|
||||
'_PASSWORD',
|
||||
'_CREDENTIALS',
|
||||
'_ACCESS_KEY',
|
||||
'_PRIVATE_KEY',
|
||||
'_OAUTH_TOKEN',
|
||||
]
|
||||
|
||||
const CREDENTIAL_NAMES = new Set([
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_TOKEN',
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'AWS_SESSION_TOKEN',
|
||||
'CUSTOM_API_KEY',
|
||||
'GEMINI_BASE_URL',
|
||||
'OPENAI_BASE_URL',
|
||||
'OPENROUTER_BASE_URL',
|
||||
'OLLAMA_BASE_URL',
|
||||
'GROQ_BASE_URL',
|
||||
'XAI_BASE_URL',
|
||||
])
|
||||
|
||||
function isCredentialEnvVar(name: string): boolean {
|
||||
if (CREDENTIAL_NAMES.has(name)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return CREDENTIAL_SUFFIXES.some((suffix) => name.endsWith(suffix))
|
||||
}
|
||||
|
||||
function stripCredentials(env: Record<string, string | undefined>): Record<string, string> {
|
||||
const clean: Record<string, string> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (!value) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isCredentialEnvVar(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
clean[key] = value
|
||||
}
|
||||
|
||||
return clean
|
||||
}
|
||||
|
||||
// ─── Sandbox creation ──────────────────────────────────────────────────
|
||||
|
||||
export interface Sandbox {
|
||||
root: string
|
||||
hermesHome: string
|
||||
userDataDir: string
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
export function createSandbox(prefix: string): Sandbox {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-e2e-${prefix}-${Math.random()}`))
|
||||
const hermesHome = path.join(root, 'hermes-home')
|
||||
const userDataDir = path.join(root, 'electron-user-data')
|
||||
|
||||
fs.mkdirSync(hermesHome, { recursive: true })
|
||||
fs.mkdirSync(userDataDir, { recursive: true })
|
||||
|
||||
// Write a fixed window-state.json so the Electron window opens at a
|
||||
// consistent size — helps with visual regression screenshots. The
|
||||
// exact size is also enforced right before each screenshot (see
|
||||
// expectVisualSnapshot in visual-snapshot.ts) because window managers
|
||||
// may resize after launch.
|
||||
fs.writeFileSync(
|
||||
path.join(userDataDir, 'window-state.json'),
|
||||
JSON.stringify(
|
||||
{ x: 0, y: 0, width: 1220, height: 800, isMaximized: false },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
// Pin Chromium actual-size zoom (level 0) for the suite. Fresh installs
|
||||
// ship DEFAULT_ZOOM_LEVEL at the Appearance 90% preset, but Playwright
|
||||
// click hit-testing and the committed visual baselines were calibrated at
|
||||
// 100%. Without this file every sandbox would inherit the product default
|
||||
// and fail pointer interception + snapshot diffs.
|
||||
fs.writeFileSync(
|
||||
path.join(userDataDir, 'zoom-state.json'),
|
||||
JSON.stringify({ zoomLevel: 0 }, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
return {
|
||||
root,
|
||||
hermesHome,
|
||||
userDataDir,
|
||||
cleanup: () => {
|
||||
try {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Config writing ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write a config.yaml that pre-configures a mock provider pointing at the
|
||||
* mock inference server. The provider is set as the active model provider so
|
||||
* the desktop app skips onboarding and boots straight to the chat UI.
|
||||
*
|
||||
* @param extraDisplayConfig optional YAML lines appended to the `display:`
|
||||
* section, used by the interim-message e2e test.
|
||||
* @param extraConfig optional top-level YAML sections for a test scenario.
|
||||
* @param modelContextLength optional primary-model context limit.
|
||||
*/
|
||||
export function writeMockProviderConfig(
|
||||
hermesHome: string,
|
||||
mockUrl: string,
|
||||
extraDisplayConfig?: string,
|
||||
extraConfig?: string,
|
||||
modelContextLength?: number,
|
||||
): void {
|
||||
const configPath = path.join(hermesHome, 'config.yaml')
|
||||
|
||||
const displaySection = extraDisplayConfig
|
||||
? `\ndisplay:\n${extraDisplayConfig}\n`
|
||||
: ''
|
||||
|
||||
// Title generation rides the MAIN model since 87af576e60 (#83636), so every
|
||||
// completed turn fires an extra background /v1/chat/completions at the mock.
|
||||
// That request contains the whole conversation — trigger keywords included —
|
||||
// which advances the mock's scripted-turn indices and trips hold-for-prompt
|
||||
// matchers from a request no spec ever sent. Disable it by default (no e2e
|
||||
// spec asserts on session titles); a test that passes its own `auxiliary:`
|
||||
// section via extraConfig owns the whole section instead.
|
||||
const autoTitleDefault = extraConfig?.includes('auxiliary:')
|
||||
? ''
|
||||
: 'auxiliary:\n title_generation:\n enabled: false\n'
|
||||
|
||||
// The scripted turns run REAL terminal commands, and anything the guard
|
||||
// classifies as dangerous (e.g. the sidebar sentinel-wait loop) parks the
|
||||
// turn behind a Run/Reject approval card. The default 'smart' mode then
|
||||
// fires an aux LLM approval call at the SAME mock provider — consuming a
|
||||
// scripted-turn index and never resolving — so the turn stalls until the
|
||||
// spec times out (the CI failure mode for the sidebar-dot family). No e2e
|
||||
// spec asserts on the approval flow, so run gate-free by default; a test
|
||||
// that passes its own `approvals:` section via extraConfig owns it.
|
||||
const approvalsDefault = extraConfig?.includes('approvals:')
|
||||
? ''
|
||||
: 'approvals:\n mode: "off"\n'
|
||||
|
||||
const config = `# Auto-generated by E2E test fixtures
|
||||
model:
|
||||
default: mock-model
|
||||
provider: mock
|
||||
${modelContextLength ? ` context_length: ${modelContextLength}\n` : ''}providers:
|
||||
mock:
|
||||
api: ${mockUrl}/v1
|
||||
name: Mock
|
||||
api_mode: chat_completions
|
||||
key_env: MOCK_API_KEY
|
||||
models:
|
||||
mock-model: {}
|
||||
context_length: 4096
|
||||
${autoTitleDefault}${approvalsDefault}${displaySection}${extraConfig ? `\n${extraConfig.trim()}\n` : ''}`
|
||||
|
||||
fs.writeFileSync(configPath, config, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a minimal .env with the mock API key. The key_env in config.yaml
|
||||
* references MOCK_API_KEY, so the backend resolves credentials from here.
|
||||
*/
|
||||
export function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void {
|
||||
const envPath = path.join(hermesHome, '.env')
|
||||
fs.writeFileSync(envPath, `MOCK_API_KEY=${apiKey}\n`, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an empty config (no providers). The desktop app should show the
|
||||
* onboarding overlay because no inference provider is configured.
|
||||
*/
|
||||
function writeEmptyConfig(hermesHome: string): void {
|
||||
const configPath = path.join(hermesHome, 'config.yaml')
|
||||
fs.writeFileSync(configPath, '# Auto-generated by E2E test fixtures — no providers configured\n', 'utf8')
|
||||
}
|
||||
|
||||
// ─── Env building ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the environment for the Electron app process.
|
||||
*
|
||||
* Key env vars:
|
||||
* - HERMES_HOME → sandbox hermes-home (isolated config/sessions)
|
||||
* - HERMES_DESKTOP_USER_DATA_DIR → sandbox electron-user-data
|
||||
* - HERMES_DESKTOP_IGNORE_EXISTING=1 → don't pick up `hermes` from PATH
|
||||
* (we want the dev checkout at REPO_ROOT)
|
||||
* - HERMES_DESKTOP_HERMES_ROOT → REPO_ROOT (dev checkout resolution)
|
||||
* - HERMES_DESKTOP_APP_NAME → unique-ish per test (avoids single-instance lock)
|
||||
* - XDG_RUNTIME_DIR → ensure Electron has a writable runtime dir on Linux
|
||||
*/
|
||||
export function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}): Record<string, string> {
|
||||
const clean = stripCredentials(process.env)
|
||||
|
||||
// XDG_RUNTIME_DIR is needed for Electron on Linux when running in a
|
||||
// headless/CI context — without it the zygote may fail to initialize.
|
||||
if (!clean.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR) {
|
||||
clean.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR
|
||||
}
|
||||
|
||||
// DISPLAY — needed for Electron to open a window.
|
||||
if (!clean.DISPLAY && process.env.DISPLAY) {
|
||||
clean.DISPLAY = process.env.DISPLAY
|
||||
}
|
||||
|
||||
return {
|
||||
...clean,
|
||||
HERMES_HOME: sandbox.hermesHome,
|
||||
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
|
||||
HERMES_DESKTOP_IGNORE_EXISTING: '1',
|
||||
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
|
||||
HERMES_DESKTOP_APP_NAME: `HermesE2E-${Date.now()}`,
|
||||
// `app.close()` in teardown must exit even when a spec leaves a turn
|
||||
// mid-flight — otherwise the quit confirmation waits on a click that no
|
||||
// one is there to make, and the worker dies on a teardown timeout.
|
||||
HERMES_DESKTOP_SKIP_QUIT_CONFIRM: '1',
|
||||
// Clear dev-server override — we want the built dist/, not a vite server.
|
||||
// The dev-server check in main.ts looks for this env var; if it's set,
|
||||
// it loads from the vite URL instead of the local file.
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Electron launch ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify that the desktop app has been built (dist/ exists). Playwright
|
||||
* tests can't run without it — the Electron main process loads
|
||||
* dist/electron-main.mjs and the renderer loads dist/index.html.
|
||||
*/
|
||||
function assertDistBuilt(): void {
|
||||
const distDir = path.join(DESKTOP_ROOT, 'dist')
|
||||
const electronMain = path.join(distDir, 'electron-main.mjs')
|
||||
const indexHtml = path.join(distDir, 'index.html')
|
||||
|
||||
if (!fs.existsSync(electronMain)) {
|
||||
throw new Error(
|
||||
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
|
||||
`Missing: ${electronMain}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(indexHtml)) {
|
||||
throw new Error(
|
||||
`Desktop dist/index.html not found. Run 'cd apps/desktop && npm run build' first.\n` +
|
||||
`Missing: ${indexHtml}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Electron binary. In the nix devshell, `electron` is on PATH.
|
||||
* As a fallback, use the node_modules/electron install from either package.
|
||||
*/
|
||||
export function findElectron(): string {
|
||||
// In dev mode, we use the `electron` binary directly (not the packaged app).
|
||||
// The dev:electron script in package.json does exactly this: `electron .`
|
||||
// after building. We replicate that here.
|
||||
//
|
||||
// The desktop package is searched first: npm workspaces only hoist
|
||||
// `electron` to the repo root when nothing conflicts, so a workspace-local
|
||||
// install is just as ordinary an outcome as a hoisted one. The rules live in
|
||||
// ./electron-binary so they can be unit-tested per platform.
|
||||
return resolveElectronBinary([DESKTOP_ROOT, REPO_ROOT])
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the desktop app in dev mode.
|
||||
*
|
||||
* @param sandbox - isolated HERMES_HOME + userData
|
||||
* @param env - the process environment (already has HERMES_HOME etc.)
|
||||
* @returns the ElectronApplication + first Page
|
||||
*/
|
||||
export async function launchDesktop(
|
||||
env: Record<string, string>,
|
||||
): Promise<{ app: ElectronApplication; page: Page }> {
|
||||
assertDistBuilt()
|
||||
|
||||
const electronBin = findElectron()
|
||||
|
||||
// `electron .` loads from the package.json `main` field
|
||||
// (dist/electron-main.mjs after build).
|
||||
const app = await _electron.launch({
|
||||
executablePath: electronBin,
|
||||
args: [
|
||||
DESKTOP_ROOT, // `electron .` — the `.` is the desktop package dir
|
||||
'--disable-gpu',
|
||||
'--no-sandbox',
|
||||
],
|
||||
env,
|
||||
cwd: DESKTOP_ROOT,
|
||||
})
|
||||
|
||||
const page = await app.firstWindow()
|
||||
|
||||
// Install the error-banner guard so any [role="alert"] that appears
|
||||
// during a test is collected and surfaced in afterEach.
|
||||
installErrorBannerGuard(page)
|
||||
|
||||
return { app, page }
|
||||
}
|
||||
|
||||
// ─── Public fixtures ────────────────────────────────────────────────────
|
||||
|
||||
export interface MockBackendFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
mock: Awaited<ReturnType<typeof startMockServer>>
|
||||
mockUrl: string
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface MockBackendOptions {
|
||||
/**
|
||||
* Optional YAML lines to inject under the `display:` section of the
|
||||
* generated config.yaml. Used by the interim-message e2e test to toggle
|
||||
* `display.interim_assistant_messages`.
|
||||
*/
|
||||
extraDisplayConfig?: string
|
||||
/** Additional top-level config.yaml sections for an E2E scenario. */
|
||||
extraConfig?: string
|
||||
/** Override the mock model's context window for compression scenarios. */
|
||||
modelContextLength?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a full mock-backend E2E environment:
|
||||
* 1. Start the mock inference server
|
||||
* 2. Create a sandbox with config.yaml pointing at it
|
||||
* 3. Launch the desktop app
|
||||
* 4. Return handles for test interaction
|
||||
*/
|
||||
export interface MockBackendOptions {
|
||||
mockServer?: MockServerOptions
|
||||
}
|
||||
|
||||
export async function setupMockBackend(options: MockBackendOptions = {}): Promise<MockBackendFixture> {
|
||||
// 1. Start mock server
|
||||
const mock = await startMockServer(options.mockServer)
|
||||
|
||||
// 2. Create sandbox + write config
|
||||
const sandbox = createSandbox('mock')
|
||||
writeMockProviderConfig(
|
||||
sandbox.hermesHome,
|
||||
mock.url,
|
||||
options.extraDisplayConfig,
|
||||
options.extraConfig,
|
||||
options.modelContextLength,
|
||||
)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
// 3. Build env + launch
|
||||
const env = buildAppEnv(sandbox)
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface NoProviderFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the app with no provider configured. The onboarding overlay should
|
||||
* appear because there's no inference provider in config.yaml.
|
||||
*/
|
||||
export async function setupNoProvider(): Promise<NoProviderFixture> {
|
||||
const sandbox = createSandbox('noprovider')
|
||||
writeEmptyConfig(sandbox.hermesHome)
|
||||
|
||||
const env = buildAppEnv(sandbox)
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeadBackendFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface DeadBackendOptions {
|
||||
/**
|
||||
* When true, inject a fake boot error via HERMES_DESKTOP_BOOT_FAKE_ERROR
|
||||
* so the backend resolution itself "fails" with a controlled error message.
|
||||
* This is the only reliable way to trigger BootFailureOverlay in dev mode
|
||||
* (the real backend always resolves via SOURCE_REPO_ROOT).
|
||||
*/
|
||||
fakeError?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the app with a provider pointing at a dead endpoint (port 1, which
|
||||
* nothing listens on). By default the backend still boots (`hermes serve`
|
||||
* starts fine — the dead endpoint only matters at chat time). Pass
|
||||
* `{ fakeError: true }` to inject a fake boot failure, triggering the
|
||||
* BootFailureOverlay.
|
||||
*/
|
||||
export async function setupDeadBackend(options: DeadBackendOptions = {}): Promise<DeadBackendFixture> {
|
||||
const sandbox = createSandbox('dead')
|
||||
const configPath = path.join(sandbox.hermesHome, 'config.yaml')
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`# Auto-generated by E2E test fixtures — dead provider
|
||||
model:
|
||||
default: mock-model
|
||||
provider: mock
|
||||
providers:
|
||||
mock:
|
||||
api: http://127.0.0.1:1/v1
|
||||
name: Mock
|
||||
api_mode: chat_completions
|
||||
key_env: MOCK_API_KEY
|
||||
models:
|
||||
mock-model: {}
|
||||
context_length: 4096
|
||||
`,
|
||||
'utf8',
|
||||
)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
const env = buildAppEnv(sandbox, options.fakeError ? { HERMES_DESKTOP_BOOT_FAKE_ERROR: 'Failed to connect to Hermes backend: connection refused' } : {})
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Packaged-binary fixture ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the packaged Electron binary path, per-platform, matching
|
||||
* electron-builder's output layout under release/.
|
||||
*/
|
||||
function resolvePackagedBinaryPath(): string {
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe')
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
|
||||
return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes')
|
||||
}
|
||||
|
||||
return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes')
|
||||
}
|
||||
|
||||
export const PACKAGED_BINARY_PATH = resolvePackagedBinaryPath()
|
||||
|
||||
export function packagedBinaryExists(): boolean {
|
||||
return fs.existsSync(PACKAGED_BINARY_PATH)
|
||||
}
|
||||
|
||||
export interface PackagedAppFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the *packaged* Electron binary (from `npm run pack` →
|
||||
* `electron-builder --dir`) with `BOOT_FAKE=1` so it simulates boot
|
||||
* progress without spawning a real Hermes backend.
|
||||
*
|
||||
* Uses the same sandbox isolation (credential stripping, isolated
|
||||
* HERMES_HOME + userData, unique app name) as the dev-mode fixtures.
|
||||
*
|
||||
* Skips if the packaged binary doesn't exist — run `npm run pack` first.
|
||||
*/
|
||||
export async function setupPackagedApp(): Promise<PackagedAppFixture> {
|
||||
if (!packagedBinaryExists()) {
|
||||
throw new Error(
|
||||
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
|
||||
)
|
||||
}
|
||||
|
||||
const sandbox = createSandbox('packaged')
|
||||
|
||||
// Build the sandbox env using the shared helpers, then add the
|
||||
// packaged-binary-specific overrides.
|
||||
const env = buildAppEnv(sandbox, {
|
||||
// Fake boot: simulates progress steps without spawning the real backend.
|
||||
HERMES_DESKTOP_BOOT_FAKE: '1',
|
||||
HERMES_DESKTOP_BOOT_FAKE_STEP_MS: '120',
|
||||
})
|
||||
|
||||
// Clear dev-server + hermes-root overrides — the packaged binary
|
||||
// should use its own bundled renderer, not the dev checkout.
|
||||
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_DEV_SERVER
|
||||
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES
|
||||
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES_ROOT
|
||||
|
||||
const app = await _electron.launch({
|
||||
executablePath: PACKAGED_BINARY_PATH,
|
||||
args: ['--disable-gpu', '--no-sandbox'],
|
||||
env,
|
||||
})
|
||||
|
||||
const page = await app.firstWindow()
|
||||
installErrorBannerGuard(page)
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Wait helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wait for the desktop app to finish booting and show the main chat UI.
|
||||
*
|
||||
* The boot overlay disappears when `completeDesktopBoot()` fires in the
|
||||
* renderer — at that point the gateway is open, config is loaded, and
|
||||
* sessions are loaded. We detect this by waiting for the boot/connecting
|
||||
* overlay to become invisible and the main app shell to be present.
|
||||
*
|
||||
* Two things must both be true before we return:
|
||||
* 1. The composer (chat input) is visible — it's disabled until the
|
||||
* gateway is open.
|
||||
* 2. No full-screen overlay (onboarding Preparing, connecting overlay,
|
||||
* boot-failure) covers the viewport center. The composer can be
|
||||
* "visible" in Playwright's eyes (non-zero bounding box, not
|
||||
* display:none) even when a z-1300+ overlay is painted on top of it,
|
||||
* so checking the composer alone catches the app mid-boot at ~92%
|
||||
* with the loading bar still showing.
|
||||
*/
|
||||
export async function waitForAppReady(fixture: MockBackendFixture | NoProviderFixture | DeadBackendFixture, timeoutMs = 60_000): Promise<void> {
|
||||
const { page, app } = fixture
|
||||
|
||||
// Wait for the composer to exist in the DOM (not necessarily interactive yet).
|
||||
await page.waitForSelector('textarea, [contenteditable="true"]', {
|
||||
state: 'attached',
|
||||
timeout: timeoutMs,
|
||||
})
|
||||
|
||||
// Now poll until no full-screen overlay covers the viewport center.
|
||||
// elementFromPoint returns the topmost element at a point — if it's part
|
||||
// of a fixed inset-0 overlay (onboarding/connecting/boot-failure), the
|
||||
// app isn't ready yet.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
|
||||
|
||||
if (!el) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Walk up to the nearest positioned ancestor — overlays are
|
||||
// `position: fixed; inset: 0`. If the hit element or an ancestor
|
||||
// is a full-viewport fixed overlay, we're still covered.
|
||||
let node: Element | null = el
|
||||
while (node) {
|
||||
const cs = window.getComputedStyle(node)
|
||||
|
||||
if (cs.position === 'fixed') {
|
||||
const rect = node.getBoundingClientRect()
|
||||
|
||||
if (rect.left <= 0 && rect.top <= 0 && rect.right >= window.innerWidth && rect.bottom >= window.innerHeight) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
node = node.parentElement
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
undefined,
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
|
||||
// On Electron 40.x, ready-to-show may never fire (electron/electron#51972)
|
||||
// and the window stays hidden even though the DOM is rendered. The main
|
||||
// process reveals it anyway — immediately under TEST_WORKER_INDEX, and via
|
||||
// wireWindowReveal's post-load fallback in production — but the DOM can be
|
||||
// ready before that lands. Poll until the window is actually visible so
|
||||
// interactions (click, screenshot) don't hit a hidden surface.
|
||||
if (app) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const visible = await app.evaluate(({ BrowserWindow }) => {
|
||||
const w = BrowserWindow.getAllWindows()[0]
|
||||
|
||||
return w ? w.isVisible() : false
|
||||
}).catch(() => false)
|
||||
|
||||
if (visible) {break}
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the onboarding overlay to appear (no provider configured).
|
||||
*/
|
||||
export async function waitForOnboarding(page: Page, timeoutMs = 60_000): Promise<void> {
|
||||
// The onboarding overlay contains a heading with "Choose your provider"
|
||||
// or similar text. We look for any text that indicates the picker.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (!root) {
|
||||
return false
|
||||
}
|
||||
|
||||
const text = root.textContent ?? ''
|
||||
|
||||
return (
|
||||
text.includes('provider') ||
|
||||
text.includes('Provider') ||
|
||||
text.includes('Choose') ||
|
||||
text.includes('API key') ||
|
||||
text.includes('Sign in')
|
||||
)
|
||||
},
|
||||
undefined,
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the boot failure overlay to appear.
|
||||
*/
|
||||
export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
// Boot failure is terminal: the backend gave up. The renderer shows
|
||||
// either BootFailureOverlay (z-1400, with Retry/Repair buttons) or
|
||||
// falls back to the onboarding picker (z-1300) as a recovery path.
|
||||
// We wait for the failure dialog itself — the Preparing component may
|
||||
// still paint its progress bar (recolored red) underneath the overlay,
|
||||
// which is harmless.
|
||||
const text = document.body.textContent ?? ''
|
||||
|
||||
// BootFailureOverlay buttons.
|
||||
const hasFailureUI =
|
||||
text.includes('Retry') ||
|
||||
text.includes('Repair') ||
|
||||
text.includes('Use local gateway') ||
|
||||
text.includes('Connection settings')
|
||||
|
||||
// The error toast / notification that fires on failDesktopBoot().
|
||||
const hasErrorToast = text.includes('Desktop boot failed')
|
||||
|
||||
return hasFailureUI || hasErrorToast
|
||||
},
|
||||
undefined,
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* E2E: the fleet profile rail with two registered gateways.
|
||||
*
|
||||
* "This device" is the Electron-managed local backend (mock inference). The
|
||||
* second gateway, "Homelab", is a REAL second `hermes serve` this spec spawns
|
||||
* with its own HERMES_HOME, profiles and session token, registered in the v2
|
||||
* connections.json as a remote URL connection. A click on an at-rest square
|
||||
* therefore performs the same dial → commit → re-home the statusbar switcher
|
||||
* does, against a real backend — not a stub.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists, and the
|
||||
* repo's Python venv (`.venv`) must exist for both backends.
|
||||
*/
|
||||
|
||||
import { type ChildProcess, spawn, spawnSync } from 'node:child_process'
|
||||
import * as fs from 'node:fs'
|
||||
import * as net from 'node:net'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
type MockBackendFixture,
|
||||
type Sandbox,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig,
|
||||
} from './fixtures'
|
||||
import { startMockServer } from './mock-server'
|
||||
import { type ElectronApplication, expect, type Page, test } from './test'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
|
||||
const REMOTE_LABEL = 'Homelab'
|
||||
const REMOTE_ID = 'homelab'
|
||||
const REMOTE_TOKEN = 'e2e-fleet-homelab-token'
|
||||
|
||||
interface RemoteGateway {
|
||||
url: string
|
||||
home: string
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
function findHermesBinary(): string {
|
||||
const venv = path.join(REPO_ROOT, '.venv', 'bin', 'hermes')
|
||||
|
||||
if (fs.existsSync(venv)) {
|
||||
return venv
|
||||
}
|
||||
|
||||
const result = spawnSync('which', ['hermes'], { encoding: 'utf8' })
|
||||
|
||||
if (result.status === 0 && result.stdout.trim()) {
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
throw new Error('hermes binary not found: create the repo venv (uv sync) or put hermes on PATH')
|
||||
}
|
||||
|
||||
async function freePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer()
|
||||
server.unref()
|
||||
server.on('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address() as net.AddressInfo
|
||||
server.close(() => resolve(port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Seed `<home>/profiles/<name>/` so the backend's /api/profiles lists it. */
|
||||
function seedProfiles(home: string, names: string[]): void {
|
||||
for (const name of names) {
|
||||
const dir = path.join(home, 'profiles', name)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(path.join(dir, 'config.yaml'), '', 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn a second, fully real `hermes serve` as the remote gateway. Its
|
||||
* session token is pinned through HERMES_DASHBOARD_SESSION_TOKEN so the
|
||||
* registry entry can carry a plaintext token envelope.
|
||||
*/
|
||||
async function startRemoteGateway(root: string, mockUrl: string, profiles: string[]): Promise<RemoteGateway> {
|
||||
const home = path.join(root, 'homelab-home')
|
||||
fs.mkdirSync(home, { recursive: true })
|
||||
writeMockProviderConfig(home, mockUrl)
|
||||
writeEnvFile(home)
|
||||
seedProfiles(home, profiles)
|
||||
|
||||
const port = await freePort()
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
|
||||
const child: ChildProcess = spawn(
|
||||
findHermesBinary(),
|
||||
['serve', '--host', '127.0.0.1', '--port', String(port), '--skip-build'],
|
||||
{
|
||||
cwd: REPO_ROOT,
|
||||
detached: true,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME: home,
|
||||
HERMES_DASHBOARD_SESSION_TOKEN: REMOTE_TOKEN,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
},
|
||||
)
|
||||
|
||||
let log = ''
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
log += chunk.toString()
|
||||
})
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
log += chunk.toString()
|
||||
})
|
||||
|
||||
const deadline = Date.now() + 90_000
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`remote hermes serve exited early (${child.exitCode}):\n${log}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${url}/api/status`, {
|
||||
headers: { 'X-Hermes-Session-Token': REMOTE_TOKEN },
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// not up yet
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
}
|
||||
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`remote hermes serve never became ready:\n${log}`)
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
home,
|
||||
close: async () => {
|
||||
if (child.pid && child.exitCode === null) {
|
||||
try {
|
||||
process.kill(-child.pid, 'SIGTERM')
|
||||
} catch {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function writeConnectionsRegistry(sandbox: Sandbox, remoteUrl: string): void {
|
||||
fs.writeFileSync(
|
||||
path.join(sandbox.userDataDir, 'connections.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: 2,
|
||||
primary: 'local',
|
||||
launchMode: 'primary',
|
||||
lastUsed: 'local',
|
||||
connections: [
|
||||
{ id: 'local', kind: 'local', label: 'This device' },
|
||||
{
|
||||
id: REMOTE_ID,
|
||||
kind: 'remote',
|
||||
label: REMOTE_LABEL,
|
||||
url: remoteUrl,
|
||||
authMode: 'token',
|
||||
token: { encoding: 'plain', value: REMOTE_TOKEN },
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
{ encoding: 'utf8', mode: 0o600 },
|
||||
)
|
||||
}
|
||||
|
||||
// FLEET_RAIL_SCREENSHOT_DIR=<dir> saves full-window captures at the key
|
||||
// states — handy for design review; never part of the assertions.
|
||||
async function capture(page: Page, name: string): Promise<void> {
|
||||
const dir = process.env.FLEET_RAIL_SCREENSHOT_DIR
|
||||
|
||||
if (!dir) {
|
||||
return
|
||||
}
|
||||
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
await page.screenshot({ path: path.join(dir, `${name}.png`) })
|
||||
}
|
||||
|
||||
const rail = (page: Page) => page.locator('[data-slot="profile-rail"]')
|
||||
const gatewayGroup = (page: Page, id: string) => rail(page).locator(`[data-slot="profile-rail-gateway"][data-connection-id="${id}"]`)
|
||||
const activeGatewayLabel = (page: Page) => page.getByRole('button', { name: /^Registered gateways: / })
|
||||
|
||||
async function groupOrder(page: Page): Promise<Array<[string, boolean]>> {
|
||||
return rail(page).locator('[data-slot="profile-rail-gateway"]').evaluateAll(nodes =>
|
||||
nodes.map(node => [node.getAttribute('data-connection-id') ?? '', node.getAttribute('data-active') === 'true'] as [string, boolean]),
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('fleet profile rail — two registered gateways', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let mock: Awaited<ReturnType<typeof startMockServer>>
|
||||
let sandbox: Sandbox
|
||||
let remote: RemoteGateway
|
||||
let app: ElectronApplication
|
||||
let page: Page
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.setTimeout(240_000)
|
||||
mock = await startMockServer()
|
||||
sandbox = createSandbox('fleet')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
// A named profile on This device too, so the active group has a square
|
||||
// beside its home pill. "research" exists on BOTH gateways on purpose: the
|
||||
// rail must keep the two apart by gateway, never by name alone.
|
||||
seedProfiles(sandbox.hermesHome, ['research'])
|
||||
|
||||
remote = await startRemoteGateway(sandbox.root, mock.url, ['inbox', 'research'])
|
||||
writeConnectionsRegistry(sandbox, remote.url)
|
||||
|
||||
;({ app, page } = await launchDesktop(buildAppEnv(sandbox)))
|
||||
await waitForAppReady({ app, page } as MockBackendFixture, 120_000)
|
||||
// Let boot settle fully (the gateway health item reports "ready" once the
|
||||
// primary socket is open) so the boot-time launch-mode restore has run
|
||||
// before any click — the rail must then hold whatever the user picks.
|
||||
await expect(page.locator('[data-slot="statusbar"]').getByText('ready', { exact: true })).toBeVisible({ timeout: 120_000 })
|
||||
await page.waitForTimeout(2_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await app?.close().catch(() => undefined)
|
||||
await remote?.close()
|
||||
await mock?.close()
|
||||
sandbox?.cleanup()
|
||||
})
|
||||
|
||||
test('lays both gateways on one strip, active gateway in its registry slot', async () => {
|
||||
// The statusbar readout names the gateway the workspace is on.
|
||||
await expect(activeGatewayLabel(page)).toHaveAttribute('aria-label', 'Registered gateways: This device', { timeout: 60_000 })
|
||||
|
||||
// The remote gateway's group appears once the roster has enumerated it.
|
||||
const homelab = gatewayGroup(page, REMOTE_ID)
|
||||
await expect(homelab).toBeVisible({ timeout: 60_000 })
|
||||
await expect(homelab.getByRole('button', { name: `default · ${REMOTE_LABEL}` })).toBeVisible()
|
||||
await expect(homelab.getByRole('button', { name: `inbox · ${REMOTE_LABEL}` })).toBeVisible()
|
||||
await expect(homelab.getByRole('button', { name: `research · ${REMOTE_LABEL}` })).toBeVisible()
|
||||
await expect(homelab).toHaveAttribute('data-reachable', 'true')
|
||||
|
||||
// Its marker carries the remote (network) glyph.
|
||||
await expect(
|
||||
rail(page).locator(`[data-slot="profile-rail-divider"][data-connection-id="${REMOTE_ID}"] [data-connection-kind="remote"]`),
|
||||
).toBeVisible()
|
||||
|
||||
// This device is the active group: its squares are unqualified, as before.
|
||||
const local = gatewayGroup(page, 'local')
|
||||
await expect(local).toHaveAttribute('data-active', 'true')
|
||||
await expect(local.getByRole('button', { name: 'research', exact: true })).toBeVisible()
|
||||
|
||||
// Registry order: This device first, Homelab second.
|
||||
expect(await groupOrder(page)).toEqual([
|
||||
['local', true],
|
||||
[REMOTE_ID, false],
|
||||
])
|
||||
|
||||
// Fleet pill replaces the default↔all toggle; the single-gateway plug is gone.
|
||||
await expect(rail(page).getByRole('button', { name: 'All profiles on this gateway' })).toBeVisible()
|
||||
await expect(rail(page).getByRole('button', { name: 'Manage gateways…' })).toHaveCount(0)
|
||||
|
||||
await gatewayGroup(page, REMOTE_ID).getByRole('button', { name: `inbox · ${REMOTE_LABEL}` }).hover()
|
||||
await capture(page, '1-on-this-device-hover-inbox-homelab')
|
||||
})
|
||||
|
||||
test('clicking an at-rest square re-homes onto that exact gateway and profile', async () => {
|
||||
test.setTimeout(180_000)
|
||||
await gatewayGroup(page, REMOTE_ID).getByRole('button', { name: `inbox · ${REMOTE_LABEL}` }).click()
|
||||
|
||||
// The workspace follows the agent: statusbar readout flips to Homelab…
|
||||
await expect(activeGatewayLabel(page)).toHaveAttribute('aria-label', `Registered gateways: ${REMOTE_LABEL}`, { timeout: 120_000 })
|
||||
|
||||
// …Homelab's group is now the active one, on the clicked profile…
|
||||
const homelab = gatewayGroup(page, REMOTE_ID)
|
||||
await expect(homelab).toHaveAttribute('data-active', 'true', { timeout: 30_000 })
|
||||
await expect(homelab.getByRole('button', { name: 'inbox', exact: true })).toHaveAttribute('aria-pressed', 'true', { timeout: 30_000 })
|
||||
|
||||
// …This device is at rest with qualified squares…
|
||||
const local = gatewayGroup(page, 'local')
|
||||
await expect(local).toHaveAttribute('data-active', 'false')
|
||||
await expect(local.getByRole('button', { name: 'research · This device' })).toBeVisible()
|
||||
|
||||
// …and nothing moved: the order is still This device, then Homelab.
|
||||
expect(await groupOrder(page)).toEqual([
|
||||
['local', false],
|
||||
[REMOTE_ID, true],
|
||||
])
|
||||
|
||||
await capture(page, '2-re-homed-on-homelab-inbox')
|
||||
})
|
||||
|
||||
test('an at-rest square offers gateway-scoped actions, never the legacy remote override', async () => {
|
||||
const square = gatewayGroup(page, 'local').getByRole('button', { name: 'research · This device' })
|
||||
await square.click({ button: 'right' })
|
||||
|
||||
const menu = page.getByRole('menu', { name: 'Actions' })
|
||||
await expect(menu).toBeVisible()
|
||||
await expect(menu.getByRole('menuitem', { name: 'Switch to research on This device' })).toBeVisible()
|
||||
await expect(menu.getByRole('menuitem', { name: 'Rename…' })).toBeVisible()
|
||||
await expect(menu.getByRole('menuitem', { name: 'Edit SOUL.md…' })).toBeVisible()
|
||||
await expect(menu.getByRole('menuitem', { name: 'Delete' })).toBeVisible()
|
||||
await expect(menu.getByRole('menuitem', { name: 'Connect to a remote host…' })).toHaveCount(0)
|
||||
|
||||
await capture(page, '3-at-rest-square-context-menu')
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(menu).toBeHidden()
|
||||
})
|
||||
|
||||
test('editing SOUL.md on an at-rest square reads the owning gateway, not the foreground one', async () => {
|
||||
const square = gatewayGroup(page, 'local').getByRole('button', { name: 'research · This device' })
|
||||
await square.click({ button: 'right' })
|
||||
await page.getByRole('menu', { name: 'Actions' }).getByRole('menuitem', { name: 'Edit SOUL.md…' }).click()
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(dialog.getByText('research · This device · SOUL.md')).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('switching back lands on the clicked profile of This device and keeps the order', async () => {
|
||||
test.setTimeout(180_000)
|
||||
await gatewayGroup(page, 'local').getByRole('button', { name: 'research · This device' }).click()
|
||||
|
||||
await expect(activeGatewayLabel(page)).toHaveAttribute('aria-label', 'Registered gateways: This device', { timeout: 120_000 })
|
||||
const local = gatewayGroup(page, 'local')
|
||||
await expect(local).toHaveAttribute('data-active', 'true', { timeout: 30_000 })
|
||||
await expect(local.getByRole('button', { name: 'research', exact: true })).toHaveAttribute('aria-pressed', 'true', { timeout: 30_000 })
|
||||
await expect(gatewayGroup(page, REMOTE_ID).getByRole('button', { name: `inbox · ${REMOTE_LABEL}` })).toBeVisible()
|
||||
|
||||
expect(await groupOrder(page)).toEqual([
|
||||
['local', true],
|
||||
[REMOTE_ID, false],
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* E2E contract for the compositor-only GlyphSpinner.
|
||||
*
|
||||
* The spinner's whole reason for existing in this shape is a CSS animation:
|
||||
* every frame is in the DOM from mount and a `transform` keyframes animation
|
||||
* scrolls between them, so there is no JS timer and no per-tick DOM mutation
|
||||
* scheduling document-scale style recalculation.
|
||||
*
|
||||
* None of that is observable in jsdom — it has no animation engine, no
|
||||
* cascade resolution for `steps()`, and no `Element.getAnimations()`. The
|
||||
* jsdom suite (src/components/ui/glyph-spinner.test.tsx) therefore pins the
|
||||
* DATA and WIRING, and this spec pins the RENDERED BEHAVIOUR in a real
|
||||
* browser, which is the only place the stylesheet actually runs.
|
||||
*
|
||||
* This replaces three tests that asserted on the TEXT of the stylesheet.
|
||||
* Reading source in a test is banned outright (AGENTS.md) and those tests
|
||||
* proved the point: a var()-fallback edit that changed no rendered pixel
|
||||
* broke one of them, while none of them had ever executed the CSS.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, type Page, test } from '@playwright/test'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
|
||||
/* Scope to a spinner that is actually RUNNING. Turns from earlier tests in
|
||||
* this file leave parked spinners mounted (kept-alive panes, swap overlays
|
||||
* hold them with data-paused='true'), and document.querySelector returns the
|
||||
* FIRST strip in the DOM — a stale parked one once two turns have run. */
|
||||
const STRIP = '.glyph-spinner:not([data-paused="true"]) .glyph-spinner__strip'
|
||||
|
||||
/** Prompt the mock server holds open so the spinner runs for the whole file. */
|
||||
const SPINNER_PROMPT = 'E2E_GLYPH_SPINNER_HOLD'
|
||||
|
||||
/**
|
||||
* Get a RUNNING frame strip into the DOM deterministically.
|
||||
*
|
||||
* A turn is sent so the app is genuinely busy (the mock server holds the
|
||||
* stream open), but which surface mounts a spinner mid-turn is app policy
|
||||
* that has changed before and will again — the transcript, status stack and
|
||||
* swap overlay all park/unmount theirs at different moments, which made this
|
||||
* spec racy. The contract under test is the STYLESHEET (steps() animation,
|
||||
* layer promotion, the data-paused and global pause gates), and that CSS is
|
||||
* driven entirely by the `data-paused` attribute — the same attribute the
|
||||
* parked assertions below already toggle. So: wait for any mounted spinner
|
||||
* (the ChatSwapOverlay keeps one mounted, parked, after boot), then unpark it
|
||||
* and assert against the running animation.
|
||||
*/
|
||||
async function mountSpinner(page: Page): Promise<void> {
|
||||
if (await page.locator(STRIP).count()) {
|
||||
return
|
||||
}
|
||||
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await composer.click()
|
||||
await composer.type(SPINNER_PROMPT, { delay: 10 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await page.waitForSelector('.glyph-spinner__strip', { state: 'attached', timeout: 20_000 })
|
||||
await page.evaluate(() => {
|
||||
for (const el of document.querySelectorAll('.glyph-spinner[data-paused]')) {
|
||||
el.removeAttribute('data-paused')
|
||||
}
|
||||
})
|
||||
await page.waitForSelector(STRIP, { state: 'attached', timeout: 20_000 })
|
||||
}
|
||||
|
||||
test.describe('GlyphSpinner (compositor animation)', () => {
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend({
|
||||
mockServer: { holdFirstStreamForPrompt: SPINNER_PROMPT },
|
||||
})
|
||||
await waitForAppReady(fixture)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
fixture?.mock.releaseHeldStream()
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('animates with a steps() transform keyframes animation, one step per frame', async () => {
|
||||
const { page } = fixture
|
||||
await mountSpinner(page)
|
||||
|
||||
const observed = await page.evaluate(strip => {
|
||||
const el = document.querySelector<HTMLElement>(strip)
|
||||
|
||||
if (!el) {
|
||||
throw new Error('no frame strip in the DOM')
|
||||
}
|
||||
|
||||
const style = getComputedStyle(el)
|
||||
const animations = el.getAnimations()
|
||||
|
||||
return {
|
||||
frameCount: el.querySelectorAll('.glyph-spinner__frame').length,
|
||||
timingFunction: style.animationTimingFunction,
|
||||
iterationCount: style.animationIterationCount,
|
||||
durationMs: animations[0]?.effect?.getTiming().duration ?? null,
|
||||
names: animations.map(a => (a as CSSAnimation).animationName),
|
||||
// A percentage translate makes the animation layout-dependent, which
|
||||
// Chromium refuses to composite. Read the engine's own keyframes: a
|
||||
// revert to translateY(-100%) shows up here, while the computed
|
||||
// `style.transform` always serializes to a matrix and can't tell.
|
||||
travel: ((animations[0]?.effect as KeyframeEffect | undefined)?.getKeyframes() ?? [])
|
||||
.map(k => String((k as Keyframe & { transform?: string }).transform ?? ''))
|
||||
.join(' | ')
|
||||
}
|
||||
}, STRIP)
|
||||
|
||||
// The strip carries every frame; `steps(N)` parks on each one in turn.
|
||||
expect(observed.frameCount).toBeGreaterThan(1)
|
||||
// Chromium has serialized jump-end as both `steps(N)` and `steps(N, end)`.
|
||||
expect(observed.timingFunction).toMatch(new RegExp(`^steps\\(${observed.frameCount}\\b`))
|
||||
expect(observed.iterationCount).toBe('infinite')
|
||||
expect(observed.names).toContain('glyph-spinner-advance')
|
||||
// One full cycle is frames x interval, so the duration must be a positive
|
||||
// multiple of the frame count — not the single-frame interval.
|
||||
expect(observed.durationMs).toBeGreaterThan(0)
|
||||
// Length-typed travel, never a percentage: `translateY(-100%)` would keep
|
||||
// the animation off the compositor. Chromium has serialized the resolved
|
||||
// keyframe both as the authored `calc(...)` and as an absolute `...px`
|
||||
// length depending on version — accept any length, reject percentages.
|
||||
expect(observed.travel).toMatch(/calc\(|px\)/)
|
||||
expect(observed.travel).not.toContain('%')
|
||||
})
|
||||
|
||||
test('is promoted to a layer while running, and neither animates nor holds a layer when parked', async () => {
|
||||
const { page } = fixture
|
||||
await mountSpinner(page)
|
||||
|
||||
const running = await page.evaluate(strip => {
|
||||
const el = document.querySelector<HTMLElement>(strip)!
|
||||
|
||||
return {
|
||||
playState: getComputedStyle(el).animationPlayState,
|
||||
willChange: getComputedStyle(el).willChange
|
||||
}
|
||||
}, STRIP)
|
||||
|
||||
expect(running.playState).toBe('running')
|
||||
// Scoped to active spinners — a permanently promoted layer per parked
|
||||
// spinner is pure memory at fan-out breadth.
|
||||
expect(running.willChange).toBe('transform')
|
||||
|
||||
// 1. The per-spinner gate: a kept-alive but inactive pane, or an explicit
|
||||
// `paused` prop (ChatSwapOverlay's fade-out).
|
||||
const parked = await page.evaluate(strip => {
|
||||
const el = document.querySelector<HTMLElement>(strip)!
|
||||
const viewport = el.closest<HTMLElement>('.glyph-spinner')!
|
||||
const previous = viewport.getAttribute('data-paused')
|
||||
|
||||
viewport.setAttribute('data-paused', 'true')
|
||||
|
||||
const state = {
|
||||
playState: getComputedStyle(el).animationPlayState,
|
||||
willChange: getComputedStyle(el).willChange
|
||||
}
|
||||
|
||||
if (previous === null) {
|
||||
viewport.removeAttribute('data-paused')
|
||||
} else {
|
||||
viewport.setAttribute('data-paused', previous)
|
||||
}
|
||||
|
||||
return state
|
||||
}, STRIP)
|
||||
|
||||
expect(parked.playState).toBe('paused')
|
||||
expect(parked.willChange).toBe('auto')
|
||||
|
||||
// 2. The global gate: window blur / minimize / document-hidden, which
|
||||
// main.tsx drives by arming this attribute on the root. The strip must
|
||||
// be named in that rule, or every spinner keeps animating behind an
|
||||
// inactive window — the CPU burn the original ticker's pause
|
||||
// controller existed to avoid.
|
||||
const globallyPaused = await page.evaluate(strip => {
|
||||
const root = document.documentElement
|
||||
const had = root.hasAttribute('data-renderer-animations-paused')
|
||||
|
||||
root.setAttribute('data-renderer-animations-paused', '')
|
||||
const playState = getComputedStyle(document.querySelector<HTMLElement>(strip)!).animationPlayState
|
||||
|
||||
if (!had) {
|
||||
root.removeAttribute('data-renderer-animations-paused')
|
||||
}
|
||||
|
||||
return playState
|
||||
}, STRIP)
|
||||
|
||||
expect(globallyPaused).toBe('paused')
|
||||
})
|
||||
|
||||
test('advances in discrete frames and creates no timer-driven DOM churn', async () => {
|
||||
const { page } = fixture
|
||||
await mountSpinner(page)
|
||||
|
||||
// Sample the resolved transform across one full cycle. A steps() animation
|
||||
// holds each value for a whole interval and jumps between them, so the
|
||||
// distinct values it visits must be bounded by the frame count — a linear
|
||||
// animation would produce a new value on every sample.
|
||||
const sampled = await page.evaluate(async strip => {
|
||||
const el = document.querySelector<HTMLElement>(strip)!
|
||||
const frames = el.querySelectorAll('.glyph-spinner__frame').length
|
||||
const duration = Number(el.getAnimations()[0]?.effect?.getTiming().duration ?? 0)
|
||||
const seen = new Set<string>()
|
||||
const textAtStart = el.textContent
|
||||
|
||||
const deadline = performance.now() + duration
|
||||
|
||||
while (performance.now() < deadline) {
|
||||
seen.add(getComputedStyle(el).transform)
|
||||
await new Promise(resolve => requestAnimationFrame(() => resolve(null)))
|
||||
}
|
||||
|
||||
return { distinct: seen.size, frames, textUnchanged: el.textContent === textAtStart }
|
||||
}, STRIP)
|
||||
|
||||
expect(sampled.distinct).toBeGreaterThan(1)
|
||||
expect(sampled.distinct).toBeLessThanOrEqual(sampled.frames + 1)
|
||||
// The old implementation rewrote textContent ~12x/second. Nothing may
|
||||
// mutate the DOM as this animates — that mutation is the whole incident.
|
||||
expect(sampled.textUnchanged).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { expect, test } from './test'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
async function openBots(page: MockBackendFixture['page']): Promise<void> {
|
||||
const tab = page.getByRole('button', { name: 'Bots', exact: true }).or(page.getByRole('tab', { name: 'Bots', exact: true })).first()
|
||||
await tab.click()
|
||||
await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
|
||||
}
|
||||
|
||||
async function createAgent(page: MockBackendFixture['page'], name: string, title: string): Promise<void> {
|
||||
await page.getByRole('button', { name: 'New bot or group chat' }).click()
|
||||
await page.getByRole('menuitem', { name: 'New Bot' }).click()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'New Bot' })
|
||||
await dialog.getByPlaceholder('inbox-triage').fill(name)
|
||||
await dialog.getByPlaceholder('Inbox Triage').fill(title)
|
||||
await dialog.getByRole('button', { name: 'Create Bot' }).click()
|
||||
await expect(dialog).toBeHidden({ timeout: 30_000 })
|
||||
await expect(page.getByRole('button', { name: new RegExp(`^${title}\\b`) }).first()).toBeVisible({ timeout: 30_000 })
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('local bot replaces an open group main workspace', async () => {
|
||||
test.setTimeout(240_000)
|
||||
const page = fixture!.page
|
||||
|
||||
await openBots(page)
|
||||
await createAgent(page, 'programmer', 'Programmer')
|
||||
await createAgent(page, 'reviewer', 'Reviewer')
|
||||
|
||||
await page.getByRole('button', { name: 'New bot or group chat' }).click()
|
||||
await page.getByRole('menuitem', { name: 'New Group Chat' }).click()
|
||||
|
||||
const dialog = page.getByRole('dialog', { name: 'New Group Chat' })
|
||||
|
||||
for (const title of ['Programmer', 'Reviewer']) {
|
||||
await dialog.getByText(title, { exact: true }).locator('xpath=ancestor::label').getByRole('checkbox').click()
|
||||
}
|
||||
|
||||
await dialog.getByRole('textbox', { name: 'Group name' }).fill('Programmer, Reviewer')
|
||||
await dialog.getByRole('button', { name: 'Create Group (2)' }).click()
|
||||
|
||||
const groupTab = page.getByRole('tab', { name: /Programmer, Reviewer Close/ })
|
||||
const groupComposer = page.getByRole('textbox', { name: 'Message Programmer, Reviewer' }).filter({ visible: true })
|
||||
await expect(groupTab).toBeVisible({ timeout: 20_000 })
|
||||
await expect(groupTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(groupComposer).toBeVisible()
|
||||
|
||||
const programmer = page.getByRole('button', { name: /^Programmer\b/ }).filter({ visible: true }).first()
|
||||
await programmer.click()
|
||||
|
||||
// The bot's canonical chat opens INTO the main workspace pane (post
|
||||
// design-system rework); as the lone pane in the zone it renders chromeless
|
||||
// — no "Bot Chat" tab exists until a second pane joins the strip. The
|
||||
// handoff is observed by the group surfaces leaving and the bot's chat
|
||||
// (here a fresh one: its empty-state splash asks for a first message)
|
||||
// taking the main workspace. The first open also spawns the bot's own
|
||||
// backend, so give the "Loading session" phase a real chance to clear.
|
||||
await expect(page.getByText('Say something to get started.').filter({ visible: true })).toBeVisible({
|
||||
timeout: 120_000
|
||||
})
|
||||
await expect(groupTab).toHaveCount(0)
|
||||
await expect(groupComposer).toHaveCount(0)
|
||||
// No "Waking up…" assertion: the mock backend can keep a bot's wake notice
|
||||
// around indefinitely (see bot-mode-row-click-mirrors-registry's settle()),
|
||||
// so its presence no longer distinguishes a stranded handoff. The splash
|
||||
// and composer above are the proof the bot's chat took the workspace.
|
||||
await expect(page.locator('[data-slot="composer-root"] [contenteditable="true"]').filter({ visible: true }).first()).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* E2E regression: desktop resume must hide agent-only transcript rows.
|
||||
*
|
||||
* Compaction handoffs are active user rows because the model needs them for
|
||||
* context continuity. They are not authored chat content, so the desktop
|
||||
* transcript must never display them after a real compressor-generated resume.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
type MockBackendFixture,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig,
|
||||
} from './fixtures'
|
||||
import {
|
||||
MOCK_REPLY,
|
||||
startMockServer,
|
||||
VERIFICATION_STOP_TEXT,
|
||||
VERIFICATION_STOP_TRIGGER,
|
||||
} from './mock-server'
|
||||
import { RealSessionBuilder } from './real-session-builder'
|
||||
import { expect, test } from './test'
|
||||
|
||||
const SESSION_TITLE = 'E2E Hidden History Messages'
|
||||
const VISIBLE_USER_TEXT = 'E2E_VISIBLE_USER_HISTORY'
|
||||
const VISIBLE_POST_COMPACTION_TEXT = 'E2E_VISIBLE_POST_COMPACTION_HISTORY'
|
||||
const COMPACTION_TRIGGER_PADDING = ' force real context compression'.repeat(600)
|
||||
|
||||
async function setupSeededMockBackend(): Promise<MockBackendFixture> {
|
||||
const mock = await startMockServer()
|
||||
const sandbox = createSandbox('hidden-history')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
fs.appendFileSync(
|
||||
path.join(sandbox.hermesHome, 'config.yaml'),
|
||||
'\ncompression:\n threshold_tokens: 1\n',
|
||||
'utf8',
|
||||
)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
|
||||
|
||||
try {
|
||||
await builder.createSession({
|
||||
title: SESSION_TITLE,
|
||||
turns: [
|
||||
`${VISIBLE_USER_TEXT}${COMPACTION_TRIGGER_PADDING}`,
|
||||
VISIBLE_POST_COMPACTION_TEXT,
|
||||
],
|
||||
})
|
||||
} finally {
|
||||
await builder.close()
|
||||
}
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('resume hides real context-compaction handoffs', async ({}, testInfo) => {
|
||||
const fixture = await setupSeededMockBackend()
|
||||
|
||||
try {
|
||||
const { page } = fixture
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
const sessionRow = page
|
||||
.locator('[data-slot="sidebar"] button')
|
||||
.filter({ hasText: SESSION_TITLE })
|
||||
.first()
|
||||
|
||||
await sessionRow.click()
|
||||
|
||||
const transcript = page.locator('[data-slot="aui_thread-viewport"]')
|
||||
await expect(transcript).toContainText(VISIBLE_USER_TEXT)
|
||||
await expect(transcript).toContainText(VISIBLE_POST_COMPACTION_TEXT)
|
||||
await expect(transcript).toContainText(MOCK_REPLY)
|
||||
await expect(transcript).not.toContainText('[CONTEXT COMPACTION — REFERENCE ONLY]')
|
||||
await page.screenshot({ path: testInfo.outputPath('hidden-history-resume.png') })
|
||||
} finally {
|
||||
await fixture.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('live verify-on-stop continuations stay out of the transcript', async ({}, testInfo) => {
|
||||
const sandbox = createSandbox('live-verification-nudge')
|
||||
const projectRoot = path.join(sandbox.root, 'project')
|
||||
const changedFile = path.join(projectRoot, 'e2e-verification-target.py')
|
||||
fs.mkdirSync(projectRoot)
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, 'pyproject.toml'),
|
||||
'[project]\nname = "e2e-verification-project"\nversion = "0.0.0"\n',
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const mock = await startMockServer({ verificationWritePath: changedFile })
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
fs.appendFileSync(path.join(sandbox.hermesHome, 'config.yaml'), '\nagent:\n verify_on_stop: true\n', 'utf8')
|
||||
// Auto session titling (feat f726090d48) fires an auxiliary title_generation
|
||||
// LLM call whose user snippet CONTAINS the trigger keyword, so the mock's
|
||||
// isVerificationStopTrigger matches it and the title call steals a scripted
|
||||
// verify-on-stop turn (the transcript then ends on 'The code edit is
|
||||
// complete.' instead of the exhausted-verifier final). Disable the
|
||||
// model-backed title upgrade so script indices track real chat turns.
|
||||
fs.appendFileSync(
|
||||
path.join(sandbox.hermesHome, 'config.yaml'),
|
||||
'\nauxiliary:\n title_generation:\n enabled: false\n',
|
||||
'utf8',
|
||||
)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
|
||||
const fixture: MockBackendFixture = {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type(VERIFICATION_STOP_TRIGGER)
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
const transcript = page.locator('[data-slot="aui_thread-viewport"]')
|
||||
await expect(transcript).toContainText(VERIFICATION_STOP_TEXT, { timeout: 60_000 })
|
||||
await expect.poll(
|
||||
() => mock.receivedPrompts.some(prompt => prompt.includes('[System: You edited code in this turn')),
|
||||
{ timeout: 30_000 },
|
||||
).toBe(true)
|
||||
expect(fs.existsSync(changedFile), 'The scripted write_file call should edit only the sandbox project').toBe(true)
|
||||
await expect(transcript).not.toContainText('[System: You edited code in this turn')
|
||||
await page.screenshot({ path: testInfo.outputPath('live-verification-nudge.png') })
|
||||
} finally {
|
||||
await fixture.cleanup()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Regression coverage for an attached image in a durable session. The gateway
|
||||
* persists the turn, the builder exits, and desktop renders it from SessionDB
|
||||
* for the first time — the "quit and relaunch" case, where the transcript used
|
||||
* to come back as vision-enrichment prose instead of a thumbnail.
|
||||
*
|
||||
* The fixture pins `image_input_mode: native` because that is the majority
|
||||
* routing path (any vision-capable model) and the one where a text-only
|
||||
* persist override is silently dropped. The image also sits behind directory
|
||||
* and file names containing spaces, mirroring the macOS composer's
|
||||
* `~/Library/Application Support/...` staging path.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
type Sandbox,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig,
|
||||
} from './fixtures'
|
||||
import { type MockServer, startMockServer } from './mock-server'
|
||||
import { RealSessionBuilder } from './real-session-builder'
|
||||
import { type ElectronApplication, expect, type Page, test } from './test'
|
||||
|
||||
// The builder-provided title now labels the sidebar row directly (seeded
|
||||
// sessions no longer fall back to the first-user-message preview).
|
||||
const SESSION_TITLE = 'E2E attached image session'
|
||||
const CAPTION = 'E2E attached image must survive a relaunch'
|
||||
const IMAGE_DIR = 'Application Support/e2e shots'
|
||||
const IMAGE_NAME = 'e2e capture.png'
|
||||
const NATIVE_IMAGE_CONFIG = 'agent:\n image_input_mode: native'
|
||||
|
||||
/** A 160x100 framed magenta block — small, but visible in the screenshots. */
|
||||
const PNG_BASE64 =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAKAAAABkCAIAAACO1KzYAAAA30lEQVR42u3dwQ2AIBAAQTAWAx1iBXYI7diCuWhEMvP2dZsj+CL30hLr2oxAYARGYARGYARGYIERGIH53n7nozpOk5rQqIcNdkQjMAIjMNPeomP3N54V+5exwY5oBEZgBEZgBEZggREYgREYgREYgQVGYARGYARGYARGYIERGIERGIERGIEFRmAERmAERmAEFhiBERiBERiBERiBBUZgBEZgBEZgBBYYgREYgREYgRFYYCMQGIERmPSj94Njb9ligxEYgRFYYJaQe2mmYIMRGIERGIERGIEFRmAERmDedAFtjAtAGWDnoAAAAABJRU5ErkJggg=='
|
||||
|
||||
interface SeededFixture {
|
||||
app: ElectronApplication
|
||||
mock: MockServer
|
||||
page: Page
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
function writeImage(sandbox: Sandbox): string {
|
||||
const dir = path.join(sandbox.root, IMAGE_DIR)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
|
||||
const imagePath = path.join(dir, IMAGE_NAME)
|
||||
fs.writeFileSync(imagePath, Buffer.from(PNG_BASE64, 'base64'))
|
||||
|
||||
return imagePath
|
||||
}
|
||||
|
||||
async function setupSeededDesktop(): Promise<SeededFixture> {
|
||||
const mock = await startMockServer()
|
||||
const sandbox = createSandbox('image-attachment')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url, undefined, NATIVE_IMAGE_CONFIG)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
|
||||
|
||||
try {
|
||||
await builder.createSession({
|
||||
title: SESSION_TITLE,
|
||||
turns: [{ images: [writeImage(sandbox)], text: CAPTION }],
|
||||
})
|
||||
} finally {
|
||||
await builder.close()
|
||||
}
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
|
||||
return {
|
||||
app,
|
||||
mock,
|
||||
page,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function sessionRow(page: Page) {
|
||||
return page.locator('[data-slot="sidebar"] button').filter({ hasText: SESSION_TITLE }).first()
|
||||
}
|
||||
|
||||
// Inactive tabs stay mounted under a data-pane-hidden ancestor. Match the
|
||||
// renderer's keep-alive visibility policy instead of relying on DOM order.
|
||||
const SURFACE = '[data-composer-target]:not([data-pane-hidden] [data-composer-target])'
|
||||
|
||||
function activeViewportText(surfaceSelector: string): string {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
|
||||
return surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? ''
|
||||
}
|
||||
|
||||
async function openSeededSession(page: Page): Promise<void> {
|
||||
const row = sessionRow(page)
|
||||
await row.waitFor({ state: 'visible', timeout: 60_000 })
|
||||
await row.click()
|
||||
await page.waitForFunction(
|
||||
([expected, surfaceSelector]: [string, string]) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const text = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? ''
|
||||
|
||||
return text.includes(expected)
|
||||
},
|
||||
[CAPTION, SURFACE] as [string, string],
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The sidebar "+" opens a NEW TAB beside the current chat instead of replacing
|
||||
* it, so the seeded session stays mounted in its own surface. Assert the new
|
||||
* surface is empty rather than waiting for the old caption to leave the page.
|
||||
*/
|
||||
async function openNewSession(page: Page): Promise<void> {
|
||||
await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
|
||||
await page.waitForFunction(
|
||||
([expected, surfaceSelector]: [string, string]) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const text = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? ''
|
||||
|
||||
return surfaces.length > 0 && !text.includes(expected)
|
||||
},
|
||||
[CAPTION, SURFACE] as [string, string],
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function transcriptText(page: Page): Promise<string> {
|
||||
return page.evaluate(activeViewportText, SURFACE)
|
||||
}
|
||||
|
||||
async function assertRendersThumbnail(page: Page, label: string): Promise<void> {
|
||||
const thumbnail = page.locator('[data-slot="aui_directive-image"] img')
|
||||
await expect(thumbnail, `${label}: the attachment should render as an image`).toHaveCount(1)
|
||||
await expect(thumbnail, `${label}: the thumbnail should resolve off disk`).toHaveAttribute('src', /^data:image\//)
|
||||
|
||||
const text = await transcriptText(page)
|
||||
expect(text, `${label}: the caption should survive alongside the image`).toContain(CAPTION)
|
||||
// A broken ref falls back to a chip whose label leaks the path, and a
|
||||
// flattened multimodal turn leaves the agent's placeholder behind.
|
||||
expect(text, `${label}: the raw image path should not leak into the transcript`).not.toContain(IMAGE_NAME)
|
||||
expect(text, `${label}: the image directive should not render literally`).not.toContain('@image:')
|
||||
expect(text, `${label}: the flattening placeholder should not render`).not.toContain('[screenshot]')
|
||||
}
|
||||
|
||||
test.describe('attached image resume', () => {
|
||||
let fixture: SeededFixture | null = null
|
||||
|
||||
test.afterEach(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('renders a persisted attachment as a thumbnail on first open and after a cold reload', async ({}, testInfo) => {
|
||||
// Seeding through the real gateway plus two full app boots does not fit the
|
||||
// default per-test budget on a cold runner.
|
||||
test.slow()
|
||||
|
||||
fixture = await setupSeededDesktop()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
// The sidebar labels a seeded session by its title. Whatever the label
|
||||
// source, an attachment directive must never leak into it as a file path.
|
||||
const row = sessionRow(fixture.page)
|
||||
await row.waitFor({ state: 'visible', timeout: 60_000 })
|
||||
|
||||
const label = (await row.textContent())?.trim() ?? ''
|
||||
expect(label.startsWith(SESSION_TITLE), `sidebar label should open with the title: ${label}`).toBe(true)
|
||||
expect(label, `sidebar label should not leak the image path: ${label}`).not.toContain(IMAGE_NAME)
|
||||
expect(label, `sidebar label should not render the directive: ${label}`).not.toContain('@image:')
|
||||
|
||||
await openSeededSession(fixture.page)
|
||||
await assertRendersThumbnail(fixture.page, 'first open')
|
||||
await fixture.page.screenshot({ path: testInfo.outputPath('attachment-first-open.png') })
|
||||
|
||||
// A reload drops every cached attachment ref, so the transcript has to come
|
||||
// back from the persisted turn alone.
|
||||
await fixture.page.reload()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
await openNewSession(fixture.page)
|
||||
|
||||
await openSeededSession(fixture.page)
|
||||
await assertRendersThumbnail(fixture.page, 'cold reload')
|
||||
await fixture.page.screenshot({ path: testInfo.outputPath('attachment-cold-reload.png') })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* E2E test for the interim-assistant-message preservation fix (#65919).
|
||||
*
|
||||
* Reproduces the bug across all three layers (agent core → tui_gateway →
|
||||
* desktop renderer): when the agent emits assistant text alongside a tool
|
||||
* call, then completes the turn with a *different* final answer, the
|
||||
* interim text must survive in the transcript — not be wiped when
|
||||
* message.complete replaces the streaming bubble.
|
||||
*
|
||||
* The mock server walks through a multi-turn script when it sees the
|
||||
* trigger keyword:
|
||||
*
|
||||
* Turn 1: "Let me start by planning the approach." + todo tool_call
|
||||
* Turn 2: "Now checking the details before answering." + todo tool_call
|
||||
* Turn 3: (no text) + todo tool_call → NO interim (no visible text)
|
||||
* Turn 4: "Found something interesting worth noting." + todo tool_call
|
||||
* Turn 5: "All done! Here is the complete summary..." (final, stop)
|
||||
*
|
||||
* Two describe blocks exercise the config flag both ways:
|
||||
*
|
||||
* display.interim_assistant_messages: true (default)
|
||||
* → ALL interim texts AND the final text must be visible in the
|
||||
* settled transcript.
|
||||
*
|
||||
* display.interim_assistant_messages: false
|
||||
* → no message.interim events are emitted, so no sealed interim bubbles
|
||||
* are created while streaming. Since the post-turn stored-history
|
||||
* reconcile (sessions.changed → reconcileActiveTranscript, commit
|
||||
* 1a2b0ca8cb) converges the visible transcript to the persisted
|
||||
* transcript — which has ALWAYS contained the mid-turn commentary as
|
||||
* real assistant rows (that is what a resume shows, flag or no flag) —
|
||||
* the settled DOM shows the whole turn as ONE assistant message
|
||||
* containing commentary + final. The flag governs live sealing only.
|
||||
* The test pins that converged single-message shape: every text
|
||||
* appears exactly once, inside a single assistant message root.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, type Page, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { INTERIM_TEXTS, restartMockServer } from './mock-server'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Auto session titling (feat f726090d48, 2026-08-08) issues an auxiliary
|
||||
* `title_generation` LLM call against the SAME provider as the chat turn.
|
||||
* The mock server counts every completion request as a script turn, so the
|
||||
* title call races the chat turn and steals a scripted interim turn (the
|
||||
* stolen turn's text then never streams to the transcript). Disable the
|
||||
* model-backed title upgrade — the instant derived title needs no LLM call —
|
||||
* so the mock's script indices line up with real chat turns again.
|
||||
*/
|
||||
const DISABLE_AUTO_TITLE = 'auxiliary:\n title_generation:\n enabled: false'
|
||||
|
||||
/** Unique trigger keyword the mock server detects to switch to the script. */
|
||||
const TRIGGER = 'E2E_INTERIM_TRIGGER'
|
||||
|
||||
/**
|
||||
* Send a message and wait for BOTH the user's message and the agent's
|
||||
* final response to appear in the transcript. Returns when the final text
|
||||
* is visible, which means message.complete has fired and the transcript
|
||||
* has settled.
|
||||
*/
|
||||
async function sendInterimMessage(page: Page): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await composer.click()
|
||||
await composer.type(TRIGGER, { delay: 20 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Wait for the user's trigger message to appear.
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('E2E_INTERIM_TRIGGER'),
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
// Wait for the agent's FINAL response (last turn). This means
|
||||
// message.complete has fired and the transcript is settled.
|
||||
await page.waitForFunction(
|
||||
(finalText) => (document.body.textContent ?? '').includes(finalText),
|
||||
INTERIM_TEXTS.finalText,
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
|
||||
// Give the renderer a moment to settle any final state updates
|
||||
// (hydration, stored-history reconcile, session refresh) before asserting.
|
||||
await page.waitForTimeout(2000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many times `text` appears as distinct text in the chat transcript
|
||||
* (excluding the session sidebar, whose session-preview label shows the
|
||||
* first streamed text as a title).
|
||||
*
|
||||
* The desktop app renders the transcript inside a
|
||||
* `[data-slot="aui_thread-viewport"]` container (from @assistant-ui/react).
|
||||
* The session sidebar's preview labels live outside that container, so
|
||||
* scoping the DOM walk to the viewport cleanly excludes them.
|
||||
*/
|
||||
async function countTranscriptMessagesContaining(page: Page, text: string): Promise<number> {
|
||||
return page.evaluate(
|
||||
(search) => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
|
||||
if (!viewport) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let count = 0
|
||||
|
||||
const walker = document.createTreeWalker(
|
||||
viewport,
|
||||
NodeFilter.SHOW_ELEMENT,
|
||||
{
|
||||
acceptNode: (node) => {
|
||||
const el = node as HTMLElement
|
||||
const directText = el.textContent ?? ''
|
||||
|
||||
if (!directText.includes(search)) {
|
||||
return NodeFilter.FILTER_SKIP
|
||||
}
|
||||
|
||||
// Only count leaf-ish elements to avoid double-counting.
|
||||
const hasChildWithText = Array.from(el.children).some(
|
||||
(child) => (child.textContent ?? '').includes(search),
|
||||
)
|
||||
|
||||
if (hasChildWithText) {
|
||||
return NodeFilter.FILTER_SKIP
|
||||
}
|
||||
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
while (walker.nextNode()) {
|
||||
count++
|
||||
}
|
||||
|
||||
return count
|
||||
},
|
||||
text,
|
||||
)
|
||||
}
|
||||
|
||||
/** Count assistant message roots in the settled transcript. */
|
||||
async function countAssistantMessageRoots(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
|
||||
return viewport
|
||||
? viewport.querySelectorAll('[data-slot="aui_assistant-message-root"]').length
|
||||
: 0
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Flag ON: interim_assistant_messages = true (default) ─────────────
|
||||
|
||||
test.describe('interim assistant messages — flag ON (default)', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend({ extraConfig: DISABLE_AUTO_TITLE })
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('all interim texts survive alongside the final response', async () => {
|
||||
const page = fixture.page
|
||||
await sendInterimMessage(page)
|
||||
|
||||
// Every interim text (turns with visible text + tool calls) must be
|
||||
// present in the settled transcript — NOT wiped by message.complete.
|
||||
// (Live, each seals as its own bubble; the post-turn stored-history
|
||||
// reconcile then converges the turn into one assistant message that
|
||||
// still carries all of them.)
|
||||
for (const interimText of INTERIM_TEXTS.interims) {
|
||||
await expect
|
||||
.poll(
|
||||
() => countTranscriptMessagesContaining(page, interimText),
|
||||
{ timeout: 15_000, message: `interim text "${interimText}" should be visible` },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
|
||||
// The final text must also be visible.
|
||||
await expect
|
||||
.poll(
|
||||
() => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText),
|
||||
{ timeout: 15_000, message: 'final text should be visible' },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
|
||||
// No duplicates: the reconcile must CONVERGE (replace the sealed live
|
||||
// bubbles), never render a stored copy alongside a live one.
|
||||
for (const text of [...INTERIM_TEXTS.interims, INTERIM_TEXTS.finalText]) {
|
||||
const count = await countTranscriptMessagesContaining(page, text)
|
||||
expect(count, `"${text}" must not be duplicated after reconcile`).toBe(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Flag OFF: interim_assistant_messages = false ────────────────────
|
||||
|
||||
test.describe('interim assistant messages — flag OFF', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend({
|
||||
extraDisplayConfig: ' interim_assistant_messages: false',
|
||||
extraConfig: DISABLE_AUTO_TITLE,
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('settled transcript converges to stored history as a single turn message', async () => {
|
||||
const page = fixture.page
|
||||
await sendInterimMessage(page)
|
||||
|
||||
// The final text must be visible.
|
||||
await expect
|
||||
.poll(
|
||||
() => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText),
|
||||
{ timeout: 15_000, message: 'final text should be visible' },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
|
||||
// With the flag off, the tui_gateway never installs
|
||||
// interim_assistant_callback, so no message.interim events fire and no
|
||||
// sealed interim bubbles are created while streaming. After
|
||||
// message.complete, the stored-history reconcile (sessions.changed →
|
||||
// reconcileActiveTranscript) converges the view to the persisted
|
||||
// transcript, which contains the mid-turn commentary as real assistant
|
||||
// rows — exactly what a resume of this session would show. Pin that
|
||||
// converged shape: ONE assistant message root for the whole turn…
|
||||
await expect
|
||||
.poll(
|
||||
() => countAssistantMessageRoots(page),
|
||||
{ timeout: 15_000, message: 'the settled turn should render as one assistant message' },
|
||||
)
|
||||
.toBe(1)
|
||||
|
||||
// …containing every commentary text and the final text exactly once.
|
||||
for (const text of [...INTERIM_TEXTS.interims, INTERIM_TEXTS.finalText]) {
|
||||
await expect
|
||||
.poll(
|
||||
() => countTranscriptMessagesContaining(page, text),
|
||||
{ timeout: 15_000, message: `"${text}" should appear exactly once in the converged turn` },
|
||||
)
|
||||
.toBe(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { type TestInfo } from '@playwright/test'
|
||||
|
||||
import { expect, test, type ElectronApplication, type Page } from './test'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
type Sandbox,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig,
|
||||
} from './fixtures'
|
||||
import { MOCK_REPLY, startMockServer, type MockServer, type MockServerOptions } from './mock-server'
|
||||
import { RealSessionBuilder } from './real-session-builder'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const SESSION_TITLE = 'E2E large persisted session'
|
||||
const EXPECTED_TEXT = 'E2E persisted user message 52'
|
||||
// The oldest seeded turn (HISTORY_TURNS[0]). The transcript first paints only
|
||||
// the newest turns (FIRST_PAINT_BUDGET) and backfills the rest in a rAF; a
|
||||
// baseline count taken before that backfill sees a clipped transcript and
|
||||
// falsely reports duplicates once the full list mounts. Waiting for this
|
||||
// oldest row means the baseline reflects the fully-mounted transcript.
|
||||
const OLDEST_SEEDED_TEXT = 'E2E persisted user message 0: audit the compatibility matrix'
|
||||
const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume'
|
||||
const HISTORY_TURNS = Array.from(
|
||||
{ length: 27 },
|
||||
(_, index) => `E2E persisted user message ${index * 2}: audit the compatibility matrix`,
|
||||
)
|
||||
|
||||
interface SeededFixture {
|
||||
app: ElectronApplication
|
||||
mock: MockServer
|
||||
mockUrl: string
|
||||
page: Page
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
interface PaintState {
|
||||
bursts: number
|
||||
timeline: Array<{ mutations: number; time: number }>
|
||||
}
|
||||
|
||||
async function setupSeededDesktop(mockServer?: MockServerOptions): Promise<SeededFixture> {
|
||||
const mock = await startMockServer(mockServer)
|
||||
const sandbox = createSandbox('large-session')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
|
||||
try {
|
||||
await builder.createSession({ title: SESSION_TITLE, turns: HISTORY_TURNS })
|
||||
} finally {
|
||||
await builder.close()
|
||||
}
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
|
||||
return {
|
||||
app,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
page,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function sessionRow(page: Page) {
|
||||
return page.locator('[data-slot="sidebar"] button').filter({ hasText: SESSION_TITLE }).first()
|
||||
}
|
||||
|
||||
async function openSeededSession(page: Page): Promise<void> {
|
||||
const row = sessionRow(page)
|
||||
await row.waitFor({ state: 'visible', timeout: 60_000 })
|
||||
await row.click()
|
||||
await page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
EXPECTED_TEXT,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function openNewSession(page: Page): Promise<void> {
|
||||
const button = page.locator('[data-slot="sidebar"] button').filter({ hasText: 'New session' }).first()
|
||||
await button.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await button.click()
|
||||
await page.waitForFunction(
|
||||
expected => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
EXPECTED_TEXT,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function submitPrompt(page: Page, prompt: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.type(prompt, { delay: 2 })
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
prompt,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function startPaintObserver(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
const state = { bursts: 0, timeline: [] as Array<{ mutations: number; time: number }> }
|
||||
;(window as Window & { __largeSessionPaints?: typeof state }).__largeSessionPaints = state
|
||||
if (!viewport) return
|
||||
|
||||
let additions = 0
|
||||
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
||||
new MutationObserver(records => {
|
||||
additions += records.reduce(
|
||||
(count, record) => count + (record.type === 'childList' && record.addedNodes.length > 0 ? 1 : 0),
|
||||
0,
|
||||
)
|
||||
if (additions === 0) return
|
||||
if (flushTimer) clearTimeout(flushTimer)
|
||||
flushTimer = setTimeout(() => {
|
||||
state.bursts += 1
|
||||
state.timeline.push({ mutations: additions, time: Date.now() })
|
||||
additions = 0
|
||||
}, 30)
|
||||
}).observe(viewport, { childList: true, subtree: true })
|
||||
})
|
||||
}
|
||||
|
||||
async function paintState(page: Page): Promise<PaintState> {
|
||||
const state = await page.evaluate(() => (window as Window & { __largeSessionPaints?: PaintState }).__largeSessionPaints)
|
||||
expect(state, 'paint observer should attach to the thread viewport').toBeDefined()
|
||||
return state!
|
||||
}
|
||||
|
||||
async function textNodeOccurrences(page: Page, expected: string): Promise<number> {
|
||||
return page.evaluate(text => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return 0
|
||||
|
||||
const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT)
|
||||
let count = 0
|
||||
while (walker.nextNode()) {
|
||||
if (walker.currentNode.textContent?.includes(text)) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}, expected)
|
||||
}
|
||||
|
||||
async function reloadIntoColdRenderer(fixture: SeededFixture): Promise<void> {
|
||||
await fixture.page.reload()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
await openNewSession(fixture.page)
|
||||
}
|
||||
|
||||
async function assertUnchangedResume(page: Page, testInfo: TestInfo): Promise<void> {
|
||||
await openSeededSession(page)
|
||||
await page.waitForTimeout(1_000)
|
||||
await page.screenshot({ path: testInfo.outputPath('unchanged-session-resume.png'), fullPage: false })
|
||||
|
||||
const paints = await paintState(page)
|
||||
expect(await textNodeOccurrences(page, EXPECTED_TEXT), 'the resumed user message should appear once').toBe(1)
|
||||
// A warm session first restores its retained view, then reconciles it with the
|
||||
// authoritative transcript. That is bounded at two builds; a third paint was
|
||||
// the old eager-prefetch + runtime-rebuild regression. A cold restore has one.
|
||||
expect(paints.bursts, `unexpected transcript paint count: ${JSON.stringify(paints.timeline)}`).toBeLessThanOrEqual(2)
|
||||
}
|
||||
|
||||
test.describe('large session resume', () => {
|
||||
let fixture: SeededFixture | null = null
|
||||
|
||||
test.afterEach(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('cold resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => {
|
||||
fixture = await setupSeededDesktop()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
await startPaintObserver(fixture.page)
|
||||
await assertUnchangedResume(fixture.page, testInfo)
|
||||
})
|
||||
|
||||
test('fast resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => {
|
||||
// Known RED: a rapid warm resume rebuilds the transcript three times
|
||||
// (28 → 53 → 53 DOM additions) instead of the two-paint budget. Keep the
|
||||
// regression visible without making unrelated desktop work fail CI.
|
||||
test.fixme(true, 'Fast warm resume has an unresolved third transcript rebuild')
|
||||
|
||||
fixture = await setupSeededDesktop()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
await openSeededSession(fixture.page)
|
||||
await openNewSession(fixture.page)
|
||||
await startPaintObserver(fixture.page)
|
||||
await assertUnchangedResume(fixture.page, testInfo)
|
||||
})
|
||||
|
||||
for (const resumeKind of ['fast', 'cold'] as const) {
|
||||
test(`${resumeKind} resume keeps background inference attached without duplicate messages`, async ({}, testInfo) => {
|
||||
fixture = await setupSeededDesktop({ holdFirstStreamForPrompt: BACKGROUND_PROMPT })
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
await openSeededSession(fixture.page)
|
||||
// The transcript first paints only the newest turns (FIRST_PAINT_BUDGET)
|
||||
// and backfills older turns in a rAF. Wait for the oldest seeded row to
|
||||
// mount before taking the baseline so it reflects the full transcript —
|
||||
// otherwise a clipped baseline makes the backfilled rows look like
|
||||
// duplicates of the completed reply.
|
||||
await fixture.page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
OLDEST_SEEDED_TEXT,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
const initialMockReplyCount = await textNodeOccurrences(fixture.page, MOCK_REPLY)
|
||||
await submitPrompt(fixture.page, BACKGROUND_PROMPT)
|
||||
await fixture.mock.waitForHeldStream()
|
||||
await openNewSession(fixture.page)
|
||||
|
||||
if (resumeKind === 'cold') {
|
||||
await reloadIntoColdRenderer(fixture)
|
||||
}
|
||||
|
||||
await openSeededSession(fixture.page)
|
||||
fixture.mock.releaseHeldStream()
|
||||
await fixture.page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
MOCK_REPLY,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
await fixture.page.waitForTimeout(300)
|
||||
await fixture.page.screenshot({ path: testInfo.outputPath(`${resumeKind}-background-inference-resume.png`), fullPage: false })
|
||||
|
||||
expect(await textNodeOccurrences(fixture.page, BACKGROUND_PROMPT), 'the running user prompt should appear once').toBe(1)
|
||||
expect(
|
||||
await textNodeOccurrences(fixture.page, MOCK_REPLY),
|
||||
'the completed assistant reply should add exactly one transcript row',
|
||||
).toBe(initialMockReplyCount + 1)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,177 @@
|
||||
import { expect, test } from './test'
|
||||
|
||||
import {
|
||||
PACKAGED_BINARY_PATH,
|
||||
type PackagedAppFixture,
|
||||
packagedBinaryExists,
|
||||
setupPackagedApp,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
/**
|
||||
* E2E smoke tests for the packaged Hermes desktop app.
|
||||
*
|
||||
* Launches the real packaged Electron binary (produced by `npm run pack` →
|
||||
* `electron-builder --dir`) with BOOT_FAKE=1 and full sandbox isolation
|
||||
* (credential stripping, isolated HERMES_HOME + userData, unique app name).
|
||||
*
|
||||
* Skips if the packaged binary doesn't exist — run `npm run pack` first.
|
||||
*/
|
||||
|
||||
let fixture: PackagedAppFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.skip(
|
||||
!packagedBinaryExists(),
|
||||
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
|
||||
)
|
||||
|
||||
fixture = await setupPackagedApp()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('window opens with the Hermes title', async () => {
|
||||
const title = await fixture!.page.title()
|
||||
expect(title).toContain('Hermes')
|
||||
})
|
||||
|
||||
test('renderer loads and shows DOM content', async () => {
|
||||
const page = fixture!.page
|
||||
await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 })
|
||||
const childCount = await page.locator('#root > *').count()
|
||||
expect(childCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('boots to the app UI, not the QueryClient error boundary (#95560)', async () => {
|
||||
const page = fixture!.page
|
||||
await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 })
|
||||
|
||||
// Wait until the root has real content (boot overlay fades, app paints) —
|
||||
// the error boundary also paints, so assert on its absence explicitly.
|
||||
await page.waitForFunction(
|
||||
() => (document.getElementById('root')?.textContent ?? '').trim().length > 0,
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
|
||||
const text = await page.locator('#root').textContent()
|
||||
// The #95560 crash: a duplicate @tanstack/react-query runtime made the
|
||||
// QueryClientProvider's context invisible to useQuery, so the app hit the
|
||||
// error boundary at launch. Neither the boundary headline nor the throw
|
||||
// message may appear on a healthy boot.
|
||||
expect(text).not.toContain('No QueryClient set')
|
||||
expect(text).not.toContain('Something broke in the interface')
|
||||
})
|
||||
|
||||
test('HUD composer remains fully inside the transparent window', async () => {
|
||||
const hudPagePromise = fixture!.app.waitForEvent('window')
|
||||
|
||||
await fixture!.page.evaluate(() =>
|
||||
(window as typeof window & {
|
||||
hermesDesktop?: { hud?: { open: (options: { sessionId: null }) => Promise<void> } }
|
||||
}).hermesDesktop?.hud?.open({ sessionId: null })
|
||||
)
|
||||
|
||||
const hudPage = await hudPagePromise
|
||||
await hudPage.waitForSelector('[data-slot="composer-rich-input"]', { state: 'visible' })
|
||||
|
||||
const geometry = await hudPage.evaluate(() => {
|
||||
const dock = document.querySelector<HTMLElement>('[data-slot="composer-dock"]')
|
||||
const input = document.querySelector<HTMLElement>('[data-slot="composer-rich-input"]')
|
||||
|
||||
if (!dock || !input) {
|
||||
throw new Error('HUD composer did not render')
|
||||
}
|
||||
|
||||
const dockRect = dock.getBoundingClientRect()
|
||||
const inputRect = input.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
dockLeft: dockRect.left,
|
||||
dockRight: dockRect.right,
|
||||
dockTop: dockRect.top,
|
||||
dockBottom: dockRect.bottom,
|
||||
inputLeft: inputRect.left,
|
||||
inputRight: inputRect.right,
|
||||
inputTop: inputRect.top,
|
||||
inputBottom: inputRect.bottom,
|
||||
// The bug class this guards: a build-time CSS optimization folding the
|
||||
// dock's identity `translate` override into `transform`, leaving
|
||||
// Tailwind's standalone `translate: -50%` live and shifting the dock
|
||||
// half a window off-screen. Surface the computed value so a failure
|
||||
// says WHY the dock moved, not just that it did.
|
||||
dockTranslate: getComputedStyle(dock).translate,
|
||||
}
|
||||
})
|
||||
|
||||
// Horizontal containment — the composer shifted half a window left when the
|
||||
// standalone `translate: -50%` survived optimization (#82214, #82233).
|
||||
expect(geometry.dockLeft).toBeGreaterThanOrEqual(0)
|
||||
expect(geometry.inputLeft).toBeGreaterThanOrEqual(0)
|
||||
expect(geometry.dockRight).toBeLessThanOrEqual(geometry.viewportWidth)
|
||||
expect(geometry.inputRight).toBeLessThanOrEqual(geometry.viewportWidth)
|
||||
|
||||
// Vertical containment — the toolbar/transcript clipping reported on
|
||||
// Windows (#82203) and macOS (#82214) is the same "composer escapes the
|
||||
// window" class on the other axis.
|
||||
expect(geometry.dockTop).toBeGreaterThanOrEqual(0)
|
||||
expect(geometry.inputTop).toBeGreaterThanOrEqual(0)
|
||||
expect(geometry.dockBottom).toBeLessThanOrEqual(geometry.viewportHeight)
|
||||
expect(geometry.inputBottom).toBeLessThanOrEqual(geometry.viewportHeight)
|
||||
|
||||
// The dock's centering translate must be fully neutralized. Any live
|
||||
// percentage translate means the HUD override lost to the app's centering.
|
||||
// (Computed `translate` keeps percentages as-is, so this is assertable;
|
||||
// computed `transform` resolves to a matrix and is covered by the
|
||||
// geometric containment checks above.)
|
||||
expect(geometry.dockTranslate ?? 'none').not.toContain('%')
|
||||
|
||||
await hudPage.close()
|
||||
})
|
||||
|
||||
test('boot progress overlay fades out or shows error state', async () => {
|
||||
const page = fixture!.page
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (!root) {
|
||||
return false
|
||||
}
|
||||
|
||||
const text = root.textContent ?? ''
|
||||
|
||||
// Error path: boot failure overlay renders an error message.
|
||||
if (text.includes('error') || text.includes('Error') || text.includes('failed')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Success path: overlay disappears and the app renders. If there's
|
||||
// no "boot" / "starting" / "installing" text visible, boot has
|
||||
// completed (either to the main UI or to onboarding).
|
||||
const bootIndicators = ['starting', 'resolving', 'spawning', 'waiting', 'installing']
|
||||
const lower = text.toLowerCase()
|
||||
|
||||
return !bootIndicators.some((word) => lower.includes(word))
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
})
|
||||
|
||||
test('can capture a screenshot for the CI artifact', async () => {
|
||||
if (!fixture) {
|
||||
test.skip(true, 'Previous test failed — no app running')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Visual snapshot — won't fail on diff, just logs + generates diff image
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'packaged-app-booted', timeout: 10_000, app: fixture!.app })
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* E2E tests asserting the mock backend gets the app past the setup/onboarding
|
||||
* screen.
|
||||
*
|
||||
* The mock backend fixture writes a config.yaml with a pre-configured mock
|
||||
* provider pointing at a mock inference server. When the app boots, the
|
||||
* runtime readiness check should detect the working provider and dismiss the
|
||||
* onboarding overlay — landing straight on the chat UI without ever showing
|
||||
* the "Let's get you setup with Hermes Agent" screen.
|
||||
*
|
||||
* If these tests fail, the mock backend config isn't getting the app past
|
||||
* onboarding — the chat interaction tests (chat.spec.ts) will also fail
|
||||
* because the composer is blocked by the setup overlay.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from './test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture!, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('mock backend gets past setup screen', () => {
|
||||
test('onboarding overlay is not shown', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
// The onboarding overlay renders "Let's get you setup with Hermes Agent"
|
||||
// when the runtime check fails to find a working provider. With the mock
|
||||
// backend configured, the runtime check should pass and the overlay
|
||||
// returns null — this text should NOT be present in the DOM.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const text = document.body.textContent ?? ''
|
||||
|
||||
return !text.includes("Let's get you setup")
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
})
|
||||
|
||||
test('chat composer is visible', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
// The composer (contenteditable div) should be visible and not blocked
|
||||
// by the onboarding overlay. If the first test passed, the overlay is
|
||||
// gone and the composer is the primary interactive surface.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await expect(composer).toBeVisible()
|
||||
})
|
||||
|
||||
test('can type into the composer', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
// If the setup overlay is truly gone, the composer accepts input.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type('hello mock backend', { delay: 20 })
|
||||
|
||||
// Verify the typed text appears in the DOM.
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('hello mock backend'),
|
||||
undefined,
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
})
|
||||
|
||||
test('screenshot shows chat UI without setup screen', async () => {
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'mock-backend-chat-ready', app: fixture!.app })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,984 @@
|
||||
/**
|
||||
* Minimal OpenAI-compatible mock inference server for E2E tests.
|
||||
*
|
||||
* Implements just enough of the /v1/* surface for `hermes serve` to resolve a
|
||||
* provider, list models, and stream a canned chat completion back to the
|
||||
* desktop app — without any real LLM.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /v1/models → { data: [{ id, ... }] }
|
||||
* POST /v1/chat/completions → streaming (SSE) or non-streaming response
|
||||
*
|
||||
* The canned response is a short, deterministic assistant message. Tool-call
|
||||
* requests are not simulated — the E2E tests only need the chat surface to
|
||||
* prove the full boot → gateway → inference → renderer chain works.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs'
|
||||
import http from 'node:http'
|
||||
import type { ServerResponse } from 'node:http'
|
||||
import os from 'node:os'
|
||||
import nodePath from 'node:path'
|
||||
|
||||
/** A canned assistant reply used for every chat completion request. */
|
||||
export const MOCK_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
|
||||
|
||||
export interface MockServerOptions {
|
||||
/** Pause the matching stream after its first token for session-switch E2E coverage. */
|
||||
holdFirstStreamForPrompt?: string
|
||||
/** Pause the first completion whose request JSON contains this text. */
|
||||
holdFirstCompletionContaining?: string
|
||||
/** Absolute sandbox path written by the verify-on-stop scripted tool call. */
|
||||
verificationWritePath?: string
|
||||
/**
|
||||
* Sentinel path that ends the E2E_SIDEBAR_CROSS background process.
|
||||
*
|
||||
* Without it that process is a bare `sleep 5`, which races the agent turn and
|
||||
* the 4s auto-dismiss linger — see `createBackgroundReleaseHandle`. Pass a
|
||||
* handle's `path` to let the test decide when the process exits.
|
||||
*/
|
||||
backgroundReleasePath?: string
|
||||
}
|
||||
|
||||
export interface MockServer {
|
||||
port: number
|
||||
url: string
|
||||
receivedPrompts: string[]
|
||||
waitForHeldStream: () => Promise<void>
|
||||
waitForHeldCompletion: () => Promise<void>
|
||||
releaseHeldStream: () => void
|
||||
heldCompletionCount: () => number
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
// ─── Multi-turn interim script ─────────────────────────────────────────
|
||||
//
|
||||
// When the user's message contains the trigger keyword, the mock server
|
||||
// walks through a scripted sequence of responses that exercise the
|
||||
// interim-assistant-message fix (#65919) across several patterns:
|
||||
//
|
||||
// 1. text + single tool_call → should produce an interim message
|
||||
// 2. text + single tool_call → another interim message
|
||||
// 3. no text + tool_call → NO interim (no visible text alongside tools)
|
||||
// 4. text + single tool_call → another interim message
|
||||
// 5. final answer (stop) → message.complete, different from all interims
|
||||
//
|
||||
// Each "turn" is one API call. The agent executes the tool after each
|
||||
// tool_calls response, then re-calls the API, advancing to the next turn.
|
||||
|
||||
export interface ScriptedTurn {
|
||||
/** Assistant text content to stream. Empty string = no visible text. */
|
||||
text: string
|
||||
/** Tool calls to emit. Empty array = final turn (finish_reason: stop). */
|
||||
toolCalls?: Array<{
|
||||
name: string
|
||||
args: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
|
||||
const INTERIM_SCRIPT: ScriptedTurn[] = [
|
||||
{
|
||||
text: 'Let me start by planning the approach.',
|
||||
toolCalls: [{ name: 'todo', args: { todos: [{ id: '1', content: 'Plan', status: 'in_progress' }] } }],
|
||||
},
|
||||
{
|
||||
text: 'Now checking the details before answering.',
|
||||
toolCalls: [{ name: 'todo', args: { todos: [{ id: '2', content: 'Check details', status: 'in_progress' }] } }],
|
||||
},
|
||||
{
|
||||
// No visible text alongside this tool call — should NOT produce an
|
||||
// interim message. The agent fires _emit_interim_assistant_message
|
||||
// but _interim_assistant_visible_text returns "" so it's a no-op.
|
||||
text: '',
|
||||
toolCalls: [{ name: 'todo', args: { todos: [{ id: '3', content: 'Silent step', status: 'completed' }] } }],
|
||||
},
|
||||
{
|
||||
text: 'Found something interesting worth noting.',
|
||||
toolCalls: [{ name: 'todo', args: { todos: [{ id: '4', content: 'Note finding', status: 'completed' }] } }],
|
||||
},
|
||||
{
|
||||
// Final answer — different from all interim texts.
|
||||
text: 'All done! Here is the complete summary of what I found.',
|
||||
},
|
||||
]
|
||||
|
||||
/** Per-server request counter so we can walk through the script turns. */
|
||||
let _scriptIndex = 0
|
||||
|
||||
/** Per-server counter for the sidebar-states script (independent from _scriptIndex). */
|
||||
let _sidebarScriptIndex = 0
|
||||
|
||||
/** Per-server counter for the cross-session sidebar script. */
|
||||
let _sidebarCrossIndex = 0
|
||||
|
||||
/** Per-server counter for the queue-stop script. */
|
||||
let _queueStopIndex = 0
|
||||
|
||||
/** Per-server counter for the correction/session-switch script. */
|
||||
let _correctionSwitchIndex = 0
|
||||
|
||||
/** Per-server counter for the verify-on-stop script. */
|
||||
let _verificationStopIndex = 0
|
||||
|
||||
/** Per-server counter for the task-panel warm-resume script. */
|
||||
let _taskPanelResumeIndex = 0
|
||||
|
||||
/** User messages received by the mock, for E2E assertions on real submits. */
|
||||
const _receivedUserTexts: string[] = []
|
||||
|
||||
/** Reset the script indices (called between tests via restartMockServer). */
|
||||
function resetScriptIndex(): void {
|
||||
_scriptIndex = 0
|
||||
_sidebarScriptIndex = 0
|
||||
_sidebarCrossIndex = 0
|
||||
_queueStopIndex = 0
|
||||
_correctionSwitchIndex = 0
|
||||
_verificationStopIndex = 0
|
||||
_taskPanelResumeIndex = 0
|
||||
_receivedUserTexts.length = 0
|
||||
}
|
||||
|
||||
/** Return the user prompts the real backend submitted to this mock server. */
|
||||
export function receivedUserTexts(): readonly string[] {
|
||||
return _receivedUserTexts
|
||||
}
|
||||
|
||||
// ─── Sidebar-states script ─────────────────────────────────────────────
|
||||
//
|
||||
// A separate trigger (E2E_SIDEBAR_TRIGGER) exercises the desktop sidebar's
|
||||
// background-process and subagent states. The mock returns tool_calls that
|
||||
// the agent executes for real — `terminal(background=true)` spawns a real
|
||||
// (but trivial) background process, and `delegate_task` spawns a real
|
||||
// subagent that calls the mock server and gets the canned reply.
|
||||
//
|
||||
// Turn 1: text + terminal(bg=true) + delegate_task → tools execute
|
||||
// Turn 2: final answer → message.complete, dot transitions
|
||||
|
||||
const SIDEBAR_SCRIPT: ScriptedTurn[] = [
|
||||
{
|
||||
text: 'Let me run a background task and delegate some work.',
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'terminal',
|
||||
args: {
|
||||
command: 'echo "background process output" && sleep 1 && echo "done"',
|
||||
background: true,
|
||||
notify_on_complete: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'delegate_task',
|
||||
args: {
|
||||
goal: 'Summarize the test results',
|
||||
context: 'This is a test subagent for the sidebar states E2E test.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'All tasks complete. The background process finished and the subagent returned its summary.',
|
||||
},
|
||||
]
|
||||
|
||||
// ─── Sidebar cross-session script ──────────────────────────────────────
|
||||
//
|
||||
// E2E_SIDEBAR_CROSS starts a long background process plus a subagent so the
|
||||
// tests can:
|
||||
// 1. See the background dot while the subagent runs.
|
||||
// 2. Open a different session and see session A's dot transition to
|
||||
// "finished unread" when the background process completes.
|
||||
//
|
||||
// The background process must outlive the agent turn — the whole point is a
|
||||
// dot that is still "running" after the final answer lands. A fixed `sleep`
|
||||
// cannot guarantee that: on a loaded CI runner the turn (two model round
|
||||
// trips + a real subagent delegation) can take longer than the sleep, the
|
||||
// process exits early, the 4s success linger elapses, and the dot is gone
|
||||
// before the test looks. That is a wall-clock race between three independent
|
||||
// timers, and it made this the flakiest spec in the suite.
|
||||
//
|
||||
// When `backgroundReleasePath` is set the process instead blocks until the
|
||||
// test creates that sentinel file, so the test — not the clock — decides when
|
||||
// the dot clears. `sleep 5` remains the fallback for callers that don't pass
|
||||
// a handle.
|
||||
function sidebarCrossBgCommand(releasePath?: string): string {
|
||||
if (!releasePath) {
|
||||
return 'echo "long bg output" && sleep 5 && echo "finished"'
|
||||
}
|
||||
// Bounded wait (60s): if a test forgets to release (or crashes mid-way),
|
||||
// the process still exits instead of hanging the worker until the suite
|
||||
// times out.
|
||||
const quoted = JSON.stringify(releasePath)
|
||||
return [
|
||||
'echo "long bg output"',
|
||||
`for _ in $(seq 1 600); do [ -e ${quoted} ] && break; sleep 0.1; done`,
|
||||
'echo "finished"',
|
||||
].join(' && ')
|
||||
}
|
||||
|
||||
function sidebarCrossScript(releasePath?: string): ScriptedTurn[] {
|
||||
return [
|
||||
{
|
||||
text: 'Starting a long background task and delegating work.',
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'terminal',
|
||||
args: {
|
||||
command: sidebarCrossBgCommand(releasePath),
|
||||
background: true,
|
||||
notify_on_complete: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'delegate_task',
|
||||
args: {
|
||||
goal: 'Analyze cross-session state',
|
||||
context: 'Testing that the background dot updates across sessions.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Both tasks are running in the background now.',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const SIDEBAR_CROSS_SCRIPT: ScriptedTurn[] = sidebarCrossScript()
|
||||
|
||||
const QUEUE_STOP_SCRIPT: ScriptedTurn[] = [
|
||||
{
|
||||
text: 'Starting a task that will keep this turn active.',
|
||||
toolCalls: [{ name: 'clarify', args: { question: 'Keep working?', choices: ['Yes', 'No'] } }],
|
||||
},
|
||||
{ text: 'The paused task completed.' },
|
||||
]
|
||||
|
||||
// The reported correction arrived while a foreground tool was still running.
|
||||
// Keep that boundary open long enough for the renderer to redirect the turn,
|
||||
// then let the next model request complete normally.
|
||||
const CORRECTION_SWITCH_SCRIPT: ScriptedTurn[] = [
|
||||
{
|
||||
text: 'Checking the long-running task before I continue.',
|
||||
toolCalls: [{ name: 'terminal', args: { command: 'sleep 5' } }],
|
||||
},
|
||||
{ text: 'The corrected task finished.' },
|
||||
]
|
||||
|
||||
export const CORRECTION_SWITCH_TRIGGER = 'E2E_CORRECTION_SWITCH_TRIGGER'
|
||||
|
||||
/**
|
||||
* Drives a real code edit followed by two finish attempts. Hermes should add
|
||||
* its synthetic verify-on-stop continuation after each finish attempt until
|
||||
* the bounded verifier gives up. The mock's request capture proves the nudge
|
||||
* reached the model; desktop must never render it as chat content.
|
||||
*/
|
||||
function verificationStopScript(writePath: string): ScriptedTurn[] {
|
||||
return [
|
||||
{
|
||||
text: 'I will make the requested code change.',
|
||||
toolCalls: [{
|
||||
name: 'write_file',
|
||||
args: {
|
||||
path: writePath,
|
||||
content: 'def changed_by_e2e():\n return "changed"\n',
|
||||
},
|
||||
}],
|
||||
},
|
||||
{ text: 'The code edit is complete.' },
|
||||
{ text: 'I cannot provide fresh verification evidence for that edit.' },
|
||||
]
|
||||
}
|
||||
|
||||
export const VERIFICATION_STOP_TRIGGER = 'E2E_VERIFY_ON_STOP_TRIGGER'
|
||||
export const VERIFICATION_STOP_TEXT = 'I cannot provide fresh verification evidence for that edit.'
|
||||
|
||||
/**
|
||||
* A marker that makes the mock emit a real blocking clarify tool call. Tests
|
||||
* use it to hold a turn open while exercising busy-composer interactions.
|
||||
*/
|
||||
export const BLOCKING_CLARIFY_TRIGGER = 'E2E_BLOCKING_CLARIFY_TRIGGER'
|
||||
export const BLOCKING_CLARIFY_QUESTION = 'Keep this test turn running?'
|
||||
|
||||
/**
|
||||
* A long live response with a five-row todo card, held open by a foreground tool.
|
||||
* The transcript is deliberately taller than the viewport so warm-session
|
||||
* tests can detect when re-opening the session leaves it above the true bottom.
|
||||
*/
|
||||
export const TASK_PANEL_RESUME_TRIGGER = 'E2E_TASK_PANEL_RESUME_TRIGGER'
|
||||
export const TASK_PANEL_RESUME_TEXT = Array.from(
|
||||
{ length: 24 },
|
||||
(_, index) => `Task-panel clearance line ${index + 1}: inspect the restored working session geometry.`,
|
||||
).join('\n\n')
|
||||
|
||||
const TASK_PANEL_RESUME_SCRIPT: ScriptedTurn[] = [
|
||||
{
|
||||
text: TASK_PANEL_RESUME_TEXT,
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'todo',
|
||||
args: {
|
||||
todos: [
|
||||
{ id: 'design', content: 'Design the restored layout', status: 'completed' },
|
||||
{ id: 'implement', content: 'Implement the measured clearance', status: 'in_progress' },
|
||||
{ id: 'verify', content: 'Verify the latest message stays visible', status: 'pending' },
|
||||
{ id: 'review', content: 'Review the visual regression', status: 'pending' },
|
||||
{ id: 'ship', content: 'Ship the focused fix', status: 'pending' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'terminal',
|
||||
args: { command: 'sleep 60' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const BLOCKING_CLARIFY_TURN: ScriptedTurn = {
|
||||
text: '',
|
||||
toolCalls: [{ name: 'clarify', args: { question: BLOCKING_CLARIFY_QUESTION, choices: ['Yes', 'No'] } }],
|
||||
}
|
||||
|
||||
/**
|
||||
* A marker that makes the mock emit a blocking BATCH clarify tool call
|
||||
* (multi-question form). Regression coverage for the duplicated-card bug:
|
||||
* the tool.start row and the clarify.request row carry different ids and a
|
||||
* batch payload has no top-level question, so the correlation key must come
|
||||
* from the question list or the card mounts twice.
|
||||
*/
|
||||
export const BATCH_CLARIFY_TRIGGER = 'E2E_BATCH_CLARIFY_TRIGGER'
|
||||
export const BATCH_CLARIFY_QUESTIONS = [
|
||||
{ question: 'Pick a batch drink?', choices: ['Coffee', 'Tea'] },
|
||||
{ question: 'Pick a batch time?', choices: ['Morning', 'Night'] },
|
||||
]
|
||||
|
||||
const BATCH_CLARIFY_TURN: ScriptedTurn = {
|
||||
text: '',
|
||||
toolCalls: [{ name: 'clarify', args: { questions: BATCH_CLARIFY_QUESTIONS } }],
|
||||
}
|
||||
|
||||
function includesBatchClarifyTrigger(value: unknown): boolean {
|
||||
if (typeof value === 'string') {
|
||||
return value.includes(BATCH_CLARIFY_TRIGGER)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(includesBatchClarifyTrigger)
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.values(value).some(includesBatchClarifyTrigger)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function includesBlockingClarifyTrigger(value: unknown): boolean {
|
||||
if (typeof value === 'string') {
|
||||
return value.includes(BLOCKING_CLARIFY_TRIGGER)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(includesBlockingClarifyTrigger)
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.values(value).some(includesBlockingClarifyTrigger)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the mock server on an ephemeral port.
|
||||
*
|
||||
* @returns a handle with `port`, `url`, received user prompts, and `close()`.
|
||||
*/
|
||||
export function startMockServer(options: MockServerOptions = {}): Promise<MockServer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const receivedPrompts: string[] = []
|
||||
let resolveHeldStreamStarted: (() => void) | null = null
|
||||
let releaseHeldStream: (() => void) | null = null
|
||||
let heldCompletionCount = 0
|
||||
const heldStreamStarted = new Promise<void>(resolveHeld => {
|
||||
resolveHeldStreamStarted = resolveHeld
|
||||
})
|
||||
const heldStreamReleased = new Promise<void>(resolveRelease => {
|
||||
releaseHeldStream = resolveRelease
|
||||
})
|
||||
const server = http.createServer((req, res) => {
|
||||
// CORS headers — the Electron renderer doesn't need them, but they
|
||||
// don't hurt and make the server usable from a browser context too.
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Headers', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
// GET /v1/models — return a single fake model.
|
||||
if (req.method === 'GET' && req.url === '/v1/models') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
object: 'list',
|
||||
data: [
|
||||
{
|
||||
id: 'mock-model',
|
||||
object: 'model',
|
||||
created: 0,
|
||||
owned_by: 'mock',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// POST /v1/chat/completions — return a canned response.
|
||||
if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
|
||||
let body = ''
|
||||
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
body += chunk.toString()
|
||||
})
|
||||
|
||||
req.on('end', () => {
|
||||
let parsed: any = {}
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(body)
|
||||
} catch {
|
||||
// malformed JSON — treat as non-streaming with defaults
|
||||
}
|
||||
|
||||
const lastUserMessage = [...(parsed.messages ?? [])]
|
||||
.reverse()
|
||||
.find((message: { role?: unknown }) => message?.role === 'user')
|
||||
|
||||
if (typeof lastUserMessage?.content === 'string') {
|
||||
receivedPrompts.push(lastUserMessage.content)
|
||||
}
|
||||
|
||||
const stream = parsed.stream === true
|
||||
const model = parsed.model || 'mock-model'
|
||||
const holdThisCompletion = Boolean(
|
||||
options.holdFirstCompletionContaining &&
|
||||
heldCompletionCount === 0 &&
|
||||
JSON.stringify(parsed).includes(options.holdFirstCompletionContaining),
|
||||
)
|
||||
|
||||
// Detect the interim-message test trigger: the user's message
|
||||
// contains a specific keyword. The mock walks through the
|
||||
// INTERIM_SCRIPT turns in sequence.
|
||||
//
|
||||
// The trigger keyword is chosen so normal chat tests (which send
|
||||
// "Hello, can you hear me?" etc.) never hit this path.
|
||||
const messages: any[] = Array.isArray(parsed.messages) ? parsed.messages : []
|
||||
const lastUserMsg = [...messages].reverse().find(m => m?.role === 'user')
|
||||
const userText = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : ''
|
||||
if (userText) {
|
||||
_receivedUserTexts.push(userText)
|
||||
}
|
||||
const isInterimTrigger = userText.includes('E2E_INTERIM_TRIGGER')
|
||||
const isSidebarTrigger = userText.includes('E2E_SIDEBAR_TRIGGER')
|
||||
const isSidebarCrossTrigger = userText.includes('E2E_SIDEBAR_CROSS')
|
||||
const isQueueStopTrigger = userText.includes('E2E_QUEUE_STOP_TRIGGER')
|
||||
const isTaskPanelResumeTrigger = userText.includes(TASK_PANEL_RESUME_TRIGGER)
|
||||
const isVerificationStopTrigger = messages.some(
|
||||
message => typeof message?.content === 'string' && message.content.includes(VERIFICATION_STOP_TRIGGER),
|
||||
)
|
||||
const isCorrectionSwitchTrigger = messages.some(
|
||||
message => typeof message?.content === 'string' && message.content.includes(CORRECTION_SWITCH_TRIGGER),
|
||||
)
|
||||
|
||||
if (isTaskPanelResumeTrigger) {
|
||||
const turn =
|
||||
TASK_PANEL_RESUME_SCRIPT[_taskPanelResumeIndex] ??
|
||||
TASK_PANEL_RESUME_SCRIPT[TASK_PANEL_RESUME_SCRIPT.length - 1]
|
||||
_taskPanelResumeIndex++
|
||||
const respond = () => {
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, turn)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, turn)
|
||||
}
|
||||
}
|
||||
|
||||
if (holdThisCompletion) {
|
||||
heldCompletionCount++
|
||||
resolveHeldStreamStarted?.()
|
||||
void heldStreamReleased.then(respond)
|
||||
} else {
|
||||
respond()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (includesBatchClarifyTrigger(parsed.messages)) {
|
||||
// Only the FIRST completion of the conversation scripts the batch
|
||||
// clarify. The trigger text stays in message history, so once the
|
||||
// answered tool result is present the turn falls through to the
|
||||
// canned reply — otherwise the mock loops the quiz forever.
|
||||
const hasToolResult = Array.isArray(parsed.messages)
|
||||
&& parsed.messages.some((message: { role?: string }) => message?.role === 'tool')
|
||||
|
||||
if (!hasToolResult) {
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, BATCH_CLARIFY_TURN)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, BATCH_CLARIFY_TURN)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (includesBlockingClarifyTrigger(parsed.messages)) {
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, BLOCKING_CLARIFY_TURN)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, BLOCKING_CLARIFY_TURN)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isQueueStopTrigger) {
|
||||
const turn = QUEUE_STOP_SCRIPT[_queueStopIndex] ?? QUEUE_STOP_SCRIPT[QUEUE_STOP_SCRIPT.length - 1]
|
||||
_queueStopIndex++
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, turn)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, turn)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isVerificationStopTrigger) {
|
||||
const script = verificationStopScript(options.verificationWritePath ?? 'e2e-verification-target.py')
|
||||
const turn = script[_verificationStopIndex] ?? script[script.length - 1]
|
||||
_verificationStopIndex++
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, turn)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, turn)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isCorrectionSwitchTrigger) {
|
||||
const turn = CORRECTION_SWITCH_SCRIPT[_correctionSwitchIndex] ?? CORRECTION_SWITCH_SCRIPT[CORRECTION_SWITCH_SCRIPT.length - 1]
|
||||
_correctionSwitchIndex++
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, turn)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, turn)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isSidebarCrossTrigger) {
|
||||
const script = sidebarCrossScript(options.backgroundReleasePath)
|
||||
const turn = script[_sidebarCrossIndex] ?? script[script.length - 1]
|
||||
_sidebarCrossIndex++
|
||||
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, turn)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, turn)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isSidebarTrigger) {
|
||||
const turn = SIDEBAR_SCRIPT[_sidebarScriptIndex] ?? SIDEBAR_SCRIPT[SIDEBAR_SCRIPT.length - 1]
|
||||
_sidebarScriptIndex++
|
||||
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, turn)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, turn)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isInterimTrigger) {
|
||||
const turn = INTERIM_SCRIPT[_scriptIndex] ?? INTERIM_SCRIPT[INTERIM_SCRIPT.length - 1]
|
||||
_scriptIndex++
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, turn)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, turn)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
const holdThisStream = Boolean(
|
||||
options.holdFirstStreamForPrompt && typeof lastUserMessage?.content === 'string' &&
|
||||
lastUserMessage.content.includes(options.holdFirstStreamForPrompt),
|
||||
)
|
||||
streamTextResponse(res, model, MOCK_REPLY, holdThisStream || holdThisCompletion ? () => {
|
||||
if (holdThisCompletion) {
|
||||
heldCompletionCount++
|
||||
}
|
||||
resolveHeldStreamStarted?.()
|
||||
return heldStreamReleased
|
||||
} : undefined)
|
||||
} else {
|
||||
if (holdThisCompletion) {
|
||||
heldCompletionCount++
|
||||
resolveHeldStreamStarted?.()
|
||||
void heldStreamReleased.then(() => nonStreamingTextResponse(res, model, MOCK_REPLY))
|
||||
} else {
|
||||
nonStreamingTextResponse(res, model, MOCK_REPLY)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
req.on('error', () => {
|
||||
res.writeHead(400)
|
||||
res.end('Bad request')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback — 404 for anything else
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'Not found' }))
|
||||
})
|
||||
|
||||
server.on('error', reject)
|
||||
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address()
|
||||
if (addr === null || typeof addr === 'string') {
|
||||
reject(new Error('Failed to get server address'))
|
||||
return
|
||||
}
|
||||
|
||||
const port = addr.port
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
|
||||
resolve({
|
||||
port,
|
||||
url,
|
||||
receivedPrompts,
|
||||
waitForHeldStream: () => heldStreamStarted,
|
||||
waitForHeldCompletion: () => heldStreamStarted,
|
||||
releaseHeldStream: () => releaseHeldStream?.(),
|
||||
heldCompletionCount: () => heldCompletionCount,
|
||||
close: () =>
|
||||
new Promise((resolveClose, rejectClose) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
rejectClose(err)
|
||||
} else {
|
||||
resolveClose()
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Response helpers ──────────────────────────────────────────────────
|
||||
|
||||
/** SSE chunk shape for a streaming chat completion. */
|
||||
function sseChunk(model: string, delta: Record<string, unknown>, finishReason: string | null = null): string {
|
||||
return `data: ${JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
||||
})}\n\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a plain text response (no tool calls) as SSE, finishing with
|
||||
* `finish_reason: "stop"`. This is the default canned-reply path.
|
||||
*/
|
||||
function streamTextResponse(
|
||||
res: ServerResponse,
|
||||
model: string,
|
||||
text: string,
|
||||
waitForRelease?: () => Promise<void>,
|
||||
): void {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
})
|
||||
|
||||
const words = text.split(' ')
|
||||
let i = 0
|
||||
|
||||
const sendChunk = (): void => {
|
||||
if (i >= words.length) {
|
||||
res.write(sseChunk(model, {}, 'stop'))
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const word = i === 0 ? words[i] : ' ' + words[i]
|
||||
res.write(sseChunk(model, { content: word }))
|
||||
i++
|
||||
if (waitForRelease && i === 1) {
|
||||
waitForRelease().then(() => setTimeout(sendChunk, 20))
|
||||
return
|
||||
}
|
||||
setTimeout(sendChunk, 20)
|
||||
}
|
||||
|
||||
sendChunk()
|
||||
}
|
||||
|
||||
/** Non-streaming plain text response. */
|
||||
function nonStreamingTextResponse(res: ServerResponse, model: string, text: string): void {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: text },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a single scripted turn: first the text content (word by word),
|
||||
* then a chunk carrying the tool_calls (if any), with the appropriate
|
||||
* finish_reason.
|
||||
*
|
||||
* If the turn has no text and no tool calls, it's an empty final response.
|
||||
* If it has text but no tool calls, it's a final answer (finish_reason: stop).
|
||||
* If it has tool calls (with or without text), finish_reason is "tool_calls".
|
||||
*/
|
||||
function streamScriptedTurn(
|
||||
res: ServerResponse,
|
||||
model: string,
|
||||
turn: ScriptedTurn,
|
||||
): void {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
})
|
||||
|
||||
const hasToolCalls = turn.toolCalls && turn.toolCalls.length > 0
|
||||
const finishReason = hasToolCalls ? 'tool_calls' : 'stop'
|
||||
|
||||
// If there's no text to stream, go straight to the tool_calls / finish.
|
||||
if (!turn.text) {
|
||||
if (hasToolCalls) {
|
||||
res.write(
|
||||
sseChunk(model, {
|
||||
tool_calls: turn.toolCalls!.map((tc, idx) => ({
|
||||
index: idx,
|
||||
id: `call_e2e_${_scriptIndex}_${idx}`,
|
||||
type: 'function',
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||
})),
|
||||
}, finishReason),
|
||||
)
|
||||
} else {
|
||||
res.write(sseChunk(model, {}, finishReason))
|
||||
}
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
// Stream the text word by word, then emit tool_calls if present.
|
||||
const words = turn.text.split(' ')
|
||||
let i = 0
|
||||
|
||||
const sendChunk = (): void => {
|
||||
if (i >= words.length) {
|
||||
// All text streamed — emit tool_calls if present, then finish.
|
||||
if (hasToolCalls) {
|
||||
res.write(
|
||||
sseChunk(model, {
|
||||
tool_calls: turn.toolCalls!.map((tc, idx) => ({
|
||||
index: idx,
|
||||
id: `call_e2e_${_scriptIndex}_${idx}`,
|
||||
type: 'function',
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||
})),
|
||||
}, finishReason),
|
||||
)
|
||||
} else {
|
||||
res.write(sseChunk(model, {}, finishReason))
|
||||
}
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const word = i === 0 ? words[i] : ' ' + words[i]
|
||||
res.write(sseChunk(model, { content: word }))
|
||||
i++
|
||||
setTimeout(sendChunk, 20)
|
||||
}
|
||||
|
||||
sendChunk()
|
||||
}
|
||||
|
||||
/** Non-streaming version of a scripted turn. */
|
||||
function nonStreamingScriptedTurn(
|
||||
res: ServerResponse,
|
||||
model: string,
|
||||
turn: ScriptedTurn,
|
||||
): void {
|
||||
const hasToolCalls = turn.toolCalls && turn.toolCalls.length > 0
|
||||
const finishReason = hasToolCalls ? 'tool_calls' : 'stop'
|
||||
|
||||
const message: Record<string, unknown> = { role: 'assistant' }
|
||||
if (turn.text) {
|
||||
message.content = turn.text
|
||||
}
|
||||
if (hasToolCalls) {
|
||||
message.tool_calls = turn.toolCalls!.map((tc, idx) => ({
|
||||
id: `call_e2e_${_scriptIndex}_${idx}`,
|
||||
type: 'function',
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||
}))
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [{ index: 0, message, finish_reason: finishReason }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the mock server's script index so each test starts from turn 0.
|
||||
* Call this between tests that use the interim trigger.
|
||||
*/
|
||||
export function restartMockServer(): void {
|
||||
resetScriptIndex()
|
||||
}
|
||||
|
||||
/** Test-controlled lifetime for the E2E_SIDEBAR_CROSS background process. */
|
||||
export interface BackgroundReleaseHandle {
|
||||
/** Sentinel path — pass as `backgroundReleasePath` to `startMockServer`. */
|
||||
path: string
|
||||
/** End the background process now (creates the sentinel). */
|
||||
release: () => void
|
||||
/** Remove the sentinel if it still exists. Safe to call twice. */
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a sentinel that keeps the E2E_SIDEBAR_CROSS background process alive
|
||||
* until the test explicitly releases it.
|
||||
*
|
||||
* The cross-session sidebar tests need a background process that is still
|
||||
* RUNNING after the agent turn finishes — that is the state under test (a
|
||||
* session whose turn is done but whose background work is not). With a fixed
|
||||
* `sleep`, three independent clocks race: the sleep, the agent turn (two model
|
||||
* round trips plus a real subagent delegation), and the 4s success linger
|
||||
* before a finished task auto-dismisses. When a loaded CI runner makes the
|
||||
* turn slower than the sleep, the process is already gone and the assertion
|
||||
* samples an empty sidebar. Observed on CI 2026-07-26 across two unrelated
|
||||
* PRs: the "should appear" poll needed 7.5s to see the dot, by which point
|
||||
* `sleep 5` had exited.
|
||||
*
|
||||
* With a sentinel there is one clock and the test owns it:
|
||||
*
|
||||
* ```ts
|
||||
* const release = createBackgroundReleaseHandle()
|
||||
* const mock = await startMockServer({ backgroundReleasePath: release.path })
|
||||
* // ... assert the dot is visible; it cannot vanish on its own ...
|
||||
* release.release() // now, and only now, the process exits
|
||||
* ```
|
||||
*/
|
||||
export function createBackgroundReleaseHandle(): BackgroundReleaseHandle {
|
||||
const path = nodePath.join(
|
||||
os.tmpdir(),
|
||||
`hermes-e2e-bg-release-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
)
|
||||
return {
|
||||
path,
|
||||
release: () => {
|
||||
try {
|
||||
fs.writeFileSync(path, 'release')
|
||||
} catch {
|
||||
// The process also has a bounded fallback wait; a failed write must
|
||||
// not crash the test before its real assertions run.
|
||||
}
|
||||
},
|
||||
cleanup: () => {
|
||||
try {
|
||||
fs.rmSync(path, { force: true })
|
||||
} catch {
|
||||
// Best-effort — the sentinel lives in the OS temp dir.
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The interim script's text constants, exported for test assertions.
|
||||
* Each entry is the visible text of one turn. Turns with empty text
|
||||
* produce no interim message and are excluded from this list.
|
||||
*/
|
||||
export const INTERIM_TEXTS = {
|
||||
/** All interim texts that should appear as sealed messages when the flag is ON. */
|
||||
interims: INTERIM_SCRIPT
|
||||
.filter((t) => t.text && t.toolCalls)
|
||||
.map((t) => t.text),
|
||||
/** The final answer text. */
|
||||
finalText: INTERIM_SCRIPT[INTERIM_SCRIPT.length - 1].text,
|
||||
/** Text that should NOT produce an interim (empty-text tool turn). */
|
||||
silentTurnIndex: INTERIM_SCRIPT.findIndex((t) => !t.text && t.toolCalls),
|
||||
} as const
|
||||
|
||||
/** The sidebar-states script's text constants, exported for test assertions. */
|
||||
export const SIDEBAR_TEXTS = {
|
||||
/** The interim text from turn 1 (alongside tool calls). */
|
||||
interimText: SIDEBAR_SCRIPT[0].text,
|
||||
/** The final answer text. */
|
||||
finalText: SIDEBAR_SCRIPT[SIDEBAR_SCRIPT.length - 1].text,
|
||||
/** The background process command (for asserting process.list entries). */
|
||||
bgCommand: 'echo "background process output" && sleep 1 && echo "done"',
|
||||
/** The subagent's goal (for asserting subagent panel state). */
|
||||
subagentGoal: 'Summarize the test results',
|
||||
} as const
|
||||
|
||||
/** The cross-session sidebar script's text constants. */
|
||||
export const SIDEBAR_CROSS_TEXTS = {
|
||||
/** The interim text from turn 1. */
|
||||
interimText: SIDEBAR_CROSS_SCRIPT[0].text,
|
||||
/** The final answer text. */
|
||||
finalText: SIDEBAR_CROSS_SCRIPT[SIDEBAR_CROSS_SCRIPT.length - 1].text,
|
||||
/**
|
||||
* The default (unheld) background process command. Tests that pass a
|
||||
* `backgroundReleasePath` get a sentinel-waiting command instead — see
|
||||
* `createBackgroundReleaseHandle`.
|
||||
*/
|
||||
bgCommand: sidebarCrossBgCommand(),
|
||||
/** The subagent's goal. */
|
||||
subagentGoal: 'Analyze cross-session state',
|
||||
} as const
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* E2E onboarding tests — verify the provider picker appears when no
|
||||
* inference provider is configured.
|
||||
*
|
||||
* Launches the app with an empty config.yaml (no providers). The renderer
|
||||
* should detect the unconfigured state and show the DesktopOnboardingOverlay
|
||||
* with provider options / API key form.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from './test'
|
||||
|
||||
import {
|
||||
type NoProviderFixture,
|
||||
setupNoProvider,
|
||||
waitForOnboarding,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: NoProviderFixture | null = null
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('onboarding with no provider configured', () => {
|
||||
test('onboarding overlay appears on first boot', async () => {
|
||||
fixture = await setupNoProvider()
|
||||
|
||||
// The app should boot (hermes serve starts fine even without a provider),
|
||||
// but the renderer should show the onboarding overlay because no
|
||||
// provider is configured.
|
||||
await waitForOnboarding(fixture.page, 90_000)
|
||||
})
|
||||
|
||||
test('onboarding shows provider options or API key form', async () => {
|
||||
if (!fixture) {
|
||||
test.skip(true, 'Previous test failed — no app running')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const page = fixture.page
|
||||
|
||||
// The onboarding overlay should contain provider-related text.
|
||||
// It might show OAuth providers, an API key form, or a "choose later"
|
||||
// link. Verify at least one of these is visible.
|
||||
const rootText = await page.evaluate(() => {
|
||||
const root = document.getElementById('root')
|
||||
|
||||
return root?.textContent ?? ''
|
||||
})
|
||||
|
||||
const hasProviderText =
|
||||
rootText.includes('provider') ||
|
||||
rootText.includes('Provider') ||
|
||||
rootText.includes('API key') ||
|
||||
rootText.includes('Sign in') ||
|
||||
rootText.includes('OpenRouter') ||
|
||||
rootText.includes('OpenAI')
|
||||
|
||||
expect(hasProviderText).toBe(true)
|
||||
})
|
||||
|
||||
test('screenshot of onboarding overlay', async () => {
|
||||
if (!fixture) {
|
||||
test.skip(true, 'Previous test failed — no app running')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await expectVisualSnapshot(fixture.page, { name: 'onboarding-overlay', app: fixture.app })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* A queued prompt must remain local until the current inference turn settles.
|
||||
*
|
||||
* Hold the first streamed reply open after its first token. This gives the
|
||||
* composer a live, busy turn while the user queues a follow-up, then lets us
|
||||
* assert against the mock provider's real request log before and after the
|
||||
* held turn completes.
|
||||
*/
|
||||
|
||||
import { expect, test, type Page } from './test'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { MOCK_REPLY } from './mock-server'
|
||||
|
||||
const ACTIVE_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_ACTIVE'
|
||||
const QUEUED_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_QUEUED'
|
||||
const STEER_PROMPT = 'E2E_STEER_TURN_BOUNDARY_CORRECTION'
|
||||
|
||||
async function send(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function steer(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function queue(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await page.keyboard.press('Control+Enter')
|
||||
}
|
||||
|
||||
async function transcriptMessageOrder(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return []
|
||||
|
||||
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="user"], [data-role="assistant"]'))
|
||||
.map(message => message.textContent?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
})
|
||||
}
|
||||
|
||||
function steerTurnOrder(messages: string[]): string[] {
|
||||
return messages.flatMap(message => {
|
||||
if (message.includes(ACTIVE_PROMPT)) return [ACTIVE_PROMPT]
|
||||
if (message.includes(STEER_PROMPT)) return [STEER_PROMPT]
|
||||
if (message.includes(MOCK_REPLY)) return [MOCK_REPLY]
|
||||
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('queued prompt turn boundary', () => {
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeEach(async () => {
|
||||
fixture = await setupMockBackend({
|
||||
mockServer: { holdFirstStreamForPrompt: ACTIVE_PROMPT }
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('submits a queued prompt only after the active turn completes', async () => {
|
||||
const { mock, page } = fixture!
|
||||
|
||||
await send(page, ACTIVE_PROMPT)
|
||||
await mock.waitForHeldStream()
|
||||
await queue(page, QUEUED_PROMPT)
|
||||
await expect(page.getByText('1 Queued')).toBeVisible()
|
||||
|
||||
// The mock keeps the active SSE stream open, so a queued prompt has no
|
||||
// completed-turn boundary that could legitimately drain it. Wait past the
|
||||
// queue retry interval and assert the provider saw only the active turn.
|
||||
await page.waitForTimeout(1_000)
|
||||
expect(mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(0)
|
||||
await expect(page.locator('[data-slot="aui_thread-viewport"]')).not.toContainText(QUEUED_PROMPT)
|
||||
|
||||
mock.releaseHeldStream()
|
||||
await page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
MOCK_REPLY,
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('places a steer prompt before the reply it redirects', async () => {
|
||||
const { mock, page } = fixture!
|
||||
|
||||
await send(page, ACTIVE_PROMPT)
|
||||
await mock.waitForHeldStream()
|
||||
await steer(page, STEER_PROMPT)
|
||||
mock.releaseHeldStream()
|
||||
|
||||
await page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
MOCK_REPLY,
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
|
||||
expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ACTIVE_PROMPT, STEER_PROMPT, MOCK_REPLY])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'
|
||||
import * as path from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
const DEFAULT_TIMEOUT_MS = 60_000
|
||||
|
||||
interface JsonRpcError {
|
||||
code?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface JsonRpcFrame {
|
||||
error?: JsonRpcError
|
||||
id?: number
|
||||
method?: string
|
||||
params?: {
|
||||
payload?: unknown
|
||||
session_id?: string
|
||||
type?: string
|
||||
}
|
||||
result?: unknown
|
||||
}
|
||||
|
||||
interface CreatedSession {
|
||||
session_id: string
|
||||
stored_session_id: string
|
||||
}
|
||||
|
||||
export interface RealSessionTurn {
|
||||
/** Local image paths attached before the prompt, as the composer would. */
|
||||
images?: readonly string[]
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface RealSessionSpec {
|
||||
/** Session label. The durable row stores no title, so clients fall back to
|
||||
* the preview (the first 60 characters of the first user message). */
|
||||
title: string
|
||||
/** Each item becomes one real user prompt followed by the mock provider's reply. */
|
||||
turns: readonly (RealSessionTurn | string)[]
|
||||
}
|
||||
|
||||
export interface RealSession {
|
||||
/** Runtime-only TUI session id, valid only while the builder process is alive. */
|
||||
runtimeId: string
|
||||
/** Durable SessionDB id that desktop resumes after the builder exits. */
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates durable desktop session history through the real TUI gateway and
|
||||
* AIAgent loop, using the E2E mock provider configured in `hermesHome`.
|
||||
*
|
||||
* This intentionally uses the shipped stdio JSON-RPC transport instead of
|
||||
* importing SessionDB or launching Electron. The desktop's WebSocket backend
|
||||
* dispatches the same `tui_gateway.server` methods.
|
||||
*/
|
||||
export class RealSessionBuilder {
|
||||
private readonly child: ChildProcessWithoutNullStreams
|
||||
private nextRequestId = 0
|
||||
private readonly pending = new Map<number, { reject: (reason: Error) => void; resolve: (value: unknown) => void }>()
|
||||
private readonly events: JsonRpcFrame[] = []
|
||||
private readonly eventWaiters: Array<{
|
||||
predicate: (frame: JsonRpcFrame) => boolean
|
||||
reject: (reason: Error) => void
|
||||
resolve: (frame: JsonRpcFrame) => void
|
||||
}> = []
|
||||
private readonly stderr: string[] = []
|
||||
private closed = false
|
||||
|
||||
private constructor(hermesHome: string) {
|
||||
this.child = spawn('uv', ['run', '--active', '--no-sync', 'python', '-m', 'tui_gateway.entry'], {
|
||||
cwd: REPO_ROOT,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME: hermesHome,
|
||||
PYTHONPATH: REPO_ROOT,
|
||||
},
|
||||
stdio: 'pipe',
|
||||
})
|
||||
|
||||
createInterface({ input: this.child.stdout }).on('line', line => this.handleLine(line))
|
||||
createInterface({ input: this.child.stderr }).on('line', line => {
|
||||
this.stderr.push(line)
|
||||
if (this.stderr.length > 80) this.stderr.shift()
|
||||
})
|
||||
this.child.once('error', error => this.failAll(new Error(`real-session gateway failed to start: ${error.message}`)))
|
||||
this.child.once('exit', (code, signal) => {
|
||||
if (!this.closed) {
|
||||
this.failAll(new Error(`real-session gateway exited unexpectedly (${signal ?? code ?? 'unknown'}):\n${this.stderr.join('\n')}`))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
static async start(hermesHome: string): Promise<RealSessionBuilder> {
|
||||
const builder = new RealSessionBuilder(hermesHome)
|
||||
await builder.waitForEvent(frame => frame.params?.type === 'gateway.ready')
|
||||
return builder
|
||||
}
|
||||
|
||||
async createSession(spec: RealSessionSpec): Promise<RealSession> {
|
||||
if (spec.turns.length === 0) {
|
||||
throw new Error('RealSessionBuilder requires at least one turn so the real agent creates a durable session row')
|
||||
}
|
||||
|
||||
const created = await this.request<CreatedSession>('session.create', {
|
||||
cols: 120,
|
||||
cwd: REPO_ROOT,
|
||||
source: 'desktop',
|
||||
title: spec.title,
|
||||
})
|
||||
const runtimeId = requireString(created, 'session_id')
|
||||
const sessionId = requireString(created, 'stored_session_id')
|
||||
|
||||
for (const turn of spec.turns) {
|
||||
const { images = [], text } = typeof turn === 'string' ? { text: turn } : turn
|
||||
|
||||
for (const image of images) {
|
||||
await this.request('image.attach', { session_id: runtimeId, path: image })
|
||||
}
|
||||
|
||||
const completion = this.waitForEvent(
|
||||
frame => frame.params?.type === 'message.complete' && frame.params.session_id === runtimeId,
|
||||
)
|
||||
await this.request('prompt.submit', { session_id: runtimeId, text })
|
||||
const frame = await completion
|
||||
const status = readString(frame.params?.payload, 'status')
|
||||
if (status !== 'complete') {
|
||||
throw new Error(`real session turn failed with status ${status ?? 'unknown'}: ${JSON.stringify(frame.params?.payload)}`)
|
||||
}
|
||||
}
|
||||
|
||||
await this.request('session.close', { session_id: runtimeId })
|
||||
return { runtimeId, sessionId }
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return
|
||||
this.closed = true
|
||||
this.child.stdin.end()
|
||||
await new Promise<void>(resolve => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.child.kill('SIGTERM')
|
||||
resolve()
|
||||
}, 5_000)
|
||||
this.child.once('exit', () => {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private request<T = unknown>(method: string, params: Record<string, unknown>): Promise<T> {
|
||||
const id = ++this.nextRequestId
|
||||
return this.withTimeout(new Promise<T>((resolve, reject) => {
|
||||
this.pending.set(id, { resolve: value => resolve(value as T), reject })
|
||||
this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, error => {
|
||||
if (error) {
|
||||
this.pending.delete(id)
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}), `request ${method}`)
|
||||
}
|
||||
|
||||
private waitForEvent(predicate: (frame: JsonRpcFrame) => boolean): Promise<JsonRpcFrame> {
|
||||
const index = this.events.findIndex(predicate)
|
||||
if (index >= 0) {
|
||||
return Promise.resolve(this.events.splice(index, 1)[0])
|
||||
}
|
||||
return this.withTimeout(new Promise<JsonRpcFrame>((resolve, reject) => {
|
||||
this.eventWaiters.push({ predicate, resolve, reject })
|
||||
}), 'gateway event')
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
let frame: JsonRpcFrame
|
||||
try {
|
||||
frame = JSON.parse(line) as JsonRpcFrame
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof frame.id === 'number') {
|
||||
const pending = this.pending.get(frame.id)
|
||||
if (!pending) return
|
||||
this.pending.delete(frame.id)
|
||||
if (frame.error) {
|
||||
pending.reject(new Error(`JSON-RPC error ${frame.error.code ?? 'unknown'}: ${frame.error.message ?? 'unknown error'}`))
|
||||
} else {
|
||||
pending.resolve(frame.result)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (frame.method !== 'event') return
|
||||
const waiter = this.eventWaiters.find(candidate => candidate.predicate(frame))
|
||||
if (!waiter) {
|
||||
this.events.push(frame)
|
||||
return
|
||||
}
|
||||
this.eventWaiters.splice(this.eventWaiters.indexOf(waiter), 1)
|
||||
waiter.resolve(frame)
|
||||
}
|
||||
|
||||
private withTimeout<T>(promise: Promise<T>, operation: string): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error(`Timed out after ${DEFAULT_TIMEOUT_MS / 1000}s waiting for ${operation}:\n${this.stderr.join('\n')}`)), DEFAULT_TIMEOUT_MS)
|
||||
promise.then(value => {
|
||||
clearTimeout(timer)
|
||||
resolve(value)
|
||||
}, error => {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private failAll(error: Error): void {
|
||||
for (const pending of this.pending.values()) pending.reject(error)
|
||||
this.pending.clear()
|
||||
for (const waiter of this.eventWaiters) waiter.reject(error)
|
||||
this.eventWaiters.length = 0
|
||||
}
|
||||
}
|
||||
|
||||
function readString(value: unknown, key: string): string | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined
|
||||
const candidate = (value as Record<string, unknown>)[key]
|
||||
return typeof candidate === 'string' ? candidate : undefined
|
||||
}
|
||||
|
||||
function requireString(value: unknown, key: string): string {
|
||||
const candidate = readString(value, key)
|
||||
if (!candidate) throw new Error(`Gateway response omitted required ${key}: ${JSON.stringify(value)}`)
|
||||
return candidate
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { test, expect } from './test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('persistent terminal overlay follows the pane after split dragging', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
await page.keyboard.press('Control+`')
|
||||
await page.locator('[data-terminal-slot]').waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await page.locator('[data-persistent-terminal] .xterm').waitFor({ state: 'visible', timeout: 30_000 })
|
||||
|
||||
const result = await page.evaluate(async () => {
|
||||
const slot = document.querySelector('[data-terminal-slot]')
|
||||
const overlay = document.querySelector('[data-persistent-terminal]')
|
||||
|
||||
if (!slot || !overlay) {
|
||||
return { drift: -1, moved: 0, target: false }
|
||||
}
|
||||
|
||||
const before = slot.getBoundingClientRect()
|
||||
const target = [...document.querySelectorAll<HTMLElement>('[role="separator"]')]
|
||||
.map(element => {
|
||||
const box = element.getBoundingClientRect()
|
||||
const horizontal = box.width > box.height
|
||||
const center = horizontal
|
||||
? (box.top + box.bottom) / 2
|
||||
: (box.left + box.right) / 2
|
||||
const sides = horizontal
|
||||
? [before.top, before.bottom]
|
||||
: [before.left, before.right]
|
||||
|
||||
return {
|
||||
element,
|
||||
box,
|
||||
horizontal,
|
||||
score: Math.min(...sides.map(side => Math.abs(center - side))),
|
||||
}
|
||||
})
|
||||
.filter(item => item.box.width > 0 && item.box.height > 0)
|
||||
.sort((a, b) => a.score - b.score)[0]
|
||||
|
||||
if (!target) {
|
||||
return { drift: -1, moved: 0, target: false }
|
||||
}
|
||||
|
||||
const x = target.box.left + target.box.width / 2
|
||||
const y0 = target.box.top + target.box.height / 2
|
||||
const nearestSide = target.horizontal
|
||||
? Math.abs(y0 - before.top) < Math.abs(y0 - before.bottom)
|
||||
? 'top'
|
||||
: 'bottom'
|
||||
: Math.abs(x - before.left) < Math.abs(x - before.right)
|
||||
? 'left'
|
||||
: 'right'
|
||||
const deltaX = nearestSide === 'left' ? -1 : nearestSide === 'right' ? 1 : 0
|
||||
const deltaY = nearestSide === 'top' ? -1 : nearestSide === 'bottom' ? 1 : 0
|
||||
let currentX = x
|
||||
let y = y0
|
||||
const pointer = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId: 71,
|
||||
pointerType: 'mouse',
|
||||
isPrimary: true,
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
}
|
||||
|
||||
target.element.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { ...pointer, clientX: x, clientY: y }),
|
||||
)
|
||||
|
||||
for (let index = 0; index < 24; index += 1) {
|
||||
currentX += deltaX
|
||||
y += deltaY
|
||||
window.dispatchEvent(
|
||||
new PointerEvent('pointermove', {
|
||||
...pointer,
|
||||
clientX: currentX,
|
||||
clientY: y,
|
||||
}),
|
||||
)
|
||||
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
|
||||
window.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
...pointer,
|
||||
buttons: 0,
|
||||
clientX: currentX,
|
||||
clientY: y,
|
||||
}),
|
||||
)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 350))
|
||||
await new Promise<void>(resolve =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
||||
)
|
||||
|
||||
const next = slot.getBoundingClientRect()
|
||||
const fixed = overlay.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
drift: Math.max(
|
||||
Math.abs(next.top - fixed.top),
|
||||
Math.abs(next.left - fixed.left),
|
||||
Math.abs(next.width - fixed.width),
|
||||
Math.abs(next.height - fixed.height),
|
||||
),
|
||||
moved: Math.max(
|
||||
Math.abs(next.top - before.top),
|
||||
Math.abs(next.left - before.left),
|
||||
Math.abs(next.width - before.width),
|
||||
Math.abs(next.height - before.height),
|
||||
),
|
||||
target: true,
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.target).toBe(true)
|
||||
expect(result.moved).toBeGreaterThan(10)
|
||||
expect(result.drift).toBeLessThanOrEqual(1)
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* E2E coverage for session compression, which rotates a live backend session.
|
||||
*/
|
||||
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { MOCK_REPLY, receivedUserTexts, restartMockServer } from './mock-server'
|
||||
|
||||
async function send(page: Page, text: string, delay = 15): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type(text, { delay })
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function pasteAndSend(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await page.keyboard.insertText(text)
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
expected => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(expected) ?? false,
|
||||
text,
|
||||
{ timeout }
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('session compression', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('compresses an existing session and accepts a follow-up turn on its continuation', async () => {
|
||||
const { page } = fixture
|
||||
const reply = 'Hello from the mock inference server! The full boot chain is working.'
|
||||
|
||||
// Three completed exchanges leave a compressible middle after the
|
||||
// compressor's protected head/tail boundaries.
|
||||
await send(page, 'E2E_COMPRESSION_FIRST')
|
||||
await waitForTranscript(page, reply)
|
||||
await send(page, 'E2E_COMPRESSION_SECOND')
|
||||
await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_SECOND').length).toBe(1)
|
||||
await send(page, 'E2E_COMPRESSION_THIRD')
|
||||
await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_THIRD').length).toBe(1)
|
||||
|
||||
// The mock receiving the third prompt does not mean the TURN is over —
|
||||
// /compress on a busy session errors with "session busy — /interrupt the
|
||||
// current turn before /compress". Wait for the third reply to render and
|
||||
// for the composer to leave its busy state (no Stop affordance) first.
|
||||
await page.waitForFunction(
|
||||
expected =>
|
||||
((document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').split(expected).length - 1) >= 3,
|
||||
reply,
|
||||
{ timeout: 90_000 }
|
||||
)
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator('[data-slot="composer-root"] button[aria-label="Stop"]').count(),
|
||||
{ timeout: 30_000, message: 'turn should settle before /compress' }
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// This test covers compression and continuation, not slash completion.
|
||||
// Insert the complete command atomically and click Send so an async
|
||||
// completion response cannot consume Enter as a picker acceptance.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await page.keyboard.insertText('/compress preserve the three test turns')
|
||||
await expect.poll(() => composer.textContent()).toContain('preserve the three test turns')
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click()
|
||||
await expect
|
||||
.poll(() => page.locator('[data-slot="aui_thread-viewport"]').textContent(), { timeout: 90_000 })
|
||||
.toMatch(/Compressed|No changes from compression/)
|
||||
|
||||
// Compression rotates the agent's live session id. A post-compression
|
||||
// ordinary turn proves the desktop's runtime binding followed that child.
|
||||
await send(page, 'E2E_COMPRESSION_FOLLOW_UP')
|
||||
await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_FOLLOW_UP').length).toBe(1)
|
||||
await waitForTranscript(page, reply)
|
||||
await page.screenshot({ path: 'test-results/session-compression-continuation.png' })
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('session compression in progress', () => {
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend({
|
||||
modelContextLength: 64_000,
|
||||
extraConfig: `compression:
|
||||
threshold_tokens: 22000
|
||||
protect_first_n: 0
|
||||
protect_last_n: 1
|
||||
auxiliary:
|
||||
title_generation:
|
||||
enabled: false
|
||||
compression:
|
||||
provider: custom
|
||||
model: mock-model`,
|
||||
mockServer: {
|
||||
holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.'
|
||||
}
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('queues an Enter-submitted draft instead of steering while compaction is active', async ({}, testInfo) => {
|
||||
const { page } = fixture
|
||||
const queued = 'E2E_QUEUED_DURING_COMPACTION'
|
||||
|
||||
// A normal message crosses the tiny configured context budget. The mock
|
||||
// blocks only the resulting summary request, so these assertions run
|
||||
// during automatic compaction rather than a slash-command path.
|
||||
// The payload must cross threshold_tokens (22k) on its OWN weight
|
||||
// (~12k tokens) on top of the system prompt. Do not shrink it: at
|
||||
// repeat(500) the trigger only worked because the ambient system prompt
|
||||
// (skills index + tool schemas) happened to carry it over the line, and
|
||||
// a 160-token skills-index cleanup on main broke the test for a day.
|
||||
await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_ONE '.repeat(5))
|
||||
await waitForTranscript(page, MOCK_REPLY)
|
||||
await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_TWO '.repeat(5))
|
||||
await waitForTranscript(page, MOCK_REPLY)
|
||||
await pasteAndSend(page, 'E2E_TRIGGER_AUTOMATIC_COMPACTION '.repeat(1500))
|
||||
await fixture.mock.waitForHeldCompletion()
|
||||
await expect(page.getByRole('status', { name: 'Summarizing thread' }).last()).toBeVisible()
|
||||
|
||||
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
|
||||
// Since "running is not busy" (3bc52fb9df) an empty composer mid-turn
|
||||
// shows Stop — the Queue affordance appears once a payload is typed, and
|
||||
// the Enter path below still queues instead of steering while compaction
|
||||
// holds the turn.
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Stop')
|
||||
|
||||
await send(page, queued)
|
||||
await expect(page.getByText('1 Queued')).toBeVisible()
|
||||
expect(fixture.mock.heldCompletionCount()).toBe(1)
|
||||
expect(receivedUserTexts()).not.toContain(queued)
|
||||
await page.screenshot({ path: testInfo.outputPath('queued-during-compaction.png') })
|
||||
|
||||
fixture.mock.releaseHeldStream()
|
||||
await expect.poll(() => receivedUserTexts().filter(text => text === queued).length).toBe(1)
|
||||
expect(fixture.mock.heldCompletionCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* E2E tests for desktop sidebar states — background processes, subagents,
|
||||
* and session dot transitions.
|
||||
*
|
||||
* The mock server returns scripted tool_calls that the agent executes for
|
||||
* real (trivial commands + real subagent delegations). The tests assert the
|
||||
* sidebar states driven by real gateway events.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import {
|
||||
createBackgroundReleaseHandle,
|
||||
restartMockServer,
|
||||
SIDEBAR_CROSS_TEXTS,
|
||||
SIDEBAR_TEXTS,
|
||||
} from './mock-server'
|
||||
|
||||
/** Background-running dot aria-label (from i18n en.ts). */
|
||||
const BG_DOT_LABEL = 'Background task running'
|
||||
/** Foreground turn-running dot aria-label. */
|
||||
const SESSION_RUNNING_DOT_LABEL = 'Session running'
|
||||
/** Finished-unread dot aria-label. */
|
||||
const UNREAD_DOT_LABEL = 'Finished — unread'
|
||||
|
||||
/**
|
||||
* The auto-title auxiliary call hits the SAME mock provider as the chat turn,
|
||||
* and its request carries the user's message — trigger keyword included. The
|
||||
* mock's trigger matching is text-based, so the title call consumes a script
|
||||
* index: the real chat turn then gets turn 2 (final answer, NO tool calls),
|
||||
* the background process is never spawned, and the bg dot never appears.
|
||||
* Whether that happens depends on which request lands first — the CI flake
|
||||
* these specs had. Disable auto-title so script indices line up with real
|
||||
* chat turns (same fix as interim-messages.spec.ts).
|
||||
*/
|
||||
const DISABLE_AUTO_TITLE = 'auxiliary:\n title_generation:\n enabled: false'
|
||||
|
||||
/** Send a message and wait for the final response to appear. */
|
||||
async function sendMessageAndWait(
|
||||
page: Page,
|
||||
trigger: string,
|
||||
finalText: string,
|
||||
timeout = 90_000,
|
||||
): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await composer.click()
|
||||
await composer.type(trigger, { delay: 20 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('E2E_'),
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
await page.waitForFunction(
|
||||
(text) => (document.body.textContent ?? '').includes(text),
|
||||
finalText,
|
||||
{ timeout },
|
||||
)
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Test 1: background process + subagent appear in sidebar during turn
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('sidebar states — background process and subagent', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend({ extraConfig: DISABLE_AUTO_TITLE })
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('background process dot appears and disappears, subagent runs, final answer visible', async () => {
|
||||
const page = fixture.page
|
||||
|
||||
await sendMessageAndWait(page, 'E2E_SIDEBAR_TRIGGER', SIDEBAR_TEXTS.finalText)
|
||||
|
||||
// The background process (sleep 1) should have shown a "Background task
|
||||
// running" dot at some point during the turn. We try to catch it; if
|
||||
// the process was too fast, that's OK — the real assertion is that the
|
||||
// final answer appeared and the dot is gone afterward.
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 15_000, message: 'background dot should appear' },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
} catch {
|
||||
// sleep 1 may have finished before we polled — not a failure.
|
||||
}
|
||||
|
||||
// After the turn completes and auto-dismiss fires, the background dot
|
||||
// should be gone.
|
||||
await page.waitForTimeout(8000)
|
||||
const bgCount = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count()
|
||||
expect(bgCount, 'background dot should be gone after auto-dismiss').toBe(0)
|
||||
|
||||
// Evidence: capture the final state — no background dot, final answer visible.
|
||||
await page.screenshot({ path: 'test-results/bg-dot-gone-after-dismiss.png' })
|
||||
|
||||
// The final answer text must be in the transcript.
|
||||
const viewportText = await page
|
||||
.locator('[data-slot="aui_thread-viewport"]')
|
||||
.textContent()
|
||||
expect(viewportText).toContain(SIDEBAR_TEXTS.finalText)
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Test 2: subagent running shows background dot too (longer bg process)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('sidebar states — subagent and background dot coexist', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
// Hold the background process open until the test releases it. Without the
|
||||
// sentinel the process is a bare `sleep 5` racing the agent turn (two model
|
||||
// trips + a real subagent spawn): on a loaded runner the turn outlives the
|
||||
// sleep, the process is reaped mid-turn, and the dot never appears at all —
|
||||
// the CI flake this spec had.
|
||||
const bgRelease = createBackgroundReleaseHandle()
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend({
|
||||
extraConfig: DISABLE_AUTO_TITLE,
|
||||
mockServer: { backgroundReleasePath: bgRelease.path },
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
bgRelease.release()
|
||||
await fixture?.cleanup()
|
||||
bgRelease.cleanup()
|
||||
})
|
||||
|
||||
test('background dot visible while subagent runs', async () => {
|
||||
const page = fixture.page
|
||||
|
||||
// Start the turn — a held background process plus a real subagent.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await composer.click()
|
||||
await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Wait for the user's message to appear.
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'),
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
// While the turn is busy the dot-state priority paints the session as
|
||||
// "working" ('Session running') — that claim OUTRANKS 'background', so
|
||||
// polling for the bg dot mid-turn races the turn length against the poll
|
||||
// budget. Wait for the turn to END (final text + running dot cleared),
|
||||
// then assert the background dot as a stable, sentinel-held state.
|
||||
await page.waitForFunction(
|
||||
(text) => (document.body.textContent ?? '').includes(text),
|
||||
SIDEBAR_CROSS_TEXTS.finalText,
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${SESSION_RUNNING_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'session running dot should disappear after turn completes' },
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// The background process is held open by the sentinel, so the bg dot is
|
||||
// a stable state — poll only to absorb the event-driven flip landing a
|
||||
// tick after the running dot clears.
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'background dot should be visible after turn completes' },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// Evidence: the background dot is visible while the process runs.
|
||||
await page.screenshot({ path: 'test-results/bg-dot-while-subagent-runs.png' })
|
||||
|
||||
// Release the process; the dot should clear on the completion event —
|
||||
// event-driven, not a fixed sleep.
|
||||
bgRelease.release()
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'background dot should be gone after process exits' },
|
||||
)
|
||||
.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Test 3: cross-session — dot updates when viewing a different session
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('sidebar states — cross-session dot transition', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
// Keeps the background process alive until this test releases it, so the
|
||||
// "still running after the turn finished" state can't expire on its own.
|
||||
const bgRelease = createBackgroundReleaseHandle()
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend({
|
||||
extraConfig: DISABLE_AUTO_TITLE,
|
||||
mockServer: { backgroundReleasePath: bgRelease.path },
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Release first so the process exits even if the test failed early,
|
||||
// then drop the sentinel file.
|
||||
bgRelease.release()
|
||||
await fixture?.cleanup()
|
||||
bgRelease.cleanup()
|
||||
})
|
||||
|
||||
test('background dot transitions to finished when viewing another session', async () => {
|
||||
const page = fixture.page
|
||||
|
||||
// Start a turn whose background process runs until we release it.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await composer.click()
|
||||
await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// While the turn is busy the dot-state priority paints the session as
|
||||
// "working" ('Session running') — that claim OUTRANKS 'background', so
|
||||
// polling for the bg dot mid-turn races the turn length (two model trips
|
||||
// + a real subagent spawn) against the poll budget: the CI flake this
|
||||
// spec had. Wait for the turn to END first, then assert the bg dot as a
|
||||
// stable, sentinel-held state.
|
||||
//
|
||||
// The final answer text streams before message.complete, so text visibility
|
||||
// alone is not a completion barrier. Wait for the foreground-running state
|
||||
// to clear before asserting the background-process state.
|
||||
await page.waitForFunction(
|
||||
(text) => (document.body.textContent ?? '').includes(text),
|
||||
SIDEBAR_CROSS_TEXTS.finalText,
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${SESSION_RUNNING_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'session running dot should disappear after turn completes' },
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// The background dot must be visible now: the turn is done but the
|
||||
// process is held open by the sentinel, so this is a stable state rather
|
||||
// than a window we have to catch in time. Poll to absorb the event-driven
|
||||
// flip landing a tick after the running dot clears.
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'background dot should be visible after turn completes' },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// Evidence: bg dot visible on session A while its turn is done but the
|
||||
// background process hasn't exited yet.
|
||||
await page.screenshot({ path: 'test-results/cross-session-bg-dot-before-switch.png' })
|
||||
|
||||
// Create a new session (click "New session" button).
|
||||
await page.locator('button:has-text("New session")').first().click()
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Now let the background process finish. The session A dot should
|
||||
// transition away from "background running".
|
||||
bgRelease.release()
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'background dot should disappear after process finishes' },
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// The original session should show a "finished unread" indicator (green dot)
|
||||
// since its turn completed while we were in a different session. This is an
|
||||
// event-driven transition, so wait for it instead of sampling the DOM right
|
||||
// after the running dot disappears.
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'original session should show finished-unread dot' },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// Evidence: the green "finished unread" dot on the original session after
|
||||
// switching to a new session — the cross-session dot transition.
|
||||
await page.screenshot({ path: 'test-results/cross-session-unread-dot-after-switch.png' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Regression coverage for #69578: harmless route-token churn during a send
|
||||
* must not make the desktop silently drop the prompt before prompt.submit.
|
||||
*/
|
||||
|
||||
import { test, expect } from './test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
|
||||
const PROMPT = 'E2E route token drift must still submit this prompt.'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture!, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('submits while same-chat search tokens churn during new-session creation', async ({}, testInfo) => {
|
||||
const { page, mock } = fixture!
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
|
||||
await composer.click()
|
||||
await composer.type(PROMPT, { delay: 10 })
|
||||
|
||||
// The submit pipeline snapshots the route synchronously, then awaits session
|
||||
// creation. Keep changing only the query string of whichever chat route is
|
||||
// current. Before #69578, comparing the raw route token treated this as a
|
||||
// user chat switch and aborted before prompt.submit.
|
||||
await page.evaluate(() => {
|
||||
let revision = 0
|
||||
const interval = window.setInterval(() => {
|
||||
const pathname = window.location.hash.slice(1).split(/[?#]/, 1)[0] || '/new'
|
||||
window.location.hash = `${pathname}?e2e-route-churn=${revision++}`
|
||||
}, 1)
|
||||
|
||||
;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn = () => {
|
||||
window.clearInterval(interval)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect
|
||||
.poll(() => mock.receivedPrompts.includes(PROMPT), { timeout: 60_000 })
|
||||
.toBe(true)
|
||||
|
||||
await page.waitForFunction(
|
||||
prompt => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(prompt) ?? false,
|
||||
PROMPT,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes('mock inference server') ?? false,
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
} finally {
|
||||
await page.evaluate(() => {
|
||||
;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn?.()
|
||||
})
|
||||
}
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('same-chat-route-churn-submitted.png') })
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Regression coverage for returning to a working session as its task panel
|
||||
* expands. The transcript must reconcile to the composer's full measured
|
||||
* height without needing a manual scroll to repair the position.
|
||||
*/
|
||||
|
||||
import { expect, test, type Page } from './test'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { TASK_PANEL_RESUME_TRIGGER } from './mock-server'
|
||||
|
||||
const SURFACE = '[data-composer-target]:visible'
|
||||
const PROMPT = `${TASK_PANEL_RESUME_TRIGGER}: keep the task panel expanded while this session is reopened.`
|
||||
|
||||
function activeSurface(page: Page) {
|
||||
return page.locator(SURFACE).last()
|
||||
}
|
||||
|
||||
async function send(page: Page, text: string): Promise<void> {
|
||||
const composer = activeSurface(page).locator('[contenteditable="true"]').first()
|
||||
|
||||
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function openFreshDraft(page: Page): Promise<void> {
|
||||
await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
|
||||
await expect(activeSurface(page).locator('[data-slot="aui_thread-viewport"]')).not.toContainText(PROMPT)
|
||||
await page.waitForTimeout(1_000)
|
||||
}
|
||||
|
||||
async function reopenWorkingSession(page: Page): Promise<void> {
|
||||
const sidebar = page.locator('[data-slot="sidebar"]')
|
||||
const row = sidebar.getByRole('button', { name: /^(?:Session running|Needs your input|Working)\b/ }).first()
|
||||
|
||||
await row.waitFor({ state: 'visible', timeout: 30_000 })
|
||||
await row.click()
|
||||
await expect(activeSurface(page).locator('[data-slot="aui_thread-viewport"]')).toContainText(
|
||||
'Task-panel clearance line 24',
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
}
|
||||
|
||||
interface ClearanceMetrics {
|
||||
composerHeight: number
|
||||
distanceFromBottom: number
|
||||
latestMessageBottom: number
|
||||
statusPanelTop: number
|
||||
viewportHeight: number
|
||||
}
|
||||
|
||||
async function clearanceMetrics(page: Page): Promise<ClearanceMetrics> {
|
||||
return activeSurface(page).evaluate(surface => {
|
||||
const chatSurface = surface.closest<HTMLElement>('[data-chat-surface]')!
|
||||
const viewport = surface.querySelector<HTMLElement>('[data-slot="aui_thread-viewport"]')!
|
||||
const latest = Array.from(surface.querySelectorAll<HTMLElement>('[data-role="assistant"]')).at(-1)!
|
||||
const status = surface.querySelector<HTMLElement>('[data-slot="composer-status-stack"]')!
|
||||
const styles = getComputedStyle(chatSurface)
|
||||
|
||||
return {
|
||||
composerHeight: Number.parseFloat(styles.getPropertyValue('--composer-measured-height')),
|
||||
distanceFromBottom: viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop,
|
||||
latestMessageBottom: latest.getBoundingClientRect().bottom,
|
||||
statusPanelTop: status.getBoundingClientRect().top,
|
||||
viewportHeight: viewport.clientHeight,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('working-session task-panel clearance', () => {
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeEach(async () => {
|
||||
fixture = await setupMockBackend({
|
||||
mockServer: { holdFirstCompletionContaining: TASK_PANEL_RESUME_TRIGGER },
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('window focus reanchors a working session above the expanded task panel', async ({}, testInfo) => {
|
||||
const page = fixture!.page
|
||||
|
||||
await send(page, PROMPT)
|
||||
await fixture!.mock.waitForHeldCompletion()
|
||||
await openFreshDraft(page)
|
||||
|
||||
// Re-open while the long response is still streaming. Its todo call lands
|
||||
// afterward, so the already-visible composer grows only after the initial
|
||||
// session-load scroll settle has finished.
|
||||
fixture!.mock.releaseHeldStream()
|
||||
await page.waitForTimeout(1_000)
|
||||
await reopenWorkingSession(page)
|
||||
await expect(activeSurface(page).getByText('Tasks 1/5')).toBeVisible({ timeout: 30_000 })
|
||||
|
||||
// Reproduce the stale geometry at the foreground boundary. Active turns
|
||||
// disable Chromium's background throttling, so visibility can stay `visible`
|
||||
// and window focus is the only foreground edge that can repair it.
|
||||
await page.waitForTimeout(750)
|
||||
const staleState = await activeSurface(page)
|
||||
.locator('[data-slot="aui_thread-viewport"]')
|
||||
.evaluate(viewport => {
|
||||
// Grow scrollHeight before the observed thread-content node. This
|
||||
// shifts the transcript behind the dock without resizing the observed
|
||||
// node or synthesizing a user scroll (which must escape the lock).
|
||||
const staleClearance = document.createElement('div')
|
||||
staleClearance.style.height = '160px'
|
||||
staleClearance.setAttribute('aria-hidden', 'true')
|
||||
viewport.prepend(staleClearance)
|
||||
|
||||
const distance = viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop
|
||||
const surface = viewport.closest<HTMLElement>('[data-composer-target]')!
|
||||
const latest = Array.from(surface.querySelectorAll<HTMLElement>('[data-role="assistant"]')).at(-1)!
|
||||
const status = surface.querySelector<HTMLElement>('[data-slot="composer-status-stack"]')!
|
||||
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
|
||||
return {
|
||||
distance,
|
||||
following: viewport.dataset.following,
|
||||
latestMessageBottom: latest.getBoundingClientRect().bottom,
|
||||
statusPanelTop: status.getBoundingClientRect().top,
|
||||
visibility: document.visibilityState,
|
||||
}
|
||||
})
|
||||
|
||||
expect(staleState.visibility, JSON.stringify(staleState)).toBe('visible')
|
||||
expect(staleState.following, JSON.stringify(staleState)).toBe('true')
|
||||
expect(staleState.distance).toBeGreaterThan(100)
|
||||
expect(staleState.latestMessageBottom, JSON.stringify(staleState)).toBeGreaterThan(staleState.statusPanelTop)
|
||||
await page.waitForTimeout(1_000)
|
||||
const metrics = await clearanceMetrics(page)
|
||||
await page.screenshot({ path: testInfo.outputPath('task-panel-after-resume.png') })
|
||||
|
||||
expect(metrics.composerHeight, JSON.stringify(metrics)).toBeGreaterThanOrEqual(190)
|
||||
expect(metrics.distanceFromBottom, JSON.stringify(metrics)).toBeLessThan(staleState.distance / 2)
|
||||
expect(metrics.latestMessageBottom, JSON.stringify(metrics)).toBeLessThanOrEqual(metrics.statusPanelTop)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Extended Playwright test fixture that auto-fails any test if an error
|
||||
* banner (notification toast with role="alert") appears in the DOM.
|
||||
*
|
||||
* The desktop app surfaces errors as `[data-slot="alert"][role="alert"]`
|
||||
* elements (see components/notifications.tsx). When one appears during a
|
||||
* test, it means something went wrong (resume failed, boot error, etc.)
|
||||
* — the test should fail with the error message, not silently pass while
|
||||
* an error toast is visible on screen.
|
||||
*
|
||||
* Usage: import { test, expect } from './test' instead of
|
||||
* '@playwright/test'. The guard is auto-installed on every page — no
|
||||
* per-spec setup needed.
|
||||
*/
|
||||
|
||||
import { test as base, expect, type Page, type ElectronApplication, _electron } from '@playwright/test'
|
||||
|
||||
// Track error messages per test so afterEach can assert + report.
|
||||
const seenErrors: string[] = []
|
||||
let activePage: Page | null = null
|
||||
// When true, the afterEach guard skips the error-banner check.
|
||||
// Set by tests that deliberately trigger error states (e.g. boot-failure).
|
||||
let errorBannersAllowed = false
|
||||
|
||||
/**
|
||||
* Opt out of the error-banner guard for the current test. Call in
|
||||
* test.beforeEach or at the top of a test body when error banners are
|
||||
* expected (e.g. boot-failure tests that deliberately trigger errors).
|
||||
*/
|
||||
export function allowErrorBanners(): void {
|
||||
errorBannersAllowed = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the error-banner guard on a page. Watches for `[role="alert"]`
|
||||
* elements appearing in the DOM. When one is found, records its text
|
||||
* content for the afterEach assertion.
|
||||
*
|
||||
* Exported so e2e fixture functions (which create pages via _electron.launch)
|
||||
* can install the guard on their custom pages — the default Playwright `page`
|
||||
* fixture override only catches pages created by Playwright itself, not
|
||||
* pages created by the test's own Electron launch.
|
||||
*/
|
||||
export function installErrorBannerGuard(page: Page): void {
|
||||
activePage = page
|
||||
|
||||
// Clear any errors from a previous test when a new page is created.
|
||||
seenErrors.length = 0
|
||||
|
||||
// Use a MutationObserver to catch error banners as they appear.
|
||||
// We inject this via addInitScript so it runs before any app code.
|
||||
page.addInitScript(() => {
|
||||
const seen: string[] = []
|
||||
;(window as unknown as { __ERROR_BANNER_GUARD__?: string[] }).__ERROR_BANNER_GUARD__ = seen
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
const alerts = document.querySelectorAll('[role="alert"]')
|
||||
|
||||
for (const alert of alerts) {
|
||||
const text = (alert.textContent ?? '').trim()
|
||||
|
||||
if (text && !seen.includes(text)) {
|
||||
seen.push(text)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Start observing once the DOM is ready.
|
||||
if (document.body) {
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
observer.observe(document.body, { childList: true, subtree: true })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Also poll via evaluate — MutationObserver via addInitScript can miss
|
||||
// elements that appear during the Electron renderer's initial mount
|
||||
// (before the observer is installed). A periodic poll catches those.
|
||||
page.on('console', () => {
|
||||
// Console messages are not errors — but we keep the listener to
|
||||
// ensure the page context is active for our evaluate calls.
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for error banners that appeared during the test. Called in
|
||||
* afterEach via the custom fixture below. Also exported so specs that
|
||||
* manage their own page lifecycle can call it directly.
|
||||
*/
|
||||
export async function collectErrorBanners(page: Page | null): Promise<string[]> {
|
||||
if (!page) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
// Read errors collected by the MutationObserver in the page context.
|
||||
const pageErrors = await page.evaluate(() => {
|
||||
const w = window as unknown as { __ERROR_BANNER_GUARD__?: string[] }
|
||||
|
||||
return [...(w.__ERROR_BANNER_GUARD__ ?? [])]
|
||||
})
|
||||
|
||||
// Also do a final DOM scan for any alert elements still visible.
|
||||
const domAlerts = await page
|
||||
.locator('[role="alert"]')
|
||||
.allTextContents()
|
||||
.catch(() => [] as string[])
|
||||
|
||||
const all = [...new Set([...pageErrors, ...domAlerts.map(t => t.trim()).filter(Boolean)])]
|
||||
seenErrors.push(...all)
|
||||
|
||||
return [...new Set(seenErrors)]
|
||||
} catch {
|
||||
// Page might be closed — return whatever we have.
|
||||
return [...new Set(seenErrors)]
|
||||
}
|
||||
}
|
||||
|
||||
// Extended test fixture: wraps the default page with the error guard.
|
||||
export const test = base.extend({
|
||||
// Override the page fixture to auto-install the guard.
|
||||
page: async ({ page }, use) => {
|
||||
installErrorBannerGuard(page)
|
||||
await use(page)
|
||||
},
|
||||
})
|
||||
|
||||
// afterEach: fail the test if any error banners appeared.
|
||||
// Always fires — even if the test already failed for another reason.
|
||||
// An error banner often IS the root cause (e.g. "resume failed" from a
|
||||
// backend bug), and suppressing it when the test also fails on an
|
||||
// assertion hides the real problem.
|
||||
//
|
||||
// Uses `activePage` (set by installErrorBannerGuard) instead of the
|
||||
// default `page` fixture — Electron tests create their own page via
|
||||
// app.firstWindow(), so the default `page` fixture is undefined.
|
||||
base.afterEach(async ({}, testInfo) => {
|
||||
const wasAllowed = errorBannersAllowed
|
||||
// Reset for the next test.
|
||||
errorBannersAllowed = false
|
||||
|
||||
if (wasAllowed) {
|
||||
// Test opted out — clear any collected errors without asserting.
|
||||
seenErrors.length = 0
|
||||
return
|
||||
}
|
||||
|
||||
const errors = await collectErrorBanners(activePage)
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
`Error banner(s) appeared during test "${testInfo.title}":\n` +
|
||||
errors.map(e => ` • ${e}`).join('\n'),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Reset for the next test file.
|
||||
base.afterAll(async () => {
|
||||
seenErrors.length = 0
|
||||
activePage = null
|
||||
})
|
||||
|
||||
export { expect, type Page, type ElectronApplication, _electron }
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* E2E tests for the tile-unread bug — two scenarios:
|
||||
*
|
||||
* 1. TAB (stacked, not visible) — a session opened as a tab via ⌃-click is
|
||||
* NOT visible on screen. When it finishes, the green "unread" dot IS
|
||||
* correct — the user isn't looking at it. This test PASSES.
|
||||
*
|
||||
* 2. SPLIT (side-by-side, visible) — a session dragged to the edge of the
|
||||
* workspace zone opens as a split tile, visible on screen at the same time
|
||||
* as the main session. When it finishes, it should NOT get the green
|
||||
* "unread" dot — the user is looking right at it. This test FAILS until
|
||||
* the fix in session-states.ts:174 lands (the unread check only compares
|
||||
* against $selectedStoredSessionId and ignores $sessionTiles).
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import {
|
||||
type BackgroundReleaseHandle,
|
||||
createBackgroundReleaseHandle,
|
||||
restartMockServer,
|
||||
SIDEBAR_CROSS_TEXTS,
|
||||
} from './mock-server'
|
||||
|
||||
/** Finished-unread dot aria-label. */
|
||||
const UNREAD_DOT_LABEL = 'Finished — unread'
|
||||
/** Background-running dot aria-label. */
|
||||
const BG_DOT_LABEL = 'Background task running'
|
||||
/** Foreground turn-running dot aria-label. */
|
||||
const SESSION_RUNNING_DOT_LABEL = 'Session running'
|
||||
|
||||
/**
|
||||
* The auto-title auxiliary call hits the SAME mock provider as the chat turn,
|
||||
* and its request carries the user's message — trigger keyword included. The
|
||||
* mock's trigger matching is text-based, so the title call consumes a script
|
||||
* index: the real chat turn then gets turn 2 (final answer, NO tool calls),
|
||||
* the background process is never spawned, and the bg dot never appears.
|
||||
* Whether that happens depends on which request lands first — the CI flake
|
||||
* this spec had. Disable auto-title so script indices line up with real chat
|
||||
* turns (same fix as interim-messages.spec.ts).
|
||||
*/
|
||||
const DISABLE_AUTO_TITLE = 'auxiliary:\n title_generation:\n enabled: false'
|
||||
|
||||
/** Locate a session's sidebar row by its preview text. */
|
||||
function sessionRow(page: import('@playwright/test').Page, text: string) {
|
||||
return page.locator('[data-slot="sidebar"] button').filter({ hasText: text }).first()
|
||||
}
|
||||
|
||||
/** Common setup: start a turn with a held bg process + subagent, wait for
|
||||
* the turn to complete, then switch to a new session so the first session is
|
||||
* no longer $selectedStoredSessionId (required before opening a tile). */
|
||||
async function startTurnAndSwitchAway(page: import('@playwright/test').Page) {
|
||||
// Send E2E_SIDEBAR_CROSS — starts a turn with sleep 5 + subagent.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await composer.click()
|
||||
await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Wait for the user's message to appear.
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'),
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
// NOTE: while the turn is busy the dot-state priority paints the session as
|
||||
// "working" ('Session running'), which OUTRANKS the background claim — the
|
||||
// 'Background task running' dot only appears once the turn completes while
|
||||
// the (sentinel-held) process is still alive. Polling for the bg dot mid-turn
|
||||
// races the turn length (two model trips + a real subagent spawn) against
|
||||
// the poll budget, which is exactly the flake this spec had on CI. So: wait
|
||||
// for the turn to END first, then assert the bg dot as a stable state.
|
||||
|
||||
// The final answer text streams before message.complete, so text visibility
|
||||
// alone is not a completion barrier. Wait for the foreground-running state
|
||||
// to clear before asserting the background-process state.
|
||||
await page.waitForFunction(
|
||||
(text) => (document.body.textContent ?? '').includes(text),
|
||||
SIDEBAR_CROSS_TEXTS.finalText,
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${SESSION_RUNNING_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'session running dot should disappear after turn completes' },
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// The background dot must be visible now: the turn is done but the process
|
||||
// is held open by the sentinel, so this is a stable state rather than a
|
||||
// window we have to catch in time. Poll rather than sampling once — the
|
||||
// dot flip is event-driven off the busy=false publish and can land a tick
|
||||
// after the running dot clears.
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'background dot should be visible after turn completes' },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// Switch to a new session — session A is no longer $selectedStoredSessionId.
|
||||
// This is required: openSessionTile bails if the session is already selected.
|
||||
await page.locator('button:has-text("New session")').first().click()
|
||||
await page.waitForTimeout(2000)
|
||||
}
|
||||
|
||||
/** Release the held background process, then wait for its dot to clear. */
|
||||
async function waitForBgProcessToFinish(
|
||||
page: import('@playwright/test').Page,
|
||||
release?: BackgroundReleaseHandle,
|
||||
) {
|
||||
release?.release()
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'background dot should disappear after process finishes' },
|
||||
)
|
||||
.toBe(0)
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Test 1: TAB (not visible) — unread dot IS correct (PASSES)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('sidebar states — tab (hidden) unread is correct', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
const bgRelease = createBackgroundReleaseHandle()
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend({
|
||||
extraConfig: DISABLE_AUTO_TITLE,
|
||||
mockServer: { backgroundReleasePath: bgRelease.path },
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
bgRelease.release()
|
||||
await fixture?.cleanup()
|
||||
bgRelease.cleanup()
|
||||
})
|
||||
|
||||
test('session opened as a tab (not visible) correctly gets unread dot', async () => {
|
||||
const page = fixture.page
|
||||
|
||||
await startTurnAndSwitchAway(page)
|
||||
|
||||
// Evidence: session A is in the background (bg dot in sidebar).
|
||||
await page.screenshot({ path: 'test-results/tile-bug-tab-switched-away.png' })
|
||||
|
||||
// ⌃-click opens the session as a TAB (center dock = stacked, not visible
|
||||
// unless it's the active tab). The session is NOT on screen.
|
||||
//
|
||||
// With auto-title disabled the sidebar row is titled by the user's
|
||||
// message (the trigger keyword), not the assistant's final text.
|
||||
const row = sessionRow(page, 'E2E_SIDEBAR_CROSS')
|
||||
await row.click({ modifiers: ['Control'] })
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Evidence: the tab is open but the session is not visible on screen.
|
||||
await page.screenshot({ path: 'test-results/tile-bug-tab-opened.png' })
|
||||
|
||||
await waitForBgProcessToFinish(page, bgRelease)
|
||||
|
||||
// A tab that's not the active tab IS hidden — the unread dot is correct.
|
||||
// The user is NOT looking at it, so marking it "unread" is right.
|
||||
//
|
||||
// Poll rather than sampling once: "finished-unread" is an event-driven
|
||||
// transition that lands slightly after the running dot clears, and with a
|
||||
// released (rather than slowly-expiring) process there is no incidental
|
||||
// slack between the two. Same reasoning as the cross-session spec.
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'hidden tab should be marked unread' },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
await page.screenshot({ path: 'test-results/tile-bug-tab-unread-correct.png' })
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Test 2: SPLIT (visible) — unread dot is WRONG (FAILS until fix)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.skip('sidebar states — split (visible) unread bug (RED)', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
const bgRelease = createBackgroundReleaseHandle()
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend({
|
||||
extraConfig: DISABLE_AUTO_TITLE,
|
||||
mockServer: { backgroundReleasePath: bgRelease.path },
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
bgRelease.release()
|
||||
await fixture?.cleanup()
|
||||
bgRelease.cleanup()
|
||||
})
|
||||
|
||||
test('session visible in a split tile does NOT get unread dot when it finishes', async () => {
|
||||
const page = fixture.page
|
||||
|
||||
await startTurnAndSwitchAway(page)
|
||||
|
||||
// Evidence: session A is in the background (bg dot in sidebar).
|
||||
await page.screenshot({ path: 'test-results/tile-bug-split-switched-away.png' })
|
||||
|
||||
// Drag the session row from the sidebar to the right edge of the workspace
|
||||
// zone to create a SPLIT (side-by-side) tile. This triggers the real
|
||||
// startSessionDrag → onCommit → openSessionTile(id, 'right', anchor) path.
|
||||
// With auto-title disabled the sidebar row is titled by the user's message.
|
||||
const row = sessionRow(page, 'E2E_SIDEBAR_CROSS')
|
||||
const rowBox = await row.boundingBox()
|
||||
expect(rowBox, 'session row must be visible').not.toBeNull()
|
||||
|
||||
// Find the workspace zone — the main chat area. We drop on its right edge.
|
||||
const workspace = page.locator('[data-session-anchor="workspace"]')
|
||||
const wsBox = await workspace.boundingBox()
|
||||
expect(wsBox, 'workspace zone must be visible').not.toBeNull()
|
||||
|
||||
// Drag from the session row to the right edge of the workspace.
|
||||
// The drag-session's subZonePosition resolves a right-edge drop as 'right'
|
||||
// (a split dock), not 'center' (which would be a composer link).
|
||||
await page.mouse.move(rowBox!.x + rowBox!.width / 2, rowBox!.y + rowBox!.height / 2)
|
||||
await page.mouse.down()
|
||||
// Move in steps so the drag-session's pointermove handler tracks the
|
||||
// position and resolves the drop zone (a single jump can miss the
|
||||
// threshold/engage logic).
|
||||
const targetX = wsBox!.x + wsBox!.width - 20
|
||||
const targetY = wsBox!.y + wsBox!.height / 2
|
||||
const steps = 10
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const x = rowBox!.x + rowBox!.width / 2 + (targetX - (rowBox!.x + rowBox!.width / 2)) * (i / steps)
|
||||
const y = rowBox!.y + rowBox!.height / 2 + (targetY - (rowBox!.y + rowBox!.height / 2)) * (i / steps)
|
||||
await page.mouse.move(x, y)
|
||||
await page.waitForTimeout(30)
|
||||
}
|
||||
await page.mouse.up()
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Evidence: the split tile is now open side-by-side — both sessions visible.
|
||||
await page.screenshot({ path: 'test-results/tile-bug-split-opened.png' })
|
||||
|
||||
await waitForBgProcessToFinish(page, bgRelease)
|
||||
|
||||
// THE BUG: the session visible in the split tile should NOT have the green
|
||||
// "finished unread" dot — the user is looking right at it. This assertion
|
||||
// FAILS until the fix in session-states.ts:174 lands.
|
||||
const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count()
|
||||
expect(unreadCount, 'session visible in a split tile should NOT be marked unread').toBe(0)
|
||||
|
||||
// Evidence: the green dot should NOT be here — this screenshot shows the bug.
|
||||
await page.screenshot({ path: 'test-results/tile-bug-split-unread-should-not-exist.png' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* E2E test for PERSISTED unread state — the green "Finished — unread" dot
|
||||
* must survive an app restart.
|
||||
*
|
||||
* Regression coverage for the reset-on-restart bug: the unread flag used to
|
||||
* live only in the in-memory `$unreadFinishedSessionIds` atom, so closing and
|
||||
* reopening the desktop app grayed out every green dot. The persisted layer
|
||||
* (src/store/session-unread.ts) now rebuilds the dot from localStorage-backed
|
||||
* finish markers + seen-count watermarks.
|
||||
*
|
||||
* The scenario uses TWO sessions on purpose: with a single session the app
|
||||
* can reopen straight into it after a restart, which acks the session (the
|
||||
* user is looking at it) and would mask the dot. Session A finishes in the
|
||||
* background while session B is the selected one; the restart reopens into
|
||||
* B, so A's dot is observable.
|
||||
*
|
||||
* 1. Session A: start a turn, hold its stream open.
|
||||
* 2. Session B: new session, send a message, let it finish while SELECTED.
|
||||
* 3. Release A's stream → its background finish paints A's green dot.
|
||||
* 4. QUIT the app, relaunch on the SAME sandbox → A's dot must still be
|
||||
* there (previously it was lost).
|
||||
* 5. Open session A → dot clears.
|
||||
* 6. Restart once more → the cleared state must persist too (no zombie dot).
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, type Page, test } from '@playwright/test'
|
||||
import { type ElectronApplication } from '@playwright/test'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
launchDesktop,
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { restartMockServer } from './mock-server'
|
||||
|
||||
/** Finished-unread dot aria-label (from i18n en.ts). */
|
||||
const UNREAD_DOT_LABEL = 'Finished — unread'
|
||||
|
||||
/** Held prompt — the mock pauses this stream until we release it, so the
|
||||
* turn is deterministically still running when we switch away. */
|
||||
const HELD_PROMPT = 'E2E unread restart: hold this stream until released.'
|
||||
/** Second session's prompt — completes normally while selected. */
|
||||
const SECOND_PROMPT = 'E2E unread restart: second session, read while open.'
|
||||
|
||||
const unreadDots = (page: Page) => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`)
|
||||
|
||||
async function sendMessage(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
test.describe('unread dot survives app restart', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
// Three full app boots in one scenario — give it more than the global 90s.
|
||||
test.setTimeout(300_000)
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
let app: ElectronApplication
|
||||
let page: Page
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend({
|
||||
mockServer: { holdFirstStreamForPrompt: HELD_PROMPT },
|
||||
})
|
||||
app = fixture.app
|
||||
page = fixture.page
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
// The fixture's own app handle may already be closed by the restart
|
||||
// steps — close whatever is current, then drop the sandbox + mock.
|
||||
try {
|
||||
await app?.close()
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
|
||||
fixture?.mock.close()
|
||||
fixture?.sandbox.cleanup()
|
||||
})
|
||||
|
||||
/** Relaunch the desktop app against the SAME sandbox (same userData →
|
||||
* same localStorage, same HERMES_HOME → same session store). */
|
||||
async function restartApp(): Promise<void> {
|
||||
await app.close()
|
||||
|
||||
const relaunched = await launchDesktop(buildAppEnv(fixture.sandbox))
|
||||
app = relaunched.app
|
||||
page = relaunched.page
|
||||
await waitForAppReady({ ...fixture, app, page }, 120_000)
|
||||
}
|
||||
|
||||
test('green dot persists across restart and its clear persists too', async () => {
|
||||
// ── 1. Session A: start a turn whose stream the mock holds open ────
|
||||
await sendMessage(page, HELD_PROMPT)
|
||||
await fixture.mock.waitForHeldStream()
|
||||
|
||||
// ── 2. Session B: complete a turn while SELECTED (stays read) ──────
|
||||
await page.locator('button:has-text("New session")').first().click()
|
||||
await sendMessage(page, SECOND_PROMPT)
|
||||
|
||||
// B's reply lands in the open transcript — this session is "read".
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('Hello from the mock inference server'),
|
||||
undefined,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
|
||||
// ── 3. Release A's stream → background finish paints A's dot ──────
|
||||
fixture.mock.releaseHeldStream()
|
||||
|
||||
await expect
|
||||
.poll(() => unreadDots(page).count(), {
|
||||
timeout: 30_000,
|
||||
message: 'green unread dot should appear after the background finish',
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// ── 4. Restart the app — the dot must survive ──────────────────────
|
||||
// The app reopens into session B (or a fresh draft), NOT session A, so
|
||||
// A's dot is observable rather than being acked by the route restore.
|
||||
await restartApp()
|
||||
|
||||
await expect
|
||||
.poll(() => unreadDots(page).count(), {
|
||||
timeout: 60_000,
|
||||
message: 'green unread dot should be rebuilt from persisted state after restart',
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// ── 5. Open session A — the dot clears ─────────────────────────────
|
||||
// The dot sits inside A's sidebar row button; click that row.
|
||||
await unreadDots(page)
|
||||
.first()
|
||||
.locator('xpath=ancestor::button[1]')
|
||||
.click()
|
||||
|
||||
await expect
|
||||
.poll(() => unreadDots(page).count(), {
|
||||
timeout: 15_000,
|
||||
message: 'opening the session should clear its unread dot',
|
||||
})
|
||||
.toBe(0)
|
||||
|
||||
// ── 6. Restart again — the CLEARED state must persist as well ──────
|
||||
await restartApp()
|
||||
|
||||
// Give the sidebar a moment to load rows, then assert no dot returns.
|
||||
await page.waitForSelector('[data-slot="sidebar"]', { timeout: 60_000 })
|
||||
await page.waitForTimeout(5_000)
|
||||
expect(await unreadDots(page).count(), 'acked session must stay read after restart').toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Visual snapshot helper — wraps `toHaveScreenshot` so visual diffs are
|
||||
* reported without failing the test suite.
|
||||
*
|
||||
* On CI, the JSON reporter + post-test script parse the results and post a
|
||||
* summary to the GitHub Actions step output, and diff images are uploaded
|
||||
* as artifacts. This keeps visual regressions visible without gating PRs
|
||||
* on pixel-perfect matches.
|
||||
*
|
||||
* The actual screenshot is always written to the test output dir so CI
|
||||
* artifacts include every screenshot — not just the ones that diffed.
|
||||
* When it differs, this helper also writes expected and diff images:
|
||||
* <name>-actual.png, <name>-expected.png, <name>-diff.png
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { type ElectronApplication, type Page, test } from '@playwright/test'
|
||||
|
||||
/** Fixed window dimensions for visual regression screenshots. */
|
||||
export const VISUAL_WINDOW_WIDTH = 1220
|
||||
export const VISUAL_WINDOW_HEIGHT = 800
|
||||
|
||||
export interface VisualSnapshotOptions {
|
||||
/** Snapshot name — defaults to the test title. */
|
||||
name?: string
|
||||
/** Full page screenshot vs. viewport-only (default). */
|
||||
fullPage?: boolean
|
||||
/** Timeout in ms. */
|
||||
timeout?: number
|
||||
/** The Electron app handle — used to size and decode screenshots. */
|
||||
app: ElectronApplication
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the Electron window to a fixed size so screenshots are comparable
|
||||
* across runs and CI environments. Window managers (Hyprland, etc.) may
|
||||
* auto-tile or resize windows after launch; calling this right before the
|
||||
* screenshot ensures the viewport is always the expected size.
|
||||
*/
|
||||
async function forceFixedSize(app: ElectronApplication): Promise<void> {
|
||||
await app.evaluate(({ BrowserWindow }, { width, height }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0]
|
||||
|
||||
if (win) {
|
||||
win.unmaximize()
|
||||
// setMinimumSize must be ≤ the target, otherwise setSize is clamped.
|
||||
win.setMinimumSize(width, height)
|
||||
win.setSize(width, height, false)
|
||||
win.setBounds({ x: 0, y: 0, width, height })
|
||||
}
|
||||
}, { width: VISUAL_WINDOW_WIDTH, height: VISUAL_WINDOW_HEIGHT })
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot and compare it against the baseline.
|
||||
*
|
||||
* If the baseline doesn't exist yet (first run), Playwright creates it.
|
||||
* If it differs, the test logs a soft warning but does NOT fail — the diff
|
||||
* images are still generated for CI to surface.
|
||||
*/
|
||||
export async function expectVisualSnapshot(
|
||||
page: Page,
|
||||
options: VisualSnapshotOptions,
|
||||
): Promise<void> {
|
||||
const { name, fullPage = false, timeout = 30_000, app } = options
|
||||
|
||||
// Force the window to a fixed size right before the screenshot so it's
|
||||
// always comparable, regardless of WM resizing during the test.
|
||||
await forceFixedSize(app)
|
||||
// Give the renderer a moment to relayout after the resize.
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Playwright appends a platform suffix (e.g. "-linux") and requires
|
||||
// a .png extension on the name argument. Auto-append it if missing.
|
||||
const snapshotName = name ? (name.endsWith('.png') ? name : `${name}.png`) : undefined
|
||||
|
||||
const info = test.info()
|
||||
const actual = await page.screenshot({ animations: 'disabled', caret: 'hide', fullPage, timeout })
|
||||
const baselinePath = info.snapshotPath(snapshotName ?? `${info.title}.png`)
|
||||
const outputName = (snapshotName ?? 'snapshot.png').replace(/\.png$/, '')
|
||||
|
||||
if (info.config.updateSnapshots === 'all' || info.config.updateSnapshots === 'changed') {
|
||||
fs.mkdirSync(path.dirname(baselinePath), { recursive: true })
|
||||
fs.writeFileSync(baselinePath, actual)
|
||||
// Also write to the output dir so CI artifacts include the screenshot.
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
|
||||
console.log(`[visual-baseline] updated ${baselinePath}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(baselinePath)) {
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
|
||||
console.log(`[visual-diff] ${name ?? '(unnamed)'} — no baseline available`)
|
||||
return
|
||||
}
|
||||
|
||||
const expected = fs.readFileSync(baselinePath)
|
||||
const comparison = await app.evaluate(
|
||||
({ nativeImage }, images) => {
|
||||
const actualImage = nativeImage.createFromBuffer(Buffer.from(images.actual, 'base64'))
|
||||
const expectedImage = nativeImage.createFromBuffer(Buffer.from(images.expected, 'base64'))
|
||||
const actualSize = actualImage.getSize()
|
||||
const expectedSize = expectedImage.getSize()
|
||||
|
||||
if (actualSize.width !== expectedSize.width || actualSize.height !== expectedSize.height) {
|
||||
return { mismatchRatio: 1, diff: images.actual }
|
||||
}
|
||||
|
||||
const actualPixels = actualImage.toBitmap()
|
||||
const expectedPixels = expectedImage.toBitmap()
|
||||
const diffPixels = Buffer.alloc(actualPixels.length)
|
||||
let mismatched = 0
|
||||
|
||||
for (let i = 0; i < actualPixels.length; i += 4) {
|
||||
const different =
|
||||
Math.abs(actualPixels[i] - expectedPixels[i]) > 51 ||
|
||||
Math.abs(actualPixels[i + 1] - expectedPixels[i + 1]) > 51 ||
|
||||
Math.abs(actualPixels[i + 2] - expectedPixels[i + 2]) > 51 ||
|
||||
Math.abs(actualPixels[i + 3] - expectedPixels[i + 3]) > 51
|
||||
|
||||
if (different) {
|
||||
mismatched++
|
||||
diffPixels[i + 2] = 255
|
||||
}
|
||||
diffPixels[i + 3] = 255
|
||||
}
|
||||
|
||||
return {
|
||||
mismatchRatio: mismatched / (actualPixels.length / 4),
|
||||
diff: nativeImage.createFromBitmap(diffPixels, actualSize).toPNG().toString('base64'),
|
||||
}
|
||||
},
|
||||
{ actual: actual.toString('base64'), expected: expected.toString('base64') },
|
||||
)
|
||||
|
||||
// Always write the actual screenshot to the output dir so CI artifacts
|
||||
// include every screenshot — not just the ones that diffed.
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
|
||||
|
||||
if (comparison.mismatchRatio <= 0.01) {
|
||||
return
|
||||
}
|
||||
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-expected.png`), expected)
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-diff.png`), Buffer.from(comparison.diff, 'base64'))
|
||||
console.log(
|
||||
`[visual-diff] ${name ?? '(unnamed)'} — ${(comparison.mismatchRatio * 100).toFixed(2)}% of pixels differ`,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
/**
|
||||
* E2E regression: warm-route resume must not re-render the transcript more
|
||||
* than once.
|
||||
*
|
||||
* When a session is already in the runtime-id cache (the "warm" path in
|
||||
* `resumeSession()`), clicking its sidebar row should paint the transcript
|
||||
* exactly once. Before the fix, the warm cache painted via
|
||||
* `syncSessionStateToView`, then the `session.activate` RPC returned a
|
||||
* reconciled message list with different message object references, causing
|
||||
* `syncSessionStateToView` to fire a second `setMessages` — a visual
|
||||
* flicker as the transcript DOM was updated.
|
||||
*
|
||||
* This test pre-seeds a session into state.db, boots the app,
|
||||
* clicks the session (cold resume — populates the warm cache), navigates
|
||||
* away to a new chat, then clicks back (warm resume). Two detectors run:
|
||||
*
|
||||
* 1. A MutationObserver counts additive DOM mutation bursts (childList
|
||||
* additions). More than 1 burst = the transcript was repainted.
|
||||
*
|
||||
* 2. A 2ms innerHTML-length poll counts "reconciles" — DOM content changes
|
||||
* that happen AFTER the initial paint, while messages are already on
|
||||
* screen. This catches the case where React reconciles by key without
|
||||
* adding/removing nodes (same keys → in-place prop update → no
|
||||
* MutationObserver burst), but `$messages` was still set twice.
|
||||
*
|
||||
* The test passes when bursts === 1 AND reconciles === 0.
|
||||
* The sidebar "+" keeps the session warm in another tab. Its reactivation
|
||||
* follows the same contract: one additive paint and zero reconciles.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from './test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
waitForAppReady,
|
||||
createSandbox,
|
||||
writeMockProviderConfig,
|
||||
writeEnvFile,
|
||||
buildAppEnv,
|
||||
launchDesktop,
|
||||
} from './fixtures'
|
||||
import { startMockServer } from './mock-server'
|
||||
import { RealSessionBuilder } from './real-session-builder'
|
||||
|
||||
const SESSION_TITLE = 'E2E Warm Resume Jitter Test'
|
||||
|
||||
// Inactive tabs stay mounted under a data-pane-hidden ancestor. Match the
|
||||
// renderer's keep-alive visibility policy instead of relying on DOM order.
|
||||
const SURFACE = '[data-composer-target]:not([data-pane-hidden] [data-composer-target])'
|
||||
const ALL_SURFACES = '[data-composer-target]'
|
||||
/**
|
||||
* 16 messages (8 user/assistant pairs) — enough DOM churn for detection while
|
||||
* still fitting a hot-hidden pane's retention budget. A kept-alive pane keeps
|
||||
* only its live tail (HIDDEN_TRANSCRIPT_RENDER_BUDGET = 40 weight units in
|
||||
* thread/list.tsx); 16 short messages ≈ 32 units, so the whole transcript
|
||||
* survives hiding. Above the budget, reveal legitimately backfills trimmed
|
||||
* turns (additive DOM bursts) — that is paging, not the repaint bug this
|
||||
* suite hunts, and it would drown the detectors.
|
||||
*/
|
||||
const MESSAGE_COUNT = 16
|
||||
/** Seeded PRNG so the generated content is deterministic across runs. */
|
||||
const RNG_SEED = 42
|
||||
|
||||
/** Mulberry32 — tiny deterministic PRNG. */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed
|
||||
return () => {
|
||||
a |= 0
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate ~40 chars of gibberish from a seeded PRNG. */
|
||||
function gibberish(rng: () => number): string {
|
||||
const len = 30 + Math.floor(rng() * 20)
|
||||
let s = ''
|
||||
for (let i = 0; i < len; i++) {
|
||||
s += String.fromCharCode(97 + Math.floor(rng() * 26))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
/** First user message — used as a wait target in the test. */
|
||||
const FIRST_USER_MSG = gibberish(mulberry32(RNG_SEED))
|
||||
|
||||
/**
|
||||
* Generate the user turns for a real session. The mock provider produces the
|
||||
* assistant side of each pair through the normal AIAgent persistence path.
|
||||
*/
|
||||
function generateSessionTurns(): string[] {
|
||||
const rng = mulberry32(RNG_SEED)
|
||||
const turns: string[] = []
|
||||
|
||||
for (let i = 0; i < MESSAGE_COUNT / 2; i++) {
|
||||
turns.push(gibberish(rng))
|
||||
gibberish(rng)
|
||||
}
|
||||
|
||||
return turns
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock-backend sandbox with a real persisted session in state.db.
|
||||
*
|
||||
* Unlike the shared `setupMockBackend()`, this variant creates the session
|
||||
* through the real stdio gateway before launching desktop so the session is
|
||||
* visible in the sidebar on first load.
|
||||
*/
|
||||
async function setupSeededMockBackend(): Promise<MockBackendFixture> {
|
||||
// 1. Start mock server
|
||||
const mock = await startMockServer()
|
||||
|
||||
// 2. Create sandbox + write config
|
||||
const sandbox = createSandbox('warm-seed')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
// 3. Produce all 16 user/assistant pairs through the real TUI gateway,
|
||||
// AIAgent, mock provider, and SessionDB persistence path before desktop starts.
|
||||
const builder = await RealSessionBuilder.start(sandbox.hermesHome)
|
||||
try {
|
||||
await builder.createSession({ title: SESSION_TITLE, turns: generateSessionTurns() })
|
||||
} finally {
|
||||
await builder.close()
|
||||
}
|
||||
|
||||
// 4. Build env + launch
|
||||
const env = buildAppEnv(sandbox)
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupSeededMockBackend()
|
||||
await waitForAppReady(fixture!, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
/**
|
||||
* Install a MutationObserver + text-content poll on the thread viewport
|
||||
* to detect re-renders after the initial paint. Returns nothing — call
|
||||
* `readRenderCount` to stop and collect results.
|
||||
*
|
||||
* - MutationObserver: counts additive childList bursts (5ms coalescing).
|
||||
* - Text-content poll: counts "reconciles" — first-message text changes
|
||||
* after the initial paint, catching key-based reconciles that don't
|
||||
* add/remove nodes.
|
||||
*/
|
||||
async function installRenderCounter(
|
||||
page: import('@playwright/test').Page,
|
||||
transcriptText?: string,
|
||||
): Promise<void> {
|
||||
await page.evaluate(([visibleSelector, allSelector, expected]: [string, string, string | undefined]) => {
|
||||
const surfaces = [...document.querySelectorAll(expected ? allSelector : visibleSelector)]
|
||||
const surface = expected
|
||||
? surfaces.find(candidate =>
|
||||
(candidate.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
)
|
||||
: surfaces.at(-1)
|
||||
const viewport = surface?.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) {
|
||||
const diag = [...document.querySelectorAll(allSelector)].map(s => ({
|
||||
hidden: Boolean(s.closest('[data-pane-hidden]')),
|
||||
target: s.getAttribute('data-composer-target'),
|
||||
hasViewport: Boolean(s.querySelector('[data-slot="aui_thread-viewport"]')),
|
||||
textLen: (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').length,
|
||||
head: (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').slice(0, 80),
|
||||
tail: (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').slice(-80),
|
||||
includesExpected: expected ? (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected) : null,
|
||||
}))
|
||||
throw new Error('Thread viewport not found before warm resume DIAG=' + JSON.stringify(diag) + ' expected=' + expected)
|
||||
}
|
||||
|
||||
const state = { bursts: 0, mutations: 0, timeline: [] as number[], stopped: false, reconciles: 0 }
|
||||
const debugWindow = window as unknown as {
|
||||
__RENDER_COUNT__: typeof state
|
||||
__RENDER_VIEWPORT__: Element
|
||||
}
|
||||
debugWindow.__RENDER_COUNT__ = state
|
||||
debugWindow.__RENDER_VIEWPORT__ = viewport
|
||||
|
||||
let currentBatch = 0
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const flush = () => {
|
||||
flushTimer = null
|
||||
if (currentBatch > 0 && !state.stopped) {
|
||||
state.bursts += 1
|
||||
state.timeline.push(currentBatch)
|
||||
currentBatch = 0
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(records => {
|
||||
if (state.stopped) return
|
||||
let batchAdded = 0
|
||||
for (const record of records) {
|
||||
state.mutations += 1
|
||||
if (record.type === 'childList' && record.addedNodes.length > 0) {
|
||||
batchAdded += 1
|
||||
}
|
||||
}
|
||||
if (batchAdded > 0) {
|
||||
currentBatch += batchAdded
|
||||
if (flushTimer) clearTimeout(flushTimer)
|
||||
flushTimer = setTimeout(flush, 5)
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(viewport, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: false,
|
||||
characterData: false,
|
||||
})
|
||||
|
||||
// Poll the first message's text content every 2ms. The MutationObserver
|
||||
// only catches childList additions; React may reconcile by key without
|
||||
// adding/removing nodes (same keys → in-place prop update → no childList
|
||||
// mutation). The poll catches this by detecting text content changes in
|
||||
// the first message after the initial paint. Metadata-only changes (model
|
||||
// name, busy indicator) don't affect message text, so they don't produce
|
||||
// false positives.
|
||||
const contentEl = viewport.querySelector('[data-slot="aui_thread-content"]') ?? viewport
|
||||
let lastFirstMsgText = ''
|
||||
let hasMessages = false
|
||||
const pollInterval = setInterval(() => {
|
||||
if (state.stopped) {
|
||||
clearInterval(pollInterval)
|
||||
return
|
||||
}
|
||||
const firstMsg = contentEl.querySelector('[data-role="message"], [data-message-id]')
|
||||
const firstMsgText = firstMsg?.textContent ?? ''
|
||||
if (firstMsgText && firstMsgText !== lastFirstMsgText) {
|
||||
if (hasMessages) {
|
||||
state.reconciles = (state.reconciles ?? 0) + 1
|
||||
}
|
||||
lastFirstMsgText = firstMsgText
|
||||
hasMessages = true
|
||||
}
|
||||
}, 2)
|
||||
}, [SURFACE, ALL_SURFACES, transcriptText] as [string, string, string | undefined])
|
||||
}
|
||||
|
||||
/** Wait until the ACTIVE chat surface's transcript contains `text`. */
|
||||
async function waitForActiveTranscriptText(
|
||||
page: import('@playwright/test').Page,
|
||||
text: string,
|
||||
timeout = 30_000,
|
||||
): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
([expected, surfaceSelector]: [string, string]) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const active = surfaces[surfaces.length - 1]
|
||||
|
||||
return (active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected)
|
||||
},
|
||||
[text, SURFACE] as [string, string],
|
||||
{ timeout },
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForActiveTranscriptWithoutText(
|
||||
page: import('@playwright/test').Page,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
([expected, surfaceSelector]: [string, string]) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const active = surfaces[surfaces.length - 1]
|
||||
|
||||
return !(active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected)
|
||||
},
|
||||
[text, SURFACE] as [string, string],
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
/** Replace the primary surface with a draft while retaining its warm cache. */
|
||||
async function openFreshDraft(page: import('@playwright/test').Page, priorText: string): Promise<void> {
|
||||
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+N' : 'Control+N')
|
||||
await waitForActiveTranscriptWithoutText(page, priorText)
|
||||
}
|
||||
|
||||
/** Stack an empty tab while leaving the current transcript mounted and warm. */
|
||||
async function openNewSessionTab(page: import('@playwright/test').Page, priorText: string): Promise<void> {
|
||||
await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
|
||||
await waitForActiveTranscriptWithoutText(page, priorText)
|
||||
}
|
||||
|
||||
/** Stop the render counter and return the recorded burst/reconcile counts. */
|
||||
async function readRenderCount(page: import('@playwright/test').Page): Promise<{
|
||||
bursts: number
|
||||
mutations: number
|
||||
timeline: number[]
|
||||
reconciles: number
|
||||
} | null> {
|
||||
return page.evaluate(() => {
|
||||
type RenderCount = { bursts: number; mutations: number; timeline: number[]; stopped: boolean; reconciles: number }
|
||||
const w = window as unknown as { __RENDER_COUNT__?: RenderCount }
|
||||
const rc = w.__RENDER_COUNT__
|
||||
if (rc) {
|
||||
rc.stopped = true
|
||||
}
|
||||
return rc ? { bursts: rc.bursts, mutations: rc.mutations, timeline: rc.timeline, reconciles: rc.reconciles } : null
|
||||
})
|
||||
}
|
||||
|
||||
async function observedViewportIsActive(page: import('@playwright/test').Page): Promise<boolean> {
|
||||
return page.evaluate((surfaceSelector: string) => {
|
||||
const surfaces = document.querySelectorAll(surfaceSelector)
|
||||
const activeViewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
const observedViewport = (window as unknown as { __RENDER_VIEWPORT__?: Element }).__RENDER_VIEWPORT__
|
||||
|
||||
return activeViewport === observedViewport
|
||||
}, SURFACE)
|
||||
}
|
||||
|
||||
/** A kept-alive tab must become visible without rebuilding its transcript. */
|
||||
function assertNoRepaint(result: { bursts: number; mutations: number; timeline: number[]; reconciles: number } | null): void {
|
||||
expect(result, 'MutationObserver should have recorded render data').toBeTruthy()
|
||||
expect(
|
||||
result!.bursts,
|
||||
`Expected no additive render bursts for a kept-alive tab, but got ${result!.bursts}. ` +
|
||||
`Mutation timeline: ${JSON.stringify(result!.timeline)}.`,
|
||||
).toBe(0)
|
||||
expect(
|
||||
result!.reconciles,
|
||||
`Expected no transcript reconciles for a kept-alive tab, but got ${result!.reconciles}.`,
|
||||
).toBe(0)
|
||||
}
|
||||
|
||||
/** Assert the render counter shows exactly one paint with no re-renders. */
|
||||
function assertNoJitter(result: { bursts: number; mutations: number; timeline: number[]; reconciles: number } | null): void {
|
||||
expect(result, 'MutationObserver should have recorded render data').toBeTruthy()
|
||||
expect(
|
||||
result!.bursts,
|
||||
`Expected 1 additive render burst (single paint), but got ${result!.bursts} bursts. ` +
|
||||
`Mutation timeline: ${JSON.stringify(result!.timeline)}.`,
|
||||
).toBe(1)
|
||||
expect(
|
||||
result!.reconciles,
|
||||
`Expected 0 reconciles (no re-render after initial paint), but got ${result!.reconciles}. ` +
|
||||
`This means the warm-route resume re-rendered the transcript after the initial paint ` +
|
||||
`— the "warm resume jitter" bug is present.`,
|
||||
).toBe(0)
|
||||
}
|
||||
|
||||
test('tab reactivation preserves the mounted transcript without repainting', async ({}, testInfo) => {
|
||||
const page = fixture!.page
|
||||
|
||||
// Wait for the sidebar to populate with our seeded session.
|
||||
const sessionRow = page
|
||||
.locator('[data-slot="sidebar"] button')
|
||||
.filter({ hasText: SESSION_TITLE })
|
||||
.first()
|
||||
await sessionRow.waitFor({ state: 'visible', timeout: 60_000 })
|
||||
|
||||
// Step 1: Cold resume — click the session row to load it.
|
||||
// This populates the warm cache (runtimeIdByStoredSessionId + sessionStateByRuntimeId).
|
||||
await sessionRow.click()
|
||||
|
||||
// Wait for the transcript to appear — the first user message text confirms
|
||||
// the cold-path prefetch painted.
|
||||
await waitForActiveTranscriptText(page, FIRST_USER_MSG)
|
||||
|
||||
// Wait for the session to fully settle (cold-path RPC + reconciliation).
|
||||
await page.waitForTimeout(2_000)
|
||||
|
||||
// Stack a new tab, then observe the seeded transcript while it is hidden.
|
||||
// Installing after the switch isolates reactivation from mutations caused
|
||||
// while the new tab was being created.
|
||||
await openNewSessionTab(page, FIRST_USER_MSG)
|
||||
await page.waitForTimeout(500)
|
||||
await installRenderCounter(page, FIRST_USER_MSG)
|
||||
|
||||
// Step 3: Click back and verify the same kept-alive viewport becomes active
|
||||
// without rebuilding or reconciling its transcript.
|
||||
await sessionRow.click()
|
||||
|
||||
await waitForActiveTranscriptText(page, FIRST_USER_MSG)
|
||||
await page.waitForTimeout(2_000)
|
||||
expect(await observedViewportIsActive(page), 'Reactivation should reveal the observed kept-alive viewport').toBe(true)
|
||||
|
||||
const result = await readRenderCount(page)
|
||||
await page.screenshot({ path: testInfo.outputPath('warm-resume-idle.png') })
|
||||
assertNoRepaint(result)
|
||||
})
|
||||
|
||||
test('warm-route resume after background inference completes (no jitter)', async ({}, testInfo) => {
|
||||
test.fixme(
|
||||
true,
|
||||
'Warm resume repaints after inference: expected one additive burst, got two ([18,1]).',
|
||||
)
|
||||
|
||||
const page = fixture!.page
|
||||
const { mock } = fixture!
|
||||
|
||||
// Wait for the sidebar to populate with our seeded session.
|
||||
const sessionRow = page
|
||||
.locator('[data-slot="sidebar"] button')
|
||||
.filter({ hasText: SESSION_TITLE })
|
||||
.first()
|
||||
await sessionRow.waitFor({ state: 'visible', timeout: 60_000 })
|
||||
|
||||
// Step 1: Cold resume — populate the warm cache.
|
||||
await sessionRow.click()
|
||||
await waitForActiveTranscriptText(page, FIRST_USER_MSG)
|
||||
await page.waitForTimeout(2_000)
|
||||
|
||||
// Step 2: Send a message — triggers inference via the mock server.
|
||||
const PROMPT = 'E2E post-inference warm resume test prompt'
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type(PROMPT, { delay: 10 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Wait for the mock response to appear in the transcript, confirming
|
||||
// the turn completed and message.complete fired (which updates the warm
|
||||
// cache via updateSessionState).
|
||||
await waitForActiveTranscriptText(page, 'mock inference server', 60_000)
|
||||
// Extra settle for message.complete → updateSessionState → cache write.
|
||||
await page.waitForTimeout(2_000)
|
||||
|
||||
// Verify the prompt was received by the mock server.
|
||||
expect(mock.receivedPrompts).toContain(PROMPT)
|
||||
|
||||
// Step 3: Replace the primary chat; the warm cache retains the updated messages.
|
||||
await openFreshDraft(page, PROMPT)
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Step 4: Install render counter, click back (warm resume), wait, assert.
|
||||
await installRenderCounter(page)
|
||||
await sessionRow.click()
|
||||
|
||||
// Wait for the transcript to reappear — the warm cache should already
|
||||
// have the completed turn (updated by message.complete events).
|
||||
await waitForActiveTranscriptText(page, FIRST_USER_MSG)
|
||||
|
||||
// Wait for at least 1 burst, then settle.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const w = window as unknown as { __RENDER_COUNT__?: { bursts: number } }
|
||||
return Boolean(w.__RENDER_COUNT__ && w.__RENDER_COUNT__.bursts > 0)
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
await page.waitForTimeout(2_000)
|
||||
|
||||
const result = await readRenderCount(page)
|
||||
await page.screenshot({ path: testInfo.outputPath('warm-resume-post-inference.png') })
|
||||
assertNoJitter(result)
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
type MockBackendFixture,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig,
|
||||
} from './fixtures'
|
||||
import { startMockServer } from './mock-server'
|
||||
import { expect, test } from './test'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
const BRANCH_NAME = 'e2e-composer-branch'
|
||||
|
||||
/**
|
||||
* Enough branches to make both the base-branch popover and the convert-branch
|
||||
* list taller than their default height. That is the condition in which the
|
||||
* dialog's own scroll box clips the popover, and that regression is what the
|
||||
* visual snapshots here guard against.
|
||||
*/
|
||||
const EXTRA_BRANCHES = [
|
||||
'feature/alpha-one',
|
||||
'feature/beta-two',
|
||||
'feature/gamma-three',
|
||||
'fix/delta-four',
|
||||
'fix/epsilon-five',
|
||||
'chore/zeta-six',
|
||||
'chore/eta-seven',
|
||||
'spike/theta-eight',
|
||||
'spike/iota-nine',
|
||||
'release/kappa-ten',
|
||||
]
|
||||
|
||||
function createGitRepo(root: string): string {
|
||||
const repo = path.join(root, 'repo')
|
||||
|
||||
fs.mkdirSync(repo, { recursive: true })
|
||||
execFileSync('git', ['init', '--initial-branch=main'], { cwd: repo })
|
||||
execFileSync('git', ['config', 'user.email', 'e2e@example.com'], { cwd: repo })
|
||||
execFileSync('git', ['config', 'user.name', 'Hermes E2E'], { cwd: repo })
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), '# E2E repo\n', 'utf8')
|
||||
execFileSync('git', ['add', 'README.md'], { cwd: repo })
|
||||
execFileSync('git', ['commit', '-m', 'initial'], { cwd: repo })
|
||||
|
||||
for (const branch of EXTRA_BRANCHES) {
|
||||
execFileSync('git', ['branch', branch], { cwd: repo })
|
||||
}
|
||||
|
||||
return repo
|
||||
}
|
||||
|
||||
function configureRepoCwd(hermesHome: string, mockUrl: string, repo: string): void {
|
||||
writeMockProviderConfig(hermesHome, mockUrl)
|
||||
fs.appendFileSync(path.join(hermesHome, 'config.yaml'), `\nterminal:\n cwd: ${repo}\n`, 'utf8')
|
||||
writeEnvFile(hermesHome)
|
||||
}
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
/** A dialog renders as `[data-slot="dialog-content"]` (components/ui/dialog.tsx). */
|
||||
const DIALOG = '[data-slot="dialog-content"]'
|
||||
|
||||
/** Open the worktree dialog with the global ⌘⇧B / ctrl+shift+B hotkey. */
|
||||
async function openWorktreeDialog(): Promise<void> {
|
||||
const page = fixture!.page
|
||||
await page.keyboard.press('Control+Shift+B')
|
||||
await expect(page.locator(DIALOG)).toBeVisible()
|
||||
}
|
||||
|
||||
/** Close the open dialog and wait until it leaves the DOM. */
|
||||
async function closeDialog(): Promise<void> {
|
||||
const page = fixture!.page
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator(DIALOG)).toHaveCount(0)
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const sandbox = createSandbox('worktree-branch-status')
|
||||
const repo = createGitRepo(sandbox.root)
|
||||
const mock = await startMockServer()
|
||||
|
||||
configureRepoCwd(sandbox.hermesHome, mock.url, repo)
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
fixture = {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
// The coding rail, and thus the ⌘⇧B worktree dialog, mounts only after the
|
||||
// session resolves a cwd that holds a repo. This happens on the first turn.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type('create a repo-backed e2e session', { delay: 2 })
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForFunction(
|
||||
prompt => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(prompt),
|
||||
'create a repo-backed e2e session',
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
await expect(page.locator('.coding-status-bar')).toContainText('main')
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('worktree dialog renders the base-branch picker over the dialog, not clipped by it', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
await openWorktreeDialog()
|
||||
|
||||
// Open the base-branch combobox. With 11 branches, the list is taller than
|
||||
// the space below the trigger. A popover that portals into the dialog's
|
||||
// `overflow-y-auto` box is therefore cut off. This snapshot catches that bug.
|
||||
await page.getByRole('button', { name: /branch off/i }).click()
|
||||
await expect(page.getByPlaceholder('Search branches…')).toBeVisible()
|
||||
await expect(page.getByRole('option', { name: 'feature/alpha-one' })).toBeVisible()
|
||||
|
||||
await expectVisualSnapshot(page, { name: 'worktree-dialog-base-branch-picker', app: fixture!.app })
|
||||
|
||||
// This check does not depend on pixels: the dialog's scroll box must not crop
|
||||
// the painted box of the popover. Measure the geometry, so a headless run
|
||||
// fails on this regression before a person looks at a diff image.
|
||||
const clipped = await page.evaluate(() => {
|
||||
const popover = document.querySelector('[data-slot="popover-content"]')
|
||||
const dialog = document.querySelector('[data-slot="dialog-content"]')
|
||||
|
||||
if (!popover || !dialog) {
|
||||
return { reason: 'missing', clipped: true }
|
||||
}
|
||||
|
||||
const p = popover.getBoundingClientRect()
|
||||
const d = dialog.getBoundingClientRect()
|
||||
const scrolls = window.getComputedStyle(dialog).overflowY
|
||||
|
||||
return {
|
||||
reason: 'measured',
|
||||
// Only a clipping ancestor can crop the popover. The popover is cut when
|
||||
// the dialog scrolls its overflow AND the popover goes past the box of
|
||||
// the dialog.
|
||||
clipped: (scrolls === 'auto' || scrolls === 'scroll' || scrolls === 'hidden') &&
|
||||
(p.bottom > d.bottom + 1 || p.top < d.top - 1 || p.right > d.right + 1 || p.left < d.left - 1),
|
||||
}
|
||||
})
|
||||
|
||||
expect(clipped.clipped, `base-branch popover is clipped by the dialog (${clipped.reason})`).toBe(false)
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await closeDialog()
|
||||
})
|
||||
|
||||
test('worktree dialog convert-an-existing-branch sub-view lists the repo branches', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
await openWorktreeDialog()
|
||||
await page.getByRole('button', { name: 'Convert an existing branch' }).click()
|
||||
|
||||
await expect(page.getByPlaceholder('Search branches…')).toBeVisible()
|
||||
await expect(page.getByRole('option', { name: /feature\/alpha-one/ })).toBeVisible()
|
||||
|
||||
await expectVisualSnapshot(page, { name: 'worktree-dialog-convert-branch', app: fixture!.app })
|
||||
|
||||
await closeDialog()
|
||||
})
|
||||
|
||||
test('creating a branch with ctrl-shift-b updates the composer git-status branch and leaves no dialog behind', async ({}, testInfo) => {
|
||||
const page = fixture!.page
|
||||
const codingRow = page.locator('.coding-status-bar')
|
||||
|
||||
await openWorktreeDialog()
|
||||
// Exactly one dialog instance. A second dialog here, hidden or empty, is the
|
||||
// symptom of the double-open bug.
|
||||
await expect(page.locator(DIALOG)).toHaveCount(1)
|
||||
|
||||
const branchInput = page.locator('input[placeholder="e.g. my-feature"]').first()
|
||||
await expect(branchInput).toBeVisible()
|
||||
await branchInput.fill(BRANCH_NAME)
|
||||
// Select a base branch, so this test uses the same path as the user: open the
|
||||
// picker, select a branch, then submit. It does not use the default value.
|
||||
// The keyboard drives this step. The dialog still clips the popover, so a
|
||||
// mouse click on an option is not reliable until that bug is corrected. The
|
||||
// double-open check below is therefore independent of the clipping bug.
|
||||
await page.getByRole('button', { name: /branch off/i }).click()
|
||||
await page.getByPlaceholder('Search branches…').fill('main')
|
||||
await expect(page.getByRole('option', { name: 'main' }).first()).toBeVisible()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0)
|
||||
|
||||
await page.getByRole('button', { name: 'New worktree' }).click()
|
||||
|
||||
await expect(codingRow).toContainText(BRANCH_NAME, { timeout: 15_000 })
|
||||
|
||||
// The dialog must close and stay closed. No empty second dialog can remain
|
||||
// after the new worktree session starts.
|
||||
await expect(page.locator(DIALOG)).toHaveCount(0)
|
||||
await page.waitForTimeout(2000)
|
||||
await expect(page.locator(DIALOG)).toHaveCount(0)
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('composer-branch-after-create.png') })
|
||||
})
|
||||
|
||||
test('ctrl-shift-b opens exactly one worktree dialog when a second composer is on screen', async ({}, testInfo) => {
|
||||
const page = fixture!.page
|
||||
|
||||
// ⌘T / ctrl+T stacks a second session tile. That gives a second live composer
|
||||
// and therefore a second coding rail. Each rail mounted its own
|
||||
// WorktreeDialog, and each rail subscribed to the same global token. One
|
||||
// keypress therefore opened two stacked dialogs, and the dialog the user
|
||||
// dismissed showed an identical empty one behind it. One mount in the sidebar
|
||||
// makes that impossible by structure.
|
||||
await page.keyboard.press('Control+T')
|
||||
await expect(page.locator('.coding-status-bar')).toHaveCount(2, { timeout: 20_000 })
|
||||
|
||||
await page.keyboard.press('Control+Shift+B')
|
||||
await expect(page.locator(DIALOG).first()).toBeVisible()
|
||||
// Wait: let the effect of every subscriber flush before the count.
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
const count = await page.locator(DIALOG).count()
|
||||
await page.screenshot({ path: testInfo.outputPath('worktree-dialog-two-composers.png') })
|
||||
|
||||
expect(count, 'one hotkey press must open exactly one worktree dialog').toBe(1)
|
||||
|
||||
// A dismissal then leaves nothing behind.
|
||||
await closeDialog()
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* E2E regression: in-page route navigation must preserve the chosen UI scale.
|
||||
*
|
||||
* Desktop is a HashRouter over one file:// document, so every route is a
|
||||
* distinct URL to Chromium's per-URL zoom store, and a route with no record of
|
||||
* its own resolves to the host default (100%). In-page navigation fires no load
|
||||
* or window event, so nothing re-asserted the persisted level: switching
|
||||
* sessions dropped the window to 100% while Appearance kept reading the chosen
|
||||
* scale (#48658, #38854, #79863).
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { expect, test } from './test'
|
||||
|
||||
const SCALE = 110
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
async function readZoomPercent(): Promise<number> {
|
||||
return fixture!.page.evaluate(async () => {
|
||||
const desktop = window as unknown as {
|
||||
hermesDesktop: { zoom: { get: () => Promise<{ percent: number }> } }
|
||||
}
|
||||
|
||||
return (await desktop.hermesDesktop.zoom.get()).percent
|
||||
})
|
||||
}
|
||||
|
||||
async function setZoomPercent(percent: number): Promise<void> {
|
||||
await fixture!.page.evaluate(target => {
|
||||
const desktop = window as unknown as {
|
||||
hermesDesktop: { zoom: { setPercent: (percent: number) => void } }
|
||||
}
|
||||
|
||||
desktop.hermesDesktop.zoom.setPercent(target)
|
||||
}, percent)
|
||||
await expect.poll(readZoomPercent).toBe(percent)
|
||||
}
|
||||
|
||||
async function gotoRoute(route: string): Promise<void> {
|
||||
const page = fixture!.page
|
||||
|
||||
await page.evaluate(target => {
|
||||
window.location.hash = target
|
||||
}, route)
|
||||
await page.waitForFunction(target => window.location.hash === `#${target}`, route)
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('a non-default UI scale survives navigation to never-zoomed routes', async () => {
|
||||
await setZoomPercent(SCALE)
|
||||
|
||||
// Routes Chromium has no zoom record for — what opening a new session looks
|
||||
// like to the per-URL store. Pre-fix, the first hop reports 100%.
|
||||
const fresh = `/e2e-zoom-${Date.now()}`
|
||||
|
||||
for (const route of [`${fresh}-one`, `${fresh}-two`, '/settings?tab=config%3Aappearance']) {
|
||||
await gotoRoute(route)
|
||||
await expect.poll(readZoomPercent, { message: `UI scale after navigating to ${route}` }).toBe(SCALE)
|
||||
}
|
||||
})
|
||||
|
||||
test('Cmd/Ctrl+N preserves a non-default UI scale', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
await gotoRoute('/settings')
|
||||
await setZoomPercent(SCALE)
|
||||
|
||||
await page.evaluate(() => {
|
||||
;(document.activeElement as HTMLElement | null)?.blur()
|
||||
})
|
||||
await page.keyboard.press(process.platform === 'darwin' ? 'Meta+N' : 'Control+N')
|
||||
await page.waitForFunction(() => window.location.hash === '#/')
|
||||
|
||||
await expect.poll(readZoomPercent).toBe(SCALE)
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { classifyActiveRuntime, hasValidBootstrapMarker } from './active-runtime-state'
|
||||
|
||||
const VALID_MARKER = {
|
||||
pinnedCommit: '1234567890abcdef1234567890abcdef12345678',
|
||||
schemaVersion: 1
|
||||
}
|
||||
|
||||
test('hasValidBootstrapMarker accepts the current schema with a real-looking commit', () => {
|
||||
assert.equal(hasValidBootstrapMarker(VALID_MARKER, 1), true)
|
||||
})
|
||||
|
||||
test('hasValidBootstrapMarker rejects missing, wrong-schema, and too-short markers', () => {
|
||||
assert.equal(hasValidBootstrapMarker(null, 1), false)
|
||||
assert.equal(hasValidBootstrapMarker({ schemaVersion: 2, pinnedCommit: VALID_MARKER.pinnedCommit }, 1), false)
|
||||
assert.equal(hasValidBootstrapMarker({ schemaVersion: 1, pinnedCommit: 'abc123' }, 1), false)
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime uses a healthy active runtime even when the bootstrap marker is missing', () => {
|
||||
assert.deepEqual(classifyActiveRuntime(null, 1, true), {
|
||||
hasValidMarker: false,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
})
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime uses a healthy active runtime even when the marker is stale or malformed', () => {
|
||||
assert.deepEqual(classifyActiveRuntime({ schemaVersion: 999, pinnedCommit: 'abc1234' }, 1, true), {
|
||||
hasValidMarker: false,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
})
|
||||
})
|
||||
|
||||
test('classifyActiveRuntime refuses an unusable runtime even if a valid marker exists', () => {
|
||||
assert.deepEqual(classifyActiveRuntime(VALID_MARKER, 1, false), {
|
||||
hasValidMarker: true,
|
||||
shouldUseActiveRuntime: false,
|
||||
usabilityReason: 'unusable'
|
||||
})
|
||||
})
|
||||
|
||||
test('a CLI-installed runtime with no marker launches instead of re-running bootstrap', () => {
|
||||
// The reported symptom (#60721): install.sh / install.ps1 produced a healthy
|
||||
// repo+venv, no desktop-managed marker was ever written, and every launch
|
||||
// dropped the user back into the first-run installer.
|
||||
const state = classifyActiveRuntime(null, 1, true)
|
||||
|
||||
assert.equal(state.shouldUseActiveRuntime, true, 'a usable runtime must launch')
|
||||
assert.equal(state.hasValidMarker, false, 'marker provenance stays honest')
|
||||
})
|
||||
|
||||
test('a repair that deleted the marker does not strand a healthy install', () => {
|
||||
// #72166: the repair handler clears the marker unconditionally. Runtime
|
||||
// usability, not marker presence, must decide the next boot.
|
||||
assert.equal(classifyActiveRuntime(null, 1, true).shouldUseActiveRuntime, true)
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
export interface BootstrapMarkerLike {
|
||||
pinnedCommit?: unknown
|
||||
schemaVersion?: unknown
|
||||
}
|
||||
|
||||
export interface ActiveRuntimeState {
|
||||
hasValidMarker: boolean
|
||||
shouldUseActiveRuntime: boolean
|
||||
usabilityReason: 'usable' | 'unusable'
|
||||
}
|
||||
|
||||
export function hasValidBootstrapMarker(
|
||||
marker: BootstrapMarkerLike | null | undefined,
|
||||
schemaVersion: number
|
||||
): boolean {
|
||||
if (!marker || typeof marker !== 'object') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (marker.schemaVersion !== schemaVersion) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeof marker.pinnedCommit !== 'string' || marker.pinnedCommit.length < 7) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// The active install at ~/.hermes/hermes-agent can be real and runnable even if
|
||||
// Desktop never wrote its first-run bootstrap marker (for example when Hermes
|
||||
// was installed by the CLI first, or when a past desktop build forgot the
|
||||
// marker). Runtime usability is authoritative for "can we launch local Hermes
|
||||
// right now?"; the marker is only provenance about how that install was
|
||||
// created. A missing/stale marker must never force a healthy local install into
|
||||
// the first-run bootstrap UI.
|
||||
export function classifyActiveRuntime(
|
||||
marker: BootstrapMarkerLike | null | undefined,
|
||||
schemaVersion: number,
|
||||
runtimeUsable: boolean
|
||||
): ActiveRuntimeState {
|
||||
const hasValidMarker = hasValidBootstrapMarker(marker, schemaVersion)
|
||||
|
||||
if (!runtimeUsable) {
|
||||
return {
|
||||
hasValidMarker,
|
||||
shouldUseActiveRuntime: false,
|
||||
usabilityReason: 'unusable'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasValidMarker,
|
||||
shouldUseActiveRuntime: true,
|
||||
usabilityReason: 'usable'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { resolveAiturkHome } from './aiturk-product'
|
||||
|
||||
describe('AITURK state isolation', () => {
|
||||
it('does not adopt the original Hermes account, even when HERMES_HOME is inherited', () => {
|
||||
expect(resolveAiturkHome({ platform: 'win32', home: 'C:\\Users\\tester',
|
||||
env: { LOCALAPPDATA: 'C:\\Users\\tester\\AppData\\Local', HERMES_HOME: 'D:\\private-hermes' }
|
||||
})).toBe('C:\\Users\\tester\\AppData\\Local\\TurkServis\\AITURK-IDE\\agent')
|
||||
})
|
||||
it('keeps a disposable app profile and its agent together', () => {
|
||||
expect(resolveAiturkHome({ platform: 'win32', home: 'C:\\Users\\tester', env: {}, userDataOverride: 'C:\\temp\\aiturk-test' }))
|
||||
.toBe('C:\\temp\\aiturk-test\\agent-home')
|
||||
})
|
||||
it('honors an explicit AITURK home and preserves platform path semantics', () => {
|
||||
expect(resolveAiturkHome({ platform: 'linux', home: '/home/tester', env: { AITURK_IDE_HOME: '/data/aiturk' } })).toBe('/data/aiturk')
|
||||
expect(resolveAiturkHome({ platform: 'linux', home: '/home/tester', env: { HERMES_HOME: '/data/hermes' } })).toBe('/home/tester/.aiturk-ide/agent')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import path from 'node:path'
|
||||
|
||||
export const AITURK_PRODUCT = Object.freeze({
|
||||
name: 'AITURK IDE',
|
||||
appId: 'online.turkservis.aiturk.hermes',
|
||||
protocol: 'aiturk-ide',
|
||||
website: 'https://turkservis.online',
|
||||
downloads: 'https://turkservis.online/ide',
|
||||
repository: 'https://gitea.twinpay.one/yilsem/aiturk-hermes-ide',
|
||||
apiBaseUrl: 'https://ai.turkservis.online/v1'
|
||||
})
|
||||
|
||||
/** Never inherit another product's HERMES_HOME or migrate its state implicitly. */
|
||||
export function resolveAiturkHome({ env, platform, home, userDataOverride }: {
|
||||
env: Record<string, string | undefined>
|
||||
platform: string
|
||||
home: string
|
||||
userDataOverride?: string
|
||||
}): string {
|
||||
const paths = platform === 'win32' ? path.win32 : path.posix
|
||||
if (env.AITURK_IDE_HOME) return paths.resolve(env.AITURK_IDE_HOME)
|
||||
if (userDataOverride) return paths.join(paths.resolve(userDataOverride), 'agent-home')
|
||||
if (platform === 'win32' && env.LOCALAPPDATA) {
|
||||
return paths.join(env.LOCALAPPDATA, 'TurkServis', 'AITURK-IDE', 'agent')
|
||||
}
|
||||
return paths.join(home, '.aiturk-ide', 'agent')
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* Unit + live-transport tests for the Electron main process's Hermes REST
|
||||
* retry policy (#92976 / PR #92977 salvage).
|
||||
*
|
||||
* The live tests run REAL node http servers that misbehave the way the
|
||||
* reported backend does (closing sockets under burst keep-alive traffic) and
|
||||
* prove two things end to end:
|
||||
*
|
||||
* - idempotent GETs that die with ECONNRESET are retried and succeed, where
|
||||
* a single bare attempt (pre-PR behavior) surfaces the raw reset;
|
||||
* - a POST whose socket is reset AFTER the server processed it is NOT
|
||||
* retried: the server-side hit counter stays at 1 and the error surfaces.
|
||||
*/
|
||||
import http from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
destroyKeepaliveAgents,
|
||||
downloadAgentFor,
|
||||
isIdempotentMethod,
|
||||
isTransientTransportError,
|
||||
jsonAgentFor,
|
||||
shouldRetryRequest,
|
||||
withRetry
|
||||
} from './api-transport'
|
||||
|
||||
function errWithCode(code: string, message = code): NodeJS.ErrnoException {
|
||||
const e: NodeJS.ErrnoException = new Error(message)
|
||||
e.code = code
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
destroyKeepaliveAgents()
|
||||
})
|
||||
|
||||
describe('isTransientTransportError', () => {
|
||||
it('accepts transient socket-level codes and messages', () => {
|
||||
for (const code of ['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN']) {
|
||||
expect(isTransientTransportError(errWithCode(code))).toBe(true)
|
||||
}
|
||||
|
||||
expect(isTransientTransportError(new Error('socket hang up'))).toBe(true)
|
||||
expect(isTransientTransportError(new Error('read ECONNRESET'))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-transport errors', () => {
|
||||
expect(isTransientTransportError(new Error('404: not found'))).toBe(false)
|
||||
expect(isTransientTransportError(new Error('Invalid JSON from http://x'))).toBe(false)
|
||||
expect(isTransientTransportError(null)).toBe(false)
|
||||
expect(isTransientTransportError(undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isIdempotentMethod', () => {
|
||||
it.each([
|
||||
['GET', true],
|
||||
['get', true],
|
||||
['HEAD', true],
|
||||
['OPTIONS', true],
|
||||
['POST', false],
|
||||
['PUT', false],
|
||||
['PATCH', false],
|
||||
['DELETE', false],
|
||||
[undefined, true] // node http defaults omitted method to GET
|
||||
])('%s -> %s', (method, expected) => {
|
||||
expect(isIdempotentMethod(method)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldRetryRequest truth table', () => {
|
||||
const reset = () => errWithCode('ECONNRESET', 'read ECONNRESET')
|
||||
const refused = () => errWithCode('ECONNREFUSED', 'connect ECONNREFUSED 127.0.0.1:1')
|
||||
const hangUp = () => new Error('socket hang up')
|
||||
|
||||
it('GET: retries any transient error regardless of body state', () => {
|
||||
expect(shouldRetryRequest(reset(), 'GET', { bodySent: true })).toBe(true)
|
||||
expect(shouldRetryRequest(reset(), 'GET', { bodySent: false })).toBe(true)
|
||||
expect(shouldRetryRequest(hangUp(), 'HEAD', { bodySent: true })).toBe(true)
|
||||
})
|
||||
|
||||
it('GET: never retries non-transport errors (HTTP 4xx/5xx surfaced as Error)', () => {
|
||||
expect(shouldRetryRequest(new Error('500: boom'), 'GET', { bodySent: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('POST: retries when the connection provably never happened', () => {
|
||||
expect(shouldRetryRequest(refused(), 'POST', { bodySent: false })).toBe(true)
|
||||
expect(shouldRetryRequest(refused(), 'POST', { bodySent: true })).toBe(true) // refused == nothing sent
|
||||
expect(shouldRetryRequest(errWithCode('ENOTFOUND'), 'PUT', { bodySent: false })).toBe(true)
|
||||
})
|
||||
|
||||
it('POST: retries transient errors thrown before the body was flushed', () => {
|
||||
expect(shouldRetryRequest(reset(), 'POST', { bodySent: false })).toBe(true)
|
||||
expect(shouldRetryRequest(hangUp(), 'DELETE', { bodySent: false })).toBe(true)
|
||||
})
|
||||
|
||||
it('POST: does NOT retry ambiguous resets after the body went out', () => {
|
||||
expect(shouldRetryRequest(reset(), 'POST', { bodySent: true })).toBe(false)
|
||||
expect(shouldRetryRequest(hangUp(), 'POST', { bodySent: true })).toBe(false)
|
||||
expect(shouldRetryRequest(errWithCode('EPIPE'), 'PUT', { bodySent: true })).toBe(false)
|
||||
expect(shouldRetryRequest(errWithCode('ETIMEDOUT'), 'DELETE', { bodySent: true })).toBe(false)
|
||||
})
|
||||
|
||||
it('POST: conservative when request state is unknown', () => {
|
||||
// No bodySent flag at all — treat as "may have been sent", don't retry.
|
||||
expect(shouldRetryRequest(reset(), 'POST', {})).toBe(false)
|
||||
expect(shouldRetryRequest(reset(), 'POST')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('withRetry', () => {
|
||||
const noDelay = { delayFn: () => Promise.resolve() }
|
||||
|
||||
it('retries a GET through transient failures and resolves', async () => {
|
||||
let attempts = 0
|
||||
|
||||
const result = await withRetry(
|
||||
() => {
|
||||
attempts += 1
|
||||
|
||||
if (attempts < 3) {
|
||||
return Promise.reject(errWithCode('ECONNRESET'))
|
||||
}
|
||||
|
||||
return Promise.resolve('ok')
|
||||
},
|
||||
{ method: 'GET', ...noDelay }
|
||||
)
|
||||
|
||||
expect(result).toBe('ok')
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
|
||||
it('gives each attempt a fresh requestState', async () => {
|
||||
const seen: boolean[] = []
|
||||
let attempts = 0
|
||||
await withRetry(
|
||||
(state: any) => {
|
||||
seen.push(state.bodySent)
|
||||
state.bodySent = true
|
||||
attempts += 1
|
||||
|
||||
if (attempts < 2) {
|
||||
return Promise.reject(errWithCode('ECONNREFUSED'))
|
||||
}
|
||||
|
||||
return Promise.resolve(null)
|
||||
},
|
||||
{ method: 'POST', ...noDelay }
|
||||
)
|
||||
expect(seen).toEqual([false, false])
|
||||
})
|
||||
|
||||
it('does not retry a POST that failed after the body was flushed', async () => {
|
||||
let attempts = 0
|
||||
await expect(
|
||||
withRetry(
|
||||
(state: any) => {
|
||||
attempts += 1
|
||||
state.bodySent = true
|
||||
|
||||
return Promise.reject(errWithCode('ECONNRESET', 'read ECONNRESET'))
|
||||
},
|
||||
{ method: 'POST', ...noDelay }
|
||||
)
|
||||
).rejects.toThrow('read ECONNRESET')
|
||||
expect(attempts).toBe(1)
|
||||
})
|
||||
|
||||
it('retries a POST on ECONNREFUSED (never reached the server)', async () => {
|
||||
let attempts = 0
|
||||
await expect(
|
||||
withRetry(
|
||||
() => {
|
||||
attempts += 1
|
||||
|
||||
return Promise.reject(errWithCode('ECONNREFUSED'))
|
||||
},
|
||||
{ method: 'POST', maxRetries: 2, ...noDelay }
|
||||
)
|
||||
).rejects.toThrow('ECONNREFUSED')
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
|
||||
it('bounds retries at maxRetries even for GET', async () => {
|
||||
let attempts = 0
|
||||
await expect(
|
||||
withRetry(
|
||||
() => {
|
||||
attempts += 1
|
||||
|
||||
return Promise.reject(errWithCode('ECONNRESET'))
|
||||
},
|
||||
{ method: 'GET', maxRetries: 2, ...noDelay }
|
||||
)
|
||||
).rejects.toThrow()
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
|
||||
it('never retries non-transient errors', async () => {
|
||||
let attempts = 0
|
||||
await expect(
|
||||
withRetry(
|
||||
() => {
|
||||
attempts += 1
|
||||
|
||||
return Promise.reject(new Error('500: internal'))
|
||||
},
|
||||
{ method: 'GET', ...noDelay }
|
||||
)
|
||||
).rejects.toThrow('500')
|
||||
expect(attempts).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('keep-alive agent pools', () => {
|
||||
it('separates JSON and download pools per protocol', () => {
|
||||
expect(jsonAgentFor('http:')).not.toBe(jsonAgentFor('https:'))
|
||||
expect(jsonAgentFor('http:')).not.toBe(downloadAgentFor('http:'))
|
||||
expect(jsonAgentFor('https:')).not.toBe(downloadAgentFor('https:'))
|
||||
// Stable across calls (a real pool, not a factory).
|
||||
expect(jsonAgentFor('http:')).toBe(jsonAgentFor('http:'))
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LIVE transport tests against real misbehaving HTTP servers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Minimal single-attempt GET mirroring the pre-PR fetchJson (no retry). */
|
||||
function bareJsonGet(url: string): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(new URL(url), { agent: jsonAgentFor('http:'), method: 'GET' }, res => {
|
||||
const chunks: Buffer[] = []
|
||||
res.on('error', reject)
|
||||
res.on('data', c => chunks.push(c))
|
||||
res.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))))
|
||||
})
|
||||
|
||||
req.on('error', reject)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
/** The head behavior: same request under the verb-gated retry policy. */
|
||||
function retriedJsonGet(url: string): Promise<any> {
|
||||
return withRetry(() => bareJsonGet(url), { method: 'GET', delayFn: () => Promise.resolve() })
|
||||
}
|
||||
|
||||
function listen(server: http.Server): Promise<string> {
|
||||
return new Promise(resolve => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
resolve(`http://127.0.0.1:${(server.address() as AddressInfo).port}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
describe('live: GET burst against a server that resets keep-alive sockets', () => {
|
||||
it('bare attempts fail with ECONNRESET/hang-up; retried GETs all succeed', async () => {
|
||||
// Deterministic misbehavior: every other request gets its socket
|
||||
// destroyed instead of a response — the observable client-side effect of
|
||||
// a backend killing idle keep-alive sockets mid-burst.
|
||||
let hits = 0
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
hits += 1
|
||||
|
||||
if (hits % 2 === 1) {
|
||||
req.socket.destroy()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
res.setHeader('content-type', 'application/json')
|
||||
res.end(JSON.stringify({ n: hits }))
|
||||
})
|
||||
|
||||
const base = await listen(server)
|
||||
|
||||
try {
|
||||
// BASE (pre-PR, single attempt): the burst surfaces raw transport errors.
|
||||
let baseFailures = 0
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
try {
|
||||
await bareJsonGet(`${base}/api/sessions`)
|
||||
} catch (error: any) {
|
||||
baseFailures += 1
|
||||
expect(isTransientTransportError(error)).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
expect(baseFailures).toBeGreaterThan(0)
|
||||
|
||||
// HEAD (retry policy): the same burst fully succeeds. Sequential so the
|
||||
// server's alternating destroy/respond pattern is deterministic per
|
||||
// request (first attempt reset, retry served).
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const r = await retriedJsonGet(`${base}/api/sessions`)
|
||||
expect(r).toHaveProperty('n')
|
||||
}
|
||||
} finally {
|
||||
server.close()
|
||||
}
|
||||
}, 20_000)
|
||||
})
|
||||
|
||||
describe('live: POST reset after server-side processing', () => {
|
||||
it('does not double-submit: server hit count stays 1, error surfaces', async () => {
|
||||
// The server fully receives and "processes" the POST (counter increments),
|
||||
// then RSTs the socket before responding — the dangerous ambiguous case.
|
||||
let posts = 0
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
req.on('data', c => chunks.push(c))
|
||||
req.on('end', () => {
|
||||
posts += 1 // processed: prompt submitted / session created
|
||||
req.socket.resetAndDestroy()
|
||||
void res
|
||||
})
|
||||
})
|
||||
|
||||
const base = await listen(server)
|
||||
|
||||
const postOnce = () =>
|
||||
withRetry(
|
||||
(state: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const body = Buffer.from(JSON.stringify({ prompt: 'hello' }))
|
||||
|
||||
const req = http.request(
|
||||
new URL(`${base}/api/prompt`),
|
||||
{
|
||||
agent: jsonAgentFor('http:'),
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'content-length': String(body.length) }
|
||||
},
|
||||
res => {
|
||||
res.resume()
|
||||
res.on('end', () => resolve(null))
|
||||
}
|
||||
)
|
||||
|
||||
req.on('error', reject)
|
||||
state.bodySent = true
|
||||
req.write(body)
|
||||
req.end()
|
||||
}),
|
||||
{ method: 'POST', delayFn: () => Promise.resolve() }
|
||||
)
|
||||
|
||||
try {
|
||||
await expect(postOnce()).rejects.toSatisfy((error: any) => isTransientTransportError(error))
|
||||
expect(posts).toBe(1) // exactly one server-side submission — no retry
|
||||
} finally {
|
||||
server.close()
|
||||
}
|
||||
}, 20_000)
|
||||
|
||||
it('sanity: an identical GET-shaped retry WOULD have re-hit the server', async () => {
|
||||
// Companion proof that the verb gate (not luck) is what kept posts === 1:
|
||||
// the same reset-after-processing server sees multiple hits under GET.
|
||||
let gets = 0
|
||||
|
||||
const server = http.createServer(req => {
|
||||
gets += 1
|
||||
req.socket.resetAndDestroy()
|
||||
})
|
||||
|
||||
const base = await listen(server)
|
||||
|
||||
try {
|
||||
await expect(retriedJsonGet(`${base}/api/thing`)).rejects.toThrow()
|
||||
expect(gets).toBeGreaterThan(1) // retried — proves the machinery fires
|
||||
} finally {
|
||||
server.close()
|
||||
}
|
||||
}, 20_000)
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Shared HTTP transport policy for the Electron main process's Hermes REST
|
||||
* helpers (fetchJson / fetchPublicJson / downloadViaTokenToFile).
|
||||
*
|
||||
* Two concerns live here so they can be unit-tested without Electron:
|
||||
*
|
||||
* 1. Connection-pooled keep-alive agents. Opening a fresh TCP socket per call
|
||||
* is what produced the burst-traffic ECONNRESET storms (#92976): the
|
||||
* backend closes idle keep-alive sockets and the next write on a reused
|
||||
* raw socket dies with 'socket hang up'. JSON calls and streaming
|
||||
* downloads get SEPARATE pools so a handful of long-lived download
|
||||
* streams can never starve the small, latency-sensitive JSON calls out of
|
||||
* the socket pool.
|
||||
*
|
||||
* 2. A retry policy that is safe for non-idempotent verbs. A transient
|
||||
* transport error does NOT mean the server didn't process the request —
|
||||
* an ECONNRESET can arrive after the backend already handled a POST
|
||||
* (created the session, submitted the prompt) and merely lost the socket
|
||||
* before the response was read. Blindly retrying every verb double-submits.
|
||||
*
|
||||
* The rule implemented by shouldRetryRequest():
|
||||
* - Idempotent verbs (GET / HEAD / OPTIONS) retry on any transient
|
||||
* transport error — replaying them is harmless by definition.
|
||||
* - Non-idempotent verbs (POST / PUT / PATCH / DELETE) retry ONLY when
|
||||
* the request provably never reached the server:
|
||||
* a) connection-establishment failures (ECONNREFUSED, ENOTFOUND,
|
||||
* EAI_AGAIN, EHOSTUNREACH, ENETUNREACH) — no connection means no
|
||||
* request; or
|
||||
* b) a transient error thrown before we started flushing the
|
||||
* request (requestState.bodySent === false).
|
||||
* Anything ambiguous — ECONNRESET / EPIPE / 'socket hang up' after
|
||||
* the body went out — is NOT retried; the error surfaces to the
|
||||
* caller. When in doubt, don't retry a non-idempotent request.
|
||||
*/
|
||||
|
||||
import http from 'node:http'
|
||||
import https from 'node:https'
|
||||
|
||||
// JSON pool: many small concurrent calls (session lists, config, prompts).
|
||||
const HTTP_JSON_AGENT = new http.Agent({ keepAlive: true, maxSockets: 50 })
|
||||
const HTTPS_JSON_AGENT = new https.Agent({ keepAlive: true, maxSockets: 50 })
|
||||
|
||||
// Download pool: few long-lived streaming bodies. Isolated from the JSON pool
|
||||
// so saturating it with large file downloads can't block interactive calls.
|
||||
const HTTP_DOWNLOAD_AGENT = new http.Agent({ keepAlive: true, maxSockets: 8 })
|
||||
const HTTPS_DOWNLOAD_AGENT = new https.Agent({ keepAlive: true, maxSockets: 8 })
|
||||
|
||||
function jsonAgentFor(protocol) {
|
||||
return protocol === 'https:' ? HTTPS_JSON_AGENT : HTTP_JSON_AGENT
|
||||
}
|
||||
|
||||
function downloadAgentFor(protocol) {
|
||||
return protocol === 'https:' ? HTTPS_DOWNLOAD_AGENT : HTTP_DOWNLOAD_AGENT
|
||||
}
|
||||
|
||||
// Close pooled sockets so lingering keep-alive connections can't hold the
|
||||
// process open (or leak FDs) across quit. Wired to app 'will-quit' in main.ts.
|
||||
function destroyKeepaliveAgents() {
|
||||
for (const agent of [HTTP_JSON_AGENT, HTTPS_JSON_AGENT, HTTP_DOWNLOAD_AGENT, HTTPS_DOWNLOAD_AGENT]) {
|
||||
agent.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
// Transient transport errors: retry MAY be safe (subject to verb gating).
|
||||
const TRANSIENT_CODES = new Set([
|
||||
'ECONNRESET',
|
||||
'ECONNREFUSED',
|
||||
'EPIPE',
|
||||
'ETIMEDOUT',
|
||||
'EAI_AGAIN',
|
||||
'ENOTFOUND',
|
||||
'EHOSTUNREACH',
|
||||
'ENETUNREACH'
|
||||
])
|
||||
|
||||
// Errors that prove the request never reached the server: the TCP connection
|
||||
// (or name resolution) failed outright, so nothing was submitted.
|
||||
const NEVER_SENT_CODES = new Set(['ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH'])
|
||||
|
||||
const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
||||
|
||||
function isIdempotentMethod(method) {
|
||||
return IDEMPOTENT_METHODS.has(String(method || 'GET').toUpperCase())
|
||||
}
|
||||
|
||||
function isTransientTransportError(error) {
|
||||
if (!error) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (TRANSIENT_CODES.has(error.code)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const msg = String(error.message || '')
|
||||
|
||||
return msg.includes('socket hang up') || msg.includes('read ECONNRESET')
|
||||
}
|
||||
|
||||
/**
|
||||
* The verb-gated retry decision.
|
||||
*
|
||||
* @param error the transport error from the failed attempt
|
||||
* @param method HTTP verb of the request ('GET', 'POST', ...)
|
||||
* @param requestState per-attempt state; requestState.bodySent is set true by
|
||||
* the caller just BEFORE the first byte of the request is
|
||||
* flushed, so a `false` here proves nothing went out.
|
||||
*/
|
||||
function shouldRetryRequest(error, method, requestState: any = {}) {
|
||||
if (!isTransientTransportError(error)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isIdempotentMethod(method)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Non-idempotent: only when the request provably never reached the server.
|
||||
if (NEVER_SENT_CODES.has(error && error.code)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (requestState.bodySent === false) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Ambiguous (reset/hang-up after the body was flushed): the server may have
|
||||
// processed it. Surface the error rather than risk a double submit.
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `makeAttempt` with bounded retries under the policy above.
|
||||
*
|
||||
* `makeAttempt(requestState)` must return a Promise and should set
|
||||
* `requestState.bodySent = true` immediately before flushing the request
|
||||
* (before the first req.write()/req.end()). Each attempt gets a fresh state
|
||||
* object initialized to { bodySent: false }.
|
||||
*/
|
||||
async function withRetry(makeAttempt, options: any = {}) {
|
||||
const method = String(options.method || 'GET').toUpperCase()
|
||||
const maxRetries = Number.isInteger(options.maxRetries) ? options.maxRetries : 2
|
||||
|
||||
const delayFn =
|
||||
options.delayFn || (attempt => new Promise(r => setTimeout(r, Math.min(200 * Math.pow(2, attempt), 2000))))
|
||||
|
||||
let lastError
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
const requestState = { bodySent: false }
|
||||
|
||||
try {
|
||||
return await makeAttempt(requestState)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
|
||||
if (attempt < maxRetries && shouldRetryRequest(error, method, requestState)) {
|
||||
await delayFn(attempt)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
export {
|
||||
destroyKeepaliveAgents,
|
||||
downloadAgentFor,
|
||||
isIdempotentMethod,
|
||||
isTransientTransportError,
|
||||
jsonAgentFor,
|
||||
shouldRetryRequest,
|
||||
withRetry
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
@@ -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')
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(), '')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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#\/$/)
|
||||
})
|
||||
@@ -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}#/`
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in new issue
Block a user