Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion server/src/bridge/terminal-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, test } from "bun:test";
import { afterEach, describe, expect, jest, test } from "bun:test";
import type { ServerWebSocket } from "bun";
import * as net from "node:net";
import { EventEmitter, once } from "node:events";
Expand Down Expand Up @@ -329,6 +329,61 @@ describe("terminal bridge sharing", () => {
});
}

test("first protocol 22 attach skips the relay without another protocol ping", async () => {
const socketPath = await startThinServer({ protocol: 22 });
const browser = {} as ServerWebSocket<unknown>;
const messages: string[] = [];
let probes = 0;
let resolveRepeatedProbe!: () => void;
const repeatedProbe = new Promise<void>((resolve) => {
resolveRepeatedProbe = resolve;
});
jest.useFakeTimers();
const bridge = createTerminalBridge({
clientSocketPath: socketPath,
// A remote ping costs a control round trip, longer than the relay
// wait. Only the first one answers; any later one never does.
herdrProtocol: () => {
probes += 1;
if (probes === 1) return Promise.resolve(22);
resolveRepeatedProbe();
return new Promise<number>(() => {});
},
safeSend: (_ws, payload) => {
messages.push(payload);
return true;
},
clientLabel: () => "browser",
markRpcError: () => undefined,
});
try {
const attached = bridge
.handleTerminalRpc(browser, "attach", "terminal.attach", {
terminal_id: "term_1",
cols: 100,
rows: 30,
})
.then(() => "attached");
// Timers stay frozen, so the attach can only finish if it never
// falls back to the relay wait.
expect(
await Promise.race([
attached,
repeatedProbe.then(() => "pinged again"),
]),
).toBe("attached");
expect(probes).toBe(1);
expect(
messages
.map((message) => JSON.parse(message))
.find((message) => message.id === "attach")?.result,
).toEqual({ ok: true });
} finally {
jest.useRealTimers();
bridge.dispose();
}
});

test("refreshes a reused terminal for a newly attached browser", async () => {
const socketPath = await startThinServer();
const firstBrowser = {} as ServerWebSocket<unknown>;
Expand Down
8 changes: 5 additions & 3 deletions server/src/bridge/terminal-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,9 @@ export function createTerminalBridge(args: {
// can never receive ServerMessage::Clipboard there. Skip the relay and
// use the endpoint session's clipboard events instead. Only the explicit
// legacy fallback still lacks OSC 52; browser copy/paste is unaffected.
const protocol = await args.herdrProtocol();
// The attach that got here already resolved the protocol, so this does
// not pay another control round trip against the relay wait.
const protocol = await bridgeProtocol();
if (disposed) return;
if (isTerminalHelloProtocol(protocol)) {
clipboardRelaySkipped = true;
Expand All @@ -487,7 +489,7 @@ export function createTerminalBridge(args: {
return;
}

const relay = new ThinClient(args.clientSocketPath, args.herdrProtocol);
const relay = new ThinClient(args.clientSocketPath, bridgeProtocol);
clipboardRelay = relay;
clipboardRelaySize = { cols, rows };
relay.on("clipboard", ({ data }) => {
Expand Down Expand Up @@ -802,7 +804,7 @@ export function createTerminalBridge(args: {
undefined,
surfaceCodecsEnabled,
)
: new ThinClient(args.clientSocketPath, args.herdrProtocol);
: new ThinClient(args.clientSocketPath, bridgeProtocol);
let resolveFirstFrame!: (seen: boolean) => void;
const firstFrame = new Promise<boolean>((resolve) => {
resolveFirstFrame = resolve;
Expand Down
30 changes: 21 additions & 9 deletions web/src/components/useMobileControlsDrag.keyboard.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterAll, afterEach, expect, test } from "bun:test";
import { afterAll, afterEach, expect, jest, test } from "bun:test";
import { Window } from "happy-dom";
import type { Root } from "react-dom/client";
import type { MobileControlsPlacement } from "../mobileControlsPlacement";
Expand Down Expand Up @@ -698,14 +698,26 @@ async function registerDomTests() {
'[aria-label="Send draft to the terminal"]',
)!;
expect(send.disabled).toBe(true);
await act(async () => {
input.focus();
input.dispatchEvent(
new browser.CompositionEvent("compositionend", {
bubbles: true,
}) as unknown as CompositionEvent,
);
});
// TerminalComposer releases the IME lock one timer tick after
// compositionend. Fake timers fire that tick explicitly, so the result does
// not depend on whether it beats act()'s setImmediate flush.
jest.useFakeTimers();
try {
await act(async () => {
input.focus();
input.dispatchEvent(
new browser.CompositionEvent("compositionend", {
bubbles: true,
}) as unknown as CompositionEvent,
);
});
expect(send.disabled).toBe(true);
await act(async () => {
jest.advanceTimersByTime(0);
});
} finally {
jest.useRealTimers();
}
expect(send.disabled).toBe(false);
document.documentElement.classList.remove("keyboard-open");
await act(async () =>
Expand Down
Loading