Compare commits

...
21 changed files with 726 additions and 197 deletions

No files matched your search

+6
View File
@@ -82,6 +82,8 @@ on:
- "scripts/resolve-et-bin-release.cjs"
- "scripts/resolve-mosh-bin-release.cjs"
- "scripts/tray-panel-layout.live.test.cjs"
- "scripts/serial-named-pipe.live.test.cjs"
- "scripts/fixtures/serial/**"
- "scripts/verify-linux-*.sh"
- "scripts/xterm-macos-column-selection.live.test.cjs"
- "skills/**"
@@ -251,6 +253,10 @@ jobs:
- name: Install deps
run: npm ci
- name: Test Windows serial pipe through native Electron
if: matrix.name == 'windows'
run: npx electron scripts/serial-named-pipe.live.test.cjs
- name: Test macOS Option column selection
if: matrix.name == 'macos'
run: npm run test:xterm-macos-selection
+3
View File
@@ -91,6 +91,9 @@ jobs:
- name: Install deps
run: npm ci --ignore-scripts
- name: Exercise serial named pipe transport on Windows
run: node --test electron/bridges/serialNamedPipe.test.cjs
- name: Apply production dependency patches
run: npx patch-package --error-on-fail
+1
View File
@@ -879,6 +879,7 @@ export const enTerminalMessages: Messages = {
'serial.field.flowControl': 'Flow Control',
'serial.noPorts': 'No serial ports detected. Connect a device and refresh.',
'serial.field.customPort': 'Custom Port Path',
'serial.field.namedPipeHint': 'Windows VM console: enter \\\\.\\pipe\\name. The VM must create the pipe as a server. Baud rate and framing are ignored.',
'serial.field.customPortPlaceholder': 'e.g. /dev/ttys001 or COM1',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudo Terminal',
+1
View File
@@ -841,6 +841,7 @@ export const esTerminalMessages: Messages = {
'serial.field.flowControl': 'Control de flujo',
'serial.noPorts': 'No se detectaron puertos seriales. Conecta un dispositivo y actualiza.',
'serial.field.customPort': 'Ruta de puerto personalizada',
'serial.field.namedPipeHint': 'Consola de VM en Windows: introduce \\\\.\\pipe\\name. La VM debe crear la canalización como servidor. Se ignoran la velocidad y la trama.',
'serial.field.customPortPlaceholder': 'p. ej., /dev/ttys001 o COM1',
'serial.type.hardware': 'Hardware',
'serial.type.pseudo': 'Pseudoterminal',
+1
View File
@@ -886,6 +886,7 @@ export const ruTerminalMessages: Messages = {
'serial.field.flowControl': 'Управление потоком',
'serial.noPorts': 'Последовательные порты не обнаружены. Подключите устройство и обновите список.',
'serial.field.customPort': 'Путь к пользовательскому порту',
'serial.field.namedPipeHint': 'Консоль ВМ Windows: введите \\\\.\\pipe\\name. ВМ должна создать канал как сервер. Скорость и параметры кадра игнорируются.',
'serial.field.customPortPlaceholder': 'например, /dev/ttys001 или COM1',
'serial.type.hardware': 'Аппаратный',
'serial.type.pseudo': 'Псевдотерминал',
@@ -969,6 +969,7 @@ export const zhCNTerminalMessages: Messages = {
'serial.field.flowControl': '流控制',
'serial.noPorts': '未检测到串口设备。请连接设备后刷新。',
'serial.field.customPort': '自定义串口路径',
'serial.field.namedPipeHint': 'Windows 虚拟机控制台:输入 \\\\.\\pipe\\name。虚拟机需作为服务端创建管道,波特率和串口帧设置不生效。',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
'serial.type.hardware': '硬件',
'serial.type.pseudo': '虚拟终端',
@@ -969,6 +969,7 @@ export const zhTWTerminalMessages: Messages = {
'serial.field.flowControl': '流量控制',
'serial.noPorts': '未偵測到序列埠裝置。請連線裝置後重新整理。',
'serial.field.customPort': '自訂序列埠路徑',
'serial.field.namedPipeHint': 'Windows 虛擬機主控台:輸入 \\\\.\\pipe\\name。虛擬機需作為伺服器端建立管道,鮑率與串口框架設定不生效。',
'serial.field.customPortPlaceholder': '例如 /dev/ttys001 或 COM1',
'serial.type.hardware': '硬體',
'serial.type.pseudo': '虛擬終端',
+105 -96
View File
@@ -8,6 +8,7 @@ import { useI18n } from '../application/i18n/I18nProvider';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
import type { Host, SerialConfig, SerialFlowControl, SerialParity } from '../domain/models';
import { prepareSerialConfigForSavedHost } from '../domain/serialBackspace';
import { isSerialEndpointPath, isWindowsNamedPipePath } from '../domain/serialEndpoint';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Combobox, type ComboboxOption } from './ui/combobox';
@@ -111,7 +112,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
if (!selectedPort) return;
const config: SerialConfig = {
path: selectedPort,
path: selectedPort.trim(),
baudRate,
dataBits,
stopBits,
@@ -129,7 +130,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
const host: Host = {
id: `serial-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
label: configLabel.trim() || `Serial: ${portName}`,
hostname: selectedPort,
hostname: selectedPort.trim(),
// For serial hosts, port field stores baud rate as a numeric identifier.
// The full configuration is stored in serialConfig for actual connection.
port: baudRate,
@@ -157,18 +158,15 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
}));
}, [ports]);
// Validate: port path must start with /dev/ (Unix/macOS) or COM/\\.\COM (Windows)
const trimmedPort = selectedPort.trim();
const isPortValid =
trimmedPort.startsWith('/dev/') ||
/^COM\d+$/i.test(trimmedPort) ||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
const isNamedPipe = isWindowsNamedPipePath(trimmedPort);
const isPortValid = isSerialEndpointPath(trimmedPort);
// Allow custom baud rates as long as they are positive integers
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
// Check if using 1.5 stop bits (limited Windows support)
const isStopBits15 = stopBits === 1.5;
const isValid = isPortValid && isBaudRateValid;
const isValid = isPortValid && (isNamedPipe || isBaudRateValid);
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
@@ -212,6 +210,9 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
icon={<Usb size={14} className="text-muted-foreground" />}
/>
<p className="text-xs text-muted-foreground">
{t('serial.field.namedPipeHint')}
</p>
{!isPortValid && selectedPort && (
<p className="text-xs text-destructive">
{t('serial.field.customPortPlaceholder')}
@@ -219,32 +220,34 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
)}
</div>
{/* Baud Rate */}
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
{/* Baud Rate (not used by named pipes) */}
{!isNamedPipe && (
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
)}
{/* Advanced Options */}
<Collapsible open={showAdvanced} onOpenChange={setShowAdvanced}>
@@ -264,92 +267,98 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
{/* Hardware-only serial framing is not used by named pipes. */}
{!isNamedPipe && (
<>
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<Select
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
>
<SelectTrigger id="data-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<Select
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
>
<SelectTrigger id="stop-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>
{/* Parity */}
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<Select
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
value={parity}
onValueChange={(v) => setParity(v as SerialParity)}
>
<SelectTrigger id="data-bits">
<SelectTrigger id="parity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Stop Bits */}
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<Select
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
value={flowControl}
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
>
<SelectTrigger id="stop-bits">
<SelectTrigger id="flow-control">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>
{/* Parity */}
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<Select
value={parity}
onValueChange={(v) => setParity(v as SerialParity)}
>
<SelectTrigger id="parity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<Select
value={flowControl}
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
>
<SelectTrigger id="flow-control">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{/* Terminal Options */}
<div className="space-y-3 pt-2 border-t border-border/60">
@@ -0,0 +1,34 @@
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';
import type { Host } from '../domain/models';
import { SerialHostDetailsPanel } from './SerialHostDetailsPanel';
function renderPanel(path: string, hostname = path) {
const host: Host = {
id: 'serial-test', label: 'VM console', hostname, protocol: 'serial',
port: 115200, username: '', tags: [], os: 'linux',
serialConfig: { path, baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' },
};
return renderToStaticMarkup(<I18nProvider locale="en"><SerialHostDetailsPanel
initialData={host} onSave={() => {}} onCancel={() => {}}
/></I18nProvider>);
}
test('saved named-pipe host is editable without hardware baud controls', () => {
const html = renderPanel(String.raw`\\.\pipe\vm-console`);
assert.match(html, /VM must create the pipe as a server/);
assert.doesNotMatch(html, /for="baud-rate"/);
assert.doesNotMatch(html, /text-destructive/);
});
test('hardware serial host retains its baud-rate controls', () => {
assert.match(renderPanel('COM3'), /for="baud-rate"/);
});
test('editor prefers the stored pipe path over a stale hardware hostname', () => {
const html = renderPanel(String.raw`\\.\pipe\VM console`, 'COM3');
assert.doesNotMatch(html, /for="baud-rate"/);
});
+107 -96
View File
@@ -12,6 +12,8 @@ import {
resolveSerialBackspaceOverrideOnSave,
} from '../domain/serialBackspace';
import { isSerialEndpointPath, isWindowsNamedPipePath } from '../domain/serialEndpoint';
import { Button } from './ui/button';
import { Combobox, ComboboxOption, MultiCombobox } from './ui/combobox';
import { Input } from './ui/input';
@@ -91,7 +93,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
// input alone cannot restore `password` to undefined — the user needs a
// dedicated control to delete the credential entirely.
const [passwordCleared, setPasswordCleared] = useState(false);
const [selectedPort, setSelectedPort] = useState(initialData.hostname || initialData.serialConfig?.path || '');
const [selectedPort, setSelectedPort] = useState(initialData.serialConfig?.path || initialData.hostname || '');
const [baudRate, setBaudRate] = useState(initialData.serialConfig?.baudRate || initialData.port || 115200);
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(initialData.serialConfig?.dataBits || 8);
const [stopBits, setStopBits] = useState<1 | 1.5 | 2>(initialData.serialConfig?.stopBits || 1);
@@ -131,7 +133,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
if (!selectedPort) return;
const config: SerialConfig = {
path: selectedPort,
path: selectedPort.trim(),
baudRate,
dataBits,
stopBits,
@@ -151,7 +153,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
const updatedHost: Host = {
...initialData,
label: label.trim() || `Serial: ${portName}`,
hostname: selectedPort,
hostname: selectedPort.trim(),
port: baudRate,
username: username.trim() || undefined,
// An empty password is meaningful for serial auto-login: the detector
@@ -206,12 +208,10 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
// Validation
const trimmedPort = selectedPort.trim();
const isPortValid =
trimmedPort.startsWith('/dev/') ||
/^COM\d+$/i.test(trimmedPort) ||
/^\\\\\.\\COM\d+$/i.test(trimmedPort);
const isNamedPipe = isWindowsNamedPipePath(trimmedPort);
const isPortValid = isSerialEndpointPath(trimmedPort);
const isBaudRateValid = Number.isInteger(baudRate) && baudRate > 0;
const isValid = isPortValid && isBaudRateValid;
const isValid = isPortValid && (isNamedPipe || isBaudRateValid);
// Check if using 1.5 stop bits (limited Windows support)
const isStopBits15 = stopBits === 1.5;
@@ -265,6 +265,9 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
createText={t('common.use')}
icon={<Usb size={14} className="text-muted-foreground" />}
/>
<p className="text-xs text-muted-foreground">
{t('serial.field.namedPipeHint')}
</p>
{!isPortValid && selectedPort && (
<p className="text-xs text-destructive">
{t('serial.field.customPortPlaceholder')}
@@ -272,32 +275,34 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
)}
</div>
{/* Baud Rate */}
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
{/* Baud Rate (not used by named pipes) */}
{!isNamedPipe && (
<div className="space-y-2">
<Label htmlFor="baud-rate">{t('serial.field.baudRate')}</Label>
<Combobox
options={BAUD_RATES.map((rate) => ({
value: String(rate),
label: String(rate),
}))}
value={String(baudRate)}
onValueChange={(val) => {
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
setBaudRate(parsed);
}
}}
placeholder={t('serial.field.baudRatePlaceholder')}
emptyText={t('serial.field.baudRateEmpty')}
allowCreate
createText={t('common.use')}
/>
{baudRate > 0 && !BAUD_RATES.includes(baudRate) && (
<p className="text-xs text-muted-foreground">
{t('serial.field.customBaudRate')}
</p>
)}
</div>
)}
{/* Login credentials (auto-login) */}
<div className="space-y-2">
@@ -408,92 +413,98 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelPropsWithRes
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-4 pt-2">
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
{/* Hardware-only serial framing is not used by named pipes. */}
{!isNamedPipe && (
<>
{/* Data Bits */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<Select
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
>
<SelectTrigger id="data-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Stop Bits */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<Select
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
>
<SelectTrigger id="stop-bits">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>
{/* Parity */}
<div className="space-y-2">
<Label htmlFor="data-bits">{t('serial.field.dataBits')}</Label>
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<Select
value={String(dataBits)}
onValueChange={(v) => setDataBits(parseInt(v, 10) as 5 | 6 | 7 | 8)}
value={parity}
onValueChange={(v) => setParity(v as SerialParity)}
>
<SelectTrigger id="data-bits">
<SelectTrigger id="parity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Stop Bits */}
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="stop-bits">{t('serial.field.stopBits')}</Label>
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<Select
value={String(stopBits)}
onValueChange={(v) => setStopBits(parseFloat(v) as 1 | 1.5 | 2)}
value={flowControl}
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
>
<SelectTrigger id="stop-bits">
<SelectTrigger id="flow-control">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STOP_BITS.map((bits) => (
<SelectItem key={bits} value={String(bits)}>
{bits}
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
{isStopBits15 && (
<p className="text-xs text-yellow-500">
{t('serial.field.stopBits15Warning')}
</p>
)}
</div>
</div>
{/* Parity */}
<div className="space-y-2">
<Label htmlFor="parity">{t('serial.field.parity')}</Label>
<Select
value={parity}
onValueChange={(v) => setParity(v as SerialParity)}
>
<SelectTrigger id="parity">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PARITY_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.parity.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Flow Control */}
<div className="space-y-2">
<Label htmlFor="flow-control">{t('serial.field.flowControl')}</Label>
<Select
value={flowControl}
onValueChange={(v) => setFlowControl(v as SerialFlowControl)}
>
<SelectTrigger id="flow-control">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FLOW_CONTROL_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{t(`serial.flowControl.${option}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</>
)}
{/* Terminal Options */}
<div className="space-y-3 pt-2 border-t border-border/60">
@@ -417,3 +417,19 @@ for (const sleepBeforeCompletion of [true, false]) {
assert.deepEqual(writes, ['show version\r']);
});
}
test("named pipe sessions preserve the endpoint and omit hardware baud information", async () => {
const pipePath = String.raw`\\.\pipe\vm-console`;
const output: string[] = [];
let capturedPath: unknown;
const backend = buildBackend({ startSerialSession: async (options: Record<string, unknown>) => {
capturedPath = options.path;
return "pipe-session";
} });
const ctx = buildCtx(backend);
ctx.serialConfig.path = pipePath;
await createTerminalSessionStarters(ctx as never).startSerial({ ...term, write: (line: string, done?: () => void) => { output.push(line); done?.(); } } as never);
assert.equal(capturedPath, pipePath);
assert.ok(output.some((line) => line.includes(`[Connected to ${pipePath}]`)));
assert.ok(output.every((line) => !line.includes('baud')));
});
@@ -1,3 +1,4 @@
import { isWindowsNamedPipePath } from '../../../domain/serialEndpoint';
import type { Terminal as XTerm } from "@xterm/xterm";
import type { ProviderValidationIssue } from "@netcatty/plugin-contract";
import { logger } from "../../../lib/logger";
@@ -2172,7 +2173,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
autoLoginAttached = true;
ctx.updateStatus("connected");
ctx.setProgressValue(100);
writeTerminalLine(ctx, term, `[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
writeTerminalLine(ctx, term, isWindowsNamedPipePath(ctx.serialConfig.path)
? `[Connected to ${ctx.serialConfig.path}]`
: `[Connected to ${ctx.serialConfig.path} at ${ctx.serialConfig.baudRate} baud]`);
if (waitsForAutoLogin) {
if (autoLoginCompletedBeforeAttach) {
+10
View File
@@ -783,3 +783,13 @@ test("CloudLinker skips the cwd probe without restricting generic Go servers", (
true,
);
});
test("sanitizeHost preserves named-pipe spaces and restores the authoritative serial path", () => {
const path = String.raw`\\.\pipe\VM console`;
const host = makeHost({ protocol: 'serial', hostname: String.raw`\\.\pipe\VM`, serialConfig: {
path, baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none',
} });
assert.equal(sanitizeHost(host).hostname, path);
assert.equal(sanitizeHost(host).serialConfig?.path, path);
assert.equal(sanitizeHost(makeHost({ hostname: 'example.com extra' })).hostname, 'example.com');
});
+4 -1
View File
@@ -415,7 +415,10 @@ export const hostsEqualForIdentityReuse = (a: Host, b: Host): boolean => {
};
export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
const cleanHostname = (host.hostname || '').trim().split(/\s+/)[0];
// Serial endpoints are paths, not network hostnames; pipe names may contain spaces.
const cleanHostname = host.protocol === 'serial'
? (host.serialConfig?.path || host.hostname || '').trim()
: (host.hostname || '').trim().split(/\s+/)[0];
const cleanDistro = normalizeDistroId(host.distro);
const cleanManualDistro = normalizeDistroId(host.manualDistro);
const cleanDistroMode =
+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { isSerialEndpointPath, isWindowsNamedPipePath } from './serialEndpoint';
const require = createRequire(import.meta.url);
const backend = require('../electron/bridges/serialNamedPipe.cjs');
test('serial endpoint validation keeps hardware ports and accepts local Windows pipes', () => {
for (const value of ['/dev/ttyS0', '/dev/ttys001', 'COM1', String.raw`\\.\COM12`, String.raw`\\.\pipe\vm`, String.raw`\\?\pipe\vm`]) {
assert.equal(isSerialEndpointPath(value), true, value);
}
for (const value of ['', 'vm', '\\\\.\\pipe\\', String.raw`\\host\pipe\vm`]) {
assert.equal(isSerialEndpointPath(value), false, value);
}
});
test('renderer and backend agree on named pipe detection', () => {
for (const value of ['', '/dev/ttyS0', 'COM1', String.raw`\\.\COM1`, String.raw`\\.\pipe\vm`, String.raw`\\?\pipe\VM console`, String.raw`\\.\PIPE\vm\console`, String.raw`\\host\pipe\vm`, '\\\\.\\pipe\\', '\\\\.\\pipe\\vm\n', '\\\\.\\pipe\\vm\0']) {
assert.equal(isWindowsNamedPipePath(value), backend.isWindowsNamedPipePath(value), value);
}
});
+9
View File
@@ -0,0 +1,9 @@
// Keep in sync with electron/bridges/serialNamedPipe.cjs.
export function isWindowsNamedPipePath(value: string): boolean {
return /^\\\\[.?]\\pipe\\[^\r\n\0]+$/i.test(value);
}
export function isSerialEndpointPath(value: string): boolean {
return value.startsWith('/dev/') || /^COM\d+$/i.test(value)
|| /^\\\\\.\\COM\d+$/i.test(value) || isWindowsNamedPipePath(value);
}
+55
View File
@@ -0,0 +1,55 @@
const net = require('node:net');
// Local Windows IPC only: do not interpret remote UNC paths as serial ports.
// Keep in sync with domain/serialEndpoint.ts (covered by the endpoint tests).
function isWindowsNamedPipePath(value) {
return typeof value === 'string' && /^\\\\[.?]\\pipe\\[^\r\n\0]+$/i.test(value);
}
/** A byte stream with the open/close interface used by serial sessions. */
class SerialNamedPipe extends net.Socket {
constructor(path) {
super();
this.path = path;
this.finishOpen = null;
}
open(callback) {
const onConnect = () => finish(null);
const onError = (error) => finish(error);
const finish = (error) => {
if (this.finishOpen !== finish) return;
this.finishOpen = null;
this.removeListener('connect', onConnect);
this.removeListener('error', onError);
callback(error);
};
this.finishOpen = finish;
this.once('connect', onConnect);
this.once('error', onError);
try {
this.connect(this.path);
} catch (error) {
finish(error);
}
}
close() {
// Cancel even before connect completes; a superseded boot must not linger.
this.destroy();
// Match asynchronous SerialPort open failures so the boot abort can settle
// with its superseded error before this transport-level cancellation.
queueMicrotask(() => this.finishOpen?.(new Error('Named pipe connection closed before opening')));
}
}
function createSerialTransport(options, SerialPort, platform = process.platform) {
const path = options.path?.trim();
if (!isWindowsNamedPipePath(path)) return new SerialPort({ ...options, path });
if (platform !== 'win32') {
throw new Error('Windows named pipes are only supported on Windows');
}
return new SerialNamedPipe(path);
}
module.exports = { isWindowsNamedPipePath, SerialNamedPipe, createSerialTransport };
+110
View File
@@ -0,0 +1,110 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const { randomUUID } = require('node:crypto');
const { once } = require('node:events');
const { SerialNamedPipe, isWindowsNamedPipePath, createSerialTransport } = require('./serialNamedPipe.cjs');
const endpoint = () => process.platform === 'win32'
? `\\\\.\\pipe\\netcatty-test-${randomUUID()}`
: path.join(os.tmpdir(), `nc-${randomUUID()}.sock`);
async function echoServer(t) {
const address = endpoint();
const peers = new Set();
const server = net.createServer((socket) => {
peers.add(socket);
socket.on('close', () => peers.delete(socket));
socket.on('error', () => {});
socket.on('data', (data) => socket.write(data));
});
server.listen(address);
await once(server, 'listening');
t.after(async () => {
for (const socket of peers) socket.destroy();
await new Promise((resolve) => server.close(resolve));
});
return { address, peers, server };
}
function open(port) {
return new Promise((resolve, reject) => port.open((error) => error ? reject(error) : resolve()));
}
test('recognizes only local Windows named pipe paths', () => {
for (const value of [String.raw`\\.\pipe\vm`, String.raw`\\?\pipe\VM console`, String.raw`\\.\PIPE\vm\console`]) {
assert.equal(isWindowsNamedPipePath(value), true, value);
}
for (const value of [undefined, '', '\\\\.\\pipe\\', String.raw`\\server\pipe\vm`, 'COM1', '/dev/ttyS0', '\\\\.\\pipe\\vm\n']) {
assert.equal(isWindowsNamedPipePath(value), false, String(value));
}
});
test('real IPC stream carries binary input/output and can reconnect after close', { timeout: 10000 }, async (t) => {
const { address } = await echoServer(t);
for (let attempt = 0; attempt < 2; attempt++) {
const port = new SerialNamedPipe(address);
t.after(() => port.close());
await open(port);
const payload = Buffer.from([0, 1, 8, 13, 27, 127, 128, 255]);
const received = once(port, 'data');
await new Promise((resolve, reject) => port.write(payload, (error) => error ? reject(error) : resolve()));
assert.deepEqual((await received)[0], payload);
const closed = once(port, 'close');
port.close();
await closed;
}
});
test('missing pipe fails opening once without an unhandled error', { timeout: 10000 }, async () => {
const port = new SerialNamedPipe(endpoint());
let callbacks = 0;
const closed = new Promise((resolve) => port.once('close', resolve));
await new Promise((resolve) => port.open((error) => {
callbacks++;
assert.ok(error);
resolve();
}));
await closed;
assert.equal(callbacks, 1);
});
test('closing during open cancels the pending connection once', { timeout: 10000 }, async (t) => {
const { address } = await echoServer(t);
const port = new SerialNamedPipe(address);
let callbacks = 0;
const closed = new Promise((resolve) => port.once('close', resolve));
port.open((error) => {
callbacks++;
assert.match(error.message, /closed before opening/);
});
port.close();
await closed;
assert.equal(callbacks, 1);
assert.equal(port.destroyed, true);
});
test('server disconnection closes the transport', { timeout: 10000 }, async (t) => {
const { address, peers, server } = await echoServer(t);
const port = new SerialNamedPipe(address);
t.after(() => port.close());
await Promise.all([once(server, 'connection'), open(port)]);
const closed = once(port, 'close');
for (const peer of peers) peer.end();
await closed;
assert.equal(port.destroyed, true);
});
test('transport selection preserves hardware settings and trims the path', () => {
class HardwarePort { constructor(options) { this.options = options; } }
const settings = { path: ' COM12 ', baudRate: 9600, parity: 'even', rtscts: true, autoOpen: false };
const hardware = createSerialTransport(settings, HardwarePort, 'win32');
assert.ok(hardware instanceof HardwarePort);
assert.deepEqual(hardware.options, { ...settings, path: 'COM12' });
const pipe = createSerialTransport({ ...settings, path: String.raw`\\.\pipe\vm` }, HardwarePort, 'win32');
assert.ok(pipe instanceof SerialNamedPipe);
pipe.close();
assert.throws(() => createSerialTransport({ path: String.raw`\\.\pipe\vm` }, HardwarePort, 'linux'), /only supported on Windows/);
});
+4 -3
View File
@@ -21,6 +21,7 @@ const trackedFlowPauseSenders = new WeakSet();
const pty = require("node-pty");
const { SerialPort } = require("serialport");
const { createSerialTransport } = require("./serialNamedPipe.cjs");
const {
configureTerminalSessionDataEmitter,
emitTerminalSessionData,
@@ -1313,7 +1314,7 @@ async function listSerialPorts() {
async function startSerialSession(event, options) {
const sessionId = options.sessionId || randomUUID();
const portPath = options.path;
const portPath = options.path?.trim();
const baudRate = options.baudRate || 115200;
const dataBits = options.dataBits || 8;
const stopBits = options.stopBits || 1;
@@ -1347,7 +1348,7 @@ async function startSerialSession(event, options) {
resolve(value);
};
try {
const serialPort = new SerialPort({
const serialPort = createSerialTransport({
path: portPath,
baudRate: baudRate,
dataBits: dataBits,
@@ -1357,7 +1358,7 @@ async function startSerialSession(event, options) {
xon: flowControl === 'xon/xoff',
xoff: flowControl === 'xon/xoff',
autoOpen: false,
});
}, SerialPort);
const abortPendingOpen = () => {
try { serialPort.close(); } catch { /* ignore */ }
@@ -0,0 +1,33 @@
using System;
using System.IO;
using System.IO.Pipes;
// A VM-like server with exactly one client slot. Keep the same server handle
// alive between clients so reconnect tests do not race pipe creation/removal.
class SingleInstancePipe {
static void Main(string[] args) {
using (var pipe = new NamedPipeServerStream(args[0], PipeDirection.InOut, 1,
PipeTransmissionMode.Byte, PipeOptions.Asynchronous)) {
Console.WriteLine("READY");
Console.Out.Flush();
var buffer = new byte[8192];
while (true) {
try {
pipe.WaitForConnection();
int count;
while ((count = pipe.Read(buffer, 0, buffer.Length)) > 0) {
pipe.Write(buffer, 0, count);
pipe.Flush();
}
} catch (IOException error) {
// Accept can also see an already-closed late client (ERROR_NO_DATA).
int code = error.HResult & 0xffff;
if (code != 109 && code != 232 && code != 233) throw;
} finally {
try { pipe.Disconnect(); }
catch (InvalidOperationException) { /* Accept failed before connection. */ }
}
}
}
}
}
+200
View File
@@ -0,0 +1,200 @@
'use strict';
// Native Electron integration, not a hypervisor fixture: the disposable server
// implements the same local Windows pipe byte-stream contract as a VM console.
if (!process.versions.electron) {
require('node:test')('serial named pipe Electron integration', {
skip: 'run electron scripts/serial-named-pipe.live.test.cjs',
}, () => {});
} else {
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const net = require('node:net');
const { spawn, execFileSync } = require('node:child_process');
const { randomUUID } = require('node:crypto');
const electron = require('electron');
const { app, BrowserWindow, ipcMain, protocol } = electron;
protocol.registerSchemesAsPrivileged([{ scheme: 'app', privileges: { standard: true, secure: true, supportFetchAPI: true } }]);
const sessions = new Map();
const peers = new Set();
const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-pipe-electron-'));
app.setPath('userData', profile);
app.on('window-all-closed', () => {});
let win;
let server;
let bridge;
let busyServer;
let busyOwner;
let finishing = false;
const watchdog = setTimeout(() => finish(1, new Error('serial pipe Electron test timed out')), 45000);
function finish(code, error) {
if (finishing) return;
finishing = true;
clearTimeout(watchdog);
if (error) console.error(error);
bridge?.cleanupAllSessions();
for (const peer of peers) peer.destroy();
server?.close();
busyOwner?.destroy();
busyServer?.kill();
if (win && !win.isDestroyed()) win.destroy();
try { fs.rmSync(profile, { recursive: true, force: true }); } catch { /* Windows may hold cache files until exit. */ }
app.exit(code);
}
const js = (source) => win.webContents.executeJavaScript(source);
const subscribe = (sessionId) => js(`window.pipeDataOff?.(); window.pipeExitOff?.(); window.pipeOutput = ''; window.pipeExits = []; window.pipeDataOff = window.netcatty.onSessionData(${JSON.stringify(sessionId)}, data => { window.pipeOutput += data; document.getElementById('output').textContent = window.pipeOutput; window.netcatty.ackSessionFlow(${JSON.stringify(sessionId)}, data.length); }); window.pipeExitOff = window.netcatty.onSessionExit(${JSON.stringify(sessionId)}, event => window.pipeExits.push(event)); void 0;`);
async function waitFor(source, timeout = 5000) {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
if (await js(source)) return;
await new Promise(resolve => setTimeout(resolve, 25));
}
throw new Error(`Timed out: ${source}`);
}
async function main() {
bridge = require('../electron/bridges/terminalBridge.cjs');
bridge.init({ sessions, electronModule: electron });
bridge.registerHandlers(ipcMain);
win = new BrowserWindow({ show: false, webPreferences: {
preload: path.join(__dirname, '../electron/preload.cjs'),
contextIsolation: true, nodeIntegration: false,
// Match the production main-window preload configuration.
sandbox: false,
} });
protocol.handle('app', () => new Response('<title>Serial pipe integration</title><pre id="output"></pre>', { headers: { 'content-type': 'text/html' } }));
await win.loadURL('app://netcatty/serial-pipe-test');
assert.equal(await js('typeof window.netcatty.startSerialSession'), 'function');
const endpoint = String.raw`\\.\pipe\netcatty VM console ${randomUUID()}`;
const busyName = `netcatty-busy-${randomUUID()}`;
const busyPath = '\\\\.\\pipe\\' + busyName;
assert.ok(busyPath.endsWith(busyName) && !busyPath.includes('${'));
if (process.platform !== 'win32') {
// Exercise callback registration too; functions must never be returned
// from executeJavaScript across Electron's structured-clone boundary.
await subscribe('unsupported');
await assert.rejects(js(`window.netcatty.startSerialSession(${JSON.stringify({ sessionId: 'unsupported', path: endpoint })})`), /only supported on Windows/);
assert.equal(sessions.size, 0);
console.log('SERIAL_PIPE_ELECTRON_PASS: native preload/IPC rejects Windows pipes on non-Windows');
return;
}
const received = [];
let slowReader = false;
server = net.createServer(peer => {
peers.add(peer);
if (slowReader) peer.pause();
peer.on('error', () => {});
peer.on('close', () => peers.delete(peer));
peer.on('data', data => { received.push(data); peer.write(data); });
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(endpoint, resolve);
});
const sessionId = 'native-pipe-session';
for (let attempt = 0; attempt < 2; attempt++) {
assert.equal(await js(`window.netcatty.startSerialSession(${JSON.stringify({ sessionId, path: endpoint, charset: 'utf-8' })})`), sessionId);
assert.equal(sessions.get(sessionId).type, 'serial');
await subscribe(sessionId);
const payload = `round ${attempt}: café 中文\r\n`;
await js(`window.netcatty.writeToSession(${JSON.stringify(sessionId)}, ${JSON.stringify(payload)})`);
await waitFor(`window.pipeOutput === ${JSON.stringify(payload)}`);
assert.ok(Buffer.concat(received).toString('utf8').includes(payload), 'renderer input reaches the real Windows pipe server');
assert.equal(await js("document.getElementById('output').textContent"), payload);
if (attempt === 0) {
await js(`window.netcatty.closeSession(${JSON.stringify(sessionId)})`);
assert.equal(sessions.has(sessionId), false);
} else {
for (const peer of peers) peer.destroy();
await waitFor('window.pipeExits.length === 1');
assert.equal(sessions.has(sessionId), false, 'broken pipe removes backend session');
await js(`window.netcatty.closeSession(${JSON.stringify(sessionId)})`);
}
}
// Bound stress to 1 MiB: pause the server reader, then verify exact bytes
// through both directions without introducing a different flow-control path.
slowReader = true;
received.length = 0;
await js(`window.netcatty.startSerialSession(${JSON.stringify({ sessionId: 'slow-reader', path: endpoint })})`);
await subscribe('slow-reader');
const largePayload = '0123456789abcdef'.repeat(65536);
await js(`window.netcatty.writeToSession('slow-reader', ${JSON.stringify(largePayload)})`);
const queueDeadline = Date.now() + 5000;
while (sessions.get('slow-reader').serialPort.writableLength === 0 && Date.now() < queueDeadline) {
await new Promise(resolve => setTimeout(resolve, 10));
}
const queuedBytes = sessions.get('slow-reader').serialPort.writableLength;
assert.ok(queuedBytes > 0, 'paused reader must leave a pending transport write');
assert.ok(queuedBytes <= Buffer.byteLength(largePayload), 'bounded write queue');
for (const peer of peers) peer.resume();
await waitFor(`window.pipeOutput.length === ${largePayload.length}`, 15000);
assert.equal(Buffer.concat(received).toString('utf8'), largePayload);
assert.equal(await js('window.pipeOutput'), largePayload);
assert.equal(sessions.get('slow-reader').serialPort.writableLength, 0);
await js("window.netcatty.closeSession('slow-reader')");
console.log(`SERIAL_PIPE_SLOW_READER_PASS: 1048576 exact bytes, observed queued bytes=${queuedBytes}, drained=0`);
// Node net.Server permits multiple simultaneous clients. Use a tiny .NET
// server to reproduce a real single-instance pipe's ERROR_PIPE_BUSY path.
const fixtureExe = path.join(profile, 'single-instance-pipe.exe');
const compiler = path.join(process.env.WINDIR, 'Microsoft.NET/Framework64/v4.0.30319/csc.exe');
execFileSync(compiler, ['/nologo', '/target:exe', `/out:${fixtureExe}`,
path.join(__dirname, 'fixtures/serial/single-instance-pipe.cs')]);
busyServer = spawn(fixtureExe, [busyName], { stdio: ['ignore', 'pipe', 'pipe'] });
let fixtureErrors = '';
busyServer.stderr.on('data', chunk => { fixtureErrors += chunk; });
busyServer.on('exit', code => { if (!finishing) finish(1, new Error(`Pipe fixture exited ${code}: ${fixtureErrors}`)); });
await new Promise((resolve, reject) => {
let output = '';
busyServer.once('error', reject);
busyServer.once('exit', code => reject(new Error(`Pipe fixture exited ${code}`)));
busyServer.stdout.on('data', chunk => { output += chunk; if (output.includes('READY')) resolve(); });
});
busyOwner = net.connect(busyPath);
await new Promise((resolve, reject) => { busyOwner.once('connect', resolve); busyOwner.once('error', reject); });
const { SerialNamedPipe } = require('../electron/bridges/serialNamedPipe.cjs');
const originalOpen = SerialNamedPipe.prototype.open;
let pendingTransport;
let pendingTransportClosed;
// Observe the real transport, without replacing its connect/close behavior.
SerialNamedPipe.prototype.open = function (callback) {
if (this.path === busyPath) {
SerialNamedPipe.prototype.open = originalOpen;
pendingTransport = this;
pendingTransportClosed = new Promise(resolve => this.once('close', resolve));
}
return originalOpen.call(this, callback);
};
await js(`window.busyResult = 'pending'; window.busyPromise = window.netcatty.startSerialSession(${JSON.stringify({ sessionId: 'busy', path: busyPath })}).then(() => { window.busyResult = 'connected'; }, error => { window.busyResult = error.message; }); void 0;`);
await new Promise(resolve => setTimeout(resolve, 150));
assert.equal(await js('window.busyResult'), 'pending', 'single-instance pipe must be busy');
await js("window.netcatty.closeSession('busy')");
await waitFor("window.busyResult !== 'pending'");
assert.match(await js('window.busyResult'), /superseded|closed before opening/i);
assert.equal(sessions.has('busy'), false);
busyOwner.destroy();
assert.ok(pendingTransportClosed, 'observe the pending real transport');
await pendingTransportClosed;
assert.equal(pendingTransport.destroyed, true);
// Reconnect after releasing the occupied pipe. This also proves a cancelled
// late connect cannot keep the one server instance occupied indefinitely.
assert.equal(await js(`window.netcatty.startSerialSession(${JSON.stringify({ sessionId: 'busy-reconnect', path: busyPath })})`), 'busy-reconnect');
await subscribe('busy-reconnect');
await js("window.netcatty.writeToSession('busy-reconnect', 'after cancellation')");
await waitFor("window.pipeOutput === 'after cancellation'");
await js("window.netcatty.closeSession('busy-reconnect')");
assert.equal(sessions.has('busy'), false, 'cancelled late connect must not create a session');
console.log('SERIAL_PIPE_BUSY_PASS: pending cancel, late-connect cleanup, manual reconnect');
await new Promise(resolve => server.close(resolve));
server = null;
await assert.rejects(js(`window.netcatty.startSerialSession(${JSON.stringify({ sessionId: 'missing', path: endpoint })})`), /Failed to open serial port/);
// A deliberately nonexistent hardware port still takes the SerialPort path,
// not the pipe adapter. This does not claim testing physical COM hardware.
await assert.rejects(js(`window.netcatty.startSerialSession({sessionId:'missing-com',path:'COM999999',baudRate:9600})`), /Failed to open serial port/);
assert.equal(sessions.size, 0);
console.log('SERIAL_PIPE_ELECTRON_PASS: real Windows pipe, renderer/preload/IPC, UTF-8 I/O, reconnect, disconnect, missing pipe and COM error');
}
app.whenReady().then(main).then(() => finish(0), error => finish(1, error));
}