Skip to content

Commit 5aa3b04

Browse files
committed
fix(web): harden mobile terminal line copy
1 parent 93e642d commit 5aa3b04

4 files changed

Lines changed: 533 additions & 19 deletions

File tree

packages/web/src/features/terminal-panel/__tests__/xterm-host.test.tsx

Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,60 @@ describe("XtermHost", () => {
428428
});
429429
});
430430

431+
it("falls back to document.execCommand when desktop clipboard writeText is rejected", async () => {
432+
const store = createStore();
433+
const writeText = vi.fn().mockRejectedValue(new Error("clipboard rejected"));
434+
const execCommand = vi.fn().mockReturnValue(true);
435+
436+
store.set(localeAtom, "en");
437+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
438+
store.set(wsClientAtom, {
439+
sendCommand: vi.fn().mockResolvedValue({ status: "ok" }),
440+
subscribe: vi.fn(() => () => {}),
441+
getStatus: vi.fn(() => "connected"),
442+
onStatus: vi.fn(() => () => {}),
443+
} as never);
444+
Object.defineProperty(navigator, "clipboard", {
445+
configurable: true,
446+
value: { writeText } satisfies Pick<Clipboard, "writeText">,
447+
});
448+
Object.defineProperty(document, "execCommand", {
449+
configurable: true,
450+
value: execCommand,
451+
});
452+
453+
const { container } = render(
454+
<Provider store={store}>
455+
<XtermHost terminalId="copy-fallback-terminal" workspaceId="test-workspace" />
456+
</Provider>
457+
);
458+
459+
mockTerminal.hasSelection.mockReturnValue(true);
460+
mockTerminal.getSelection.mockReturnValue("selected text");
461+
462+
const selectionHandler = mockTerminal.onSelectionChange.mock.calls[0]?.[0] as
463+
| (() => void)
464+
| undefined;
465+
466+
await act(async () => {
467+
selectionHandler?.();
468+
});
469+
470+
fireEvent.pointerDown(container.querySelector(".xterm-host")!, {
471+
pointerType: "mouse",
472+
pointerId: 1,
473+
});
474+
fireEvent.pointerUp(container.querySelector(".xterm-host")!, {
475+
pointerType: "mouse",
476+
pointerId: 1,
477+
});
478+
479+
await waitFor(() => {
480+
expect(writeText).toHaveBeenCalledWith("selected text");
481+
expect(execCommand).toHaveBeenCalledWith("copy");
482+
});
483+
});
484+
431485
it("copies the terminal selection when desktop mouse pointerup ends outside the host", async () => {
432486
const store = createStore();
433487
const writeText = vi.fn().mockResolvedValue(undefined);
@@ -664,6 +718,7 @@ describe("XtermHost", () => {
664718
it("pushes only one error toast within the throttle window when clipboard writes fail", async () => {
665719
const store = createStore();
666720
const writeText = vi.fn().mockRejectedValue(new Error("clipboard failed"));
721+
const execCommand = vi.fn().mockReturnValue(false);
667722

668723
store.set(localeAtom, "zh");
669724
store.set(terminalPreferencesAtom, { copyOnSelect: true });
@@ -677,6 +732,10 @@ describe("XtermHost", () => {
677732
configurable: true,
678733
value: { writeText } satisfies Pick<Clipboard, "writeText">,
679734
});
735+
Object.defineProperty(document, "execCommand", {
736+
configurable: true,
737+
value: execCommand,
738+
});
680739

681740
const { container } = render(
682741
<Provider store={store}>
@@ -7077,6 +7136,13 @@ describe("XtermHost", () => {
70777136
await act(async () => {
70787137
vi.advanceTimersByTime(500);
70797138
await Promise.resolve();
7139+
});
7140+
7141+
expect(writeText).not.toHaveBeenCalled();
7142+
7143+
dispatchTouchEvent(host!, "touchend", [], [{ identifier: 1, clientX: 6, clientY: 150 }]);
7144+
7145+
await act(async () => {
70807146
await Promise.resolve();
70817147
});
70827148

@@ -7161,6 +7227,13 @@ describe("XtermHost", () => {
71617227
await act(async () => {
71627228
vi.advanceTimersByTime(500);
71637229
await Promise.resolve();
7230+
});
7231+
7232+
expect(writeText).not.toHaveBeenCalled();
7233+
7234+
dispatchTouchEvent(host!, "touchend", [], [{ identifier: 1, clientX: 20, clientY: 110 }]);
7235+
7236+
await act(async () => {
71647237
await Promise.resolve();
71657238
});
71667239

@@ -7488,6 +7561,7 @@ describe("XtermHost", () => {
74887561

74897562
const originalMatchMedia = window.matchMedia;
74907563
const writeText = vi.fn().mockRejectedValue(new Error("clipboard failed"));
7564+
const execCommand = vi.fn().mockReturnValue(false);
74917565
const vibrate = vi.fn();
74927566
const store = createStore();
74937567

@@ -7514,6 +7588,10 @@ describe("XtermHost", () => {
75147588
configurable: true,
75157589
value: vibrate,
75167590
});
7591+
Object.defineProperty(document, "execCommand", {
7592+
configurable: true,
7593+
value: execCommand,
7594+
});
75177595

75187596
store.set(localeAtom, "zh");
75197597
store.set(terminalPreferencesAtom, { copyOnSelect: true });
@@ -7548,6 +7626,13 @@ describe("XtermHost", () => {
75487626
await act(async () => {
75497627
vi.advanceTimersByTime(500);
75507628
await Promise.resolve();
7629+
});
7630+
7631+
expect(writeText).not.toHaveBeenCalled();
7632+
7633+
dispatchTouchEvent(host!, "touchend", [], [{ identifier: 1, clientX: 20, clientY: 110 }]);
7634+
7635+
await act(async () => {
75517636
await Promise.resolve();
75527637
});
75537638

@@ -7566,6 +7651,103 @@ describe("XtermHost", () => {
75667651
vi.useRealTimers();
75677652
});
75687653

7654+
it("mobile line copy falls back to document.execCommand when clipboard writeText is rejected", async () => {
7655+
vi.useFakeTimers();
7656+
viewportMocks.viewport = "mobile";
7657+
7658+
const originalMatchMedia = window.matchMedia;
7659+
const writeText = vi.fn().mockRejectedValue(new Error("clipboard rejected"));
7660+
const execCommand = vi.fn().mockReturnValue(true);
7661+
const vibrate = vi.fn();
7662+
const store = createStore();
7663+
7664+
mockTerminal.cols = 80;
7665+
mockTerminal.buffer.active.viewportY = 8;
7666+
setMockBufferLines([[8, "fallback line", false]]);
7667+
7668+
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
7669+
matches: query === "(pointer: coarse)",
7670+
media: query,
7671+
onchange: null,
7672+
addListener: vi.fn(),
7673+
removeListener: vi.fn(),
7674+
addEventListener: vi.fn(),
7675+
removeEventListener: vi.fn(),
7676+
dispatchEvent: vi.fn(),
7677+
})) as typeof window.matchMedia;
7678+
7679+
Object.defineProperty(navigator, "clipboard", {
7680+
configurable: true,
7681+
value: { writeText } satisfies Pick<Clipboard, "writeText">,
7682+
});
7683+
Object.defineProperty(navigator, "vibrate", {
7684+
configurable: true,
7685+
value: vibrate,
7686+
});
7687+
Object.defineProperty(document, "execCommand", {
7688+
configurable: true,
7689+
value: execCommand,
7690+
});
7691+
7692+
store.set(localeAtom, "en");
7693+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
7694+
store.set(wsClientAtom, {
7695+
sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }),
7696+
subscribe: vi.fn(() => () => {}),
7697+
getStatus: vi.fn(() => "connected"),
7698+
onStatus: vi.fn(() => () => {}),
7699+
sendTerminalInput: vi.fn().mockResolvedValue(undefined),
7700+
} as never);
7701+
7702+
const { container } = render(
7703+
<Provider store={store}>
7704+
<XtermHost terminalId="mobile-line-copy-fallback-terminal" workspaceId="test-workspace" />
7705+
</Provider>
7706+
);
7707+
7708+
const host = container.querySelector(".xterm-host") as HTMLDivElement | null;
7709+
expect(host).toBeTruthy();
7710+
7711+
const rowsElement = document.createElement("div");
7712+
rowsElement.className = "xterm-rows";
7713+
rowsElement.innerHTML = "<div><span>fallback line</span></div>";
7714+
host!.appendChild(rowsElement);
7715+
stubRowsGeometry(host!, rowsElement, [rowsElement.firstElementChild as HTMLDivElement], {
7716+
rowsRect: { x: 0, y: 100, width: 320, height: 20 },
7717+
rowHeight: 20,
7718+
});
7719+
7720+
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 20, clientY: 110 }]);
7721+
7722+
await act(async () => {
7723+
vi.advanceTimersByTime(500);
7724+
await Promise.resolve();
7725+
});
7726+
7727+
expect(writeText).not.toHaveBeenCalled();
7728+
7729+
dispatchTouchEvent(host!, "touchend", [], [{ identifier: 1, clientX: 20, clientY: 110 }]);
7730+
7731+
await act(async () => {
7732+
await Promise.resolve();
7733+
});
7734+
7735+
expect(writeText).toHaveBeenCalledWith("fallback line");
7736+
expect(execCommand).toHaveBeenCalledWith("copy");
7737+
expect(store.get(toastsAtom)).toEqual(
7738+
expect.arrayContaining([
7739+
expect.objectContaining({
7740+
kind: "success",
7741+
title: "Copied current line",
7742+
}),
7743+
])
7744+
);
7745+
expect(vibrate).toHaveBeenCalledWith(10);
7746+
7747+
window.matchMedia = originalMatchMedia;
7748+
vi.useRealTimers();
7749+
});
7750+
75697751
it("mobile line copy still succeeds when the touch target is only the host element", async () => {
75707752
vi.useFakeTimers();
75717753
viewportMocks.viewport = "mobile";
@@ -7641,6 +7823,13 @@ describe("XtermHost", () => {
76417823
await act(async () => {
76427824
vi.advanceTimersByTime(500);
76437825
await Promise.resolve();
7826+
});
7827+
7828+
expect(writeText).not.toHaveBeenCalled();
7829+
7830+
dispatchTouchEvent(host!, "touchend", [], [{ identifier: 1, clientX: 40, clientY: 130 }]);
7831+
7832+
await act(async () => {
76447833
await Promise.resolve();
76457834
});
76467835

@@ -7816,6 +8005,91 @@ describe("XtermHost", () => {
78168005
vi.useRealTimers();
78178006
});
78188007

8008+
it("mobile line copy does not copy an empty row when a long press lands in the right gutter outside the xterm screen grid", async () => {
8009+
vi.useFakeTimers();
8010+
viewportMocks.viewport = "mobile";
8011+
8012+
const originalMatchMedia = window.matchMedia;
8013+
const writeText = vi.fn().mockResolvedValue(undefined);
8014+
const vibrate = vi.fn();
8015+
const store = createStore();
8016+
8017+
mockTerminal.cols = 80;
8018+
mockTerminal.buffer.active.viewportY = 16;
8019+
setMockBufferLines([[16, "", false]]);
8020+
8021+
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
8022+
matches: query === "(pointer: coarse)",
8023+
media: query,
8024+
onchange: null,
8025+
addListener: vi.fn(),
8026+
removeListener: vi.fn(),
8027+
addEventListener: vi.fn(),
8028+
removeEventListener: vi.fn(),
8029+
dispatchEvent: vi.fn(),
8030+
})) as typeof window.matchMedia;
8031+
8032+
Object.defineProperty(navigator, "clipboard", {
8033+
configurable: true,
8034+
value: { writeText } satisfies Pick<Clipboard, "writeText">,
8035+
});
8036+
Object.defineProperty(navigator, "vibrate", {
8037+
configurable: true,
8038+
value: vibrate,
8039+
});
8040+
8041+
store.set(localeAtom, "en");
8042+
store.set(terminalPreferencesAtom, { copyOnSelect: true });
8043+
store.set(wsClientAtom, {
8044+
sendCommand: vi.fn().mockResolvedValue({ ok: true, data: { status: "ok" } }),
8045+
subscribe: vi.fn(() => () => {}),
8046+
getStatus: vi.fn(() => "connected"),
8047+
onStatus: vi.fn(() => () => {}),
8048+
sendTerminalInput: vi.fn().mockResolvedValue(undefined),
8049+
} as never);
8050+
8051+
const { container } = render(
8052+
<Provider store={store}>
8053+
<XtermHost
8054+
terminalId="mobile-line-copy-empty-right-gutter-terminal"
8055+
workspaceId="test-workspace"
8056+
/>
8057+
</Provider>
8058+
);
8059+
8060+
const host = container.querySelector(".xterm-host") as HTMLDivElement | null;
8061+
expect(host).toBeTruthy();
8062+
8063+
const screenElement = document.createElement("div");
8064+
screenElement.className = "xterm-screen";
8065+
host!.appendChild(screenElement);
8066+
8067+
const rowsElement = document.createElement("div");
8068+
rowsElement.className = "xterm-rows";
8069+
rowsElement.innerHTML = "<div><span>&nbsp;</span></div>";
8070+
host!.appendChild(rowsElement);
8071+
stubRowsGeometry(host!, rowsElement, [rowsElement.firstElementChild as HTMLDivElement], {
8072+
rowsRect: { x: 0, y: 100, width: 320, height: 20 },
8073+
screenRect: { x: 0, y: 100, width: 280, height: 20 },
8074+
rowHeight: 20,
8075+
});
8076+
8077+
dispatchTouchEvent(host!, "touchstart", [{ identifier: 1, clientX: 300, clientY: 110 }]);
8078+
8079+
await act(async () => {
8080+
vi.advanceTimersByTime(500);
8081+
await Promise.resolve();
8082+
await Promise.resolve();
8083+
});
8084+
8085+
expect(writeText).not.toHaveBeenCalled();
8086+
expect(vibrate).not.toHaveBeenCalled();
8087+
expect(store.get(toastsAtom)).toEqual([]);
8088+
8089+
window.matchMedia = originalMatchMedia;
8090+
vi.useRealTimers();
8091+
});
8092+
78198093
it("mobile line copy still succeeds after the row DOM is replaced before long press matures", async () => {
78208094
vi.useFakeTimers();
78218095
viewportMocks.viewport = "mobile";
@@ -7890,6 +8164,13 @@ describe("XtermHost", () => {
78908164
await act(async () => {
78918165
vi.advanceTimersByTime(500);
78928166
await Promise.resolve();
8167+
});
8168+
8169+
expect(writeText).not.toHaveBeenCalled();
8170+
8171+
dispatchTouchEvent(host!, "touchend", [], [{ identifier: 1, clientX: 40, clientY: 130 }]);
8172+
8173+
await act(async () => {
78938174
await Promise.resolve();
78948175
});
78958176

0 commit comments

Comments
 (0)