Files
Netcatty/components/terminal/TerminalConnectionDialog.test.ts
T
陈大猫andnetcatty-bot d6efb6a313 fix(terminal): keep Enter reconnect working after disconnect (#2550)
* fix(terminal): keep Enter reconnect working after disconnect

Disconnected sessions advertised "Press Enter to reconnect" but often
swallowed the key when focus left xterm (body blur or dialog controls),
and an open terminal search bar globally hid the hint and disabled the
shortcut. Give the disconnected overlay a focus sink + local Enter
handler, and stop treating search-open as a global reconnect gate while
still leaving real buttons/inputs alone.

Closes #2544
Closes #2546

* fix(terminal): stop disconnected dialog focus theft

Only claim Enter-reconnect focus when it is already lost or still inside
this session tree, and restore xterm focus when the overlay unmounts.
Avoid stealing focus from other panes/side panels on disconnect.

* fix(terminal): restore dialog focus only when overlay leaves

Split claim/restore effects so showLogs/error rerenders no longer
yank focus off dialog buttons onto xterm mid-interaction.

* fix(terminal): restore Enter-reconnect focus in popup terminals

Popup Terminal windows lack data-session-id ancestors. Walk up from
the dialog to the local xterm root so claim/restore still work there.

* fix: address Codex review on PR #2550

* fix: address Codex review on PR #2550

* fix: address Codex review on PR #2550

* fix: address Codex review on PR #2550

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-07-28 00:20:02 +08:00

242 lines
7.0 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import type { Host } from "../../types.ts";
import { TerminalConnectionDialog } from "./TerminalConnectionDialog.tsx";
const host: Host = {
id: "host-1",
label: "10.2.0.32",
hostname: "10.2.0.32",
port: 22,
username: "root",
tags: [],
os: "linux",
protocol: "ssh",
};
const renderDialog = (
props: Partial<React.ComponentProps<typeof TerminalConnectionDialog>> = {},
) => renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(TerminalConnectionDialog, {
host,
status: "connecting",
error: null,
progressValue: 55,
chainProgress: null,
needsAuth: false,
showLogs: false,
_setShowLogs: () => {},
keys: [],
authProps: {
authMethod: "password",
setAuthMethod: () => {},
authUsername: "root",
setAuthUsername: () => {},
authPassword: "",
setAuthPassword: () => {},
authKeyId: null,
setAuthKeyId: () => {},
authPassphrase: "",
setAuthPassphrase: () => {},
showAuthPassphrase: false,
setShowAuthPassphrase: () => {},
showAuthPassword: false,
setShowAuthPassword: () => {},
authRetryMessage: null,
onSubmit: () => {},
onCancel: () => {},
isValid: true,
},
progressProps: {
timeLeft: 20,
isCancelling: false,
progressLogs: ["Host key verification required for 10.2.0.32."],
onCancelConnect: () => {},
onCloseSession: () => {},
onRetry: () => {},
},
...props,
}),
),
);
test("renders host key confirmation inside the connection dialog", () => {
const markup = renderDialog({
showLogs: true,
hostKeyVerification: {
hostKeyInfo: {
hostname: "10.2.0.32",
port: 22,
keyType: "ssh-ed25519",
fingerprint: "abc123",
status: "unknown",
},
onClose: () => {},
onContinue: () => {},
onAddAndContinue: () => {},
},
});
assert.match(markup, /Confirm this host key/);
assert.match(markup, /abc123/);
assert.match(markup, /Add and continue/);
assert.match(markup, /Host key verification required for 10\.2\.0\.32\./);
assert.equal(markup.includes("Timeout in"), false);
});
test("does not show a countdown while waiting for user input", () => {
const markup = renderDialog({
progressProps: {
timeLeft: 20,
isAwaitingUserInput: true,
isCancelling: false,
progressLogs: ["Waiting for passphrase."],
onCancelConnect: () => {},
onCloseSession: () => {},
onRetry: () => {},
},
});
assert.match(markup, /Waiting for user input/);
assert.equal(markup.includes("Timeout in"), false);
});
test("shows enter reconnect hint when disconnected reconnect is available", () => {
const markup = renderDialog({
status: "disconnected",
error: null,
showEnterReconnectHint: true,
});
assert.match(markup, /Press Enter to reconnect/);
// Focus sink so Enter still reaches the overlay after body/document blur (#2544).
assert.match(markup, /data-terminal-disconnected-dialog="true"/);
assert.match(markup, /tabindex="-1"/);
});
test("does not show enter reconnect hint until the caller marks enter reconnect available", () => {
const markup = renderDialog({
status: "disconnected",
error: null,
});
assert.equal(markup.includes("Press Enter to reconnect"), false);
assert.equal(markup.includes("data-terminal-disconnected-dialog"), false);
});
test("renders changed host key warning in the same connection dialog", () => {
const markup = renderDialog({
hostKeyVerification: {
hostKeyInfo: {
hostname: "10.2.0.32",
port: 22,
keyType: "ssh-ed25519",
fingerprint: "new-fingerprint",
knownFingerprint: "old-fingerprint",
status: "changed",
},
onClose: () => {},
onContinue: () => {},
onAddAndContinue: () => {},
},
});
assert.match(markup, /Host key changed/);
assert.match(markup, /new-fingerprint/);
assert.match(markup, /Saved fingerprint/);
assert.match(markup, /old-fingerprint/);
assert.match(markup, /Update and continue/);
});
test("keeps the second progress segment parked until the first segment finishes", () => {
const markup = renderDialog({ progressValue: 75 });
assert.match(markup, /style="width:100%"/);
assert.match(markup, /style="width:0%"/);
});
test("fills both progress segments for disconnected states", () => {
const markup = renderDialog({
status: "disconnected",
error: "Connection timed out.",
progressValue: 5,
});
const fullSegments = markup.match(/style="width:100%"/g) ?? [];
assert.equal(fullSegments.length >= 2, true);
});
test("keeps connection log padding inside the scrollable content", () => {
const markup = renderDialog({
status: "disconnected",
error: "Connection timed out.",
showLogs: true,
progressProps: {
timeLeft: 0,
isCancelling: false,
progressLogs: Array.from({ length: 12 }, (_, index) => `Log line ${index + 1}`),
onCancelConnect: () => {},
onCloseSession: () => {},
onRetry: () => {},
},
});
assert.match(markup, /class="[^"]*max-h-44/);
assert.doesNotMatch(markup, /class="[^"]*max-h-44[^"]*p-2\.5/);
assert.match(markup, /class="[^"]*p-2\.5[^"]*pb-4[^"]*pr-4/);
});
test("shows the ET server port (not the SSH port) for an ET host with a custom etPort", () => {
const markup = renderDialog({
host: { ...host, etEnabled: true, port: 22, etPort: 9022 },
});
// ET connectivity hinges on the etserver port, so the dialog must show it.
assert.match(markup, /10\.2\.0\.32:9022/);
assert.equal(markup.includes("10.2.0.32:22"), false);
});
test("defaults the displayed ET port to 2022 when no etPort is set", () => {
const markup = renderDialog({
host: { ...host, etEnabled: true, port: 22 },
});
assert.match(markup, /10\.2\.0\.32:2022/);
assert.equal(markup.includes("10.2.0.32:22"), false);
});
test("labels plugin transports without presenting them as SSH endpoints", () => {
const providerId = "com.example.transport.connection";
const markup = renderDialog({
host: {
...host,
hostname: providerId,
port: 22,
protocol: `plugin:${providerId}`,
pluginConnection: { providerId, configuration: {} },
},
});
assert.match(markup, /Plugin connection/);
assert.equal(markup.includes(`${providerId}:22`), false);
});
test("shows restored session copy for disconnected restored placeholders", () => {
const markup = renderDialog({
status: "disconnected",
error: null,
restoreState: "restored-disconnected",
} as Partial<React.ComponentProps<typeof TerminalConnectionDialog>>);
assert.match(markup, /Restored session/);
assert.match(markup, /This terminal is disconnected/);
assert.match(markup, /Reconnect/);
});