@wangyaoshen/remux 0.3.8-dev.29e114b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
  2. package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
  4. package/.github/dependabot.yml +33 -0
  5. package/.github/workflows/ci.yml +65 -0
  6. package/.github/workflows/deploy.yml +65 -0
  7. package/.github/workflows/publish.yml +312 -0
  8. package/.github/workflows/release-please.yml +21 -0
  9. package/.gitmodules +3 -0
  10. package/.nvmrc +1 -0
  11. package/.release-please-manifest.json +3 -0
  12. package/CLAUDE.md +104 -0
  13. package/Dockerfile +23 -0
  14. package/LICENSE +21 -0
  15. package/README.md +120 -0
  16. package/apps/ios/Config/signing.xcconfig +4 -0
  17. package/apps/ios/Package.swift +26 -0
  18. package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
  19. package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
  20. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
  21. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
  22. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
  23. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
  24. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
  25. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
  26. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
  27. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
  28. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
  29. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
  30. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
  31. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
  32. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
  33. package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
  34. package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
  35. package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
  36. package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
  37. package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
  38. package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
  39. package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
  40. package/apps/ios/Sources/Remux/RootView.swift +130 -0
  41. package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
  42. package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
  43. package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
  44. package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
  45. package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
  46. package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
  47. package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
  48. package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
  49. package/apps/macos/Package.swift +37 -0
  50. package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
  51. package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
  52. package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
  53. package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
  54. package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
  55. package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
  56. package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
  57. package/apps/macos/Resources/terminfo/67/ghostty +0 -0
  58. package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
  59. package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
  60. package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
  61. package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
  62. package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
  63. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
  64. package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
  65. package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
  66. package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
  67. package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
  68. package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
  69. package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
  70. package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
  71. package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
  72. package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
  73. package/apps/macos/Sources/Remux/SocketController.swift +258 -0
  74. package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
  75. package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
  76. package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
  77. package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
  78. package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
  79. package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
  80. package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
  81. package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
  82. package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
  83. package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
  84. package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
  85. package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
  86. package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
  87. package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
  88. package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
  89. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
  90. package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
  91. package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
  92. package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
  93. package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
  94. package/build.mjs +33 -0
  95. package/native/android/DecodeGoldenPayloads.kt +487 -0
  96. package/native/android/ProtocolModels.kt +188 -0
  97. package/native/ios/DecodeGoldenPayloads.swift +711 -0
  98. package/native/ios/ProtocolModels.swift +200 -0
  99. package/package.json +45 -0
  100. package/packages/RemuxKit/Package.swift +27 -0
  101. package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
  102. package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
  103. package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
  104. package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
  105. package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
  106. package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
  107. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
  108. package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
  109. package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
  110. package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
  111. package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
  112. package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
  113. package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
  114. package/playwright.config.ts +17 -0
  115. package/pnpm-lock.yaml +1588 -0
  116. package/pty-daemon.js +303 -0
  117. package/release-please-config.json +14 -0
  118. package/scripts/auto-deploy.sh +46 -0
  119. package/scripts/build-dmg.sh +121 -0
  120. package/scripts/build-ghostty-kit.sh +43 -0
  121. package/scripts/check-active-terminology.mjs +132 -0
  122. package/scripts/setup-ci-secrets.sh +80 -0
  123. package/scripts/sync-ghostty-web.sh +28 -0
  124. package/scripts/upload-testflight.sh +100 -0
  125. package/server.js +7074 -0
  126. package/src/adapters/agent-events.ts +246 -0
  127. package/src/adapters/claude-code.ts +158 -0
  128. package/src/adapters/codex.ts +210 -0
  129. package/src/adapters/generic-shell.ts +58 -0
  130. package/src/adapters/index.ts +15 -0
  131. package/src/adapters/registry.ts +99 -0
  132. package/src/adapters/types.ts +41 -0
  133. package/src/auth.ts +174 -0
  134. package/src/e2ee.ts +236 -0
  135. package/src/git-service.ts +168 -0
  136. package/src/message-buffer.ts +137 -0
  137. package/src/pty-daemon.ts +357 -0
  138. package/src/push.ts +127 -0
  139. package/src/renderers.ts +455 -0
  140. package/src/server.ts +2407 -0
  141. package/src/service.ts +226 -0
  142. package/src/session.ts +978 -0
  143. package/src/store.ts +1422 -0
  144. package/src/team.ts +123 -0
  145. package/src/tunnel.ts +126 -0
  146. package/src/types.d.ts +50 -0
  147. package/src/vt-tracker.ts +188 -0
  148. package/src/workspace-head.ts +144 -0
  149. package/src/workspace.ts +153 -0
  150. package/src/ws-handler.ts +1526 -0
  151. package/start.ps1 +83 -0
  152. package/tests/adapters.test.js +171 -0
  153. package/tests/auth.test.js +243 -0
  154. package/tests/codex-adapter.test.js +535 -0
  155. package/tests/durable-stream.test.js +153 -0
  156. package/tests/e2e/app.spec.js +530 -0
  157. package/tests/e2ee.test.js +325 -0
  158. package/tests/message-buffer.test.js +245 -0
  159. package/tests/message-routing.test.js +305 -0
  160. package/tests/pty-daemon.test.js +346 -0
  161. package/tests/push.test.js +281 -0
  162. package/tests/renderers.test.js +391 -0
  163. package/tests/search-shell.test.js +499 -0
  164. package/tests/server.test.js +882 -0
  165. package/tests/service.test.js +267 -0
  166. package/tests/store.test.js +369 -0
  167. package/tests/tunnel.test.js +67 -0
  168. package/tests/workspace-head.test.js +116 -0
  169. package/tests/workspace.test.js +417 -0
  170. package/tsconfig.backend.json +11 -0
  171. package/tsconfig.json +15 -0
  172. package/tui/client/client_test.go +125 -0
  173. package/tui/client/connection.go +342 -0
  174. package/tui/client/host_manager.go +141 -0
  175. package/tui/config/cache.go +81 -0
  176. package/tui/config/config.go +53 -0
  177. package/tui/config/config_test.go +89 -0
  178. package/tui/go.mod +32 -0
  179. package/tui/go.sum +50 -0
  180. package/tui/main.go +261 -0
  181. package/tui/tests/integration_test.go +283 -0
  182. package/tui/ui/model.go +310 -0
  183. package/vitest.config.js +10 -0
@@ -0,0 +1,530 @@
1
+ /**
2
+ * E2E tests for Remux using Playwright.
3
+ * Starts the server, loads the app in a real browser, and tests core flows.
4
+ *
5
+ * ghostty-web renders into a <canvas> so terminal content isn't readable from DOM.
6
+ * We use the Inspect view to verify terminal output as plain text.
7
+ */
8
+
9
+ import { test, expect } from "@playwright/test";
10
+ import { spawn } from "child_process";
11
+
12
+ const PORT = 29876;
13
+ const TOKEN = "e2e-test-token";
14
+ const BASE = `http://localhost:${PORT}/?token=${TOKEN}`;
15
+
16
+ let server;
17
+
18
+ test.beforeAll(async () => {
19
+ server = spawn("node", ["server.js"], {
20
+ env: {
21
+ ...process.env,
22
+ PORT: String(PORT),
23
+ REMUX_TOKEN: TOKEN,
24
+ REMUX_INSTANCE_ID: "e2e-test",
25
+ },
26
+ stdio: "pipe",
27
+ cwd: process.cwd(),
28
+ });
29
+
30
+ // Collect stderr for debugging
31
+ server.stderr.on("data", (d) => {
32
+ const msg = d.toString();
33
+ if (msg.includes("Error") || msg.includes("error")) {
34
+ console.error("[server stderr]", msg);
35
+ }
36
+ });
37
+
38
+ await new Promise((resolve, reject) => {
39
+ const timeout = setTimeout(
40
+ () => reject(new Error("Server start timeout (15s)")),
41
+ 15000,
42
+ );
43
+ server.stdout.on("data", (d) => {
44
+ if (d.toString().includes("Remux running")) {
45
+ clearTimeout(timeout);
46
+ // Extra wait for WASM init to complete
47
+ setTimeout(resolve, 3000);
48
+ }
49
+ });
50
+ server.on("error", (err) => {
51
+ clearTimeout(timeout);
52
+ reject(err);
53
+ });
54
+ });
55
+ });
56
+
57
+ test.afterAll(async () => {
58
+ if (server) {
59
+ server.kill("SIGTERM");
60
+ // Give process time to shut down gracefully
61
+ await new Promise((r) => setTimeout(r, 500));
62
+ }
63
+ });
64
+
65
+ test.describe.serial("Remux E2E", () => {
66
+ // ── 1. Page loads and shows terminal ──
67
+
68
+ test("page loads and shows terminal", async ({ page }) => {
69
+ await page.goto(BASE);
70
+ await expect(page).toHaveTitle("Remux");
71
+
72
+ // Terminal canvas should be visible (ghostty-web renders to canvas)
73
+ const canvas = page.locator("#terminal canvas");
74
+ await expect(canvas).toBeVisible({ timeout: 10000 });
75
+
76
+ // Sidebar should show "main" session
77
+ const sessionItem = page.locator(".session-item .name", { hasText: "main" });
78
+ await expect(sessionItem).toBeVisible();
79
+ });
80
+
81
+ // ── 2. Live terminal interaction ──
82
+
83
+ test("live terminal interaction via Inspect", async ({ page }) => {
84
+ await page.goto(BASE);
85
+ await expect(page.locator("#terminal canvas")).toBeVisible({
86
+ timeout: 10000,
87
+ });
88
+
89
+ // Wait for WebSocket to connect and terminal to be ready
90
+ await page.waitForFunction(
91
+ () =>
92
+ window._remuxTerm &&
93
+ document.querySelector("#status-dot")?.classList.contains("connected"),
94
+ { timeout: 10000 },
95
+ );
96
+
97
+ // Type into the terminal — ghostty-web uses a hidden textarea for keyboard input
98
+ const textarea = page.locator("#terminal textarea");
99
+ await textarea.focus();
100
+ await textarea.pressSequentially("echo e2e-test-output", { delay: 30 });
101
+ await textarea.press("Enter");
102
+
103
+ // Wait for shell to process the command
104
+ await page.waitForTimeout(2000);
105
+
106
+ // Switch to Inspect to read terminal content as text
107
+ await page.locator("#btn-inspect").click();
108
+ await expect(page.locator("#inspect")).toHaveClass(/visible/, {
109
+ timeout: 5000,
110
+ });
111
+
112
+ // Wait for inspect data to arrive from server
113
+ await page.waitForFunction(
114
+ () => (window._inspectText || "").includes("e2e-test-output"),
115
+ { timeout: 10000 },
116
+ );
117
+
118
+ const inspectText = await page.evaluate(() => window._inspectText);
119
+ expect(inspectText).toContain("e2e-test-output");
120
+
121
+ // Go back to Live
122
+ await page.locator("#btn-live").click();
123
+ await expect(page.locator("#terminal")).not.toHaveClass(/hidden/);
124
+ });
125
+
126
+ // ── 3. Inspect view ──
127
+
128
+ test("inspect view shows content and meta", async ({ page }) => {
129
+ await page.goto(BASE);
130
+ await expect(page.locator("#terminal canvas")).toBeVisible({
131
+ timeout: 10000,
132
+ });
133
+ await page.waitForFunction(
134
+ () =>
135
+ window._remuxTerm &&
136
+ document.querySelector("#status-dot")?.classList.contains("connected"),
137
+ { timeout: 10000 },
138
+ );
139
+
140
+ // Click Inspect button
141
+ await page.locator("#btn-inspect").click();
142
+ await expect(page.locator("#inspect")).toHaveClass(/visible/, {
143
+ timeout: 5000,
144
+ });
145
+
146
+ // Wait for inspect data
147
+ await page.waitForFunction(() => !!window._inspectText, { timeout: 10000 });
148
+
149
+ // Inspect panel should have text content
150
+ const content = page.locator("#inspect-content");
151
+ await expect(content).toBeVisible();
152
+
153
+ // Meta info should contain session/tab reference
154
+ const meta = page.locator("#inspect-meta");
155
+ await expect(meta).toContainText("main");
156
+ await expect(meta).toContainText("Tab");
157
+
158
+ // Click Live to go back
159
+ await page.locator("#btn-live").click();
160
+ await expect(page.locator("#inspect")).not.toHaveClass(/visible/);
161
+ });
162
+
163
+ // ── 4. Session management ──
164
+
165
+ test("create and delete session", async ({ page }) => {
166
+ await page.goto(BASE);
167
+ await expect(page.locator("#terminal canvas")).toBeVisible({
168
+ timeout: 10000,
169
+ });
170
+ await page.waitForFunction(
171
+ () =>
172
+ window._remuxTerm &&
173
+ document.querySelector("#status-dot")?.classList.contains("connected"),
174
+ { timeout: 10000 },
175
+ );
176
+
177
+ // Listen for the prompt dialog and accept with session name
178
+ page.on("dialog", (d) => d.accept("test-e2e-session"));
179
+
180
+ // Click "+" to create a new session
181
+ await page.locator("#btn-new-session").click();
182
+
183
+ // Wait for the new session to appear in sidebar
184
+ const newSession = page.locator(".session-item .name", {
185
+ hasText: "test-e2e-session",
186
+ });
187
+ await expect(newSession).toBeVisible({ timeout: 5000 });
188
+
189
+ // Delete the session — click the × button on its session-item
190
+ const sessionItem = page.locator(".session-item", {
191
+ has: page.locator('.name:text("test-e2e-session")'),
192
+ });
193
+ const delBtn = sessionItem.locator(".del");
194
+ // The delete button may be hidden until hover
195
+ await sessionItem.hover();
196
+ await delBtn.click();
197
+
198
+ // Expect the session to be removed
199
+ await expect(newSession).not.toBeVisible({ timeout: 5000 });
200
+ });
201
+
202
+ // ── 5. Tab management ──
203
+
204
+ test("create and close tab", async ({ page }) => {
205
+ await page.goto(BASE);
206
+ await expect(page.locator("#terminal canvas")).toBeVisible({
207
+ timeout: 10000,
208
+ });
209
+ await page.waitForFunction(
210
+ () =>
211
+ window._remuxTerm &&
212
+ document.querySelector("#status-dot")?.classList.contains("connected"),
213
+ { timeout: 10000 },
214
+ );
215
+
216
+ // Count current tabs
217
+ const tabList = page.locator("#tab-list");
218
+ const initialCount = await tabList.locator(".tab").count();
219
+
220
+ // Click "+" to create a new tab
221
+ await page.locator("#btn-new-tab").click();
222
+
223
+ // Wait for new tab to appear
224
+ await expect(tabList.locator(".tab")).toHaveCount(initialCount + 1, { timeout: 5000 });
225
+
226
+ // Close the last tab (click its × button)
227
+ const lastTab = tabList.locator(".tab").last();
228
+ await lastTab.hover();
229
+ await lastTab.locator(".close").click({ force: true });
230
+
231
+ // Tab count should return to initial
232
+ await expect(tabList.locator(".tab")).toHaveCount(initialCount, { timeout: 5000 });
233
+ });
234
+
235
+ // ── 6. Theme toggle ──
236
+
237
+ test("theme toggle switches dark/light", async ({ page }) => {
238
+ await page.goto(BASE);
239
+ await expect(page.locator("#terminal canvas")).toBeVisible({
240
+ timeout: 10000,
241
+ });
242
+
243
+ // Get initial theme
244
+ const initialTheme = await page.getAttribute("html", "data-theme");
245
+ expect(["dark", "light"]).toContain(initialTheme);
246
+
247
+ // Click theme toggle
248
+ await page.locator("#btn-theme").click();
249
+
250
+ // Wait for theme to change (terminal recreates, so give it time)
251
+ await page.waitForTimeout(1000);
252
+
253
+ const newTheme = await page.getAttribute("html", "data-theme");
254
+ expect(newTheme).not.toBe(initialTheme);
255
+ expect(["dark", "light"]).toContain(newTheme);
256
+
257
+ // Toggle back
258
+ await page.locator("#btn-theme").click();
259
+ await page.waitForTimeout(1000);
260
+
261
+ const restoredTheme = await page.getAttribute("html", "data-theme");
262
+ expect(restoredTheme).toBe(initialTheme);
263
+ });
264
+
265
+ // ── 7. Inspect search ──
266
+
267
+ test("inspect search highlights matches", async ({ page }) => {
268
+ await page.goto(BASE);
269
+ await expect(page.locator("#terminal canvas")).toBeVisible({
270
+ timeout: 10000,
271
+ });
272
+ await page.waitForFunction(
273
+ () =>
274
+ window._remuxTerm &&
275
+ document.querySelector("#status-dot")?.classList.contains("connected"),
276
+ { timeout: 10000 },
277
+ );
278
+
279
+ // Switch to Inspect
280
+ await page.locator("#btn-inspect").click();
281
+ await expect(page.locator("#inspect")).toHaveClass(/visible/, {
282
+ timeout: 5000,
283
+ });
284
+
285
+ // Wait for inspect data
286
+ await page.waitForFunction(() => !!window._inspectText, { timeout: 10000 });
287
+
288
+ // Type a search query that won't match anything
289
+ const searchInput = page.locator("#inspect-search-input");
290
+ await searchInput.fill("zzz-no-match-zzz");
291
+
292
+ // Should show "No matches"
293
+ const matchCount = page.locator("#inspect-match-count");
294
+ await expect(matchCount).toHaveText("No matches");
295
+
296
+ // Clear and search for something that exists (the shell prompt typically contains $)
297
+ await searchInput.fill("");
298
+
299
+ // Get the actual inspect text and search for a substring of it
300
+ const inspectText = await page.evaluate(() => window._inspectText || "");
301
+ // Find a short common substring to search for
302
+ // The terminal likely has the user's home dir, shell prompt, etc.
303
+ // Use a generic character that's almost certainly in any terminal output
304
+ if (inspectText.length > 0) {
305
+ // Search for first 3 printable chars from the inspect text
306
+ const searchable = inspectText.replace(/\s+/g, " ").trim();
307
+ const snippet = searchable.slice(0, 3);
308
+ if (snippet.length >= 1) {
309
+ await searchInput.fill(snippet);
310
+ // Should show match count or highlight
311
+ await expect(matchCount).not.toHaveText("");
312
+ // Check for highlight marks in content
313
+ const hasMarks = await page.evaluate(
314
+ () =>
315
+ document.querySelectorAll("#inspect-content mark").length > 0,
316
+ );
317
+ expect(hasMarks).toBe(true);
318
+ }
319
+ }
320
+ });
321
+
322
+ test("IME composition ignores viewport shrink on touch devices", async ({
323
+ browser,
324
+ }) => {
325
+ const context = await browser.newContext({
326
+ hasTouch: true,
327
+ isMobile: true,
328
+ viewport: { width: 1280, height: 720 },
329
+ });
330
+ const page = await context.newPage();
331
+
332
+ try {
333
+ await page.goto(`${BASE}&debug=1`);
334
+ await expect(page.locator("#terminal canvas")).toBeVisible({
335
+ timeout: 10000,
336
+ });
337
+ await page.waitForFunction(
338
+ () =>
339
+ window._remuxTerm &&
340
+ document.querySelector("#status-dot")?.classList.contains("connected"),
341
+ { timeout: 10000 },
342
+ );
343
+
344
+ const snapshot = await page.evaluate(async () => {
345
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
346
+ const visualViewport = window.visualViewport;
347
+ const viewportProto = visualViewport
348
+ ? Object.getPrototypeOf(visualViewport)
349
+ : null;
350
+ const originalHeight =
351
+ viewportProto &&
352
+ Object.getOwnPropertyDescriptor(viewportProto, "height");
353
+ const textarea = document.querySelector("#terminal textarea");
354
+ const terminal = document.getElementById("terminal");
355
+ const canvas = document.querySelector("#terminal canvas");
356
+
357
+ if (
358
+ !visualViewport ||
359
+ !viewportProto ||
360
+ !originalHeight ||
361
+ !textarea ||
362
+ !terminal ||
363
+ !canvas
364
+ ) {
365
+ return {
366
+ missing: {
367
+ visualViewport: !!visualViewport,
368
+ textarea: !!textarea,
369
+ terminal: !!terminal,
370
+ canvas: !!canvas,
371
+ },
372
+ };
373
+ }
374
+
375
+ const readLayout = () => ({
376
+ bodyHeight: document.body.offsetHeight,
377
+ bodyStyleHeight: document.body.style.height,
378
+ terminalHeight: terminal.offsetHeight,
379
+ canvasHeight: canvas.height,
380
+ });
381
+
382
+ const baseline = readLayout();
383
+
384
+ try {
385
+ textarea.focus();
386
+ textarea.dispatchEvent(
387
+ new CompositionEvent("compositionstart", {
388
+ bubbles: true,
389
+ data: "",
390
+ }),
391
+ );
392
+ textarea.dispatchEvent(
393
+ new CompositionEvent("compositionupdate", {
394
+ bubbles: true,
395
+ data: "zhong",
396
+ }),
397
+ );
398
+
399
+ Object.defineProperty(viewportProto, "height", {
400
+ configurable: true,
401
+ get: () => 120,
402
+ });
403
+ visualViewport.dispatchEvent(new Event("resize"));
404
+ await sleep(180);
405
+
406
+ const composing = readLayout();
407
+
408
+ textarea.dispatchEvent(
409
+ new CompositionEvent("compositionend", {
410
+ bubbles: true,
411
+ data: "中文",
412
+ }),
413
+ );
414
+ Object.defineProperty(viewportProto, "height", originalHeight);
415
+ visualViewport.dispatchEvent(new Event("resize"));
416
+ await sleep(180);
417
+
418
+ return { baseline, composing, recovered: readLayout() };
419
+ } finally {
420
+ Object.defineProperty(viewportProto, "height", originalHeight);
421
+ }
422
+ });
423
+
424
+ expect(snapshot.missing).toBeUndefined();
425
+ expect(snapshot.baseline.bodyHeight).toBeGreaterThan(400);
426
+ expect(snapshot.composing.bodyHeight).toBe(snapshot.baseline.bodyHeight);
427
+ expect(snapshot.composing.bodyStyleHeight).toBe(
428
+ snapshot.baseline.bodyStyleHeight,
429
+ );
430
+ expect(snapshot.composing.terminalHeight).toBe(
431
+ snapshot.baseline.terminalHeight,
432
+ );
433
+ expect(snapshot.composing.canvasHeight).toBe(
434
+ snapshot.baseline.canvasHeight,
435
+ );
436
+ expect(snapshot.recovered.bodyHeight).toBe(snapshot.baseline.bodyHeight);
437
+ expect(snapshot.recovered.terminalHeight).toBe(
438
+ snapshot.baseline.terminalHeight,
439
+ );
440
+ } finally {
441
+ await context.close();
442
+ }
443
+ });
444
+
445
+ test("desktop resize waits until composition ends before refit", async ({
446
+ page,
447
+ }) => {
448
+ await page.goto(`${BASE}&debug=1`);
449
+ await expect(page.locator("#terminal canvas")).toBeVisible({
450
+ timeout: 10000,
451
+ });
452
+ await page.waitForFunction(
453
+ () =>
454
+ window._remuxTerm &&
455
+ document.querySelector("#status-dot")?.classList.contains("connected"),
456
+ { timeout: 10000 },
457
+ );
458
+
459
+ const snapshot = await page.evaluate(async () => {
460
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
461
+ const textarea = document.querySelector("#terminal textarea");
462
+ const sidebar = document.getElementById("sidebar");
463
+ const term = window._remuxTerm;
464
+
465
+ if (!textarea || !sidebar || !term) {
466
+ return {
467
+ missing: {
468
+ textarea: !!textarea,
469
+ sidebar: !!sidebar,
470
+ term: !!term,
471
+ },
472
+ };
473
+ }
474
+
475
+ const baseline = {
476
+ cols: term.cols,
477
+ rows: term.rows,
478
+ sidebarCollapsed: sidebar.classList.contains("collapsed"),
479
+ };
480
+
481
+ textarea.focus();
482
+ textarea.dispatchEvent(
483
+ new CompositionEvent("compositionstart", {
484
+ bubbles: true,
485
+ data: "",
486
+ }),
487
+ );
488
+ textarea.dispatchEvent(
489
+ new CompositionEvent("compositionupdate", {
490
+ bubbles: true,
491
+ data: "nihon",
492
+ }),
493
+ );
494
+
495
+ sidebar.classList.add("collapsed");
496
+ await sleep(300);
497
+
498
+ const composing = {
499
+ cols: term.cols,
500
+ rows: term.rows,
501
+ sidebarCollapsed: sidebar.classList.contains("collapsed"),
502
+ };
503
+
504
+ textarea.dispatchEvent(
505
+ new CompositionEvent("compositionend", {
506
+ bubbles: true,
507
+ data: "日本語",
508
+ }),
509
+ );
510
+ await sleep(400);
511
+
512
+ const recovered = {
513
+ cols: term.cols,
514
+ rows: term.rows,
515
+ sidebarCollapsed: sidebar.classList.contains("collapsed"),
516
+ };
517
+
518
+ sidebar.classList.remove("collapsed");
519
+ await sleep(300);
520
+
521
+ return { baseline, composing, recovered };
522
+ });
523
+
524
+ expect(snapshot.missing).toBeUndefined();
525
+ expect(snapshot.composing.cols).toBe(snapshot.baseline.cols);
526
+ expect(snapshot.composing.rows).toBe(snapshot.baseline.rows);
527
+ expect(snapshot.recovered.cols).toBeGreaterThan(snapshot.baseline.cols);
528
+ expect(snapshot.recovered.sidebarCollapsed).toBe(true);
529
+ });
530
+ });