SaylorTwift HF Staff commited on
Commit
a1b6014
·
verified ·
1 Parent(s): f76c374

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. apps/desktop/assets/icon.ico +0 -0
  2. apps/desktop/e2e/at-rest-connection-token.spec.ts +999 -0
  3. apps/desktop/e2e/batch-clarify.spec.ts +86 -0
  4. apps/desktop/e2e/boot-failure.spec.ts +53 -0
  5. apps/desktop/e2e/boot.spec.ts +82 -0
  6. apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts +158 -0
  7. apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts +137 -0
  8. apps/desktop/e2e/bot-roster-user-sections.spec.ts +254 -0
  9. apps/desktop/e2e/chat.spec.ts +139 -0
  10. apps/desktop/e2e/connectors.spec.ts +38 -0
  11. apps/desktop/e2e/context-menu-editables.spec.ts +123 -0
  12. apps/desktop/e2e/correction-session-switch.spec.ts +270 -0
  13. apps/desktop/e2e/electron-binary.ts +100 -0
  14. apps/desktop/e2e/electron-binary.unit.test.ts +54 -0
  15. apps/desktop/e2e/fix-electron-tracing.ts +72 -0
  16. apps/desktop/e2e/fixtures.ts +746 -0
  17. apps/desktop/e2e/fleet-profile-rail.spec.ts +360 -0
  18. apps/desktop/e2e/glass-surfaces.spec.ts +350 -0
  19. apps/desktop/e2e/glyph-spinner.spec.ts +227 -0
  20. apps/desktop/e2e/group-handoff-to-primary-bot.spec.ts +164 -0
  21. apps/desktop/e2e/group-to-local-bot-handoff.spec.ts +80 -0
  22. apps/desktop/e2e/group-turn-integrity.spec.ts +235 -0
  23. apps/desktop/e2e/hidden-history-messages.spec.ts +160 -0
  24. apps/desktop/e2e/image-attachment-resume.spec.ts +199 -0
  25. apps/desktop/e2e/interim-messages.spec.ts +275 -0
  26. apps/desktop/e2e/large-session-resume.spec.ts +255 -0
  27. apps/desktop/e2e/launch-packaged-app.spec.ts +177 -0
  28. apps/desktop/e2e/mock-backend-setup.spec.ts +87 -0
  29. apps/desktop/e2e/onboarding-settings.spec.ts +82 -0
  30. apps/desktop/e2e/onboarding.spec.ts +76 -0
  31. apps/desktop/e2e/queue-turn-boundary.spec.ts +119 -0
  32. apps/desktop/e2e/real-session-builder.ts +239 -0
  33. apps/desktop/e2e/remote-oauth-recovery.spec.ts +111 -0
  34. apps/desktop/e2e/right-pane.spec.ts +138 -0
  35. apps/desktop/e2e/session-compression-and-queue-stop.spec.ts +162 -0
  36. apps/desktop/e2e/sidebar-states.spec.ts +321 -0
  37. apps/desktop/e2e/submit-drift.spec.ts +75 -0
  38. apps/desktop/e2e/task-panel-clearance.spec.ts +145 -0
  39. apps/desktop/e2e/test.ts +166 -0
  40. apps/desktop/e2e/tile-unread-bug.spec.ts +274 -0
  41. apps/desktop/e2e/unread-dot-restart.spec.ts +162 -0
  42. apps/desktop/e2e/visual-snapshot.ts +150 -0
  43. apps/desktop/e2e/warm-resume-jitter.spec.ts +477 -0
  44. apps/desktop/e2e/window-input.unit.test.ts +94 -0
  45. apps/desktop/e2e/worktree-branch-status.spec.ts +243 -0
  46. apps/desktop/e2e/zoom-preservation.spec.ts +87 -0
  47. apps/desktop/electron/active-runtime-state.test.ts +60 -0
  48. apps/desktop/electron/api-transport.test.ts +406 -0
  49. apps/desktop/electron/app-icon.ts +85 -0
  50. apps/desktop/electron/backend-command.ts +48 -0
apps/desktop/assets/icon.ico ADDED
apps/desktop/e2e/at-rest-connection-token.spec.ts ADDED
@@ -0,0 +1,999 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * E2E at-rest contract for the remote-gateway session token (issue #77486).
3
+ *
4
+ * The reported bug: configuring a remote gateway persisted the dashboard
5
+ * session token as PLAINTEXT into `connection.json` under the app's userData
6
+ * dir (macOS `~/Library/Application Support/Hermes/connection.json`, Windows
7
+ * `AppData\Roaming\Hermes\connection.json`). Anything that can read the file
8
+ * — a backup, a sync client, another local process, a support bundle — got a
9
+ * live gateway credential.
10
+ *
11
+ * The contract these tests encode is deliberately stated WITHOUT naming a
12
+ * storage strategy:
13
+ *
14
+ * 1. ABSENT FROM DISK. After the app has been configured with a remote
15
+ * gateway token, the token's plaintext value must not appear anywhere in
16
+ * `connection.json`, in any sibling file the app writes under userData,
17
+ * or in HERMES_HOME (logs included).
18
+ * 2. STILL FUNCTIONAL. After a restart, the app must still be able to USE
19
+ * that credential — it decrypts the stored blob and puts the exact
20
+ * original token on the wire.
21
+ * 3. UNREADABLE BY OTHER LOCAL ACCOUNTS. `connection.json` must not be
22
+ * group/other-accessible, whether the app just wrote it or inherited it
23
+ * from an older install.
24
+ *
25
+ * All three matter and none is sufficient alone. (1) alone is trivially
26
+ * satisfied by a "fix" that drops the token on the floor; (2) alone is
27
+ * satisfied by the bug itself. So (2) is verified through the app's own
28
+ * connection test against a fake gateway that records the
29
+ * `X-Hermes-Session-Token` header it receives — a dropped or mangled token
30
+ * cannot produce that header.
31
+ *
32
+ * (3) is orthogonal to (1) and invisible to it: safeStorage keeps the token
33
+ * opaque no matter what the file's mode is, so a 0644 `connection.json` passes
34
+ * the raw-bytes scan every time while still exposing the ciphertext blob, the
35
+ * gateway URL and the SSH host/user/keyPath to any other local account. It is
36
+ * asserted explicitly (see `expectOwnerOnlyMode`) because no amount of
37
+ * encryption evidence implies it.
38
+ *
39
+ * We deliberately do NOT assert `encoding === 'safeStorage'` or any other
40
+ * shape of the stored blob. That would be a change-detector: a fix that moved
41
+ * to the OS keychain proper, to an async safeStorage provider, or to a
42
+ * separate credential file would break the test while being *more* correct.
43
+ * The load-bearing assertion is the raw-bytes absence of the secret.
44
+ *
45
+ * Four at-rest paths, hence four tests — three enforced, one a documented gap:
46
+ *
47
+ * 1. A NEWLY configured token (ACTIVE). The app's own write path routes
48
+ * through the strict `encryptDesktopSecret`; this test holds it there
49
+ * against regression, and pins the mode of the file it actually wrote.
50
+ * 2. An EXISTING `connection.json` at the old 0644 (ACTIVE). Covers the
51
+ * read-side tighten, and ONLY the mode — its token is already ciphertext,
52
+ * which is what keeps it independent of the migration test 4 defers.
53
+ * 3. A CORRUPT `connection.json` at 0644 (ACTIVE). The tighten must not be
54
+ * gated on the parse succeeding: a truncated file still holds the token
55
+ * bytes, and the parse failure is swallowed, so nothing would ever come
56
+ * back for it.
57
+ * 4. An EXISTING plaintext `connection.json` (`test.fixme`). Legacy payloads
58
+ * are deliberately NOT migrated yet. The test is kept, disabled, with a
59
+ * precise reason — see the block comment above it.
60
+ *
61
+ * ── Correcting the record on test 4 ─────────────────────────────────────
62
+ *
63
+ * An earlier revision of this file asserted that migration and justified it by
64
+ * claiming the first implementation (`d3d177283`) fell back to
65
+ * `{ encoding: 'plain', value }` when `isEncryptionAvailable()` was false.
66
+ * That citation is FALSE for this codebase. What is actually true:
67
+ *
68
+ * git merge-base --is-ancestor d3d1772837a7b0552940b55455ae734c72e0a8f1 HEAD -> 1 (NOT an ancestor)
69
+ * git merge-base --is-ancestor 51c68d4ab1a9e3c62fb1048fccb84144c409f0e7 HEAD -> 0 (IS an ancestor)
70
+ * git log -S 'Fall through to plaintext' upstream/main -- apps/desktop -> (no commits)
71
+ *
72
+ * `d3d177283` exists only on `upstream/bb/gui-mainmerge-tmp`,
73
+ * `brooklyn/gui-installer-prereqs`, and the `desktop-pr20059-installers`
74
+ * pre-release tag. Mainline NEVER shipped a code path that wrote a plaintext
75
+ * gateway token: `51c68d4ab` ("Add Hermes desktop app (#20059)"), the commit
76
+ * that brought the desktop app to mainline, already contained the strict
77
+ * throw ("Secure token storage is unavailable, …") in `hardening.cjs`.
78
+ *
79
+ * One `{ encoding: 'plain', value }` literal does remain on mainline
80
+ * (`electron/main.ts`, in `coerceDesktopConnectionConfig`), but it is
81
+ * unreachable as an at-rest write: it is gated on `persistToken === false`,
82
+ * whose only caller is the connection-TEST handler, which never calls
83
+ * `writeDesktopConnectionConfig`. That token stays in memory for the duration
84
+ * of one probe.
85
+ *
86
+ * So the affected population is not "anyone who configured a gateway before
87
+ * the fix". It is narrow and non-mainline: pre-release `bb/gui` installs
88
+ * (including the `desktop-pr20059-installers` build) plus hand-edited or
89
+ * hand-migrated `connection.json` files. Those files DO still work, because
90
+ * `decryptDesktopSecret` returns any non-safeStorage `value` verbatim on read
91
+ * — the read path is intentionally unchanged, so nobody is signed out. That
92
+ * read-path acceptance, not a mainline writer, is what makes the fixme'd
93
+ * fixture realistic.
94
+ *
95
+ * Migration is DEFERRED, not forgotten. An adversarial review of the
96
+ * migration that briefly lived here returned DO NOT SHIP, having reproduced
97
+ * two token-loss scenarios: it silently reverts and then destroys the opt-in
98
+ * plaintext choice that open upstream PR #62319 deliberately adds; and it
99
+ * converts a portable credential into a keychain-bound one with no consent,
100
+ * destroying the only recoverable copy while not actually remediating the
101
+ * exposure (the plaintext is already in backups, so the real remedy is
102
+ * ROTATION). The prerequisites are enumerated above test 2.
103
+ *
104
+ * Environment limits are encoded rather than papered over. Electron's
105
+ * safeStorage is unavailable on Linux with no keyring, which is the shape of
106
+ * this suite's CI runner (ubuntu-latest, see .github/workflows/e2e-desktop.yml).
107
+ * The absence assertion is unconditional there — it is the security
108
+ * requirement, and it must hold in every environment. Only the *other* half is
109
+ * conditional: with secure storage the save must succeed, and without it the
110
+ * save must fail loudly (which is what the current strict `encryptDesktopSecret`
111
+ * does) instead of quietly writing plaintext. See the branch comments in each
112
+ * test for the reasoning, including the one case this spec refuses to invent a
113
+ * policy for.
114
+ *
115
+ * Prerequisite: `npm run build` must have been run so dist/ exists.
116
+ */
117
+
118
+ import * as fs from 'node:fs'
119
+ import * as http from 'node:http'
120
+ import type { AddressInfo } from 'node:net'
121
+ import * as path from 'node:path'
122
+
123
+ import { buildAppEnv, createSandbox, launchDesktop, type Sandbox } from './fixtures'
124
+ import { allowErrorBanners, type ElectronApplication, expect, type Page, test } from './test'
125
+
126
+ /**
127
+ * The secret under test. Long, random-looking, and unique to this spec so a
128
+ * raw-bytes scan cannot produce a false negative by colliding with ordinary
129
+ * config content. Kept to `[A-Za-z0-9-]` on purpose: encodeURIComponent() is
130
+ * the identity function over this alphabet, so the raw-bytes needle also
131
+ * covers the URL-encoded form the WS dialer builds (`?token=…`).
132
+ */
133
+ const SENTINEL_TOKEN = 'hermes-e2e-at-rest-sentinel-Zq7Z4hV9nX2pL8sK3tB6wR1yM5jD0fG'
134
+
135
+ /** Skip absurdly large files during the leak scan (Chromium caches). */
136
+ const MAX_SCAN_BYTES = 16 * 1024 * 1024
137
+
138
+ /**
139
+ * One fixed Electron app name for this spec, instead of the timestamped one
140
+ * `buildAppEnv` generates. On macOS the safeStorage keychain item is derived
141
+ * from the app name, so a per-launch name would (a) make the post-restart
142
+ * decrypt fail for the wrong reason and (b) leave a fresh keychain entry on
143
+ * the developer's login keychain on every run. Safe because the suite runs
144
+ * one worker at a time and both launches here are sequential; the
145
+ * single-instance lock keys off userData, which is per-sandbox.
146
+ */
147
+ const STABLE_APP_NAME = 'HermesE2EAtRestStorage'
148
+
149
+ // ─── Fake gateway ───────────────────────────────────────────────────────
150
+
151
+ interface FakeGateway {
152
+ url: string
153
+ /** Every `X-Hermes-Session-Token` value the app has sent us. */
154
+ sessionTokens: string[]
155
+ close: () => Promise<void>
156
+ }
157
+
158
+ /**
159
+ * A minimal stand-in for a remote Hermes gateway. It serves the public
160
+ * `/api/status` probe (which the desktop connection test hits first, with the
161
+ * session token in a header) and refuses the WebSocket upgrade immediately so
162
+ * the second leg of the connection test fails fast instead of burning the
163
+ * probe's 10s connect timeout. We only care about the header it captured.
164
+ *
165
+ * The e2e mock-server is an OpenAI-compatible *inference* mock, not a gateway,
166
+ * so it cannot answer /api/status — hence this small local server.
167
+ */
168
+ async function startFakeGateway(): Promise<FakeGateway> {
169
+ const sessionTokens: string[] = []
170
+
171
+ const server = http.createServer((req, res) => {
172
+ const token = req.headers['x-hermes-session-token']
173
+
174
+ if (typeof token === 'string' && token) {
175
+ sessionTokens.push(token)
176
+ }
177
+
178
+ if (req.url?.startsWith('/api/status')) {
179
+ res.writeHead(200, { 'Content-Type': 'application/json' })
180
+ res.end(JSON.stringify({ auth_required: false, ok: true, version: '0.0.0-e2e-fake' }))
181
+
182
+ return
183
+ }
184
+
185
+ res.writeHead(404, { 'Content-Type': 'application/json' })
186
+ res.end(JSON.stringify({ detail: 'not found' }))
187
+ })
188
+
189
+ // Refuse the WS leg at once: the connection test's WS probe should return a
190
+ // fast failure rather than hang. The status header is already captured.
191
+ server.on('upgrade', (req, socket) => {
192
+ const token = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('token')
193
+
194
+ if (token) {
195
+ sessionTokens.push(token)
196
+ }
197
+
198
+ socket.destroy()
199
+ })
200
+
201
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
202
+
203
+ const { port } = server.address() as AddressInfo
204
+
205
+ return {
206
+ close: () =>
207
+ new Promise<void>(resolve => {
208
+ server.closeAllConnections?.()
209
+ server.close(() => resolve())
210
+ }),
211
+ sessionTokens,
212
+ url: `http://127.0.0.1:${port}`,
213
+ }
214
+ }
215
+
216
+ // ─── On-disk leak scanning ──────────────────────────────────────────────
217
+
218
+ interface Needle {
219
+ bytes: Buffer
220
+ label: string
221
+ }
222
+
223
+ /**
224
+ * The forms a leak could take. Raw bytes, not JSON.parse + field inspection:
225
+ * the point is that the secret is nowhere in the file — including inside a
226
+ * nested field, a cached WS URL, or a field name nobody thought to check.
227
+ *
228
+ * The base64 needle catches the cheapest wrong "fix": base64 is an encoding,
229
+ * not encryption, so a token that is merely base64'd is still plaintext at
230
+ * rest. A real ciphertext will contain neither needle.
231
+ */
232
+ function secretNeedles(secret: string): Needle[] {
233
+ return [
234
+ { bytes: Buffer.from(secret, 'utf8'), label: 'plaintext' },
235
+ { bytes: Buffer.from(Buffer.from(secret, 'utf8').toString('base64'), 'utf8'), label: 'base64' },
236
+ ]
237
+ }
238
+
239
+ /** Relative paths of every file under `root` whose bytes contain a needle. */
240
+ function scanTreeForSecret(root: string, needles: Needle[]): string[] {
241
+ const hits: string[] = []
242
+
243
+ const walk = (dir: string): void => {
244
+ let entries: fs.Dirent[]
245
+
246
+ try {
247
+ entries = fs.readdirSync(dir, { withFileTypes: true })
248
+ } catch {
249
+ return
250
+ }
251
+
252
+ for (const entry of entries) {
253
+ const full = path.join(dir, entry.name)
254
+
255
+ if (entry.isDirectory()) {
256
+ walk(full)
257
+
258
+ continue
259
+ }
260
+
261
+ if (!entry.isFile()) {
262
+ continue
263
+ }
264
+
265
+ try {
266
+ if (fs.statSync(full).size > MAX_SCAN_BYTES) {
267
+ continue
268
+ }
269
+ } catch {
270
+ continue
271
+ }
272
+
273
+ let buf: Buffer
274
+
275
+ try {
276
+ buf = fs.readFileSync(full)
277
+ } catch {
278
+ continue
279
+ }
280
+
281
+ for (const needle of needles) {
282
+ if (buf.includes(needle.bytes)) {
283
+ hits.push(`${path.relative(root, full)} [${needle.label}]`)
284
+ }
285
+ }
286
+ }
287
+ }
288
+
289
+ walk(root)
290
+
291
+ return hits
292
+ }
293
+
294
+ /**
295
+ * Read a file's bytes, or an empty buffer when it does not exist. A correct
296
+ * fix is allowed to delete/replace `connection.json` rather than rewrite it,
297
+ * and the refusal path may never create it at all — neither should crash the
298
+ * scan before its assertion runs.
299
+ */
300
+ function readIfExists(filePath: string): Buffer {
301
+ try {
302
+ return fs.readFileSync(filePath)
303
+ } catch {
304
+ return Buffer.alloc(0)
305
+ }
306
+ }
307
+
308
+ /**
309
+ * The stored token's `encoding` tag, for diagnostics only — never its value.
310
+ * Reported on failure so a red run says *why* (e.g. still `plain`) instead of
311
+ * only that a scan matched. Deliberately NOT an assertion: which encoding a
312
+ * correct fix chooses is its own business.
313
+ */
314
+ function storedTokenEncoding(connectionFile: string): string {
315
+ try {
316
+ const parsed = JSON.parse(readIfExists(connectionFile).toString('utf8'))
317
+
318
+ return String(parsed?.remote?.token?.encoding ?? '<none>')
319
+ } catch {
320
+ return '<unparsable>'
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Assert a credential file is not readable or writable by group/other.
326
+ *
327
+ * This is the one contract the raw-bytes scan above structurally cannot see:
328
+ * safeStorage keeps the token opaque regardless of the file's mode, so a
329
+ * world-readable `connection.json` passes every absence assertion in this file
330
+ * while still handing the URL, the SSH host/user/keyPath, and the ciphertext
331
+ * blob to any other local account. Encryption and permissions are independent
332
+ * halves of "at rest", and only one of them was covered here.
333
+ *
334
+ * Asserted as `mode & 0o077 === 0` rather than `=== 0o600`: the requirement is
335
+ * that nobody else can reach the file, and pinning the exact bits would make
336
+ * this a change-detector against a future 0400 or a setgid-dir umask.
337
+ *
338
+ * POSIX only. `tightenSecretFileMode` no-ops on Windows deliberately (Node maps
339
+ * chmod to the read-only bit there, and userData is already ACL'd to the user
340
+ * profile — see the docstring in electron/hardening.ts, and PR #77527 for the
341
+ * one place ACLs are being handled). Mode bits are advisory on Windows, so
342
+ * asserting them would go red for behaviour the fix never claimed. The suite
343
+ * runs ubuntu-latest today (.github/workflows/e2e-desktop.yml); nothing else in
344
+ * this spec is platform-specific, and this assertion should not be what
345
+ * changes that.
346
+ */
347
+ function expectOwnerOnlyMode(filePath: string, why: string): void {
348
+ if (process.platform === 'win32') {
349
+ return
350
+ }
351
+
352
+ const mode = fs.statSync(filePath).mode & 0o777
353
+
354
+ expect(mode & 0o077, `${why} (mode ${mode.toString(8)})`).toBe(0)
355
+ }
356
+
357
+ // ─── App helpers ────────────────────────────────────────────────────────
358
+
359
+ /**
360
+ * Launch the desktop app against `sandbox` with a fake boot failure injected.
361
+ *
362
+ * The credential path we are testing is entirely main-process (IPC handler →
363
+ * coerce → safeStorage → userData write) and does not need a live agent
364
+ * backend, so we skip spawning `hermes serve` (no Python needed, ~3s launch,
365
+ * hermetic). This is also a real user situation rather than an artificial one:
366
+ * the boot-failure overlay's own recovery affordance is "Connection settings",
367
+ * i.e. pointing the app at a remote gateway is exactly what a user does from
368
+ * this state. BOOT_FAKE_ERROR short-circuits startHermes() *before* remote
369
+ * resolution, so no launch ever dials the fake gateway on its own.
370
+ */
371
+ async function launchAgainst(sandbox: Sandbox): Promise<{ app: ElectronApplication; page: Page }> {
372
+ const env = buildAppEnv(sandbox, {
373
+ HERMES_DESKTOP_APP_NAME: STABLE_APP_NAME,
374
+ HERMES_DESKTOP_BOOT_FAKE_ERROR: 'E2E at-rest storage spec: local backend intentionally not started',
375
+ })
376
+
377
+ const { app, page } = await launchDesktop(env)
378
+
379
+ // The capability bridge is what we drive; it lands with the preload, well
380
+ // before the app would be "ready" in the boot sense.
381
+ await page.waitForFunction(
382
+ () => Boolean((window as unknown as { hermesDesktop?: Record<string, unknown> }).hermesDesktop?.saveConnectionConfig),
383
+ undefined,
384
+ { timeout: 60_000 },
385
+ )
386
+
387
+ return { app, page }
388
+ }
389
+
390
+ /**
391
+ * Ask the running app where userData actually is, the same way the app does
392
+ * (`app.getPath('userData')`). The fixtures point userData at a temp sandbox,
393
+ * so a home-relative hardcoded path would test the wrong file — or no file.
394
+ */
395
+ async function resolveUserDataDir(app: ElectronApplication): Promise<string> {
396
+ return app.evaluate(({ app: electronApp }) => electronApp.getPath('userData'))
397
+ }
398
+
399
+ interface SafeStorageCapability {
400
+ available: boolean
401
+ backend: string
402
+ }
403
+
404
+ /**
405
+ * What secure storage is actually capable of on THIS host, asked after ready
406
+ * (on Linux the answer is meaningless before then).
407
+ *
408
+ * `backend` matters for the honest reading of a green run: on Linux with no
409
+ * keyring, Electron can still report encryption as available while selecting
410
+ * the `basic_text` backend, which encrypts with a hardcoded password — the
411
+ * bytes on disk are not the plaintext, but they are not meaningfully
412
+ * protected either. We record it rather than assert on it, because which
413
+ * posture Hermes should take there (refuse to save vs. accept basic_text) is
414
+ * a product decision, not something this test should silently ratify.
415
+ */
416
+ async function readSafeStorageCapability(app: ElectronApplication): Promise<SafeStorageCapability> {
417
+ return app.evaluate(async ({ app: electronApp, safeStorage }) => {
418
+ await electronApp.whenReady()
419
+
420
+ let available = false
421
+ let backend = 'unavailable'
422
+
423
+ try {
424
+ available = safeStorage.isEncryptionAvailable()
425
+ } catch {
426
+ available = false
427
+ }
428
+
429
+ try {
430
+ // Linux-oriented API; other platforms may not implement it.
431
+ backend = safeStorage.getSelectedStorageBackend?.() ?? 'n/a'
432
+ } catch {
433
+ backend = 'n/a'
434
+ }
435
+
436
+ return { available, backend }
437
+ })
438
+ }
439
+
440
+ interface SaveOutcome {
441
+ config: { remoteTokenPreview?: null | string; remoteTokenSet?: boolean; remoteUrl?: string } | null
442
+ error: null | string
443
+ }
444
+
445
+ /**
446
+ * Drive the app's REAL save surface: the same `saveConnectionConfig` payload
447
+ * Settings → Gateway sends (see src/app/settings/gateway-settings.tsx). We use
448
+ * save rather than apply so the app persists the credential without trying to
449
+ * re-home onto the fake gateway.
450
+ */
451
+ async function saveRemoteToken(page: Page, remoteUrl: string, remoteToken?: string): Promise<SaveOutcome> {
452
+ return page.evaluate(
453
+ async ([url, token]) => {
454
+ const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
455
+
456
+ try {
457
+ const config = await desktop.saveConnectionConfig({
458
+ mode: 'remote',
459
+ remoteAuthMode: 'token',
460
+ ...(token ? { remoteToken: token } : {}),
461
+ remoteUrl: url,
462
+ })
463
+
464
+ return { config, error: null }
465
+ } catch (error) {
466
+ return { config: null, error: error instanceof Error ? error.message : String(error) }
467
+ }
468
+ },
469
+ [remoteUrl, remoteToken ?? ''] as const,
470
+ )
471
+ }
472
+
473
+ /**
474
+ * Make the app USE the stored credential. No token in the payload, so the main
475
+ * process must read `connection.json`, decrypt what it stored, and put the
476
+ * plaintext on the wire itself. `buildRemoteBlock` throws "Remote gateway
477
+ * session token is required." when the stored blob no longer decrypts, so a
478
+ * fix that dropped the token fails here instead of quietly passing the
479
+ * absence assertion.
480
+ */
481
+ async function exerciseStoredToken(page: Page, remoteUrl: string): Promise<{ error: null | string }> {
482
+ return page.evaluate(async url => {
483
+ const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
484
+
485
+ try {
486
+ await desktop.testConnectionConfig({ mode: 'remote', remoteUrl: url })
487
+
488
+ return { error: null }
489
+ } catch (error) {
490
+ // A failing WS leg is expected (the fake gateway refuses the upgrade).
491
+ // The assertion is on what the gateway received, not on this result.
492
+ return { error: error instanceof Error ? error.message : String(error) }
493
+ }
494
+ }, remoteUrl)
495
+ }
496
+
497
+ // ─── Tests ──────────────────────────────────────────────────────────────
498
+
499
+ let gateway: FakeGateway | null = null
500
+ let sandbox: Sandbox | null = null
501
+ let app: ElectronApplication | null = null
502
+
503
+ test.beforeAll(async () => {
504
+ gateway = await startFakeGateway()
505
+ })
506
+
507
+ test.afterAll(async () => {
508
+ await gateway?.close()
509
+ gateway = null
510
+ })
511
+
512
+ test.beforeEach(() => {
513
+ // Boot is intentionally failed in this spec (see launchAgainst), so the
514
+ // boot-failure overlay's error banner is expected, not a failure.
515
+ allowErrorBanners()
516
+ })
517
+
518
+ test.afterEach(async () => {
519
+ await app?.close().catch(() => undefined)
520
+ app = null
521
+ sandbox?.cleanup()
522
+ sandbox = null
523
+ })
524
+
525
+ test.describe('remote gateway session token at rest', () => {
526
+ test('with keychain encryption opted IN, a newly configured token is never written to userData in plaintext, and still works after restart', async () => {
527
+ const fake = gateway!
528
+ sandbox = createSandbox('at-rest-fresh')
529
+
530
+ // Keychain-backed encryption is opt-in (default OFF — see
531
+ // electron/secret-storage-policy.ts). This test covers the opted-IN
532
+ // posture, so seed the policy the way the Settings toggle writes it.
533
+ fs.writeFileSync(
534
+ path.join(sandbox.userDataDir, 'secure-token-storage.json'),
535
+ JSON.stringify({ migrated: true, on: true }),
536
+ 'utf8',
537
+ )
538
+
539
+ const first = await launchAgainst(sandbox)
540
+ app = first.app
541
+
542
+ const capability = await readSafeStorageCapability(app)
543
+ const userDataDir = await resolveUserDataDir(app)
544
+ const connectionFile = path.join(userDataDir, 'connection.json')
545
+
546
+ test.info().annotations.push({
547
+ description: `isEncryptionAvailable=${capability.available} backend=${capability.backend}`,
548
+ type: 'safeStorage',
549
+ })
550
+
551
+ const saved = await saveRemoteToken(first.page, fake.url, SENTINEL_TOKEN)
552
+
553
+ // Defined degradation, not a silent plaintext write. Where secure storage
554
+ // works, the save must succeed. Where it genuinely does not (headless
555
+ // Linux with no keyring, per Electron's safeStorage docs), refusing the
556
+ // save with a loud error is an acceptable outcome — what is NEVER
557
+ // acceptable is reporting success while leaving the secret readable on
558
+ // disk. The absence assertion below runs in both branches.
559
+ if (capability.available) {
560
+ expect(
561
+ saved.error,
562
+ 'secure storage is available on this host, so saving a remote gateway token must succeed',
563
+ ).toBeNull()
564
+ expect(saved.config?.remoteTokenSet).toBe(true)
565
+ } else {
566
+ expect(
567
+ saved.error,
568
+ 'secure storage is unavailable, so the save must fail loudly rather than persist a plaintext token',
569
+ ).not.toBeNull()
570
+ }
571
+
572
+ // Guard against a vacuous pass: when the save succeeded, the artifact must
573
+ // exist and must be the file the app really wrote for THIS connection.
574
+ // Without this, "no plaintext on disk" would also be true if nothing had
575
+ // been saved at all. Only asserted on the success branch — a refused save
576
+ // legitimately leaves no file behind.
577
+ const rawConnection = readIfExists(connectionFile)
578
+
579
+ if (capability.available) {
580
+ expect(fs.existsSync(connectionFile), `expected the app to write ${connectionFile}`).toBe(true)
581
+ expect(
582
+ rawConnection.includes(Buffer.from(fake.url, 'utf8')),
583
+ 'connection.json should record the configured gateway URL (proves this is the real artifact)',
584
+ ).toBe(true)
585
+
586
+ // The write path's OTHER half of at-rest: opaque bytes AND owner-only
587
+ // permissions. Deliberately here, on the file this test just proved the
588
+ // app really wrote, rather than in a unit test — nothing in the repo
589
+ // imports electron/main.ts (it imports electron), so this is the only
590
+ // place that can witness the app's own write actually going out at 0600
591
+ // instead of the 0644 umask default.
592
+ expectOwnerOnlyMode(
593
+ connectionFile,
594
+ 'connection.json is group/other-accessible, so the encrypted token blob, gateway URL and SSH fields are readable by other local accounts',
595
+ )
596
+ }
597
+
598
+ // ── The load-bearing assertion ─────────────────────────────────────
599
+ const needles = secretNeedles(SENTINEL_TOKEN)
600
+
601
+ const connectionHits = needles.filter(needle => rawConnection.includes(needle.bytes)).map(needle => needle.label)
602
+ expect(
603
+ connectionHits,
604
+ `the gateway session token must not be recoverable from ${connectionFile} ` +
605
+ `(stored token encoding is "${storedTokenEncoding(connectionFile)}")`,
606
+ ).toEqual([])
607
+
608
+ // …and not in any sibling file the app writes alongside it, nor in
609
+ // HERMES_HOME (desktop.log lives there).
610
+ expect(
611
+ scanTreeForSecret(userDataDir, needles),
612
+ 'the gateway session token leaked into a userData file',
613
+ ).toEqual([])
614
+ expect(
615
+ scanTreeForSecret(sandbox.hermesHome, needles),
616
+ 'the gateway session token leaked into a HERMES_HOME file (logs included)',
617
+ ).toEqual([])
618
+
619
+ if (!capability.available) {
620
+ // Nothing was stored, so there is no round trip to verify. The refusal
621
+ // itself was already asserted above.
622
+ return
623
+ }
624
+
625
+ // ── Secondary: the credential must still be USABLE ─────────────────
626
+ // Restart against the same userData so the token comes off disk, not out
627
+ // of a live process's memory.
628
+ await app.close().catch(() => undefined)
629
+ app = null
630
+
631
+ const second = await launchAgainst(sandbox)
632
+ app = second.app
633
+
634
+ expect(
635
+ await resolveUserDataDir(app),
636
+ 'the restarted app must resolve the same userData dir, or this is not a round trip',
637
+ ).toBe(userDataDir)
638
+
639
+ const reread = await second.page.evaluate(async () => {
640
+ const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
641
+
642
+ return desktop.getConnectionConfig()
643
+ })
644
+
645
+ expect(reread.remoteTokenSet, 'the stored token must survive a restart').toBe(true)
646
+ expect(reread.remoteUrl).toBe(fake.url)
647
+
648
+ const before = fake.sessionTokens.length
649
+ await exerciseStoredToken(second.page, fake.url)
650
+
651
+ // The gateway is the witness: the app decrypted its stored blob and put
652
+ // the original secret on the wire. A dropped, truncated, or re-encoded
653
+ // token cannot produce this.
654
+ expect(
655
+ fake.sessionTokens.slice(before),
656
+ 'the app must send the exact stored token to the gateway after a restart',
657
+ ).toContain(SENTINEL_TOKEN)
658
+ })
659
+
660
+ /**
661
+ * The DEFAULT posture: keychain encryption opted out (no policy file at
662
+ * all). Saving a token must (a) succeed without ever touching safeStorage
663
+ * — this is the whole point of the opt-in: no macOS Keychain dialog on
664
+ * machines with a broken login keychain — (b) store the token with a
665
+ * non-safeStorage encoding at 0600, and (c) round-trip it across a
666
+ * restart. The plaintext-on-disk trade-off is the user's chosen (default)
667
+ * mode; owner-only file bits remain the at-rest boundary.
668
+ */
669
+ test('with the default policy (no keychain), a token saves without secure storage, is owner-only on disk, and survives a restart', async () => {
670
+ const fake = gateway!
671
+ sandbox = createSandbox('at-rest-default')
672
+
673
+ const first = await launchAgainst(sandbox)
674
+ app = first.app
675
+
676
+ const userDataDir = await resolveUserDataDir(app)
677
+ const connectionFile = path.join(userDataDir, 'connection.json')
678
+
679
+ // Must succeed regardless of host keyring state — the default policy
680
+ // never consults safeStorage, so "no keyring" cannot refuse the save.
681
+ const saved = await saveRemoteToken(first.page, fake.url, SENTINEL_TOKEN)
682
+
683
+ expect(saved.error, 'the default (opted-out) policy must save without secure storage').toBeNull()
684
+ expect(saved.config?.remoteTokenSet).toBe(true)
685
+
686
+ // Not a safeStorage blob, and owner-only on disk.
687
+ expect(storedTokenEncoding(connectionFile)).not.toBe('safeStorage')
688
+ expectOwnerOnlyMode(
689
+ connectionFile,
690
+ 'connection.json is group/other-accessible; owner-only bits are the at-rest boundary for opted-out storage',
691
+ )
692
+
693
+ // Round trip across a restart, same witness as the opted-in test.
694
+ await app.close().catch(() => undefined)
695
+ app = null
696
+
697
+ const second = await launchAgainst(sandbox)
698
+ app = second.app
699
+
700
+ const reread = await second.page.evaluate(async () => {
701
+ const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
702
+
703
+ return desktop.getConnectionConfig()
704
+ })
705
+
706
+ expect(reread.remoteTokenSet, 'the stored token must survive a restart').toBe(true)
707
+
708
+ const before = fake.sessionTokens.length
709
+ await exerciseStoredToken(second.page, fake.url)
710
+
711
+ expect(
712
+ fake.sessionTokens.slice(before),
713
+ 'the app must send the exact stored token to the gateway after a restart',
714
+ ).toContain(SENTINEL_TOKEN)
715
+ })
716
+
717
+ /**
718
+ * The read side of the same contract: an install written BEFORE the file was
719
+ * owner-only keeps its 0644 bits until something chmods it, and the write
720
+ * path cannot fix it — `fs.writeFileSync(path, data, { mode })` applies
721
+ * `mode` only when it CREATES the file. Waiting for the user's next Settings
722
+ * save would leave the file group/other-readable indefinitely, which is why
723
+ * `readDesktopConnectionConfig` tightens on a cache miss.
724
+ *
725
+ * Scoped to the MODE, and deliberately independent of the deferred migration
726
+ * below. The fixture's token is already safeStorage ciphertext (the app wrote
727
+ * it), so nothing here re-encrypts anything, touches the #62319 opt-in
728
+ * plaintext marker, or needs rotation guidance — the three prerequisites that
729
+ * keep the next test fixme'd. Tightening a permission bit neither performs a
730
+ * migration nor claims to, so it can be covered now while migration stays
731
+ * deferred.
732
+ *
733
+ * The fixture is produced by the app itself rather than hand-written, so the
734
+ * only difference from a real pre-fix install is the one bit under test.
735
+ */
736
+ test('an install whose connection.json predates owner-only mode is tightened on read', async () => {
737
+ const fake = gateway!
738
+ sandbox = createSandbox('at-rest-tighten')
739
+
740
+ const first = await launchAgainst(sandbox)
741
+ app = first.app
742
+
743
+ const capability = await readSafeStorageCapability(app)
744
+
745
+ test.info().annotations.push({
746
+ description: `isEncryptionAvailable=${capability.available} backend=${capability.backend}`,
747
+ type: 'safeStorage',
748
+ })
749
+
750
+ if (!capability.available) {
751
+ // Without secure storage the save is refused by design, so there is no
752
+ // app-written artifact to loosen and re-read. The refusal itself is
753
+ // already asserted in the first test.
754
+ test.skip(true, 'secure storage unavailable on this host — no app-written connection.json to tighten')
755
+
756
+ return
757
+ }
758
+
759
+ const userDataDir = await resolveUserDataDir(app)
760
+ const connectionFile = path.join(userDataDir, 'connection.json')
761
+
762
+ const saved = await saveRemoteToken(first.page, fake.url, SENTINEL_TOKEN)
763
+ expect(saved.error, 'the fixture write must succeed, or there is nothing to tighten').toBeNull()
764
+
765
+ await app.close().catch(() => undefined)
766
+ app = null
767
+
768
+ // Regress the file to what a pre-fix install has on disk. Everything else
769
+ // about it — including the encrypted token — is exactly what the app wrote.
770
+ fs.chmodSync(connectionFile, 0o644)
771
+ expect(fs.statSync(connectionFile).mode & 0o077, 'the fixture must start group/other-accessible').not.toBe(0)
772
+
773
+ const seededMtimeMs = fs.statSync(connectionFile).mtimeMs
774
+
775
+ // A fresh process starts with an empty config cache, so the first read is a
776
+ // miss and the tighten runs. `getConnectionConfig()` forces that read
777
+ // through the app's own IPC surface.
778
+ const second = await launchAgainst(sandbox)
779
+ app = second.app
780
+
781
+ const reread = await second.page.evaluate(async () => {
782
+ const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
783
+
784
+ return desktop.getConnectionConfig()
785
+ })
786
+
787
+ expectOwnerOnlyMode(
788
+ connectionFile,
789
+ 'a pre-existing world-readable connection.json was not tightened when the app read it',
790
+ )
791
+
792
+ // The tighten must be a chmod, not a rewrite. It sits INSIDE the function
793
+ // whose cache keys on mtimeMs, so if it ever moved mtime it would
794
+ // invalidate that cache on every read and re-tighten forever. chmod moves
795
+ // ctime only, which is what makes the placement safe — this pins it.
796
+ expect(
797
+ Math.abs(fs.statSync(connectionFile).mtimeMs - seededMtimeMs),
798
+ 'tightening must not rewrite the file: mtime is the config cache key, so moving it would invalidate the cache the tighten sits inside',
799
+ ).toBeLessThan(1)
800
+
801
+ // And tightening must not have cost the user their credential — the whole
802
+ // reason this happens on read instead of by deleting the file.
803
+ expect(reread.remoteTokenSet, 'the stored token must survive being tightened').toBe(true)
804
+ expect(reread.remoteUrl).toBe(fake.url)
805
+ })
806
+
807
+ /**
808
+ * The tighten must not be gated on the file being valid JSON.
809
+ *
810
+ * A truncated `connection.json` — an interrupted write on an older build, a
811
+ * half-finished hand edit, a partially restored backup — still contains the
812
+ * token bytes, and `JSON.parse` throws straight into the `catch` that falls
813
+ * back to local mode. That fallback is never written back, so nothing
814
+ * re-tightens the file later. With the chmod sequenced AFTER the parse,
815
+ * exactly the file that is both corrupt AND world-readable would be the one
816
+ * file never tightened, permanently.
817
+ *
818
+ * This is the only test that can tell the two orderings apart: every other
819
+ * test here uses a parseable file, where either ordering tightens. Asserting
820
+ * `mode === 'local'` is what makes it load-bearing — it proves the parse
821
+ * really threw, so a green mode assertion cannot be explained by anything
822
+ * downstream of the parse.
823
+ *
824
+ * Needs no secure storage: it is a chmod on a file that is never decrypted,
825
+ * so it holds on the keyring-less CI runner too.
826
+ */
827
+ test('a corrupt connection.json is tightened even though it never parses', async () => {
828
+ sandbox = createSandbox('at-rest-tighten-corrupt')
829
+
830
+ const connectionFile = path.join(sandbox.userDataDir, 'connection.json')
831
+
832
+ // Truncated mid-token: unparseable, yet the secret bytes are right there.
833
+ fs.writeFileSync(
834
+ connectionFile,
835
+ `{"mode":"remote","remote":{"authMode":"token","token":{"encoding":"plain","value":"${SENTINEL_TOKEN}`,
836
+ { encoding: 'utf8', mode: 0o644 },
837
+ )
838
+ fs.chmodSync(connectionFile, 0o644)
839
+ expect(fs.statSync(connectionFile).mode & 0o077, 'the fixture must start group/other-accessible').not.toBe(0)
840
+
841
+ const seededMtimeMs = fs.statSync(connectionFile).mtimeMs
842
+
843
+ const launched = await launchAgainst(sandbox)
844
+ app = launched.app
845
+
846
+ const reread = await launched.page.evaluate(async () => {
847
+ const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
848
+
849
+ return desktop.getConnectionConfig()
850
+ })
851
+
852
+ expect(
853
+ reread.mode,
854
+ 'the fixture must be unparseable, so the app falls back to local — otherwise this test proves nothing about ordering',
855
+ ).toBe('local')
856
+
857
+ expectOwnerOnlyMode(
858
+ connectionFile,
859
+ 'a corrupt world-readable connection.json still holding token bytes was left group/other-accessible',
860
+ )
861
+
862
+ // Same cache invariant as above: chmod, not rewrite.
863
+ expect(
864
+ Math.abs(fs.statSync(connectionFile).mtimeMs - seededMtimeMs),
865
+ 'tightening must not rewrite the file: mtime is the config cache key',
866
+ ).toBeLessThan(1)
867
+ })
868
+
869
+ /**
870
+ * DEFERRED GAP — legacy plaintext payloads are not migrated.
871
+ *
872
+ * Held as `fixme` rather than deleted: the fixture below is the correct
873
+ * fixture for the population that a migration must eventually cover, and
874
+ * the harness (seed → boot-poll → authoritative re-save → raw-bytes scan →
875
+ * wire check) is the harness such a migration needs. Keeping it typechecked
876
+ * and listed makes the gap visible in `--list` and in every report; deleting
877
+ * it would make the gap invisible and cost the next implementer this setup.
878
+ *
879
+ * It is NOT enabled because the migration it asserted was reviewed
880
+ * DO NOT SHIP. Before this can be un-fixme'd, three prerequisites (see the
881
+ * header, and the matching note in electron/main.ts readDesktopConnectionConfig):
882
+ *
883
+ * 1. Sequence with #62319's opt-in plaintext marker, so a user who
884
+ * deliberately chose plaintext is not silently overridden. This
885
+ * fixture has NO marker, so it stays in scope for migration — but the
886
+ * implementation must be able to tell the two apart.
887
+ * 2. Write through the config sanitizer, not around it.
888
+ * 3. Surface ROTATION guidance. Re-encrypting cannot un-expose a secret
889
+ * that is already in a backup; it only prevents future exposure.
890
+ *
891
+ * Un-fixme'ing this without (1) risks destroying a deliberate user choice,
892
+ * and without (3) it reports a remediation it did not actually perform.
893
+ */
894
+ test('an existing plaintext connection.json is migrated off plaintext and keeps working', async () => {
895
+ test.fixme(
896
+ true,
897
+ 'Deferred: legacy plaintext connection.json is intentionally NOT migrated. ' +
898
+ 'Affected population is pre-release bb/gui installs (incl. the desktop-pr20059-installers build) ' +
899
+ 'plus hand-edited configs — mainline never wrote a plaintext gateway token. ' +
900
+ 'Blocked on: (1) #62319 opt-in-marker coordination, (2) writing through the config sanitizer, ' +
901
+ '(3) surfacing token-rotation guidance. Re-encrypting alone does not remediate an already-backed-up secret.',
902
+ )
903
+
904
+ const fake = gateway!
905
+ sandbox = createSandbox('at-rest-migrate')
906
+
907
+ // Seed the file an affected user has on disk. This is live, usable
908
+ // plaintext rather than a strawman, because `decryptDesktopSecret` returns
909
+ // `value` verbatim for any non-safeStorage encoding — the READ path
910
+ // accepts it. Note what does NOT justify this fixture: mainline never
911
+ // WROTE this shape to disk. `coerceDesktopConnectionConfig` does build it,
912
+ // but only under `persistToken: false`, whose sole caller is the
913
+ // connection-test handler, which never persists. The writers were
914
+ // non-mainline pre-release builds and hand edits. There is deliberately no
915
+ // opt-in marker here, so this payload is in scope for a future migration.
916
+ fs.writeFileSync(
917
+ path.join(sandbox.userDataDir, 'connection.json'),
918
+ JSON.stringify(
919
+ {
920
+ mode: 'remote',
921
+ profiles: {},
922
+ remote: {
923
+ authMode: 'token',
924
+ token: { encoding: 'plain', value: SENTINEL_TOKEN },
925
+ url: fake.url,
926
+ },
927
+ },
928
+ null,
929
+ 2,
930
+ ),
931
+ 'utf8',
932
+ )
933
+
934
+ const launched = await launchAgainst(sandbox)
935
+ app = launched.app
936
+
937
+ const capability = await readSafeStorageCapability(app)
938
+
939
+ test.info().annotations.push({
940
+ description: `isEncryptionAvailable=${capability.available} backend=${capability.backend}`,
941
+ type: 'safeStorage',
942
+ })
943
+
944
+ if (!capability.available) {
945
+ // With no secure storage there is nowhere to migrate the secret TO, and
946
+ // scrubbing it would silently sign the user out of a working gateway.
947
+ // Asserting either outcome here would be inventing policy.
948
+ test.skip(
949
+ true,
950
+ 'secure storage unavailable on this host — the correct migration policy for an existing plaintext file is undecided',
951
+ )
952
+
953
+ return
954
+ }
955
+
956
+ const userDataDir = await resolveUserDataDir(app)
957
+ const connectionFile = path.join(userDataDir, 'connection.json')
958
+ const needles = secretNeedles(SENTINEL_TOKEN)
959
+
960
+ // Two chances, so the test does not depend on WHERE the fix hooks the
961
+ // migration: (a) on read at boot, (b) on the next authoritative write.
962
+ // Poll for (a) first.
963
+ const deadline = Date.now() + 15_000
964
+ let stillPlaintext = true
965
+
966
+ while (Date.now() < deadline) {
967
+ stillPlaintext = readIfExists(connectionFile).includes(needles[0].bytes)
968
+
969
+ if (!stillPlaintext) {
970
+ break
971
+ }
972
+
973
+ await launched.page.waitForTimeout(500)
974
+ }
975
+
976
+ if (stillPlaintext) {
977
+ // (b) A real save through the app's own surface, carrying no new token —
978
+ // the stored blob is inherited. Re-persisting an inherited secret is the
979
+ // other place plaintext must not survive.
980
+ const resaved = await saveRemoteToken(launched.page, fake.url)
981
+ expect(resaved.error, 'a re-save that inherits the stored token must not fail').toBeNull()
982
+ }
983
+
984
+ expect(
985
+ scanTreeForSecret(userDataDir, needles),
986
+ 'an existing plaintext gateway token must not remain readable under userData after the app has run ' +
987
+ `(stored token encoding is still "${storedTokenEncoding(connectionFile)}")`,
988
+ ).toEqual([])
989
+
990
+ // And the migration must not have cost the user their credential.
991
+ const before = fake.sessionTokens.length
992
+ await exerciseStoredToken(launched.page, fake.url)
993
+
994
+ expect(
995
+ fake.sessionTokens.slice(before),
996
+ 'the migrated token must still reach the gateway unchanged',
997
+ ).toContain(SENTINEL_TOKEN)
998
+ })
999
+ })
apps/desktop/e2e/batch-clarify.spec.ts ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * E2E batch clarify test — the multi-question clarify card must mount ONCE.
3
+ *
4
+ * Regression coverage for the duplicated-card bug: `tool.start` carries the
5
+ * model's tool_call_id while `clarify.request` carries a gateway-generated
6
+ * request_id. A batch payload has no top-level `question`, so the two rows
7
+ * only merge when the correlation key comes from the question list
8
+ * (`batchClarifyMatchValue` in lib/chat-messages/tool-parts.ts). Before that
9
+ * fix this exact flow rendered two identical interactive cards.
10
+ *
11
+ * The flow runs the real chain: composer → gateway → agent → clarify tool →
12
+ * clarify.request event → renderer, against the mock inference server.
13
+ */
14
+
15
+ import { expect, test } from './test'
16
+
17
+ import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
18
+ import { BATCH_CLARIFY_QUESTIONS, BATCH_CLARIFY_TRIGGER } from '../../../tests-js/scripts/mock-server'
19
+
20
+ let fixture: MockBackendFixture | null = null
21
+
22
+ test.beforeAll(async () => {
23
+ fixture = await setupMockBackend()
24
+ await waitForAppReady(fixture!, 120_000)
25
+ })
26
+
27
+ test.afterAll(async () => {
28
+ await fixture?.cleanup()
29
+ fixture = null
30
+ })
31
+
32
+ test.describe('batch clarify card', () => {
33
+ test('renders exactly one card and completes via per-question locks', async () => {
34
+ const page = fixture!.page
35
+ const composer = page.locator('[contenteditable="true"]').first()
36
+ await composer.waitFor({ state: 'visible', timeout: 10_000 })
37
+
38
+ await composer.click()
39
+ await composer.type(BATCH_CLARIFY_TRIGGER, { delay: 20 })
40
+ await page.keyboard.press('Enter')
41
+
42
+ // The live batch form marks itself with data-clarify-batch=<count>.
43
+ const batchCard = page.locator('form[data-clarify-batch]')
44
+ await batchCard.first().waitFor({ state: 'visible', timeout: 60_000 })
45
+
46
+ // THE regression assertion: one card, not two.
47
+ await expect(batchCard).toHaveCount(1)
48
+ await expect(batchCard).toHaveAttribute('data-clarify-batch', String(BATCH_CLARIFY_QUESTIONS.length))
49
+
50
+ // Both questions render inside the single card.
51
+ for (const entry of BATCH_CLARIFY_QUESTIONS) {
52
+ await expect(batchCard.getByText(entry.question)).toHaveCount(1)
53
+ }
54
+
55
+ // Each question text also appears exactly once in the whole transcript —
56
+ // catches a duplicate that mounts outside a form[data-clarify-batch].
57
+ for (const entry of BATCH_CLARIFY_QUESTIONS) {
58
+ await expect(page.getByText(entry.question)).toHaveCount(1)
59
+ }
60
+
61
+ // Answer both questions: stage picks locally (no server traffic yet).
62
+ const confirmButton = batchCard.locator('button[type="submit"]')
63
+ await expect(confirmButton).toContainText('Confirm and continue')
64
+ await expect(confirmButton).toBeDisabled()
65
+
66
+ await batchCard.getByRole('button', { name: /Coffee/ }).click()
67
+ await expect(confirmButton).toBeDisabled()
68
+
69
+ await batchCard.getByRole('button', { name: /Morning/ }).click()
70
+ await expect(confirmButton).toBeEnabled()
71
+
72
+ // ONE confirm submits the whole batch.
73
+ await confirmButton.click()
74
+
75
+ // The settled card lists both questions with their locked answers.
76
+ const settled = page.locator('[data-clarify-settled]')
77
+ await settled.waitFor({ state: 'visible', timeout: 30_000 })
78
+ await expect(settled.getByText(BATCH_CLARIFY_QUESTIONS[0].question)).toBeVisible()
79
+ await expect(settled.getByText('Coffee', { exact: true })).toBeVisible()
80
+ await expect(settled.getByText(BATCH_CLARIFY_QUESTIONS[1].question)).toBeVisible()
81
+ await expect(settled.getByText('Morning', { exact: true })).toBeVisible()
82
+
83
+ // And still no duplicate live card lingering after settle.
84
+ await expect(page.locator('form[data-clarify-batch]')).toHaveCount(0)
85
+ })
86
+ })
apps/desktop/e2e/boot-failure.spec.ts ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * E2E boot-failure tests — verify the app shows an error overlay when the
3
+ * backend can't start.
4
+ *
5
+ * Injects a fake boot error (HERMES_DESKTOP_BOOT_FAKE_ERROR) so the backend
6
+ * resolution fails with a controlled error message. The app should show the
7
+ * BootFailureOverlay with retry/repair actions.
8
+ *
9
+ * Prerequisite: `npm run build` must have been run so dist/ exists.
10
+ */
11
+
12
+ import { allowErrorBanners, test } from './test'
13
+
14
+ import {
15
+ type DeadBackendFixture,
16
+ setupDeadBackend,
17
+ waitForBootFailure,
18
+ } from './fixtures'
19
+ import { expectVisualSnapshot } from './visual-snapshot'
20
+
21
+ let fixture: DeadBackendFixture | null = null
22
+
23
+ test.afterAll(async () => {
24
+ await fixture?.cleanup()
25
+ fixture = null
26
+ })
27
+
28
+ test.describe('boot failure with dead backend', () => {
29
+ test.beforeEach(() => {
30
+ // These tests deliberately trigger boot errors — error banners
31
+ // (notifyError → [role="alert"]) are expected, not failures.
32
+ allowErrorBanners()
33
+ })
34
+
35
+ test('app shows error state', async () => {
36
+ // Inject a fake boot error so the backend resolution "fails" with a
37
+ // controlled error message. This is the only reliable way to trigger
38
+ // BootFailureOverlay in dev mode.
39
+ fixture = await setupDeadBackend({ fakeError: true })
40
+
41
+ await waitForBootFailure(fixture.page, 90_000)
42
+ })
43
+
44
+ test('screenshot of error state', async () => {
45
+ if (!fixture) {
46
+ test.skip(true, 'Previous test failed — no app running')
47
+
48
+ return
49
+ }
50
+
51
+ await expectVisualSnapshot(fixture!.page, { name: 'boot-failure-error-state', app: fixture.app })
52
+ })
53
+ })
apps/desktop/e2e/boot.spec.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * E2E smoke tests for the dev-mode desktop app.
3
+ *
4
+ * These tests launch the Electron app from the built dist/ (not the
5
+ * packaged binary) with a real `hermes serve` backend pointed at a mock
6
+ * inference server. The full chain is exercised:
7
+ *
8
+ * electron → hermes serve (python) → mock provider → renderer
9
+ *
10
+ * Prerequisite: `npm run build` must have been run so dist/ exists.
11
+ * Run from the nix devshell:
12
+ * npm exec playwright test e2e/boot.spec.ts --reporter=list
13
+ */
14
+ import { expect, test } from './test'
15
+
16
+ import {
17
+ type MockBackendFixture,
18
+ setupMockBackend,
19
+ waitForAppReady,
20
+ } from './fixtures'
21
+ import { expectVisualSnapshot } from './visual-snapshot'
22
+
23
+ let fixture: MockBackendFixture | null = null
24
+
25
+ test.beforeAll(async () => {
26
+ fixture = await setupMockBackend()
27
+ })
28
+
29
+ test.afterAll(async () => {
30
+ await fixture?.cleanup()
31
+ fixture = null
32
+ })
33
+
34
+ test.describe('dev-mode boot with mock backend', () => {
35
+ test('window opens with Hermes title', async () => {
36
+ const title = await fixture!.page.title()
37
+ expect(title).toContain('Hermes')
38
+ })
39
+
40
+ test('renderer mounts and shows DOM content', async () => {
41
+ const page = fixture!.page
42
+ // Wait for the React root to mount. The app renders into #root
43
+ // (see src/main.tsx), but content may arrive through portals — so
44
+ // check the body for any interactive content instead.
45
+ await page.waitForSelector('body', { state: 'attached' })
46
+ // Wait for the main app shell — the composer is always present.
47
+ await page.waitForSelector('textarea, [contenteditable="true"]', {
48
+ state: 'attached',
49
+ timeout: 30_000,
50
+ })
51
+ })
52
+
53
+ // A preload that throws never reaches contextBridge, so the renderer boots
54
+ // into "Desktop IPC bridge is unavailable" and every test below it dies on a
55
+ // 120s never-became-ready timeout instead. Checking the bridge by name makes
56
+ // that failure legible. The sandbox lets preload require only electron,
57
+ // events, timers and url — adding any other node builtin lands here.
58
+ test('the preload bridge reaches the renderer', async () => {
59
+ const bridge = await fixture!.page.evaluate(() => {
60
+ const desktop = (window as unknown as { hermesDesktop?: Record<string, unknown> }).hermesDesktop
61
+
62
+ return {
63
+ present: typeof desktop,
64
+ glassSupported: typeof desktop?.glassSupported,
65
+ translucencySupported: typeof desktop?.translucencySupported
66
+ }
67
+ })
68
+
69
+ expect(bridge).toEqual({ present: 'object', glassSupported: 'boolean', translucencySupported: 'boolean' })
70
+ })
71
+
72
+ test('backend boots and app becomes ready', async () => {
73
+ // This is the big one — wait for the full boot chain to complete:
74
+ // electron starts → hermes serve is spawned → WS connects → config
75
+ // loaded → sessions loaded → boot overlay dismissed → composer visible.
76
+ await waitForAppReady(fixture!, 120_000)
77
+ })
78
+
79
+ test('screenshot after boot', async () => {
80
+ await expectVisualSnapshot(fixture!.page, { name: 'boot-ready', app: fixture!.app })
81
+ })
82
+ })
apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import {
5
+ buildAppEnv,
6
+ createSandbox,
7
+ launchDesktop,
8
+ type MockBackendFixture,
9
+ waitForAppReady,
10
+ writeEnvFile,
11
+ writeMockProviderConfig
12
+ } from './fixtures'
13
+ import { MOCK_REPLY, startMockServer } from '../../../tests-js/scripts/mock-server'
14
+ import { RealSessionBuilder } from './real-session-builder'
15
+ import { expect, test } from './test'
16
+
17
+ // A bot row previews the bot's canonical Bot Chat (the gateway resolves it by
18
+ // name on every roster poll). Clicking the row must land on THAT conversation.
19
+ // Before this fix a plain click fronted whatever bots-workspace tile the user
20
+ // last had open for that bot — a `+` side thread outlived every restart in
21
+ // Local Storage and won every click forever, while the row kept previewing the
22
+ // Bot Chat. The user saw the sidebar and the center describe two different
23
+ // conversations ("sessions not in sync"; support thread 1544460286084391043).
24
+
25
+ type Page = MockBackendFixture['page']
26
+
27
+ let fixture: MockBackendFixture | null = null
28
+
29
+ async function openBots(page: Page): Promise<void> {
30
+ const tab = page
31
+ .getByRole('button', { name: 'Bots', exact: true })
32
+ .or(page.getByRole('tab', { name: 'Bots', exact: true }))
33
+ .first()
34
+
35
+ await tab.click()
36
+ await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
37
+ }
38
+
39
+ async function settle(page: Page, timeout = 90_000): Promise<void> {
40
+ await page
41
+ .getByText(/Waking up/i)
42
+ .first()
43
+ .waitFor({ state: 'hidden', timeout })
44
+ .catch(() => undefined)
45
+ await page.waitForTimeout(500)
46
+ }
47
+
48
+ async function openUntil(action: () => Promise<void>, expected: () => Promise<void>, attempts = 3): Promise<void> {
49
+ for (let attempt = 1; ; attempt += 1) {
50
+ await action()
51
+
52
+ try {
53
+ await expected()
54
+
55
+ return
56
+ } catch (error) {
57
+ if (attempt >= attempts) {
58
+ throw error
59
+ }
60
+ }
61
+ }
62
+ }
63
+
64
+ async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise<void> {
65
+ const dir = path.join(hermesHome, 'profiles', name)
66
+ fs.mkdirSync(dir, { recursive: true })
67
+ writeMockProviderConfig(dir, mockUrl)
68
+ writeEnvFile(dir)
69
+
70
+ const builder = await RealSessionBuilder.start(dir)
71
+
72
+ try {
73
+ await builder.createSession({ title: 'Bot Chat', turns: [`Hello ${name}`] })
74
+ } finally {
75
+ await builder.close()
76
+ }
77
+ }
78
+
79
+ test.beforeAll(async () => {
80
+ const mock = await startMockServer()
81
+ const sandbox = createSandbox('bots-sync')
82
+ writeMockProviderConfig(sandbox.hermesHome, mock.url)
83
+ writeEnvFile(sandbox.hermesHome)
84
+ await seedBot(sandbox.hermesHome, mock.url, 'alpha')
85
+ await seedBot(sandbox.hermesHome, mock.url, 'beta')
86
+
87
+ const { app, page } = await launchDesktop(buildAppEnv(sandbox))
88
+
89
+ fixture = {
90
+ app,
91
+ page,
92
+ mock,
93
+ mockUrl: mock.url,
94
+ sandbox,
95
+ cleanup: async () => {
96
+ await app.close().catch(() => undefined)
97
+ await mock.close()
98
+ sandbox.cleanup()
99
+ }
100
+ }
101
+ await waitForAppReady(fixture, 120_000)
102
+ })
103
+
104
+ test.afterAll(async () => {
105
+ await fixture?.cleanup()
106
+ fixture = null
107
+ })
108
+
109
+ test('a bot row click lands on the Bot Chat the row previews, not a side thread', async () => {
110
+ test.setTimeout(300_000)
111
+ const page = fixture!.page
112
+
113
+ await openBots(page)
114
+
115
+ const alphaRow = page.getByRole('button', { name: /^alpha\b/i }).filter({ visible: true }).first()
116
+ const betaRow = page.getByRole('button', { name: /^beta\b/i }).filter({ visible: true }).first()
117
+ await expect(alphaRow).toBeVisible({ timeout: 30_000 })
118
+ await expect(betaRow).toBeVisible({ timeout: 30_000 })
119
+ const seededTurn = page.getByText('Hello alpha', { exact: true }).filter({ visible: true })
120
+
121
+ await openUntil(
122
+ () => alphaRow.click(),
123
+ () => expect(seededTurn.first()).toBeVisible({ timeout: 45_000 })
124
+ )
125
+ await settle(page, 15_000)
126
+
127
+ // A `+` side thread for alpha, with a real turn so it is a persisted tile.
128
+ await page.keyboard.press('Control+t')
129
+ const composer = page.locator('[data-slot="composer-root"] [contenteditable="true"]').filter({ visible: true }).first()
130
+ await expect(composer).toBeVisible({ timeout: 15_000 })
131
+ await composer.click()
132
+ await composer.fill('hello alpha thread')
133
+ await page.keyboard.press('Enter')
134
+ await expect(page.getByText(MOCK_REPLY).filter({ visible: true }).first()).toBeVisible({ timeout: 60_000 })
135
+
136
+ // Leave alpha on the side thread, go to beta, come back via the row.
137
+ await betaRow.click()
138
+ await expect(page.getByText('Hello beta', { exact: true }).filter({ visible: true }).first()).toBeVisible({
139
+ timeout: 60_000
140
+ })
141
+ await settle(page)
142
+
143
+ await alphaRow.click()
144
+ // The row previews the Bot Chat; the click must front it.
145
+ await expect(seededTurn.first()).toBeVisible({ timeout: 45_000 })
146
+ // The side thread is still open beside it (scoped to alpha), not closed.
147
+ await expect
148
+ .poll(
149
+ () =>
150
+ page.evaluate(() =>
151
+ [...document.querySelectorAll<HTMLElement>('[data-zone-tabstrip="grp-main"] [data-tree-tab]')]
152
+ .map(element => element.getAttribute('data-tree-tab') ?? '')
153
+ .filter(id => id.startsWith('session-tile:')).length
154
+ ),
155
+ { timeout: 15_000 }
156
+ )
157
+ .toBe(1)
158
+ })
apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import {
5
+ buildAppEnv,
6
+ createSandbox,
7
+ launchDesktop,
8
+ type MockBackendFixture,
9
+ waitForAppReady,
10
+ writeEnvFile,
11
+ writeMockProviderConfig
12
+ } from './fixtures'
13
+ import { MOCK_REPLY, startMockServer } from '../../../tests-js/scripts/mock-server'
14
+ import { RealSessionBuilder } from './real-session-builder'
15
+ import { expect, test } from './test'
16
+
17
+ // Every bot's canonical chat is STORED under the same title ("Bot Chat" — the
18
+ // name the gateway resolves it by), so the main tab strip captioned every open
19
+ // bot chat identically and two bots' tabs were indistinguishable (#99152). The
20
+ // tab must read the bot's display name while the stored title stays canonical.
21
+
22
+ type Page = MockBackendFixture['page']
23
+
24
+ let fixture: MockBackendFixture | null = null
25
+
26
+ async function openBots(page: Page): Promise<void> {
27
+ const tab = page
28
+ .getByRole('button', { name: 'Bots', exact: true })
29
+ .or(page.getByRole('tab', { name: 'Bots', exact: true }))
30
+ .first()
31
+
32
+ await tab.click()
33
+ await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
34
+ }
35
+
36
+ async function openUntil(action: () => Promise<void>, expected: () => Promise<void>, attempts = 3): Promise<void> {
37
+ for (let attempt = 1; ; attempt += 1) {
38
+ await action()
39
+
40
+ try {
41
+ await expected()
42
+
43
+ return
44
+ } catch (error) {
45
+ if (attempt >= attempts) {
46
+ throw error
47
+ }
48
+ }
49
+ }
50
+ }
51
+
52
+ async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise<void> {
53
+ const dir = path.join(hermesHome, 'profiles', name)
54
+ fs.mkdirSync(dir, { recursive: true })
55
+ writeMockProviderConfig(dir, mockUrl)
56
+ writeEnvFile(dir)
57
+
58
+ const builder = await RealSessionBuilder.start(dir)
59
+
60
+ try {
61
+ await builder.createSession({ title: 'Bot Chat', turns: [`Hello ${name}`] })
62
+ } finally {
63
+ await builder.close()
64
+ }
65
+ }
66
+
67
+ /** Every tab caption in the main strip (the main `workspace` tab + tiles). */
68
+ function mainStripTabTitles(page: Page): Promise<string[]> {
69
+ return page.evaluate(() =>
70
+ [...document.querySelectorAll<HTMLElement>('[data-zone-tabstrip="grp-main"] [data-tree-tab]')].map(element =>
71
+ (element.textContent ?? '').trim()
72
+ )
73
+ )
74
+ }
75
+
76
+ test.beforeAll(async () => {
77
+ const mock = await startMockServer()
78
+ const sandbox = createSandbox('bots-tabname')
79
+ writeMockProviderConfig(sandbox.hermesHome, mock.url)
80
+ writeEnvFile(sandbox.hermesHome)
81
+ await seedBot(sandbox.hermesHome, mock.url, 'alpha')
82
+ await seedBot(sandbox.hermesHome, mock.url, 'beta')
83
+
84
+ const { app, page } = await launchDesktop(buildAppEnv(sandbox))
85
+
86
+ fixture = {
87
+ app,
88
+ page,
89
+ mock,
90
+ mockUrl: mock.url,
91
+ sandbox,
92
+ cleanup: async () => {
93
+ await app.close().catch(() => undefined)
94
+ await mock.close()
95
+ sandbox.cleanup()
96
+ }
97
+ }
98
+ await waitForAppReady(fixture, 120_000)
99
+ })
100
+
101
+ test.afterAll(async () => {
102
+ await fixture?.cleanup()
103
+ fixture = null
104
+ })
105
+
106
+ test("an open Bot Chat's tab reads the bot's name, not the canonical 'Bot Chat' title", async () => {
107
+ test.setTimeout(300_000)
108
+ const page = fixture!.page
109
+
110
+ await openBots(page)
111
+
112
+ const alphaRow = page.getByRole('button', { name: /^alpha\b/i }).filter({ visible: true }).first()
113
+ await expect(alphaRow).toBeVisible({ timeout: 30_000 })
114
+
115
+ await openUntil(
116
+ () => alphaRow.click(),
117
+ () =>
118
+ expect(page.getByText('Hello alpha', { exact: true }).filter({ visible: true }).first()).toBeVisible({
119
+ timeout: 45_000
120
+ })
121
+ )
122
+
123
+ // A `+` side thread beside the Bot Chat gives the main zone a tab strip —
124
+ // the surface where every bot chat used to read "Bot Chat".
125
+ await page.keyboard.press('Control+t')
126
+ const composer = page.locator('[data-slot="composer-root"] [contenteditable="true"]').filter({ visible: true }).first()
127
+ await expect(composer).toBeVisible({ timeout: 15_000 })
128
+ await composer.click()
129
+ await composer.fill('hello alpha thread')
130
+ await page.keyboard.press('Enter')
131
+ await expect(page.getByText(MOCK_REPLY).filter({ visible: true }).first()).toBeVisible({ timeout: 60_000 })
132
+
133
+ await expect.poll(() => mainStripTabTitles(page), { timeout: 15_000 }).toHaveLength(2)
134
+ const captions = await mainStripTabTitles(page)
135
+ expect(captions.some(caption => /alpha/i.test(caption))).toBe(true)
136
+ expect(captions.some(caption => /bot chat/i.test(caption))).toBe(false)
137
+ })
apps/desktop/e2e/bot-roster-user-sections.spec.ts ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import {
5
+ buildAppEnv,
6
+ createSandbox,
7
+ launchDesktop,
8
+ type MockBackendFixture,
9
+ waitForAppReady,
10
+ writeEnvFile,
11
+ writeMockProviderConfig
12
+ } from './fixtures'
13
+ import { startMockServer } from '../../../tests-js/scripts/mock-server'
14
+ import { RealSessionBuilder } from './real-session-builder'
15
+ import { expect, test } from './test'
16
+
17
+ // User-made sections in the Bots roster: a bot is filed by dragging it onto a
18
+ // section or through its row menu, the section is renamed through the same
19
+ // dialog shape sessions use, and deleting a section returns its bots to
20
+ // Unassigned (with an Undo toast, no confirmation). With no sections created
21
+ // the roster is the plain list it always was.
22
+
23
+ type Page = MockBackendFixture['page']
24
+
25
+ let fixture: MockBackendFixture | null = null
26
+
27
+ // BOT_SECTIONS_SCREENSHOT_DIR=<dir> saves full-window captures at the key
28
+ // states — handy for design review; never part of the assertions.
29
+ async function capture(page: Page, name: string): Promise<void> {
30
+ const dir = process.env.BOT_SECTIONS_SCREENSHOT_DIR
31
+
32
+ if (!dir) {
33
+ return
34
+ }
35
+
36
+ fs.mkdirSync(dir, { recursive: true })
37
+ await page.screenshot({ path: path.join(dir, `${name}.png`) })
38
+ }
39
+
40
+ async function seedBot(hermesHome: string, mockUrl: string, name: string): Promise<void> {
41
+ const dir = path.join(hermesHome, 'profiles', name)
42
+ fs.mkdirSync(dir, { recursive: true })
43
+ writeMockProviderConfig(dir, mockUrl)
44
+ writeEnvFile(dir)
45
+
46
+ const builder = await RealSessionBuilder.start(dir)
47
+
48
+ try {
49
+ await builder.createSession({ title: 'Bot Chat', turns: [`Hello ${name}`] })
50
+ } finally {
51
+ await builder.close()
52
+ }
53
+ }
54
+
55
+ const roster = (page: Page) => page.locator('[data-slot="bots-roster"]')
56
+ const botRow = (page: Page, name: string) => roster(page).locator(`[data-roster-key="local::${name}"]`)
57
+
58
+ /** A section's label span — the one node whose text is exactly the name. */
59
+ const sectionLabel = (page: Page, name: string) =>
60
+ page.locator('span.truncate', { hasText: new RegExp(`^${name}$`, 'i') })
61
+
62
+ /** The heading's fold button (label + count) — the ⋯ menu trigger is a sibling with no text. */
63
+ const sectionHeading = (page: Page, name: string) =>
64
+ roster(page).locator('[data-slot="bots-section"] button[aria-expanded]').filter({ has: sectionLabel(page, name) })
65
+
66
+ const sectionBlock = (page: Page, name: string) =>
67
+ roster(page).locator('[data-slot="bots-section"]').filter({ has: sectionLabel(page, name) })
68
+
69
+ /** Section name → roster keys of the rows under it (the plain list has no sections). */
70
+ async function layout(page: Page): Promise<Array<[string, string[]]>> {
71
+ return roster(page).locator('[data-slot="bots-section"]').evaluateAll(blocks =>
72
+ blocks.map(block => [
73
+ block.querySelector('button[aria-expanded] span.truncate')?.textContent?.trim() ?? '',
74
+ [...block.querySelectorAll<HTMLElement>('[data-roster-key]')].map(row => row.dataset.rosterKey ?? '')
75
+ ])
76
+ )
77
+ }
78
+
79
+ test.beforeAll(async () => {
80
+ const mock = await startMockServer()
81
+ const sandbox = createSandbox('bots-sections')
82
+ writeMockProviderConfig(sandbox.hermesHome, mock.url)
83
+ writeEnvFile(sandbox.hermesHome)
84
+
85
+ for (const name of ['alpha', 'beta', 'gamma']) {
86
+ await seedBot(sandbox.hermesHome, mock.url, name)
87
+ }
88
+
89
+ const { app, page } = await launchDesktop(buildAppEnv(sandbox))
90
+
91
+ fixture = {
92
+ app,
93
+ page,
94
+ mock,
95
+ mockUrl: mock.url,
96
+ sandbox,
97
+ cleanup: async () => {
98
+ await app.close().catch(() => undefined)
99
+ await mock.close()
100
+ sandbox.cleanup()
101
+ }
102
+ }
103
+ await waitForAppReady(fixture, 120_000)
104
+ })
105
+
106
+ test.afterAll(async () => {
107
+ await fixture?.cleanup()
108
+ fixture = null
109
+ })
110
+
111
+ test('file bots into user sections by menu and drag; rename; delete returns them to Unassigned', async () => {
112
+ test.setTimeout(300_000)
113
+ const page = fixture!.page
114
+
115
+ const tab = page
116
+ .getByRole('button', { name: 'Bots', exact: true })
117
+ .or(page.getByRole('tab', { name: 'Bots', exact: true }))
118
+ .first()
119
+
120
+ await tab.click()
121
+ await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
122
+ await expect(botRow(page, 'alpha')).toBeVisible({ timeout: 30_000 })
123
+ await expect(botRow(page, 'beta')).toBeVisible({ timeout: 30_000 })
124
+
125
+ // No sections yet: the plain list, no section chrome at all.
126
+ await expect(roster(page).locator('[data-slot="bots-section"]')).toHaveCount(0)
127
+ await capture(page, '1-plain-roster')
128
+
129
+ // Right-click alpha → Move to section → New section… → name it → alpha is filed.
130
+ await botRow(page, 'alpha').click({ button: 'right' })
131
+ await page.getByRole('menuitem', { name: 'Move to section' }).hover()
132
+ await expect(page.getByRole('menuitem', { name: 'New section…' })).toBeVisible()
133
+ await capture(page, '2-row-menu-move-to-section')
134
+ await page.getByRole('menuitem', { name: 'New section…' }).click()
135
+ const nameField = page.getByRole('textbox', { name: 'Section name' })
136
+ await expect(nameField).toBeVisible()
137
+ await nameField.fill('Clients')
138
+ await capture(page, '3-new-section-dialog')
139
+ await page.getByRole('button', { name: 'Create' }).click()
140
+
141
+ await expect(sectionHeading(page, 'Clients')).toBeVisible()
142
+ await expect(sectionBlock(page, 'Clients').locator('[data-roster-key="local::alpha"]')).toBeVisible()
143
+ // The remainder is Unassigned, drawn last.
144
+ await expect
145
+ .poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length]))
146
+ .toEqual([
147
+ ['Clients', 1],
148
+ ['Unassigned', 3]
149
+ ])
150
+ await capture(page, '4-alpha-filed')
151
+
152
+ // Drag beta over the Clients block: the target highlights while over it.
153
+ // Escape cancels — nothing moves, nothing stays highlighted or faded.
154
+ const target = sectionBlock(page, 'Clients')
155
+ const from = (await botRow(page, 'beta').boundingBox())!
156
+ const to = (await sectionHeading(page, 'Clients').boundingBox())!
157
+
158
+ const dragBetaOverClients = async () => {
159
+ await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2)
160
+ await page.mouse.down()
161
+ await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2 - 10, { steps: 4 })
162
+ await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 })
163
+ await expect(target).toHaveAttribute('data-drop-over', 'true')
164
+ }
165
+
166
+ await dragBetaOverClients()
167
+ await page.keyboard.press('Escape')
168
+ await page.mouse.up()
169
+ await expect(target).not.toHaveAttribute('data-drop-over', 'true')
170
+ await expect(botRow(page, 'beta')).toHaveCSS('opacity', '1')
171
+ expect((await layout(page)).map(([name, keys]) => [name, keys.length])).toEqual([
172
+ ['Clients', 1],
173
+ ['Unassigned', 3]
174
+ ])
175
+
176
+ // Drop it for real: the bot is filed.
177
+ await dragBetaOverClients()
178
+ await capture(page, '5-drag-over-clients')
179
+ await page.mouse.up()
180
+
181
+ await expect(target.locator('[data-roster-key="local::beta"]')).toBeVisible()
182
+ await expect(target).not.toHaveAttribute('data-drop-over', 'true')
183
+ // The moved row remounts under its new section; it must not stay faded.
184
+ await expect(botRow(page, 'beta')).toHaveCSS('opacity', '1')
185
+ await expect
186
+ .poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length]))
187
+ .toEqual([
188
+ ['Clients', 2],
189
+ ['Unassigned', 2]
190
+ ])
191
+ await capture(page, '6-beta-dropped')
192
+
193
+ // Rename through the heading's context menu — the same Dialog + Input
194
+ // + Save shape as a session rename.
195
+ await sectionHeading(page, 'Clients').click({ button: 'right' })
196
+ await page.getByRole('menuitem', { name: 'Rename…' }).click()
197
+ await expect(nameField).toHaveValue('Clients')
198
+ await nameField.fill('Customers')
199
+ await page.getByRole('button', { name: 'Save' }).click()
200
+ await expect(sectionHeading(page, 'Customers')).toBeVisible()
201
+ await expect(sectionHeading(page, 'Clients')).toHaveCount(0)
202
+ await capture(page, '7-renamed')
203
+
204
+ // A second, empty section from the + menu shows its drop hint; collapsing
205
+ // a section folds its rows like the gateway headings do.
206
+ await page.getByRole('button', { name: 'New bot or group chat' }).click()
207
+ await page.getByRole('menuitem', { name: 'New section' }).click()
208
+ await nameField.fill('Team')
209
+ await page.getByRole('button', { name: 'Create' }).click()
210
+ await expect(sectionBlock(page, 'Team').getByText('Drag bots here')).toBeVisible()
211
+ await sectionHeading(page, 'Customers').click()
212
+ await expect(sectionBlock(page, 'Customers').locator('[data-roster-key]')).toHaveCount(0)
213
+ await capture(page, '8-empty-section-and-collapsed')
214
+ await sectionHeading(page, 'Customers').click()
215
+ await expect(sectionBlock(page, 'Customers').locator('[data-roster-key]')).toHaveCount(2)
216
+
217
+ // Delete Customers: no confirmation, its two bots return to Unassigned,
218
+ // and the toast offers Undo.
219
+ await sectionHeading(page, 'Customers').click({ button: 'right' })
220
+ await page.getByRole('menuitem', { name: 'Delete' }).click()
221
+ await expect(sectionHeading(page, 'Customers')).toHaveCount(0)
222
+ const toast = page.getByRole('status').filter({ hasText: 'Deleted “Customers”' })
223
+ await expect(toast).toBeVisible()
224
+ await expect
225
+ .poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length]))
226
+ .toEqual([
227
+ ['Team', 0],
228
+ ['Unassigned', 4]
229
+ ])
230
+ await capture(page, '9-deleted-with-undo-toast')
231
+
232
+ await toast.getByRole('button', { name: 'Undo' }).click()
233
+ await expect(sectionHeading(page, 'Customers')).toBeVisible()
234
+ await expect
235
+ .poll(async () => (await layout(page)).map(([name, keys]) => [name, keys.length]))
236
+ .toEqual([
237
+ ['Customers', 2],
238
+ ['Team', 0],
239
+ ['Unassigned', 2]
240
+ ])
241
+
242
+ // Membership rides the bot's profile ui_meta, so it follows profile sync.
243
+ const alphaProfile = path.join(fixture!.sandbox.hermesHome, 'profiles', 'alpha', 'profile.yaml')
244
+ await expect.poll(() => (fs.existsSync(alphaProfile) ? fs.readFileSync(alphaProfile, 'utf8') : '')).toMatch(/sectionId:\s*sec-/)
245
+
246
+ // Delete both sections: the roster is the plain list again.
247
+ for (const name of ['Customers', 'Team']) {
248
+ await sectionHeading(page, name).click({ button: 'right' })
249
+ await page.getByRole('menuitem', { name: 'Delete' }).click()
250
+ }
251
+
252
+ await expect(roster(page).locator('[data-slot="bots-section"]')).toHaveCount(0)
253
+ await expect(botRow(page, 'alpha')).toBeVisible()
254
+ })
apps/desktop/e2e/chat.spec.ts ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * E2E chat tests — send a message and verify a response appears.
3
+ *
4
+ * Requires the full boot chain to complete (hermes serve + mock inference
5
+ * provider). The mock server returns a canned reply, so we verify the
6
+ * response text shows up in the chat transcript.
7
+ *
8
+ * Prerequisite: `npm run build` must have been run so dist/ exists.
9
+ */
10
+
11
+ import { expect, test } from './test'
12
+
13
+ import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
14
+ import { BLOCKING_CLARIFY_QUESTION, BLOCKING_CLARIFY_TRIGGER } from '../../../tests-js/scripts/mock-server'
15
+ import { expectVisualSnapshot } from './visual-snapshot'
16
+
17
+ let fixture: MockBackendFixture | null = null
18
+
19
+ test.beforeAll(async () => {
20
+ fixture = await setupMockBackend()
21
+ await waitForAppReady(fixture!, 120_000)
22
+ })
23
+
24
+ test.afterAll(async () => {
25
+ await fixture?.cleanup()
26
+ fixture = null
27
+ })
28
+
29
+ test.describe('chat interaction with mock backend', () => {
30
+ test('send a message and receive a response', async () => {
31
+ const page = fixture!.page
32
+
33
+ // Find the composer — it's a contenteditable textbox.
34
+ const composer = page.locator('[contenteditable="true"]').first()
35
+ await composer.waitFor({ state: 'visible', timeout: 10_000 })
36
+
37
+ // Click to focus, then type the message character by character.
38
+ // Using `type` instead of `fill` because the composer is a
39
+ // contenteditable div with custom keydown handling that tracks
40
+ // IME composition state — `fill` bypasses the event chain.
41
+ await composer.click()
42
+ await composer.type('Hello, can you hear me?', { delay: 20 })
43
+
44
+ // Submit with Enter — the composer's keydown handler intercepts
45
+ // plain Enter (without Shift) and calls submitDraft().
46
+ await page.keyboard.press('Enter')
47
+
48
+ // Wait for the user's message to appear in the transcript.
49
+ // The message renders as an assistant-ui message in the chat view.
50
+ await page.waitForFunction(
51
+ () => {
52
+ const body = document.body
53
+
54
+ if (!body) {
55
+ return false
56
+ }
57
+
58
+ return (body.textContent ?? '').includes('Hello, can you hear me?')
59
+ },
60
+ undefined,
61
+ { timeout: 15_000 }
62
+ )
63
+
64
+ // Wait for the mock response to appear. The canned reply is:
65
+ // "Hello from the mock inference server! The full boot chain is working."
66
+ // Give it a generous timeout — the inference request goes through the
67
+ // gateway → hermes serve → mock server → streaming SSE back.
68
+ await page.waitForFunction(
69
+ () => {
70
+ const body = document.body
71
+
72
+ if (!body) {
73
+ return false
74
+ }
75
+
76
+ const text = body.textContent ?? ''
77
+
78
+ return text.includes('mock inference server') || text.includes('boot chain is working')
79
+ },
80
+ undefined,
81
+ { timeout: 60_000 }
82
+ )
83
+ })
84
+
85
+ test('screenshot of chat with messages', async () => {
86
+ await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app })
87
+ })
88
+
89
+ test('offers stop, steer, and queue actions while busy', async ({}, testInfo) => {
90
+ const page = fixture!.page
91
+ const composer = page.locator('[contenteditable="true"]').first()
92
+ const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
93
+ const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]')
94
+ const dictation = page.locator('[data-slot="composer-root"] button[aria-label="Voice dictation"]')
95
+ const speakReplies = page.locator(
96
+ '[data-slot="composer-root"] button[aria-label="Read replies aloud"], [data-slot="composer-root"] button[aria-label="Stop reading replies aloud"]'
97
+ )
98
+
99
+ await composer.click()
100
+ await composer.type(BLOCKING_CLARIFY_TRIGGER)
101
+ await page.keyboard.press('Enter')
102
+ await page.getByText(BLOCKING_CLARIFY_QUESTION).waitFor({ state: 'visible', timeout: 30_000 })
103
+
104
+ await expect(primary).toHaveAttribute('aria-label', 'Stop')
105
+ await expect(primary.locator('span')).toHaveClass(/bg-current/)
106
+
107
+ await composer.click()
108
+ await composer.type('please answer tersely')
109
+ // Since "running is not busy" (3bc52fb9df) the primary keeps the Send
110
+ // affordance mid-turn — steer is routed through the submit engine, not a
111
+ // separate labeled button. Queue remains the explicit secondary action.
112
+ await expect(primary).toHaveAttribute('aria-label', 'Send')
113
+ await expect(dictation).toBeVisible()
114
+ await expect(speakReplies).toBeVisible()
115
+ await expect(queue).toBeVisible()
116
+ await expect(queue.locator('svg.tabler-icon-layers-intersect-2')).toBeVisible()
117
+ const controlLabels = await page
118
+ .locator('[data-slot="composer-root"] button')
119
+ .evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-label')))
120
+ const speakRepliesIndex = controlLabels.findIndex(
121
+ label => label === 'Read replies aloud' || label === 'Stop reading replies aloud'
122
+ )
123
+ expect(controlLabels.indexOf('Voice dictation')).toBeLessThan(speakRepliesIndex)
124
+ expect(speakRepliesIndex).toBeLessThan(controlLabels.indexOf('Queue message'))
125
+ expect(controlLabels.indexOf('Queue message')).toBeLessThan(controlLabels.indexOf('Send'))
126
+ await page.screenshot({ path: testInfo.outputPath('busy-composer-steer.png') })
127
+ await expect(primary.locator('.codicon-arrow-up')).toBeVisible()
128
+
129
+ await queue.click()
130
+ await expect(primary).toHaveAttribute('aria-label', 'Stop')
131
+ await expect(queue).toHaveCount(0)
132
+ await page.screenshot({ path: testInfo.outputPath('busy-composer-queue.png') })
133
+ await expect(page.getByText('1 Queued')).toBeVisible()
134
+
135
+ await primary.click()
136
+ await expect(page.getByText('1 Queued — paused')).toBeVisible()
137
+ await page.screenshot({ path: testInfo.outputPath('busy-composer-queue-paused.png') })
138
+ })
139
+ })
apps/desktop/e2e/connectors.spec.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from './test'
2
+ import { createSandbox, buildAppEnv, launchDesktop } from './fixtures'
3
+
4
+ const GMAIL = 'Gmail'
5
+ const CALENDAR = 'Google Calendar'
6
+
7
+ test.describe('flagged connector onboarding', () => {
8
+ test('shows the optional connector controls in a fresh flagged session', async () => {
9
+ const sandbox = createSandbox('connectors-ui')
10
+ const { app, page } = await launchDesktop(buildAppEnv(sandbox, {
11
+ HERMES_GUEST_ONBOARDING: '1'
12
+ }))
13
+
14
+ try {
15
+ await expect(page.getByText('Connect your apps')).toBeVisible({ timeout: 90_000 })
16
+ await expect(page.getByText(/Connecting is optional/i)).toBeVisible()
17
+ await expect(page.getByRole('button', { name: 'Connect', exact: true }).first()).toBeVisible()
18
+ await expect(page.getByRole('button', { name: 'Not now', exact: true }).first()).toBeVisible()
19
+ await expect(page.getByText(GMAIL)).toBeVisible()
20
+ await expect(page.getByText(CALENDAR)).toBeVisible()
21
+ } finally {
22
+ await app.close().catch(() => undefined)
23
+ sandbox.cleanup()
24
+ }
25
+ })
26
+
27
+ test('keeps the default surface free of connector authorization controls', async () => {
28
+ const sandbox = createSandbox('connectors-off')
29
+ const { app, page } = await launchDesktop(buildAppEnv(sandbox))
30
+
31
+ try {
32
+ await expect(page.locator('[data-connector-offer]')).toHaveCount(0)
33
+ } finally {
34
+ await app.close().catch(() => undefined)
35
+ sandbox.cleanup()
36
+ }
37
+ })
38
+ })
apps/desktop/e2e/context-menu-editables.spec.ts ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Context-menu edit verbs on real editables — the regressions jsdom cannot
3
+ * catch, exercised against the real renderer (real radix focus trap, real
4
+ * React unmount timing, real selection).
5
+ *
6
+ * The class under test: "Select all" from the app context menu must act on
7
+ * the FIELD the menu was opened on, never on the surrounding transcript.
8
+ * The first fix (focus-restore before dispatch) passed unit tests and still
9
+ * failed live because the radix trap steals focus back; the second fix runs
10
+ * selection renderer-side after the trap unmounts. These tests pin the
11
+ * observable outcome, not the mechanism.
12
+ *
13
+ * Menu items are addressed by accessible-name PREFIX (`/^Copy/`): the name
14
+ * includes the shortcut suffix ("Copy Ctrl+V" / "Copy ⌘V"), which is also
15
+ * host-dependent.
16
+ */
17
+
18
+ import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
19
+ import { expect, test } from './test'
20
+
21
+ let fixture: MockBackendFixture | null = null
22
+
23
+ test.beforeAll(async () => {
24
+ fixture = await setupMockBackend()
25
+ await waitForAppReady(fixture, 120_000)
26
+ })
27
+
28
+ test.afterAll(async () => {
29
+ await fixture?.cleanup()
30
+ fixture = null
31
+ })
32
+
33
+ test('select all from the composer context menu selects the draft, not the chat', async () => {
34
+ const page = fixture!.page
35
+ const composer = page.locator('[data-slot="composer-rich-input"]').first()
36
+
37
+ // Put a message into the transcript so there is chat text a document-wide
38
+ // select-all WOULD grab — the bug this test exists to catch. Wait for the
39
+ // mock reply to COMPLETE: while the turn is busy the composer is in its
40
+ // steer shape and a typed draft does not land in it.
41
+ await composer.click()
42
+ await composer.pressSequentially('transcript anchor message')
43
+ await page.keyboard.press('Enter')
44
+ await page.waitForFunction(() => (document.body.textContent ?? '').includes('mock inference server'), undefined, {
45
+ timeout: 60_000
46
+ })
47
+
48
+ // Draft text in the composer, then right-click it.
49
+ await composer.click()
50
+ await composer.pressSequentially('draft under selection')
51
+ await composer.click({ button: 'right' })
52
+
53
+ const selectAll = page.getByRole('menuitem', { name: /^Select all/ })
54
+
55
+ await selectAll.waitFor({ state: 'visible', timeout: 10_000 })
56
+ await selectAll.click()
57
+
58
+ // The selection must live inside the composer and cover exactly the draft.
59
+ await expect
60
+ .poll(
61
+ () =>
62
+ page.evaluate(() => {
63
+ const selection = window.getSelection()
64
+ const editable = document.querySelector('[data-slot="composer-rich-input"]')
65
+
66
+ if (!selection || selection.rangeCount === 0 || !editable) {
67
+ return { inside: false, text: '' }
68
+ }
69
+
70
+ return {
71
+ inside: editable.contains(selection.getRangeAt(0).commonAncestorContainer),
72
+ text: selection.toString()
73
+ }
74
+ }),
75
+ { timeout: 10_000 }
76
+ )
77
+ .toEqual({ inside: true, text: 'draft under selection' })
78
+
79
+ // Clear the draft so later tests start clean.
80
+ await page.keyboard.press('Delete')
81
+ })
82
+
83
+ test('cut, copy, and select all gray out in an empty composer', async () => {
84
+ const page = fixture!.page
85
+ const composer = page.locator('[data-slot="composer-rich-input"]').first()
86
+
87
+ await composer.click()
88
+ await composer.click({ button: 'right' })
89
+
90
+ const selectAll = page.getByRole('menuitem', { name: /^Select all/ })
91
+
92
+ await selectAll.waitFor({ state: 'visible', timeout: 10_000 })
93
+
94
+ await expect(selectAll).toHaveAttribute('data-disabled', /.*/)
95
+ await expect(page.getByRole('menuitem', { name: /^Cut/ })).toHaveAttribute('data-disabled', /.*/)
96
+ await expect(page.getByRole('menuitem', { name: /^Copy/ })).toHaveAttribute('data-disabled', /.*/)
97
+
98
+ await page.keyboard.press('Escape')
99
+ })
100
+
101
+ test('paste enables when the clipboard holds text', async () => {
102
+ const page = fixture!.page
103
+ const composer = page.locator('[data-slot="composer-rich-input"]').first()
104
+
105
+ // The empty-clipboard branch stays in the unit suite: the e2e app shares
106
+ // the SYSTEM clipboard, and writeText('') does not reliably clear it.
107
+ await page.evaluate(() =>
108
+ (
109
+ window as unknown as { hermesDesktop?: { writeClipboard?: (text: string) => Promise<boolean> } }
110
+ ).hermesDesktop?.writeClipboard?.('clipboard payload')
111
+ )
112
+ await composer.click()
113
+ await composer.click({ button: 'right' })
114
+
115
+ const paste = page.getByRole('menuitem', { name: /^Paste/ })
116
+
117
+ await paste.waitFor({ state: 'visible', timeout: 10_000 })
118
+
119
+ // The clipboard probe is an async IPC — the item enables when it lands.
120
+ await expect.poll(() => paste.getAttribute('data-disabled'), { timeout: 10_000 }).toBeNull()
121
+
122
+ await page.keyboard.press('Escape')
123
+ })
apps/desktop/e2e/correction-session-switch.spec.ts ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Regression coverage for a correction sent during a live response, then a
3
+ * warm session switch away and back. The correction is an accepted user turn,
4
+ * not an optimistic duplicate of the original prompt, and its relative place
5
+ * in the transcript must survive the resume reconciliation.
6
+ */
7
+
8
+ import { type TestInfo } from '@playwright/test'
9
+
10
+ import { expect, test, type Page } from './test'
11
+
12
+ import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
13
+ import { CORRECTION_SWITCH_TRIGGER, MOCK_REPLY } from '../../../tests-js/scripts/mock-server'
14
+
15
+ const OTHER_SESSION_PROMPT = 'E2E persisted session used for a warm resume.'
16
+ const ORIGINAL_PROMPT = `${CORRECTION_SWITCH_TRIGGER}: original prompt must remain singular after a correction.`
17
+ const CORRECTION = 'E2E correction must stay after the original prompt.'
18
+ const TOOL_STARTED = 'Checking the long-running task before I continue.'
19
+ const CORRECTED_REPLY = 'The corrected task finished.'
20
+ const INFERENCE_SWITCH_TRIGGER = 'E2E_INFERENCE_SWITCH_TRIGGER'
21
+ const INFERENCE_PROMPT = `${INFERENCE_SWITCH_TRIGGER}: original inference prompt must remain singular.`
22
+ const INFERENCE_CORRECTION = `${INFERENCE_SWITCH_TRIGGER}: correction sent while inference is live.`
23
+
24
+ // Inactive tabs stay mounted under a data-pane-hidden ancestor. Match the
25
+ // renderer's keep-alive visibility policy instead of relying on DOM order.
26
+ const SURFACE = '[data-composer-target]:not([data-pane-hidden] [data-composer-target])'
27
+
28
+ function activeSurface(page: Page) {
29
+ return page.locator(SURFACE).last()
30
+ }
31
+
32
+ async function send(page: Page, text: string): Promise<void> {
33
+ const composer = activeSurface(page).locator('[contenteditable="true"]').first()
34
+ await composer.waitFor({ state: 'visible', timeout: 15_000 })
35
+ await composer.click()
36
+ await composer.type(text, { delay: 5 })
37
+ await page.keyboard.press('Enter')
38
+ }
39
+
40
+ async function steer(page: Page, text: string): Promise<void> {
41
+ const surface = activeSurface(page)
42
+ const composer = surface.locator('[contenteditable="true"]').first()
43
+ const primary = surface.locator('[data-slot="composer-root"] button[type="submit"]')
44
+
45
+ await composer.waitFor({ state: 'visible', timeout: 15_000 })
46
+ await composer.click()
47
+ await composer.type(text, { delay: 5 })
48
+ // Since "running is not busy" (3bc52fb9df) the primary keeps the Send label
49
+ // mid-turn; the submit engine still routes a text payload to steer.
50
+ await expect(primary).toHaveAttribute('aria-label', 'Send')
51
+ await primary.click()
52
+ }
53
+
54
+ async function waitForTranscriptText(page: Page, text: string): Promise<void> {
55
+ await page.waitForFunction(
56
+ ([expected, surfaceSelector]: [string, string]) => {
57
+ const surfaces = document.querySelectorAll(surfaceSelector)
58
+ const active = surfaces[surfaces.length - 1]
59
+
60
+ return (active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected)
61
+ },
62
+ [text, SURFACE] as [string, string],
63
+ { timeout: 30_000 },
64
+ )
65
+ }
66
+
67
+ async function textNodeOccurrences(page: Page, text: string): Promise<number> {
68
+ return page.evaluate(
69
+ ([expected, surfaceSelector]: [string, string]) => {
70
+ const surfaces = document.querySelectorAll(surfaceSelector)
71
+ const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')
72
+ if (!viewport) return 0
73
+
74
+ const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT)
75
+ let count = 0
76
+ while (walker.nextNode()) {
77
+ if (walker.currentNode.textContent?.includes(expected)) {
78
+ count += 1
79
+ }
80
+ }
81
+ return count
82
+ },
83
+ [text, SURFACE] as [string, string],
84
+ )
85
+ }
86
+
87
+ async function transcriptTextOrder(page: Page): Promise<string[]> {
88
+ return page.evaluate((surfaceSelector: string) => {
89
+ const surfaces = document.querySelectorAll(surfaceSelector)
90
+ const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')
91
+ if (!viewport) return []
92
+
93
+ return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="message"], [data-message-id]'))
94
+ .map(message => message.textContent?.trim() ?? '')
95
+ .filter(Boolean)
96
+ }, SURFACE)
97
+ }
98
+
99
+ async function transcriptMessageOrder(page: Page): Promise<string[]> {
100
+ return page.evaluate((surfaceSelector: string) => {
101
+ const surfaces = document.querySelectorAll(surfaceSelector)
102
+ const viewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')
103
+ if (!viewport) return []
104
+
105
+ return Array.from(
106
+ viewport.querySelectorAll<HTMLElement>('[data-role="user"], [data-role="assistant"], [data-role="system"]'),
107
+ )
108
+ .map(message => message.textContent?.trim() ?? '')
109
+ .filter(Boolean)
110
+ }, SURFACE)
111
+ }
112
+
113
+ /**
114
+ * The sidebar "+" opens a NEW TAB beside the current chat rather than
115
+ * replacing it, so the prior session stays mounted in its own surface. Wait
116
+ * for the newly-mounted surface to show an empty transcript instead of waiting
117
+ * for the old text to disappear from the page (it never will).
118
+ */
119
+ async function openFreshDraft(page: Page, priorSessionText: string): Promise<void> {
120
+ await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
121
+ await page.waitForFunction(
122
+ ([priorText, surfaceSelector]: [string, string]) => {
123
+ const surfaces = document.querySelectorAll(surfaceSelector)
124
+ const active = surfaces[surfaces.length - 1]
125
+ const transcript = active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? ''
126
+
127
+ return surfaces.length > 0 && !transcript.includes(priorText)
128
+ },
129
+ [priorSessionText, SURFACE] as [string, string],
130
+ { timeout: 15_000 },
131
+ )
132
+ }
133
+
134
+ async function openSidebarSession(page: Page, sidebarText: string, expectedTranscriptText: string): Promise<void> {
135
+ const row = page.locator('[data-slot="sidebar"] button').filter({ hasText: sidebarText }).first()
136
+ await row.waitFor({ state: 'visible', timeout: 30_000 })
137
+ await row.click()
138
+ await waitForTranscriptText(page, expectedTranscriptText)
139
+ }
140
+
141
+ async function reopenOriginalSession(page: Page): Promise<void> {
142
+ // A still-running tool has not generated a final title yet, so the sidebar
143
+ // retains the source prompt as its provisional session title.
144
+ await openSidebarSession(page, ORIGINAL_PROMPT, ORIGINAL_PROMPT)
145
+ }
146
+
147
+ async function reopenInferenceSession(page: Page): Promise<void> {
148
+ const row = page.locator('[data-slot="sidebar"] button').filter({ hasText: INFERENCE_PROMPT }).first()
149
+ await row.waitFor({ state: 'visible', timeout: 30_000 })
150
+ await row.click()
151
+ await waitForTranscriptText(page, INFERENCE_PROMPT)
152
+ }
153
+
154
+ function relevantOrder(messages: string[]): string[] {
155
+ return messages.flatMap(message => {
156
+ if (message.includes(ORIGINAL_PROMPT)) return [ORIGINAL_PROMPT]
157
+ if (message.includes(CORRECTION)) return [CORRECTION]
158
+
159
+ return []
160
+ })
161
+ }
162
+
163
+ function steerTurnOrder(messages: string[]): string[] {
164
+ return messages.flatMap(message => {
165
+ if (message.includes(ORIGINAL_PROMPT)) return [ORIGINAL_PROMPT]
166
+ if (message.includes(CORRECTION)) return [CORRECTION]
167
+ if (message.includes(CORRECTED_REPLY)) return [CORRECTED_REPLY]
168
+
169
+ return []
170
+ })
171
+ }
172
+
173
+ test.describe('correction session switch', () => {
174
+ let fixture: MockBackendFixture | null = null
175
+
176
+ test.beforeEach(async () => {
177
+ fixture = await setupMockBackend({
178
+ mockServer: { holdFirstStreamForPrompt: INFERENCE_SWITCH_TRIGGER },
179
+ })
180
+ await waitForAppReady(fixture, 120_000)
181
+ })
182
+
183
+ test.afterEach(async () => {
184
+ await fixture?.cleanup()
185
+ fixture = null
186
+ })
187
+
188
+ test('keeps a live correction in place and does not duplicate its original prompt after switching sessions', async ({}, testInfo: TestInfo) => {
189
+ const { page } = fixture!
190
+
191
+ // A blank draft does not exercise session hydration. Seed a real second
192
+ // session first, matching the observed switch between two saved chats.
193
+ await send(page, OTHER_SESSION_PROMPT)
194
+ await waitForTranscriptText(page, MOCK_REPLY)
195
+ await openFreshDraft(page, OTHER_SESSION_PROMPT)
196
+
197
+ await send(page, ORIGINAL_PROMPT)
198
+ await waitForTranscriptText(page, TOOL_STARTED)
199
+ await waitForTranscriptText(page, ORIGINAL_PROMPT)
200
+
201
+ // The historical session redirects while a foreground terminal task is
202
+ // running. Use the visible Steer action to cover the real composer path.
203
+ await steer(page, CORRECTION)
204
+ await waitForTranscriptText(page, CORRECTION)
205
+
206
+ const orderBeforeSwitch = relevantOrder(await transcriptTextOrder(page))
207
+ expect(orderBeforeSwitch).toEqual([ORIGINAL_PROMPT, CORRECTION])
208
+ expect(await textNodeOccurrences(page, ORIGINAL_PROMPT)).toBe(1)
209
+ expect(await textNodeOccurrences(page, CORRECTION)).toBe(1)
210
+ await page.screenshot({ path: testInfo.outputPath('correction-before-session-switch.png') })
211
+
212
+ // Reproduce the observed race: switch to another persisted session while
213
+ // the foreground tool is live, then return before its redirect settles.
214
+ // Sidebar rows title by the session's first user prompt (auto-title is
215
+ // disabled in the e2e fixture config).
216
+ await openSidebarSession(page, OTHER_SESSION_PROMPT, OTHER_SESSION_PROMPT)
217
+ await reopenOriginalSession(page)
218
+ // The warm resume first paints the persisted history and then reconciles
219
+ // the live turn (including a steer whose persistence may lag on a loaded
220
+ // runner) back in. Poll to the converged order instead of sampling one
221
+ // arbitrary mid-reconcile frame; the duplicate checks then pin the
222
+ // regression (the prompt/correction must appear exactly once).
223
+ await expect
224
+ .poll(async () => relevantOrder(await transcriptTextOrder(page)), {
225
+ message: 'correction should stay in place after the warm resume',
226
+ timeout: 30_000,
227
+ })
228
+ .toEqual(orderBeforeSwitch)
229
+ await page.screenshot({ path: testInfo.outputPath('correction-after-warm-resume.png') })
230
+
231
+ expect(await textNodeOccurrences(page, ORIGINAL_PROMPT)).toBe(1)
232
+ expect(await textNodeOccurrences(page, CORRECTION)).toBe(1)
233
+
234
+ await waitForTranscriptText(page, CORRECTED_REPLY)
235
+ // The post-turn stored-history reconcile can momentarily repaint from a
236
+ // snapshot in which the steer's user row hasn't been folded back in yet —
237
+ // poll to the converged order instead of sampling one frame.
238
+ await expect
239
+ .poll(async () => steerTurnOrder(await transcriptMessageOrder(page)), {
240
+ message: 'steered turn should settle as prompt → correction → corrected reply',
241
+ timeout: 30_000,
242
+ })
243
+ .toEqual([ORIGINAL_PROMPT, CORRECTION, CORRECTED_REPLY])
244
+ })
245
+
246
+ test('keeps an inference-time correction visible through a warm session switch', async ({}, testInfo: TestInfo) => {
247
+ const { mock, page } = fixture!
248
+
249
+ await send(page, OTHER_SESSION_PROMPT)
250
+ await waitForTranscriptText(page, MOCK_REPLY)
251
+ await openFreshDraft(page, OTHER_SESSION_PROMPT)
252
+
253
+ await send(page, INFERENCE_PROMPT)
254
+ await mock.waitForHeldStream()
255
+ await waitForTranscriptText(page, INFERENCE_PROMPT)
256
+
257
+ await send(page, INFERENCE_CORRECTION)
258
+ await waitForTranscriptText(page, INFERENCE_CORRECTION)
259
+
260
+ await openSidebarSession(page, OTHER_SESSION_PROMPT, OTHER_SESSION_PROMPT)
261
+ await reopenInferenceSession(page)
262
+
263
+ expect(await textNodeOccurrences(page, INFERENCE_PROMPT)).toBe(1)
264
+ expect(await textNodeOccurrences(page, INFERENCE_CORRECTION)).toBe(1)
265
+ await page.screenshot({ path: testInfo.outputPath('inference-correction-after-warm-resume.png') })
266
+
267
+ mock.releaseHeldStream()
268
+ await waitForTranscriptText(page, MOCK_REPLY)
269
+ })
270
+ })
apps/desktop/e2e/electron-binary.ts ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Locating the dev Electron binary for the e2e fixtures.
3
+ *
4
+ * Kept in its own module so the resolution rules can be unit-tested without
5
+ * importing the Playwright runner (fixtures.ts pulls in `_electron`, the mock
6
+ * server and the error-banner guard).
7
+ *
8
+ * Three rules the previous single-path probe got wrong:
9
+ *
10
+ * 1. The binary is not always under the REPO ROOT. This is an npm workspaces
11
+ * repo, and npm only hoists a dependency to the root when nothing conflicts
12
+ * — otherwise `electron` installs into `apps/desktop/node_modules`. Both
13
+ * layouts are normal, so both have to be searched, nearest package first.
14
+ * 2. The binary is `electron.exe` on Windows. A bare `electron` never exists
15
+ * there, so the probe could only ever miss.
16
+ * 3. `which` is not a command on Windows. The PATH fallback spawned it
17
+ * unconditionally, so on Windows the fallback failed for the wrong reason
18
+ * and the error message blamed a missing `npm install`.
19
+ */
20
+
21
+ import { spawnSync } from 'node:child_process'
22
+ import * as fs from 'node:fs'
23
+ import { createRequire } from 'node:module'
24
+ import * as path from 'node:path'
25
+
26
+ /** The dist file name: `electron.exe` on Windows, `electron` elsewhere. */
27
+ export function electronBinaryName(platform: NodeJS.Platform = process.platform): string {
28
+ return platform === 'win32' ? 'electron.exe' : 'electron'
29
+ }
30
+
31
+ /**
32
+ * Where an npm install can leave the binary, in probe order: nearest package
33
+ * first, so a workspace-local install wins over a stale hoisted one.
34
+ */
35
+ export function electronDistCandidates(roots: string[], platform: NodeJS.Platform = process.platform): string[] {
36
+ return roots.map((root) => path.join(root, 'node_modules', 'electron', 'dist', electronBinaryName(platform)))
37
+ }
38
+
39
+ /** The PATH-lookup command for this platform. Windows has `where`, not `which`. */
40
+ export function pathLookupCommand(platform: NodeJS.Platform = process.platform): string {
41
+ return platform === 'win32' ? 'where' : 'which'
42
+ }
43
+
44
+ /**
45
+ * Ask the installed `electron` package where its own binary is.
46
+ *
47
+ * Its main export IS the absolute executable path, resolved from `path.txt`
48
+ * and honouring `ELECTRON_OVERRIDE_DIST_PATH`, so this covers layouts and
49
+ * overrides a hand-built path cannot know about. Returns null when the package
50
+ * is not resolvable from `from`, or when it does not hand back a path (the
51
+ * export is the Electron API object, not a path, when required from inside
52
+ * Electron itself).
53
+ */
54
+ export function electronPackagePath(from: string): null | string {
55
+ try {
56
+ const resolved = createRequire(path.join(from, 'package.json'))('electron') as unknown
57
+
58
+ return typeof resolved === 'string' && resolved ? resolved : null
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Resolve the Electron binary, or throw with the layouts that were searched.
66
+ *
67
+ * `roots` are searched in order; pass the desktop package before the repo root.
68
+ */
69
+ export function resolveElectronBinary(roots: string[]): string {
70
+ for (const root of roots) {
71
+ const declared = electronPackagePath(root)
72
+
73
+ if (declared && fs.existsSync(declared)) {
74
+ return declared
75
+ }
76
+ }
77
+
78
+ for (const candidate of electronDistCandidates(roots)) {
79
+ if (fs.existsSync(candidate)) {
80
+ return candidate
81
+ }
82
+ }
83
+
84
+ // Nix devshells put `electron` on PATH with no node_modules copy at all.
85
+ const lookup = spawnSync(pathLookupCommand(), ['electron'], { encoding: 'utf8' })
86
+
87
+ if (lookup.status === 0 && lookup.stdout.trim()) {
88
+ // `where` reports every match, one per line; take the first.
89
+ const first = lookup.stdout.trim().split(/\r?\n/)[0].trim()
90
+
91
+ if (first) {
92
+ return first
93
+ }
94
+ }
95
+
96
+ throw new Error(
97
+ `Electron binary not found. Searched ${electronDistCandidates(roots).join(', ')} and PATH. ` +
98
+ 'Run "npm install" from the repo root to install devDependencies.',
99
+ )
100
+ }
apps/desktop/e2e/electron-binary.unit.test.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as path from 'node:path'
2
+
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ import { electronBinaryName, electronDistCandidates, pathLookupCommand } from './electron-binary'
6
+
7
+ // Platform is a parameter everywhere below rather than read from
8
+ // process.platform, so the Windows rules are pinned on the Linux CI runner too.
9
+ // Reading the real platform would leave every Windows-only rule untested.
10
+
11
+ describe('electronBinaryName', () => {
12
+ it('asks for electron.exe on Windows', () => {
13
+ expect(electronBinaryName('win32')).toBe('electron.exe')
14
+ })
15
+
16
+ it('asks for a bare electron everywhere else', () => {
17
+ expect(electronBinaryName('linux')).toBe('electron')
18
+ expect(electronBinaryName('darwin')).toBe('electron')
19
+ })
20
+ })
21
+
22
+ describe('electronDistCandidates', () => {
23
+ const desktop = path.join('repo', 'apps', 'desktop')
24
+ const repo = 'repo'
25
+
26
+ it('probes the workspace-local install before the hoisted one', () => {
27
+ // npm only hoists `electron` to the repo root when nothing conflicts, so
28
+ // apps/desktop/node_modules is an ordinary outcome of `npm install`, not a
29
+ // broken tree. Probing only the repo root is what makes the suite refuse to
30
+ // start with "run npm install" on a tree that has electron installed.
31
+ expect(electronDistCandidates([desktop, repo], 'linux')).toEqual([
32
+ path.join(desktop, 'node_modules', 'electron', 'dist', 'electron'),
33
+ path.join(repo, 'node_modules', 'electron', 'dist', 'electron'),
34
+ ])
35
+ })
36
+
37
+ it('carries the platform binary name into every candidate', () => {
38
+ // A bare `electron` file never exists in a Windows dist, so a probe built
39
+ // from a hardcoded name cannot match there no matter which root it walks.
40
+ for (const candidate of electronDistCandidates([desktop, repo], 'win32')) {
41
+ expect(path.basename(candidate)).toBe('electron.exe')
42
+ }
43
+ })
44
+ })
45
+
46
+ describe('pathLookupCommand', () => {
47
+ it('uses where on Windows and which elsewhere', () => {
48
+ // `which` is not a command on Windows; spawning it unconditionally made the
49
+ // PATH fallback fail for a reason unrelated to whether electron is on PATH.
50
+ expect(pathLookupCommand('win32')).toBe('where')
51
+ expect(pathLookupCommand('linux')).toBe('which')
52
+ expect(pathLookupCommand('darwin')).toBe('which')
53
+ })
54
+ })
apps/desktop/e2e/fix-electron-tracing.ts ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Monkey-patch: playwright's test runner never calls tracing.start() on
3
+ * Electron's internal BrowserContext because:
4
+ * 1. Playwright._allContexts() only returns [chromium, firefox, webkit]
5
+ * contexts — Electron's context is excluded.
6
+ * 2. ArtifactsRecorder.didCreateBrowserContext runs in willStartTest, before
7
+ * beforeAll launches the electron app.
8
+ * 3. The runAfterCreateBrowserContext hook doesn't exist on the Electron
9
+ * class (only on BrowserType).
10
+ *
11
+ * As a result, trace screenshots (screencast) and DOM snapshots are never
12
+ * captured for electron tests.
13
+ *
14
+ * This patch:
15
+ * 1. Patches _allContexts() to include electron contexts, so the test
16
+ * runner's didFinishTest() cleanup calls _stopTracing() → stopChunk()
17
+ * on the electron context (saving the trace chunk + merging it into
18
+ * the final trace.zip).
19
+ * 2. Manually calls tracing.start() + startChunk() after launch.
20
+ * 3. Wraps tracing.start to become startChunk after the first call,
21
+ * so the test runner's willStartTest doesn't throw "already started".
22
+ *
23
+ * Imported from playwright.config.ts so it runs before any test.
24
+ *
25
+ * Pinned dependency: this file reaches into Playwright internals (_playwright,
26
+ * _allContexts, _context) that have no public contract. @playwright/test is
27
+ * pinned exact (=1.58.2 in package.json) so a bump can't silently break the
28
+ * monkeypatch. When bumping, re-verify these private symbols still exist on
29
+ * the Electron / PlaywrightInternal classes and that tracing still merges.
30
+ */
31
+
32
+ import { _electron as electron, type BrowserContext } from '@playwright/test'
33
+ import * as crypto from 'node:crypto'
34
+
35
+ const electronContexts = new Set<BrowserContext>()
36
+ const originalLaunch = electron.launch.bind(electron)
37
+
38
+ electron.launch = async (options: any) => {
39
+ const app = await originalLaunch(options)
40
+ const ctx = (app as any)._context as BrowserContext
41
+ electronContexts.add(ctx)
42
+ ctx.once('close', () => electronContexts.delete(ctx))
43
+
44
+ // Patch _allContexts so the test runner sees the electron context
45
+ // (didFinishTest cleanup → _stopTracing → stopChunk → merge into trace.zip).
46
+ const pw = (electron as any)._playwright as any
47
+ if (pw && !pw.__electronTracingPatched) {
48
+ pw.__electronTracingPatched = true
49
+ const original = pw._allContexts.bind(pw)
50
+ pw._allContexts = () => [...original(), ...electronContexts]
51
+ }
52
+
53
+ // Start tracing — mirrors ArtifactsRecorder.didCreateBrowserContext.
54
+ const traceName = crypto.randomUUID()
55
+ await ctx.tracing.start({
56
+ screenshots: true,
57
+ snapshots: true,
58
+ sources: true,
59
+ }).catch(() => {})
60
+ await ctx.tracing.startChunk({ title: 'electron', name: traceName }).catch(() => {})
61
+
62
+ // Wrap tracing.start to redirect to startChunk after the first call.
63
+ // The test runner's willStartTest calls tracing.start() on all contexts
64
+ // in _allContexts(). Since we already started, redirect to startChunk
65
+ // to avoid "Tracing has been already started" errors.
66
+ const tracing = ctx.tracing as any
67
+ tracing.start = async (opts: any) => {
68
+ return tracing.startChunk(opts)
69
+ }
70
+
71
+ return app
72
+ }
apps/desktop/e2e/fixtures.ts ADDED
@@ -0,0 +1,746 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Shared E2E fixtures for the Hermes desktop Playwright suite.
3
+ *
4
+ * Two fixture modes:
5
+ *
6
+ * 1. `mockBackend` — starts a mock inference server, writes a config.yaml
7
+ * that points at it, and launches the desktop app so the full chain
8
+ * (electron → hermes serve → provider → inference → renderer) is
9
+ * exercised with a real backend but a fake LLM.
10
+ *
11
+ * 2. `noProvider` — launches the app with an empty config (no provider
12
+ * configured). The onboarding overlay should appear. Used to test the
13
+ * first-run flow without real credentials.
14
+ *
15
+ * Both modes launch the *dev* Electron app (`electron .` against the built
16
+ * `dist/`), not the packaged binary. This avoids the multi-minute
17
+ * `electron-builder --dir` step and matches `hermes desktop --source`. The
18
+ * packaged-binary path is already covered by `launch.spec.ts`.
19
+ *
20
+ * Prerequisite: `npm run build` must have been run so that `dist/` exists.
21
+ */
22
+
23
+ import * as fs from 'node:fs'
24
+ import * as os from 'node:os'
25
+ import * as path from 'node:path'
26
+
27
+ import { _electron, type ElectronApplication, type Page } from '@playwright/test'
28
+
29
+ import { resolveElectronBinary } from './electron-binary'
30
+ import { startMockServer, type MockServerOptions } from '../../../tests-js/scripts/mock-server'
31
+ import { installErrorBannerGuard } from './test'
32
+
33
+ const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
34
+ const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
35
+ const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
36
+
37
+ // ─── Credential stripping (matches launch.spec.ts) ──────────────────────
38
+
39
+ const CREDENTIAL_SUFFIXES: string[] = [
40
+ '_API_KEY',
41
+ '_TOKEN',
42
+ '_SECRET',
43
+ '_PASSWORD',
44
+ '_CREDENTIALS',
45
+ '_ACCESS_KEY',
46
+ '_PRIVATE_KEY',
47
+ '_OAUTH_TOKEN',
48
+ ]
49
+
50
+ const CREDENTIAL_NAMES = new Set([
51
+ 'ANTHROPIC_BASE_URL',
52
+ 'ANTHROPIC_TOKEN',
53
+ 'AWS_ACCESS_KEY_ID',
54
+ 'AWS_SECRET_ACCESS_KEY',
55
+ 'AWS_SESSION_TOKEN',
56
+ 'CUSTOM_API_KEY',
57
+ 'GEMINI_BASE_URL',
58
+ 'OPENAI_BASE_URL',
59
+ 'OPENROUTER_BASE_URL',
60
+ 'OLLAMA_BASE_URL',
61
+ 'GROQ_BASE_URL',
62
+ 'XAI_BASE_URL',
63
+ ])
64
+
65
+ function isCredentialEnvVar(name: string): boolean {
66
+ if (CREDENTIAL_NAMES.has(name)) {
67
+ return true
68
+ }
69
+
70
+ return CREDENTIAL_SUFFIXES.some((suffix) => name.endsWith(suffix))
71
+ }
72
+
73
+ function stripCredentials(env: Record<string, string | undefined>): Record<string, string> {
74
+ const clean: Record<string, string> = {}
75
+
76
+ for (const [key, value] of Object.entries(env)) {
77
+ if (!value) {
78
+ continue
79
+ }
80
+
81
+ if (isCredentialEnvVar(key)) {
82
+ continue
83
+ }
84
+
85
+ clean[key] = value
86
+ }
87
+
88
+ return clean
89
+ }
90
+
91
+ // ─── Sandbox creation ──────────────────────────────────────────────────
92
+
93
+ export interface Sandbox {
94
+ root: string
95
+ hermesHome: string
96
+ userDataDir: string
97
+ cleanup: () => void
98
+ }
99
+
100
+ export function createSandbox(prefix: string): Sandbox {
101
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-e2e-${prefix}-${Math.random()}`))
102
+ const hermesHome = path.join(root, 'hermes-home')
103
+ const userDataDir = path.join(root, 'electron-user-data')
104
+
105
+ fs.mkdirSync(hermesHome, { recursive: true })
106
+ fs.mkdirSync(userDataDir, { recursive: true })
107
+
108
+ // Write a fixed window-state.json so the Electron window opens at a
109
+ // consistent size — helps with visual regression screenshots. The
110
+ // exact size is also enforced right before each screenshot (see
111
+ // expectVisualSnapshot in visual-snapshot.ts) because window managers
112
+ // may resize after launch.
113
+ fs.writeFileSync(
114
+ path.join(userDataDir, 'window-state.json'),
115
+ JSON.stringify(
116
+ { x: 0, y: 0, width: 1220, height: 800, isMaximized: false },
117
+ null,
118
+ 2,
119
+ ),
120
+ 'utf8',
121
+ )
122
+
123
+ // Pin Chromium actual-size zoom (level 0) for the suite. Fresh installs
124
+ // ship DEFAULT_ZOOM_LEVEL at the Appearance 90% preset, but Playwright
125
+ // click hit-testing and the committed visual baselines were calibrated at
126
+ // 100%. Without this file every sandbox would inherit the product default
127
+ // and fail pointer interception + snapshot diffs.
128
+ fs.writeFileSync(
129
+ path.join(userDataDir, 'zoom-state.json'),
130
+ JSON.stringify({ zoomLevel: 0 }, null, 2),
131
+ 'utf8',
132
+ )
133
+
134
+ return {
135
+ root,
136
+ hermesHome,
137
+ userDataDir,
138
+ cleanup: () => {
139
+ try {
140
+ fs.rmSync(root, { recursive: true, force: true })
141
+ } catch {
142
+ // best-effort
143
+ }
144
+ },
145
+ }
146
+ }
147
+
148
+ // ─── Config writing ─────────────────────────────────────────────────────
149
+
150
+ /**
151
+ * Write a config.yaml that pre-configures a mock provider pointing at the
152
+ * mock inference server. The provider is set as the active model provider so
153
+ * the desktop app skips onboarding and boots straight to the chat UI.
154
+ *
155
+ * @param extraDisplayConfig optional YAML lines appended to the `display:`
156
+ * section, used by the interim-message e2e test.
157
+ * @param extraConfig optional top-level YAML sections for a test scenario.
158
+ * @param modelContextLength optional primary-model context limit.
159
+ */
160
+ export function writeMockProviderConfig(
161
+ hermesHome: string,
162
+ mockUrl: string,
163
+ extraDisplayConfig?: string,
164
+ extraConfig?: string,
165
+ modelContextLength?: number,
166
+ ): void {
167
+ const configPath = path.join(hermesHome, 'config.yaml')
168
+
169
+ const displaySection = extraDisplayConfig
170
+ ? `\ndisplay:\n${extraDisplayConfig}\n`
171
+ : ''
172
+
173
+ // Title generation rides the MAIN model since 87af576e60 (#83636), so every
174
+ // completed turn fires an extra background /v1/chat/completions at the mock.
175
+ // That request contains the whole conversation — trigger keywords included —
176
+ // which advances the mock's scripted-turn indices and trips hold-for-prompt
177
+ // matchers from a request no spec ever sent. Disable it by default (no e2e
178
+ // spec asserts on session titles); a test that passes its own `auxiliary:`
179
+ // section via extraConfig owns the whole section instead.
180
+ const autoTitleDefault = extraConfig?.includes('auxiliary:')
181
+ ? ''
182
+ : 'auxiliary:\n title_generation:\n enabled: false\n'
183
+
184
+ // The scripted turns run REAL terminal commands, and anything the guard
185
+ // classifies as dangerous (e.g. the sidebar sentinel-wait loop) parks the
186
+ // turn behind a Run/Reject approval card. The default 'smart' mode then
187
+ // fires an aux LLM approval call at the SAME mock provider — consuming a
188
+ // scripted-turn index and never resolving — so the turn stalls until the
189
+ // spec times out (the CI failure mode for the sidebar-dot family). No e2e
190
+ // spec asserts on the approval flow, so run gate-free by default; a test
191
+ // that passes its own `approvals:` section via extraConfig owns it.
192
+ const approvalsDefault = extraConfig?.includes('approvals:')
193
+ ? ''
194
+ : 'approvals:\n mode: "off"\n'
195
+
196
+ const config = `# Auto-generated by E2E test fixtures
197
+ model:
198
+ default: mock-model
199
+ provider: mock
200
+ ${modelContextLength ? ` context_length: ${modelContextLength}\n` : ''}providers:
201
+ mock:
202
+ api: ${mockUrl}/v1
203
+ name: Mock
204
+ api_mode: chat_completions
205
+ key_env: MOCK_API_KEY
206
+ models:
207
+ mock-model: {}
208
+ context_length: 64000
209
+ ${autoTitleDefault}${approvalsDefault}${displaySection}${extraConfig ? `\n${extraConfig.trim()}\n` : ''}`
210
+
211
+ fs.writeFileSync(configPath, config, 'utf8')
212
+ }
213
+
214
+ /**
215
+ * Write a minimal .env with the mock API key. The key_env in config.yaml
216
+ * references MOCK_API_KEY, so the backend resolves credentials from here.
217
+ */
218
+ export function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void {
219
+ const envPath = path.join(hermesHome, '.env')
220
+ fs.writeFileSync(envPath, `MOCK_API_KEY=${apiKey}\n`, 'utf8')
221
+ }
222
+
223
+ /**
224
+ * Write an empty config (no providers). The desktop app should show the
225
+ * onboarding overlay because no inference provider is configured.
226
+ */
227
+ function writeEmptyConfig(hermesHome: string): void {
228
+ const configPath = path.join(hermesHome, 'config.yaml')
229
+ fs.writeFileSync(configPath, '# Auto-generated by E2E test fixtures — no providers configured\n', 'utf8')
230
+ }
231
+
232
+ // ─── Env building ──────────────────────────────────────────────────────
233
+
234
+ /**
235
+ * Build the environment for the Electron app process.
236
+ *
237
+ * Key env vars:
238
+ * - HERMES_HOME → sandbox hermes-home (isolated config/sessions)
239
+ * - HERMES_DESKTOP_USER_DATA_DIR → sandbox electron-user-data
240
+ * - HERMES_DESKTOP_IGNORE_EXISTING=1 → don't pick up `hermes` from PATH
241
+ * (we want the dev checkout at REPO_ROOT)
242
+ * - HERMES_DESKTOP_HERMES_ROOT → REPO_ROOT (dev checkout resolution)
243
+ * - HERMES_DESKTOP_APP_NAME → unique-ish per test (avoids single-instance lock)
244
+ * - XDG_RUNTIME_DIR → ensure Electron has a writable runtime dir on Linux
245
+ */
246
+ export function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}): Record<string, string> {
247
+ const clean = stripCredentials(process.env)
248
+
249
+ // XDG_RUNTIME_DIR is needed for Electron on Linux when running in a
250
+ // headless/CI context — without it the zygote may fail to initialize.
251
+ if (!clean.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR) {
252
+ clean.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR
253
+ }
254
+
255
+ // DISPLAY — needed for Electron to open a window.
256
+ if (!clean.DISPLAY && process.env.DISPLAY) {
257
+ clean.DISPLAY = process.env.DISPLAY
258
+ }
259
+
260
+ return {
261
+ ...clean,
262
+ HERMES_HOME: sandbox.hermesHome,
263
+ HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
264
+ HERMES_DESKTOP_IGNORE_EXISTING: '1',
265
+ HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
266
+ HERMES_DESKTOP_APP_NAME: `HermesE2E-${Date.now()}`,
267
+ // `app.close()` in teardown must exit even when a spec leaves a turn
268
+ // mid-flight — otherwise the quit confirmation waits on a click that no
269
+ // one is there to make, and the worker dies on a teardown timeout.
270
+ HERMES_DESKTOP_SKIP_QUIT_CONFIRM: '1',
271
+ // Clear dev-server override — we want the built dist/, not a vite server.
272
+ // The dev-server check in main.ts looks for this env var; if it's set,
273
+ // it loads from the vite URL instead of the local file.
274
+ ...extra,
275
+ }
276
+ }
277
+
278
+ // ─── Electron launch ────────────────────────────────────────────────────
279
+
280
+ /**
281
+ * Verify that the desktop app has been built (dist/ exists). Playwright
282
+ * tests can't run without it — the Electron main process loads
283
+ * dist/electron-main.mjs and the renderer loads dist/index.html.
284
+ */
285
+ function assertDistBuilt(): void {
286
+ const distDir = path.join(DESKTOP_ROOT, 'dist')
287
+ const electronMain = path.join(distDir, 'electron-main.mjs')
288
+ const indexHtml = path.join(distDir, 'index.html')
289
+
290
+ if (!fs.existsSync(electronMain)) {
291
+ throw new Error(
292
+ `Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
293
+ `Missing: ${electronMain}`,
294
+ )
295
+ }
296
+
297
+ if (!fs.existsSync(indexHtml)) {
298
+ throw new Error(
299
+ `Desktop dist/index.html not found. Run 'cd apps/desktop && npm run build' first.\n` +
300
+ `Missing: ${indexHtml}`,
301
+ )
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Find the Electron binary. In the nix devshell, `electron` is on PATH.
307
+ * As a fallback, use the node_modules/electron install from either package.
308
+ */
309
+ export function findElectron(): string {
310
+ // In dev mode, we use the `electron` binary directly (not the packaged app).
311
+ // The dev:electron script in package.json does exactly this: `electron .`
312
+ // after building. We replicate that here.
313
+ //
314
+ // The desktop package is searched first: npm workspaces only hoist
315
+ // `electron` to the repo root when nothing conflicts, so a workspace-local
316
+ // install is just as ordinary an outcome as a hoisted one. The rules live in
317
+ // ./electron-binary so they can be unit-tested per platform.
318
+ return resolveElectronBinary([DESKTOP_ROOT, REPO_ROOT])
319
+ }
320
+
321
+ /**
322
+ * Launch the desktop app in dev mode.
323
+ *
324
+ * @param sandbox - isolated HERMES_HOME + userData
325
+ * @param env - the process environment (already has HERMES_HOME etc.)
326
+ * @returns the ElectronApplication + first Page
327
+ */
328
+ export async function launchDesktop(
329
+ env: Record<string, string>,
330
+ ): Promise<{ app: ElectronApplication; page: Page }> {
331
+ assertDistBuilt()
332
+
333
+ const electronBin = findElectron()
334
+
335
+ // `electron .` loads from the package.json `main` field
336
+ // (dist/electron-main.mjs after build).
337
+ const app = await _electron.launch({
338
+ executablePath: electronBin,
339
+ args: [
340
+ DESKTOP_ROOT, // `electron .` — the `.` is the desktop package dir
341
+ '--disable-gpu',
342
+ '--no-sandbox',
343
+ ],
344
+ env,
345
+ cwd: DESKTOP_ROOT,
346
+ })
347
+
348
+ const page = await app.firstWindow()
349
+
350
+ // Install the error-banner guard so any [role="alert"] that appears
351
+ // during a test is collected and surfaced in afterEach.
352
+ installErrorBannerGuard(page)
353
+
354
+ return { app, page }
355
+ }
356
+
357
+ // ─── Public fixtures ────────────────────────────────────────────────────
358
+
359
+ export interface MockBackendFixture {
360
+ app: ElectronApplication
361
+ page: Page
362
+ mock: Awaited<ReturnType<typeof startMockServer>>
363
+ mockUrl: string
364
+ sandbox: Sandbox
365
+ cleanup: () => Promise<void>
366
+ }
367
+
368
+ export interface MockBackendOptions {
369
+ /**
370
+ * Optional YAML lines to inject under the `display:` section of the
371
+ * generated config.yaml. Used by the interim-message e2e test to toggle
372
+ * `display.interim_assistant_messages`.
373
+ */
374
+ extraDisplayConfig?: string
375
+ /** Additional top-level config.yaml sections for an E2E scenario. */
376
+ extraConfig?: string
377
+ /** Override the mock model's context window for compression scenarios. */
378
+ modelContextLength?: number
379
+ }
380
+
381
+ /**
382
+ * Set up a full mock-backend E2E environment:
383
+ * 1. Start the mock inference server
384
+ * 2. Create a sandbox with config.yaml pointing at it
385
+ * 3. Launch the desktop app
386
+ * 4. Return handles for test interaction
387
+ */
388
+ export interface MockBackendOptions {
389
+ mockServer?: MockServerOptions
390
+ }
391
+
392
+ export async function setupMockBackend(options: MockBackendOptions = {}): Promise<MockBackendFixture> {
393
+ // 1. Start mock server
394
+ const mock = await startMockServer(options.mockServer)
395
+
396
+ // 2. Create sandbox + write config
397
+ const sandbox = createSandbox('mock')
398
+ writeMockProviderConfig(
399
+ sandbox.hermesHome,
400
+ mock.url,
401
+ options.extraDisplayConfig,
402
+ options.extraConfig,
403
+ options.modelContextLength,
404
+ )
405
+ writeEnvFile(sandbox.hermesHome)
406
+
407
+ // 3. Build env + launch
408
+ const env = buildAppEnv(sandbox)
409
+ const { app, page } = await launchDesktop(env)
410
+
411
+ return {
412
+ app,
413
+ page,
414
+ mock,
415
+ mockUrl: mock.url,
416
+ sandbox,
417
+ cleanup: async () => {
418
+ await app.close().catch(() => undefined)
419
+ await mock.close()
420
+ sandbox.cleanup()
421
+ },
422
+ }
423
+ }
424
+
425
+ export interface NoProviderFixture {
426
+ app: ElectronApplication
427
+ page: Page
428
+ sandbox: Sandbox
429
+ cleanup: () => Promise<void>
430
+ }
431
+
432
+ /**
433
+ * Launch the app with no provider configured. The onboarding overlay should
434
+ * appear because there's no inference provider in config.yaml.
435
+ */
436
+ export async function setupNoProvider(): Promise<NoProviderFixture> {
437
+ const sandbox = createSandbox('noprovider')
438
+ writeEmptyConfig(sandbox.hermesHome)
439
+
440
+ const env = buildAppEnv(sandbox)
441
+ const { app, page } = await launchDesktop(env)
442
+
443
+ return {
444
+ app,
445
+ page,
446
+ sandbox,
447
+ cleanup: async () => {
448
+ await app.close().catch(() => undefined)
449
+ sandbox.cleanup()
450
+ },
451
+ }
452
+ }
453
+
454
+ export interface DeadBackendFixture {
455
+ app: ElectronApplication
456
+ page: Page
457
+ sandbox: Sandbox
458
+ cleanup: () => Promise<void>
459
+ }
460
+
461
+ export interface DeadBackendOptions {
462
+ /**
463
+ * When true, inject a fake boot error via HERMES_DESKTOP_BOOT_FAKE_ERROR
464
+ * so the backend resolution itself "fails" with a controlled error message.
465
+ * This is the only reliable way to trigger BootFailureOverlay in dev mode
466
+ * (the real backend always resolves via SOURCE_REPO_ROOT).
467
+ */
468
+ fakeError?: boolean
469
+ }
470
+
471
+ /**
472
+ * Launch the app with a provider pointing at a dead endpoint (port 1, which
473
+ * nothing listens on). By default the backend still boots (`hermes serve`
474
+ * starts fine — the dead endpoint only matters at chat time). Pass
475
+ * `{ fakeError: true }` to inject a fake boot failure, triggering the
476
+ * BootFailureOverlay.
477
+ */
478
+ export async function setupDeadBackend(options: DeadBackendOptions = {}): Promise<DeadBackendFixture> {
479
+ const sandbox = createSandbox('dead')
480
+ const configPath = path.join(sandbox.hermesHome, 'config.yaml')
481
+ fs.writeFileSync(
482
+ configPath,
483
+ `# Auto-generated by E2E test fixtures — dead provider
484
+ model:
485
+ default: mock-model
486
+ provider: mock
487
+ providers:
488
+ mock:
489
+ api: http://127.0.0.1:1/v1
490
+ name: Mock
491
+ api_mode: chat_completions
492
+ key_env: MOCK_API_KEY
493
+ models:
494
+ mock-model: {}
495
+ context_length: 64000
496
+ `,
497
+ 'utf8',
498
+ )
499
+ writeEnvFile(sandbox.hermesHome)
500
+
501
+ const env = buildAppEnv(sandbox, options.fakeError ? { HERMES_DESKTOP_BOOT_FAKE_ERROR: 'Failed to connect to Hermes backend: connection refused' } : {})
502
+ const { app, page } = await launchDesktop(env)
503
+
504
+ return {
505
+ app,
506
+ page,
507
+ sandbox,
508
+ cleanup: async () => {
509
+ await app.close().catch(() => undefined)
510
+ sandbox.cleanup()
511
+ },
512
+ }
513
+ }
514
+
515
+ // ─── Packaged-binary fixture ───────────────────────────────────────────
516
+
517
+ /**
518
+ * Resolve the packaged Electron binary path, per-platform, matching
519
+ * electron-builder's output layout under release/.
520
+ */
521
+ function resolvePackagedBinaryPath(): string {
522
+ if (process.platform === 'win32') {
523
+ return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe')
524
+ }
525
+
526
+ if (process.platform === 'darwin') {
527
+ const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
528
+
529
+ return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes')
530
+ }
531
+
532
+ return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes')
533
+ }
534
+
535
+ export const PACKAGED_BINARY_PATH = resolvePackagedBinaryPath()
536
+
537
+ export function packagedBinaryExists(): boolean {
538
+ return fs.existsSync(PACKAGED_BINARY_PATH)
539
+ }
540
+
541
+ export interface PackagedAppFixture {
542
+ app: ElectronApplication
543
+ page: Page
544
+ sandbox: Sandbox
545
+ cleanup: () => Promise<void>
546
+ }
547
+
548
+ /**
549
+ * Launch the *packaged* Electron binary (from `npm run pack` →
550
+ * `electron-builder --dir`) with `BOOT_FAKE=1` so it simulates boot
551
+ * progress without spawning a real Hermes backend.
552
+ *
553
+ * Uses the same sandbox isolation (credential stripping, isolated
554
+ * HERMES_HOME + userData, unique app name) as the dev-mode fixtures.
555
+ *
556
+ * Skips if the packaged binary doesn't exist — run `npm run pack` first.
557
+ */
558
+ export async function setupPackagedApp(): Promise<PackagedAppFixture> {
559
+ if (!packagedBinaryExists()) {
560
+ throw new Error(
561
+ `Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
562
+ )
563
+ }
564
+
565
+ const sandbox = createSandbox('packaged')
566
+
567
+ // Build the sandbox env using the shared helpers, then add the
568
+ // packaged-binary-specific overrides.
569
+ const env = buildAppEnv(sandbox, {
570
+ // Fake boot: simulates progress steps without spawning the real backend.
571
+ HERMES_DESKTOP_BOOT_FAKE: '1',
572
+ HERMES_DESKTOP_BOOT_FAKE_STEP_MS: '120',
573
+ })
574
+
575
+ // Clear dev-server + hermes-root overrides — the packaged binary
576
+ // should use its own bundled renderer, not the dev checkout.
577
+ delete (env as Record<string, string | undefined>).HERMES_DESKTOP_DEV_SERVER
578
+ delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES
579
+ delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES_ROOT
580
+
581
+ const app = await _electron.launch({
582
+ executablePath: PACKAGED_BINARY_PATH,
583
+ args: ['--disable-gpu', '--no-sandbox'],
584
+ env,
585
+ })
586
+
587
+ const page = await app.firstWindow()
588
+ installErrorBannerGuard(page)
589
+
590
+ return {
591
+ app,
592
+ page,
593
+ sandbox,
594
+ cleanup: async () => {
595
+ await app.close().catch(() => undefined)
596
+ sandbox.cleanup()
597
+ },
598
+ }
599
+ }
600
+
601
+ // ─── Wait helpers ──────────────────────────────────────────────────────
602
+
603
+ /**
604
+ * Wait for the desktop app to finish booting and show the main chat UI.
605
+ *
606
+ * The boot overlay disappears when `completeDesktopBoot()` fires in the
607
+ * renderer — at that point the gateway is open, config is loaded, and
608
+ * sessions are loaded. We detect this by waiting for the boot/connecting
609
+ * overlay to become invisible and the main app shell to be present.
610
+ *
611
+ * Two things must both be true before we return:
612
+ * 1. The composer (chat input) is visible — it's disabled until the
613
+ * gateway is open.
614
+ * 2. No full-screen overlay (onboarding Preparing, connecting overlay,
615
+ * boot-failure) covers the viewport center. The composer can be
616
+ * "visible" in Playwright's eyes (non-zero bounding box, not
617
+ * display:none) even when a z-1300+ overlay is painted on top of it,
618
+ * so checking the composer alone catches the app mid-boot at ~92%
619
+ * with the loading bar still showing.
620
+ */
621
+ export async function waitForAppReady(fixture: MockBackendFixture | NoProviderFixture | DeadBackendFixture, timeoutMs = 60_000): Promise<void> {
622
+ const { page, app } = fixture
623
+
624
+ // Wait for the composer to exist in the DOM (not necessarily interactive yet).
625
+ await page.waitForSelector('textarea, [contenteditable="true"]', {
626
+ state: 'attached',
627
+ timeout: timeoutMs,
628
+ })
629
+
630
+ // Now poll until no full-screen overlay covers the viewport center.
631
+ // elementFromPoint returns the topmost element at a point — if it's part
632
+ // of a fixed inset-0 overlay (onboarding/connecting/boot-failure), the
633
+ // app isn't ready yet.
634
+ await page.waitForFunction(
635
+ () => {
636
+ const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
637
+
638
+ if (!el) {
639
+ return false
640
+ }
641
+
642
+ // Walk up to the nearest positioned ancestor — overlays are
643
+ // `position: fixed; inset: 0`. If the hit element or an ancestor
644
+ // is a full-viewport fixed overlay, we're still covered.
645
+ let node: Element | null = el
646
+ while (node) {
647
+ const cs = window.getComputedStyle(node)
648
+
649
+ if (cs.position === 'fixed') {
650
+ const rect = node.getBoundingClientRect()
651
+
652
+ if (rect.left <= 0 && rect.top <= 0 && rect.right >= window.innerWidth && rect.bottom >= window.innerHeight) {
653
+ return false
654
+ }
655
+ }
656
+
657
+ node = node.parentElement
658
+ }
659
+
660
+ return true
661
+ },
662
+ undefined,
663
+ { timeout: timeoutMs },
664
+ )
665
+
666
+ // On Electron 40.x, ready-to-show may never fire (electron/electron#51972)
667
+ // and the window stays hidden even though the DOM is rendered. The main
668
+ // process reveals it anyway — immediately under TEST_WORKER_INDEX, and via
669
+ // wireWindowReveal's post-load fallback in production — but the DOM can be
670
+ // ready before that lands. Poll until the window is actually visible so
671
+ // interactions (click, screenshot) don't hit a hidden surface.
672
+ if (app) {
673
+ const deadline = Date.now() + timeoutMs
674
+
675
+ while (Date.now() < deadline) {
676
+ const visible = await app.evaluate(({ BrowserWindow }) => {
677
+ const w = BrowserWindow.getAllWindows()[0]
678
+
679
+ return w ? w.isVisible() : false
680
+ }).catch(() => false)
681
+
682
+ if (visible) {break}
683
+ await page.waitForTimeout(500)
684
+ }
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Wait for the onboarding overlay to appear (no provider configured).
690
+ */
691
+ export async function waitForOnboarding(page: Page, timeoutMs = 60_000): Promise<void> {
692
+ // The onboarding overlay contains a heading with "Choose your provider"
693
+ // or similar text. We look for any text that indicates the picker.
694
+ await page.waitForFunction(
695
+ () => {
696
+ const root = document.getElementById('root')
697
+
698
+ if (!root) {
699
+ return false
700
+ }
701
+
702
+ const text = root.textContent ?? ''
703
+
704
+ return (
705
+ text.includes('provider') ||
706
+ text.includes('Provider') ||
707
+ text.includes('Choose') ||
708
+ text.includes('API key') ||
709
+ text.includes('Sign in')
710
+ )
711
+ },
712
+ undefined,
713
+ { timeout: timeoutMs },
714
+ )
715
+ }
716
+
717
+ /**
718
+ * Wait for the boot failure overlay to appear.
719
+ */
720
+ export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise<void> {
721
+ await page.waitForFunction(
722
+ () => {
723
+ // Boot failure is terminal: the backend gave up. The renderer shows
724
+ // either BootFailureOverlay (z-1400, with Retry/Repair buttons) or
725
+ // falls back to the onboarding picker (z-1300) as a recovery path.
726
+ // We wait for the failure dialog itself — the Preparing component may
727
+ // still paint its progress bar (recolored red) underneath the overlay,
728
+ // which is harmless.
729
+ const text = document.body.textContent ?? ''
730
+
731
+ // BootFailureOverlay buttons.
732
+ const hasFailureUI =
733
+ text.includes('Retry') ||
734
+ text.includes('Repair') ||
735
+ text.includes('Use local gateway') ||
736
+ text.includes('Connection settings')
737
+
738
+ // The error toast / notification that fires on failDesktopBoot().
739
+ const hasErrorToast = text.includes('Desktop boot failed')
740
+
741
+ return hasFailureUI || hasErrorToast
742
+ },
743
+ undefined,
744
+ { timeout: timeoutMs },
745
+ )
746
+ }
apps/desktop/e2e/fleet-profile-rail.spec.ts ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * E2E: the fleet profile rail with two registered gateways.
3
+ *
4
+ * "This device" is the Electron-managed local backend (mock inference). The
5
+ * second gateway, "Homelab", is a REAL second `hermes serve` this spec spawns
6
+ * with its own HERMES_HOME, profiles and session token, registered in the v2
7
+ * connections.json as a remote URL connection. A click on an at-rest square
8
+ * therefore performs the same dial → commit → re-home the statusbar switcher
9
+ * does, against a real backend — not a stub.
10
+ *
11
+ * Prerequisite: `npm run build` must have been run so dist/ exists, and the
12
+ * repo's Python venv (`.venv`) must exist for both backends.
13
+ */
14
+
15
+ import { type ChildProcess, spawn, spawnSync } from 'node:child_process'
16
+ import * as fs from 'node:fs'
17
+ import * as net from 'node:net'
18
+ import * as path from 'node:path'
19
+
20
+ import {
21
+ buildAppEnv,
22
+ createSandbox,
23
+ launchDesktop,
24
+ type MockBackendFixture,
25
+ type Sandbox,
26
+ waitForAppReady,
27
+ writeEnvFile,
28
+ writeMockProviderConfig,
29
+ } from './fixtures'
30
+ import { startMockServer } from '../../../tests-js/scripts/mock-server'
31
+ import { type ElectronApplication, expect, type Page, test } from './test'
32
+
33
+ const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
34
+ const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
35
+
36
+ const REMOTE_LABEL = 'Homelab'
37
+ const REMOTE_ID = 'homelab'
38
+ const REMOTE_TOKEN = 'e2e-fleet-homelab-token'
39
+
40
+ interface RemoteGateway {
41
+ url: string
42
+ home: string
43
+ close: () => Promise<void>
44
+ }
45
+
46
+ function findHermesBinary(): string {
47
+ const venv = path.join(REPO_ROOT, '.venv', 'bin', 'hermes')
48
+
49
+ if (fs.existsSync(venv)) {
50
+ return venv
51
+ }
52
+
53
+ const result = spawnSync('which', ['hermes'], { encoding: 'utf8' })
54
+
55
+ if (result.status === 0 && result.stdout.trim()) {
56
+ return result.stdout.trim()
57
+ }
58
+
59
+ throw new Error('hermes binary not found: create the repo venv (uv sync) or put hermes on PATH')
60
+ }
61
+
62
+ async function freePort(): Promise<number> {
63
+ return new Promise((resolve, reject) => {
64
+ const server = net.createServer()
65
+ server.unref()
66
+ server.on('error', reject)
67
+ server.listen(0, '127.0.0.1', () => {
68
+ const { port } = server.address() as net.AddressInfo
69
+ server.close(() => resolve(port))
70
+ })
71
+ })
72
+ }
73
+
74
+ /** Seed `<home>/profiles/<name>/` so the backend's /api/profiles lists it. */
75
+ function seedProfiles(home: string, names: string[]): void {
76
+ for (const name of names) {
77
+ const dir = path.join(home, 'profiles', name)
78
+ fs.mkdirSync(dir, { recursive: true })
79
+ fs.writeFileSync(path.join(dir, 'config.yaml'), '', 'utf8')
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Spawn a second, fully real `hermes serve` as the remote gateway. Its
85
+ * session token is pinned through HERMES_DASHBOARD_SESSION_TOKEN so the
86
+ * registry entry can carry a plaintext token envelope.
87
+ */
88
+ async function startRemoteGateway(root: string, mockUrl: string, profiles: string[]): Promise<RemoteGateway> {
89
+ const home = path.join(root, 'homelab-home')
90
+ fs.mkdirSync(home, { recursive: true })
91
+ writeMockProviderConfig(home, mockUrl)
92
+ writeEnvFile(home)
93
+ seedProfiles(home, profiles)
94
+
95
+ const port = await freePort()
96
+ const url = `http://127.0.0.1:${port}`
97
+
98
+ const child: ChildProcess = spawn(
99
+ findHermesBinary(),
100
+ ['serve', '--host', '127.0.0.1', '--port', String(port), '--skip-build'],
101
+ {
102
+ cwd: REPO_ROOT,
103
+ detached: true,
104
+ env: {
105
+ ...process.env,
106
+ HERMES_HOME: home,
107
+ HERMES_DASHBOARD_SESSION_TOKEN: REMOTE_TOKEN,
108
+ },
109
+ stdio: ['ignore', 'pipe', 'pipe'],
110
+ },
111
+ )
112
+
113
+ let log = ''
114
+ child.stdout?.on('data', (chunk: Buffer) => {
115
+ log += chunk.toString()
116
+ })
117
+ child.stderr?.on('data', (chunk: Buffer) => {
118
+ log += chunk.toString()
119
+ })
120
+
121
+ const deadline = Date.now() + 90_000
122
+
123
+ while (Date.now() < deadline) {
124
+ if (child.exitCode !== null) {
125
+ throw new Error(`remote hermes serve exited early (${child.exitCode}):\n${log}`)
126
+ }
127
+
128
+ try {
129
+ const response = await fetch(`${url}/api/status`, {
130
+ headers: { 'X-Hermes-Session-Token': REMOTE_TOKEN },
131
+ })
132
+
133
+ if (response.ok) {
134
+ break
135
+ }
136
+ } catch {
137
+ // not up yet
138
+ }
139
+
140
+ await new Promise(resolve => setTimeout(resolve, 500))
141
+ }
142
+
143
+ if (Date.now() >= deadline) {
144
+ throw new Error(`remote hermes serve never became ready:\n${log}`)
145
+ }
146
+
147
+ return {
148
+ url,
149
+ home,
150
+ close: async () => {
151
+ if (child.pid && child.exitCode === null) {
152
+ try {
153
+ process.kill(-child.pid, 'SIGTERM')
154
+ } catch {
155
+ child.kill('SIGTERM')
156
+ }
157
+ }
158
+
159
+ await new Promise(resolve => setTimeout(resolve, 500))
160
+ },
161
+ }
162
+ }
163
+
164
+ function writeConnectionsRegistry(sandbox: Sandbox, remoteUrl: string): void {
165
+ fs.writeFileSync(
166
+ path.join(sandbox.userDataDir, 'connections.json'),
167
+ JSON.stringify(
168
+ {
169
+ version: 2,
170
+ primary: 'local',
171
+ launchMode: 'primary',
172
+ lastUsed: 'local',
173
+ connections: [
174
+ { id: 'local', kind: 'local', label: 'This device' },
175
+ {
176
+ id: REMOTE_ID,
177
+ kind: 'remote',
178
+ label: REMOTE_LABEL,
179
+ url: remoteUrl,
180
+ authMode: 'token',
181
+ token: { encoding: 'plain', value: REMOTE_TOKEN },
182
+ },
183
+ ],
184
+ },
185
+ null,
186
+ 2,
187
+ ),
188
+ { encoding: 'utf8', mode: 0o600 },
189
+ )
190
+ }
191
+
192
+ // FLEET_RAIL_SCREENSHOT_DIR=<dir> saves full-window captures at the key
193
+ // states — handy for design review; never part of the assertions.
194
+ async function capture(page: Page, name: string): Promise<void> {
195
+ const dir = process.env.FLEET_RAIL_SCREENSHOT_DIR
196
+
197
+ if (!dir) {
198
+ return
199
+ }
200
+
201
+ fs.mkdirSync(dir, { recursive: true })
202
+ await page.screenshot({ path: path.join(dir, `${name}.png`) })
203
+ }
204
+
205
+ const rail = (page: Page) => page.locator('[data-slot="profile-rail"]')
206
+ const gatewayGroup = (page: Page, id: string) => rail(page).locator(`[data-slot="profile-rail-gateway"][data-connection-id="${id}"]`)
207
+ const activeGatewayLabel = (page: Page) => page.getByRole('button', { name: /^Registered gateways: / })
208
+
209
+ async function groupOrder(page: Page): Promise<Array<[string, boolean]>> {
210
+ return rail(page).locator('[data-slot="profile-rail-gateway"]').evaluateAll(nodes =>
211
+ nodes.map(node => [node.getAttribute('data-connection-id') ?? '', node.getAttribute('data-active') === 'true'] as [string, boolean]),
212
+ )
213
+ }
214
+
215
+ test.describe('fleet profile rail — two registered gateways', () => {
216
+ test.describe.configure({ mode: 'serial' })
217
+
218
+ let mock: Awaited<ReturnType<typeof startMockServer>>
219
+ let sandbox: Sandbox
220
+ let remote: RemoteGateway
221
+ let app: ElectronApplication
222
+ let page: Page
223
+
224
+ test.beforeAll(async () => {
225
+ test.setTimeout(240_000)
226
+ mock = await startMockServer()
227
+ sandbox = createSandbox('fleet')
228
+ writeMockProviderConfig(sandbox.hermesHome, mock.url)
229
+ writeEnvFile(sandbox.hermesHome)
230
+ // A named profile on This device too, so the active group has a square
231
+ // beside its home pill. "research" exists on BOTH gateways on purpose: the
232
+ // rail must keep the two apart by gateway, never by name alone.
233
+ seedProfiles(sandbox.hermesHome, ['research'])
234
+
235
+ remote = await startRemoteGateway(sandbox.root, mock.url, ['inbox', 'research'])
236
+ writeConnectionsRegistry(sandbox, remote.url)
237
+
238
+ ;({ app, page } = await launchDesktop(buildAppEnv(sandbox)))
239
+ await waitForAppReady({ app, page } as MockBackendFixture, 120_000)
240
+ // Let boot settle fully (the gateway health item reports "ready" once the
241
+ // primary socket is open) so the boot-time launch-mode restore has run
242
+ // before any click — the rail must then hold whatever the user picks.
243
+ await expect(page.locator('[data-slot="statusbar"]').getByText('ready', { exact: true })).toBeVisible({ timeout: 120_000 })
244
+ await page.waitForTimeout(2_000)
245
+ })
246
+
247
+ test.afterAll(async () => {
248
+ await app?.close().catch(() => undefined)
249
+ await remote?.close()
250
+ await mock?.close()
251
+ sandbox?.cleanup()
252
+ })
253
+
254
+ test('lays both gateways on one strip, active gateway in its registry slot', async () => {
255
+ // The statusbar readout names the gateway the workspace is on.
256
+ await expect(activeGatewayLabel(page)).toHaveAttribute('aria-label', 'Registered gateways: This device', { timeout: 60_000 })
257
+
258
+ // The remote gateway's group appears once the roster has enumerated it.
259
+ const homelab = gatewayGroup(page, REMOTE_ID)
260
+ await expect(homelab).toBeVisible({ timeout: 60_000 })
261
+ await expect(homelab.getByRole('button', { name: `default · ${REMOTE_LABEL}` })).toBeVisible()
262
+ await expect(homelab.getByRole('button', { name: `inbox · ${REMOTE_LABEL}` })).toBeVisible()
263
+ await expect(homelab.getByRole('button', { name: `research · ${REMOTE_LABEL}` })).toBeVisible()
264
+ await expect(homelab).toHaveAttribute('data-reachable', 'true')
265
+
266
+ // Its marker carries the remote (network) glyph.
267
+ await expect(
268
+ rail(page).locator(`[data-slot="profile-rail-divider"][data-connection-id="${REMOTE_ID}"] [data-connection-kind="remote"]`),
269
+ ).toBeVisible()
270
+
271
+ // This device is the active group: its squares are unqualified, as before.
272
+ const local = gatewayGroup(page, 'local')
273
+ await expect(local).toHaveAttribute('data-active', 'true')
274
+ await expect(local.getByRole('button', { name: 'research', exact: true })).toBeVisible()
275
+
276
+ // Registry order: This device first, Homelab second.
277
+ expect(await groupOrder(page)).toEqual([
278
+ ['local', true],
279
+ [REMOTE_ID, false],
280
+ ])
281
+
282
+ // Fleet pill replaces the default↔all toggle; the single-gateway plug is gone.
283
+ await expect(rail(page).getByRole('button', { name: 'All profiles on this gateway' })).toBeVisible()
284
+ await expect(rail(page).getByRole('button', { name: 'Manage gateways…' })).toHaveCount(0)
285
+
286
+ await gatewayGroup(page, REMOTE_ID).getByRole('button', { name: `inbox · ${REMOTE_LABEL}` }).hover()
287
+ await capture(page, '1-on-this-device-hover-inbox-homelab')
288
+ })
289
+
290
+ test('clicking an at-rest square re-homes onto that exact gateway and profile', async () => {
291
+ test.setTimeout(180_000)
292
+ await gatewayGroup(page, REMOTE_ID).getByRole('button', { name: `inbox · ${REMOTE_LABEL}` }).click()
293
+
294
+ // The workspace follows the agent: statusbar readout flips to Homelab…
295
+ await expect(activeGatewayLabel(page)).toHaveAttribute('aria-label', `Registered gateways: ${REMOTE_LABEL}`, { timeout: 120_000 })
296
+
297
+ // …Homelab's group is now the active one, on the clicked profile…
298
+ const homelab = gatewayGroup(page, REMOTE_ID)
299
+ await expect(homelab).toHaveAttribute('data-active', 'true', { timeout: 30_000 })
300
+ await expect(homelab.getByRole('button', { name: 'inbox', exact: true })).toHaveAttribute('aria-pressed', 'true', { timeout: 30_000 })
301
+
302
+ // …This device is at rest with qualified squares…
303
+ const local = gatewayGroup(page, 'local')
304
+ await expect(local).toHaveAttribute('data-active', 'false')
305
+ await expect(local.getByRole('button', { name: 'research · This device' })).toBeVisible()
306
+
307
+ // …and nothing moved: the order is still This device, then Homelab.
308
+ expect(await groupOrder(page)).toEqual([
309
+ ['local', false],
310
+ [REMOTE_ID, true],
311
+ ])
312
+
313
+ await capture(page, '2-re-homed-on-homelab-inbox')
314
+ })
315
+
316
+ test('an at-rest square offers gateway-scoped actions, never the legacy remote override', async () => {
317
+ const square = gatewayGroup(page, 'local').getByRole('button', { name: 'research · This device' })
318
+ await square.click({ button: 'right' })
319
+
320
+ const menu = page.getByRole('menu', { name: 'Actions' })
321
+ await expect(menu).toBeVisible()
322
+ await expect(menu.getByRole('menuitem', { name: 'Switch to research on This device' })).toBeVisible()
323
+ await expect(menu.getByRole('menuitem', { name: 'Rename…' })).toBeVisible()
324
+ await expect(menu.getByRole('menuitem', { name: 'Edit SOUL.md…' })).toBeVisible()
325
+ await expect(menu.getByRole('menuitem', { name: 'Delete' })).toBeVisible()
326
+ await expect(menu.getByRole('menuitem', { name: 'Connect to a remote host…' })).toHaveCount(0)
327
+
328
+ await capture(page, '3-at-rest-square-context-menu')
329
+ await page.keyboard.press('Escape')
330
+ await expect(menu).toBeHidden()
331
+ })
332
+
333
+ test('editing SOUL.md on an at-rest square reads the owning gateway, not the foreground one', async () => {
334
+ const square = gatewayGroup(page, 'local').getByRole('button', { name: 'research · This device' })
335
+ await square.click({ button: 'right' })
336
+ await page.getByRole('menu', { name: 'Actions' }).getByRole('menuitem', { name: 'Edit SOUL.md…' }).click()
337
+
338
+ const dialog = page.getByRole('dialog')
339
+ await expect(dialog).toBeVisible()
340
+ await expect(dialog.getByText('research · This device · SOUL.md')).toBeVisible()
341
+ await page.keyboard.press('Escape')
342
+ await expect(dialog).toBeHidden()
343
+ })
344
+
345
+ test('switching back lands on the clicked profile of This device and keeps the order', async () => {
346
+ test.setTimeout(180_000)
347
+ await gatewayGroup(page, 'local').getByRole('button', { name: 'research · This device' }).click()
348
+
349
+ await expect(activeGatewayLabel(page)).toHaveAttribute('aria-label', 'Registered gateways: This device', { timeout: 120_000 })
350
+ const local = gatewayGroup(page, 'local')
351
+ await expect(local).toHaveAttribute('data-active', 'true', { timeout: 30_000 })
352
+ await expect(local.getByRole('button', { name: 'research', exact: true })).toHaveAttribute('aria-pressed', 'true', { timeout: 30_000 })
353
+ await expect(gatewayGroup(page, REMOTE_ID).getByRole('button', { name: `inbox · ${REMOTE_LABEL}` })).toBeVisible()
354
+
355
+ expect(await groupOrder(page)).toEqual([
356
+ ['local', true],
357
+ [REMOTE_ID, false],
358
+ ])
359
+ })
360
+ })
apps/desktop/e2e/glass-surfaces.spec.ts ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as fs from 'node:fs/promises'
2
+ import * as os from 'node:os'
3
+ import * as path from 'node:path'
4
+
5
+ import { expect, test } from '@playwright/test'
6
+ import { createServer, type ViteDevServer } from 'vite'
7
+
8
+ const desktop = path.resolve(import.meta.dirname, '..')
9
+ let server: ViteDevServer
10
+ let scratch: string
11
+ let url: string
12
+
13
+ test.beforeAll(async () => {
14
+ scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'hermes-glass-'))
15
+ // Match the existing component-browser fixtures without sharing Vite's cache.
16
+ Object.assign(globalThis, { __dirname: desktop })
17
+ server = await createServer({
18
+ root: desktop,
19
+ configFile: path.join(desktop, 'vite.config.ts'),
20
+ configLoader: 'runner',
21
+ cacheDir: path.join(scratch, 'node_modules/.vite'),
22
+ server: { host: '127.0.0.1', port: 0, strictPort: false },
23
+ optimizeDeps: { entries: ['scripts/fixtures/glass-surfaces.html'] }
24
+ })
25
+ await server.listen()
26
+ url = `${server.resolvedUrls!.local[0]}scripts/fixtures/glass-surfaces.html`
27
+ })
28
+
29
+ test.afterAll(async () => {
30
+ await server?.close()
31
+
32
+ if (scratch) {
33
+ await fs.rm(scratch, { recursive: true, force: true })
34
+ }
35
+ })
36
+
37
+ test('glass tabs fade the label beneath the close button without repainting the field', async ({ page }) => {
38
+ await page.goto(url)
39
+ const tab = page.getByTestId('active-tab')
40
+ const close = tab.getByRole('button', { name: 'Close', exact: true })
41
+ await expect(tab).toBeVisible()
42
+
43
+ for (const id of ['active-tab', 'idle-tab', 'fixed-tab']) {
44
+ expect(await page.getByTestId(id).evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)')
45
+ }
46
+
47
+ const bounds = await tab.boundingBox()
48
+ await tab.hover()
49
+ await expect(close).toBeVisible()
50
+ expect(await close.evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)')
51
+
52
+ const mask = await tab.getByText('A long session title', { exact: false }).evaluate(el => {
53
+ const masks = []
54
+
55
+ for (let node: Element | null = el; node && !node.hasAttribute('data-testid'); node = node.parentElement) {
56
+ masks.push(getComputedStyle(node).maskImage)
57
+ }
58
+
59
+ return masks.find(value => value !== 'none')
60
+ })
61
+
62
+ expect(mask).toContain('linear-gradient')
63
+ expect(await tab.boundingBox()).toEqual(bounds)
64
+
65
+ // Paint a continuous marker through the real label slot to test the mask's
66
+ // pixels, not just the existence of a gradient. The close glyph is hidden so
67
+ // its paint cannot be mistaken for label bleed-through.
68
+ const sample = await tab.evaluate(el => {
69
+ const content = el.querySelector<HTMLElement>('.pane-tab-content')!
70
+ const button = el.querySelector<HTMLButtonElement>('button[aria-label="Close"]')!
71
+ content.style.background = '#ff0000'
72
+ button.style.visibility = 'hidden'
73
+ const bounds = content.getBoundingClientRect()
74
+ const closeBounds = button.getBoundingClientRect()
75
+
76
+ return {
77
+ y: Math.floor(bounds.top + 2),
78
+ solid: Math.floor(bounds.left + 2),
79
+ fade: Math.floor(closeBounds.left - 7),
80
+ covered: Math.floor(closeBounds.left + 3)
81
+ }
82
+ })
83
+
84
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
85
+ const pixels = await page.screenshot({ omitBackground: true, path: test.info().outputPath('masked-tab.png') })
86
+
87
+ const alpha = await page.evaluate(
88
+ async ({ image, points }) => {
89
+ const img = new Image()
90
+ img.src = image
91
+ await img.decode()
92
+ const canvas = document.createElement('canvas')
93
+ canvas.width = img.width
94
+ canvas.height = img.height
95
+ const context = canvas.getContext('2d')!
96
+ context.drawImage(img, 0, 0)
97
+
98
+ return [points.solid, points.fade, points.covered].map(x => context.getImageData(x, points.y, 1, 1).data[3])
99
+ },
100
+ { image: `data:image/png;base64,${pixels.toString('base64')}`, points: sample }
101
+ )
102
+
103
+ expect(alpha[0]).toBeGreaterThan(alpha[1])
104
+ expect(alpha[1]).toBeGreaterThan(alpha[2])
105
+ expect(alpha[2]).toBeLessThan(128)
106
+ await tab.evaluate(el => {
107
+ el.querySelector<HTMLElement>('.pane-tab-content')!.style.removeProperty('background')
108
+ el.querySelector<HTMLButtonElement>('button[aria-label="Close"]')!.style.removeProperty('visibility')
109
+ })
110
+ // The active underline remains on the tab itself, without a second stroke
111
+ // on the close button increasing its opacity.
112
+ expect(await tab.evaluate(el => getComputedStyle(el).boxShadow)).toContain('inset')
113
+ await close.click()
114
+ await expect(page.locator('body')).toHaveAttribute('data-closed', 'true')
115
+ await expect(page.locator('body')).not.toHaveAttribute('data-activated', 'true')
116
+
117
+ for (const id of ['idle-tab', 'short-tab', 'selected-tab']) {
118
+ const sampleTab = page.getByTestId(id)
119
+ const before = await sampleTab.boundingBox()
120
+ await sampleTab.hover()
121
+ const content = sampleTab.locator('.pane-tab-content')
122
+ expect(await content.evaluate(el => getComputedStyle(el).maskImage)).toContain('linear-gradient')
123
+ expect(await sampleTab.boundingBox()).toEqual(before)
124
+ expect(
125
+ await sampleTab
126
+ .getByRole('button', { name: 'Close', exact: true })
127
+ .evaluate(el => getComputedStyle(el).backgroundColor)
128
+ ).toBe('rgba(0, 0, 0, 0)')
129
+ }
130
+
131
+ // Glass scope and tint do not affect the fade. Solid mode still honors each
132
+ // tab's surface token instead of becoming unconditionally transparent.
133
+ await page.evaluate(() => {
134
+ document.documentElement.setAttribute('data-hermes-glass-scope', 'sidebar')
135
+ document.documentElement.style.setProperty('--translucency-glass-keep', '85%')
136
+ })
137
+ expect(await tab.evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)')
138
+ await page.evaluate(() => document.documentElement.removeAttribute('data-hermes-glass'))
139
+ expect(await tab.evaluate(el => getComputedStyle(el).backgroundColor)).not.toBe('rgba(0, 0, 0, 0)')
140
+ await tab.hover()
141
+ expect(await tab.locator('.pane-tab-content').evaluate(el => getComputedStyle(el).maskImage)).toContain(
142
+ 'linear-gradient'
143
+ )
144
+ })
145
+
146
+ test('sticky prompts clip scrolling replies without an opaque backing', async ({ page }) => {
147
+ await page.goto(url)
148
+ const transcript = page.getByTestId('first-transcript')
149
+ const viewport = transcript.locator('[data-slot="aui_thread-viewport"]')
150
+ const prompt = transcript.locator('[data-slot="aui_user-message-root"]').first()
151
+ await expect(prompt).toBeAttached()
152
+ await viewport.evaluate(el => {
153
+ el.scrollTop = 0
154
+ })
155
+ await expect.poll(() => viewport.evaluate(el => el.scrollTop)).toBe(0)
156
+ expect(await prompt.evaluate(el => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)')
157
+ await page.evaluate(() => document.documentElement.style.setProperty('--user-bubble-keep', '0%'))
158
+
159
+ await viewport.evaluate(el => {
160
+ el.scrollTop = 300
161
+ })
162
+ await expect.poll(() => viewport.evaluate(el => el.scrollTop)).toBe(300)
163
+
164
+ const points = await prompt.evaluate(el => {
165
+ const rect = el.getBoundingClientRect()
166
+ const viewport = el.closest('[data-slot="aui_thread-viewport"]')!.getBoundingClientRect()
167
+
168
+ return {
169
+ x: Math.floor(rect.left + rect.width / 2),
170
+ gap: Math.floor(viewport.top + 1),
171
+ hidden: Math.floor(rect.top + 8),
172
+ visible: Math.ceil(rect.bottom + 5)
173
+ }
174
+ })
175
+
176
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
177
+ const image = await page.screenshot({ omitBackground: true, path: test.info().outputPath('sticky-mask.png') })
178
+
179
+ const colors = await page.evaluate(
180
+ async ({ image, points }) => {
181
+ const bitmap = new Image()
182
+ bitmap.src = image
183
+ await bitmap.decode()
184
+ const canvas = document.createElement('canvas')
185
+ canvas.width = bitmap.width
186
+ canvas.height = bitmap.height
187
+ const context = canvas.getContext('2d')!
188
+ context.drawImage(bitmap, 0, 0)
189
+
190
+ return [points.gap, points.hidden, points.visible].map(y =>
191
+ Array.from(context.getImageData(points.x, y, 1, 1).data)
192
+ )
193
+ },
194
+ { image: `data:image/png;base64,${image.toString('base64')}`, points }
195
+ )
196
+
197
+ expect(colors[0][3]).toBeLessThan(128)
198
+ expect(colors[1][3]).toBeLessThan(128)
199
+ expect(colors[2]).toEqual([255, 0, 0, 255])
200
+
201
+ const clipEdge = () =>
202
+ transcript
203
+ .locator('[data-slot="aui_assistant-message-root"]')
204
+ .first()
205
+ .evaluate(el => {
206
+ const rect = el.getBoundingClientRect()
207
+ const clip = Number.parseFloat(getComputedStyle(el).getPropertyValue('--sticky-prompt-clip'))
208
+
209
+ const prompt = el
210
+ .closest('[data-slot="aui_message-group"]')!
211
+ .querySelector('[data-slot="aui_user-message-root"]')!
212
+
213
+ return Math.abs(rect.top + clip - prompt.getBoundingClientRect().bottom)
214
+ })
215
+
216
+ await expect.poll(clipEdge).toBeLessThan(1)
217
+
218
+ const clips = await page.evaluate(async () => {
219
+ const transcript = document.querySelector('[data-testid="first-transcript"]')!
220
+ const reply = transcript.querySelector<HTMLElement>('[data-slot="aui_assistant-message-root"]')!
221
+ const read = () => getComputedStyle(reply).clipPath
222
+ const before = read()
223
+ document.querySelector<HTMLButtonElement>('[data-testid="first-transcript-append"]')!.click()
224
+ const samples = [read()]
225
+
226
+ for (let i = 0; i < 3; i++) {
227
+ await new Promise(requestAnimationFrame)
228
+ samples.push(read())
229
+ }
230
+
231
+ return { before, samples }
232
+ })
233
+
234
+ expect(clips.before).not.toBe('none')
235
+ expect(clips.samples.every(value => value !== 'none')).toBe(true)
236
+
237
+ // Expanding a pinned prompt must move the clip without needing a scroll.
238
+ await prompt.getByRole('button').click()
239
+ await expect.poll(() => prompt.getByRole('button').evaluate(el => el.getBoundingClientRect().height)).toBe(140)
240
+ await expect.poll(clipEdge).toBeLessThan(1)
241
+
242
+ // A larger secondary-window titlebar offset moves both the sticky and clip.
243
+ await viewport.evaluate(el => el.style.setProperty('--sticky-human-top', '55px'))
244
+ await viewport.evaluate(el => {
245
+ el.scrollTop = 301
246
+ })
247
+ await expect.poll(clipEdge).toBeLessThan(1)
248
+
249
+ // Scroll back through attachments and out of the pinned state. Nothing may
250
+ // remain clipped, and the other pane's independently pinned reply is intact.
251
+ await viewport.evaluate(el => el.style.removeProperty('--sticky-human-top'))
252
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
253
+ await viewport.evaluate(el => {
254
+ el.scrollTop = 0
255
+ })
256
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
257
+ await expect.poll(() => transcript.locator('[data-sticky-prompt-clip]').count()).toBe(0)
258
+ await expect(transcript.getByTestId('attachment').first()).toBeVisible()
259
+ const other = page.getByTestId('second-transcript')
260
+ expect(await other.locator('[data-slot="aui_thread-viewport"]').evaluate(el => el.scrollTop)).toBeGreaterThan(0)
261
+ expect(await other.locator('[data-sticky-prompt-clip]').count()).toBeGreaterThan(0)
262
+
263
+ // Enter another turn via a large jump (including virtualized history), then
264
+ // return: stale clip styles must leave the previous turn.
265
+ await viewport.evaluate(el => {
266
+ el.scrollTop = 1400
267
+ })
268
+ await expect.poll(() => transcript.locator('[data-sticky-prompt-clip]').count()).toBeGreaterThan(0)
269
+ await viewport.evaluate(el => {
270
+ el.scrollTop = 0
271
+ })
272
+ await expect.poll(() => transcript.locator('[data-sticky-prompt-clip]').count()).toBe(0)
273
+
274
+ // A standalone reply can still occupy the gap below a secondary titlebar
275
+ // when the following prompt pins. It must be clipped across group boundaries.
276
+ await page.goto(`${url}?preceding`)
277
+ const gapViewport = page.getByTestId('first-transcript').locator('[data-slot="aui_thread-viewport"]')
278
+ await gapViewport.locator('[data-slot="aui_user-message-root"]').first().waitFor({ state: 'attached' })
279
+ await gapViewport.evaluate(el => {
280
+ el.scrollTop = 0
281
+ })
282
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
283
+
284
+ const gapScroll = await gapViewport.evaluate(el => {
285
+ el.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
286
+ el.style.setProperty('--sticky-human-top', '55px')
287
+ const group = el.querySelector('[data-slot="aui_user-message-root"]')!.closest('[data-slot="aui_message-group"]')!
288
+ const target = el.scrollTop + group.getBoundingClientRect().top - el.getBoundingClientRect().top - 45
289
+ el.scrollTop = target
290
+
291
+ return el.scrollTop
292
+ })
293
+
294
+ await expect.poll(() => gapViewport.evaluate(el => el.scrollTop)).toBe(gapScroll)
295
+ await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
296
+
297
+ const gapClear = () =>
298
+ gapViewport.evaluate(el => {
299
+ const previous = el.querySelector('[data-slot="aui_assistant-message-root"]')!
300
+ const bounds = el.getBoundingClientRect()
301
+ const x = bounds.left + bounds.width / 2
302
+ const hit = document.elementFromPoint(x, bounds.top + 34)
303
+
304
+ return { hidden: !previous.contains(hit), clip: getComputedStyle(previous).clipPath }
305
+ })
306
+
307
+ await expect.poll(async () => (await gapClear()).hidden).toBe(true)
308
+ expect((await gapClear()).clip).not.toBe('none')
309
+
310
+ // Exercise the production message/edit components as well as the colored
311
+ // clipping markers. Editing replaces the prompt with a display:contents root.
312
+ await page.goto(`${url}?real`)
313
+ const realViewport = page.getByTestId('first-transcript').locator('[data-slot="aui_thread-viewport"]')
314
+ await realViewport.locator('[data-slot="aui_assistant-message-root"]').first().waitFor()
315
+ await realViewport
316
+ .locator('[data-slot="aui_assistant-message-root"]')
317
+ .first()
318
+ .evaluate(el => {
319
+ el.style.height = '900px'
320
+ })
321
+ await realViewport.evaluate(el => {
322
+ el.scrollTop = 300
323
+ })
324
+ const realPrompt = realViewport.locator('[data-slot="aui_user-message-root"]').first()
325
+ await realPrompt.getByRole('button', { name: 'Edit message', exact: true }).click()
326
+ const editor = realViewport.getByRole('textbox', { name: 'Edit message', exact: true })
327
+ await expect(editor).toBeVisible()
328
+ await editor.fill('Expanded editable prompt\n'.repeat(8))
329
+ await realViewport.evaluate(el => {
330
+ el.scrollTop = 400
331
+ })
332
+ await expect
333
+ .poll(() =>
334
+ realViewport
335
+ .locator('[data-slot="aui_assistant-message-root"]')
336
+ .first()
337
+ .evaluate(el => {
338
+ const prompt = el
339
+ .closest('[data-slot="aui_message-group"]')!
340
+ .querySelector('[data-slot="aui_user-message-root"]')!
341
+
342
+ return Math.abs(
343
+ el.getBoundingClientRect().top +
344
+ Number.parseFloat(getComputedStyle(el).getPropertyValue('--sticky-prompt-clip')) -
345
+ prompt.getBoundingClientRect().bottom
346
+ )
347
+ })
348
+ )
349
+ .toBeLessThan(1)
350
+ })
apps/desktop/e2e/glyph-spinner.spec.ts ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * E2E contract for the compositor-only GlyphSpinner.
3
+ *
4
+ * The spinner's whole reason for existing in this shape is a CSS animation:
5
+ * every frame is in the DOM from mount and a `transform` keyframes animation
6
+ * scrolls between them, so there is no JS timer and no per-tick DOM mutation
7
+ * scheduling document-scale style recalculation.
8
+ *
9
+ * None of that is observable in jsdom — it has no animation engine, no
10
+ * cascade resolution for `steps()`, and no `Element.getAnimations()`. The
11
+ * jsdom suite (src/components/ui/glyph-spinner.test.tsx) therefore pins the
12
+ * DATA and WIRING, and this spec pins the RENDERED BEHAVIOUR in a real
13
+ * browser, which is the only place the stylesheet actually runs.
14
+ *
15
+ * This replaces three tests that asserted on the TEXT of the stylesheet.
16
+ * Reading source in a test is banned outright (AGENTS.md) and those tests
17
+ * proved the point: a var()-fallback edit that changed no rendered pixel
18
+ * broke one of them, while none of them had ever executed the CSS.
19
+ *
20
+ * Prerequisite: `npm run build` must have been run so dist/ exists.
21
+ */
22
+
23
+ import { expect, type Page, test } from '@playwright/test'
24
+
25
+ import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
26
+
27
+ /* Scope to a spinner that is actually RUNNING. Turns from earlier tests in
28
+ * this file leave parked spinners mounted (kept-alive panes, swap overlays
29
+ * hold them with data-paused='true'), and document.querySelector returns the
30
+ * FIRST strip in the DOM — a stale parked one once two turns have run. */
31
+ const STRIP = '.glyph-spinner:not([data-paused="true"]) .glyph-spinner__strip'
32
+
33
+ /** Prompt the mock server holds open so the spinner runs for the whole file. */
34
+ const SPINNER_PROMPT = 'E2E_GLYPH_SPINNER_HOLD'
35
+
36
+ /**
37
+ * Get a RUNNING frame strip into the DOM deterministically.
38
+ *
39
+ * A turn is sent so the app is genuinely busy (the mock server holds the
40
+ * stream open), but which surface mounts a spinner mid-turn is app policy
41
+ * that has changed before and will again — the transcript, status stack and
42
+ * swap overlay all park/unmount theirs at different moments, which made this
43
+ * spec racy. The contract under test is the STYLESHEET (steps() animation,
44
+ * layer promotion, the data-paused and global pause gates), and that CSS is
45
+ * driven entirely by the `data-paused` attribute — the same attribute the
46
+ * parked assertions below already toggle. So: wait for any mounted spinner
47
+ * (the ChatSwapOverlay keeps one mounted, parked, after boot), then unpark it
48
+ * and assert against the running animation.
49
+ */
50
+ async function mountSpinner(page: Page): Promise<void> {
51
+ if (await page.locator(STRIP).count()) {
52
+ return
53
+ }
54
+
55
+ const composer = page.locator('[contenteditable="true"]').first()
56
+ await composer.waitFor({ state: 'visible', timeout: 10_000 })
57
+ await composer.click()
58
+ await composer.type(SPINNER_PROMPT, { delay: 10 })
59
+ await page.keyboard.press('Enter')
60
+
61
+ await page.waitForSelector('.glyph-spinner__strip', { state: 'attached', timeout: 20_000 })
62
+ await page.evaluate(() => {
63
+ for (const el of document.querySelectorAll('.glyph-spinner[data-paused]')) {
64
+ el.removeAttribute('data-paused')
65
+ }
66
+ })
67
+ await page.waitForSelector(STRIP, { state: 'attached', timeout: 20_000 })
68
+ }
69
+
70
+ test.describe('GlyphSpinner (compositor animation)', () => {
71
+ let fixture: MockBackendFixture
72
+
73
+ test.beforeAll(async () => {
74
+ fixture = await setupMockBackend({
75
+ mockServer: { holdFirstStreamForPrompt: SPINNER_PROMPT },
76
+ })
77
+ await waitForAppReady(fixture)
78
+ })
79
+
80
+ test.afterAll(async () => {
81
+ fixture?.mock.releaseHeldStream()
82
+ await fixture?.cleanup()
83
+ })
84
+
85
+ test('animates with a steps() transform keyframes animation, one step per frame', async () => {
86
+ const { page } = fixture
87
+ await mountSpinner(page)
88
+
89
+ const observed = await page.evaluate(strip => {
90
+ const el = document.querySelector<HTMLElement>(strip)
91
+
92
+ if (!el) {
93
+ throw new Error('no frame strip in the DOM')
94
+ }
95
+
96
+ const style = getComputedStyle(el)
97
+ const animations = el.getAnimations()
98
+
99
+ return {
100
+ frameCount: el.querySelectorAll('.glyph-spinner__frame').length,
101
+ timingFunction: style.animationTimingFunction,
102
+ iterationCount: style.animationIterationCount,
103
+ durationMs: animations[0]?.effect?.getTiming().duration ?? null,
104
+ names: animations.map(a => (a as CSSAnimation).animationName),
105
+ // A percentage translate makes the animation layout-dependent, which
106
+ // Chromium refuses to composite. Read the engine's own keyframes: a
107
+ // revert to translateY(-100%) shows up here, while the computed
108
+ // `style.transform` always serializes to a matrix and can't tell.
109
+ travel: ((animations[0]?.effect as KeyframeEffect | undefined)?.getKeyframes() ?? [])
110
+ .map(k => String((k as Keyframe & { transform?: string }).transform ?? ''))
111
+ .join(' | ')
112
+ }
113
+ }, STRIP)
114
+
115
+ // The strip carries every frame; `steps(N)` parks on each one in turn.
116
+ expect(observed.frameCount).toBeGreaterThan(1)
117
+ // Chromium has serialized jump-end as both `steps(N)` and `steps(N, end)`.
118
+ expect(observed.timingFunction).toMatch(new RegExp(`^steps\\(${observed.frameCount}\\b`))
119
+ expect(observed.iterationCount).toBe('infinite')
120
+ expect(observed.names).toContain('glyph-spinner-advance')
121
+ // One full cycle is frames x interval, so the duration must be a positive
122
+ // multiple of the frame count — not the single-frame interval.
123
+ expect(observed.durationMs).toBeGreaterThan(0)
124
+ // Length-typed travel, never a percentage: `translateY(-100%)` would keep
125
+ // the animation off the compositor. Chromium has serialized the resolved
126
+ // keyframe both as the authored `calc(...)` and as an absolute `...px`
127
+ // length depending on version — accept any length, reject percentages.
128
+ expect(observed.travel).toMatch(/calc\(|px\)/)
129
+ expect(observed.travel).not.toContain('%')
130
+ })
131
+
132
+ test('is promoted to a layer while running, and neither animates nor holds a layer when parked', async () => {
133
+ const { page } = fixture
134
+ await mountSpinner(page)
135
+
136
+ const running = await page.evaluate(strip => {
137
+ const el = document.querySelector<HTMLElement>(strip)!
138
+
139
+ return {
140
+ playState: getComputedStyle(el).animationPlayState,
141
+ willChange: getComputedStyle(el).willChange
142
+ }
143
+ }, STRIP)
144
+
145
+ expect(running.playState).toBe('running')
146
+ // Scoped to active spinners — a permanently promoted layer per parked
147
+ // spinner is pure memory at fan-out breadth.
148
+ expect(running.willChange).toBe('transform')
149
+
150
+ // 1. The per-spinner gate: a kept-alive but inactive pane, or an explicit
151
+ // `paused` prop (ChatSwapOverlay's fade-out).
152
+ const parked = await page.evaluate(strip => {
153
+ const el = document.querySelector<HTMLElement>(strip)!
154
+ const viewport = el.closest<HTMLElement>('.glyph-spinner')!
155
+ const previous = viewport.getAttribute('data-paused')
156
+
157
+ viewport.setAttribute('data-paused', 'true')
158
+
159
+ const state = {
160
+ playState: getComputedStyle(el).animationPlayState,
161
+ willChange: getComputedStyle(el).willChange
162
+ }
163
+
164
+ if (previous === null) {
165
+ viewport.removeAttribute('data-paused')
166
+ } else {
167
+ viewport.setAttribute('data-paused', previous)
168
+ }
169
+
170
+ return state
171
+ }, STRIP)
172
+
173
+ expect(parked.playState).toBe('paused')
174
+ expect(parked.willChange).toBe('auto')
175
+
176
+ // 2. The global gate: window minimize / document-hidden, which
177
+ // main.tsx drives by arming this attribute on the root. Visible windows
178
+ // keep animating even when another app has focus.
179
+ const globallyPaused = await page.evaluate(strip => {
180
+ const root = document.documentElement
181
+ const had = root.hasAttribute('data-renderer-animations-paused')
182
+
183
+ root.setAttribute('data-renderer-animations-paused', '')
184
+ const playState = getComputedStyle(document.querySelector<HTMLElement>(strip)!).animationPlayState
185
+
186
+ if (!had) {
187
+ root.removeAttribute('data-renderer-animations-paused')
188
+ }
189
+
190
+ return playState
191
+ }, STRIP)
192
+
193
+ expect(globallyPaused).toBe('paused')
194
+ })
195
+
196
+ test('advances in discrete frames and creates no timer-driven DOM churn', async () => {
197
+ const { page } = fixture
198
+ await mountSpinner(page)
199
+
200
+ // Sample the resolved transform across one full cycle. A steps() animation
201
+ // holds each value for a whole interval and jumps between them, so the
202
+ // distinct values it visits must be bounded by the frame count — a linear
203
+ // animation would produce a new value on every sample.
204
+ const sampled = await page.evaluate(async strip => {
205
+ const el = document.querySelector<HTMLElement>(strip)!
206
+ const frames = el.querySelectorAll('.glyph-spinner__frame').length
207
+ const duration = Number(el.getAnimations()[0]?.effect?.getTiming().duration ?? 0)
208
+ const seen = new Set<string>()
209
+ const textAtStart = el.textContent
210
+
211
+ const deadline = performance.now() + duration
212
+
213
+ while (performance.now() < deadline) {
214
+ seen.add(getComputedStyle(el).transform)
215
+ await new Promise(resolve => requestAnimationFrame(() => resolve(null)))
216
+ }
217
+
218
+ return { distinct: seen.size, frames, textUnchanged: el.textContent === textAtStart }
219
+ }, STRIP)
220
+
221
+ expect(sampled.distinct).toBeGreaterThan(1)
222
+ expect(sampled.distinct).toBeLessThanOrEqual(sampled.frames + 1)
223
+ // The old implementation rewrote textContent ~12x/second. Nothing may
224
+ // mutate the DOM as this animates — that mutation is the whole incident.
225
+ expect(sampled.textUnchanged).toBe(true)
226
+ })
227
+ })
apps/desktop/e2e/group-handoff-to-primary-bot.spec.ts ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
5
+ import { expect, test } from './test'
6
+
7
+ // #100406: in a Bot Mode group room a teammate's `@hermes` handoff must give
8
+ // the primary profile (internal name `default`) its turn, exactly like
9
+ // `@hermes → @code-farmer` already does. The live roster stamps the primary
10
+ // row's handle as the bare profile id ("default"), and the mention parser let
11
+ // that stamped handle shadow the `@hermes` alias — so the room settled with
12
+ // Hermes never driven. The mock inference server scripts each member's line
13
+ // from the user's send (`E2E_SAY(<handle>)[…]`), so the assertion is on the
14
+ // persisted room log: an entry authored by `default` saying "B".
15
+
16
+ let fixture: MockBackendFixture | null = null
17
+
18
+ type Page = MockBackendFixture['page']
19
+
20
+ interface RoomLogEntry {
21
+ from?: { kind?: string; name?: string }
22
+ text?: string
23
+ }
24
+
25
+ async function openBots(page: Page): Promise<void> {
26
+ const tab = page
27
+ .getByRole('button', { name: 'Bots', exact: true })
28
+ .or(page.getByRole('tab', { name: 'Bots', exact: true }))
29
+ .first()
30
+
31
+ await tab.click()
32
+ await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
33
+ }
34
+
35
+ async function createAgent(page: Page, name: string, title: string): Promise<void> {
36
+ await page.getByRole('button', { name: 'New bot or group chat' }).click()
37
+ await page.getByRole('menuitem', { name: 'New Bot' }).click()
38
+
39
+ const dialog = page.getByRole('dialog', { name: 'New Bot' })
40
+ await dialog.getByPlaceholder('inbox-triage').fill(name)
41
+ await dialog.getByPlaceholder('Inbox Triage').fill(title)
42
+ await dialog.getByRole('button', { name: 'Create Bot' }).click()
43
+ await expect(dialog).toBeHidden({ timeout: 30_000 })
44
+ await expect(page.getByRole('button', { name: new RegExp(`^${title}\\b`) }).first()).toBeVisible({ timeout: 30_000 })
45
+ }
46
+
47
+ /** The plugin's persisted room log (`hermes.plugin.hermes-bots.group-chats`). */
48
+ async function roomLog(page: Page, group: string): Promise<RoomLogEntry[]> {
49
+ return page.evaluate(name => {
50
+ const raw = window.localStorage.getItem('hermes.plugin.hermes-bots.group-chats')
51
+ const rooms = raw ? (JSON.parse(raw) as Record<string, { log?: RoomLogEntry[] }>) : {}
52
+
53
+ return rooms[name]?.log ?? []
54
+ }, group)
55
+ }
56
+
57
+ test.beforeAll(async () => {
58
+ fixture = await setupMockBackend()
59
+ await waitForAppReady(fixture, 120_000)
60
+ })
61
+
62
+ // Playwright requires an object-destructured fixture argument.
63
+ // eslint-disable-next-line no-empty-pattern
64
+ test.afterEach(async ({}, info) => {
65
+ if (!fixture) {
66
+ return
67
+ }
68
+
69
+ await info.attach('room-log', {
70
+ body: JSON.stringify(await roomLog(fixture.page, 'Hermes, Code Farmer'), null, 2),
71
+ contentType: 'application/json'
72
+ })
73
+ await info.attach('native-window', { body: await fixture.page.screenshot(), contentType: 'image/png' })
74
+ await info.attach('runtime-source', {
75
+ body: JSON.stringify(
76
+ await fixture.app.evaluate(() => ({
77
+ cwd: process.cwd(),
78
+ argv: process.argv,
79
+ root: process.env.HERMES_DESKTOP_HERMES_ROOT,
80
+ home: process.env.HERMES_HOME
81
+ })),
82
+ null,
83
+ 2
84
+ ),
85
+ contentType: 'application/json'
86
+ })
87
+ await info.attach('desktop-log', {
88
+ body: readFileSync(join(fixture.sandbox.hermesHome, 'logs/desktop.log')),
89
+ contentType: 'text/plain'
90
+ })
91
+ })
92
+
93
+ test.afterAll(async () => {
94
+ await fixture?.cleanup()
95
+ fixture = null
96
+ })
97
+
98
+ test('a teammate handing off with @hermes drives the primary profile', async () => {
99
+ test.setTimeout(420_000)
100
+ const page = fixture!.page
101
+ const group = 'Hermes, Code Farmer'
102
+
103
+ await openBots(page)
104
+ await createAgent(page, 'code-farmer', 'Code Farmer')
105
+
106
+ await page.getByRole('button', { name: 'New bot or group chat' }).click()
107
+ await page.getByRole('menuitem', { name: 'New Group Chat' }).click()
108
+
109
+ const dialog = page.getByRole('dialog', { name: 'New Group Chat' })
110
+
111
+ for (const title of ['Hermes', 'Code Farmer']) {
112
+ await dialog.getByText(title, { exact: true }).locator('xpath=ancestor::label').getByRole('checkbox').click()
113
+ }
114
+
115
+ await dialog.getByRole('textbox', { name: 'Group name' }).fill(group)
116
+ await dialog.getByRole('button', { name: 'Create Group (2)' }).click()
117
+
118
+ const composer = page.getByRole('textbox', { name: `Message ${group}` }).filter({ visible: true })
119
+ await expect(composer).toBeVisible({ timeout: 20_000 })
120
+
121
+ // Only Code Farmer is addressed by the user. Its scripted reply hands off
122
+ // to @hermes; Hermes' scripted reply is "B". Neither script token carries
123
+ // a literal `@`, so the user send itself never mentions Hermes.
124
+ await composer.fill(
125
+ '@code-farmer Please reply with one line only. ' +
126
+ 'E2E_SAY(code-farmer)[{at}hermes Please reply with the letter B.] E2E_SAY(hermes)[B]'
127
+ )
128
+ await composer.press('Enter')
129
+
130
+ // Code Farmer's handoff line lands first (the reverse direction is not in
131
+ // question); then the room must NOT settle without Hermes' turn.
132
+ await expect
133
+ .poll(
134
+ async () =>
135
+ (await roomLog(page, group)).some(e => e.from?.name === 'code-farmer' && /@hermes/.test(e.text || '')),
136
+ {
137
+ timeout: 180_000
138
+ }
139
+ )
140
+ .toBe(true)
141
+
142
+ await expect
143
+ .poll(
144
+ async () => (await roomLog(page, group)).some(e => e.from?.name === 'default' && (e.text || '').trim() === 'B'),
145
+ {
146
+ timeout: 180_000,
147
+ message: 'the primary profile (default / @hermes) never took its turn after being @mentioned by a teammate'
148
+ }
149
+ )
150
+ .toBe(true)
151
+
152
+ // And the transcript shows the handoff answered.
153
+ await expect(page.getByText('B', { exact: true }).filter({ visible: true }).first()).toBeVisible()
154
+
155
+ await composer.fill(
156
+ '@hermes Begin the reverse handoff. E2E_SAY(hermes)[{at}code-farmer Reply with D.] E2E_SAY(code-farmer)[D]'
157
+ )
158
+ await composer.press('Enter')
159
+ await expect
160
+ .poll(async () => (await roomLog(page, group)).some(e => e.from?.name === 'code-farmer' && e.text?.trim() === 'D'), {
161
+ timeout: 180_000
162
+ })
163
+ .toBe(true)
164
+ })
apps/desktop/e2e/group-to-local-bot-handoff.spec.ts ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
2
+ import { expect, test } from './test'
3
+
4
+ let fixture: MockBackendFixture | null = null
5
+
6
+ async function openBots(page: MockBackendFixture['page']): Promise<void> {
7
+ const tab = page.getByRole('button', { name: 'Bots', exact: true }).or(page.getByRole('tab', { name: 'Bots', exact: true })).first()
8
+ await tab.click()
9
+ await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
10
+ }
11
+
12
+ async function createAgent(page: MockBackendFixture['page'], name: string, title: string): Promise<void> {
13
+ await page.getByRole('button', { name: 'New bot or group chat' }).click()
14
+ await page.getByRole('menuitem', { name: 'New Bot' }).click()
15
+
16
+ const dialog = page.getByRole('dialog', { name: 'New Bot' })
17
+ await dialog.getByPlaceholder('inbox-triage').fill(name)
18
+ await dialog.getByPlaceholder('Inbox Triage').fill(title)
19
+ await dialog.getByRole('button', { name: 'Create Bot' }).click()
20
+ await expect(dialog).toBeHidden({ timeout: 30_000 })
21
+ await expect(page.getByRole('button', { name: new RegExp(`^${title}\\b`) }).first()).toBeVisible({ timeout: 30_000 })
22
+ }
23
+
24
+ test.beforeAll(async () => {
25
+ fixture = await setupMockBackend()
26
+ await waitForAppReady(fixture, 120_000)
27
+ })
28
+
29
+ test.afterAll(async () => {
30
+ await fixture?.cleanup()
31
+ fixture = null
32
+ })
33
+
34
+ test('local bot replaces an open group main workspace', async () => {
35
+ test.setTimeout(240_000)
36
+ const page = fixture!.page
37
+
38
+ await openBots(page)
39
+ await createAgent(page, 'programmer', 'Programmer')
40
+ await createAgent(page, 'reviewer', 'Reviewer')
41
+
42
+ await page.getByRole('button', { name: 'New bot or group chat' }).click()
43
+ await page.getByRole('menuitem', { name: 'New Group Chat' }).click()
44
+
45
+ const dialog = page.getByRole('dialog', { name: 'New Group Chat' })
46
+
47
+ for (const title of ['Programmer', 'Reviewer']) {
48
+ await dialog.getByText(title, { exact: true }).locator('xpath=ancestor::label').getByRole('checkbox').click()
49
+ }
50
+
51
+ await dialog.getByRole('textbox', { name: 'Group name' }).fill('Programmer, Reviewer')
52
+ await dialog.getByRole('button', { name: 'Create Group (2)' }).click()
53
+
54
+ const groupTab = page.getByRole('tab', { name: /Programmer, Reviewer Close/ })
55
+ const groupComposer = page.getByRole('textbox', { name: 'Message Programmer, Reviewer' }).filter({ visible: true })
56
+ await expect(groupTab).toBeVisible({ timeout: 20_000 })
57
+ await expect(groupTab).toHaveAttribute('aria-selected', 'true')
58
+ await expect(groupComposer).toBeVisible()
59
+
60
+ const programmer = page.getByRole('button', { name: /^Programmer\b/ }).filter({ visible: true }).first()
61
+ await programmer.click()
62
+
63
+ // The bot's canonical chat opens INTO the main workspace pane (post
64
+ // design-system rework); as the lone pane in the zone it renders chromeless
65
+ // — no "Bot Chat" tab exists until a second pane joins the strip. The
66
+ // handoff is observed by the group surfaces leaving and the bot's chat
67
+ // (here a fresh one: its empty-state splash asks for a first message)
68
+ // taking the main workspace. The first open also spawns the bot's own
69
+ // backend, so give the "Loading session" phase a real chance to clear.
70
+ await expect(page.getByText('Say something to get started.').filter({ visible: true })).toBeVisible({
71
+ timeout: 120_000
72
+ })
73
+ await expect(groupTab).toHaveCount(0)
74
+ await expect(groupComposer).toHaveCount(0)
75
+ // No "Waking up…" assertion: the mock backend can keep a bot's wake notice
76
+ // around indefinitely (see bot-mode-row-click-mirrors-registry's settle()),
77
+ // so its presence no longer distinguishes a stranded handoff. The splash
78
+ // and composer above are the proof the bot's chat took the workspace.
79
+ await expect(page.locator('[data-slot="composer-root"] [contenteditable="true"]').filter({ visible: true }).first()).toBeVisible()
80
+ })
apps/desktop/e2e/group-turn-integrity.spec.ts ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MOCK_REPLY } from '../../../tests-js/scripts/mock-server'
2
+
3
+ import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
4
+ import { expect, test } from './test'
5
+
6
+ const FIRST_REPLY = 'FIRST_REPLY: initial work completed'
7
+ const FOLLOWUP_REPLY = 'FOLLOWUP_REPLY: subsequent work completed'
8
+ let fixture: MockBackendFixture | null = null
9
+
10
+ async function publicLog(page: MockBackendFixture['page']) {
11
+ return page.evaluate(() => {
12
+ const rooms = JSON.parse(localStorage.getItem('hermes.plugin.hermes-bots.group-chats') || '{}')
13
+
14
+ return (rooms['Programmer, Reviewer']?.log || []).map((entry: any) => ({
15
+ from: entry.from.name, text: entry.text, thread: entry.thread
16
+ })) as { from: string; text: string; thread: string }[]
17
+ })
18
+ }
19
+
20
+ async function openBots(page: MockBackendFixture['page']): Promise<void> {
21
+ const tab = page.getByRole('button', { name: 'Bots', exact: true }).or(page.getByRole('tab', { name: 'Bots', exact: true })).first()
22
+ await tab.click()
23
+ await expect(page.getByRole('button', { name: 'New bot or group chat' })).toBeVisible()
24
+ }
25
+
26
+ async function createAgent(page: MockBackendFixture['page'], name: string, title: string): Promise<void> {
27
+ await page.getByRole('button', { name: 'New bot or group chat' }).click()
28
+ await page.getByRole('menuitem', { name: 'New Bot' }).click()
29
+
30
+ const dialog = page.getByRole('dialog', { name: 'New Bot' })
31
+ await dialog.getByPlaceholder('inbox-triage').fill(name)
32
+ await dialog.getByPlaceholder('Inbox Triage').fill(title)
33
+ await dialog.getByRole('button', { name: 'Create Bot' }).click()
34
+ await expect(dialog).toBeHidden({ timeout: 30_000 })
35
+ await expect(page.getByRole('button', { name: new RegExp(`^${title}\\b`) }).first()).toBeVisible({ timeout: 30_000 })
36
+ }
37
+
38
+ async function createRoom(page: MockBackendFixture['page']) {
39
+ await openBots(page)
40
+ await createAgent(page, 'programmer', 'Programmer')
41
+ await createAgent(page, 'reviewer', 'Reviewer')
42
+
43
+ await page.getByRole('button', { name: 'New bot or group chat' }).click()
44
+ await page.getByRole('menuitem', { name: 'New Group Chat' }).click()
45
+
46
+ const dialog = page.getByRole('dialog', { name: 'New Group Chat' })
47
+
48
+ for (const title of ['Programmer', 'Reviewer']) {
49
+ await dialog.getByText(title, { exact: true }).locator('xpath=ancestor::label').getByRole('checkbox').click()
50
+ }
51
+
52
+ await dialog.getByRole('textbox', { name: 'Group name' }).fill('Programmer, Reviewer')
53
+ await dialog.getByRole('button', { name: 'Create Group (2)' }).click()
54
+
55
+ const groupTab = page.getByRole('tab', { name: /Programmer, Reviewer Close/ })
56
+ const groupComposer = page.getByRole('textbox', { name: 'Message Programmer, Reviewer' }).filter({ visible: true })
57
+ await expect(groupTab).toBeVisible({ timeout: 20_000 })
58
+ await expect(groupTab).toHaveAttribute('aria-selected', 'true')
59
+ await expect(groupComposer).toBeVisible()
60
+
61
+
62
+ return groupComposer
63
+ }
64
+
65
+ test.beforeEach(async () => {
66
+ fixture = await setupMockBackend({ mockServer: {
67
+ holdFirstCompletionContaining: 'LANE_A_FIRST',
68
+ replyForPrompt: prompt => prompt.includes('LANE_A_FOLLOWUP') ? FOLLOWUP_REPLY : prompt.includes('LANE_A_FIRST') ? FIRST_REPLY : MOCK_REPLY
69
+ } })
70
+ await waitForAppReady(fixture, 120_000)
71
+ })
72
+
73
+ test.afterEach(async () => {
74
+ await fixture?.cleanup()
75
+ fixture = null
76
+ })
77
+
78
+ test('group follow-up waits for its active member and retains the reply', async () => {
79
+ test.setTimeout(240_000)
80
+ const page = fixture!.page
81
+ const groupComposer = await createRoom(page)
82
+ await groupComposer.fill('@programmer LANE_A_FIRST')
83
+ await groupComposer.press('Enter')
84
+ await fixture!.mock.waitForHeldCompletion()
85
+ console.log('PRODUCTION CLOCK: first inference held; no second submit before provider release.')
86
+ await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-held.png' })
87
+ await page.getByRole('button', { name: 'Reply in thread', exact: true }).click()
88
+ const replyComposer = page.getByRole('textbox', { name: 'Reply in thread', exact: true })
89
+ await replyComposer.fill('@programmer LANE_A_FOLLOWUP')
90
+ await replyComposer.press('Enter')
91
+ await page.waitForTimeout(3000)
92
+ const overlapping = fixture!.mock.receivedPrompts.filter(p => p.includes('LANE_A_FOLLOWUP'))
93
+ console.log('OVERLAPPING', overlapping)
94
+ fixture!.mock.releaseHeldStream()
95
+ expect(overlapping).toHaveLength(0)
96
+ await expect.poll(() => fixture!.mock.receivedPrompts.some(p => p.includes('LANE_A_FOLLOWUP')), { timeout: 60000 }).toBe(true)
97
+ const delivered = fixture!.mock.receivedPrompts.find(p => p.includes('LANE_A_FOLLOWUP'))!
98
+ expect(delivered).toContain(FIRST_REPLY)
99
+ expect(delivered).not.toContain('LANE_A_FIRST')
100
+ expect(delivered.indexOf('LANE_A_FOLLOWUP')).toBeLessThan(delivered.indexOf(FIRST_REPLY))
101
+ await expect(page.getByText(FIRST_REPLY, { exact: true }).filter({ visible: true })).toHaveCount(1)
102
+ await expect(page.getByText(FOLLOWUP_REPLY, { exact: true }).filter({ visible: true })).toHaveCount(1)
103
+ await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0)
104
+ const log = await publicLog(page)
105
+ expect(log.map(({ from, text }) => [from, text])).toEqual([
106
+ ['You', '@programmer LANE_A_FIRST'], ['You', '@programmer LANE_A_FOLLOWUP'],
107
+ ['programmer', FIRST_REPLY], ['programmer', FOLLOWUP_REPLY]
108
+ ])
109
+ expect(new Set(log.map(entry => entry.thread)).size).toBe(1)
110
+ expect(fixture!.mock.receivedPrompts.filter(p => p.includes('LANE_A_FIRST'))).toHaveLength(1)
111
+ expect(fixture!.mock.receivedPrompts.filter(p => p.includes('LANE_A_FOLLOWUP'))).toHaveLength(1)
112
+ console.log('PRODUCTION CLOCK: released; exact public log', JSON.stringify(log))
113
+ await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-followup-after.png' })
114
+ })
115
+
116
+ test('quiet group still harvests a late answer after sixty observation ticks', async () => {
117
+ test.setTimeout(240_000)
118
+ const page = fixture!.page
119
+ const groupComposer = await createRoom(page)
120
+
121
+ await groupComposer.fill('@programmer LANE_A_FIRST')
122
+ await groupComposer.press('Enter')
123
+ await fixture!.mock.waitForHeldCompletion()
124
+ // Jump only the deadline clock, not WebSocket heartbeats or reconnect timers.
125
+ await page.evaluate(() => {
126
+ const now = Date.now
127
+ Date.now = () => now() + 21 * 60_000
128
+
129
+ const timeout = window.setTimeout.bind(window)
130
+
131
+ ;(window as any).__harvestTicks = 0
132
+ window.setTimeout = ((handler: TimerHandler, delay?: number, ...args: any[]) => {
133
+ if (delay === 5000) {
134
+ return timeout(() => {
135
+ (window as any).__harvestTicks++
136
+
137
+ if (typeof handler === 'function') { handler(...args) }
138
+ }, 100)
139
+ }
140
+
141
+ return timeout(handler, delay, ...args)
142
+ }) as typeof window.setTimeout
143
+ })
144
+ await expect.poll(() => page.evaluate(() => (window as any).__harvestTicks), { timeout: 60000 }).toBeGreaterThanOrEqual(60)
145
+ await new Promise(resolve => setTimeout(resolve, 1500))
146
+ console.log('Harvest ticks before release:', await page.evaluate(() => (window as any).__harvestTicks))
147
+ console.log('Activity before release:', await page.getByRole('button', { name: /^Activity/ }).textContent())
148
+ fixture!.mock.releaseHeldStream()
149
+ await expect(page.getByText(FIRST_REPLY, { exact: true }).filter({ visible: true })).toHaveCount(1, { timeout: 5000 })
150
+ await page.waitForTimeout(1000)
151
+ expect((await publicLog(page)).filter(entry => entry.from === 'programmer').map(entry => entry.text)).toEqual([FIRST_REPLY])
152
+ console.log('ACCELERATED DEADLINE/OBSERVATION CLOCK ONLY: exact late public log', JSON.stringify(await publicLog(page)))
153
+ await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-late-after.png' })
154
+ })
155
+
156
+ test('a rejected member turn stays visible when the room settles', async () => {
157
+ test.setTimeout(240_000)
158
+ const page = fixture!.page
159
+ await page.evaluate(() => {
160
+ const send = WebSocket.prototype.send
161
+
162
+ WebSocket.prototype.send = function(data) {
163
+ const frame = JSON.parse(String(data))
164
+
165
+ if (frame.method === 'prompt.submit' && JSON.stringify(frame.params).includes('LANE_A_FAILURE')) {
166
+ (window as any).__rejected = ((window as any).__rejected || 0) + 1
167
+ const reject = () => this.dispatchEvent(new MessageEvent('message', { data: JSON.stringify({ jsonrpc: '2.0', id: frame.id, error: { code: 4003, message: 'Controlled member admission refusal' } }) }))
168
+
169
+ if ((window as any).__rejected <= 3) { (window as any).__releaseRefusal = reject } else { queueMicrotask(reject) }
170
+ } else {
171
+ send.call(this, data)
172
+ }
173
+ }
174
+ })
175
+ const groupComposer = await createRoom(page)
176
+
177
+ await groupComposer.fill('@programmer LANE_A_FAILURE')
178
+ await groupComposer.press('Enter')
179
+ await expect.poll(() => page.evaluate(() => (window as any).__rejected), { timeout: 30000 }).toBe(1)
180
+ await page.getByRole('button', { name: 'Reply in thread', exact: true }).click()
181
+ const replyComposer = page.getByRole('textbox', { name: 'Reply in thread', exact: true })
182
+ await replyComposer.fill('@programmer prequeued LANE_A_FAILURE')
183
+ await replyComposer.press('Enter')
184
+ await groupComposer.fill('@programmer cross-thread LANE_A_FAILURE')
185
+ await groupComposer.press('Enter')
186
+ console.log('PRODUCTION CLOCK / CONTROLLED TRANSPORT: two sends queued before refusal released')
187
+ await page.evaluate(() => (window as any).__releaseRefusal())
188
+ await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0)
189
+ await page.waitForTimeout(2000)
190
+ expect(await page.evaluate(() => (window as any).__rejected)).toBe(1)
191
+ expect((await publicLog(page)).filter(entry => entry.from !== 'You')).toEqual([])
192
+ await expect(page.getByRole('button', { name: /^Activity/ })).toContainText('Programmer hit an error')
193
+ await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-error-after.png' })
194
+
195
+ // One epoch: A fails, B waits, the user retries A, then B and A fail.
196
+ // The retry is explicit and after A's failure, unlike the prequeued sends above.
197
+ await groupComposer.fill('@programmer LANE_A_FAILURE recency')
198
+ await groupComposer.press('Enter')
199
+ await expect.poll(() => page.evaluate(() => (window as any).__rejected)).toBe(2)
200
+ await groupComposer.fill('@reviewer LANE_A_FAILURE recency')
201
+ await groupComposer.press('Enter')
202
+ await page.evaluate(() => (window as any).__releaseRefusal())
203
+ await expect.poll(() => page.evaluate(() => (window as any).__rejected)).toBe(3)
204
+ await groupComposer.fill('@programmer LANE_A_FAILURE explicit retry')
205
+ await groupComposer.press('Enter')
206
+ await page.evaluate(() => (window as any).__releaseRefusal())
207
+ await expect.poll(() => page.evaluate(() => (window as any).__rejected), { timeout: 30000 }).toBe(4)
208
+ await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0)
209
+ await expect(page.getByRole('button', { name: /^Activity/ })).toContainText('Programmer hit an error')
210
+ console.log('CONTROLLED REFUSAL: A failed, B failed, A failed again; newest unresolved summary:', await page.getByRole('button', { name: /^Activity/ }).textContent())
211
+ })
212
+
213
+ test('Stop clears a queued follow-up and a direct mention resumes the held member', async () => {
214
+ test.setTimeout(240_000)
215
+ const page = fixture!.page
216
+ const groupComposer = await createRoom(page)
217
+ await groupComposer.fill('@programmer LANE_A_FIRST')
218
+ await groupComposer.press('Enter')
219
+ await fixture!.mock.waitForHeldCompletion()
220
+ await page.getByRole('button', { name: 'Reply in thread', exact: true }).click()
221
+ const replyComposer = page.getByRole('textbox', { name: 'Reply in thread', exact: true })
222
+ await replyComposer.fill('@programmer LANE_A_CANCELLED')
223
+ await replyComposer.press('Enter')
224
+ await page.getByRole('button', { name: 'Stop', exact: true }).click()
225
+ fixture!.mock.releaseHeldStream()
226
+ await expect(page.getByRole('button', { name: 'Stop', exact: true })).toHaveCount(0)
227
+ await page.waitForTimeout(2000)
228
+ expect(fixture!.mock.receivedPrompts.some(p => p.includes('LANE_A_CANCELLED'))).toBe(false)
229
+ console.log('Stop: queued inference count = 0; paused status:', await page.getByText(/Paused:/).allTextContents())
230
+ await groupComposer.fill('@programmer LANE_A_RESUME')
231
+ await groupComposer.press('Enter')
232
+ await expect.poll(() => fixture!.mock.receivedPrompts.some(p => p.includes('LANE_A_RESUME')), { timeout: 60000 }).toBe(true)
233
+ await expect(page.getByText(MOCK_REPLY, { exact: true }).first()).toBeVisible()
234
+ await page.screenshot({ path: '/tmp/botmode-campaign/lane-a-stop-resume.png' })
235
+ })
apps/desktop/e2e/hidden-history-messages.spec.ts ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * E2E regression: desktop resume must hide agent-only transcript rows.
3
+ *
4
+ * Compaction handoffs are active user rows because the model needs them for
5
+ * context continuity. They are not authored chat content, so the desktop
6
+ * transcript must never display them after a real compressor-generated resume.
7
+ */
8
+
9
+ import * as fs from 'node:fs'
10
+ import * as path from 'node:path'
11
+
12
+ import {
13
+ buildAppEnv,
14
+ createSandbox,
15
+ launchDesktop,
16
+ type MockBackendFixture,
17
+ waitForAppReady,
18
+ writeEnvFile,
19
+ writeMockProviderConfig,
20
+ } from './fixtures'
21
+ import {
22
+ MOCK_REPLY,
23
+ startMockServer,
24
+ VERIFICATION_STOP_TEXT,
25
+ VERIFICATION_STOP_TRIGGER,
26
+ } from '../../../tests-js/scripts/mock-server'
27
+ import { RealSessionBuilder } from './real-session-builder'
28
+ import { expect, test } from './test'
29
+
30
+ const SESSION_TITLE = 'E2E Hidden History Messages'
31
+ const VISIBLE_USER_TEXT = 'E2E_VISIBLE_USER_HISTORY'
32
+ const VISIBLE_POST_COMPACTION_TEXT = 'E2E_VISIBLE_POST_COMPACTION_HISTORY'
33
+ const COMPACTION_TRIGGER_PADDING = ' force real context compression'.repeat(600)
34
+
35
+ async function setupSeededMockBackend(): Promise<MockBackendFixture> {
36
+ const mock = await startMockServer()
37
+ const sandbox = createSandbox('hidden-history')
38
+ writeMockProviderConfig(sandbox.hermesHome, mock.url)
39
+ fs.appendFileSync(
40
+ path.join(sandbox.hermesHome, 'config.yaml'),
41
+ '\ncompression:\n threshold_tokens: 1\n',
42
+ 'utf8',
43
+ )
44
+ writeEnvFile(sandbox.hermesHome)
45
+ const builder = await RealSessionBuilder.start(sandbox.hermesHome)
46
+
47
+ try {
48
+ await builder.createSession({
49
+ title: SESSION_TITLE,
50
+ turns: [
51
+ `${VISIBLE_USER_TEXT}${COMPACTION_TRIGGER_PADDING}`,
52
+ VISIBLE_POST_COMPACTION_TEXT,
53
+ ],
54
+ })
55
+ } finally {
56
+ await builder.close()
57
+ }
58
+
59
+ const { app, page } = await launchDesktop(buildAppEnv(sandbox))
60
+
61
+ return {
62
+ app,
63
+ page,
64
+ mock,
65
+ mockUrl: mock.url,
66
+ sandbox,
67
+ cleanup: async () => {
68
+ await app.close().catch(() => undefined)
69
+ await mock.close()
70
+ sandbox.cleanup()
71
+ },
72
+ }
73
+ }
74
+
75
+ test('resume hides real context-compaction handoffs', async ({}, testInfo) => {
76
+ const fixture = await setupSeededMockBackend()
77
+
78
+ try {
79
+ const { page } = fixture
80
+ await waitForAppReady(fixture, 120_000)
81
+
82
+ const sessionRow = page
83
+ .locator('[data-slot="sidebar"] button')
84
+ .filter({ hasText: SESSION_TITLE })
85
+ .first()
86
+
87
+ await sessionRow.click()
88
+
89
+ const transcript = page.locator('[data-slot="aui_thread-viewport"]')
90
+ await expect(transcript).toContainText(VISIBLE_USER_TEXT)
91
+ await expect(transcript).toContainText(VISIBLE_POST_COMPACTION_TEXT)
92
+ await expect(transcript).toContainText(MOCK_REPLY)
93
+ await expect(transcript).not.toContainText('[CONTEXT COMPACTION — REFERENCE ONLY]')
94
+ await page.screenshot({ path: testInfo.outputPath('hidden-history-resume.png') })
95
+ } finally {
96
+ await fixture.cleanup()
97
+ }
98
+ })
99
+
100
+ test('live verify-on-stop continuations stay out of the transcript', async ({}, testInfo) => {
101
+ const sandbox = createSandbox('live-verification-nudge')
102
+ const projectRoot = path.join(sandbox.root, 'project')
103
+ const changedFile = path.join(projectRoot, 'e2e-verification-target.py')
104
+ fs.mkdirSync(projectRoot)
105
+ fs.writeFileSync(
106
+ path.join(projectRoot, 'pyproject.toml'),
107
+ '[project]\nname = "e2e-verification-project"\nversion = "0.0.0"\n',
108
+ 'utf8',
109
+ )
110
+
111
+ const mock = await startMockServer({ verificationWritePath: changedFile })
112
+ writeMockProviderConfig(sandbox.hermesHome, mock.url)
113
+ fs.appendFileSync(path.join(sandbox.hermesHome, 'config.yaml'), '\nagent:\n verify_on_stop: true\n', 'utf8')
114
+ // Auto session titling (feat f726090d48) fires an auxiliary title_generation
115
+ // LLM call whose user snippet CONTAINS the trigger keyword, so the mock's
116
+ // isVerificationStopTrigger matches it and the title call steals a scripted
117
+ // verify-on-stop turn (the transcript then ends on 'The code edit is
118
+ // complete.' instead of the exhausted-verifier final). Disable the
119
+ // model-backed title upgrade so script indices track real chat turns.
120
+ fs.appendFileSync(
121
+ path.join(sandbox.hermesHome, 'config.yaml'),
122
+ '\nauxiliary:\n title_generation:\n enabled: false\n',
123
+ 'utf8',
124
+ )
125
+ writeEnvFile(sandbox.hermesHome)
126
+ const { app, page } = await launchDesktop(buildAppEnv(sandbox))
127
+
128
+ const fixture: MockBackendFixture = {
129
+ app,
130
+ page,
131
+ mock,
132
+ mockUrl: mock.url,
133
+ sandbox,
134
+ cleanup: async () => {
135
+ await app.close().catch(() => undefined)
136
+ await mock.close()
137
+ sandbox.cleanup()
138
+ },
139
+ }
140
+
141
+ try {
142
+ await waitForAppReady(fixture, 120_000)
143
+ const composer = page.locator('[contenteditable="true"]').first()
144
+ await composer.click()
145
+ await composer.type(VERIFICATION_STOP_TRIGGER)
146
+ await page.keyboard.press('Enter')
147
+
148
+ const transcript = page.locator('[data-slot="aui_thread-viewport"]')
149
+ await expect(transcript).toContainText(VERIFICATION_STOP_TEXT, { timeout: 60_000 })
150
+ await expect.poll(
151
+ () => mock.receivedPrompts.some(prompt => prompt.includes('[System: You edited code in this turn')),
152
+ { timeout: 30_000 },
153
+ ).toBe(true)
154
+ expect(fs.existsSync(changedFile), 'The scripted write_file call should edit only the sandbox project').toBe(true)
155
+ await expect(transcript).not.toContainText('[System: You edited code in this turn')
156
+ await page.screenshot({ path: testInfo.outputPath('live-verification-nudge.png') })
157
+ } finally {
158
+ await fixture.cleanup()
159
+ }
160
+ })
apps/desktop/e2e/image-attachment-resume.spec.ts ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Regression coverage for an attached image in a durable session. The gateway
3
+ * persists the turn, the builder exits, and desktop renders it from SessionDB
4
+ * for the first time — the "quit and relaunch" case, where the transcript used
5
+ * to come back as vision-enrichment prose instead of a thumbnail.
6
+ *
7
+ * The fixture pins `image_input_mode: native` because that is the majority
8
+ * routing path (any vision-capable model) and the one where a text-only
9
+ * persist override is silently dropped. The image also sits behind directory
10
+ * and file names containing spaces, mirroring the macOS composer's
11
+ * `~/Library/Application Support/...` staging path.
12
+ */
13
+
14
+ import * as fs from 'node:fs'
15
+ import * as path from 'node:path'
16
+
17
+ import {
18
+ buildAppEnv,
19
+ createSandbox,
20
+ launchDesktop,
21
+ type Sandbox,
22
+ waitForAppReady,
23
+ writeEnvFile,
24
+ writeMockProviderConfig,
25
+ } from './fixtures'
26
+ import { type MockServer, startMockServer } from '../../../tests-js/scripts/mock-server'
27
+ import { RealSessionBuilder } from './real-session-builder'
28
+ import { type ElectronApplication, expect, type Page, test } from './test'
29
+
30
+ // The builder-provided title now labels the sidebar row directly (seeded
31
+ // sessions no longer fall back to the first-user-message preview).
32
+ const SESSION_TITLE = 'E2E attached image session'
33
+ const CAPTION = 'E2E attached image must survive a relaunch'
34
+ const IMAGE_DIR = 'Application Support/e2e shots'
35
+ const IMAGE_NAME = 'e2e capture.png'
36
+ const NATIVE_IMAGE_CONFIG = 'agent:\n image_input_mode: native'
37
+
38
+ /** A 160x100 framed magenta block — small, but visible in the screenshots. */
39
+ const PNG_BASE64 =
40
+ 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABkCAIAAACO1KzYAAAA30lEQVR42u3dwQ2AIBAAQTAWAx1iBXYI7diCuWhEMvP2dZsj+CL30hLr2oxAYARGYARGYARGYIERGIH53n7nozpOk5rQqIcNdkQjMAIjMNPeomP3N54V+5exwY5oBEZgBEZgBEZggREYgREYgREYgQVGYARGYARGYARGYIERGIERGIERGIEFRmAERmAERmAEFhiBERiBERiBERiBBUZgBEZgBEZgBBYYgREYgREYgRFYYCMQGIERmPSj94Njb9ligxEYgRFYYJaQe2mmYIMRGIERGIERGIEFRmAERmDedAFtjAtAGWDnoAAAAABJRU5ErkJggg=='
41
+
42
+ interface SeededFixture {
43
+ app: ElectronApplication
44
+ mock: MockServer
45
+ page: Page
46
+ sandbox: Sandbox
47
+ cleanup: () => Promise<void>
48
+ }
49
+
50
+ function writeImage(sandbox: Sandbox): string {
51
+ const dir = path.join(sandbox.root, IMAGE_DIR)
52
+ fs.mkdirSync(dir, { recursive: true })
53
+
54
+ const imagePath = path.join(dir, IMAGE_NAME)
55
+ fs.writeFileSync(imagePath, Buffer.from(PNG_BASE64, 'base64'))
56
+
57
+ return imagePath
58
+ }
59
+
60
+ async function setupSeededDesktop(): Promise<SeededFixture> {
61
+ const mock = await startMockServer()
62
+ const sandbox = createSandbox('image-attachment')
63
+ writeMockProviderConfig(sandbox.hermesHome, mock.url, undefined, NATIVE_IMAGE_CONFIG)
64
+ writeEnvFile(sandbox.hermesHome)
65
+
66
+ const builder = await RealSessionBuilder.start(sandbox.hermesHome)
67
+
68
+ try {
69
+ await builder.createSession({
70
+ title: SESSION_TITLE,
71
+ turns: [{ images: [writeImage(sandbox)], text: CAPTION }],
72
+ })
73
+ } finally {
74
+ await builder.close()
75
+ }
76
+
77
+ const { app, page } = await launchDesktop(buildAppEnv(sandbox))
78
+
79
+ return {
80
+ app,
81
+ mock,
82
+ page,
83
+ sandbox,
84
+ cleanup: async () => {
85
+ await app.close().catch(() => undefined)
86
+ await mock.close()
87
+ sandbox.cleanup()
88
+ },
89
+ }
90
+ }
91
+
92
+ function sessionRow(page: Page) {
93
+ return page.locator('[data-slot="sidebar"] button').filter({ hasText: SESSION_TITLE }).first()
94
+ }
95
+
96
+ // Inactive tabs stay mounted under a data-pane-hidden ancestor. Match the
97
+ // renderer's keep-alive visibility policy instead of relying on DOM order.
98
+ const SURFACE = '[data-composer-target]:not([data-pane-hidden] [data-composer-target])'
99
+
100
+ function activeViewportText(surfaceSelector: string): string {
101
+ const surfaces = document.querySelectorAll(surfaceSelector)
102
+
103
+ return surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? ''
104
+ }
105
+
106
+ async function openSeededSession(page: Page): Promise<void> {
107
+ const row = sessionRow(page)
108
+ await row.waitFor({ state: 'visible', timeout: 60_000 })
109
+ await row.click()
110
+ await page.waitForFunction(
111
+ ([expected, surfaceSelector]: [string, string]) => {
112
+ const surfaces = document.querySelectorAll(surfaceSelector)
113
+ const text = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? ''
114
+
115
+ return text.includes(expected)
116
+ },
117
+ [CAPTION, SURFACE] as [string, string],
118
+ { timeout: 30_000 },
119
+ )
120
+ }
121
+
122
+ /**
123
+ * The sidebar "+" opens a NEW TAB beside the current chat instead of replacing
124
+ * it, so the seeded session stays mounted in its own surface. Assert the new
125
+ * surface is empty rather than waiting for the old caption to leave the page.
126
+ */
127
+ async function openNewSession(page: Page): Promise<void> {
128
+ await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
129
+ await page.waitForFunction(
130
+ ([expected, surfaceSelector]: [string, string]) => {
131
+ const surfaces = document.querySelectorAll(surfaceSelector)
132
+ const text = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? ''
133
+
134
+ return surfaces.length > 0 && !text.includes(expected)
135
+ },
136
+ [CAPTION, SURFACE] as [string, string],
137
+ { timeout: 15_000 },
138
+ )
139
+ }
140
+
141
+ async function transcriptText(page: Page): Promise<string> {
142
+ return page.evaluate(activeViewportText, SURFACE)
143
+ }
144
+
145
+ async function assertRendersThumbnail(page: Page, label: string): Promise<void> {
146
+ const thumbnail = page.locator('[data-slot="aui_directive-image"] img')
147
+ await expect(thumbnail, `${label}: the attachment should render as an image`).toHaveCount(1)
148
+ await expect(thumbnail, `${label}: the thumbnail should resolve off disk`).toHaveAttribute('src', /^data:image\//)
149
+
150
+ const text = await transcriptText(page)
151
+ expect(text, `${label}: the caption should survive alongside the image`).toContain(CAPTION)
152
+ // A broken ref falls back to a chip whose label leaks the path, and a
153
+ // flattened multimodal turn leaves the agent's placeholder behind.
154
+ expect(text, `${label}: the raw image path should not leak into the transcript`).not.toContain(IMAGE_NAME)
155
+ expect(text, `${label}: the image directive should not render literally`).not.toContain('@image:')
156
+ expect(text, `${label}: the flattening placeholder should not render`).not.toContain('[screenshot]')
157
+ }
158
+
159
+ test.describe('attached image resume', () => {
160
+ let fixture: SeededFixture | null = null
161
+
162
+ test.afterEach(async () => {
163
+ await fixture?.cleanup()
164
+ fixture = null
165
+ })
166
+
167
+ test('renders a persisted attachment as a thumbnail on first open and after a cold reload', async ({}, testInfo) => {
168
+ // Seeding through the real gateway plus two full app boots does not fit the
169
+ // default per-test budget on a cold runner.
170
+ test.slow()
171
+
172
+ fixture = await setupSeededDesktop()
173
+ await waitForAppReady(fixture, 120_000)
174
+
175
+ // The sidebar labels a seeded session by its title. Whatever the label
176
+ // source, an attachment directive must never leak into it as a file path.
177
+ const row = sessionRow(fixture.page)
178
+ await row.waitFor({ state: 'visible', timeout: 60_000 })
179
+
180
+ const label = (await row.textContent())?.trim() ?? ''
181
+ expect(label.startsWith(SESSION_TITLE), `sidebar label should open with the title: ${label}`).toBe(true)
182
+ expect(label, `sidebar label should not leak the image path: ${label}`).not.toContain(IMAGE_NAME)
183
+ expect(label, `sidebar label should not render the directive: ${label}`).not.toContain('@image:')
184
+
185
+ await openSeededSession(fixture.page)
186
+ await assertRendersThumbnail(fixture.page, 'first open')
187
+ await fixture.page.screenshot({ path: testInfo.outputPath('attachment-first-open.png') })
188
+
189
+ // A reload drops every cached attachment ref, so the transcript has to come
190
+ // back from the persisted turn alone.
191
+ await fixture.page.reload()
192
+ await waitForAppReady(fixture, 120_000)
193
+ await openNewSession(fixture.page)
194
+
195
+ await openSeededSession(fixture.page)
196
+ await assertRendersThumbnail(fixture.page, 'cold reload')
197
+ await fixture.page.screenshot({ path: testInfo.outputPath('attachment-cold-reload.png') })
198
+ })
199
+ })
apps/desktop/e2e/interim-messages.spec.ts ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * E2E test for the interim-assistant-message preservation fix (#65919).
3
+ *
4
+ * Reproduces the bug across all three layers (agent core → tui_gateway →
5
+ * desktop renderer): when the agent emits assistant text alongside a tool
6
+ * call, then completes the turn with a *different* final answer, the
7
+ * interim text must survive in the transcript — not be wiped when
8
+ * message.complete replaces the streaming bubble.
9
+ *
10
+ * The mock server walks through a multi-turn script when it sees the
11
+ * trigger keyword:
12
+ *
13
+ * Turn 1: "Let me start by planning the approach." + todo tool_call
14
+ * Turn 2: "Now checking the details before answering." + todo tool_call
15
+ * Turn 3: (no text) + todo tool_call → NO interim (no visible text)
16
+ * Turn 4: "Found something interesting worth noting." + todo tool_call
17
+ * Turn 5: "All done! Here is the complete summary..." (final, stop)
18
+ *
19
+ * Two describe blocks exercise the config flag both ways:
20
+ *
21
+ * display.interim_assistant_messages: true (default)
22
+ * → ALL interim texts AND the final text must be visible in the
23
+ * settled transcript.
24
+ *
25
+ * display.interim_assistant_messages: false
26
+ * → no message.interim events are emitted, so no sealed interim bubbles
27
+ * are created while streaming. Since the post-turn stored-history
28
+ * reconcile (sessions.changed → reconcileActiveTranscript, commit
29
+ * 1a2b0ca8cb) converges the visible transcript to the persisted
30
+ * transcript — which has ALWAYS contained the mid-turn commentary as
31
+ * real assistant rows (that is what a resume shows, flag or no flag) —
32
+ * the settled DOM shows the whole turn as ONE assistant message
33
+ * containing commentary + final. The flag governs live sealing only.
34
+ * The test pins that converged single-message shape: every text
35
+ * appears exactly once, inside a single assistant message root.
36
+ *
37
+ * Prerequisite: `npm run build` must have been run so dist/ exists.
38
+ */
39
+
40
+ import { expect, type Page, test } from '@playwright/test'
41
+
42
+ import {
43
+ type MockBackendFixture,
44
+ setupMockBackend,
45
+ waitForAppReady,
46
+ } from './fixtures'
47
+ import { INTERIM_TEXTS, restartMockServer } from '../../../tests-js/scripts/mock-server'
48
+
49
+ // ─── Helpers ──────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Auto session titling (feat f726090d48, 2026-08-08) issues an auxiliary
53
+ * `title_generation` LLM call against the SAME provider as the chat turn.
54
+ * The mock server counts every completion request as a script turn, so the
55
+ * title call races the chat turn and steals a scripted interim turn (the
56
+ * stolen turn's text then never streams to the transcript). Disable the
57
+ * model-backed title upgrade — the instant derived title needs no LLM call —
58
+ * so the mock's script indices line up with real chat turns again.
59
+ */
60
+ const DISABLE_AUTO_TITLE = 'auxiliary:\n title_generation:\n enabled: false'
61
+
62
+ /** Unique trigger keyword the mock server detects to switch to the script. */
63
+ const TRIGGER = 'E2E_INTERIM_TRIGGER'
64
+
65
+ /**
66
+ * Send a message and wait for BOTH the user's message and the agent's
67
+ * final response to appear in the transcript. Returns when the final text
68
+ * is visible, which means message.complete has fired and the transcript
69
+ * has settled.
70
+ */
71
+ async function sendInterimMessage(page: Page): Promise<void> {
72
+ const composer = page.locator('[contenteditable="true"]').first()
73
+ await composer.waitFor({ state: 'visible', timeout: 10_000 })
74
+ await composer.click()
75
+ await composer.type(TRIGGER, { delay: 20 })
76
+ await page.keyboard.press('Enter')
77
+
78
+ // Wait for the user's trigger message to appear.
79
+ await page.waitForFunction(
80
+ () => (document.body.textContent ?? '').includes('E2E_INTERIM_TRIGGER'),
81
+ undefined,
82
+ { timeout: 15_000 },
83
+ )
84
+
85
+ // Wait for the agent's FINAL response (last turn). This means
86
+ // message.complete has fired and the transcript is settled.
87
+ await page.waitForFunction(
88
+ (finalText) => (document.body.textContent ?? '').includes(finalText),
89
+ INTERIM_TEXTS.finalText,
90
+ { timeout: 90_000 },
91
+ )
92
+
93
+ // Give the renderer a moment to settle any final state updates
94
+ // (hydration, stored-history reconcile, session refresh) before asserting.
95
+ await page.waitForTimeout(2000)
96
+ }
97
+
98
+ /**
99
+ * Count how many times `text` appears as distinct text in the chat transcript
100
+ * (excluding the session sidebar, whose session-preview label shows the
101
+ * first streamed text as a title).
102
+ *
103
+ * The desktop app renders the transcript inside a
104
+ * `[data-slot="aui_thread-viewport"]` container (from @assistant-ui/react).
105
+ * The session sidebar's preview labels live outside that container, so
106
+ * scoping the DOM walk to the viewport cleanly excludes them.
107
+ */
108
+ async function countTranscriptMessagesContaining(page: Page, text: string): Promise<number> {
109
+ return page.evaluate(
110
+ (search) => {
111
+ const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
112
+
113
+ if (!viewport) {
114
+ return 0
115
+ }
116
+
117
+ let count = 0
118
+
119
+ const walker = document.createTreeWalker(
120
+ viewport,
121
+ NodeFilter.SHOW_ELEMENT,
122
+ {
123
+ acceptNode: (node) => {
124
+ const el = node as HTMLElement
125
+ const directText = el.textContent ?? ''
126
+
127
+ if (!directText.includes(search)) {
128
+ return NodeFilter.FILTER_SKIP
129
+ }
130
+
131
+ // Only count leaf-ish elements to avoid double-counting.
132
+ const hasChildWithText = Array.from(el.children).some(
133
+ (child) => (child.textContent ?? '').includes(search),
134
+ )
135
+
136
+ if (hasChildWithText) {
137
+ return NodeFilter.FILTER_SKIP
138
+ }
139
+
140
+ return NodeFilter.FILTER_ACCEPT
141
+ },
142
+ },
143
+ )
144
+
145
+ while (walker.nextNode()) {
146
+ count++
147
+ }
148
+
149
+ return count
150
+ },
151
+ text,
152
+ )
153
+ }
154
+
155
+ /** Count assistant message roots in the settled transcript. */
156
+ async function countAssistantMessageRoots(page: Page): Promise<number> {
157
+ return page.evaluate(() => {
158
+ const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
159
+
160
+ return viewport
161
+ ? viewport.querySelectorAll('[data-slot="aui_assistant-message-root"]').length
162
+ : 0
163
+ })
164
+ }
165
+
166
+ // ─── Flag ON: interim_assistant_messages = true (default) ─────────────
167
+
168
+ test.describe('interim assistant messages — flag ON (default)', () => {
169
+ test.describe.configure({ mode: 'serial' })
170
+
171
+ let fixture: MockBackendFixture
172
+
173
+ test.beforeAll(async () => {
174
+ restartMockServer()
175
+ fixture = await setupMockBackend({ extraConfig: DISABLE_AUTO_TITLE })
176
+ await waitForAppReady(fixture, 120_000)
177
+ })
178
+
179
+ test.afterAll(async () => {
180
+ await fixture?.cleanup()
181
+ })
182
+
183
+ test('all interim texts survive alongside the final response', async () => {
184
+ const page = fixture.page
185
+ await sendInterimMessage(page)
186
+
187
+ // Every interim text (turns with visible text + tool calls) must be
188
+ // present in the settled transcript — NOT wiped by message.complete.
189
+ // (Live, each seals as its own bubble; the post-turn stored-history
190
+ // reconcile then converges the turn into one assistant message that
191
+ // still carries all of them.)
192
+ for (const interimText of INTERIM_TEXTS.interims) {
193
+ await expect
194
+ .poll(
195
+ () => countTranscriptMessagesContaining(page, interimText),
196
+ { timeout: 15_000, message: `interim text "${interimText}" should be visible` },
197
+ )
198
+ .toBeGreaterThanOrEqual(1)
199
+ }
200
+
201
+ // The final text must also be visible.
202
+ await expect
203
+ .poll(
204
+ () => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText),
205
+ { timeout: 15_000, message: 'final text should be visible' },
206
+ )
207
+ .toBeGreaterThanOrEqual(1)
208
+
209
+ // No duplicates: the reconcile must CONVERGE (replace the sealed live
210
+ // bubbles), never render a stored copy alongside a live one.
211
+ for (const text of [...INTERIM_TEXTS.interims, INTERIM_TEXTS.finalText]) {
212
+ const count = await countTranscriptMessagesContaining(page, text)
213
+ expect(count, `"${text}" must not be duplicated after reconcile`).toBe(1)
214
+ }
215
+ })
216
+ })
217
+
218
+ // ─── Flag OFF: interim_assistant_messages = false ────────────────────
219
+
220
+ test.describe('interim assistant messages — flag OFF', () => {
221
+ test.describe.configure({ mode: 'serial' })
222
+
223
+ let fixture: MockBackendFixture
224
+
225
+ test.beforeAll(async () => {
226
+ restartMockServer()
227
+ fixture = await setupMockBackend({
228
+ extraDisplayConfig: ' interim_assistant_messages: false',
229
+ extraConfig: DISABLE_AUTO_TITLE,
230
+ })
231
+ await waitForAppReady(fixture, 120_000)
232
+ })
233
+
234
+ test.afterAll(async () => {
235
+ await fixture?.cleanup()
236
+ })
237
+
238
+ test('settled transcript converges to stored history as a single turn message', async () => {
239
+ const page = fixture.page
240
+ await sendInterimMessage(page)
241
+
242
+ // The final text must be visible.
243
+ await expect
244
+ .poll(
245
+ () => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText),
246
+ { timeout: 15_000, message: 'final text should be visible' },
247
+ )
248
+ .toBeGreaterThanOrEqual(1)
249
+
250
+ // With the flag off, the tui_gateway never installs
251
+ // interim_assistant_callback, so no message.interim events fire and no
252
+ // sealed interim bubbles are created while streaming. After
253
+ // message.complete, the stored-history reconcile (sessions.changed →
254
+ // reconcileActiveTranscript) converges the view to the persisted
255
+ // transcript, which contains the mid-turn commentary as real assistant
256
+ // rows — exactly what a resume of this session would show. Pin that
257
+ // converged shape: ONE assistant message root for the whole turn…
258
+ await expect
259
+ .poll(
260
+ () => countAssistantMessageRoots(page),
261
+ { timeout: 15_000, message: 'the settled turn should render as one assistant message' },
262
+ )
263
+ .toBe(1)
264
+
265
+ // …containing every commentary text and the final text exactly once.
266
+ for (const text of [...INTERIM_TEXTS.interims, INTERIM_TEXTS.finalText]) {
267
+ await expect
268
+ .poll(
269
+ () => countTranscriptMessagesContaining(page, text),
270
+ { timeout: 15_000, message: `"${text}" should appear exactly once in the converged turn` },
271
+ )
272
+ .toBe(1)
273
+ }
274
+ })
275
+ })
apps/desktop/e2e/large-session-resume.spec.ts ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as path from 'node:path'
2
+
3
+ import { type TestInfo } from '@playwright/test'
4
+
5
+ import { expect, test, type ElectronApplication, type Page } from './test'
6
+
7
+ import {
8
+ buildAppEnv,
9
+ createSandbox,
10
+ launchDesktop,
11
+ type Sandbox,
12
+ waitForAppReady,
13
+ writeEnvFile,
14
+ writeMockProviderConfig,
15
+ } from './fixtures'
16
+ import { MOCK_REPLY, startMockServer, type MockServer, type MockServerOptions } from '../../../tests-js/scripts/mock-server'
17
+ import { RealSessionBuilder } from './real-session-builder'
18
+
19
+ const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
20
+ const SESSION_TITLE = 'E2E large persisted session'
21
+ const EXPECTED_TEXT = 'E2E persisted user message 52'
22
+ // The oldest seeded turn (HISTORY_TURNS[0]). The transcript first paints only
23
+ // the newest turns (FIRST_PAINT_BUDGET) and backfills the rest in a rAF; a
24
+ // baseline count taken before that backfill sees a clipped transcript and
25
+ // falsely reports duplicates once the full list mounts. Waiting for this
26
+ // oldest row means the baseline reflects the fully-mounted transcript.
27
+ const OLDEST_SEEDED_TEXT = 'E2E persisted user message 0: audit the compatibility matrix'
28
+ const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume'
29
+ const HISTORY_TURNS = Array.from(
30
+ { length: 27 },
31
+ (_, index) => `E2E persisted user message ${index * 2}: audit the compatibility matrix`,
32
+ )
33
+
34
+ interface SeededFixture {
35
+ app: ElectronApplication
36
+ mock: MockServer
37
+ mockUrl: string
38
+ page: Page
39
+ sandbox: Sandbox
40
+ cleanup: () => Promise<void>
41
+ }
42
+
43
+ interface PaintState {
44
+ bursts: number
45
+ timeline: Array<{ mutations: number; time: number }>
46
+ }
47
+
48
+ async function setupSeededDesktop(mockServer?: MockServerOptions): Promise<SeededFixture> {
49
+ const mock = await startMockServer(mockServer)
50
+ const sandbox = createSandbox('large-session')
51
+ writeMockProviderConfig(sandbox.hermesHome, mock.url)
52
+ writeEnvFile(sandbox.hermesHome)
53
+
54
+ const builder = await RealSessionBuilder.start(sandbox.hermesHome)
55
+ try {
56
+ await builder.createSession({ title: SESSION_TITLE, turns: HISTORY_TURNS })
57
+ } finally {
58
+ await builder.close()
59
+ }
60
+
61
+ const { app, page } = await launchDesktop(buildAppEnv(sandbox))
62
+
63
+ return {
64
+ app,
65
+ mock,
66
+ mockUrl: mock.url,
67
+ page,
68
+ sandbox,
69
+ cleanup: async () => {
70
+ await app.close().catch(() => undefined)
71
+ await mock.close()
72
+ sandbox.cleanup()
73
+ },
74
+ }
75
+ }
76
+
77
+ function sessionRow(page: Page) {
78
+ return page.locator('[data-slot="sidebar"] button').filter({ hasText: SESSION_TITLE }).first()
79
+ }
80
+
81
+ async function openSeededSession(page: Page): Promise<void> {
82
+ const row = sessionRow(page)
83
+ await row.waitFor({ state: 'visible', timeout: 60_000 })
84
+ await row.click()
85
+ await page.waitForFunction(
86
+ expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
87
+ EXPECTED_TEXT,
88
+ { timeout: 30_000 },
89
+ )
90
+ }
91
+
92
+ async function openNewSession(page: Page): Promise<void> {
93
+ const button = page.locator('[data-slot="sidebar"] button').filter({ hasText: 'New session' }).first()
94
+ await button.waitFor({ state: 'visible', timeout: 10_000 })
95
+ await button.click()
96
+ await page.waitForFunction(
97
+ expected => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
98
+ EXPECTED_TEXT,
99
+ { timeout: 15_000 },
100
+ )
101
+ }
102
+
103
+ async function submitPrompt(page: Page, prompt: string): Promise<void> {
104
+ const composer = page.locator('[contenteditable="true"]').first()
105
+ await composer.waitFor({ state: 'visible', timeout: 15_000 })
106
+ await composer.click()
107
+ await composer.type(prompt, { delay: 2 })
108
+ await page.keyboard.press('Enter')
109
+ await page.waitForFunction(
110
+ expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
111
+ prompt,
112
+ { timeout: 15_000 },
113
+ )
114
+ }
115
+
116
+ async function startPaintObserver(page: Page): Promise<void> {
117
+ await page.evaluate(() => {
118
+ const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
119
+ const state = { bursts: 0, timeline: [] as Array<{ mutations: number; time: number }> }
120
+ ;(window as Window & { __largeSessionPaints?: typeof state }).__largeSessionPaints = state
121
+ if (!viewport) return
122
+
123
+ let additions = 0
124
+ let flushTimer: ReturnType<typeof setTimeout> | undefined
125
+ new MutationObserver(records => {
126
+ additions += records.reduce(
127
+ (count, record) => count + (record.type === 'childList' && record.addedNodes.length > 0 ? 1 : 0),
128
+ 0,
129
+ )
130
+ if (additions === 0) return
131
+ if (flushTimer) clearTimeout(flushTimer)
132
+ flushTimer = setTimeout(() => {
133
+ state.bursts += 1
134
+ state.timeline.push({ mutations: additions, time: Date.now() })
135
+ additions = 0
136
+ }, 30)
137
+ }).observe(viewport, { childList: true, subtree: true })
138
+ })
139
+ }
140
+
141
+ async function paintState(page: Page): Promise<PaintState> {
142
+ const state = await page.evaluate(() => (window as Window & { __largeSessionPaints?: PaintState }).__largeSessionPaints)
143
+ expect(state, 'paint observer should attach to the thread viewport').toBeDefined()
144
+ return state!
145
+ }
146
+
147
+ async function textNodeOccurrences(page: Page, expected: string): Promise<number> {
148
+ return page.evaluate(text => {
149
+ const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
150
+ if (!viewport) return 0
151
+
152
+ const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT)
153
+ let count = 0
154
+ while (walker.nextNode()) {
155
+ if (walker.currentNode.textContent?.includes(text)) {
156
+ count += 1
157
+ }
158
+ }
159
+ return count
160
+ }, expected)
161
+ }
162
+
163
+ async function reloadIntoColdRenderer(fixture: SeededFixture): Promise<void> {
164
+ await fixture.page.reload()
165
+ await waitForAppReady(fixture, 120_000)
166
+ await openNewSession(fixture.page)
167
+ }
168
+
169
+ async function assertUnchangedResume(page: Page, testInfo: TestInfo): Promise<void> {
170
+ await openSeededSession(page)
171
+ await page.waitForTimeout(1_000)
172
+ await page.screenshot({ path: testInfo.outputPath('unchanged-session-resume.png'), fullPage: false })
173
+
174
+ const paints = await paintState(page)
175
+ expect(await textNodeOccurrences(page, EXPECTED_TEXT), 'the resumed user message should appear once').toBe(1)
176
+ // A warm session first restores its retained view, then reconciles it with the
177
+ // authoritative transcript. That is bounded at two builds; a third paint was
178
+ // the old eager-prefetch + runtime-rebuild regression. A cold restore has one.
179
+ expect(paints.bursts, `unexpected transcript paint count: ${JSON.stringify(paints.timeline)}`).toBeLessThanOrEqual(2)
180
+ }
181
+
182
+ test.describe('large session resume', () => {
183
+ let fixture: SeededFixture | null = null
184
+
185
+ test.afterEach(async () => {
186
+ await fixture?.cleanup()
187
+ fixture = null
188
+ })
189
+
190
+ test('cold resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => {
191
+ fixture = await setupSeededDesktop()
192
+ await waitForAppReady(fixture, 120_000)
193
+
194
+ await startPaintObserver(fixture.page)
195
+ await assertUnchangedResume(fixture.page, testInfo)
196
+ })
197
+
198
+ test('fast resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => {
199
+ // Known RED: a rapid warm resume rebuilds the transcript three times
200
+ // (28 → 53 → 53 DOM additions) instead of the two-paint budget. Keep the
201
+ // regression visible without making unrelated desktop work fail CI.
202
+ test.fixme(true, 'Fast warm resume has an unresolved third transcript rebuild')
203
+
204
+ fixture = await setupSeededDesktop()
205
+ await waitForAppReady(fixture, 120_000)
206
+
207
+ await openSeededSession(fixture.page)
208
+ await openNewSession(fixture.page)
209
+ await startPaintObserver(fixture.page)
210
+ await assertUnchangedResume(fixture.page, testInfo)
211
+ })
212
+
213
+ for (const resumeKind of ['fast', 'cold'] as const) {
214
+ test(`${resumeKind} resume keeps background inference attached without duplicate messages`, async ({}, testInfo) => {
215
+ fixture = await setupSeededDesktop({ holdFirstStreamForPrompt: BACKGROUND_PROMPT })
216
+ await waitForAppReady(fixture, 120_000)
217
+
218
+ await openSeededSession(fixture.page)
219
+ // The transcript first paints only the newest turns (FIRST_PAINT_BUDGET)
220
+ // and backfills older turns in a rAF. Wait for the oldest seeded row to
221
+ // mount before taking the baseline so it reflects the full transcript —
222
+ // otherwise a clipped baseline makes the backfilled rows look like
223
+ // duplicates of the completed reply.
224
+ await fixture.page.waitForFunction(
225
+ expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
226
+ OLDEST_SEEDED_TEXT,
227
+ { timeout: 30_000 },
228
+ )
229
+ const initialMockReplyCount = await textNodeOccurrences(fixture.page, MOCK_REPLY)
230
+ await submitPrompt(fixture.page, BACKGROUND_PROMPT)
231
+ await fixture.mock.waitForHeldStream()
232
+ await openNewSession(fixture.page)
233
+
234
+ if (resumeKind === 'cold') {
235
+ await reloadIntoColdRenderer(fixture)
236
+ }
237
+
238
+ await openSeededSession(fixture.page)
239
+ fixture.mock.releaseHeldStream()
240
+ await fixture.page.waitForFunction(
241
+ expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
242
+ MOCK_REPLY,
243
+ { timeout: 60_000 },
244
+ )
245
+ await fixture.page.waitForTimeout(300)
246
+ await fixture.page.screenshot({ path: testInfo.outputPath(`${resumeKind}-background-inference-resume.png`), fullPage: false })
247
+
248
+ expect(await textNodeOccurrences(fixture.page, BACKGROUND_PROMPT), 'the running user prompt should appear once').toBe(1)
249
+ expect(
250
+ await textNodeOccurrences(fixture.page, MOCK_REPLY),
251
+ 'the completed assistant reply should add exactly one transcript row',
252
+ ).toBe(initialMockReplyCount + 1)
253
+ })
254
+ }
255
+ })
apps/desktop/e2e/launch-packaged-app.spec.ts ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { expect, test } from './test'
2
+
3
+ import {
4
+ PACKAGED_BINARY_PATH,
5
+ type PackagedAppFixture,
6
+ packagedBinaryExists,
7
+ setupPackagedApp,
8
+ } from './fixtures'
9
+ import { expectVisualSnapshot } from './visual-snapshot'
10
+
11
+ /**
12
+ * E2E smoke tests for the packaged Hermes desktop app.
13
+ *
14
+ * Launches the real packaged Electron binary (produced by `npm run pack` →
15
+ * `electron-builder --dir`) with BOOT_FAKE=1 and full sandbox isolation
16
+ * (credential stripping, isolated HERMES_HOME + userData, unique app name).
17
+ *
18
+ * Skips if the packaged binary doesn't exist — run `npm run pack` first.
19
+ */
20
+
21
+ let fixture: PackagedAppFixture | null = null
22
+
23
+ test.beforeAll(async () => {
24
+ test.skip(
25
+ !packagedBinaryExists(),
26
+ `Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
27
+ )
28
+
29
+ fixture = await setupPackagedApp()
30
+ })
31
+
32
+ test.afterAll(async () => {
33
+ await fixture?.cleanup()
34
+ fixture = null
35
+ })
36
+
37
+ test('window opens with the Hermes title', async () => {
38
+ const title = await fixture!.page.title()
39
+ expect(title).toContain('Hermes')
40
+ })
41
+
42
+ test('renderer loads and shows DOM content', async () => {
43
+ const page = fixture!.page
44
+ await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 })
45
+ const childCount = await page.locator('#root > *').count()
46
+ expect(childCount).toBeGreaterThan(0)
47
+ })
48
+
49
+ test('boots to the app UI, not the QueryClient error boundary (#95560)', async () => {
50
+ const page = fixture!.page
51
+ await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 })
52
+
53
+ // Wait until the root has real content (boot overlay fades, app paints) —
54
+ // the error boundary also paints, so assert on its absence explicitly.
55
+ await page.waitForFunction(
56
+ () => (document.getElementById('root')?.textContent ?? '').trim().length > 0,
57
+ undefined,
58
+ { timeout: 60_000 },
59
+ )
60
+
61
+ const text = await page.locator('#root').textContent()
62
+ // The #95560 crash: a duplicate @tanstack/react-query runtime made the
63
+ // QueryClientProvider's context invisible to useQuery, so the app hit the
64
+ // error boundary at launch. Neither the boundary headline nor the throw
65
+ // message may appear on a healthy boot.
66
+ expect(text).not.toContain('No QueryClient set')
67
+ expect(text).not.toContain('Something broke in the interface')
68
+ })
69
+
70
+ test('HUD composer remains fully inside the transparent window', async () => {
71
+ const hudPagePromise = fixture!.app.waitForEvent('window')
72
+
73
+ await fixture!.page.evaluate(() =>
74
+ (window as typeof window & {
75
+ hermesDesktop?: { hud?: { open: (options: { sessionId: null }) => Promise<void> } }
76
+ }).hermesDesktop?.hud?.open({ sessionId: null })
77
+ )
78
+
79
+ const hudPage = await hudPagePromise
80
+ await hudPage.waitForSelector('[data-slot="composer-rich-input"]', { state: 'visible' })
81
+
82
+ const geometry = await hudPage.evaluate(() => {
83
+ const dock = document.querySelector<HTMLElement>('[data-slot="composer-dock"]')
84
+ const input = document.querySelector<HTMLElement>('[data-slot="composer-rich-input"]')
85
+
86
+ if (!dock || !input) {
87
+ throw new Error('HUD composer did not render')
88
+ }
89
+
90
+ const dockRect = dock.getBoundingClientRect()
91
+ const inputRect = input.getBoundingClientRect()
92
+
93
+ return {
94
+ viewportWidth: window.innerWidth,
95
+ viewportHeight: window.innerHeight,
96
+ dockLeft: dockRect.left,
97
+ dockRight: dockRect.right,
98
+ dockTop: dockRect.top,
99
+ dockBottom: dockRect.bottom,
100
+ inputLeft: inputRect.left,
101
+ inputRight: inputRect.right,
102
+ inputTop: inputRect.top,
103
+ inputBottom: inputRect.bottom,
104
+ // The bug class this guards: a build-time CSS optimization folding the
105
+ // dock's identity `translate` override into `transform`, leaving
106
+ // Tailwind's standalone `translate: -50%` live and shifting the dock
107
+ // half a window off-screen. Surface the computed value so a failure
108
+ // says WHY the dock moved, not just that it did.
109
+ dockTranslate: getComputedStyle(dock).translate,
110
+ }
111
+ })
112
+
113
+ // Horizontal containment — the composer shifted half a window left when the
114
+ // standalone `translate: -50%` survived optimization (#82214, #82233).
115
+ expect(geometry.dockLeft).toBeGreaterThanOrEqual(0)
116
+ expect(geometry.inputLeft).toBeGreaterThanOrEqual(0)
117
+ expect(geometry.dockRight).toBeLessThanOrEqual(geometry.viewportWidth)
118
+ expect(geometry.inputRight).toBeLessThanOrEqual(geometry.viewportWidth)
119
+
120
+ // Vertical containment — the toolbar/transcript clipping reported on
121
+ // Windows (#82203) and macOS (#82214) is the same "composer escapes the
122
+ // window" class on the other axis.
123
+ expect(geometry.dockTop).toBeGreaterThanOrEqual(0)
124
+ expect(geometry.inputTop).toBeGreaterThanOrEqual(0)
125
+ expect(geometry.dockBottom).toBeLessThanOrEqual(geometry.viewportHeight)
126
+ expect(geometry.inputBottom).toBeLessThanOrEqual(geometry.viewportHeight)
127
+
128
+ // The dock's centering translate must be fully neutralized. Any live
129
+ // percentage translate means the HUD override lost to the app's centering.
130
+ // (Computed `translate` keeps percentages as-is, so this is assertable;
131
+ // computed `transform` resolves to a matrix and is covered by the
132
+ // geometric containment checks above.)
133
+ expect(geometry.dockTranslate ?? 'none').not.toContain('%')
134
+
135
+ await hudPage.close()
136
+ })
137
+
138
+ test('boot progress overlay fades out or shows error state', async () => {
139
+ const page = fixture!.page
140
+ await page.waitForFunction(
141
+ () => {
142
+ const root = document.getElementById('root')
143
+
144
+ if (!root) {
145
+ return false
146
+ }
147
+
148
+ const text = root.textContent ?? ''
149
+
150
+ // Error path: boot failure overlay renders an error message.
151
+ if (text.includes('error') || text.includes('Error') || text.includes('failed')) {
152
+ return true
153
+ }
154
+
155
+ // Success path: overlay disappears and the app renders. If there's
156
+ // no "boot" / "starting" / "installing" text visible, boot has
157
+ // completed (either to the main UI or to onboarding).
158
+ const bootIndicators = ['starting', 'resolving', 'spawning', 'waiting', 'installing']
159
+ const lower = text.toLowerCase()
160
+
161
+ return !bootIndicators.some((word) => lower.includes(word))
162
+ },
163
+ undefined,
164
+ { timeout: 60_000 },
165
+ )
166
+ })
167
+
168
+ test('can capture a screenshot for the CI artifact', async () => {
169
+ if (!fixture) {
170
+ test.skip(true, 'Previous test failed — no app running')
171
+
172
+ return
173
+ }
174
+
175
+ // Visual snapshot — won't fail on diff, just logs + generates diff image
176
+ await expectVisualSnapshot(fixture!.page, { name: 'packaged-app-booted', timeout: 10_000, app: fixture!.app })
177
+ })
apps/desktop/e2e/mock-backend-setup.spec.ts ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * E2E tests asserting the mock backend gets the app past the setup/onboarding
3
+ * screen.
4
+ *
5
+ * The mock backend fixture writes a config.yaml with a pre-configured mock
6
+ * provider pointing at a mock inference server. When the app boots, the
7
+ * runtime readiness check should detect the working provider and dismiss the
8
+ * onboarding overlay — landing straight on the chat UI without ever showing
9
+ * the "Let's get you setup with Hermes Agent" screen.
10
+ *
11
+ * If these tests fail, the mock backend config isn't getting the app past
12
+ * onboarding — the chat interaction tests (chat.spec.ts) will also fail
13
+ * because the composer is blocked by the setup overlay.
14
+ *
15
+ * Prerequisite: `npm run build` must have been run so dist/ exists.
16
+ */
17
+
18
+ import { expect, test } from './test'
19
+
20
+ import {
21
+ type MockBackendFixture,
22
+ setupMockBackend,
23
+ waitForAppReady,
24
+ } from './fixtures'
25
+ import { expectVisualSnapshot } from './visual-snapshot'
26
+
27
+ let fixture: MockBackendFixture | null = null
28
+
29
+ test.beforeAll(async () => {
30
+ fixture = await setupMockBackend()
31
+ await waitForAppReady(fixture!, 120_000)
32
+ })
33
+
34
+ test.afterAll(async () => {
35
+ await fixture?.cleanup()
36
+ fixture = null
37
+ })
38
+
39
+ test.describe('mock backend gets past setup screen', () => {
40
+ test('onboarding overlay is not shown', async () => {
41
+ const page = fixture!.page
42
+
43
+ // The onboarding overlay renders "Let's get you setup with Hermes Agent"
44
+ // when the runtime check fails to find a working provider. With the mock
45
+ // backend configured, the runtime check should pass and the overlay
46
+ // returns null — this text should NOT be present in the DOM.
47
+ await page.waitForFunction(
48
+ () => {
49
+ const text = document.body.textContent ?? ''
50
+
51
+ return !text.includes("Let's get you setup")
52
+ },
53
+ undefined,
54
+ { timeout: 30_000 },
55
+ )
56
+ })
57
+
58
+ test('chat composer is visible', async () => {
59
+ const page = fixture!.page
60
+
61
+ // The composer (contenteditable div) should be visible and not blocked
62
+ // by the onboarding overlay. If the first test passed, the overlay is
63
+ // gone and the composer is the primary interactive surface.
64
+ const composer = page.locator('[contenteditable="true"]').first()
65
+ await expect(composer).toBeVisible()
66
+ })
67
+
68
+ test('can type into the composer', async () => {
69
+ const page = fixture!.page
70
+
71
+ // If the setup overlay is truly gone, the composer accepts input.
72
+ const composer = page.locator('[contenteditable="true"]').first()
73
+ await composer.click()
74
+ await composer.type('hello mock backend', { delay: 20 })
75
+
76
+ // Verify the typed text appears in the DOM.
77
+ await page.waitForFunction(
78
+ () => (document.body.textContent ?? '').includes('hello mock backend'),
79
+ undefined,
80
+ { timeout: 10_000 },
81
+ )
82
+ })
83
+
84
+ test('screenshot shows chat UI without setup screen', async () => {
85
+ await expectVisualSnapshot(fixture!.page, { name: 'mock-backend-chat-ready', app: fixture!.app })
86
+ })
87
+ })
apps/desktop/e2e/onboarding-settings.spec.ts ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readFileSync, unlinkSync, writeFileSync } from 'node:fs'
2
+ import { createRequire } from 'node:module'
3
+ import path from 'node:path'
4
+
5
+ import { buildAppEnv, createSandbox, launchDesktop, setupNoProvider } from './fixtures'
6
+ import { type ElectronApplication, expect, type Page, test } from './test'
7
+
8
+ const { prepareWindowForInput } = createRequire(import.meta.url)(
9
+ '../../../tests/install/e2e-assets/window-input.cjs',
10
+ ) as { prepareWindowForInput: (app: ElectronApplication, page: Page) => Promise<void> }
11
+
12
+ test('input setup survives a fresh-install zoom restore before onboarding', async () => {
13
+ const sandbox = createSandbox('cold-input')
14
+ unlinkSync(path.join(sandbox.userDataDir, 'zoom-state.json'))
15
+ writeFileSync(path.join(sandbox.hermesHome, 'config.yaml'), '# no provider\n', 'utf8')
16
+ let app: ElectronApplication | undefined
17
+
18
+ try {
19
+ const launched = await launchDesktop(buildAppEnv(sandbox))
20
+ app = launched.app
21
+ const page = launched.page
22
+ await page.waitForSelector('button', { state: 'attached' })
23
+ await prepareWindowForInput(app, page)
24
+ const later = page.getByRole('button', { name: /choose a provider later/i })
25
+ await expect(later).toBeVisible({ timeout: 60_000 })
26
+ const appWindow = await app.browserWindow(page)
27
+ await appWindow.evaluate(win => win.emit('focus'))
28
+ await expect.poll(() => appWindow.evaluate(win => win.webContents.getZoomFactor())).toBeCloseTo(1)
29
+ await later.click({ timeout: 5_000 })
30
+ await expect(later).toBeHidden()
31
+ } finally {
32
+ await app?.close().catch(() => undefined)
33
+ sandbox.cleanup()
34
+ }
35
+ })
36
+
37
+ // Exercise the install driver's input setup against the real renderer/backend,
38
+ // with no installer, update, credentials, or live user data.
39
+ for (const lifecycleEvent of ['focus', 'navigation'] as const) {
40
+ test(`onboarding input zoom survives ${lifecycleEvent} and opens Settings`, async () => {
41
+ const fixture = await setupNoProvider()
42
+ const { app, page, sandbox } = fixture
43
+
44
+ try {
45
+ await prepareWindowForInput(app, page)
46
+ const later = page.getByRole('button', { name: /choose a provider later/i })
47
+ await expect(later).toBeVisible({ timeout: 60_000 })
48
+ const zoomFile = path.join(sandbox.userDataDir, 'zoom-state.json')
49
+ const savedLevel = () => JSON.parse(readFileSync(zoomFile, 'utf8')).zoomLevel as number
50
+ await page.evaluate(() => {
51
+ const desktop = (window as unknown as { hermesDesktop: { zoom: { setPercent: (percent: number) => void } } }).hermesDesktop
52
+ desktop.zoom.setPercent(90)
53
+ })
54
+ await expect.poll(savedLevel).toBeCloseTo(Math.log(0.9) / Math.log(1.2))
55
+
56
+ await prepareWindowForInput(app, page)
57
+ const appWindow = await app.browserWindow(page)
58
+
59
+ // The same lifecycle callback that fires when another window takes focus
60
+ // must restore our input scale, not the original 90% preference.
61
+ if (lifecycleEvent === 'focus') {
62
+ await appWindow.evaluate(win => win.emit('focus'))
63
+ } else {
64
+ await page.evaluate(() => { window.location.hash = '#/settings' })
65
+ }
66
+
67
+ await expect.poll(() => appWindow.evaluate(win => win.webContents.getZoomFactor())).toBeCloseTo(1)
68
+ expect(savedLevel()).toBe(0)
69
+ await later.click({ timeout: 5_000 })
70
+ await expect(later).toBeHidden()
71
+
72
+ if (lifecycleEvent === 'navigation') {
73
+ await page.evaluate(() => { window.location.hash = '#/' })
74
+ }
75
+
76
+ await page.getByRole('button', { name: 'Open settings', exact: true }).click({ timeout: 5_000 })
77
+ await expect(page).toHaveURL(/settings/)
78
+ } finally {
79
+ await fixture.cleanup()
80
+ }
81
+ })
82
+ }
apps/desktop/e2e/onboarding.spec.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * E2E onboarding tests — verify the provider picker appears when no
3
+ * inference provider is configured.
4
+ *
5
+ * Launches the app with an empty config.yaml (no providers). The renderer
6
+ * should detect the unconfigured state and show the DesktopOnboardingOverlay
7
+ * with provider options / API key form.
8
+ *
9
+ * Prerequisite: `npm run build` must have been run so dist/ exists.
10
+ */
11
+
12
+ import { expect, test } from './test'
13
+
14
+ import {
15
+ type NoProviderFixture,
16
+ setupNoProvider,
17
+ waitForOnboarding,
18
+ } from './fixtures'
19
+ import { expectVisualSnapshot } from './visual-snapshot'
20
+
21
+ let fixture: NoProviderFixture | null = null
22
+
23
+ test.afterAll(async () => {
24
+ await fixture?.cleanup()
25
+ fixture = null
26
+ })
27
+
28
+ test.describe('onboarding with no provider configured', () => {
29
+ test('onboarding overlay appears on first boot', async () => {
30
+ fixture = await setupNoProvider()
31
+
32
+ // The app should boot (hermes serve starts fine even without a provider),
33
+ // but the renderer should show the onboarding overlay because no
34
+ // provider is configured.
35
+ await waitForOnboarding(fixture.page, 90_000)
36
+ })
37
+
38
+ test('onboarding shows provider options or API key form', async () => {
39
+ if (!fixture) {
40
+ test.skip(true, 'Previous test failed — no app running')
41
+
42
+ return
43
+ }
44
+
45
+ const page = fixture.page
46
+
47
+ // The onboarding overlay should contain provider-related text.
48
+ // It might show OAuth providers, an API key form, or a "choose later"
49
+ // link. Verify at least one of these is visible.
50
+ const rootText = await page.evaluate(() => {
51
+ const root = document.getElementById('root')
52
+
53
+ return root?.textContent ?? ''
54
+ })
55
+
56
+ const hasProviderText =
57
+ rootText.includes('provider') ||
58
+ rootText.includes('Provider') ||
59
+ rootText.includes('API key') ||
60
+ rootText.includes('Sign in') ||
61
+ rootText.includes('OpenRouter') ||
62
+ rootText.includes('OpenAI')
63
+
64
+ expect(hasProviderText).toBe(true)
65
+ })
66
+
67
+ test('screenshot of onboarding overlay', async () => {
68
+ if (!fixture) {
69
+ test.skip(true, 'Previous test failed — no app running')
70
+
71
+ return
72
+ }
73
+
74
+ await expectVisualSnapshot(fixture.page, { name: 'onboarding-overlay', app: fixture.app })
75
+ })
76
+ })
apps/desktop/e2e/queue-turn-boundary.spec.ts ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * A queued prompt must remain local until the current inference turn settles.
3
+ *
4
+ * Hold the first streamed reply open after its first token. This gives the
5
+ * composer a live, busy turn while the user queues a follow-up, then lets us
6
+ * assert against the mock provider's real request log before and after the
7
+ * held turn completes.
8
+ */
9
+
10
+ import { expect, test, type Page } from './test'
11
+
12
+ import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
13
+ import { MOCK_REPLY } from '../../../tests-js/scripts/mock-server'
14
+
15
+ const ACTIVE_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_ACTIVE'
16
+ const QUEUED_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_QUEUED'
17
+ const STEER_PROMPT = 'E2E_STEER_TURN_BOUNDARY_CORRECTION'
18
+
19
+ async function send(page: Page, text: string): Promise<void> {
20
+ const composer = page.locator('[contenteditable="true"]').first()
21
+ await composer.waitFor({ state: 'visible', timeout: 15_000 })
22
+ await composer.click()
23
+ await composer.type(text, { delay: 5 })
24
+ await page.keyboard.press('Enter')
25
+ }
26
+
27
+ async function steer(page: Page, text: string): Promise<void> {
28
+ const composer = page.locator('[contenteditable="true"]').first()
29
+
30
+ await composer.click()
31
+ await composer.type(text, { delay: 5 })
32
+ await page.keyboard.press('Enter')
33
+ }
34
+
35
+ async function queue(page: Page, text: string): Promise<void> {
36
+ const composer = page.locator('[contenteditable="true"]').first()
37
+
38
+ await composer.click()
39
+ await composer.type(text, { delay: 5 })
40
+ await page.keyboard.press('Control+Enter')
41
+ }
42
+
43
+ async function transcriptMessageOrder(page: Page): Promise<string[]> {
44
+ return page.evaluate(() => {
45
+ const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
46
+ if (!viewport) return []
47
+
48
+ return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="user"], [data-role="assistant"]'))
49
+ .map(message => message.textContent?.trim() ?? '')
50
+ .filter(Boolean)
51
+ })
52
+ }
53
+
54
+ function steerTurnOrder(messages: string[]): string[] {
55
+ return messages.flatMap(message => {
56
+ if (message.includes(ACTIVE_PROMPT)) return [ACTIVE_PROMPT]
57
+ if (message.includes(STEER_PROMPT)) return [STEER_PROMPT]
58
+ if (message.includes(MOCK_REPLY)) return [MOCK_REPLY]
59
+
60
+ return []
61
+ })
62
+ }
63
+
64
+ test.describe('queued prompt turn boundary', () => {
65
+ let fixture: MockBackendFixture | null = null
66
+
67
+ test.beforeEach(async () => {
68
+ fixture = await setupMockBackend({
69
+ mockServer: { holdFirstStreamForPrompt: ACTIVE_PROMPT }
70
+ })
71
+ await waitForAppReady(fixture, 120_000)
72
+ })
73
+
74
+ test.afterEach(async () => {
75
+ await fixture?.cleanup()
76
+ fixture = null
77
+ })
78
+
79
+ test('submits a queued prompt only after the active turn completes', async () => {
80
+ const { mock, page } = fixture!
81
+
82
+ await send(page, ACTIVE_PROMPT)
83
+ await mock.waitForHeldStream()
84
+ await queue(page, QUEUED_PROMPT)
85
+ await expect(page.getByText('1 Queued')).toBeVisible()
86
+
87
+ // The mock keeps the active SSE stream open, so a queued prompt has no
88
+ // completed-turn boundary that could legitimately drain it. Wait past the
89
+ // queue retry interval and assert the provider saw only the active turn.
90
+ await page.waitForTimeout(1_000)
91
+ expect(mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(0)
92
+ await expect(page.locator('[data-slot="aui_thread-viewport"]')).not.toContainText(QUEUED_PROMPT)
93
+
94
+ mock.releaseHeldStream()
95
+ await page.waitForFunction(
96
+ expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
97
+ MOCK_REPLY,
98
+ { timeout: 60_000 }
99
+ )
100
+ await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(1)
101
+ })
102
+
103
+ test('places a steer prompt before the reply it redirects', async () => {
104
+ const { mock, page } = fixture!
105
+
106
+ await send(page, ACTIVE_PROMPT)
107
+ await mock.waitForHeldStream()
108
+ await steer(page, STEER_PROMPT)
109
+ mock.releaseHeldStream()
110
+
111
+ await page.waitForFunction(
112
+ expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
113
+ MOCK_REPLY,
114
+ { timeout: 60_000 }
115
+ )
116
+
117
+ expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ACTIVE_PROMPT, STEER_PROMPT, MOCK_REPLY])
118
+ })
119
+ })
apps/desktop/e2e/real-session-builder.ts ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process'
2
+ import * as path from 'node:path'
3
+ import { createInterface } from 'node:readline'
4
+
5
+ const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
6
+ const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
7
+ const DEFAULT_TIMEOUT_MS = 60_000
8
+
9
+ interface JsonRpcError {
10
+ code?: number
11
+ message?: string
12
+ }
13
+
14
+ interface JsonRpcFrame {
15
+ error?: JsonRpcError
16
+ id?: number
17
+ method?: string
18
+ params?: {
19
+ payload?: unknown
20
+ session_id?: string
21
+ type?: string
22
+ }
23
+ result?: unknown
24
+ }
25
+
26
+ interface CreatedSession {
27
+ session_id: string
28
+ stored_session_id: string
29
+ }
30
+
31
+ export interface RealSessionTurn {
32
+ /** Local image paths attached before the prompt, as the composer would. */
33
+ images?: readonly string[]
34
+ text: string
35
+ }
36
+
37
+ export interface RealSessionSpec {
38
+ /** Session label. The durable row stores no title, so clients fall back to
39
+ * the preview (the first 60 characters of the first user message). */
40
+ title: string
41
+ /** Each item becomes one real user prompt followed by the mock provider's reply. */
42
+ turns: readonly (RealSessionTurn | string)[]
43
+ }
44
+
45
+ export interface RealSession {
46
+ /** Runtime-only TUI session id, valid only while the builder process is alive. */
47
+ runtimeId: string
48
+ /** Durable SessionDB id that desktop resumes after the builder exits. */
49
+ sessionId: string
50
+ }
51
+
52
+ /**
53
+ * Creates durable desktop session history through the real TUI gateway and
54
+ * AIAgent loop, using the E2E mock provider configured in `hermesHome`.
55
+ *
56
+ * This intentionally uses the shipped stdio JSON-RPC transport instead of
57
+ * importing SessionDB or launching Electron. The desktop's WebSocket backend
58
+ * dispatches the same `tui_gateway.server` methods.
59
+ */
60
+ export class RealSessionBuilder {
61
+ private readonly child: ChildProcessWithoutNullStreams
62
+ private nextRequestId = 0
63
+ private readonly pending = new Map<number, { reject: (reason: Error) => void; resolve: (value: unknown) => void }>()
64
+ private readonly events: JsonRpcFrame[] = []
65
+ private readonly eventWaiters: Array<{
66
+ predicate: (frame: JsonRpcFrame) => boolean
67
+ reject: (reason: Error) => void
68
+ resolve: (frame: JsonRpcFrame) => void
69
+ }> = []
70
+ private readonly stderr: string[] = []
71
+ private closed = false
72
+
73
+ private constructor(hermesHome: string) {
74
+ this.child = spawn('uv', ['run', '--active', '--no-sync', 'python', '-m', 'tui_gateway.entry'], {
75
+ cwd: REPO_ROOT,
76
+ env: {
77
+ ...process.env,
78
+ HERMES_HOME: hermesHome,
79
+ PYTHONPATH: REPO_ROOT,
80
+ },
81
+ stdio: 'pipe',
82
+ })
83
+
84
+ createInterface({ input: this.child.stdout }).on('line', line => this.handleLine(line))
85
+ createInterface({ input: this.child.stderr }).on('line', line => {
86
+ this.stderr.push(line)
87
+ if (this.stderr.length > 80) this.stderr.shift()
88
+ })
89
+ this.child.once('error', error => this.failAll(new Error(`real-session gateway failed to start: ${error.message}`)))
90
+ this.child.once('exit', (code, signal) => {
91
+ if (!this.closed) {
92
+ this.failAll(new Error(`real-session gateway exited unexpectedly (${signal ?? code ?? 'unknown'}):\n${this.stderr.join('\n')}`))
93
+ }
94
+ })
95
+ }
96
+
97
+ static async start(hermesHome: string): Promise<RealSessionBuilder> {
98
+ const builder = new RealSessionBuilder(hermesHome)
99
+ await builder.waitForEvent(frame => frame.params?.type === 'gateway.ready')
100
+ return builder
101
+ }
102
+
103
+ async createSession(spec: RealSessionSpec): Promise<RealSession> {
104
+ if (spec.turns.length === 0) {
105
+ throw new Error('RealSessionBuilder requires at least one turn so the real agent creates a durable session row')
106
+ }
107
+
108
+ const created = await this.request<CreatedSession>('session.create', {
109
+ cols: 120,
110
+ cwd: REPO_ROOT,
111
+ source: 'desktop',
112
+ title: spec.title,
113
+ })
114
+ const runtimeId = requireString(created, 'session_id')
115
+ const sessionId = requireString(created, 'stored_session_id')
116
+
117
+ for (const turn of spec.turns) {
118
+ const { images = [], text } = typeof turn === 'string' ? { text: turn } : turn
119
+
120
+ for (const image of images) {
121
+ await this.request('image.attach', { session_id: runtimeId, path: image })
122
+ }
123
+
124
+ const completion = this.waitForEvent(
125
+ frame => frame.params?.type === 'message.complete' && frame.params.session_id === runtimeId,
126
+ )
127
+ await this.request('prompt.submit', { session_id: runtimeId, text })
128
+ const frame = await completion
129
+ const status = readString(frame.params?.payload, 'status')
130
+ if (status !== 'complete') {
131
+ throw new Error(`real session turn failed with status ${status ?? 'unknown'}: ${JSON.stringify(frame.params?.payload)}`)
132
+ }
133
+ }
134
+
135
+ await this.request('session.close', { session_id: runtimeId })
136
+ return { runtimeId, sessionId }
137
+ }
138
+
139
+ async close(): Promise<void> {
140
+ if (this.closed) return
141
+ this.closed = true
142
+ this.child.stdin.end()
143
+ await new Promise<void>(resolve => {
144
+ const timeout = setTimeout(() => {
145
+ this.child.kill('SIGTERM')
146
+ resolve()
147
+ }, 5_000)
148
+ this.child.once('exit', () => {
149
+ clearTimeout(timeout)
150
+ resolve()
151
+ })
152
+ })
153
+ }
154
+
155
+ private request<T = unknown>(method: string, params: Record<string, unknown>): Promise<T> {
156
+ const id = ++this.nextRequestId
157
+ return this.withTimeout(new Promise<T>((resolve, reject) => {
158
+ this.pending.set(id, { resolve: value => resolve(value as T), reject })
159
+ this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`, error => {
160
+ if (error) {
161
+ this.pending.delete(id)
162
+ reject(error)
163
+ }
164
+ })
165
+ }), `request ${method}`)
166
+ }
167
+
168
+ private waitForEvent(predicate: (frame: JsonRpcFrame) => boolean): Promise<JsonRpcFrame> {
169
+ const index = this.events.findIndex(predicate)
170
+ if (index >= 0) {
171
+ return Promise.resolve(this.events.splice(index, 1)[0])
172
+ }
173
+ return this.withTimeout(new Promise<JsonRpcFrame>((resolve, reject) => {
174
+ this.eventWaiters.push({ predicate, resolve, reject })
175
+ }), 'gateway event')
176
+ }
177
+
178
+ private handleLine(line: string): void {
179
+ let frame: JsonRpcFrame
180
+ try {
181
+ frame = JSON.parse(line) as JsonRpcFrame
182
+ } catch {
183
+ return
184
+ }
185
+
186
+ if (typeof frame.id === 'number') {
187
+ const pending = this.pending.get(frame.id)
188
+ if (!pending) return
189
+ this.pending.delete(frame.id)
190
+ if (frame.error) {
191
+ pending.reject(new Error(`JSON-RPC error ${frame.error.code ?? 'unknown'}: ${frame.error.message ?? 'unknown error'}`))
192
+ } else {
193
+ pending.resolve(frame.result)
194
+ }
195
+ return
196
+ }
197
+
198
+ if (frame.method !== 'event') return
199
+ const waiter = this.eventWaiters.find(candidate => candidate.predicate(frame))
200
+ if (!waiter) {
201
+ this.events.push(frame)
202
+ return
203
+ }
204
+ this.eventWaiters.splice(this.eventWaiters.indexOf(waiter), 1)
205
+ waiter.resolve(frame)
206
+ }
207
+
208
+ private withTimeout<T>(promise: Promise<T>, operation: string): Promise<T> {
209
+ return new Promise<T>((resolve, reject) => {
210
+ 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)
211
+ promise.then(value => {
212
+ clearTimeout(timer)
213
+ resolve(value)
214
+ }, error => {
215
+ clearTimeout(timer)
216
+ reject(error)
217
+ })
218
+ })
219
+ }
220
+
221
+ private failAll(error: Error): void {
222
+ for (const pending of this.pending.values()) pending.reject(error)
223
+ this.pending.clear()
224
+ for (const waiter of this.eventWaiters) waiter.reject(error)
225
+ this.eventWaiters.length = 0
226
+ }
227
+ }
228
+
229
+ function readString(value: unknown, key: string): string | undefined {
230
+ if (!value || typeof value !== 'object') return undefined
231
+ const candidate = (value as Record<string, unknown>)[key]
232
+ return typeof candidate === 'string' ? candidate : undefined
233
+ }
234
+
235
+ function requireString(value: unknown, key: string): string {
236
+ const candidate = readString(value, key)
237
+ if (!candidate) throw new Error(`Gateway response omitted required ${key}: ${JSON.stringify(value)}`)
238
+ return candidate
239
+ }
apps/desktop/e2e/remote-oauth-recovery.spec.ts ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as fs from 'node:fs'
2
+ import * as http from 'node:http'
3
+ import type { AddressInfo } from 'node:net'
4
+ import * as path from 'node:path'
5
+
6
+ import { buildAppEnv, createSandbox, launchDesktop } from './fixtures'
7
+ import { allowErrorBanners, expect, test } from './test'
8
+
9
+ type DesktopWindow = Window & {
10
+ hermesDesktop: {
11
+ getBootProgress: () => Promise<{ running: boolean; retryable?: boolean; statusCode?: number; error?: string }>
12
+ getConnection: () => Promise<unknown>
13
+ }
14
+ }
15
+
16
+ // Real Electron/preload/renderer against a loopback gateway whose session was
17
+ // lost during restart. No real credentials or installed user state are used.
18
+ for (const status of [401, 403]) {
19
+ test(`unsigned gateway ${status} leaves recovery settings usable`, async () => {
20
+ allowErrorBanners()
21
+ const sandbox = createSandbox(`oauth-recovery-${status}`)
22
+ let mints = 0
23
+ let signedIn = false
24
+ const server = http.createServer((req, res) => {
25
+ req.resume()
26
+ req.on('end', () => {
27
+ const pathname = new URL(req.url ?? '/', 'http://localhost').pathname
28
+ res.setHeader('Content-Type', 'application/json')
29
+ if (pathname === '/api/status') {
30
+ res.end(JSON.stringify({ auth_required: true, auth_flows: [], version: 'test' }))
31
+ } else if (pathname === '/api/auth/providers') {
32
+ res.end(JSON.stringify({ providers: [{ name: 'portal', supports_password: false }] }))
33
+ } else if (pathname === '/login') {
34
+ signedIn = true
35
+ res.setHeader('Set-Cookie', 'hermes_session_at=fixture-session; Path=/; HttpOnly; SameSite=Lax')
36
+ res.end('{}')
37
+ } else if (signedIn && pathname === '/api/auth/ws-ticket') {
38
+ mints += 1
39
+ res.end(JSON.stringify({ ticket: `fixture-ticket-${mints}` }))
40
+ } else if (signedIn && pathname === '/api/health') {
41
+ res.end(JSON.stringify({ ok: true, status: 'ok' }))
42
+ } else {
43
+ if (pathname === '/api/auth/ws-ticket') {
44
+ mints += 1
45
+ if (mints === 1) {
46
+ // A NAS restart can truncate a response after headers. Exercise
47
+ // Electron's real IncomingMessage error, then recover on retry.
48
+ res.setHeader('Content-Length', '1024')
49
+ res.write('{"partial":')
50
+ setTimeout(() => res.destroy(), 20)
51
+ return
52
+ }
53
+ }
54
+ res.statusCode = status
55
+ res.end(JSON.stringify({ error: 'unauthenticated', reason: 'no_cookie' }))
56
+ }
57
+ })
58
+ })
59
+ await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
60
+ const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
61
+ fs.writeFileSync(path.join(sandbox.userDataDir, 'connection.json'), JSON.stringify({
62
+ mode: 'remote', remote: { url, authMode: 'oauth' }, profiles: {}
63
+ }))
64
+ let app: Awaited<ReturnType<typeof launchDesktop>>['app'] | undefined
65
+ try {
66
+ const launched = await launchDesktop(buildAppEnv(sandbox, {
67
+ HERMES_DESKTOP_DEV_SERVER: ''
68
+ }))
69
+ app = launched.app
70
+ const page = launched.page
71
+ await expect(page.getByRole('button', { name: /gateway settings/i })).toBeVisible({ timeout: 60_000 })
72
+ const snapshot = await page.evaluate(() => (window as unknown as DesktopWindow).hermesDesktop.getBootProgress())
73
+ expect(snapshot).toMatchObject({ running: false, retryable: false, statusCode: status })
74
+ expect(snapshot.error).toMatch(/not signed in/)
75
+ expect(mints).toBeGreaterThan(0)
76
+ const settledMints = mints
77
+ await page.getByRole('button', { name: /gateway settings/i }).click()
78
+ const back = page.getByRole('button', { name: /^back$/i })
79
+ await expect(back).toBeVisible()
80
+ const gatewayUrl = page.getByPlaceholder('https://gateway.example.com/hermes')
81
+ await expect(gatewayUrl).toHaveValue(url)
82
+ // Concurrent IPC readers must reuse the terminal failure, not republish
83
+ // startup progress and unmount the settings form.
84
+ await page.evaluate(async () => {
85
+ await Promise.allSettled(Array.from({ length: 20 }, () => (window as unknown as DesktopWindow).hermesDesktop.getConnection()))
86
+ })
87
+ await expect(back).toBeVisible()
88
+ await expect(gatewayUrl).toHaveValue(url)
89
+ expect(mints).toBe(settledMints)
90
+ await page.screenshot({ path: test.info().outputPath(`gateway-settings-${status}.png`) })
91
+ await back.click()
92
+ const signIn = page.getByRole('button', { name: /sign in/i })
93
+ await expect(signIn).toBeEnabled()
94
+ await signIn.click()
95
+ await expect.poll(() => signedIn).toBe(true)
96
+ await expect.poll(async () => {
97
+ try {
98
+ return await page.evaluate(() => (window as unknown as DesktopWindow).hermesDesktop.getConnection())
99
+ } catch {
100
+ return null
101
+ }
102
+ }).toMatchObject({ mode: 'remote', baseUrl: url })
103
+ expect(mints).toBeGreaterThan(settledMints)
104
+ } finally {
105
+ await app?.close()
106
+ server.closeAllConnections()
107
+ await new Promise<void>(resolve => server.close(() => resolve()))
108
+ sandbox.cleanup()
109
+ }
110
+ })
111
+ }
apps/desktop/e2e/right-pane.spec.ts ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test, expect } from './test'
2
+
3
+ import {
4
+ type MockBackendFixture,
5
+ setupMockBackend,
6
+ waitForAppReady,
7
+ } from './fixtures'
8
+
9
+ let fixture: MockBackendFixture | null = null
10
+
11
+ test.beforeAll(async () => {
12
+ fixture = await setupMockBackend()
13
+ await waitForAppReady(fixture, 120_000)
14
+ })
15
+
16
+ test.afterAll(async () => {
17
+ await fixture?.cleanup()
18
+ fixture = null
19
+ })
20
+
21
+ test('persistent terminal overlay follows the pane after split dragging', async () => {
22
+ const page = fixture!.page
23
+
24
+ await page.keyboard.press('Control+`')
25
+ await page.locator('[data-terminal-slot]').waitFor({ state: 'visible', timeout: 30_000 })
26
+ await page.locator('[data-persistent-terminal] .xterm').waitFor({ state: 'visible', timeout: 30_000 })
27
+
28
+ const result = await page.evaluate(async () => {
29
+ const slot = document.querySelector('[data-terminal-slot]')
30
+ const overlay = document.querySelector('[data-persistent-terminal]')
31
+
32
+ if (!slot || !overlay) {
33
+ return { drift: -1, moved: 0, target: false }
34
+ }
35
+
36
+ const before = slot.getBoundingClientRect()
37
+ const target = [...document.querySelectorAll<HTMLElement>('[role="separator"]')]
38
+ .map(element => {
39
+ const box = element.getBoundingClientRect()
40
+ const horizontal = box.width > box.height
41
+ const center = horizontal
42
+ ? (box.top + box.bottom) / 2
43
+ : (box.left + box.right) / 2
44
+ const sides = horizontal
45
+ ? [before.top, before.bottom]
46
+ : [before.left, before.right]
47
+
48
+ return {
49
+ element,
50
+ box,
51
+ horizontal,
52
+ score: Math.min(...sides.map(side => Math.abs(center - side))),
53
+ }
54
+ })
55
+ .filter(item => item.box.width > 0 && item.box.height > 0)
56
+ .sort((a, b) => a.score - b.score)[0]
57
+
58
+ if (!target) {
59
+ return { drift: -1, moved: 0, target: false }
60
+ }
61
+
62
+ const x = target.box.left + target.box.width / 2
63
+ const y0 = target.box.top + target.box.height / 2
64
+ const nearestSide = target.horizontal
65
+ ? Math.abs(y0 - before.top) < Math.abs(y0 - before.bottom)
66
+ ? 'top'
67
+ : 'bottom'
68
+ : Math.abs(x - before.left) < Math.abs(x - before.right)
69
+ ? 'left'
70
+ : 'right'
71
+ const deltaX = nearestSide === 'left' ? -1 : nearestSide === 'right' ? 1 : 0
72
+ const deltaY = nearestSide === 'top' ? -1 : nearestSide === 'bottom' ? 1 : 0
73
+ let currentX = x
74
+ let y = y0
75
+ const pointer = {
76
+ bubbles: true,
77
+ cancelable: true,
78
+ pointerId: 71,
79
+ pointerType: 'mouse',
80
+ isPrimary: true,
81
+ button: 0,
82
+ buttons: 1,
83
+ }
84
+
85
+ target.element.dispatchEvent(
86
+ new PointerEvent('pointerdown', { ...pointer, clientX: x, clientY: y }),
87
+ )
88
+
89
+ for (let index = 0; index < 24; index += 1) {
90
+ currentX += deltaX
91
+ y += deltaY
92
+ window.dispatchEvent(
93
+ new PointerEvent('pointermove', {
94
+ ...pointer,
95
+ clientX: currentX,
96
+ clientY: y,
97
+ }),
98
+ )
99
+ await new Promise<void>(resolve => requestAnimationFrame(() => resolve()))
100
+ }
101
+
102
+ window.dispatchEvent(
103
+ new PointerEvent('pointerup', {
104
+ ...pointer,
105
+ buttons: 0,
106
+ clientX: currentX,
107
+ clientY: y,
108
+ }),
109
+ )
110
+ await new Promise<void>(resolve => setTimeout(resolve, 350))
111
+ await new Promise<void>(resolve =>
112
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
113
+ )
114
+
115
+ const next = slot.getBoundingClientRect()
116
+ const fixed = overlay.getBoundingClientRect()
117
+
118
+ return {
119
+ drift: Math.max(
120
+ Math.abs(next.top - fixed.top),
121
+ Math.abs(next.left - fixed.left),
122
+ Math.abs(next.width - fixed.width),
123
+ Math.abs(next.height - fixed.height),
124
+ ),
125
+ moved: Math.max(
126
+ Math.abs(next.top - before.top),
127
+ Math.abs(next.left - before.left),
128
+ Math.abs(next.width - before.width),
129
+ Math.abs(next.height - before.height),
130
+ ),
131
+ target: true,
132
+ }
133
+ })
134
+
135
+ expect(result.target).toBe(true)
136
+ expect(result.moved).toBeGreaterThan(10)
137
+ expect(result.drift).toBeLessThanOrEqual(1)
138
+ })
apps/desktop/e2e/session-compression-and-queue-stop.spec.ts ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * E2E coverage for session compression, which rotates a live backend session.
3
+ */
4
+
5
+ import { expect, test, type Page } from '@playwright/test'
6
+
7
+ import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
8
+ import { MOCK_REPLY, receivedUserTexts, restartMockServer } from '../../../tests-js/scripts/mock-server'
9
+
10
+ async function send(page: Page, text: string, delay = 15): Promise<void> {
11
+ const composer = page.locator('[contenteditable="true"]').first()
12
+ await composer.click()
13
+ await composer.type(text, { delay })
14
+ await page.keyboard.press('Enter')
15
+ }
16
+
17
+ async function pasteAndSend(page: Page, text: string): Promise<void> {
18
+ const composer = page.locator('[contenteditable="true"]').first()
19
+ await composer.click()
20
+ await page.keyboard.insertText(text)
21
+ await page.keyboard.press('Enter')
22
+ }
23
+
24
+ async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise<void> {
25
+ await page.waitForFunction(
26
+ expected => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(expected) ?? false,
27
+ text,
28
+ { timeout }
29
+ )
30
+ }
31
+
32
+ test.describe('session compression', () => {
33
+ test.describe.configure({ mode: 'serial' })
34
+
35
+ let fixture: MockBackendFixture
36
+
37
+ test.beforeAll(async () => {
38
+ restartMockServer()
39
+ fixture = await setupMockBackend()
40
+ await waitForAppReady(fixture, 120_000)
41
+ })
42
+
43
+ test.afterAll(async () => {
44
+ await fixture?.cleanup()
45
+ })
46
+
47
+ test('compresses an existing session and accepts a follow-up turn on its continuation', async () => {
48
+ const { page } = fixture
49
+ const reply = 'Hello from the mock inference server! The full boot chain is working.'
50
+
51
+ // Three completed exchanges leave a compressible middle after the
52
+ // compressor's protected head/tail boundaries.
53
+ await send(page, 'E2E_COMPRESSION_FIRST')
54
+ await waitForTranscript(page, reply)
55
+ await send(page, 'E2E_COMPRESSION_SECOND')
56
+ await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_SECOND').length).toBe(1)
57
+ await send(page, 'E2E_COMPRESSION_THIRD')
58
+ await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_THIRD').length).toBe(1)
59
+
60
+ // The mock receiving the third prompt does not mean the TURN is over —
61
+ // /compress on a busy session errors with "session busy — /interrupt the
62
+ // current turn before /compress". Wait for the third reply to render and
63
+ // for the composer to leave its busy state (no Stop affordance) first.
64
+ await page.waitForFunction(
65
+ expected =>
66
+ ((document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').split(expected).length - 1) >= 3,
67
+ reply,
68
+ { timeout: 90_000 }
69
+ )
70
+ await expect
71
+ .poll(
72
+ () => page.locator('[data-slot="composer-root"] button[aria-label="Stop"]').count(),
73
+ { timeout: 30_000, message: 'turn should settle before /compress' }
74
+ )
75
+ .toBe(0)
76
+
77
+ // This test covers compression and continuation, not slash completion.
78
+ // Insert the complete command atomically and click Send so an async
79
+ // completion response cannot consume Enter as a picker acceptance.
80
+ const composer = page.locator('[contenteditable="true"]').first()
81
+ await composer.click()
82
+ await page.keyboard.insertText('/compress preserve the three test turns')
83
+ await expect.poll(() => composer.textContent()).toContain('preserve the three test turns')
84
+ await page.getByRole('button', { name: 'Send', exact: true }).click()
85
+ await expect
86
+ .poll(() => page.locator('[data-slot="aui_thread-viewport"]').textContent(), { timeout: 90_000 })
87
+ .toMatch(/Compressed|No changes from compression/)
88
+
89
+ // Compression rotates the agent's live session id. A post-compression
90
+ // ordinary turn proves the desktop's runtime binding followed that child.
91
+ await send(page, 'E2E_COMPRESSION_FOLLOW_UP')
92
+ await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_FOLLOW_UP').length).toBe(1)
93
+ await waitForTranscript(page, reply)
94
+ await page.screenshot({ path: 'test-results/session-compression-continuation.png' })
95
+ })
96
+ })
97
+
98
+ test.describe('session compression in progress', () => {
99
+ let fixture: MockBackendFixture
100
+
101
+ test.beforeAll(async () => {
102
+ fixture = await setupMockBackend({
103
+ modelContextLength: 64_000,
104
+ extraConfig: `compression:
105
+ threshold_tokens: 22000
106
+ protect_first_n: 0
107
+ protect_last_n: 1
108
+ auxiliary:
109
+ title_generation:
110
+ enabled: false
111
+ compression:
112
+ provider: custom
113
+ model: mock-model`,
114
+ mockServer: {
115
+ holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.'
116
+ }
117
+ })
118
+ await waitForAppReady(fixture, 120_000)
119
+ })
120
+
121
+ test.afterAll(async () => {
122
+ await fixture?.cleanup()
123
+ })
124
+
125
+ test('queues an Enter-submitted draft instead of steering while compaction is active', async ({}, testInfo) => {
126
+ const { page } = fixture
127
+ const queued = 'E2E_QUEUED_DURING_COMPACTION'
128
+
129
+ // A normal message crosses the tiny configured context budget. The mock
130
+ // blocks only the resulting summary request, so these assertions run
131
+ // during automatic compaction rather than a slash-command path.
132
+ // The payload must cross threshold_tokens (22k) on its OWN weight
133
+ // (~12k tokens) on top of the system prompt. Do not shrink it: at
134
+ // repeat(500) the trigger only worked because the ambient system prompt
135
+ // (skills index + tool schemas) happened to carry it over the line, and
136
+ // a 160-token skills-index cleanup on main broke the test for a day.
137
+ await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_ONE '.repeat(5))
138
+ await waitForTranscript(page, MOCK_REPLY)
139
+ await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_TWO '.repeat(5))
140
+ await waitForTranscript(page, MOCK_REPLY)
141
+ await pasteAndSend(page, 'E2E_TRIGGER_AUTOMATIC_COMPACTION '.repeat(1500))
142
+ await fixture.mock.waitForHeldCompletion()
143
+ await expect(page.getByRole('status', { name: 'Summarizing thread' }).last()).toBeVisible()
144
+
145
+ const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
146
+ // Since "running is not busy" (3bc52fb9df) an empty composer mid-turn
147
+ // shows Stop — the Queue affordance appears once a payload is typed, and
148
+ // the Enter path below still queues instead of steering while compaction
149
+ // holds the turn.
150
+ await expect(primary).toHaveAttribute('aria-label', 'Stop')
151
+
152
+ await send(page, queued)
153
+ await expect(page.getByText('1 Queued')).toBeVisible()
154
+ expect(fixture.mock.heldCompletionCount()).toBe(1)
155
+ expect(receivedUserTexts()).not.toContain(queued)
156
+ await page.screenshot({ path: testInfo.outputPath('queued-during-compaction.png') })
157
+
158
+ fixture.mock.releaseHeldStream()
159
+ await expect.poll(() => receivedUserTexts().filter(text => text === queued).length).toBe(1)
160
+ expect(fixture.mock.heldCompletionCount()).toBe(1)
161
+ })
162
+ })
apps/desktop/e2e/sidebar-states.spec.ts ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * E2E tests for desktop sidebar states — background processes, subagents,
3
+ * and session dot transitions.
4
+ *
5
+ * The mock server returns scripted tool_calls that the agent executes for
6
+ * real (trivial commands + real subagent delegations). The tests assert the
7
+ * sidebar states driven by real gateway events.
8
+ *
9
+ * Prerequisite: `npm run build` must have been run so dist/ exists.
10
+ */
11
+
12
+ import { expect, test, type Page } from '@playwright/test'
13
+
14
+ import {
15
+ type MockBackendFixture,
16
+ setupMockBackend,
17
+ waitForAppReady,
18
+ } from './fixtures'
19
+ import {
20
+ createBackgroundReleaseHandle,
21
+ restartMockServer,
22
+ SIDEBAR_CROSS_TEXTS,
23
+ SIDEBAR_TEXTS,
24
+ } from '../../../tests-js/scripts/mock-server'
25
+
26
+ /** Background-running dot aria-label (from i18n en.ts). */
27
+ const BG_DOT_LABEL = 'Background task running'
28
+ /** Foreground turn-running dot aria-label. */
29
+ const SESSION_RUNNING_DOT_LABEL = 'Session running'
30
+ /** Finished-unread dot aria-label. */
31
+ const UNREAD_DOT_LABEL = 'Finished — unread'
32
+
33
+ /**
34
+ * The auto-title auxiliary call hits the SAME mock provider as the chat turn,
35
+ * and its request carries the user's message — trigger keyword included. The
36
+ * mock's trigger matching is text-based, so the title call consumes a script
37
+ * index: the real chat turn then gets turn 2 (final answer, NO tool calls),
38
+ * the background process is never spawned, and the bg dot never appears.
39
+ * Whether that happens depends on which request lands first — the CI flake
40
+ * these specs had. Disable auto-title so script indices line up with real
41
+ * chat turns (same fix as interim-messages.spec.ts).
42
+ */
43
+ const DISABLE_AUTO_TITLE = 'auxiliary:\n title_generation:\n enabled: false'
44
+
45
+ /** Send a message and wait for the final response to appear. */
46
+ async function sendMessageAndWait(
47
+ page: Page,
48
+ trigger: string,
49
+ finalText: string,
50
+ timeout = 90_000,
51
+ ): Promise<void> {
52
+ const composer = page.locator('[contenteditable="true"]').first()
53
+ await composer.waitFor({ state: 'visible', timeout: 10_000 })
54
+ await composer.click()
55
+ await composer.type(trigger, { delay: 20 })
56
+ await page.keyboard.press('Enter')
57
+
58
+ await page.waitForFunction(
59
+ () => (document.body.textContent ?? '').includes('E2E_'),
60
+ undefined,
61
+ { timeout: 15_000 },
62
+ )
63
+
64
+ await page.waitForFunction(
65
+ (text) => (document.body.textContent ?? '').includes(text),
66
+ finalText,
67
+ { timeout },
68
+ )
69
+ }
70
+
71
+ // ────────────────────────────────────────────────────────────────────────
72
+ // Test 1: background process + subagent appear in sidebar during turn
73
+ // ────────────────────────────────────────────────────────────────────────
74
+
75
+ test.describe('sidebar states — background process and subagent', () => {
76
+ test.describe.configure({ mode: 'serial' })
77
+
78
+ let fixture: MockBackendFixture
79
+
80
+ test.beforeAll(async () => {
81
+ restartMockServer()
82
+ fixture = await setupMockBackend({ extraConfig: DISABLE_AUTO_TITLE })
83
+ await waitForAppReady(fixture, 120_000)
84
+ })
85
+
86
+ test.afterAll(async () => {
87
+ await fixture?.cleanup()
88
+ })
89
+
90
+ test('background process dot appears and disappears, subagent runs, final answer visible', async () => {
91
+ const page = fixture.page
92
+
93
+ await sendMessageAndWait(page, 'E2E_SIDEBAR_TRIGGER', SIDEBAR_TEXTS.finalText)
94
+
95
+ // The background process (sleep 1) should have shown a "Background task
96
+ // running" dot at some point during the turn. We try to catch it; if
97
+ // the process was too fast, that's OK — the real assertion is that the
98
+ // final answer appeared and the dot is gone afterward.
99
+ try {
100
+ await expect
101
+ .poll(
102
+ () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
103
+ { timeout: 15_000, message: 'background dot should appear' },
104
+ )
105
+ .toBeGreaterThan(0)
106
+ } catch {
107
+ // sleep 1 may have finished before we polled — not a failure.
108
+ }
109
+
110
+ // After the turn completes and auto-dismiss fires, the background dot
111
+ // should be gone.
112
+ await page.waitForTimeout(8000)
113
+ const bgCount = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count()
114
+ expect(bgCount, 'background dot should be gone after auto-dismiss').toBe(0)
115
+
116
+ // Evidence: capture the final state — no background dot, final answer visible.
117
+ await page.screenshot({ path: 'test-results/bg-dot-gone-after-dismiss.png' })
118
+
119
+ // The final answer text must be in the transcript.
120
+ const viewportText = await page
121
+ .locator('[data-slot="aui_thread-viewport"]')
122
+ .textContent()
123
+ expect(viewportText).toContain(SIDEBAR_TEXTS.finalText)
124
+ })
125
+ })
126
+
127
+ // ─────────────────────────────────────────────────���──────────────────────
128
+ // Test 2: subagent running shows background dot too (longer bg process)
129
+ // ────────────────────────────────────────────────────────────────────────
130
+
131
+ test.describe('sidebar states — subagent and background dot coexist', () => {
132
+ test.describe.configure({ mode: 'serial' })
133
+
134
+ let fixture: MockBackendFixture
135
+ // Hold the background process open until the test releases it. Without the
136
+ // sentinel the process is a bare `sleep 5` racing the agent turn (two model
137
+ // trips + a real subagent spawn): on a loaded runner the turn outlives the
138
+ // sleep, the process is reaped mid-turn, and the dot never appears at all —
139
+ // the CI flake this spec had.
140
+ const bgRelease = createBackgroundReleaseHandle()
141
+
142
+ test.beforeAll(async () => {
143
+ restartMockServer()
144
+ fixture = await setupMockBackend({
145
+ extraConfig: DISABLE_AUTO_TITLE,
146
+ mockServer: { backgroundReleasePath: bgRelease.path },
147
+ })
148
+ await waitForAppReady(fixture, 120_000)
149
+ })
150
+
151
+ test.afterAll(async () => {
152
+ bgRelease.release()
153
+ await fixture?.cleanup()
154
+ bgRelease.cleanup()
155
+ })
156
+
157
+ test('background dot visible while subagent runs', async () => {
158
+ const page = fixture.page
159
+
160
+ // Start the turn — a held background process plus a real subagent.
161
+ const composer = page.locator('[contenteditable="true"]').first()
162
+ await composer.waitFor({ state: 'visible', timeout: 10_000 })
163
+ await composer.click()
164
+ await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
165
+ await page.keyboard.press('Enter')
166
+
167
+ // Wait for the user's message to appear.
168
+ await page.waitForFunction(
169
+ () => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'),
170
+ undefined,
171
+ { timeout: 15_000 },
172
+ )
173
+
174
+ // While the turn is busy the dot-state priority paints the session as
175
+ // "working" ('Session running') — that claim OUTRANKS 'background', so
176
+ // polling for the bg dot mid-turn races the turn length against the poll
177
+ // budget. Wait for the turn to END (final text + running dot cleared),
178
+ // then assert the background dot as a stable, sentinel-held state.
179
+ await page.waitForFunction(
180
+ (text) => (document.body.textContent ?? '').includes(text),
181
+ SIDEBAR_CROSS_TEXTS.finalText,
182
+ { timeout: 90_000 },
183
+ )
184
+ await expect
185
+ .poll(
186
+ () => page.locator(`[aria-label="${SESSION_RUNNING_DOT_LABEL}"]`).count(),
187
+ { timeout: 30_000, message: 'session running dot should disappear after turn completes' },
188
+ )
189
+ .toBe(0)
190
+
191
+ // The background process is held open by the sentinel, so the bg dot is
192
+ // a stable state — poll only to absorb the event-driven flip landing a
193
+ // tick after the running dot clears.
194
+ await expect
195
+ .poll(
196
+ () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
197
+ { timeout: 30_000, message: 'background dot should be visible after turn completes' },
198
+ )
199
+ .toBeGreaterThan(0)
200
+
201
+ // Evidence: the background dot is visible while the process runs.
202
+ await page.screenshot({ path: 'test-results/bg-dot-while-subagent-runs.png' })
203
+
204
+ // Release the process; the dot should clear on the completion event —
205
+ // event-driven, not a fixed sleep.
206
+ bgRelease.release()
207
+ await expect
208
+ .poll(
209
+ () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
210
+ { timeout: 30_000, message: 'background dot should be gone after process exits' },
211
+ )
212
+ .toBe(0)
213
+ })
214
+ })
215
+
216
+ // ────────────────────────────────────────────────────────────────────────
217
+ // Test 3: cross-session — dot updates when viewing a different session
218
+ // ────────────────────────────────────────────────────────────────────────
219
+
220
+ test.describe('sidebar states — cross-session dot transition', () => {
221
+ test.describe.configure({ mode: 'serial' })
222
+
223
+ let fixture: MockBackendFixture
224
+ // Keeps the background process alive until this test releases it, so the
225
+ // "still running after the turn finished" state can't expire on its own.
226
+ const bgRelease = createBackgroundReleaseHandle()
227
+
228
+ test.beforeAll(async () => {
229
+ restartMockServer()
230
+ fixture = await setupMockBackend({
231
+ extraConfig: DISABLE_AUTO_TITLE,
232
+ mockServer: { backgroundReleasePath: bgRelease.path },
233
+ })
234
+ await waitForAppReady(fixture, 120_000)
235
+ })
236
+
237
+ test.afterAll(async () => {
238
+ // Release first so the process exits even if the test failed early,
239
+ // then drop the sentinel file.
240
+ bgRelease.release()
241
+ await fixture?.cleanup()
242
+ bgRelease.cleanup()
243
+ })
244
+
245
+ test('background dot transitions to finished when viewing another session', async () => {
246
+ const page = fixture.page
247
+
248
+ // Start a turn whose background process runs until we release it.
249
+ const composer = page.locator('[contenteditable="true"]').first()
250
+ await composer.waitFor({ state: 'visible', timeout: 10_000 })
251
+ await composer.click()
252
+ await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
253
+ await page.keyboard.press('Enter')
254
+
255
+ // While the turn is busy the dot-state priority paints the session as
256
+ // "working" ('Session running') — that claim OUTRANKS 'background', so
257
+ // polling for the bg dot mid-turn races the turn length (two model trips
258
+ // + a real subagent spawn) against the poll budget: the CI flake this
259
+ // spec had. Wait for the turn to END first, then assert the bg dot as a
260
+ // stable, sentinel-held state.
261
+ //
262
+ // The final answer text streams before message.complete, so text visibility
263
+ // alone is not a completion barrier. Wait for the foreground-running state
264
+ // to clear before asserting the background-process state.
265
+ await page.waitForFunction(
266
+ (text) => (document.body.textContent ?? '').includes(text),
267
+ SIDEBAR_CROSS_TEXTS.finalText,
268
+ { timeout: 90_000 },
269
+ )
270
+ await expect
271
+ .poll(
272
+ () => page.locator(`[aria-label="${SESSION_RUNNING_DOT_LABEL}"]`).count(),
273
+ { timeout: 30_000, message: 'session running dot should disappear after turn completes' },
274
+ )
275
+ .toBe(0)
276
+
277
+ // The background dot must be visible now: the turn is done but the
278
+ // process is held open by the sentinel, so this is a stable state rather
279
+ // than a window we have to catch in time. Poll to absorb the event-driven
280
+ // flip landing a tick after the running dot clears.
281
+ await expect
282
+ .poll(
283
+ () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
284
+ { timeout: 30_000, message: 'background dot should be visible after turn completes' },
285
+ )
286
+ .toBeGreaterThan(0)
287
+
288
+ // Evidence: bg dot visible on session A while its turn is done but the
289
+ // background process hasn't exited yet.
290
+ await page.screenshot({ path: 'test-results/cross-session-bg-dot-before-switch.png' })
291
+
292
+ // Create a new session (click "New session" button).
293
+ await page.locator('button:has-text("New session")').first().click()
294
+ await page.waitForTimeout(2000)
295
+
296
+ // Now let the background process finish. The session A dot should
297
+ // transition away from "background running".
298
+ bgRelease.release()
299
+ await expect
300
+ .poll(
301
+ () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
302
+ { timeout: 30_000, message: 'background dot should disappear after process finishes' },
303
+ )
304
+ .toBe(0)
305
+
306
+ // The original session should show a "finished unread" indicator (green dot)
307
+ // since its turn completed while we were in a different session. This is an
308
+ // event-driven transition, so wait for it instead of sampling the DOM right
309
+ // after the running dot disappears.
310
+ await expect
311
+ .poll(
312
+ () => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count(),
313
+ { timeout: 30_000, message: 'original session should show finished-unread dot' },
314
+ )
315
+ .toBeGreaterThan(0)
316
+
317
+ // Evidence: the green "finished unread" dot on the original session after
318
+ // switching to a new session — the cross-session dot transition.
319
+ await page.screenshot({ path: 'test-results/cross-session-unread-dot-after-switch.png' })
320
+ })
321
+ })
apps/desktop/e2e/submit-drift.spec.ts ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Regression coverage for #69578: harmless route-token churn during a send
3
+ * must not make the desktop silently drop the prompt before prompt.submit.
4
+ */
5
+
6
+ import { test, expect } from './test'
7
+
8
+ import {
9
+ type MockBackendFixture,
10
+ setupMockBackend,
11
+ waitForAppReady,
12
+ } from './fixtures'
13
+
14
+ const PROMPT = 'E2E route token drift must still submit this prompt.'
15
+
16
+ let fixture: MockBackendFixture | null = null
17
+
18
+ test.beforeAll(async () => {
19
+ fixture = await setupMockBackend()
20
+ await waitForAppReady(fixture!, 120_000)
21
+ })
22
+
23
+ test.afterAll(async () => {
24
+ await fixture?.cleanup()
25
+ fixture = null
26
+ })
27
+
28
+ test('submits while same-chat search tokens churn during new-session creation', async ({}, testInfo) => {
29
+ const { page, mock } = fixture!
30
+ const composer = page.locator('[contenteditable="true"]').first()
31
+
32
+ await composer.click()
33
+ await composer.type(PROMPT, { delay: 10 })
34
+
35
+ // The submit pipeline snapshots the route synchronously, then awaits session
36
+ // creation. Keep changing only the query string of whichever chat route is
37
+ // current. Before #69578, comparing the raw route token treated this as a
38
+ // user chat switch and aborted before prompt.submit.
39
+ await page.evaluate(() => {
40
+ let revision = 0
41
+ const interval = window.setInterval(() => {
42
+ const pathname = window.location.hash.slice(1).split(/[?#]/, 1)[0] || '/new'
43
+ window.location.hash = `${pathname}?e2e-route-churn=${revision++}`
44
+ }, 1)
45
+
46
+ ;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn = () => {
47
+ window.clearInterval(interval)
48
+ }
49
+ })
50
+
51
+ try {
52
+ await page.keyboard.press('Enter')
53
+
54
+ await expect
55
+ .poll(() => mock.receivedPrompts.includes(PROMPT), { timeout: 60_000 })
56
+ .toBe(true)
57
+
58
+ await page.waitForFunction(
59
+ prompt => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(prompt) ?? false,
60
+ PROMPT,
61
+ { timeout: 15_000 },
62
+ )
63
+ await page.waitForFunction(
64
+ () => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes('mock inference server') ?? false,
65
+ undefined,
66
+ { timeout: 60_000 },
67
+ )
68
+ } finally {
69
+ await page.evaluate(() => {
70
+ ;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn?.()
71
+ })
72
+ }
73
+
74
+ await page.screenshot({ path: testInfo.outputPath('same-chat-route-churn-submitted.png') })
75
+ })
apps/desktop/e2e/task-panel-clearance.spec.ts ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Regression coverage for returning to a working session as its task panel
3
+ * expands. The transcript must reconcile to the composer's full measured
4
+ * height without needing a manual scroll to repair the position.
5
+ */
6
+
7
+ import { expect, test, type Page } from './test'
8
+
9
+ import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
10
+ import { TASK_PANEL_RESUME_TRIGGER } from '../../../tests-js/scripts/mock-server'
11
+
12
+ const SURFACE = '[data-composer-target]:visible'
13
+ const PROMPT = `${TASK_PANEL_RESUME_TRIGGER}: keep the task panel expanded while this session is reopened.`
14
+
15
+ function activeSurface(page: Page) {
16
+ return page.locator(SURFACE).last()
17
+ }
18
+
19
+ async function send(page: Page, text: string): Promise<void> {
20
+ const composer = activeSurface(page).locator('[contenteditable="true"]').first()
21
+
22
+ await composer.waitFor({ state: 'visible', timeout: 15_000 })
23
+ await composer.click()
24
+ await composer.type(text, { delay: 5 })
25
+ await page.keyboard.press('Enter')
26
+ }
27
+
28
+ async function openFreshDraft(page: Page): Promise<void> {
29
+ await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
30
+ await expect(activeSurface(page).locator('[data-slot="aui_thread-viewport"]')).not.toContainText(PROMPT)
31
+ await page.waitForTimeout(1_000)
32
+ }
33
+
34
+ async function reopenWorkingSession(page: Page): Promise<void> {
35
+ const sidebar = page.locator('[data-slot="sidebar"]')
36
+ const row = sidebar.getByRole('button', { name: /^(?:Session running|Needs your input|Working)\b/ }).first()
37
+
38
+ await row.waitFor({ state: 'visible', timeout: 30_000 })
39
+ await row.click()
40
+ await expect(activeSurface(page).locator('[data-slot="aui_thread-viewport"]')).toContainText(
41
+ 'Task-panel clearance line 24',
42
+ { timeout: 30_000 },
43
+ )
44
+ }
45
+
46
+ interface ClearanceMetrics {
47
+ composerHeight: number
48
+ distanceFromBottom: number
49
+ latestMessageBottom: number
50
+ statusPanelTop: number
51
+ viewportHeight: number
52
+ }
53
+
54
+ async function clearanceMetrics(page: Page): Promise<ClearanceMetrics> {
55
+ return activeSurface(page).evaluate(surface => {
56
+ const chatSurface = surface.closest<HTMLElement>('[data-chat-surface]')!
57
+ const viewport = surface.querySelector<HTMLElement>('[data-slot="aui_thread-viewport"]')!
58
+ const latest = Array.from(surface.querySelectorAll<HTMLElement>('[data-role="assistant"]')).at(-1)!
59
+ const status = surface.querySelector<HTMLElement>('[data-slot="composer-status-stack"]')!
60
+ const styles = getComputedStyle(chatSurface)
61
+
62
+ return {
63
+ composerHeight: Number.parseFloat(styles.getPropertyValue('--composer-measured-height')),
64
+ distanceFromBottom: viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop,
65
+ latestMessageBottom: latest.getBoundingClientRect().bottom,
66
+ statusPanelTop: status.getBoundingClientRect().top,
67
+ viewportHeight: viewport.clientHeight,
68
+ }
69
+ })
70
+ }
71
+
72
+ test.describe('working-session task-panel clearance', () => {
73
+ let fixture: MockBackendFixture | null = null
74
+
75
+ test.beforeEach(async () => {
76
+ fixture = await setupMockBackend({
77
+ mockServer: { holdFirstCompletionContaining: TASK_PANEL_RESUME_TRIGGER },
78
+ })
79
+ await waitForAppReady(fixture, 120_000)
80
+ })
81
+
82
+ test.afterEach(async () => {
83
+ await fixture?.cleanup()
84
+ fixture = null
85
+ })
86
+
87
+ test('window focus reanchors a working session above the expanded task panel', async ({}, testInfo) => {
88
+ const page = fixture!.page
89
+
90
+ await send(page, PROMPT)
91
+ await fixture!.mock.waitForHeldCompletion()
92
+ await openFreshDraft(page)
93
+
94
+ // Re-open while the long response is still streaming. Its todo call lands
95
+ // afterward, so the already-visible composer grows only after the initial
96
+ // session-load scroll settle has finished.
97
+ fixture!.mock.releaseHeldStream()
98
+ await page.waitForTimeout(1_000)
99
+ await reopenWorkingSession(page)
100
+ await expect(activeSurface(page).getByText('Tasks 1/5')).toBeVisible({ timeout: 30_000 })
101
+
102
+ // Reproduce the stale geometry at the foreground boundary. Active turns
103
+ // disable Chromium's background throttling, so visibility can stay `visible`
104
+ // and window focus is the only foreground edge that can repair it.
105
+ await page.waitForTimeout(750)
106
+ const staleState = await activeSurface(page)
107
+ .locator('[data-slot="aui_thread-viewport"]')
108
+ .evaluate(viewport => {
109
+ // Grow scrollHeight before the observed thread-content node. This
110
+ // shifts the transcript behind the dock without resizing the observed
111
+ // node or synthesizing a user scroll (which must escape the lock).
112
+ const staleClearance = document.createElement('div')
113
+ staleClearance.style.height = '160px'
114
+ staleClearance.setAttribute('aria-hidden', 'true')
115
+ viewport.prepend(staleClearance)
116
+
117
+ const distance = viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop
118
+ const surface = viewport.closest<HTMLElement>('[data-composer-target]')!
119
+ const latest = Array.from(surface.querySelectorAll<HTMLElement>('[data-role="assistant"]')).at(-1)!
120
+ const status = surface.querySelector<HTMLElement>('[data-slot="composer-status-stack"]')!
121
+
122
+ window.dispatchEvent(new Event('focus'))
123
+
124
+ return {
125
+ distance,
126
+ following: viewport.dataset.following,
127
+ latestMessageBottom: latest.getBoundingClientRect().bottom,
128
+ statusPanelTop: status.getBoundingClientRect().top,
129
+ visibility: document.visibilityState,
130
+ }
131
+ })
132
+
133
+ expect(staleState.visibility, JSON.stringify(staleState)).toBe('visible')
134
+ expect(staleState.following, JSON.stringify(staleState)).toBe('true')
135
+ expect(staleState.distance).toBeGreaterThan(100)
136
+ expect(staleState.latestMessageBottom, JSON.stringify(staleState)).toBeGreaterThan(staleState.statusPanelTop)
137
+ await page.waitForTimeout(1_000)
138
+ const metrics = await clearanceMetrics(page)
139
+ await page.screenshot({ path: testInfo.outputPath('task-panel-after-resume.png') })
140
+
141
+ expect(metrics.composerHeight, JSON.stringify(metrics)).toBeGreaterThanOrEqual(190)
142
+ expect(metrics.distanceFromBottom, JSON.stringify(metrics)).toBeLessThan(staleState.distance / 2)
143
+ expect(metrics.latestMessageBottom, JSON.stringify(metrics)).toBeLessThanOrEqual(metrics.statusPanelTop)
144
+ })
145
+ })
apps/desktop/e2e/test.ts ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Extended Playwright test fixture that auto-fails any test if an error
3
+ * banner (notification toast with role="alert") appears in the DOM.
4
+ *
5
+ * The desktop app surfaces errors as `[data-slot="alert"][role="alert"]`
6
+ * elements (see components/notifications.tsx). When one appears during a
7
+ * test, it means something went wrong (resume failed, boot error, etc.)
8
+ * — the test should fail with the error message, not silently pass while
9
+ * an error toast is visible on screen.
10
+ *
11
+ * Usage: import { test, expect } from './test' instead of
12
+ * '@playwright/test'. The guard is auto-installed on every page — no
13
+ * per-spec setup needed.
14
+ */
15
+
16
+ import { test as base, expect, type Page, type ElectronApplication, _electron } from '@playwright/test'
17
+
18
+ // Track error messages per test so afterEach can assert + report.
19
+ const seenErrors: string[] = []
20
+ let activePage: Page | null = null
21
+ // When true, the afterEach guard skips the error-banner check.
22
+ // Set by tests that deliberately trigger error states (e.g. boot-failure).
23
+ let errorBannersAllowed = false
24
+
25
+ /**
26
+ * Opt out of the error-banner guard for the current test. Call in
27
+ * test.beforeEach or at the top of a test body when error banners are
28
+ * expected (e.g. boot-failure tests that deliberately trigger errors).
29
+ */
30
+ export function allowErrorBanners(): void {
31
+ errorBannersAllowed = true
32
+ }
33
+
34
+ /**
35
+ * Install the error-banner guard on a page. Watches for `[role="alert"]`
36
+ * elements appearing in the DOM. When one is found, records its text
37
+ * content for the afterEach assertion.
38
+ *
39
+ * Exported so e2e fixture functions (which create pages via _electron.launch)
40
+ * can install the guard on their custom pages — the default Playwright `page`
41
+ * fixture override only catches pages created by Playwright itself, not
42
+ * pages created by the test's own Electron launch.
43
+ */
44
+ export function installErrorBannerGuard(page: Page): void {
45
+ activePage = page
46
+
47
+ // Clear any errors from a previous test when a new page is created.
48
+ seenErrors.length = 0
49
+
50
+ // Use a MutationObserver to catch error banners as they appear.
51
+ // We inject this via addInitScript so it runs before any app code.
52
+ page.addInitScript(() => {
53
+ const seen: string[] = []
54
+ ;(window as unknown as { __ERROR_BANNER_GUARD__?: string[] }).__ERROR_BANNER_GUARD__ = seen
55
+
56
+ const observer = new MutationObserver(() => {
57
+ const alerts = document.querySelectorAll('[role="alert"]')
58
+
59
+ for (const alert of alerts) {
60
+ const text = (alert.textContent ?? '').trim()
61
+
62
+ if (text && !seen.includes(text)) {
63
+ seen.push(text)
64
+ }
65
+ }
66
+ })
67
+
68
+ // Start observing once the DOM is ready.
69
+ if (document.body) {
70
+ observer.observe(document.body, { childList: true, subtree: true })
71
+ } else {
72
+ document.addEventListener('DOMContentLoaded', () => {
73
+ observer.observe(document.body, { childList: true, subtree: true })
74
+ })
75
+ }
76
+ })
77
+
78
+ // Also poll via evaluate — MutationObserver via addInitScript can miss
79
+ // elements that appear during the Electron renderer's initial mount
80
+ // (before the observer is installed). A periodic poll catches those.
81
+ page.on('console', () => {
82
+ // Console messages are not errors — but we keep the listener to
83
+ // ensure the page context is active for our evaluate calls.
84
+ })
85
+ }
86
+
87
+ /**
88
+ * Check for error banners that appeared during the test. Called in
89
+ * afterEach via the custom fixture below. Also exported so specs that
90
+ * manage their own page lifecycle can call it directly.
91
+ */
92
+ export async function collectErrorBanners(page: Page | null): Promise<string[]> {
93
+ if (!page) {
94
+ return []
95
+ }
96
+
97
+ try {
98
+ // Read errors collected by the MutationObserver in the page context.
99
+ const pageErrors = await page.evaluate(() => {
100
+ const w = window as unknown as { __ERROR_BANNER_GUARD__?: string[] }
101
+
102
+ return [...(w.__ERROR_BANNER_GUARD__ ?? [])]
103
+ })
104
+
105
+ // Also do a final DOM scan for any alert elements still visible.
106
+ const domAlerts = await page
107
+ .locator('[role="alert"]')
108
+ .allTextContents()
109
+ .catch(() => [] as string[])
110
+
111
+ const all = [...new Set([...pageErrors, ...domAlerts.map(t => t.trim()).filter(Boolean)])]
112
+ seenErrors.push(...all)
113
+
114
+ return [...new Set(seenErrors)]
115
+ } catch {
116
+ // Page might be closed — return whatever we have.
117
+ return [...new Set(seenErrors)]
118
+ }
119
+ }
120
+
121
+ // Extended test fixture: wraps the default page with the error guard.
122
+ export const test = base.extend({
123
+ // Override the page fixture to auto-install the guard.
124
+ page: async ({ page }, use) => {
125
+ installErrorBannerGuard(page)
126
+ await use(page)
127
+ },
128
+ })
129
+
130
+ // afterEach: fail the test if any error banners appeared.
131
+ // Always fires — even if the test already failed for another reason.
132
+ // An error banner often IS the root cause (e.g. "resume failed" from a
133
+ // backend bug), and suppressing it when the test also fails on an
134
+ // assertion hides the real problem.
135
+ //
136
+ // Uses `activePage` (set by installErrorBannerGuard) instead of the
137
+ // default `page` fixture — Electron tests create their own page via
138
+ // app.firstWindow(), so the default `page` fixture is undefined.
139
+ base.afterEach(async ({}, testInfo) => {
140
+ const wasAllowed = errorBannersAllowed
141
+ // Reset for the next test.
142
+ errorBannersAllowed = false
143
+
144
+ if (wasAllowed) {
145
+ // Test opted out — clear any collected errors without asserting.
146
+ seenErrors.length = 0
147
+ return
148
+ }
149
+
150
+ const errors = await collectErrorBanners(activePage)
151
+
152
+ if (errors.length > 0) {
153
+ throw new Error(
154
+ `Error banner(s) appeared during test "${testInfo.title}":\n` +
155
+ errors.map(e => ` • ${e}`).join('\n'),
156
+ )
157
+ }
158
+ })
159
+
160
+ // Reset for the next test file.
161
+ base.afterAll(async () => {
162
+ seenErrors.length = 0
163
+ activePage = null
164
+ })
165
+
166
+ export { expect, type Page, type ElectronApplication, _electron }
apps/desktop/e2e/tile-unread-bug.spec.ts ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * E2E tests for the tile-unread bug — two scenarios:
3
+ *
4
+ * 1. TAB (stacked, not visible) — a session opened as a tab via ⌃-click is
5
+ * NOT visible on screen. When it finishes, the green "unread" dot IS
6
+ * correct — the user isn't looking at it. This test PASSES.
7
+ *
8
+ * 2. SPLIT (side-by-side, visible) — a session dragged to the edge of the
9
+ * workspace zone opens as a split tile, visible on screen at the same time
10
+ * as the main session. When it finishes, it should NOT get the green
11
+ * "unread" dot — the user is looking right at it. This test FAILS until
12
+ * the fix in session-states.ts:174 lands (the unread check only compares
13
+ * against $selectedStoredSessionId and ignores $sessionTiles).
14
+ *
15
+ * Prerequisite: `npm run build` must have been run so dist/ exists.
16
+ */
17
+
18
+ import { expect, test } from '@playwright/test'
19
+
20
+ import {
21
+ type MockBackendFixture,
22
+ setupMockBackend,
23
+ waitForAppReady,
24
+ } from './fixtures'
25
+ import {
26
+ type BackgroundReleaseHandle,
27
+ createBackgroundReleaseHandle,
28
+ restartMockServer,
29
+ SIDEBAR_CROSS_TEXTS,
30
+ } from '../../../tests-js/scripts/mock-server'
31
+
32
+ /** Finished-unread dot aria-label. */
33
+ const UNREAD_DOT_LABEL = 'Finished — unread'
34
+ /** Background-running dot aria-label. */
35
+ const BG_DOT_LABEL = 'Background task running'
36
+ /** Foreground turn-running dot aria-label. */
37
+ const SESSION_RUNNING_DOT_LABEL = 'Session running'
38
+
39
+ /**
40
+ * The auto-title auxiliary call hits the SAME mock provider as the chat turn,
41
+ * and its request carries the user's message — trigger keyword included. The
42
+ * mock's trigger matching is text-based, so the title call consumes a script
43
+ * index: the real chat turn then gets turn 2 (final answer, NO tool calls),
44
+ * the background process is never spawned, and the bg dot never appears.
45
+ * Whether that happens depends on which request lands first — the CI flake
46
+ * this spec had. Disable auto-title so script indices line up with real chat
47
+ * turns (same fix as interim-messages.spec.ts).
48
+ */
49
+ const DISABLE_AUTO_TITLE = 'auxiliary:\n title_generation:\n enabled: false'
50
+
51
+ /** Locate a session's sidebar row by its preview text. */
52
+ function sessionRow(page: import('@playwright/test').Page, text: string) {
53
+ return page.locator('[data-slot="sidebar"] button').filter({ hasText: text }).first()
54
+ }
55
+
56
+ /** Common setup: start a turn with a held bg process + subagent, wait for
57
+ * the turn to complete, then switch to a new session so the first session is
58
+ * no longer $selectedStoredSessionId (required before opening a tile). */
59
+ async function startTurnAndSwitchAway(page: import('@playwright/test').Page) {
60
+ // Send E2E_SIDEBAR_CROSS — starts a turn with sleep 5 + subagent.
61
+ const composer = page.locator('[contenteditable="true"]').first()
62
+ await composer.waitFor({ state: 'visible', timeout: 10_000 })
63
+ await composer.click()
64
+ await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
65
+ await page.keyboard.press('Enter')
66
+
67
+ // Wait for the user's message to appear.
68
+ await page.waitForFunction(
69
+ () => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'),
70
+ undefined,
71
+ { timeout: 15_000 },
72
+ )
73
+
74
+ // NOTE: while the turn is busy the dot-state priority paints the session as
75
+ // "working" ('Session running'), which OUTRANKS the background claim — the
76
+ // 'Background task running' dot only appears once the turn completes while
77
+ // the (sentinel-held) process is still alive. Polling for the bg dot mid-turn
78
+ // races the turn length (two model trips + a real subagent spawn) against
79
+ // the poll budget, which is exactly the flake this spec had on CI. So: wait
80
+ // for the turn to END first, then assert the bg dot as a stable state.
81
+
82
+ // The final answer text streams before message.complete, so text visibility
83
+ // alone is not a completion barrier. Wait for the foreground-running state
84
+ // to clear before asserting the background-process state.
85
+ await page.waitForFunction(
86
+ (text) => (document.body.textContent ?? '').includes(text),
87
+ SIDEBAR_CROSS_TEXTS.finalText,
88
+ { timeout: 90_000 },
89
+ )
90
+ await expect
91
+ .poll(
92
+ () => page.locator(`[aria-label="${SESSION_RUNNING_DOT_LABEL}"]`).count(),
93
+ { timeout: 30_000, message: 'session running dot should disappear after turn completes' },
94
+ )
95
+ .toBe(0)
96
+
97
+ // The background dot must be visible now: the turn is done but the process
98
+ // is held open by the sentinel, so this is a stable state rather than a
99
+ // window we have to catch in time. Poll rather than sampling once — the
100
+ // dot flip is event-driven off the busy=false publish and can land a tick
101
+ // after the running dot clears.
102
+ await expect
103
+ .poll(
104
+ () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
105
+ { timeout: 30_000, message: 'background dot should be visible after turn completes' },
106
+ )
107
+ .toBeGreaterThan(0)
108
+
109
+ // Switch to a new session — session A is no longer $selectedStoredSessionId.
110
+ // This is required: openSessionTile bails if the session is already selected.
111
+ await page.locator('button:has-text("New session")').first().click()
112
+ await page.waitForTimeout(2000)
113
+ }
114
+
115
+ /** Release the held background process, then wait for its dot to clear. */
116
+ async function waitForBgProcessToFinish(
117
+ page: import('@playwright/test').Page,
118
+ release?: BackgroundReleaseHandle,
119
+ ) {
120
+ release?.release()
121
+ await expect
122
+ .poll(
123
+ () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
124
+ { timeout: 30_000, message: 'background dot should disappear after process finishes' },
125
+ )
126
+ .toBe(0)
127
+ }
128
+
129
+ // ────────────────────────────────────────────────────────────────────────
130
+ // Test 1: TAB (not visible) — unread dot IS correct (PASSES)
131
+ // ────────────────────────────────────────────────────────────────────────
132
+
133
+ test.describe('sidebar states — tab (hidden) unread is correct', () => {
134
+ test.describe.configure({ mode: 'serial' })
135
+
136
+ let fixture: MockBackendFixture
137
+ const bgRelease = createBackgroundReleaseHandle()
138
+
139
+ test.beforeAll(async () => {
140
+ restartMockServer()
141
+ fixture = await setupMockBackend({
142
+ extraConfig: DISABLE_AUTO_TITLE,
143
+ mockServer: { backgroundReleasePath: bgRelease.path },
144
+ })
145
+ await waitForAppReady(fixture, 120_000)
146
+ })
147
+
148
+ test.afterAll(async () => {
149
+ bgRelease.release()
150
+ await fixture?.cleanup()
151
+ bgRelease.cleanup()
152
+ })
153
+
154
+ test('session opened as a tab (not visible) correctly gets unread dot', async () => {
155
+ const page = fixture.page
156
+
157
+ await startTurnAndSwitchAway(page)
158
+
159
+ // Evidence: session A is in the background (bg dot in sidebar).
160
+ await page.screenshot({ path: 'test-results/tile-bug-tab-switched-away.png' })
161
+
162
+ // ⌃-click opens the session as a TAB (center dock = stacked, not visible
163
+ // unless it's the active tab). The session is NOT on screen.
164
+ //
165
+ // With auto-title disabled the sidebar row is titled by the user's
166
+ // message (the trigger keyword), not the assistant's final text.
167
+ const row = sessionRow(page, 'E2E_SIDEBAR_CROSS')
168
+ await row.click({ modifiers: ['Control'] })
169
+ await page.waitForTimeout(2000)
170
+
171
+ // Evidence: the tab is open but the session is not visible on screen.
172
+ await page.screenshot({ path: 'test-results/tile-bug-tab-opened.png' })
173
+
174
+ await waitForBgProcessToFinish(page, bgRelease)
175
+
176
+ // A tab that's not the active tab IS hidden — the unread dot is correct.
177
+ // The user is NOT looking at it, so marking it "unread" is right.
178
+ //
179
+ // Poll rather than sampling once: "finished-unread" is an event-driven
180
+ // transition that lands slightly after the running dot clears, and with a
181
+ // released (rather than slowly-expiring) process there is no incidental
182
+ // slack between the two. Same reasoning as the cross-session spec.
183
+ await expect
184
+ .poll(
185
+ () => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count(),
186
+ { timeout: 30_000, message: 'hidden tab should be marked unread' },
187
+ )
188
+ .toBeGreaterThan(0)
189
+
190
+ await page.screenshot({ path: 'test-results/tile-bug-tab-unread-correct.png' })
191
+ })
192
+ })
193
+
194
+ // ────────────────────────────────────────────────────────────────────────
195
+ // Test 2: SPLIT (visible) — unread dot is WRONG (FAILS until fix)
196
+ // ────────────────────────────────────────────────────────────────────────
197
+
198
+ test.describe.skip('sidebar states — split (visible) unread bug (RED)', () => {
199
+ test.describe.configure({ mode: 'serial' })
200
+
201
+ let fixture: MockBackendFixture
202
+ const bgRelease = createBackgroundReleaseHandle()
203
+
204
+ test.beforeAll(async () => {
205
+ restartMockServer()
206
+ fixture = await setupMockBackend({
207
+ extraConfig: DISABLE_AUTO_TITLE,
208
+ mockServer: { backgroundReleasePath: bgRelease.path },
209
+ })
210
+ await waitForAppReady(fixture, 120_000)
211
+ })
212
+
213
+ test.afterAll(async () => {
214
+ bgRelease.release()
215
+ await fixture?.cleanup()
216
+ bgRelease.cleanup()
217
+ })
218
+
219
+ test('session visible in a split tile does NOT get unread dot when it finishes', async () => {
220
+ const page = fixture.page
221
+
222
+ await startTurnAndSwitchAway(page)
223
+
224
+ // Evidence: session A is in the background (bg dot in sidebar).
225
+ await page.screenshot({ path: 'test-results/tile-bug-split-switched-away.png' })
226
+
227
+ // Drag the session row from the sidebar to the right edge of the workspace
228
+ // zone to create a SPLIT (side-by-side) tile. This triggers the real
229
+ // startSessionDrag → onCommit → openSessionTile(id, 'right', anchor) path.
230
+ // With auto-title disabled the sidebar row is titled by the user's message.
231
+ const row = sessionRow(page, 'E2E_SIDEBAR_CROSS')
232
+ const rowBox = await row.boundingBox()
233
+ expect(rowBox, 'session row must be visible').not.toBeNull()
234
+
235
+ // Find the workspace zone — the main chat area. We drop on its right edge.
236
+ const workspace = page.locator('[data-session-anchor="workspace"]')
237
+ const wsBox = await workspace.boundingBox()
238
+ expect(wsBox, 'workspace zone must be visible').not.toBeNull()
239
+
240
+ // Drag from the session row to the right edge of the workspace.
241
+ // The drag-session's subZonePosition resolves a right-edge drop as 'right'
242
+ // (a split dock), not 'center' (which would be a composer link).
243
+ await page.mouse.move(rowBox!.x + rowBox!.width / 2, rowBox!.y + rowBox!.height / 2)
244
+ await page.mouse.down()
245
+ // Move in steps so the drag-session's pointermove handler tracks the
246
+ // position and resolves the drop zone (a single jump can miss the
247
+ // threshold/engage logic).
248
+ const targetX = wsBox!.x + wsBox!.width - 20
249
+ const targetY = wsBox!.y + wsBox!.height / 2
250
+ const steps = 10
251
+ for (let i = 1; i <= steps; i++) {
252
+ const x = rowBox!.x + rowBox!.width / 2 + (targetX - (rowBox!.x + rowBox!.width / 2)) * (i / steps)
253
+ const y = rowBox!.y + rowBox!.height / 2 + (targetY - (rowBox!.y + rowBox!.height / 2)) * (i / steps)
254
+ await page.mouse.move(x, y)
255
+ await page.waitForTimeout(30)
256
+ }
257
+ await page.mouse.up()
258
+ await page.waitForTimeout(2000)
259
+
260
+ // Evidence: the split tile is now open side-by-side — both sessions visible.
261
+ await page.screenshot({ path: 'test-results/tile-bug-split-opened.png' })
262
+
263
+ await waitForBgProcessToFinish(page, bgRelease)
264
+
265
+ // THE BUG: the session visible in the split tile should NOT have the green
266
+ // "finished unread" dot — the user is looking right at it. This assertion
267
+ // FAILS until the fix in session-states.ts:174 lands.
268
+ const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count()
269
+ expect(unreadCount, 'session visible in a split tile should NOT be marked unread').toBe(0)
270
+
271
+ // Evidence: the green dot should NOT be here — this screenshot shows the bug.
272
+ await page.screenshot({ path: 'test-results/tile-bug-split-unread-should-not-exist.png' })
273
+ })
274
+ })
apps/desktop/e2e/unread-dot-restart.spec.ts ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * E2E test for PERSISTED unread state — the green "Finished — unread" dot
3
+ * must survive an app restart.
4
+ *
5
+ * Regression coverage for the reset-on-restart bug: the unread flag used to
6
+ * live only in the in-memory `$unreadFinishedSessionIds` atom, so closing and
7
+ * reopening the desktop app grayed out every green dot. The persisted layer
8
+ * (src/store/session-unread.ts) now rebuilds the dot from localStorage-backed
9
+ * finish markers + seen-count watermarks.
10
+ *
11
+ * The scenario uses TWO sessions on purpose: with a single session the app
12
+ * can reopen straight into it after a restart, which acks the session (the
13
+ * user is looking at it) and would mask the dot. Session A finishes in the
14
+ * background while session B is the selected one; the restart reopens into
15
+ * B, so A's dot is observable.
16
+ *
17
+ * 1. Session A: start a turn, hold its stream open.
18
+ * 2. Session B: new session, send a message, let it finish while SELECTED.
19
+ * 3. Release A's stream → its background finish paints A's green dot.
20
+ * 4. QUIT the app, relaunch on the SAME sandbox → A's dot must still be
21
+ * there (previously it was lost).
22
+ * 5. Open session A → dot clears.
23
+ * 6. Restart once more → the cleared state must persist too (no zombie dot).
24
+ *
25
+ * Prerequisite: `npm run build` must have been run so dist/ exists.
26
+ */
27
+
28
+ import { expect, type Page, test } from '@playwright/test'
29
+ import { type ElectronApplication } from '@playwright/test'
30
+
31
+ import {
32
+ buildAppEnv,
33
+ launchDesktop,
34
+ type MockBackendFixture,
35
+ setupMockBackend,
36
+ waitForAppReady,
37
+ } from './fixtures'
38
+ import { restartMockServer } from '../../../tests-js/scripts/mock-server'
39
+
40
+ /** Finished-unread dot aria-label (from i18n en.ts). */
41
+ const UNREAD_DOT_LABEL = 'Finished — unread'
42
+
43
+ /** Held prompt — the mock pauses this stream until we release it, so the
44
+ * turn is deterministically still running when we switch away. */
45
+ const HELD_PROMPT = 'E2E unread restart: hold this stream until released.'
46
+ /** Second session's prompt — completes normally while selected. */
47
+ const SECOND_PROMPT = 'E2E unread restart: second session, read while open.'
48
+
49
+ const unreadDots = (page: Page) => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`)
50
+
51
+ async function sendMessage(page: Page, text: string): Promise<void> {
52
+ const composer = page.locator('[contenteditable="true"]').first()
53
+ await composer.waitFor({ state: 'visible', timeout: 15_000 })
54
+ await composer.click()
55
+ await composer.type(text, { delay: 5 })
56
+ await page.keyboard.press('Enter')
57
+ }
58
+
59
+ test.describe('unread dot survives app restart', () => {
60
+ test.describe.configure({ mode: 'serial' })
61
+ // Three full app boots in one scenario — give it more than the global 90s.
62
+ test.setTimeout(300_000)
63
+
64
+ let fixture: MockBackendFixture
65
+ let app: ElectronApplication
66
+ let page: Page
67
+
68
+ test.beforeAll(async () => {
69
+ restartMockServer()
70
+ fixture = await setupMockBackend({
71
+ mockServer: { holdFirstStreamForPrompt: HELD_PROMPT },
72
+ })
73
+ app = fixture.app
74
+ page = fixture.page
75
+ await waitForAppReady(fixture, 120_000)
76
+ })
77
+
78
+ test.afterAll(async () => {
79
+ // The fixture's own app handle may already be closed by the restart
80
+ // steps — close whatever is current, then drop the sandbox + mock.
81
+ try {
82
+ await app?.close()
83
+ } catch {
84
+ // already closed
85
+ }
86
+
87
+ fixture?.mock.close()
88
+ fixture?.sandbox.cleanup()
89
+ })
90
+
91
+ /** Relaunch the desktop app against the SAME sandbox (same userData →
92
+ * same localStorage, same HERMES_HOME → same session store). */
93
+ async function restartApp(): Promise<void> {
94
+ await app.close()
95
+
96
+ const relaunched = await launchDesktop(buildAppEnv(fixture.sandbox))
97
+ app = relaunched.app
98
+ page = relaunched.page
99
+ await waitForAppReady({ ...fixture, app, page }, 120_000)
100
+ }
101
+
102
+ test('green dot persists across restart and its clear persists too', async () => {
103
+ // ── 1. Session A: start a turn whose stream the mock holds open ────
104
+ await sendMessage(page, HELD_PROMPT)
105
+ await fixture.mock.waitForHeldStream()
106
+
107
+ // ── 2. Session B: complete a turn while SELECTED (stays read) ──────
108
+ await page.locator('button:has-text("New session")').first().click()
109
+ await sendMessage(page, SECOND_PROMPT)
110
+
111
+ // B's reply lands in the open transcript — this session is "read".
112
+ await page.waitForFunction(
113
+ () => (document.body.textContent ?? '').includes('Hello from the mock inference server'),
114
+ undefined,
115
+ { timeout: 30_000 },
116
+ )
117
+
118
+ // ── 3. Release A's stream → background finish paints A's dot ──────
119
+ fixture.mock.releaseHeldStream()
120
+
121
+ await expect
122
+ .poll(() => unreadDots(page).count(), {
123
+ timeout: 30_000,
124
+ message: 'green unread dot should appear after the background finish',
125
+ })
126
+ .toBeGreaterThan(0)
127
+
128
+ // ── 4. Restart the app — the dot must survive ─────────────────────��
129
+ // The app reopens into session B (or a fresh draft), NOT session A, so
130
+ // A's dot is observable rather than being acked by the route restore.
131
+ await restartApp()
132
+
133
+ await expect
134
+ .poll(() => unreadDots(page).count(), {
135
+ timeout: 60_000,
136
+ message: 'green unread dot should be rebuilt from persisted state after restart',
137
+ })
138
+ .toBeGreaterThan(0)
139
+
140
+ // ── 5. Open session A — the dot clears ─────────────────────────────
141
+ // The dot sits inside A's sidebar row button; click that row.
142
+ await unreadDots(page)
143
+ .first()
144
+ .locator('xpath=ancestor::button[1]')
145
+ .click()
146
+
147
+ await expect
148
+ .poll(() => unreadDots(page).count(), {
149
+ timeout: 15_000,
150
+ message: 'opening the session should clear its unread dot',
151
+ })
152
+ .toBe(0)
153
+
154
+ // ── 6. Restart again — the CLEARED state must persist as well ──────
155
+ await restartApp()
156
+
157
+ // Give the sidebar a moment to load rows, then assert no dot returns.
158
+ await page.waitForSelector('[data-slot="sidebar"]', { timeout: 60_000 })
159
+ await page.waitForTimeout(5_000)
160
+ expect(await unreadDots(page).count(), 'acked session must stay read after restart').toBe(0)
161
+ })
162
+ })
apps/desktop/e2e/visual-snapshot.ts ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Visual snapshot helper — wraps `toHaveScreenshot` so visual diffs are
3
+ * reported without failing the test suite.
4
+ *
5
+ * On CI, the JSON reporter + post-test script parse the results and post a
6
+ * summary to the GitHub Actions step output, and diff images are uploaded
7
+ * as artifacts. This keeps visual regressions visible without gating PRs
8
+ * on pixel-perfect matches.
9
+ *
10
+ * The actual screenshot is always written to the test output dir so CI
11
+ * artifacts include every screenshot — not just the ones that diffed.
12
+ * When it differs, this helper also writes expected and diff images:
13
+ * <name>-actual.png, <name>-expected.png, <name>-diff.png
14
+ */
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+
18
+ import { type ElectronApplication, type Page, test } from '@playwright/test'
19
+
20
+ /** Fixed window dimensions for visual regression screenshots. */
21
+ export const VISUAL_WINDOW_WIDTH = 1220
22
+ export const VISUAL_WINDOW_HEIGHT = 800
23
+
24
+ export interface VisualSnapshotOptions {
25
+ /** Snapshot name — defaults to the test title. */
26
+ name?: string
27
+ /** Full page screenshot vs. viewport-only (default). */
28
+ fullPage?: boolean
29
+ /** Timeout in ms. */
30
+ timeout?: number
31
+ /** The Electron app handle — used to size and decode screenshots. */
32
+ app: ElectronApplication
33
+ }
34
+
35
+ /**
36
+ * Force the Electron window to a fixed size so screenshots are comparable
37
+ * across runs and CI environments. Window managers (Hyprland, etc.) may
38
+ * auto-tile or resize windows after launch; calling this right before the
39
+ * screenshot ensures the viewport is always the expected size.
40
+ */
41
+ async function forceFixedSize(app: ElectronApplication): Promise<void> {
42
+ await app.evaluate(({ BrowserWindow }, { width, height }) => {
43
+ const win = BrowserWindow.getAllWindows()[0]
44
+
45
+ if (win) {
46
+ win.unmaximize()
47
+ // setMinimumSize must be ≤ the target, otherwise setSize is clamped.
48
+ win.setMinimumSize(width, height)
49
+ win.setSize(width, height, false)
50
+ win.setBounds({ x: 0, y: 0, width, height })
51
+ }
52
+ }, { width: VISUAL_WINDOW_WIDTH, height: VISUAL_WINDOW_HEIGHT })
53
+ }
54
+
55
+ /**
56
+ * Take a screenshot and compare it against the baseline.
57
+ *
58
+ * If the baseline doesn't exist yet (first run), Playwright creates it.
59
+ * If it differs, the test logs a soft warning but does NOT fail — the diff
60
+ * images are still generated for CI to surface.
61
+ */
62
+ export async function expectVisualSnapshot(
63
+ page: Page,
64
+ options: VisualSnapshotOptions,
65
+ ): Promise<void> {
66
+ const { name, fullPage = false, timeout = 30_000, app } = options
67
+
68
+ // Force the window to a fixed size right before the screenshot so it's
69
+ // always comparable, regardless of WM resizing during the test.
70
+ await forceFixedSize(app)
71
+ // Give the renderer a moment to relayout after the resize.
72
+ await page.waitForTimeout(500)
73
+
74
+ // Playwright appends a platform suffix (e.g. "-linux") and requires
75
+ // a .png extension on the name argument. Auto-append it if missing.
76
+ const snapshotName = name ? (name.endsWith('.png') ? name : `${name}.png`) : undefined
77
+
78
+ const info = test.info()
79
+ const actual = await page.screenshot({ animations: 'disabled', caret: 'hide', fullPage, timeout })
80
+ const baselinePath = info.snapshotPath(snapshotName ?? `${info.title}.png`)
81
+ const outputName = (snapshotName ?? 'snapshot.png').replace(/\.png$/, '')
82
+
83
+ if (info.config.updateSnapshots === 'all' || info.config.updateSnapshots === 'changed') {
84
+ fs.mkdirSync(path.dirname(baselinePath), { recursive: true })
85
+ fs.writeFileSync(baselinePath, actual)
86
+ // Also write to the output dir so CI artifacts include the screenshot.
87
+ fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
88
+ console.log(`[visual-baseline] updated ${baselinePath}`)
89
+ return
90
+ }
91
+
92
+ if (!fs.existsSync(baselinePath)) {
93
+ fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
94
+ console.log(`[visual-diff] ${name ?? '(unnamed)'} — no baseline available`)
95
+ return
96
+ }
97
+
98
+ const expected = fs.readFileSync(baselinePath)
99
+ const comparison = await app.evaluate(
100
+ ({ nativeImage }, images) => {
101
+ const actualImage = nativeImage.createFromBuffer(Buffer.from(images.actual, 'base64'))
102
+ const expectedImage = nativeImage.createFromBuffer(Buffer.from(images.expected, 'base64'))
103
+ const actualSize = actualImage.getSize()
104
+ const expectedSize = expectedImage.getSize()
105
+
106
+ if (actualSize.width !== expectedSize.width || actualSize.height !== expectedSize.height) {
107
+ return { mismatchRatio: 1, diff: images.actual }
108
+ }
109
+
110
+ const actualPixels = actualImage.toBitmap()
111
+ const expectedPixels = expectedImage.toBitmap()
112
+ const diffPixels = Buffer.alloc(actualPixels.length)
113
+ let mismatched = 0
114
+
115
+ for (let i = 0; i < actualPixels.length; i += 4) {
116
+ const different =
117
+ Math.abs(actualPixels[i] - expectedPixels[i]) > 51 ||
118
+ Math.abs(actualPixels[i + 1] - expectedPixels[i + 1]) > 51 ||
119
+ Math.abs(actualPixels[i + 2] - expectedPixels[i + 2]) > 51 ||
120
+ Math.abs(actualPixels[i + 3] - expectedPixels[i + 3]) > 51
121
+
122
+ if (different) {
123
+ mismatched++
124
+ diffPixels[i + 2] = 255
125
+ }
126
+ diffPixels[i + 3] = 255
127
+ }
128
+
129
+ return {
130
+ mismatchRatio: mismatched / (actualPixels.length / 4),
131
+ diff: nativeImage.createFromBitmap(diffPixels, actualSize).toPNG().toString('base64'),
132
+ }
133
+ },
134
+ { actual: actual.toString('base64'), expected: expected.toString('base64') },
135
+ )
136
+
137
+ // Always write the actual screenshot to the output dir so CI artifacts
138
+ // include every screenshot — not just the ones that diffed.
139
+ fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
140
+
141
+ if (comparison.mismatchRatio <= 0.01) {
142
+ return
143
+ }
144
+
145
+ fs.writeFileSync(info.outputPath(`${outputName}-expected.png`), expected)
146
+ fs.writeFileSync(info.outputPath(`${outputName}-diff.png`), Buffer.from(comparison.diff, 'base64'))
147
+ console.log(
148
+ `[visual-diff] ${name ?? '(unnamed)'} — ${(comparison.mismatchRatio * 100).toFixed(2)}% of pixels differ`,
149
+ )
150
+ }
apps/desktop/e2e/warm-resume-jitter.spec.ts ADDED
@@ -0,0 +1,477 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * E2E regression: warm-route resume must not re-render the transcript more
3
+ * than once.
4
+ *
5
+ * When a session is already in the runtime-id cache (the "warm" path in
6
+ * `resumeSession()`), clicking its sidebar row should paint the transcript
7
+ * exactly once. Before the fix, the warm cache painted via
8
+ * `syncSessionStateToView`, then the `session.activate` RPC returned a
9
+ * reconciled message list with different message object references, causing
10
+ * `syncSessionStateToView` to fire a second `setMessages` — a visual
11
+ * flicker as the transcript DOM was updated.
12
+ *
13
+ * This test pre-seeds a session into state.db, boots the app,
14
+ * clicks the session (cold resume — populates the warm cache), navigates
15
+ * away to a new chat, then clicks back (warm resume). Two detectors run:
16
+ *
17
+ * 1. A MutationObserver counts additive DOM mutation bursts (childList
18
+ * additions). More than 1 burst = the transcript was repainted.
19
+ *
20
+ * 2. A 2ms innerHTML-length poll counts "reconciles" — DOM content changes
21
+ * that happen AFTER the initial paint, while messages are already on
22
+ * screen. This catches the case where React reconciles by key without
23
+ * adding/removing nodes (same keys → in-place prop update → no
24
+ * MutationObserver burst), but `$messages` was still set twice.
25
+ *
26
+ * The test passes when bursts === 1 AND reconciles === 0.
27
+ * The sidebar "+" keeps the session warm in another tab. Its reactivation
28
+ * follows the same contract: one additive paint and zero reconciles.
29
+ *
30
+ * Prerequisite: `npm run build` must have been run so dist/ exists.
31
+ */
32
+
33
+ import { expect, test } from './test'
34
+
35
+ import {
36
+ type MockBackendFixture,
37
+ waitForAppReady,
38
+ createSandbox,
39
+ writeMockProviderConfig,
40
+ writeEnvFile,
41
+ buildAppEnv,
42
+ launchDesktop,
43
+ } from './fixtures'
44
+ import { startMockServer } from '../../../tests-js/scripts/mock-server'
45
+ import { RealSessionBuilder } from './real-session-builder'
46
+
47
+ const SESSION_TITLE = 'E2E Warm Resume Jitter Test'
48
+
49
+ // Inactive tabs stay mounted under a data-pane-hidden ancestor. Match the
50
+ // renderer's keep-alive visibility policy instead of relying on DOM order.
51
+ const SURFACE = '[data-composer-target]:not([data-pane-hidden] [data-composer-target])'
52
+ const ALL_SURFACES = '[data-composer-target]'
53
+ /**
54
+ * 16 messages (8 user/assistant pairs) — enough DOM churn for detection while
55
+ * still fitting a hot-hidden pane's retention budget. A kept-alive pane keeps
56
+ * only its live tail (HIDDEN_TRANSCRIPT_RENDER_BUDGET = 40 weight units in
57
+ * thread/list.tsx); 16 short messages ≈ 32 units, so the whole transcript
58
+ * survives hiding. Above the budget, reveal legitimately backfills trimmed
59
+ * turns (additive DOM bursts) — that is paging, not the repaint bug this
60
+ * suite hunts, and it would drown the detectors.
61
+ */
62
+ const MESSAGE_COUNT = 16
63
+ /** Seeded PRNG so the generated content is deterministic across runs. */
64
+ const RNG_SEED = 42
65
+
66
+ /** Mulberry32 — tiny deterministic PRNG. */
67
+ function mulberry32(seed: number): () => number {
68
+ let a = seed
69
+ return () => {
70
+ a |= 0
71
+ a = (a + 0x6d2b79f5) | 0
72
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
73
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
74
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
75
+ }
76
+ }
77
+
78
+ /** Generate ~40 chars of gibberish from a seeded PRNG. */
79
+ function gibberish(rng: () => number): string {
80
+ const len = 30 + Math.floor(rng() * 20)
81
+ let s = ''
82
+ for (let i = 0; i < len; i++) {
83
+ s += String.fromCharCode(97 + Math.floor(rng() * 26))
84
+ }
85
+ return s
86
+ }
87
+
88
+ /** First user message — used as a wait target in the test. */
89
+ const FIRST_USER_MSG = gibberish(mulberry32(RNG_SEED))
90
+
91
+ /**
92
+ * Generate the user turns for a real session. The mock provider produces the
93
+ * assistant side of each pair through the normal AIAgent persistence path.
94
+ */
95
+ function generateSessionTurns(): string[] {
96
+ const rng = mulberry32(RNG_SEED)
97
+ const turns: string[] = []
98
+
99
+ for (let i = 0; i < MESSAGE_COUNT / 2; i++) {
100
+ turns.push(gibberish(rng))
101
+ gibberish(rng)
102
+ }
103
+
104
+ return turns
105
+ }
106
+
107
+ /**
108
+ * Set up a mock-backend sandbox with a real persisted session in state.db.
109
+ *
110
+ * Unlike the shared `setupMockBackend()`, this variant creates the session
111
+ * through the real stdio gateway before launching desktop so the session is
112
+ * visible in the sidebar on first load.
113
+ */
114
+ async function setupSeededMockBackend(): Promise<MockBackendFixture> {
115
+ // 1. Start mock server
116
+ const mock = await startMockServer()
117
+
118
+ // 2. Create sandbox + write config
119
+ const sandbox = createSandbox('warm-seed')
120
+ writeMockProviderConfig(sandbox.hermesHome, mock.url)
121
+ writeEnvFile(sandbox.hermesHome)
122
+
123
+ // 3. Produce all 16 user/assistant pairs through the real TUI gateway,
124
+ // AIAgent, mock provider, and SessionDB persistence path before desktop starts.
125
+ const builder = await RealSessionBuilder.start(sandbox.hermesHome)
126
+ try {
127
+ await builder.createSession({ title: SESSION_TITLE, turns: generateSessionTurns() })
128
+ } finally {
129
+ await builder.close()
130
+ }
131
+
132
+ // 4. Build env + launch
133
+ const env = buildAppEnv(sandbox)
134
+ const { app, page } = await launchDesktop(env)
135
+
136
+ return {
137
+ app,
138
+ page,
139
+ mock,
140
+ mockUrl: mock.url,
141
+ sandbox,
142
+ cleanup: async () => {
143
+ await app.close().catch(() => undefined)
144
+ await mock.close()
145
+ sandbox.cleanup()
146
+ },
147
+ }
148
+ }
149
+
150
+ let fixture: MockBackendFixture | null = null
151
+
152
+ test.beforeAll(async () => {
153
+ fixture = await setupSeededMockBackend()
154
+ await waitForAppReady(fixture!, 120_000)
155
+ })
156
+
157
+ test.afterAll(async () => {
158
+ await fixture?.cleanup()
159
+ fixture = null
160
+ })
161
+
162
+ /**
163
+ * Install a MutationObserver + text-content poll on the thread viewport
164
+ * to detect re-renders after the initial paint. Returns nothing — call
165
+ * `readRenderCount` to stop and collect results.
166
+ *
167
+ * - MutationObserver: counts additive childList bursts (5ms coalescing).
168
+ * - Text-content poll: counts "reconciles" — first-message text changes
169
+ * after the initial paint, catching key-based reconciles that don't
170
+ * add/remove nodes.
171
+ */
172
+ async function installRenderCounter(
173
+ page: import('@playwright/test').Page,
174
+ transcriptText?: string,
175
+ ): Promise<void> {
176
+ await page.evaluate(([visibleSelector, allSelector, expected]: [string, string, string | undefined]) => {
177
+ const surfaces = [...document.querySelectorAll(expected ? allSelector : visibleSelector)]
178
+ const surface = expected
179
+ ? surfaces.find(candidate =>
180
+ (candidate.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
181
+ )
182
+ : surfaces.at(-1)
183
+ const viewport = surface?.querySelector('[data-slot="aui_thread-viewport"]')
184
+ if (!viewport) {
185
+ const diag = [...document.querySelectorAll(allSelector)].map(s => ({
186
+ hidden: Boolean(s.closest('[data-pane-hidden]')),
187
+ target: s.getAttribute('data-composer-target'),
188
+ hasViewport: Boolean(s.querySelector('[data-slot="aui_thread-viewport"]')),
189
+ textLen: (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').length,
190
+ head: (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').slice(0, 80),
191
+ tail: (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').slice(-80),
192
+ includesExpected: expected ? (s.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected) : null,
193
+ }))
194
+ throw new Error('Thread viewport not found before warm resume DIAG=' + JSON.stringify(diag) + ' expected=' + expected)
195
+ }
196
+
197
+ const state = { bursts: 0, mutations: 0, timeline: [] as number[], stopped: false, reconciles: 0 }
198
+ const debugWindow = window as unknown as {
199
+ __RENDER_COUNT__: typeof state
200
+ __RENDER_VIEWPORT__: Element
201
+ }
202
+ debugWindow.__RENDER_COUNT__ = state
203
+ debugWindow.__RENDER_VIEWPORT__ = viewport
204
+
205
+ let currentBatch = 0
206
+ let flushTimer: ReturnType<typeof setTimeout> | null = null
207
+
208
+ const flush = () => {
209
+ flushTimer = null
210
+ if (currentBatch > 0 && !state.stopped) {
211
+ state.bursts += 1
212
+ state.timeline.push(currentBatch)
213
+ currentBatch = 0
214
+ }
215
+ }
216
+
217
+ const observer = new MutationObserver(records => {
218
+ if (state.stopped) return
219
+ let batchAdded = 0
220
+ for (const record of records) {
221
+ state.mutations += 1
222
+ if (record.type === 'childList' && record.addedNodes.length > 0) {
223
+ batchAdded += 1
224
+ }
225
+ }
226
+ if (batchAdded > 0) {
227
+ currentBatch += batchAdded
228
+ if (flushTimer) clearTimeout(flushTimer)
229
+ flushTimer = setTimeout(flush, 5)
230
+ }
231
+ })
232
+
233
+ observer.observe(viewport, {
234
+ childList: true,
235
+ subtree: true,
236
+ attributes: false,
237
+ characterData: false,
238
+ })
239
+
240
+ // Poll the first message's text content every 2ms. The MutationObserver
241
+ // only catches childList additions; React may reconcile by key without
242
+ // adding/removing nodes (same keys → in-place prop update → no childList
243
+ // mutation). The poll catches this by detecting text content changes in
244
+ // the first message after the initial paint. Metadata-only changes (model
245
+ // name, busy indicator) don't affect message text, so they don't produce
246
+ // false positives.
247
+ const contentEl = viewport.querySelector('[data-slot="aui_thread-content"]') ?? viewport
248
+ let lastFirstMsgText = ''
249
+ let hasMessages = false
250
+ const pollInterval = setInterval(() => {
251
+ if (state.stopped) {
252
+ clearInterval(pollInterval)
253
+ return
254
+ }
255
+ const firstMsg = contentEl.querySelector('[data-role="message"], [data-message-id]')
256
+ const firstMsgText = firstMsg?.textContent ?? ''
257
+ if (firstMsgText && firstMsgText !== lastFirstMsgText) {
258
+ if (hasMessages) {
259
+ state.reconciles = (state.reconciles ?? 0) + 1
260
+ }
261
+ lastFirstMsgText = firstMsgText
262
+ hasMessages = true
263
+ }
264
+ }, 2)
265
+ }, [SURFACE, ALL_SURFACES, transcriptText] as [string, string, string | undefined])
266
+ }
267
+
268
+ /** Wait until the ACTIVE chat surface's transcript contains `text`. */
269
+ async function waitForActiveTranscriptText(
270
+ page: import('@playwright/test').Page,
271
+ text: string,
272
+ timeout = 30_000,
273
+ ): Promise<void> {
274
+ await page.waitForFunction(
275
+ ([expected, surfaceSelector]: [string, string]) => {
276
+ const surfaces = document.querySelectorAll(surfaceSelector)
277
+ const active = surfaces[surfaces.length - 1]
278
+
279
+ return (active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected)
280
+ },
281
+ [text, SURFACE] as [string, string],
282
+ { timeout },
283
+ )
284
+ }
285
+
286
+ async function waitForActiveTranscriptWithoutText(
287
+ page: import('@playwright/test').Page,
288
+ text: string,
289
+ ): Promise<void> {
290
+ await page.waitForFunction(
291
+ ([expected, surfaceSelector]: [string, string]) => {
292
+ const surfaces = document.querySelectorAll(surfaceSelector)
293
+ const active = surfaces[surfaces.length - 1]
294
+
295
+ return !(active?.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected)
296
+ },
297
+ [text, SURFACE] as [string, string],
298
+ { timeout: 15_000 },
299
+ )
300
+ }
301
+
302
+ /** Replace the primary surface with a draft while retaining its warm cache. */
303
+ async function openFreshDraft(page: import('@playwright/test').Page, priorText: string): Promise<void> {
304
+ await page.keyboard.press(process.platform === 'darwin' ? 'Meta+N' : 'Control+N')
305
+ await waitForActiveTranscriptWithoutText(page, priorText)
306
+ }
307
+
308
+ /** Stack an empty tab while leaving the current transcript mounted and warm. */
309
+ async function openNewSessionTab(page: import('@playwright/test').Page, priorText: string): Promise<void> {
310
+ await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
311
+ await waitForActiveTranscriptWithoutText(page, priorText)
312
+ }
313
+
314
+ /** Stop the render counter and return the recorded burst/reconcile counts. */
315
+ async function readRenderCount(page: import('@playwright/test').Page): Promise<{
316
+ bursts: number
317
+ mutations: number
318
+ timeline: number[]
319
+ reconciles: number
320
+ } | null> {
321
+ return page.evaluate(() => {
322
+ type RenderCount = { bursts: number; mutations: number; timeline: number[]; stopped: boolean; reconciles: number }
323
+ const w = window as unknown as { __RENDER_COUNT__?: RenderCount }
324
+ const rc = w.__RENDER_COUNT__
325
+ if (rc) {
326
+ rc.stopped = true
327
+ }
328
+ return rc ? { bursts: rc.bursts, mutations: rc.mutations, timeline: rc.timeline, reconciles: rc.reconciles } : null
329
+ })
330
+ }
331
+
332
+ async function observedViewportIsActive(page: import('@playwright/test').Page): Promise<boolean> {
333
+ return page.evaluate((surfaceSelector: string) => {
334
+ const surfaces = document.querySelectorAll(surfaceSelector)
335
+ const activeViewport = surfaces[surfaces.length - 1]?.querySelector('[data-slot="aui_thread-viewport"]')
336
+ const observedViewport = (window as unknown as { __RENDER_VIEWPORT__?: Element }).__RENDER_VIEWPORT__
337
+
338
+ return activeViewport === observedViewport
339
+ }, SURFACE)
340
+ }
341
+
342
+ /** A kept-alive tab must become visible without rebuilding its transcript. */
343
+ function assertNoRepaint(result: { bursts: number; mutations: number; timeline: number[]; reconciles: number } | null): void {
344
+ expect(result, 'MutationObserver should have recorded render data').toBeTruthy()
345
+ expect(
346
+ result!.bursts,
347
+ `Expected no additive render bursts for a kept-alive tab, but got ${result!.bursts}. ` +
348
+ `Mutation timeline: ${JSON.stringify(result!.timeline)}.`,
349
+ ).toBe(0)
350
+ expect(
351
+ result!.reconciles,
352
+ `Expected no transcript reconciles for a kept-alive tab, but got ${result!.reconciles}.`,
353
+ ).toBe(0)
354
+ }
355
+
356
+ /** Assert the render counter shows exactly one paint with no re-renders. */
357
+ function assertNoJitter(result: { bursts: number; mutations: number; timeline: number[]; reconciles: number } | null): void {
358
+ expect(result, 'MutationObserver should have recorded render data').toBeTruthy()
359
+ expect(
360
+ result!.bursts,
361
+ `Expected 1 additive render burst (single paint), but got ${result!.bursts} bursts. ` +
362
+ `Mutation timeline: ${JSON.stringify(result!.timeline)}.`,
363
+ ).toBe(1)
364
+ expect(
365
+ result!.reconciles,
366
+ `Expected 0 reconciles (no re-render after initial paint), but got ${result!.reconciles}. ` +
367
+ `This means the warm-route resume re-rendered the transcript after the initial paint ` +
368
+ `— the "warm resume jitter" bug is present.`,
369
+ ).toBe(0)
370
+ }
371
+
372
+ test('tab reactivation preserves the mounted transcript without repainting', async ({}, testInfo) => {
373
+ const page = fixture!.page
374
+
375
+ // Wait for the sidebar to populate with our seeded session.
376
+ const sessionRow = page
377
+ .locator('[data-slot="sidebar"] button')
378
+ .filter({ hasText: SESSION_TITLE })
379
+ .first()
380
+ await sessionRow.waitFor({ state: 'visible', timeout: 60_000 })
381
+
382
+ // Step 1: Cold resume — click the session row to load it.
383
+ // This populates the warm cache (runtimeIdByStoredSessionId + sessionStateByRuntimeId).
384
+ await sessionRow.click()
385
+
386
+ // Wait for the transcript to appear — the first user message text confirms
387
+ // the cold-path prefetch painted.
388
+ await waitForActiveTranscriptText(page, FIRST_USER_MSG)
389
+
390
+ // Wait for the session to fully settle (cold-path RPC + reconciliation).
391
+ await page.waitForTimeout(2_000)
392
+
393
+ // Stack a new tab, then observe the seeded transcript while it is hidden.
394
+ // Installing after the switch isolates reactivation from mutations caused
395
+ // while the new tab was being created.
396
+ await openNewSessionTab(page, FIRST_USER_MSG)
397
+ await page.waitForTimeout(500)
398
+ await installRenderCounter(page, FIRST_USER_MSG)
399
+
400
+ // Step 3: Click back and verify the same kept-alive viewport becomes active
401
+ // without rebuilding or reconciling its transcript.
402
+ await sessionRow.click()
403
+
404
+ await waitForActiveTranscriptText(page, FIRST_USER_MSG)
405
+ await page.waitForTimeout(2_000)
406
+ expect(await observedViewportIsActive(page), 'Reactivation should reveal the observed kept-alive viewport').toBe(true)
407
+
408
+ const result = await readRenderCount(page)
409
+ await page.screenshot({ path: testInfo.outputPath('warm-resume-idle.png') })
410
+ assertNoRepaint(result)
411
+ })
412
+
413
+ test('warm-route resume after background inference completes (no jitter)', async ({}, testInfo) => {
414
+ test.fixme(
415
+ true,
416
+ 'Warm resume repaints after inference: expected one additive burst, got two ([18,1]).',
417
+ )
418
+
419
+ const page = fixture!.page
420
+ const { mock } = fixture!
421
+
422
+ // Wait for the sidebar to populate with our seeded session.
423
+ const sessionRow = page
424
+ .locator('[data-slot="sidebar"] button')
425
+ .filter({ hasText: SESSION_TITLE })
426
+ .first()
427
+ await sessionRow.waitFor({ state: 'visible', timeout: 60_000 })
428
+
429
+ // Step 1: Cold resume — populate the warm cache.
430
+ await sessionRow.click()
431
+ await waitForActiveTranscriptText(page, FIRST_USER_MSG)
432
+ await page.waitForTimeout(2_000)
433
+
434
+ // Step 2: Send a message — triggers inference via the mock server.
435
+ const PROMPT = 'E2E post-inference warm resume test prompt'
436
+ const composer = page.locator('[contenteditable="true"]').first()
437
+ await composer.click()
438
+ await composer.type(PROMPT, { delay: 10 })
439
+ await page.keyboard.press('Enter')
440
+
441
+ // Wait for the mock response to appear in the transcript, confirming
442
+ // the turn completed and message.complete fired (which updates the warm
443
+ // cache via updateSessionState).
444
+ await waitForActiveTranscriptText(page, 'mock inference server', 60_000)
445
+ // Extra settle for message.complete → updateSessionState → cache write.
446
+ await page.waitForTimeout(2_000)
447
+
448
+ // Verify the prompt was received by the mock server.
449
+ expect(mock.receivedPrompts).toContain(PROMPT)
450
+
451
+ // Step 3: Replace the primary chat; the warm cache retains the updated messages.
452
+ await openFreshDraft(page, PROMPT)
453
+ await page.waitForTimeout(500)
454
+
455
+ // Step 4: Install render counter, click back (warm resume), wait, assert.
456
+ await installRenderCounter(page)
457
+ await sessionRow.click()
458
+
459
+ // Wait for the transcript to reappear — the warm cache should already
460
+ // have the completed turn (updated by message.complete events).
461
+ await waitForActiveTranscriptText(page, FIRST_USER_MSG)
462
+
463
+ // Wait for at least 1 burst, then settle.
464
+ await page.waitForFunction(
465
+ () => {
466
+ const w = window as unknown as { __RENDER_COUNT__?: { bursts: number } }
467
+ return Boolean(w.__RENDER_COUNT__ && w.__RENDER_COUNT__.bursts > 0)
468
+ },
469
+ undefined,
470
+ { timeout: 10_000 },
471
+ )
472
+ await page.waitForTimeout(2_000)
473
+
474
+ const result = await readRenderCount(page)
475
+ await page.screenshot({ path: testInfo.outputPath('warm-resume-post-inference.png') })
476
+ assertNoJitter(result)
477
+ })
apps/desktop/e2e/window-input.unit.test.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createRequire } from 'node:module'
2
+
3
+ import { expect, test } from 'vitest'
4
+
5
+ const { prepareWindowForInput } = createRequire(import.meta.url)(
6
+ '../../../tests/install/e2e-assets/window-input.cjs',
7
+ )
8
+
9
+ test('does not finish when IPC reports 100% before the window factor settles', async () => {
10
+ let observations = 0
11
+ const previous = (globalThis as any).hermesDesktop
12
+ ;(globalThis as any).hermesDesktop = { zoom: {
13
+ setPercent: () => undefined,
14
+ get: async () => ({ percent: 100 }),
15
+ } }
16
+ const appWindow = { evaluate: async (fn: any) => fn({ webContents: {
17
+ getZoomFactor: () => ++observations === 1 ? 0.9 : 1,
18
+ } }) }
19
+ const page = {
20
+ evaluate: async (fn: any) => fn(),
21
+ waitForTimeout: async () => undefined,
22
+ }
23
+ try {
24
+ await prepareWindowForInput({ browserWindow: async () => appWindow }, page)
25
+ expect(observations).toBeGreaterThan(1)
26
+ } finally {
27
+ ;(globalThis as any).hermesDesktop = previous
28
+ }
29
+ })
30
+
31
+ test('reapplies zoom when startup overwrites the first request', async () => {
32
+ let requests = 0
33
+ let factor = 0.9
34
+
35
+ const previous = (globalThis as any).hermesDesktop
36
+
37
+ ;(globalThis as any).hermesDesktop = { zoom: {
38
+ setPercent: () => { requests++;
39
+
40
+ if (requests > 1) {factor = 1} },
41
+ get: async () => ({ percent: factor * 100 }),
42
+ } }
43
+ const window = { evaluate: async (fn: any) => fn({ webContents: { getZoomFactor: () => factor } }) }
44
+
45
+ const page = {
46
+ evaluate: async (fn: any) => fn(),
47
+ waitForTimeout: async () => {
48
+ if (requests === 1) {throw new Error('startup overwrote zoom and the driver never reapplied it')}
49
+ },
50
+ }
51
+
52
+ try {
53
+ await prepareWindowForInput({ browserWindow: async () => window }, page)
54
+ expect(requests).toBeGreaterThan(1)
55
+ } finally {
56
+ ;(globalThis as any).hermesDesktop = previous
57
+ }
58
+ })
59
+
60
+ test('awaits the zoom response instead of accepting a truthy Promise', async () => {
61
+ let reads = 0
62
+ let factor = 0.9
63
+
64
+ const zoom = {
65
+ setPercent: () => undefined,
66
+ get: async () => {
67
+ reads++
68
+
69
+ if (reads > 1) {factor = 1}
70
+
71
+ return { percent: factor * 100 }
72
+ },
73
+ }
74
+
75
+ const previous = (globalThis as any).hermesDesktop
76
+
77
+ ;(globalThis as any).hermesDesktop = { zoom }
78
+ const window = { evaluate: async (fn: any) => fn({ webContents: { getZoomFactor: () => factor } }) }
79
+
80
+ const page = {
81
+ evaluate: async (fn: any) => fn(),
82
+ // Playwright 1.58 accepts the predicate's Promise before it resolves.
83
+ waitForFunction: async (fn: any) => { await fn() },
84
+ waitForTimeout: async () => undefined,
85
+ }
86
+
87
+ try {
88
+ await prepareWindowForInput({ browserWindow: async () => window }, page)
89
+ expect(reads).toBeGreaterThan(1)
90
+ expect(factor).toBe(1)
91
+ } finally {
92
+ ;(globalThis as any).hermesDesktop = previous
93
+ }
94
+ })
apps/desktop/e2e/worktree-branch-status.spec.ts ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { execFileSync } from 'node:child_process'
2
+ import * as fs from 'node:fs'
3
+ import * as path from 'node:path'
4
+
5
+ import {
6
+ buildAppEnv,
7
+ createSandbox,
8
+ launchDesktop,
9
+ type MockBackendFixture,
10
+ waitForAppReady,
11
+ writeEnvFile,
12
+ writeMockProviderConfig,
13
+ } from './fixtures'
14
+ import { startMockServer } from '../../../tests-js/scripts/mock-server'
15
+ import { expect, test } from './test'
16
+ import { expectVisualSnapshot } from './visual-snapshot'
17
+
18
+ const BRANCH_NAME = 'e2e-composer-branch'
19
+
20
+ /**
21
+ * Enough branches to make both the base-branch popover and the convert-branch
22
+ * list taller than their default height. That is the condition in which the
23
+ * dialog's own scroll box clips the popover, and that regression is what the
24
+ * visual snapshots here guard against.
25
+ */
26
+ const EXTRA_BRANCHES = [
27
+ 'feature/alpha-one',
28
+ 'feature/beta-two',
29
+ 'feature/gamma-three',
30
+ 'fix/delta-four',
31
+ 'fix/epsilon-five',
32
+ 'chore/zeta-six',
33
+ 'chore/eta-seven',
34
+ 'spike/theta-eight',
35
+ 'spike/iota-nine',
36
+ 'release/kappa-ten',
37
+ ]
38
+
39
+ function createGitRepo(root: string): string {
40
+ const repo = path.join(root, 'repo')
41
+
42
+ fs.mkdirSync(repo, { recursive: true })
43
+ execFileSync('git', ['init', '--initial-branch=main'], { cwd: repo })
44
+ execFileSync('git', ['config', 'user.email', 'e2e@example.com'], { cwd: repo })
45
+ execFileSync('git', ['config', 'user.name', 'Hermes E2E'], { cwd: repo })
46
+ fs.writeFileSync(path.join(repo, 'README.md'), '# E2E repo\n', 'utf8')
47
+ execFileSync('git', ['add', 'README.md'], { cwd: repo })
48
+ execFileSync('git', ['commit', '-m', 'initial'], { cwd: repo })
49
+
50
+ for (const branch of EXTRA_BRANCHES) {
51
+ execFileSync('git', ['branch', branch], { cwd: repo })
52
+ }
53
+
54
+ return repo
55
+ }
56
+
57
+ function configureRepoCwd(hermesHome: string, mockUrl: string, repo: string): void {
58
+ writeMockProviderConfig(hermesHome, mockUrl)
59
+ fs.appendFileSync(path.join(hermesHome, 'config.yaml'), `\nterminal:\n cwd: ${repo}\n`, 'utf8')
60
+ writeEnvFile(hermesHome)
61
+ }
62
+
63
+ let fixture: MockBackendFixture | null = null
64
+
65
+ /** A dialog renders as `[data-slot="dialog-content"]` (components/ui/dialog.tsx). */
66
+ const DIALOG = '[data-slot="dialog-content"]'
67
+
68
+ /** Open the worktree dialog with the global ⌘⇧B / ctrl+shift+B hotkey. */
69
+ async function openWorktreeDialog(): Promise<void> {
70
+ const page = fixture!.page
71
+ await page.keyboard.press('Control+Shift+B')
72
+ await expect(page.locator(DIALOG)).toBeVisible()
73
+ }
74
+
75
+ /** Close the open dialog and wait until it leaves the DOM. */
76
+ async function closeDialog(): Promise<void> {
77
+ const page = fixture!.page
78
+ await page.keyboard.press('Escape')
79
+ await expect(page.locator(DIALOG)).toHaveCount(0)
80
+ }
81
+
82
+ test.beforeAll(async () => {
83
+ const sandbox = createSandbox('worktree-branch-status')
84
+ const repo = createGitRepo(sandbox.root)
85
+ const mock = await startMockServer()
86
+
87
+ configureRepoCwd(sandbox.hermesHome, mock.url, repo)
88
+
89
+ const { app, page } = await launchDesktop(buildAppEnv(sandbox))
90
+ fixture = {
91
+ app,
92
+ page,
93
+ mock,
94
+ mockUrl: mock.url,
95
+ sandbox,
96
+ cleanup: async () => {
97
+ await app.close().catch(() => undefined)
98
+ await mock.close()
99
+ sandbox.cleanup()
100
+ },
101
+ }
102
+
103
+ await waitForAppReady(fixture, 120_000)
104
+
105
+ // The coding rail, and thus the ⌘⇧B worktree dialog, mounts only after the
106
+ // session resolves a cwd that holds a repo. This happens on the first turn.
107
+ const composer = page.locator('[contenteditable="true"]').first()
108
+ await composer.click()
109
+ await composer.type('create a repo-backed e2e session', { delay: 2 })
110
+ await page.keyboard.press('Enter')
111
+ await page.waitForFunction(
112
+ prompt => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(prompt),
113
+ 'create a repo-backed e2e session',
114
+ { timeout: 15_000 },
115
+ )
116
+ await expect(page.locator('.coding-status-bar')).toContainText('main')
117
+ })
118
+
119
+ test.afterAll(async () => {
120
+ await fixture?.cleanup()
121
+ fixture = null
122
+ })
123
+
124
+ test('worktree dialog renders the base-branch picker over the dialog, not clipped by it', async () => {
125
+ const page = fixture!.page
126
+
127
+ await openWorktreeDialog()
128
+
129
+ // Open the base-branch combobox. With 11 branches, the list is taller than
130
+ // the space below the trigger. A popover that portals into the dialog's
131
+ // `overflow-y-auto` box is therefore cut off. This snapshot catches that bug.
132
+ await page.getByRole('button', { name: /branch off/i }).click()
133
+ await expect(page.getByPlaceholder('Search branches…')).toBeVisible()
134
+ await expect(page.getByRole('option', { name: 'feature/alpha-one' })).toBeVisible()
135
+
136
+ await expectVisualSnapshot(page, { name: 'worktree-dialog-base-branch-picker', app: fixture!.app })
137
+
138
+ // This check does not depend on pixels: the dialog's scroll box must not crop
139
+ // the painted box of the popover. Measure the geometry, so a headless run
140
+ // fails on this regression before a person looks at a diff image.
141
+ const clipped = await page.evaluate(() => {
142
+ const popover = document.querySelector('[data-slot="popover-content"]')
143
+ const dialog = document.querySelector('[data-slot="dialog-content"]')
144
+
145
+ if (!popover || !dialog) {
146
+ return { reason: 'missing', clipped: true }
147
+ }
148
+
149
+ const p = popover.getBoundingClientRect()
150
+ const d = dialog.getBoundingClientRect()
151
+ const scrolls = window.getComputedStyle(dialog).overflowY
152
+
153
+ return {
154
+ reason: 'measured',
155
+ // Only a clipping ancestor can crop the popover. The popover is cut when
156
+ // the dialog scrolls its overflow AND the popover goes past the box of
157
+ // the dialog.
158
+ clipped: (scrolls === 'auto' || scrolls === 'scroll' || scrolls === 'hidden') &&
159
+ (p.bottom > d.bottom + 1 || p.top < d.top - 1 || p.right > d.right + 1 || p.left < d.left - 1),
160
+ }
161
+ })
162
+
163
+ expect(clipped.clipped, `base-branch popover is clipped by the dialog (${clipped.reason})`).toBe(false)
164
+
165
+ await page.keyboard.press('Escape')
166
+ await closeDialog()
167
+ })
168
+
169
+ test('worktree dialog convert-an-existing-branch sub-view lists the repo branches', async () => {
170
+ const page = fixture!.page
171
+
172
+ await openWorktreeDialog()
173
+ await page.getByRole('button', { name: 'Convert an existing branch' }).click()
174
+
175
+ await expect(page.getByPlaceholder('Search branches…')).toBeVisible()
176
+ await expect(page.getByRole('option', { name: /feature\/alpha-one/ })).toBeVisible()
177
+
178
+ await expectVisualSnapshot(page, { name: 'worktree-dialog-convert-branch', app: fixture!.app })
179
+
180
+ await closeDialog()
181
+ })
182
+
183
+ test('creating a branch with ctrl-shift-b updates the composer git-status branch and leaves no dialog behind', async ({}, testInfo) => {
184
+ const page = fixture!.page
185
+ const codingRow = page.locator('.coding-status-bar')
186
+
187
+ await openWorktreeDialog()
188
+ // Exactly one dialog instance. A second dialog here, hidden or empty, is the
189
+ // symptom of the double-open bug.
190
+ await expect(page.locator(DIALOG)).toHaveCount(1)
191
+
192
+ const branchInput = page.locator('input[placeholder="e.g. my-feature"]').first()
193
+ await expect(branchInput).toBeVisible()
194
+ await branchInput.fill(BRANCH_NAME)
195
+ // Select a base branch, so this test uses the same path as the user: open the
196
+ // picker, select a branch, then submit. It does not use the default value.
197
+ // The keyboard drives this step. The dialog still clips the popover, so a
198
+ // mouse click on an option is not reliable until that bug is corrected. The
199
+ // double-open check below is therefore independent of the clipping bug.
200
+ await page.getByRole('button', { name: /branch off/i }).click()
201
+ await page.getByPlaceholder('Search branches…').fill('main')
202
+ await expect(page.getByRole('option', { name: 'main' }).first()).toBeVisible()
203
+ await page.keyboard.press('Enter')
204
+ await expect(page.locator('[data-slot="popover-content"]')).toHaveCount(0)
205
+
206
+ await page.getByRole('button', { name: 'New worktree' }).click()
207
+
208
+ await expect(codingRow).toContainText(BRANCH_NAME, { timeout: 15_000 })
209
+
210
+ // The dialog must close and stay closed. No empty second dialog can remain
211
+ // after the new worktree session starts.
212
+ await expect(page.locator(DIALOG)).toHaveCount(0)
213
+ await page.waitForTimeout(2000)
214
+ await expect(page.locator(DIALOG)).toHaveCount(0)
215
+
216
+ await page.screenshot({ path: testInfo.outputPath('composer-branch-after-create.png') })
217
+ })
218
+
219
+ test('ctrl-shift-b opens exactly one worktree dialog when a second composer is on screen', async ({}, testInfo) => {
220
+ const page = fixture!.page
221
+
222
+ // ⌘T / ctrl+T stacks a second session tile. That gives a second live composer
223
+ // and therefore a second coding rail. Each rail mounted its own
224
+ // WorktreeDialog, and each rail subscribed to the same global token. One
225
+ // keypress therefore opened two stacked dialogs, and the dialog the user
226
+ // dismissed showed an identical empty one behind it. One mount in the sidebar
227
+ // makes that impossible by structure.
228
+ await page.keyboard.press('Control+T')
229
+ await expect(page.locator('.coding-status-bar')).toHaveCount(2, { timeout: 20_000 })
230
+
231
+ await page.keyboard.press('Control+Shift+B')
232
+ await expect(page.locator(DIALOG).first()).toBeVisible()
233
+ // Wait: let the effect of every subscriber flush before the count.
234
+ await page.waitForTimeout(500)
235
+
236
+ const count = await page.locator(DIALOG).count()
237
+ await page.screenshot({ path: testInfo.outputPath('worktree-dialog-two-composers.png') })
238
+
239
+ expect(count, 'one hotkey press must open exactly one worktree dialog').toBe(1)
240
+
241
+ // A dismissal then leaves nothing behind.
242
+ await closeDialog()
243
+ })
apps/desktop/e2e/zoom-preservation.spec.ts ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * E2E regression: in-page route navigation must preserve the chosen UI scale.
3
+ *
4
+ * Desktop is a HashRouter over one file:// document, so every route is a
5
+ * distinct URL to Chromium's per-URL zoom store, and a route with no record of
6
+ * its own resolves to the host default (100%). In-page navigation fires no load
7
+ * or window event, so nothing re-asserted the persisted level: switching
8
+ * sessions dropped the window to 100% while Appearance kept reading the chosen
9
+ * scale (#48658, #38854, #79863).
10
+ *
11
+ * Prerequisite: `npm run build` must have been run so dist/ exists.
12
+ */
13
+
14
+ import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
15
+ import { expect, test } from './test'
16
+
17
+ const SCALE = 110
18
+
19
+ let fixture: MockBackendFixture | null = null
20
+
21
+ async function readZoomPercent(): Promise<number> {
22
+ return fixture!.page.evaluate(async () => {
23
+ const desktop = window as unknown as {
24
+ hermesDesktop: { zoom: { get: () => Promise<{ percent: number }> } }
25
+ }
26
+
27
+ return (await desktop.hermesDesktop.zoom.get()).percent
28
+ })
29
+ }
30
+
31
+ async function setZoomPercent(percent: number): Promise<void> {
32
+ await fixture!.page.evaluate(target => {
33
+ const desktop = window as unknown as {
34
+ hermesDesktop: { zoom: { setPercent: (percent: number) => void } }
35
+ }
36
+
37
+ desktop.hermesDesktop.zoom.setPercent(target)
38
+ }, percent)
39
+ await expect.poll(readZoomPercent).toBe(percent)
40
+ }
41
+
42
+ async function gotoRoute(route: string): Promise<void> {
43
+ const page = fixture!.page
44
+
45
+ await page.evaluate(target => {
46
+ window.location.hash = target
47
+ }, route)
48
+ await page.waitForFunction(target => window.location.hash === `#${target}`, route)
49
+ }
50
+
51
+ test.beforeAll(async () => {
52
+ fixture = await setupMockBackend()
53
+ await waitForAppReady(fixture, 120_000)
54
+ })
55
+
56
+ test.afterAll(async () => {
57
+ await fixture?.cleanup()
58
+ fixture = null
59
+ })
60
+
61
+ test('a non-default UI scale survives navigation to never-zoomed routes', async () => {
62
+ await setZoomPercent(SCALE)
63
+
64
+ // Routes Chromium has no zoom record for — what opening a new session looks
65
+ // like to the per-URL store. Pre-fix, the first hop reports 100%.
66
+ const fresh = `/e2e-zoom-${Date.now()}`
67
+
68
+ for (const route of [`${fresh}-one`, `${fresh}-two`, '/settings?tab=config%3Aappearance']) {
69
+ await gotoRoute(route)
70
+ await expect.poll(readZoomPercent, { message: `UI scale after navigating to ${route}` }).toBe(SCALE)
71
+ }
72
+ })
73
+
74
+ test('Cmd/Ctrl+N preserves a non-default UI scale', async () => {
75
+ const page = fixture!.page
76
+
77
+ await gotoRoute('/settings')
78
+ await setZoomPercent(SCALE)
79
+
80
+ await page.evaluate(() => {
81
+ ;(document.activeElement as HTMLElement | null)?.blur()
82
+ })
83
+ await page.keyboard.press(process.platform === 'darwin' ? 'Meta+N' : 'Control+N')
84
+ await page.waitForFunction(() => window.location.hash === '#/')
85
+
86
+ await expect.poll(readZoomPercent).toBe(SCALE)
87
+ })
apps/desktop/electron/active-runtime-state.test.ts ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import assert from 'node:assert/strict'
2
+
3
+ import { test } from 'vitest'
4
+
5
+ import { classifyActiveRuntime, hasValidBootstrapMarker } from './active-runtime-state'
6
+
7
+ const VALID_MARKER = {
8
+ pinnedCommit: '1234567890abcdef1234567890abcdef12345678',
9
+ schemaVersion: 1
10
+ }
11
+
12
+ test('hasValidBootstrapMarker accepts the current schema with a real-looking commit', () => {
13
+ assert.equal(hasValidBootstrapMarker(VALID_MARKER, 1), true)
14
+ })
15
+
16
+ test('hasValidBootstrapMarker rejects missing, wrong-schema, and too-short markers', () => {
17
+ assert.equal(hasValidBootstrapMarker(null, 1), false)
18
+ assert.equal(hasValidBootstrapMarker({ schemaVersion: 2, pinnedCommit: VALID_MARKER.pinnedCommit }, 1), false)
19
+ assert.equal(hasValidBootstrapMarker({ schemaVersion: 1, pinnedCommit: 'abc123' }, 1), false)
20
+ })
21
+
22
+ test('classifyActiveRuntime uses a healthy active runtime even when the bootstrap marker is missing', () => {
23
+ assert.deepEqual(classifyActiveRuntime(null, 1, true), {
24
+ hasValidMarker: false,
25
+ shouldUseActiveRuntime: true,
26
+ usabilityReason: 'usable'
27
+ })
28
+ })
29
+
30
+ test('classifyActiveRuntime uses a healthy active runtime even when the marker is stale or malformed', () => {
31
+ assert.deepEqual(classifyActiveRuntime({ schemaVersion: 999, pinnedCommit: 'abc1234' }, 1, true), {
32
+ hasValidMarker: false,
33
+ shouldUseActiveRuntime: true,
34
+ usabilityReason: 'usable'
35
+ })
36
+ })
37
+
38
+ test('classifyActiveRuntime refuses an unusable runtime even if a valid marker exists', () => {
39
+ assert.deepEqual(classifyActiveRuntime(VALID_MARKER, 1, false), {
40
+ hasValidMarker: true,
41
+ shouldUseActiveRuntime: false,
42
+ usabilityReason: 'unusable'
43
+ })
44
+ })
45
+
46
+ test('a CLI-installed runtime with no marker launches instead of re-running bootstrap', () => {
47
+ // The reported symptom (#60721): install.sh / install.ps1 produced a healthy
48
+ // repo+venv, no desktop-managed marker was ever written, and every launch
49
+ // dropped the user back into the first-run installer.
50
+ const state = classifyActiveRuntime(null, 1, true)
51
+
52
+ assert.equal(state.shouldUseActiveRuntime, true, 'a usable runtime must launch')
53
+ assert.equal(state.hasValidMarker, false, 'marker provenance stays honest')
54
+ })
55
+
56
+ test('a repair that deleted the marker does not strand a healthy install', () => {
57
+ // #72166: the repair handler clears the marker unconditionally. Runtime
58
+ // usability, not marker presence, must decide the next boot.
59
+ assert.equal(classifyActiveRuntime(null, 1, true).shouldUseActiveRuntime, true)
60
+ })
apps/desktop/electron/api-transport.test.ts ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Unit + live-transport tests for the Electron main process's Hermes REST
3
+ * retry policy (#92976 / PR #92977 salvage).
4
+ *
5
+ * The live tests run REAL node http servers that misbehave the way the
6
+ * reported backend does (closing sockets under burst keep-alive traffic) and
7
+ * prove two things end to end:
8
+ *
9
+ * - idempotent GETs that die with ECONNRESET are retried and succeed, where
10
+ * a single bare attempt (pre-PR behavior) surfaces the raw reset;
11
+ * - a POST whose socket is reset AFTER the server processed it is NOT
12
+ * retried: the server-side hit counter stays at 1 and the error surfaces.
13
+ */
14
+ import http from 'node:http'
15
+ import type { AddressInfo } from 'node:net'
16
+
17
+ import { afterAll, describe, expect, it } from 'vitest'
18
+
19
+ import {
20
+ destroyKeepaliveAgents,
21
+ downloadAgentFor,
22
+ httpStatusError,
23
+ isIdempotentMethod,
24
+ isTransientTransportError,
25
+ jsonAgentFor,
26
+ shouldRetryRequest,
27
+ withRetry
28
+ } from './api-transport'
29
+
30
+ function errWithCode(code: string, message = code): NodeJS.ErrnoException {
31
+ const e: NodeJS.ErrnoException = new Error(message)
32
+ e.code = code
33
+
34
+ return e
35
+ }
36
+
37
+ afterAll(() => {
38
+ destroyKeepaliveAgents()
39
+ })
40
+
41
+ describe('isTransientTransportError', () => {
42
+ it('accepts transient socket-level codes and messages', () => {
43
+ for (const code of ['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN']) {
44
+ expect(isTransientTransportError(errWithCode(code))).toBe(true)
45
+ }
46
+
47
+ expect(isTransientTransportError(new Error('socket hang up'))).toBe(true)
48
+ expect(isTransientTransportError(new Error('read ECONNRESET'))).toBe(true)
49
+ })
50
+
51
+ it('rejects non-transport errors', () => {
52
+ expect(isTransientTransportError(new Error('404: not found'))).toBe(false)
53
+ expect(isTransientTransportError(new Error('Invalid JSON from http://x'))).toBe(false)
54
+ expect(isTransientTransportError(null)).toBe(false)
55
+ expect(isTransientTransportError(undefined)).toBe(false)
56
+ })
57
+ })
58
+
59
+ describe('isIdempotentMethod', () => {
60
+ it.each([
61
+ ['GET', true],
62
+ ['get', true],
63
+ ['HEAD', true],
64
+ ['OPTIONS', true],
65
+ ['POST', false],
66
+ ['PUT', false],
67
+ ['PATCH', false],
68
+ ['DELETE', false],
69
+ [undefined, true] // node http defaults omitted method to GET
70
+ ])('%s -> %s', (method, expected) => {
71
+ expect(isIdempotentMethod(method)).toBe(expected)
72
+ })
73
+ })
74
+
75
+ describe('shouldRetryRequest truth table', () => {
76
+ const reset = () => errWithCode('ECONNRESET', 'read ECONNRESET')
77
+ const refused = () => errWithCode('ECONNREFUSED', 'connect ECONNREFUSED 127.0.0.1:1')
78
+ const hangUp = () => new Error('socket hang up')
79
+
80
+ it('GET: retries any transient error regardless of body state', () => {
81
+ expect(shouldRetryRequest(reset(), 'GET', { bodySent: true })).toBe(true)
82
+ expect(shouldRetryRequest(reset(), 'GET', { bodySent: false })).toBe(true)
83
+ expect(shouldRetryRequest(hangUp(), 'HEAD', { bodySent: true })).toBe(true)
84
+ })
85
+
86
+ it('GET: never retries non-transport errors (HTTP 4xx/5xx surfaced as Error)', () => {
87
+ expect(shouldRetryRequest(new Error('500: boom'), 'GET', { bodySent: true })).toBe(false)
88
+ })
89
+
90
+ it('POST: retries when the connection provably never happened', () => {
91
+ expect(shouldRetryRequest(refused(), 'POST', { bodySent: false })).toBe(true)
92
+ expect(shouldRetryRequest(refused(), 'POST', { bodySent: true })).toBe(true) // refused == nothing sent
93
+ expect(shouldRetryRequest(errWithCode('ENOTFOUND'), 'PUT', { bodySent: false })).toBe(true)
94
+ })
95
+
96
+ it('POST: retries transient errors thrown before the body was flushed', () => {
97
+ expect(shouldRetryRequest(reset(), 'POST', { bodySent: false })).toBe(true)
98
+ expect(shouldRetryRequest(hangUp(), 'DELETE', { bodySent: false })).toBe(true)
99
+ })
100
+
101
+ it('POST: does NOT retry ambiguous resets after the body went out', () => {
102
+ expect(shouldRetryRequest(reset(), 'POST', { bodySent: true })).toBe(false)
103
+ expect(shouldRetryRequest(hangUp(), 'POST', { bodySent: true })).toBe(false)
104
+ expect(shouldRetryRequest(errWithCode('EPIPE'), 'PUT', { bodySent: true })).toBe(false)
105
+ expect(shouldRetryRequest(errWithCode('ETIMEDOUT'), 'DELETE', { bodySent: true })).toBe(false)
106
+ })
107
+
108
+ it('POST: conservative when request state is unknown', () => {
109
+ // No bodySent flag at all — treat as "may have been sent", don't retry.
110
+ expect(shouldRetryRequest(reset(), 'POST', {})).toBe(false)
111
+ expect(shouldRetryRequest(reset(), 'POST')).toBe(false)
112
+ })
113
+ })
114
+
115
+ describe('withRetry', () => {
116
+ const noDelay = { delayFn: () => Promise.resolve() }
117
+
118
+ it('retries a GET through transient failures and resolves', async () => {
119
+ let attempts = 0
120
+
121
+ const result = await withRetry(
122
+ () => {
123
+ attempts += 1
124
+
125
+ if (attempts < 3) {
126
+ return Promise.reject(errWithCode('ECONNRESET'))
127
+ }
128
+
129
+ return Promise.resolve('ok')
130
+ },
131
+ { method: 'GET', ...noDelay }
132
+ )
133
+
134
+ expect(result).toBe('ok')
135
+ expect(attempts).toBe(3)
136
+ })
137
+
138
+ it('gives each attempt a fresh requestState', async () => {
139
+ const seen: boolean[] = []
140
+ let attempts = 0
141
+ await withRetry(
142
+ (state: any) => {
143
+ seen.push(state.bodySent)
144
+ state.bodySent = true
145
+ attempts += 1
146
+
147
+ if (attempts < 2) {
148
+ return Promise.reject(errWithCode('ECONNREFUSED'))
149
+ }
150
+
151
+ return Promise.resolve(null)
152
+ },
153
+ { method: 'POST', ...noDelay }
154
+ )
155
+ expect(seen).toEqual([false, false])
156
+ })
157
+
158
+ it('does not retry a POST that failed after the body was flushed', async () => {
159
+ let attempts = 0
160
+ await expect(
161
+ withRetry(
162
+ (state: any) => {
163
+ attempts += 1
164
+ state.bodySent = true
165
+
166
+ return Promise.reject(errWithCode('ECONNRESET', 'read ECONNRESET'))
167
+ },
168
+ { method: 'POST', ...noDelay }
169
+ )
170
+ ).rejects.toThrow('read ECONNRESET')
171
+ expect(attempts).toBe(1)
172
+ })
173
+
174
+ it('retries a POST on ECONNREFUSED (never reached the server)', async () => {
175
+ let attempts = 0
176
+ await expect(
177
+ withRetry(
178
+ () => {
179
+ attempts += 1
180
+
181
+ return Promise.reject(errWithCode('ECONNREFUSED'))
182
+ },
183
+ { method: 'POST', maxRetries: 2, ...noDelay }
184
+ )
185
+ ).rejects.toThrow('ECONNREFUSED')
186
+ expect(attempts).toBe(3)
187
+ })
188
+
189
+ it('bounds retries at maxRetries even for GET', async () => {
190
+ let attempts = 0
191
+ await expect(
192
+ withRetry(
193
+ () => {
194
+ attempts += 1
195
+
196
+ return Promise.reject(errWithCode('ECONNRESET'))
197
+ },
198
+ { method: 'GET', maxRetries: 2, ...noDelay }
199
+ )
200
+ ).rejects.toThrow()
201
+ expect(attempts).toBe(3)
202
+ })
203
+
204
+ it('never retries non-transient errors', async () => {
205
+ let attempts = 0
206
+ await expect(
207
+ withRetry(
208
+ () => {
209
+ attempts += 1
210
+
211
+ return Promise.reject(new Error('500: internal'))
212
+ },
213
+ { method: 'GET', ...noDelay }
214
+ )
215
+ ).rejects.toThrow('500')
216
+ expect(attempts).toBe(1)
217
+ })
218
+ })
219
+
220
+ describe('keep-alive agent pools', () => {
221
+ it('separates JSON and download pools per protocol', () => {
222
+ expect(jsonAgentFor('http:')).not.toBe(jsonAgentFor('https:'))
223
+ expect(jsonAgentFor('http:')).not.toBe(downloadAgentFor('http:'))
224
+ expect(jsonAgentFor('https:')).not.toBe(downloadAgentFor('https:'))
225
+ // Stable across calls (a real pool, not a factory).
226
+ expect(jsonAgentFor('http:')).toBe(jsonAgentFor('http:'))
227
+ })
228
+ })
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // LIVE transport tests against real misbehaving HTTP servers.
232
+ // ---------------------------------------------------------------------------
233
+
234
+ /** Minimal single-attempt GET mirroring the pre-PR fetchJson (no retry). */
235
+ function bareJsonGet(url: string): Promise<any> {
236
+ return new Promise((resolve, reject) => {
237
+ const req = http.request(new URL(url), { agent: jsonAgentFor('http:'), method: 'GET' }, res => {
238
+ const chunks: Buffer[] = []
239
+ res.on('error', reject)
240
+ res.on('data', c => chunks.push(c))
241
+ res.on('end', () => resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))))
242
+ })
243
+
244
+ req.on('error', reject)
245
+ req.end()
246
+ })
247
+ }
248
+
249
+ /** The head behavior: same request under the verb-gated retry policy. */
250
+ function retriedJsonGet(url: string): Promise<any> {
251
+ return withRetry(() => bareJsonGet(url), { method: 'GET', delayFn: () => Promise.resolve() })
252
+ }
253
+
254
+ function listen(server: http.Server): Promise<string> {
255
+ return new Promise(resolve => {
256
+ server.listen(0, '127.0.0.1', () => {
257
+ resolve(`http://127.0.0.1:${(server.address() as AddressInfo).port}`)
258
+ })
259
+ })
260
+ }
261
+
262
+ describe('live: GET burst against a server that resets keep-alive sockets', () => {
263
+ it('bare attempts fail with ECONNRESET/hang-up; retried GETs all succeed', async () => {
264
+ // Deterministic misbehavior: every other request gets its socket
265
+ // destroyed instead of a response — the observable client-side effect of
266
+ // a backend killing idle keep-alive sockets mid-burst.
267
+ let hits = 0
268
+
269
+ const server = http.createServer((req, res) => {
270
+ hits += 1
271
+
272
+ if (hits % 2 === 1) {
273
+ req.socket.destroy()
274
+
275
+ return
276
+ }
277
+
278
+ res.setHeader('content-type', 'application/json')
279
+ res.end(JSON.stringify({ n: hits }))
280
+ })
281
+
282
+ const base = await listen(server)
283
+
284
+ try {
285
+ // BASE (pre-PR, single attempt): the burst surfaces raw transport errors.
286
+ let baseFailures = 0
287
+
288
+ for (let i = 0; i < 6; i++) {
289
+ try {
290
+ await bareJsonGet(`${base}/api/sessions`)
291
+ } catch (error: any) {
292
+ baseFailures += 1
293
+ expect(isTransientTransportError(error)).toBe(true)
294
+ }
295
+ }
296
+
297
+ expect(baseFailures).toBeGreaterThan(0)
298
+
299
+ // HEAD (retry policy): the same burst fully succeeds. Sequential so the
300
+ // server's alternating destroy/respond pattern is deterministic per
301
+ // request (first attempt reset, retry served).
302
+ for (let i = 0; i < 6; i++) {
303
+ const r = await retriedJsonGet(`${base}/api/sessions`)
304
+ expect(r).toHaveProperty('n')
305
+ }
306
+ } finally {
307
+ server.close()
308
+ }
309
+ }, 20_000)
310
+ })
311
+
312
+ describe('live: POST reset after server-side processing', () => {
313
+ it('does not double-submit: server hit count stays 1, error surfaces', async () => {
314
+ // The server fully receives and "processes" the POST (counter increments),
315
+ // then RSTs the socket before responding — the dangerous ambiguous case.
316
+ let posts = 0
317
+
318
+ const server = http.createServer((req, res) => {
319
+ const chunks: Buffer[] = []
320
+ req.on('data', c => chunks.push(c))
321
+ req.on('end', () => {
322
+ posts += 1 // processed: prompt submitted / session created
323
+ req.socket.resetAndDestroy()
324
+ void res
325
+ })
326
+ })
327
+
328
+ const base = await listen(server)
329
+
330
+ const postOnce = () =>
331
+ withRetry(
332
+ (state: any) =>
333
+ new Promise((resolve, reject) => {
334
+ const body = Buffer.from(JSON.stringify({ prompt: 'hello' }))
335
+
336
+ const req = http.request(
337
+ new URL(`${base}/api/prompt`),
338
+ {
339
+ agent: jsonAgentFor('http:'),
340
+ method: 'POST',
341
+ headers: { 'content-type': 'application/json', 'content-length': String(body.length) }
342
+ },
343
+ res => {
344
+ res.resume()
345
+ res.on('end', () => resolve(null))
346
+ }
347
+ )
348
+
349
+ req.on('error', reject)
350
+ state.bodySent = true
351
+ req.write(body)
352
+ req.end()
353
+ }),
354
+ { method: 'POST', delayFn: () => Promise.resolve() }
355
+ )
356
+
357
+ try {
358
+ await expect(postOnce()).rejects.toSatisfy((error: any) => isTransientTransportError(error))
359
+ expect(posts).toBe(1) // exactly one server-side submission — no retry
360
+ } finally {
361
+ server.close()
362
+ }
363
+ }, 20_000)
364
+
365
+ it('sanity: an identical GET-shaped retry WOULD have re-hit the server', async () => {
366
+ // Companion proof that the verb gate (not luck) is what kept posts === 1:
367
+ // the same reset-after-processing server sees multiple hits under GET.
368
+ let gets = 0
369
+
370
+ const server = http.createServer(req => {
371
+ gets += 1
372
+ req.socket.resetAndDestroy()
373
+ })
374
+
375
+ const base = await listen(server)
376
+
377
+ try {
378
+ await expect(retriedJsonGet(`${base}/api/thing`)).rejects.toThrow()
379
+ expect(gets).toBeGreaterThan(1) // retried — proves the machinery fires
380
+ } finally {
381
+ server.close()
382
+ }
383
+ }, 20_000)
384
+ })
385
+
386
+ describe('httpStatusError', () => {
387
+ it('carries the integer statusCode downstream classifiers read, plus the legacy "<status>: <body>" message', () => {
388
+ const err = httpStatusError(401, '{"error":"session_expired"}', 'Unauthorized')
389
+
390
+ expect(err).toBeInstanceOf(Error)
391
+ expect(err.statusCode).toBe(401)
392
+ expect(err.message).toBe('401: {"error":"session_expired"}')
393
+ })
394
+
395
+ it('falls back to the status message, then to an empty detail, when the body is empty', () => {
396
+ expect(httpStatusError(503, '', 'Service Unavailable').message).toBe('503: Service Unavailable')
397
+ expect(httpStatusError(403, '', undefined).message).toBe('403: ')
398
+ expect(httpStatusError(403, '', undefined).statusCode).toBe(403)
399
+ })
400
+
401
+ it('normalizes a missing/invalid status to 500 exactly like the `res.statusCode || 500` guard', () => {
402
+ expect(httpStatusError(undefined, 'boom').statusCode).toBe(500)
403
+ expect(httpStatusError(undefined, 'boom').message).toBe('500: boom')
404
+ expect(httpStatusError(0, 'boom').statusCode).toBe(500)
405
+ })
406
+ })
apps/desktop/electron/app-icon.ts ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { nativeImage } from 'electron'
5
+
6
+ /**
7
+ * Validate that a candidate app-icon file exists and decodes as an image.
8
+ *
9
+ * Electron's `new BrowserWindow({ icon })` and `app.dock.setIcon()` decode the
10
+ * file synchronously on the main process and THROW when the bytes are not a
11
+ * decodable image — `statSync().isFile()` only proves the file exists, not that
12
+ * it decodes. A truncated or zero-byte PNG inside a packaged `app.asar` (e.g.
13
+ * interrupted electron-builder run) therefore killed the main process inside
14
+ * `createWindow()` and took the whole app down mid-session: the window never
15
+ * appeared, running turns lost their renderer, and the desktop log showed
16
+ * `Uncaught exception: Error: Failed to load image from path
17
+ * '.../app.asar/public/apple-touch-icon.png' at createWindow`.
18
+ *
19
+ * This helper makes icon resolution fail-soft: a candidate that exists but does
20
+ * not decode is skipped like a missing one, so the app falls through to the
21
+ * next candidate (or starts with the platform default icon) instead of dying.
22
+ * `nativeImage.createFromPath` is Electron's own decoder with the same failure
23
+ * mode, so callers can inject a probe matching their environment; the shipped
24
+ * probe decodes eagerly and treats a thrown error OR an empty image as invalid.
25
+ */
26
+ export type IconProbe = (filePath: string) => boolean
27
+
28
+ /** Eager-decoding default probe: the file must decode to a non-empty image. */
29
+ export function decodingFileProbe(filePath: string): boolean {
30
+ try {
31
+ if (!fs.statSync(filePath).isFile()) {
32
+ return false
33
+ }
34
+ } catch {
35
+ return false
36
+ }
37
+
38
+ try {
39
+ return !nativeImage.createFromPath(filePath).isEmpty()
40
+ } catch {
41
+ return false
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Pick the first app-icon candidate that exists AND decodes; `undefined` when
47
+ * none do (callers already treat a missing icon as optional — `if (icon)`).
48
+ *
49
+ * Pure over `(candidates, probe)` so the precedence ladder is unit-testable
50
+ * without a running Electron app; the shipped probe injects the real decoder.
51
+ */
52
+ export function resolveAppIcon(
53
+ candidates: readonly string[],
54
+ probe: IconProbe = decodingFileProbe
55
+ ): string | undefined {
56
+ for (const candidate of candidates) {
57
+ if (probe(candidate)) {
58
+ return candidate
59
+ }
60
+ }
61
+
62
+ return undefined
63
+ }
64
+
65
+ /**
66
+ * Build the platform-aware candidate ladder shared by every window factory.
67
+ * Kept next to the resolver so precedence has one home; `appRoot` is injected
68
+ * (packaged `APP_ROOT` vs dev tree) and `unpackedPathFor` maps into
69
+ * `app.asar.unpacked` for builds that leave assets outside the archive.
70
+ */
71
+ export function appIconCandidates(opts: {
72
+ isWindows: boolean
73
+ appRoot: string
74
+ resourcesPath?: string
75
+ unpackedPathFor: (p: string) => string
76
+ }): string[] {
77
+ const { isWindows, appRoot, resourcesPath, unpackedPathFor } = opts
78
+
79
+ return [
80
+ ...(isWindows ? [path.join(resourcesPath ?? '', 'icon.ico'), path.join(appRoot, 'assets', 'icon.ico')] : []),
81
+ path.join(appRoot, 'public', 'apple-touch-icon.png'),
82
+ path.join(appRoot, 'dist', 'apple-touch-icon.png'),
83
+ path.join(unpackedPathFor(appRoot), 'dist', 'apple-touch-icon.png')
84
+ ]
85
+ }
apps/desktop/electron/backend-command.ts ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Backend subcommand routing for the desktop-managed Hermes process.
2
+ //
3
+ // The desktop app launches its own headless backend via `hermes serve` — it
4
+ // must NEVER depend on or launch the browser `dashboard`. But `serve` is a
5
+ // newer subcommand: a runtime that predates it (an older managed install the
6
+ // app hasn't updated yet, or an older `hermes` resolved from PATH) only knows
7
+ // `dashboard --no-open`. To avoid bricking those users mid-upgrade we detect
8
+ // whether the resolved runtime understands `serve` and, only when it does not,
9
+ // fall back to the legacy `dashboard --no-open` invocation. Both produce the
10
+ // exact same headless gateway; `serve` is just the decoupled name.
11
+ //
12
+ // These helpers are pure so they can be unit-tested without Electron.
13
+
14
+ /**
15
+ * Build the canonical headless backend argv (always `serve`).
16
+ * @param {string} [profile] optional Hermes profile to pin via `--profile`.
17
+ */
18
+ export function serveBackendArgs(profile?: string) {
19
+ const head = profile ? ['--profile', profile] : []
20
+
21
+ return [...head, 'serve', '--host', '127.0.0.1', '--port', '0']
22
+ }
23
+
24
+ /**
25
+ * Rewrite a resolved backend argv from `serve` to the legacy
26
+ * `dashboard --no-open` form, preserving every other argument (incl. a leading
27
+ * `-m hermes_cli.main` and any `--profile <name>`). Returns a copy; if there is
28
+ * no `serve` token the argv is returned unchanged.
29
+ */
30
+ export function dashboardFallbackArgs(args) {
31
+ const i = args.indexOf('serve')
32
+
33
+ if (i === -1) {
34
+ return args.slice()
35
+ }
36
+
37
+ return [...args.slice(0, i), 'dashboard', '--no-open', ...args.slice(i + 1)]
38
+ }
39
+
40
+ /**
41
+ * True when a runtime's `hermes_cli/subcommands/dashboard.py` source registers
42
+ * the `serve` subcommand. Matches `add_parser("serve"` / `add_parser('serve'`
43
+ * specifically so the substring "server" (e.g. "start_server", "web server")
44
+ * never produces a false positive.
45
+ */
46
+ export function sourceDeclaresServe(dashboardPySource) {
47
+ return /add_parser\(\s*["']serve["']/.test(String(dashboardPySource || ''))
48
+ }