@wangyaoshen/remux 0.3.8-dev.a8ceb0c
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.
- package/.github/ISSUE_TEMPLATE/bug_report.md +47 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +38 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +28 -0
- package/.github/dependabot.yml +33 -0
- package/.github/workflows/ci.yml +65 -0
- package/.github/workflows/deploy.yml +65 -0
- package/.github/workflows/publish.yml +312 -0
- package/.github/workflows/release-please.yml +21 -0
- package/.gitmodules +3 -0
- package/.nvmrc +1 -0
- package/.release-please-manifest.json +3 -0
- package/CLAUDE.md +104 -0
- package/Dockerfile +23 -0
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/apps/ios/Config/signing.xcconfig +4 -0
- package/apps/ios/Package.swift +26 -0
- package/apps/ios/Remux.xcodeproj/project.pbxproj +477 -0
- package/apps/ios/Remux.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/Contents.json +23 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_1024x1024.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_120x120.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_152x152.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_167x167.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_180x180.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_20x20.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_29x29.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_40x40.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_58x58.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_60x60.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_76x76.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_80x80.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/AppIcon.appiconset/icon_87x87.png +0 -0
- package/apps/ios/Sources/Remux/Assets.xcassets/Contents.json +6 -0
- package/apps/ios/Sources/Remux/Extensions/FaceIDManager.swift +29 -0
- package/apps/ios/Sources/Remux/Extensions/InspectCache.swift +66 -0
- package/apps/ios/Sources/Remux/MainTabView.swift +32 -0
- package/apps/ios/Sources/Remux/Remux.entitlements +8 -0
- package/apps/ios/Sources/Remux/RemuxiOSApp.swift +14 -0
- package/apps/ios/Sources/Remux/RootView.swift +130 -0
- package/apps/ios/Sources/Remux/Views/Control/ControlView.swift +102 -0
- package/apps/ios/Sources/Remux/Views/Inspect/InspectView.swift +98 -0
- package/apps/ios/Sources/Remux/Views/Live/LiveTerminalView.swift +132 -0
- package/apps/ios/Sources/Remux/Views/Now/NowView.swift +173 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/ManualConnectView.swift +55 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/OnboardingView.swift +70 -0
- package/apps/ios/Sources/Remux/Views/Onboarding/QRScannerView.swift +92 -0
- package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +136 -0
- package/apps/macos/Package.swift +37 -0
- package/apps/macos/Resources/shell-integration/bash/bash-preexec.sh +382 -0
- package/apps/macos/Resources/shell-integration/bash/ghostty.bash +315 -0
- package/apps/macos/Resources/shell-integration/elvish/lib/ghostty-integration.elv +191 -0
- package/apps/macos/Resources/shell-integration/fish/vendor_conf.d/ghostty-shell-integration.fish +246 -0
- package/apps/macos/Resources/shell-integration/nushell/vendor/autoload/ghostty.nu +110 -0
- package/apps/macos/Resources/shell-integration/zsh/.zshenv +61 -0
- package/apps/macos/Resources/shell-integration/zsh/ghostty-integration +458 -0
- package/apps/macos/Resources/terminfo/67/ghostty +0 -0
- package/apps/macos/Resources/terminfo/78/xterm-ghostty +0 -0
- package/apps/macos/Sources/Remux/AppDelegate.swift +257 -0
- package/apps/macos/Sources/Remux/CrashReporter.swift +210 -0
- package/apps/macos/Sources/Remux/FinderIntegration.swift +117 -0
- package/apps/macos/Sources/Remux/GhosttyConfig.swift +311 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutAction.swift +115 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/ShortcutSettingsView.swift +271 -0
- package/apps/macos/Sources/Remux/KeyboardShortcuts/StoredShortcut.swift +149 -0
- package/apps/macos/Sources/Remux/MainContentView.swift +308 -0
- package/apps/macos/Sources/Remux/MenuBarManager.swift +275 -0
- package/apps/macos/Sources/Remux/NotificationManager.swift +145 -0
- package/apps/macos/Sources/Remux/PortScanner.swift +152 -0
- package/apps/macos/Sources/Remux/RemuxApp.swift +13 -0
- package/apps/macos/Sources/Remux/SSHDetector.swift +151 -0
- package/apps/macos/Sources/Remux/SessionPersistence.swift +226 -0
- package/apps/macos/Sources/Remux/SocketController.swift +258 -0
- package/apps/macos/Sources/Remux/UpdateChecker.swift +152 -0
- package/apps/macos/Sources/Remux/Views/CommandPalette.swift +198 -0
- package/apps/macos/Sources/Remux/Views/ConnectionView.swift +84 -0
- package/apps/macos/Sources/Remux/Views/InspectView.swift +127 -0
- package/apps/macos/Sources/Remux/Views/SettingsView.swift +77 -0
- package/apps/macos/Sources/Remux/Views/Sidebar/SidebarView.swift +410 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/BrowserPanel.swift +193 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/MarkdownPanel.swift +277 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/PanelProtocol.swift +14 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitNode.swift +149 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +234 -0
- package/apps/macos/Sources/Remux/Views/SplitTree/TerminalPanel.swift +26 -0
- package/apps/macos/Sources/Remux/Views/TabBarView.swift +94 -0
- package/apps/macos/Sources/Remux/Views/Terminal/ClipboardHelper.swift +101 -0
- package/apps/macos/Sources/Remux/Views/Terminal/CopyModeOverlay.swift +325 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeTerminalView.swift +39 -0
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +559 -0
- package/apps/macos/Sources/Remux/Views/Terminal/SurfaceSearchOverlay.swift +109 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +95 -0
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalRelay.swift +117 -0
- package/build.mjs +33 -0
- package/native/android/DecodeGoldenPayloads.kt +487 -0
- package/native/android/ProtocolModels.kt +188 -0
- package/native/ios/DecodeGoldenPayloads.swift +711 -0
- package/native/ios/ProtocolModels.swift +200 -0
- package/package.json +45 -0
- package/packages/RemuxKit/Package.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Device/DeviceManager.swift +27 -0
- package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +206 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +108 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +395 -0
- package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +188 -0
- package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +142 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyBridge.swift +145 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/GhosttyTerminalView.swift +35 -0
- package/packages/RemuxKit/Sources/RemuxKit/Terminal/Resources/ghostty-terminal.html +91 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +74 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +81 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +179 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +62 -0
- package/playwright.config.ts +17 -0
- package/pnpm-lock.yaml +1588 -0
- package/pty-daemon.js +303 -0
- package/release-please-config.json +14 -0
- package/scripts/auto-deploy.sh +46 -0
- package/scripts/build-dmg.sh +121 -0
- package/scripts/build-ghostty-kit.sh +43 -0
- package/scripts/check-active-terminology.mjs +132 -0
- package/scripts/setup-ci-secrets.sh +80 -0
- package/scripts/sync-ghostty-web.sh +28 -0
- package/scripts/upload-testflight.sh +100 -0
- package/server.js +7074 -0
- package/src/adapters/agent-events.ts +246 -0
- package/src/adapters/claude-code.ts +158 -0
- package/src/adapters/codex.ts +210 -0
- package/src/adapters/generic-shell.ts +58 -0
- package/src/adapters/index.ts +15 -0
- package/src/adapters/registry.ts +99 -0
- package/src/adapters/types.ts +41 -0
- package/src/auth.ts +174 -0
- package/src/e2ee.ts +236 -0
- package/src/git-service.ts +168 -0
- package/src/message-buffer.ts +137 -0
- package/src/pty-daemon.ts +357 -0
- package/src/push.ts +127 -0
- package/src/renderers.ts +455 -0
- package/src/server.ts +2407 -0
- package/src/service.ts +226 -0
- package/src/session.ts +978 -0
- package/src/store.ts +1422 -0
- package/src/team.ts +123 -0
- package/src/tunnel.ts +126 -0
- package/src/types.d.ts +50 -0
- package/src/vt-tracker.ts +188 -0
- package/src/workspace-head.ts +144 -0
- package/src/workspace.ts +153 -0
- package/src/ws-handler.ts +1526 -0
- package/start.ps1 +83 -0
- package/tests/adapters.test.js +171 -0
- package/tests/auth.test.js +243 -0
- package/tests/codex-adapter.test.js +535 -0
- package/tests/durable-stream.test.js +153 -0
- package/tests/e2e/app.spec.js +530 -0
- package/tests/e2ee.test.js +325 -0
- package/tests/message-buffer.test.js +245 -0
- package/tests/message-routing.test.js +305 -0
- package/tests/pty-daemon.test.js +346 -0
- package/tests/push.test.js +281 -0
- package/tests/renderers.test.js +391 -0
- package/tests/search-shell.test.js +499 -0
- package/tests/server.test.js +882 -0
- package/tests/service.test.js +267 -0
- package/tests/store.test.js +369 -0
- package/tests/tunnel.test.js +67 -0
- package/tests/workspace-head.test.js +116 -0
- package/tests/workspace.test.js +417 -0
- package/tsconfig.backend.json +11 -0
- package/tsconfig.json +15 -0
- package/tui/client/client_test.go +125 -0
- package/tui/client/connection.go +342 -0
- package/tui/client/host_manager.go +141 -0
- package/tui/config/cache.go +81 -0
- package/tui/config/config.go +53 -0
- package/tui/config/config_test.go +89 -0
- package/tui/go.mod +32 -0
- package/tui/go.sum +50 -0
- package/tui/main.go +261 -0
- package/tui/tests/integration_test.go +283 -0
- package/tui/ui/model.go +310 -0
- 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
|
+
});
|