-
Notifications
You must be signed in to change notification settings - Fork 4
Add Appium SSH scenario test scaffolding #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
xiaoshuai7038
wants to merge
2
commits into
tuzig:blessed
Choose a base branch
from
xiaoshuai7038:issue-3-appium-ssh-tests
base: blessed
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }; | ||
|
|
||
| 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); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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*\}/); | ||
| }); |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.