feat: W1 frontend — index.html, style.css, keybar, main.ts (esbuild entry)
- T8 public/index.html — #term/#keybar, loads ./build/main.js (type=module)
- T9 public/style.css — fullscreen term, keybar fixed bottom, hidden >768px
- T10 public/keybar.ts — key→byte map + mountKeybar (13 tests)
- T11 public/main.ts — xterm+fit, WS client (wss on https/M6), reconnect
backoff, resize debounce, mountKeybar wiring
Verified by orchestrator: backend+frontend tsc clean; full suite 141 tests;
npm run build:web bundles main.ts -> public/build/main.js (430kb).
Note: main.ts uses one @ts-ignore for the CSS import (minor; future: *.css d.ts).
This commit is contained in:
15
public/index.html
Normal file
15
public/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Web Terminal</title>
|
||||||
|
<link rel="stylesheet" href="./build/main.css">
|
||||||
|
<link rel="stylesheet" href="./style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="term"></div>
|
||||||
|
<div id="keybar"></div>
|
||||||
|
<script type="module" src="./build/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
79
public/keybar.ts
Normal file
79
public/keybar.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
/**
|
||||||
|
* public/keybar.ts — Mobile touch key bar for terminal input (T10).
|
||||||
|
*
|
||||||
|
* Exports pure key-to-byte mappings and a mount function that binds DOM buttons
|
||||||
|
* to onSend callback on touchstart (bypassing xterm to avoid soft keyboard popup).
|
||||||
|
*
|
||||||
|
* Key bytes (ARCHITECTURE §5):
|
||||||
|
* Esc → \x1b (0x1B)
|
||||||
|
* Shift+Tab → \x1b[Z
|
||||||
|
* ↑ → \x1b[A
|
||||||
|
* ↓ → \x1b[B
|
||||||
|
* ← → \x1b[D
|
||||||
|
* → → \x1b[C
|
||||||
|
* Enter → \r (0x0D, not \n)
|
||||||
|
* Ctrl+C → \x03 (0x03)
|
||||||
|
* Tab → \t (0x09)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { MountKeybar } from '../src/types.js'
|
||||||
|
|
||||||
|
/** Key name → byte string mapping (pure data, for testing). */
|
||||||
|
export const KEY_MAP = {
|
||||||
|
esc: '\x1b',
|
||||||
|
shiftTab: '\x1b[Z',
|
||||||
|
arrowUp: '\x1b[A',
|
||||||
|
arrowDown: '\x1b[B',
|
||||||
|
arrowLeft: '\x1b[D',
|
||||||
|
arrowRight: '\x1b[C',
|
||||||
|
enter: '\r',
|
||||||
|
ctrlC: '\x03',
|
||||||
|
tab: '\t',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type KeyName = keyof typeof KEY_MAP
|
||||||
|
|
||||||
|
/** Mount the key bar: create buttons in #keybar, bind touchstart → onSend(bytes) + preventDefault. */
|
||||||
|
export const mountKeybar: MountKeybar = (onSend) => {
|
||||||
|
const keybarEl = document.getElementById('keybar')
|
||||||
|
if (!keybarEl) return
|
||||||
|
|
||||||
|
// Button labels and their key map entries
|
||||||
|
const buttons: Array<{
|
||||||
|
label: string
|
||||||
|
keyName: KeyName
|
||||||
|
}> = [
|
||||||
|
{ label: 'Esc', keyName: 'esc' },
|
||||||
|
{ label: 'Shift+Tab', keyName: 'shiftTab' },
|
||||||
|
{ label: '↑', keyName: 'arrowUp' },
|
||||||
|
{ label: '↓', keyName: 'arrowDown' },
|
||||||
|
{ label: '←', keyName: 'arrowLeft' },
|
||||||
|
{ label: '→', keyName: 'arrowRight' },
|
||||||
|
{ label: 'Enter', keyName: 'enter' },
|
||||||
|
{ label: 'Ctrl+C', keyName: 'ctrlC' },
|
||||||
|
{ label: 'Tab', keyName: 'tab' },
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const { label, keyName } of buttons) {
|
||||||
|
const btn = document.createElement('button')
|
||||||
|
btn.textContent = label
|
||||||
|
btn.classList.add('keybar-btn')
|
||||||
|
btn.dataset.key = keyName
|
||||||
|
|
||||||
|
const bytes = KEY_MAP[keyName]
|
||||||
|
|
||||||
|
// touchstart: send immediately, prevent default to avoid soft keyboard
|
||||||
|
btn.addEventListener('touchstart', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSend(bytes)
|
||||||
|
})
|
||||||
|
|
||||||
|
// For desktop testing/fallback: click also sends
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
onSend(bytes)
|
||||||
|
})
|
||||||
|
|
||||||
|
keybarEl.appendChild(btn)
|
||||||
|
}
|
||||||
|
}
|
||||||
274
public/main.ts
Normal file
274
public/main.ts
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/**
|
||||||
|
* public/main.ts — esbuild entry point for the browser frontend (T11).
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - xterm.js Terminal + FitAddon initialization, mounted to #term
|
||||||
|
* - WebSocket client with scheme following page protocol (M6)
|
||||||
|
* - Session persistence via localStorage (sessionId)
|
||||||
|
* - Reconnection with exponential backoff (1s/2s/4s…cap 30s)
|
||||||
|
* - Resize handling with 100ms debounce
|
||||||
|
* - Mobile key bar mounting via mountKeybar (T10)
|
||||||
|
*
|
||||||
|
* No console.log — status messages are written as ANSI color sequences to the terminal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// CSS side-effect import processed by esbuild (→ public/build/main.css).
|
||||||
|
// tsc cannot resolve CSS paths; @ts-ignore suppresses the single diagnostic.
|
||||||
|
// The import is intentional and required per PLAN §1 / ARCHITECTURE §5.
|
||||||
|
// @ts-ignore
|
||||||
|
import '@xterm/xterm/css/xterm.css'
|
||||||
|
import { Terminal } from '@xterm/xterm'
|
||||||
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
|
import { mountKeybar } from './keybar.js'
|
||||||
|
import type { ClientMessage, ServerMessage } from '../src/types.js'
|
||||||
|
|
||||||
|
/* ─── ANSI color helpers ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const RESET = '\x1b[0m'
|
||||||
|
const BOLD = '\x1b[1m'
|
||||||
|
const DIM = '\x1b[2m'
|
||||||
|
const YELLOW = '\x1b[33m'
|
||||||
|
const RED = '\x1b[31m'
|
||||||
|
const GREEN = '\x1b[32m'
|
||||||
|
const CYAN = '\x1b[36m'
|
||||||
|
|
||||||
|
function statusLine(msg: string): string {
|
||||||
|
return `\r\n${DIM}${CYAN}[terminal] ${RESET}${msg}${RESET}\r\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Message construction helper ───────────────────────────────── */
|
||||||
|
|
||||||
|
function buildMessage(msg: ClientMessage): string {
|
||||||
|
return JSON.stringify(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Session ID persistence ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
const SESSION_KEY = 'web-terminal:sessionId'
|
||||||
|
|
||||||
|
function loadSessionId(): string | null {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(SESSION_KEY)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSessionId(id: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SESSION_KEY, id)
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable — proceed without persistence
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── WS URL (M6: scheme follows page protocol) ─────────────────── */
|
||||||
|
|
||||||
|
function buildWsUrl(): string {
|
||||||
|
const scheme = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
|
return `${scheme}://${location.host}/term`
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Terminal initialization ────────────────────────────────────── */
|
||||||
|
|
||||||
|
const term = new Terminal({
|
||||||
|
scrollback: 5000,
|
||||||
|
fontFamily: 'Menlo, Consolas, monospace',
|
||||||
|
theme: {
|
||||||
|
background: '#1a1a1a',
|
||||||
|
foreground: '#e8e8e8',
|
||||||
|
cursor: '#e8e8e8',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fitAddon = new FitAddon()
|
||||||
|
term.loadAddon(fitAddon)
|
||||||
|
|
||||||
|
const termEl = document.getElementById('term')
|
||||||
|
if (!termEl) {
|
||||||
|
throw new Error('#term element not found in DOM')
|
||||||
|
}
|
||||||
|
|
||||||
|
term.open(termEl)
|
||||||
|
|
||||||
|
// fit() must only run when the container has real dimensions (display:none → NaN)
|
||||||
|
function safefit(): { cols: number; rows: number } | null {
|
||||||
|
try {
|
||||||
|
fitAddon.fit()
|
||||||
|
const cols = term.cols
|
||||||
|
const rows = term.rows
|
||||||
|
if (!Number.isFinite(cols) || !Number.isFinite(rows) || cols <= 0 || rows <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return { cols, rows }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial fit after open — element should have real dimensions by now
|
||||||
|
const initialDims = safefit()
|
||||||
|
|
||||||
|
/* ─── WebSocket + reconnect state machine ────────────────────────── */
|
||||||
|
|
||||||
|
let ws: WebSocket | null = null
|
||||||
|
let reconnectDelay = 1000 // ms; doubles each attempt, capped at 30 000 ms
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let isConnecting = false
|
||||||
|
|
||||||
|
/** Resize debounce state */
|
||||||
|
let resizeTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let lastSentCols = initialDims?.cols ?? 0
|
||||||
|
let lastSentRows = initialDims?.rows ?? 0
|
||||||
|
|
||||||
|
function sendResize(cols: number, rows: number): void {
|
||||||
|
if (ws === null || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
if (cols === lastSentCols && rows === lastSentRows) return
|
||||||
|
lastSentCols = cols
|
||||||
|
lastSentRows = rows
|
||||||
|
ws.send(buildMessage({ type: 'resize', cols, rows }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleResize(): void {
|
||||||
|
if (resizeTimer !== null) clearTimeout(resizeTimer)
|
||||||
|
resizeTimer = setTimeout(() => {
|
||||||
|
resizeTimer = null
|
||||||
|
const dims = safefit()
|
||||||
|
if (dims === null) return
|
||||||
|
sendResize(dims.cols, dims.rows)
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(): void {
|
||||||
|
if (isConnecting) return
|
||||||
|
isConnecting = true
|
||||||
|
|
||||||
|
const sessionId = loadSessionId()
|
||||||
|
const url = buildWsUrl()
|
||||||
|
|
||||||
|
term.write(statusLine(`${YELLOW}Connecting…${RESET}`))
|
||||||
|
|
||||||
|
const socket = new WebSocket(url)
|
||||||
|
ws = socket
|
||||||
|
|
||||||
|
socket.addEventListener('open', () => {
|
||||||
|
reconnectDelay = 1000 // reset backoff on successful open
|
||||||
|
term.write(statusLine(`${GREEN}${BOLD}Connected${RESET}`))
|
||||||
|
|
||||||
|
// First frame must be attach
|
||||||
|
socket.send(buildMessage({ type: 'attach', sessionId }))
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.addEventListener('message', (event: MessageEvent<string>) => {
|
||||||
|
let msg: ServerMessage
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(event.data) as ServerMessage
|
||||||
|
} catch {
|
||||||
|
// Unparseable frame — ignore
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handleServerMessage(msg, socket)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.addEventListener('close', () => {
|
||||||
|
isConnecting = false
|
||||||
|
ws = null
|
||||||
|
scheduleReconnect()
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.addEventListener('error', () => {
|
||||||
|
// 'error' is always followed by 'close'; close handler drives reconnect
|
||||||
|
isConnecting = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleServerMessage(msg: ServerMessage, socket: WebSocket): void {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'attached': {
|
||||||
|
saveSessionId(msg.sessionId)
|
||||||
|
// Send current terminal dimensions after attach
|
||||||
|
const dims = safefit()
|
||||||
|
if (dims !== null) {
|
||||||
|
sendResize(dims.cols, dims.rows)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'output': {
|
||||||
|
term.write(msg.data)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'exit': {
|
||||||
|
const reason = msg.reason ? ` (${msg.reason})` : ''
|
||||||
|
term.write(
|
||||||
|
statusLine(
|
||||||
|
`${RED}${BOLD}Process exited${RESET} code=${msg.code}${reason}` +
|
||||||
|
`\r\n${DIM}Press Enter to reconnect…${RESET}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enter → start a new session (clear old sessionId so server spawns fresh)
|
||||||
|
const onEnterReconnect = term.onData((data) => {
|
||||||
|
if (data === '\r') {
|
||||||
|
onEnterReconnect.dispose()
|
||||||
|
// Clear stored session so next attach creates a new one
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(SESSION_KEY)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
socket.close()
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect(): void {
|
||||||
|
if (reconnectTimer !== null) return
|
||||||
|
|
||||||
|
const delay = reconnectDelay
|
||||||
|
reconnectDelay = Math.min(reconnectDelay * 2, 30_000)
|
||||||
|
|
||||||
|
term.write(
|
||||||
|
statusLine(
|
||||||
|
`${YELLOW}Disconnected — reconnecting in ${(delay / 1000).toFixed(0)}s…${RESET}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
reconnectTimer = null
|
||||||
|
connect()
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Keyboard input ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
term.onData((data) => {
|
||||||
|
if (ws === null || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
ws.send(buildMessage({ type: 'input', data }))
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ─── Resize observers ───────────────────────────────────────────── */
|
||||||
|
|
||||||
|
window.addEventListener('resize', scheduleResize)
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
|
scheduleResize()
|
||||||
|
})
|
||||||
|
resizeObserver.observe(termEl)
|
||||||
|
|
||||||
|
/* ─── Mobile key bar ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
mountKeybar((data) => {
|
||||||
|
if (ws === null || ws.readyState !== WebSocket.OPEN) return
|
||||||
|
ws.send(buildMessage({ type: 'input', data }))
|
||||||
|
})
|
||||||
|
|
||||||
|
/* ─── Initial connection ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
connect()
|
||||||
73
public/style.css
Normal file
73
public/style.css
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/* Web Terminal Styling */
|
||||||
|
|
||||||
|
/* Full-screen terminal layout */
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#term {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 60px;
|
||||||
|
width: 100%;
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile touch keybar — fixed at bottom, horizontal layout */
|
||||||
|
#keybar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 60px;
|
||||||
|
background-color: #2a2a2a;
|
||||||
|
border-top: 1px solid #3a3a3a;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
z-index: 1000;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keybar buttons */
|
||||||
|
#keybar button {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 50px;
|
||||||
|
height: 60px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #444;
|
||||||
|
background-color: #333;
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 0;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-touch-callout: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#keybar button:active {
|
||||||
|
background-color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide keybar on desktop (viewport width > 768px) */
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
#keybar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#term {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
106
test/keybar.test.ts
Normal file
106
test/keybar.test.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* test/keybar.test.ts — Unit tests for keybar module (T10).
|
||||||
|
*
|
||||||
|
* Tests the pure KEY_MAP data structure and verifies each key
|
||||||
|
* is mapped to the correct byte string (ARCHITECTURE §5 / §6.3).
|
||||||
|
*
|
||||||
|
* Per task spec: "对纯字节表单测,DOM 部分手动验" —
|
||||||
|
* We test the byte mappings here. DOM behavior (button creation,
|
||||||
|
* event binding, touchstart vs click) is left to manual E2E verification
|
||||||
|
* since jsdom is not in the project dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { KEY_MAP } from '../public/keybar.js'
|
||||||
|
|
||||||
|
describe('keybar', () => {
|
||||||
|
describe('KEY_MAP — pure byte mappings', () => {
|
||||||
|
it('exports KEY_MAP as a constant object', () => {
|
||||||
|
expect(KEY_MAP).toBeDefined()
|
||||||
|
expect(typeof KEY_MAP).toBe('object')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Esc maps to \\x1b (0x1B)', () => {
|
||||||
|
expect(KEY_MAP.esc).toBe('\x1b')
|
||||||
|
expect(KEY_MAP.esc.charCodeAt(0)).toBe(0x1b)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Shift+Tab maps to \\x1b[Z', () => {
|
||||||
|
expect(KEY_MAP.shiftTab).toBe('\x1b[Z')
|
||||||
|
expect(KEY_MAP.shiftTab).toHaveLength(3)
|
||||||
|
expect(KEY_MAP.shiftTab.charCodeAt(0)).toBe(0x1b)
|
||||||
|
expect(KEY_MAP.shiftTab.charCodeAt(1)).toBe('['.charCodeAt(0))
|
||||||
|
expect(KEY_MAP.shiftTab.charCodeAt(2)).toBe('Z'.charCodeAt(0))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('arrow up maps to \\x1b[A', () => {
|
||||||
|
expect(KEY_MAP.arrowUp).toBe('\x1b[A')
|
||||||
|
expect(KEY_MAP.arrowUp).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('arrow down maps to \\x1b[B', () => {
|
||||||
|
expect(KEY_MAP.arrowDown).toBe('\x1b[B')
|
||||||
|
expect(KEY_MAP.arrowDown).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('arrow left maps to \\x1b[D', () => {
|
||||||
|
expect(KEY_MAP.arrowLeft).toBe('\x1b[D')
|
||||||
|
expect(KEY_MAP.arrowLeft).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('arrow right maps to \\x1b[C', () => {
|
||||||
|
expect(KEY_MAP.arrowRight).toBe('\x1b[C')
|
||||||
|
expect(KEY_MAP.arrowRight).toHaveLength(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Enter maps to \\r (0x0D carriage return, not \\n)', () => {
|
||||||
|
expect(KEY_MAP.enter).toBe('\r')
|
||||||
|
expect(KEY_MAP.enter).toHaveLength(1)
|
||||||
|
expect(KEY_MAP.enter.charCodeAt(0)).toBe(0x0d)
|
||||||
|
expect(KEY_MAP.enter).not.toBe('\n')
|
||||||
|
expect(KEY_MAP.enter.charCodeAt(0)).not.toBe(0x0a)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Ctrl+C maps to \\x03 (0x03 ETX)', () => {
|
||||||
|
expect(KEY_MAP.ctrlC).toBe('\x03')
|
||||||
|
expect(KEY_MAP.ctrlC).toHaveLength(1)
|
||||||
|
expect(KEY_MAP.ctrlC.charCodeAt(0)).toBe(0x03)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Tab maps to \\t (0x09)', () => {
|
||||||
|
expect(KEY_MAP.tab).toBe('\t')
|
||||||
|
expect(KEY_MAP.tab).toHaveLength(1)
|
||||||
|
expect(KEY_MAP.tab.charCodeAt(0)).toBe(0x09)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('all 9 keys are defined', () => {
|
||||||
|
const keys = Object.keys(KEY_MAP)
|
||||||
|
expect(keys).toEqual([
|
||||||
|
'esc',
|
||||||
|
'shiftTab',
|
||||||
|
'arrowUp',
|
||||||
|
'arrowDown',
|
||||||
|
'arrowLeft',
|
||||||
|
'arrowRight',
|
||||||
|
'enter',
|
||||||
|
'ctrlC',
|
||||||
|
'tab',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('all mapped bytes are unique strings', () => {
|
||||||
|
const bytes = Object.values(KEY_MAP)
|
||||||
|
expect(bytes).toHaveLength(9)
|
||||||
|
expect(new Set(bytes).size).toBe(9) // all unique
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ANSI arrow sequences use correct format', () => {
|
||||||
|
// All arrow keys follow \x1b[X pattern where X is A/B/C/D
|
||||||
|
const arrowKeys = [KEY_MAP.arrowUp, KEY_MAP.arrowDown, KEY_MAP.arrowLeft, KEY_MAP.arrowRight]
|
||||||
|
arrowKeys.forEach((seq) => {
|
||||||
|
expect(seq).toMatch(/^\x1b\[./)
|
||||||
|
expect(seq).toHaveLength(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user