From 97cb0820243815e62c5d586f8f5c5540a9ef1ad2 Mon Sep 17 00:00:00 2001 From: xiaoshuai <49056817+xiaoshuai7038@users.noreply.github.com> Date: Sat, 16 May 2026 23:18:10 +0800 Subject: [PATCH 1/2] Add Appium SSH scenario test scaffolding --- README.md | 4 +- package.json | 3 + src/definitions.ts | 2 +- src/web.ts | 2 +- tests/appium/README.md | 31 +++++ tests/appium/ios-ssh.e2e.js | 220 ++++++++++++++++++++++++++++++++++++ tests/ssh-contract.test.js | 61 ++++++++++ 7 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 tests/appium/README.md create mode 100644 tests/appium/ios-ssh.e2e.js create mode 100644 tests/ssh-contract.test.js diff --git a/README.md b/README.md index 9f4079d..bb7e644 100644 --- a/README.md +++ b/README.md @@ -106,14 +106,14 @@ arrive on the channel. ### writeToChannel(...) ```typescript -writeToChannel(options: { channel: number; s: string; }) => Promise +writeToChannel(options: { channel: number; message: string; }) => Promise ``` writes a message to an open channel | Param | Type | | ------------- | -------------------------------------------- | -| **`options`** | { channel: number; s: string; } | +| **`options`** | { channel: number; message: string; } | -------------------- diff --git a/package.json b/package.json index 82aee8a..8719eb9 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,9 @@ "native" ], "scripts": { + "test": "npm run test:contract", + "test:contract": "node tests/ssh-contract.test.js", + "test:e2e:ios": "node tests/appium/ios-ssh.e2e.js", "verify": "npm run verify:ios && npm run verify:android && npm run verify:web", "verify:ios": "cd ios && pod install && xcodebuild -workspace Plugin.xcworkspace -scheme Plugin && cd ..", "verify:android": "cd android && ./gradlew clean build test && cd ..", diff --git a/src/definitions.ts b/src/definitions.ts index 951bbc8..642a8da 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -79,7 +79,7 @@ export interface SSHPlugin { /** * writes a message to an open channel */ - writeToChannel(options: { channel: number, s: string }): Promise + writeToChannel(options: { channel: number, message: string }): Promise /* * all good things come to an end and so is the `channel` */ diff --git a/src/web.ts b/src/web.ts index a2fc4d3..c6340d4 100644 --- a/src/web.ts +++ b/src/web.ts @@ -18,7 +18,7 @@ export class SSHWeb extends WebPlugin implements SSHPlugin { this.CB = callback throw this.unimplemented('Not implemented on web'); }; - writeToChannel = async (_: { channel: number, s: string }): Promise< void > => { + writeToChannel = async (_: { channel: number, message: string }): Promise< void > => { throw this.unimplemented('Not implemented on web'); } closeChannel= async (_: { channel: number }): Promise => { diff --git a/tests/appium/README.md b/tests/appium/README.md new file mode 100644 index 0000000..a373d93 --- /dev/null +++ b/tests/appium/README.md @@ -0,0 +1,31 @@ +# Appium iOS SSH scenario + +Issue #3 asks for automated coverage of Terminal7's SSH usage scenario on a real iPad, using Appium with XCUITest against a local ssh daemon accepting the `jrandom` user on port 22. + +The script in this directory is intentionally opt-in. It exits successfully without running unless `RUN_APPIUM_SSH_E2E=1` is set, so normal CI can run `npm test` without a device lab and without pretending that the real iPad scenario was verified. + +## Lab requirements + +- Appium 2 server running with the XCUITest driver installed. +- A real iPad that can run the Capacitor test app. A simulator may be useful for harness smoke tests, but it is not a substitute for the lab requested in issue #3. +- A built `.app` or `.ipa` containing this plugin and a Capacitor WebView. +- An SSH server reachable from that device. +- Credentials supplied through environment variables. + +## Run + +```bash +export RUN_APPIUM_SSH_E2E=1 +export IOS_APP=/absolute/path/to/TestApp.app +export IOS_DEVICE_NAME='iPad' +export IOS_PLATFORM_VERSION='17.0' +export IOS_UDID='optional-real-device-udid' +export SSH_HOST='localhost' +export SSH_PORT=22 +export SSH_USERNAME='jrandom' +export SSH_PASSWORD='password' + +npm run test:e2e:ios +``` + +The test creates one SSH session, opens seven channels, verifies `echo hello world` on each shell, then repeatedly closes one shell and verifies that a remaining shell can still echo. diff --git a/tests/appium/ios-ssh.e2e.js b/tests/appium/ios-ssh.e2e.js new file mode 100644 index 0000000..e034237 --- /dev/null +++ b/tests/appium/ios-ssh.e2e.js @@ -0,0 +1,220 @@ +const assert = require('assert/strict'); + +const shouldRun = process.env.RUN_APPIUM_SSH_E2E === '1'; + +if (!shouldRun) { + console.log('skipped - set RUN_APPIUM_SSH_E2E=1 to run the Appium/XCUITest SSH scenario'); + process.exit(0); +} + +const APPIUM_SERVER_URL = process.env.APPIUM_SERVER_URL || 'http://127.0.0.1:4723'; +const IOS_APP = requiredEnv('IOS_APP'); +const SSH_PASSWORD = requiredEnv('SSH_PASSWORD'); + +const sshOptions = { + address: process.env.SSH_HOST || 'localhost', + port: Number(process.env.SSH_PORT || 22), + username: process.env.SSH_USERNAME || 'jrandom', + password: SSH_PASSWORD, +}; + +const desiredCapabilities = { + platformName: 'iOS', + 'appium:automationName': 'XCUITest', + 'appium:app': IOS_APP, + 'appium:autoWebview': true, + 'appium:autoWebviewTimeout': Number(process.env.APPIUM_AUTOWEBVIEW_TIMEOUT || 15000), + 'appium:newCommandTimeout': Number(process.env.APPIUM_NEW_COMMAND_TIMEOUT || 120), +}; + +optionalCapability('IOS_DEVICE_NAME', 'appium:deviceName'); +optionalCapability('IOS_PLATFORM_VERSION', 'appium:platformVersion'); +optionalCapability('IOS_UDID', 'appium:udid'); + +async function main() { + const session = await createSession(); + + try { + await switchToWebView(session.sessionId); + const result = await executeAsync(session.sessionId, sshScenarioScript(), [sshOptions]); + assert.equal(result.ok, true, result.error || 'SSH scenario failed'); + console.log(`ok - ${result.message}`); + } finally { + await deleteSession(session.sessionId); + } +} + +function requiredEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required when RUN_APPIUM_SSH_E2E=1`); + } + return value; +} + +function optionalCapability(envName, capabilityName) { + if (process.env[envName]) { + desiredCapabilities[capabilityName] = process.env[envName]; + } +} + +async function createSession() { + const response = await appiumRequest('POST', '/session', { + capabilities: { + alwaysMatch: desiredCapabilities, + firstMatch: [{}], + }, + }); + + const sessionId = response.sessionId || (response.value && response.value.sessionId); + if (!sessionId) { + throw new Error('Appium did not return a session id'); + } + + return { + sessionId, + capabilities: response.capabilities || (response.value && response.value.capabilities), + }; +} + +async function deleteSession(sessionId) { + if (!sessionId) { + return; + } + await appiumRequest('DELETE', `/session/${sessionId}`); +} + +async function switchToWebView(sessionId) { + const deadline = Date.now() + Number(process.env.APPIUM_WEBVIEW_TIMEOUT || 15000); + + while (Date.now() < deadline) { + const contexts = await appiumRequest('GET', `/session/${sessionId}/contexts`); + const names = contexts.value || contexts; + const webview = names.find(name => String(name).startsWith('WEBVIEW')); + + if (webview) { + await appiumRequest('POST', `/session/${sessionId}/context`, { name: webview }); + return; + } + + await sleep(500); + } + + throw new Error('Timed out waiting for a WEBVIEW context'); +} + +async function executeAsync(sessionId, script, args) { + const response = await appiumRequest('POST', `/session/${sessionId}/execute/async`, { + script, + args, + }); + + return response.value; +} + +async function appiumRequest(method, path, body) { + const response = await fetch(appiumUrl(path), { + method, + headers: body ? { 'content-type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + const message = payload.value && payload.value.message ? payload.value.message : response.statusText; + throw new Error(`${method} ${path} failed: ${message}`); + } + + return payload; +} + +function appiumUrl(path) { + const base = new URL(APPIUM_SERVER_URL.endsWith('/') ? APPIUM_SERVER_URL : `${APPIUM_SERVER_URL}/`); + return new URL(path.replace(/^\/+/, ''), base); +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function sshScenarioScript() { + return ` + const done = arguments[arguments.length - 1]; + const options = arguments[0]; + + (async () => { + const SSH = window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.SSH; + if (!SSH) { + throw new Error('Capacitor SSH plugin is not available in the WebView'); + } + + const sessionResult = await SSH.startSessionByPasswd(options); + const session = typeof sessionResult === 'string' ? sessionResult : sessionResult.session; + if (!session) { + throw new Error('startSessionByPasswd did not return a session id'); + } + + const channels = []; + + async function openShell(index) { + const channelResult = await SSH.newChannel({ session }); + const channel = channelResult.id; + const state = { channel, buffer: '' }; + channels.push(state); + + await SSH.startShell({ channel }, event => { + if (!event) return; + if (typeof event === 'string') { + state.buffer += event; + } else if (event.data) { + state.buffer += event.data; + } else if (event.error) { + state.buffer += event.error; + } + }); + + await verifyEcho(state, 'hello world ' + index); + } + + async function verifyEcho(state, message) { + const marker = 'ssh-e2e-' + Date.now() + '-' + Math.random().toString(16).slice(2); + const command = 'echo ' + marker + ' ' + message + '\\n'; + const previousLength = state.buffer.length; + await SSH.writeToChannel({ channel: state.channel, message: command }); + + await waitFor(() => state.buffer.slice(previousLength).includes(marker), 5000); + } + + async function waitFor(predicate, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise(resolve => setTimeout(resolve, 100)); + } + throw new Error('Timed out waiting for shell output'); + } + + for (let i = 0; i < 7; i += 1) { + await openShell(i); + } + + for (let i = 0; i < 6; i += 1) { + const closed = channels.shift(); + await SSH.closeChannel({ channel: closed.channel }); + const remaining = channels[i % channels.length]; + await verifyEcho(remaining, 'remaining shell ' + i); + } + + if (typeof SSH.closeSession === 'function') { + await SSH.closeSession({ session }); + } + + return { ok: true, message: 'verified seven shells and remaining-shell echo checks' }; + })().then(done).catch(error => done({ ok: false, error: error.message || String(error) })); + `; +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/tests/ssh-contract.test.js b/tests/ssh-contract.test.js new file mode 100644 index 0000000..e1559c3 --- /dev/null +++ b/tests/ssh-contract.test.js @@ -0,0 +1,61 @@ +const assert = require('assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); + +function read(file) { + return fs.readFileSync(path.join(root, file), 'utf8'); +} + +function test(name, fn) { + try { + fn(); + console.log(`ok - ${name}`); + } catch (error) { + console.error(`not ok - ${name}`); + throw error; + } +} + +const requiredScenarioMethods = [ + 'startSessionByPasswd', + 'newChannel', + 'startShell', + 'writeToChannel', + 'closeChannel', + 'setPtySize', +]; + +test('native plugins expose the SSH scenario methods from issue #3', () => { + const iosBridge = read('ios/Plugin/SSHPlugin.m'); + const androidPlugin = read('android/src/main/java/dev/terminal7/plugins/ssh/SSHPlugin.java'); + + for (const method of requiredScenarioMethods) { + assert.match(iosBridge, new RegExp(`CAP_PLUGIN_METHOD\\(${method},`), `iOS bridge is missing ${method}`); + assert.match(androidPlugin, new RegExp(`public void ${method}\\(PluginCall call\\)`), `Android plugin is missing ${method}`); + } +}); + +test('startShell is registered as a callback-returning plugin method', () => { + const iosBridge = read('ios/Plugin/SSHPlugin.m'); + const androidPlugin = read('android/src/main/java/dev/terminal7/plugins/ssh/SSHPlugin.java'); + + assert.match(iosBridge, /CAP_PLUGIN_METHOD\(startShell,\s*CAPPluginReturnCallback\)/); + assert.match(androidPlugin, /@PluginMethod\(returnType\s*=\s*PluginMethod\.RETURN_CALLBACK\)\s*public void startShell/); +}); + +test('writeToChannel uses the same payload key in TypeScript, iOS, and Android', () => { + const definitions = read('src/definitions.ts'); + const web = read('src/web.ts'); + const readme = read('README.md'); + const iosPlugin = read('ios/Plugin/SSHPlugin.swift'); + const androidPlugin = read('android/src/main/java/dev/terminal7/plugins/ssh/SSHPlugin.java'); + + assert.match(definitions, /writeToChannel\(options:\s*\{\s*channel:\s*number,\s*message:\s*string\s*\}\):\s*Promise/); + assert.match(web, /writeToChannel\s*=\s*async\s*\(_:\s*\{\s*channel:\s*number,\s*message:\s*string\s*\}/); + assert.match(readme, /writeToChannel\(options:\s*\{\s*channel:\s*number;\s*message:\s*string;\s*\}\)\s*=>\s*Promise/); + assert.match(iosPlugin, /call\.getString\("message"\)/); + assert.match(androidPlugin, /call\.getString\("message"\)/); + assert.doesNotMatch(definitions, /writeToChannel\(options:\s*\{\s*channel:\s*number,\s*s:\s*string\s*\}/); +}); From 05fdeadef2748e6158393fe8dcaf2acaa14310bb Mon Sep 17 00:00:00 2001 From: xiaoshuai <49056817+xiaoshuai7038@users.noreply.github.com> Date: Sat, 16 May 2026 23:44:18 +0800 Subject: [PATCH 2/2] Validate explicit SSH host for Appium tests --- tests/appium/README.md | 2 +- tests/appium/ios-ssh.e2e.js | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/tests/appium/README.md b/tests/appium/README.md index a373d93..d3d2d88 100644 --- a/tests/appium/README.md +++ b/tests/appium/README.md @@ -20,7 +20,7 @@ export IOS_APP=/absolute/path/to/TestApp.app export IOS_DEVICE_NAME='iPad' export IOS_PLATFORM_VERSION='17.0' export IOS_UDID='optional-real-device-udid' -export SSH_HOST='localhost' +export SSH_HOST='192.168.1.10' export SSH_PORT=22 export SSH_USERNAME='jrandom' export SSH_PASSWORD='password' diff --git a/tests/appium/ios-ssh.e2e.js b/tests/appium/ios-ssh.e2e.js index e034237..2270eac 100644 --- a/tests/appium/ios-ssh.e2e.js +++ b/tests/appium/ios-ssh.e2e.js @@ -12,8 +12,8 @@ const IOS_APP = requiredEnv('IOS_APP'); const SSH_PASSWORD = requiredEnv('SSH_PASSWORD'); const sshOptions = { - address: process.env.SSH_HOST || 'localhost', - port: Number(process.env.SSH_PORT || 22), + address: requiredEnv('SSH_HOST'), + port: parsePort(process.env.SSH_PORT || '22'), username: process.env.SSH_USERNAME || 'jrandom', password: SSH_PASSWORD, }; @@ -52,6 +52,14 @@ function requiredEnv(name) { return value; } +function parsePort(raw) { + const port = Number(raw); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`SSH_PORT must be an integer between 1 and 65535 (received: ${raw})`); + } + return port; +} + function optionalCapability(envName, capabilityName) { if (process.env[envName]) { desiredCapabilities[capabilityName] = process.env[envName];