Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,14 @@ arrive on the channel.
### writeToChannel(...)

```typescript
writeToChannel(options: { channel: number; s: string; }) => Promise<void>
writeToChannel(options: { channel: number; message: string; }) => Promise<void>
```

writes a message to an open channel

| Param | Type |
| ------------- | -------------------------------------------- |
| **`options`** | <code>{ channel: number; s: string; }</code> |
| **`options`** | <code>{ channel: number; message: string; }</code> |

--------------------

Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 ..",
Expand Down
2 changes: 1 addition & 1 deletion src/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export interface SSHPlugin {
/**
* writes a message to an open channel
*/
writeToChannel(options: { channel: number, s: string }): Promise<void>
writeToChannel(options: { channel: number, message: string }): Promise<void>
/*
* all good things come to an end and so is the `channel`
*/
Expand Down
2 changes: 1 addition & 1 deletion src/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
Expand Down
31 changes: 31 additions & 0 deletions tests/appium/README.md
Original file line number Diff line number Diff line change
@@ -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='192.168.1.10'
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.
228 changes: 228 additions & 0 deletions tests/appium/ios-ssh.e2e.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
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: requiredEnv('SSH_HOST'),
port: parsePort(process.env.SSH_PORT || '22'),
username: process.env.SSH_USERNAME || 'jrandom',
password: SSH_PASSWORD,
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 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];
}
}

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);
});
61 changes: 61 additions & 0 deletions tests/ssh-contract.test.js
Original file line number Diff line number Diff line change
@@ -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<void>/);
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<void>/);
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*\}/);
});