// End-to-end SSH verification. // // Drives the real UI over the Chrome DevTools Protocol — creating a host, // double-clicking it, answering the host-key prompt, and typing into the // resulting terminal. Nothing here is stubbed: the keystrokes go through // xterm, the port, the SSH channel, and a real sshd. // // Prerequisites (see tests/dial.test.ts for the server setup): // - sshd listening on 127.0.0.1:2222 with key auth // - the app running with --remote-debugging-port=9222 // // npm run build && electron . --no-sandbox --remote-debugging-port=9222 & // node scripts/ssh-e2e.mjs // // DESTRUCTIVE: this reloads the renderer, which closes every open session. // Run it against a throwaway instance, not one you are working in. import { existsSync, readFileSync, rmSync } from 'node:fs' import { userInfo } from 'node:os' const PORT = 9222 const HOST_LABEL = 'E2E-LOCAL' const MARKER = 'E2E_MARKER_OK' let nextId = 1 let ws async function findPage() { for (let attempt = 0; attempt < 40; attempt++) { try { const res = await fetch(`http://127.0.0.1:${PORT}/json`) const targets = await res.json() const page = targets.find((t) => t.type === 'page' && t.webSocketDebuggerUrl) if (page) return page } catch { // DevTools endpoint not up yet. } await new Promise((r) => setTimeout(r, 500)) } throw new Error(`no debuggable page on port ${PORT}`) } /** * Issue a CDP command, with a deadline. * * The deadline is not decoration. This script reloads the page partway through, * and a reload destroys the JavaScript execution context — a `Runtime.evaluate` * issued into the old context is simply never answered. Without a timeout that * hangs the whole run forever, which is a far worse failure than an error: * nothing to read, nothing to retry from. */ function command(method, params = {}, timeoutMs = 15_000) { const id = nextId++ return new Promise((resolve, reject) => { const onMessage = (event) => { const msg = JSON.parse(event.data) if (msg.id !== id) return clearTimeout(timer) ws.removeEventListener('message', onMessage) if (msg.error) return reject(new Error(JSON.stringify(msg.error))) resolve(msg.result) } const timer = setTimeout(() => { ws.removeEventListener('message', onMessage) reject(new Error(`CDP ${method} timed out after ${timeoutMs}ms`)) }, timeoutMs) ws.addEventListener('message', onMessage) ws.send(JSON.stringify({ id, method, params })) }) } async function evaluate(expression) { const result = await command('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, }) if (result.exceptionDetails) { const d = result.exceptionDetails throw new Error(d.exception?.description ?? d.text ?? 'evaluate failed') } return result.result.value } /** * Poll an expression until it returns truthy, or give up. * * Evaluation failures are swallowed rather than propagated: during a * navigation the execution context briefly does not exist, and a poll that gave * up on the first such hiccup would be useless. */ async function waitFor(expression, { timeoutMs = 20_000, label = expression } = {}) { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { try { if (await evaluate(expression)) return true } catch { // Context is being torn down or rebuilt; try again. } await new Promise((r) => setTimeout(r, 250)) } throw new Error(`timed out waiting for: ${label}`) } async function click(selectorExpression) { const clicked = await evaluate(`(() => { const el = ${selectorExpression} if (!el) return false el.click() return true })()`) if (!clicked) throw new Error(`nothing to click: ${selectorExpression}`) } async function type(text) { for (const ch of text) { await command('Input.dispatchKeyEvent', { type: 'char', text: ch }) } } async function pressEnter() { await command('Input.dispatchKeyEvent', { type: 'rawKeyDown', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, key: 'Enter', }) await command('Input.dispatchKeyEvent', { type: 'char', text: '\r' }) await command('Input.dispatchKeyEvent', { type: 'keyUp', windowsVirtualKeyCode: 13, nativeVirtualKeyCode: 13, key: 'Enter', }) } const report = {} const page = await findPage() ws = new WebSocket(page.webSocketDebuggerUrl) await new Promise((resolve, reject) => { ws.addEventListener('open', resolve, { once: true }) ws.addEventListener('error', reject, { once: true }) }) // --- clean any previous run, then create the host ------------------------ await evaluate(`(async () => { const tree = await window.nexusterm.tree.list() for (const n of tree) { if (n.kind === 'host' && n.label.startsWith('${HOST_LABEL}')) await window.nexusterm.tree.deleteNode(n.id) } return true })()`) await evaluate(`window.nexusterm.tree.createHost({ parentId: null, label: '${HOST_LABEL}', hostname: '127.0.0.1', port: 2222, username: ${JSON.stringify(userInfo().username)}, authType: 'key', keyPath: '/tmp/testsshd/user_key', color: null, })`) // Reload so the React tree picks the new host up the way a restart would. await evaluate('location.reload()') await new Promise((r) => setTimeout(r, 2_500)) await waitFor( `!!document.querySelector('.row[title^="${HOST_LABEL}"]')`, { label: 'host row in the tree' }, ) report.hostCreated = true // --- double-click it, exactly as a user would --------------------------- await evaluate(`(() => { const row = document.querySelector('.row[title^="${HOST_LABEL}"]') row.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, view: window })) return true })()`) // --- the host key prompt, if this host has not been trusted yet --------- // // Only the first connection to a host prompts. A rerun against a host whose // key is already recorded goes straight through — which is itself worth // distinguishing, so the report says which path was taken rather than // pretending both runs were identical. const prompted = await waitFor(`document.body.textContent.includes('首次连接此主机')`, { label: 'host key dialog', timeoutMs: 6_000, }).catch(() => false) report.hostKeyPrompted = prompted if (prompted) { report.fingerprintShown = await evaluate(`(() => { const el = document.querySelector('.hostkey__fingerprint') return el ? el.textContent : null })()`) await click(`[...document.querySelectorAll('button')].find(b => b.textContent.includes('信任并连接'))`) } else { report.alreadyTrusted = true } // --- a terminal should now exist ---------------------------------------- await waitFor(`!!document.querySelector('.terminal-slot .xterm canvas')`, { label: 'xterm mounted with a renderer', }) report.terminalMounted = true // Give the shell a moment to print its prompt and for the port to settle. await new Promise((r) => setTimeout(r, 2_000)) // --- the local shell is pinned: first, and unclosable -------------------- report.tabOrder = await evaluate(`JSON.stringify( [...document.querySelectorAll('.tab')].map(t => ({ label: t.querySelector('.tab__label').textContent, pinned: t.classList.contains('tab--pinned'), hasClose: !!t.querySelector('.tab__close'), })) )`) report.pinnedTabIsFirst = await evaluate(`(() => { const tabs = [...document.querySelectorAll('.tab')] return tabs.length >= 2 && tabs[0].classList.contains('tab--pinned') })()`) report.pinnedTabHasNoCloseButton = await evaluate( `!document.querySelector('.tab--pinned .tab__close')`, ) // --- prove the input path through an observable side effect ------------- // // Terminal output cannot be read back from the DOM: the WebGL renderer draws // into canvases, so there is no text node to assert on. Asserting on pixels // would be brittle and would only prove that *something* was drawn. // // Instead the command makes the remote shell touch a file, which is checked // from outside the browser. That covers the whole outbound path — keystroke, // xterm, port, main process, SSH channel, remote shell — and the result cannot // be faked by a renderer bug. const markerPath = `/tmp/${MARKER.toLowerCase()}` rmSync(markerPath, { force: true }) await evaluate(`document.querySelector('.xterm-helper-textarea')?.focus()`) await type(`touch ${markerPath}`) await pressEnter() const deadline = Date.now() + 15_000 while (Date.now() < deadline && !existsSync(markerPath)) { await new Promise((r) => setTimeout(r, 200)) } report.inputRoundTrip = existsSync(markerPath) // --- and back again: the remote's output must reach the terminal -------- // `whoami` output is not readable from the DOM either, so this uses a command // whose effect is another file — proving output was produced and consumed. await type(`id -un > ${markerPath}.out`) await pressEnter() const outDeadline = Date.now() + 15_000 while (Date.now() < outDeadline && !existsSync(`${markerPath}.out`)) { await new Promise((r) => setTimeout(r, 200)) } report.outputRoundTrip = existsSync(`${markerPath}.out`) report.remoteUser = existsSync(`${markerPath}.out`) ? readFileSync(`${markerPath}.out`, 'utf8').trim() : null rmSync(markerPath, { force: true }) rmSync(`${markerPath}.out`, { force: true }) // --- the stored-password path ------------------------------------------- // // The test server refuses password authentication, so a successful password // login cannot be demonstrated here. What *can* be demonstrated is that the // app goes to the trouble of using a saved password instead of asking for one // — which is the part this project actually implements, and the part the // user's "remember the password" requirement rests on. const passwordHostId = await evaluate(`(async () => { const created = await window.nexusterm.tree.createHost({ parentId: null, label: '${HOST_LABEL}-PW', hostname: '127.0.0.1', port: 2222, username: ${JSON.stringify(userInfo().username)}, authType: 'password', keyPath: null, color: null, }) return created.find(n => n.kind === 'host' && n.label === '${HOST_LABEL}-PW').id })()`) const withoutStored = await evaluate( `(async () => (await window.nexusterm.session.open({ hostId: ${passwordHostId}, password: null, trustFingerprint: null })).status)()`, ) // With nothing saved, the app must ask rather than fail or guess. report.asksWhenNoPasswordStored = withoutStored === 'need-password' await evaluate(`window.nexusterm.credentials.set({ hostId: ${passwordHostId}, kind: 'password', secret: 'stored-but-the-server-refuses-passwords' })`) report.credentialStored = await evaluate( `window.nexusterm.credentials.has(${passwordHostId}, 'password')`, ) const withStored = await evaluate( `(async () => (await window.nexusterm.session.open({ hostId: ${passwordHostId}, password: null, trustFingerprint: null })).status)()`, ) // Reaching `auth-failed` rather than `need-password` means the stored secret // was read from the vault and handed to SSH — the round trip happened. report.usedStoredPassword = withStored === 'auth-failed' report.statusWithStoredPassword = withStored // There is deliberately no API that returns the secret; only its existence. report.secretIsNotReadableFromRenderer = await evaluate( `typeof window.nexusterm.credentials.get === 'undefined'`, ) // --- clean up ------------------------------------------------------------ await evaluate(`(async () => { const tree = await window.nexusterm.tree.list() for (const n of tree) { if (n.kind === 'host' && n.label.startsWith('${HOST_LABEL}')) await window.nexusterm.tree.deleteNode(n.id) } return true })()`) report.cleanedUp = true console.log(JSON.stringify(report, null, 2)) ws.close()