Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- apps/desktop/assets/icon.ico +0 -0
- apps/desktop/e2e/at-rest-connection-token.spec.ts +999 -0
- apps/desktop/e2e/batch-clarify.spec.ts +86 -0
- apps/desktop/e2e/boot-failure.spec.ts +53 -0
- apps/desktop/e2e/boot.spec.ts +82 -0
- apps/desktop/e2e/bot-mode-row-click-mirrors-registry.spec.ts +158 -0
- apps/desktop/e2e/bot-mode-tab-shows-bot-name.spec.ts +137 -0
- apps/desktop/e2e/bot-roster-user-sections.spec.ts +254 -0
- apps/desktop/e2e/chat.spec.ts +139 -0
- apps/desktop/e2e/connectors.spec.ts +38 -0
- apps/desktop/e2e/context-menu-editables.spec.ts +123 -0
- apps/desktop/e2e/correction-session-switch.spec.ts +270 -0
- apps/desktop/e2e/electron-binary.ts +100 -0
- apps/desktop/e2e/electron-binary.unit.test.ts +54 -0
- apps/desktop/e2e/fix-electron-tracing.ts +72 -0
- apps/desktop/e2e/fixtures.ts +746 -0
- apps/desktop/e2e/fleet-profile-rail.spec.ts +360 -0
- apps/desktop/e2e/glass-surfaces.spec.ts +350 -0
- apps/desktop/e2e/glyph-spinner.spec.ts +227 -0
- apps/desktop/e2e/group-handoff-to-primary-bot.spec.ts +164 -0
- apps/desktop/e2e/group-to-local-bot-handoff.spec.ts +80 -0
- apps/desktop/e2e/group-turn-integrity.spec.ts +235 -0
- apps/desktop/e2e/hidden-history-messages.spec.ts +160 -0
- apps/desktop/e2e/image-attachment-resume.spec.ts +199 -0
- apps/desktop/e2e/interim-messages.spec.ts +275 -0
- apps/desktop/e2e/large-session-resume.spec.ts +255 -0
- apps/desktop/e2e/launch-packaged-app.spec.ts +177 -0
- apps/desktop/e2e/mock-backend-setup.spec.ts +87 -0
- apps/desktop/e2e/onboarding-settings.spec.ts +82 -0
- apps/desktop/e2e/onboarding.spec.ts +76 -0
- apps/desktop/e2e/queue-turn-boundary.spec.ts +119 -0
- apps/desktop/e2e/real-session-builder.ts +239 -0
- apps/desktop/e2e/remote-oauth-recovery.spec.ts +111 -0
- apps/desktop/e2e/right-pane.spec.ts +138 -0
- apps/desktop/e2e/session-compression-and-queue-stop.spec.ts +162 -0
- apps/desktop/e2e/sidebar-states.spec.ts +321 -0
- apps/desktop/e2e/submit-drift.spec.ts +75 -0
- apps/desktop/e2e/task-panel-clearance.spec.ts +145 -0
- apps/desktop/e2e/test.ts +166 -0
- apps/desktop/e2e/tile-unread-bug.spec.ts +274 -0
- apps/desktop/e2e/unread-dot-restart.spec.ts +162 -0
- apps/desktop/e2e/visual-snapshot.ts +150 -0
- apps/desktop/e2e/warm-resume-jitter.spec.ts +477 -0
- apps/desktop/e2e/window-input.unit.test.ts +94 -0
- apps/desktop/e2e/worktree-branch-status.spec.ts +243 -0
- apps/desktop/e2e/zoom-preservation.spec.ts +87 -0
- apps/desktop/electron/active-runtime-state.test.ts +60 -0
- apps/desktop/electron/api-transport.test.ts +406 -0
- apps/desktop/electron/app-icon.ts +85 -0
- 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 |
+
}
|