@wangyaoshen/remux 0.3.8-dev.bab6c95 → 0.3.10-dev.19fb76c
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/workflows/publish.yml +191 -17
- package/apps/ios/Remux.xcodeproj/project.pbxproj +21 -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/RootView.swift +2 -2
- package/apps/ios/Sources/Remux/Views/Settings/MeView.swift +11 -4
- package/apps/macos/Package.swift +5 -0
- package/apps/macos/Sources/Remux/AppCommand.swift +114 -0
- package/apps/macos/Sources/Remux/AppDelegate.swift +26 -0
- package/apps/macos/Sources/Remux/MainContentView.swift +56 -0
- package/apps/macos/Sources/Remux/MenuBarManager.swift +18 -26
- package/apps/macos/Sources/Remux/NotificationManager.swift +52 -7
- package/apps/macos/Sources/Remux/Views/ConnectionView.swift +4 -8
- package/apps/macos/Sources/Remux/Views/SplitTree/SplitView.swift +1 -1
- package/apps/macos/Sources/Remux/Views/Terminal/GhosttyNativeView.swift +10 -4
- package/apps/macos/Sources/Remux/Views/Terminal/TerminalContainerView.swift +35 -5
- package/apps/macos/Sources/Remux/WindowObserver.swift +38 -0
- package/apps/macos/Tests/RemuxTests/AppCommandTests.swift +30 -0
- package/apps/macos/Tests/RemuxTests/NotificationManagerTests.swift +28 -0
- package/package.json +1 -1
- package/packages/RemuxKit/Sources/RemuxKit/Models/ProtocolModels.swift +64 -0
- package/packages/RemuxKit/Sources/RemuxKit/Networking/MessageRouter.swift +88 -9
- package/packages/RemuxKit/Sources/RemuxKit/Networking/RemuxConnection.swift +47 -8
- package/packages/RemuxKit/Sources/RemuxKit/State/RemuxState.swift +81 -8
- package/packages/RemuxKit/Sources/RemuxKit/Storage/KeychainStore.swift +20 -1
- package/packages/RemuxKit/Tests/RemuxKitTests/ConnectionIntegrationTest.swift +16 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/KeychainStoreTests.swift +26 -0
- package/packages/RemuxKit/Tests/RemuxKitTests/ProtocolModelsTests.swift +41 -7
- package/packages/RemuxKit/Tests/RemuxKitTests/RemuxStateTests.swift +20 -2
- package/pty-daemon.js +17 -11
- package/scripts/setup-ci-secrets.sh +80 -0
- package/scripts/upload-testflight.sh +100 -0
- package/server.js +146 -872
- package/src/pty-daemon.ts +17 -11
- package/src/server.ts +96 -859
- package/src/session.ts +42 -4
- package/tests/auth.test.js +1 -1
- package/tests/e2e/app.spec.js +44 -4
- package/tests/pty-daemon.test.js +20 -1
- package/tests/server.test.js +50 -12
- package/vitest.config.js +1 -0
package/src/session.ts
CHANGED
|
@@ -160,6 +160,34 @@ function getShell(): string {
|
|
|
160
160
|
return "/bin/bash";
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
function formatPtyError(error: unknown): string {
|
|
164
|
+
if (error instanceof Error && error.message) return error.message;
|
|
165
|
+
if (typeof error === "string" && error) return error;
|
|
166
|
+
return "unknown PTY error";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function markTabUnavailable(
|
|
170
|
+
tab: Tab,
|
|
171
|
+
sessionName: string,
|
|
172
|
+
error: unknown,
|
|
173
|
+
): void {
|
|
174
|
+
const reason = formatPtyError(error);
|
|
175
|
+
const banner =
|
|
176
|
+
`\r\n\x1b[31mShell unavailable on this machine: ${reason}\x1b[0m\r\n`;
|
|
177
|
+
|
|
178
|
+
tab.ended = true;
|
|
179
|
+
tab.pty = null;
|
|
180
|
+
tab.daemonSocket = null;
|
|
181
|
+
tab.daemonClient = null;
|
|
182
|
+
tab.restored = false;
|
|
183
|
+
tab.scrollback.write(banner);
|
|
184
|
+
if (tab.vt) tab.vt.consume(banner);
|
|
185
|
+
|
|
186
|
+
console.error(
|
|
187
|
+
`[tab] shell unavailable for id=${tab.id} in session "${sessionName}": ${reason}`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
163
191
|
// ── State ────────────────────────────────────────────────────────
|
|
164
192
|
|
|
165
193
|
let tabIdCounter = 0;
|
|
@@ -353,6 +381,8 @@ export async function reviveTab(tab: Tab, session: Session): Promise<boolean> {
|
|
|
353
381
|
return true;
|
|
354
382
|
} catch (err: any) {
|
|
355
383
|
console.error(`[session] failed to revive tab ${tab.id}:`, err.message);
|
|
384
|
+
markTabUnavailable(tab, session.name, err);
|
|
385
|
+
broadcastState();
|
|
356
386
|
return false;
|
|
357
387
|
}
|
|
358
388
|
}
|
|
@@ -501,12 +531,20 @@ export function createTab(
|
|
|
501
531
|
console.log(`[tab] daemon connected for id=${id} in session "${session.name}"`);
|
|
502
532
|
}).catch((err) => {
|
|
503
533
|
console.error(`[tab] failed to connect to daemon for id=${id}:`, err.message);
|
|
504
|
-
|
|
505
|
-
|
|
534
|
+
try {
|
|
535
|
+
// Fallback: spawn direct PTY
|
|
536
|
+
spawnDirectPty(tab, session, shell, cols, rows);
|
|
537
|
+
} catch (spawnError) {
|
|
538
|
+
markTabUnavailable(tab, session.name, spawnError);
|
|
539
|
+
}
|
|
506
540
|
});
|
|
507
541
|
} else {
|
|
508
|
-
|
|
509
|
-
|
|
542
|
+
try {
|
|
543
|
+
// Direct PTY mode (fallback when daemon script not available)
|
|
544
|
+
spawnDirectPty(tab, session, shell, cols, rows);
|
|
545
|
+
} catch (err) {
|
|
546
|
+
markTabUnavailable(tab, session.name, err);
|
|
547
|
+
}
|
|
510
548
|
}
|
|
511
549
|
|
|
512
550
|
session.tabs.push(tab);
|
package/tests/auth.test.js
CHANGED
|
@@ -100,7 +100,7 @@ function startServer(env, port) {
|
|
|
100
100
|
const cleanEnv = { ...process.env };
|
|
101
101
|
delete cleanEnv.REMUX_TOKEN;
|
|
102
102
|
delete cleanEnv.REMUX_PASSWORD;
|
|
103
|
-
const proc = spawn(
|
|
103
|
+
const proc = spawn(process.execPath, [SERVER_JS], {
|
|
104
104
|
env: { ...cleanEnv, ...env, PORT: String(port) },
|
|
105
105
|
stdio: "pipe",
|
|
106
106
|
});
|
package/tests/e2e/app.spec.js
CHANGED
|
@@ -76,6 +76,11 @@ test.describe.serial("Remux E2E", () => {
|
|
|
76
76
|
// Sidebar should show "main" session
|
|
77
77
|
const sessionItem = page.locator(".session-item .name", { hasText: "main" });
|
|
78
78
|
await expect(sessionItem).toBeVisible();
|
|
79
|
+
|
|
80
|
+
await expect(page.locator("#btn-inspect")).toBeVisible();
|
|
81
|
+
await expect(page.locator("#btn-workspace")).toHaveCount(0);
|
|
82
|
+
await expect(page.locator("#devices-section")).toHaveCount(0);
|
|
83
|
+
await expect(page.locator("#push-section")).toHaveCount(0);
|
|
79
84
|
});
|
|
80
85
|
|
|
81
86
|
// ── 2. Live terminal interaction ──
|
|
@@ -174,11 +179,10 @@ test.describe.serial("Remux E2E", () => {
|
|
|
174
179
|
{ timeout: 10000 },
|
|
175
180
|
);
|
|
176
181
|
|
|
177
|
-
//
|
|
178
|
-
page.on("dialog", (d) => d.accept("test-e2e-session"));
|
|
179
|
-
|
|
180
|
-
// Click "+" to create a new session
|
|
182
|
+
// Open the inline composer and create a session
|
|
181
183
|
await page.locator("#btn-new-session").click();
|
|
184
|
+
await page.locator("#new-session-input").fill("test-e2e-session");
|
|
185
|
+
await page.locator("#btn-create-session").click();
|
|
182
186
|
|
|
183
187
|
// Wait for the new session to appear in sidebar
|
|
184
188
|
const newSession = page.locator(".session-item .name", {
|
|
@@ -319,6 +323,42 @@ test.describe.serial("Remux E2E", () => {
|
|
|
319
323
|
}
|
|
320
324
|
});
|
|
321
325
|
|
|
326
|
+
test("mobile chrome keeps control button visible and hides compose bar outside live", async ({
|
|
327
|
+
browser,
|
|
328
|
+
}) => {
|
|
329
|
+
const context = await browser.newContext({
|
|
330
|
+
hasTouch: true,
|
|
331
|
+
isMobile: true,
|
|
332
|
+
viewport: { width: 390, height: 664 },
|
|
333
|
+
});
|
|
334
|
+
const page = await context.newPage();
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
await page.goto(BASE);
|
|
338
|
+
await expect(page.locator("#terminal canvas")).toBeVisible({
|
|
339
|
+
timeout: 10000,
|
|
340
|
+
});
|
|
341
|
+
await page.waitForFunction(
|
|
342
|
+
() =>
|
|
343
|
+
window._remuxTerm &&
|
|
344
|
+
document.querySelector("#status-dot")?.classList.contains("connected"),
|
|
345
|
+
{ timeout: 10000 },
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
await expect(page.locator("#btn-role")).toBeVisible();
|
|
349
|
+
await expect(page.locator("#devices-section")).toHaveCount(0);
|
|
350
|
+
await expect(page.locator("#compose-bar")).toHaveClass(/visible/);
|
|
351
|
+
|
|
352
|
+
await page.locator("#btn-inspect").click();
|
|
353
|
+
await expect(page.locator("#inspect")).toHaveClass(/visible/, {
|
|
354
|
+
timeout: 5000,
|
|
355
|
+
});
|
|
356
|
+
await expect(page.locator("#compose-bar")).not.toHaveClass(/visible/);
|
|
357
|
+
} finally {
|
|
358
|
+
await context.close();
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
322
362
|
test("IME composition ignores viewport shrink on touch devices", async ({
|
|
323
363
|
browser,
|
|
324
364
|
}) => {
|
package/tests/pty-daemon.test.js
CHANGED
|
@@ -7,6 +7,7 @@ import net from "net";
|
|
|
7
7
|
import fs from "fs";
|
|
8
8
|
import path from "path";
|
|
9
9
|
import { fileURLToPath } from "url";
|
|
10
|
+
import pty from "node-pty";
|
|
10
11
|
|
|
11
12
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
13
|
const DAEMON_SCRIPT = path.join(__dirname, "..", "pty-daemon.js");
|
|
@@ -21,6 +22,24 @@ const TAG_SNAPSHOT_REQ = 0x06;
|
|
|
21
22
|
const TAG_SNAPSHOT_RES = 0x07;
|
|
22
23
|
const TAG_SHUTDOWN = 0xff;
|
|
23
24
|
|
|
25
|
+
function supportsNodePty() {
|
|
26
|
+
try {
|
|
27
|
+
const proc = pty.spawn("/bin/sh", ["-lc", "exit 0"], {
|
|
28
|
+
name: "xterm-256color",
|
|
29
|
+
cols: 80,
|
|
30
|
+
rows: 24,
|
|
31
|
+
cwd: "/tmp",
|
|
32
|
+
env: process.env,
|
|
33
|
+
});
|
|
34
|
+
proc.kill();
|
|
35
|
+
return true;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const describePtyLifecycle = supportsNodePty() ? describe : describe.skip;
|
|
42
|
+
|
|
24
43
|
function encodeFrame(tag, payload) {
|
|
25
44
|
const data =
|
|
26
45
|
typeof payload === "string" ? Buffer.from(payload, "utf8") : payload;
|
|
@@ -137,7 +156,7 @@ describe("TLV Frame Codec", () => {
|
|
|
137
156
|
});
|
|
138
157
|
});
|
|
139
158
|
|
|
140
|
-
|
|
159
|
+
describePtyLifecycle("PTY Daemon Lifecycle", () => {
|
|
141
160
|
let daemon;
|
|
142
161
|
let socketPath;
|
|
143
162
|
|
package/tests/server.test.js
CHANGED
|
@@ -11,8 +11,9 @@ import WebSocket from "ws";
|
|
|
11
11
|
import fs from "fs";
|
|
12
12
|
import path from "path";
|
|
13
13
|
import { homedir } from "os";
|
|
14
|
+
import pty from "node-pty";
|
|
14
15
|
|
|
15
|
-
const PORT = 19876; // test
|
|
16
|
+
const PORT = 19876 + Math.floor(Math.random() * 1000); // randomized test port
|
|
16
17
|
const TOKEN = "test-token-" + Date.now();
|
|
17
18
|
const INSTANCE_ID = "test-" + Date.now();
|
|
18
19
|
const PERSIST_DIR = path.join(homedir(), ".remux");
|
|
@@ -20,6 +21,24 @@ const PERSIST_FILE = path.join(PERSIST_DIR, `sessions-${INSTANCE_ID}.json`);
|
|
|
20
21
|
const DB_FILE = path.join(PERSIST_DIR, `remux-${INSTANCE_ID}.db`);
|
|
21
22
|
let serverProc;
|
|
22
23
|
|
|
24
|
+
function supportsNodePty() {
|
|
25
|
+
try {
|
|
26
|
+
const proc = pty.spawn("/bin/sh", ["-lc", "exit 0"], {
|
|
27
|
+
name: "xterm-256color",
|
|
28
|
+
cols: 80,
|
|
29
|
+
rows: 24,
|
|
30
|
+
cwd: "/tmp",
|
|
31
|
+
env: process.env,
|
|
32
|
+
});
|
|
33
|
+
proc.kill();
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const PTY_SUPPORTED = supportsNodePty();
|
|
41
|
+
|
|
23
42
|
function httpGet(urlPath) {
|
|
24
43
|
return new Promise((resolve, reject) => {
|
|
25
44
|
http.get(`http://localhost:${PORT}${urlPath}`, (res) => {
|
|
@@ -113,7 +132,7 @@ beforeAll(async () => {
|
|
|
113
132
|
// Explicitly remove REMUX_PASSWORD to avoid env leaking from parent
|
|
114
133
|
const cleanEnv = { ...process.env };
|
|
115
134
|
delete cleanEnv.REMUX_PASSWORD;
|
|
116
|
-
serverProc = spawn(
|
|
135
|
+
serverProc = spawn(process.execPath, ["server.js"], {
|
|
117
136
|
env: {
|
|
118
137
|
...cleanEnv,
|
|
119
138
|
PORT: String(PORT),
|
|
@@ -185,6 +204,15 @@ describe("HTTP", () => {
|
|
|
185
204
|
expect(res.body).toContain("<title>Remux</title>");
|
|
186
205
|
});
|
|
187
206
|
|
|
207
|
+
it("serves reduced shell without workspace or device chrome", async () => {
|
|
208
|
+
const res = await httpGet(`/?token=${TOKEN}`);
|
|
209
|
+
expect(res.status).toBe(200);
|
|
210
|
+
expect(res.body).toContain("Inspect");
|
|
211
|
+
expect(res.body).not.toContain("Workspace");
|
|
212
|
+
expect(res.body).not.toContain("Devices");
|
|
213
|
+
expect(res.body).not.toContain("Enable Notifications");
|
|
214
|
+
});
|
|
215
|
+
|
|
188
216
|
it("serves ghostty-web JS", async () => {
|
|
189
217
|
const res = await httpGet("/dist/ghostty-web.js");
|
|
190
218
|
expect(res.status).toBe(200);
|
|
@@ -513,16 +541,18 @@ describe("client connection state", () => {
|
|
|
513
541
|
const ws2 = await connectAuthed();
|
|
514
542
|
|
|
515
543
|
// ws1 attaches first (active)
|
|
516
|
-
await sendAndCollect(
|
|
544
|
+
const msgs1 = await sendAndCollect(
|
|
517
545
|
ws1,
|
|
518
546
|
{ type: "attach_first", session: "main", cols: 80, rows: 24 },
|
|
519
547
|
{ timeout: 3000 },
|
|
520
548
|
);
|
|
549
|
+
const att1 = msgs1.find((m) => m.type === "attached");
|
|
550
|
+
expect(att1).toBeDefined();
|
|
521
551
|
|
|
522
552
|
// ws2 attaches to same tab (observer)
|
|
523
553
|
const msgs2 = await sendAndCollect(
|
|
524
554
|
ws2,
|
|
525
|
-
{ type: "
|
|
555
|
+
{ type: "attach_tab", tabId: att1.tabId, cols: 80, rows: 24 },
|
|
526
556
|
{ timeout: 3000 },
|
|
527
557
|
);
|
|
528
558
|
const att2 = msgs2.find((m) => m.type === "attached");
|
|
@@ -552,11 +582,12 @@ describe("client connection state", () => {
|
|
|
552
582
|
const att1 = msgs1.find((m) => m.type === "attached");
|
|
553
583
|
expect(att1.role).toBe("active");
|
|
554
584
|
const clientId1 = att1.clientId;
|
|
585
|
+
const tabId = att1.tabId;
|
|
555
586
|
|
|
556
587
|
// ws2 attaches (observer)
|
|
557
588
|
const msgs2 = await sendAndCollect(
|
|
558
589
|
ws2,
|
|
559
|
-
{ type: "
|
|
590
|
+
{ type: "attach_tab", tabId, cols: 80, rows: 24 },
|
|
560
591
|
{ timeout: 3000 },
|
|
561
592
|
);
|
|
562
593
|
const att2 = msgs2.find((m) => m.type === "attached");
|
|
@@ -617,11 +648,12 @@ describe("client connection state", () => {
|
|
|
617
648
|
const att1 = msgs1.find((m) => m.type === "attached");
|
|
618
649
|
expect(att1.role).toBe("active");
|
|
619
650
|
const clientId1 = att1.clientId;
|
|
651
|
+
const tabId = att1.tabId;
|
|
620
652
|
|
|
621
653
|
// ws2 attaches (observer)
|
|
622
654
|
const msgs2 = await sendAndCollect(
|
|
623
655
|
ws2,
|
|
624
|
-
{ type: "
|
|
656
|
+
{ type: "attach_tab", tabId, cols: 80, rows: 24 },
|
|
625
657
|
{ timeout: 3000 },
|
|
626
658
|
);
|
|
627
659
|
const att2 = msgs2.find((m) => m.type === "attached");
|
|
@@ -669,16 +701,18 @@ describe("client connection state", () => {
|
|
|
669
701
|
const ws2 = await connectAuthed();
|
|
670
702
|
|
|
671
703
|
// ws1 attaches (active)
|
|
672
|
-
await sendAndCollect(
|
|
704
|
+
const msgs1 = await sendAndCollect(
|
|
673
705
|
ws1,
|
|
674
706
|
{ type: "attach_first", session: "main", cols: 80, rows: 24 },
|
|
675
707
|
{ timeout: 3000 },
|
|
676
708
|
);
|
|
709
|
+
const att1 = msgs1.find((m) => m.type === "attached");
|
|
710
|
+
expect(att1).toBeDefined();
|
|
677
711
|
|
|
678
712
|
// ws2 attaches (observer)
|
|
679
713
|
const msgs2 = await sendAndCollect(
|
|
680
714
|
ws2,
|
|
681
|
-
{ type: "
|
|
715
|
+
{ type: "attach_tab", tabId: att1.tabId, cols: 80, rows: 24 },
|
|
682
716
|
{ timeout: 3000 },
|
|
683
717
|
);
|
|
684
718
|
const att2 = msgs2.find((m) => m.type === "attached");
|
|
@@ -720,15 +754,15 @@ describe("multi-client", () => {
|
|
|
720
754
|
{ type: "attach_first", session: "main", cols: 100, rows: 30 },
|
|
721
755
|
{ timeout: 3000 },
|
|
722
756
|
);
|
|
757
|
+
const att1 = msgs1.find((m) => m.type === "attached");
|
|
758
|
+
expect(att1).toBeDefined();
|
|
723
759
|
const msgs2 = await sendAndCollect(
|
|
724
760
|
ws2,
|
|
725
|
-
{ type: "
|
|
761
|
+
{ type: "attach_tab", tabId: att1.tabId, cols: 80, rows: 24 },
|
|
726
762
|
{ timeout: 3000 },
|
|
727
763
|
);
|
|
728
764
|
|
|
729
|
-
const att1 = msgs1.find((m) => m.type === "attached");
|
|
730
765
|
const att2 = msgs2.find((m) => m.type === "attached");
|
|
731
|
-
expect(att1).toBeDefined();
|
|
732
766
|
expect(att2).toBeDefined();
|
|
733
767
|
expect(att1.tabId).toBe(att2.tabId);
|
|
734
768
|
|
|
@@ -840,7 +874,11 @@ describe("inspect", () => {
|
|
|
840
874
|
const result = msgs.find((m) => m.type === "inspect_result");
|
|
841
875
|
expect(result).toBeDefined();
|
|
842
876
|
expect(typeof result.text).toBe("string");
|
|
843
|
-
|
|
877
|
+
if (PTY_SUPPORTED) {
|
|
878
|
+
expect(result.text).toContain("inspect-test-output");
|
|
879
|
+
} else {
|
|
880
|
+
expect(result.text).toContain("Shell unavailable on this machine");
|
|
881
|
+
}
|
|
844
882
|
expect(result.meta).toBeDefined();
|
|
845
883
|
expect(result.meta.session).toBe("main");
|
|
846
884
|
expect(typeof result.meta.cols).toBe("number");
|