From e5ed3b07faa6c56ec03fd8e735bc502af945b432 Mon Sep 17 00:00:00 2001 From: Jochen Delabie Date: Sun, 22 Dec 2024 20:51:29 +0100 Subject: [PATCH] maestro provider --- package.json | 2 +- src/cli.ts | 99 +++++++++++++++++ src/index.ts | 74 +------------ src/models/maestro_options.ts | 34 ++++++ src/providers/maestro.ts | 171 +++++++++++++++++++++++++++++ tests/cli.test.ts | 183 ++++++++++++++++++++++++++++++++ tests/providers/maestro.test.ts | 124 ++++++++++++++++++++++ 7 files changed, 613 insertions(+), 74 deletions(-) create mode 100644 src/cli.ts create mode 100644 src/models/maestro_options.ts create mode 100644 src/providers/maestro.ts create mode 100644 tests/cli.test.ts create mode 100644 tests/providers/maestro.test.ts diff --git a/package.json b/package.json index 797379a..79d06d4 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "clean": "rm -rf dist", "start": "node dist/index.js", "format": "prettier --write '**/*.{js,ts}'", - "test": "jest --detectOpenHandles", + "test": "jest", "release": "release-it --github.release", "release:ci": "npm run release -- --ci --npm.skipChecks --no-git.requireCleanWorkingDir", "release:patch": "npm run release -- patch", diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..7e47192 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,99 @@ +import { Command } from 'commander'; +import logger from './logger'; +import auth from './auth'; +import Espresso from './providers/espresso'; +import EspressoOptions from './models/espresso_options'; +import XCUITestOptions from './models/xcuitest_options'; +import XCUITest from './providers/xcuitest'; +import packageJson from '../package.json'; +import MaestroOptions from './models/maestro_options'; +import Maestro from './providers/maestro'; + +const program = new Command(); + +program + .version(packageJson.version) + .description( + 'TestingBotCTL is a CLI-tool to run Espresso, XCUITest and Maestro tests in the TestingBot cloud', + ); + +program + .command('espresso') + .description('Bootstrap an Espresso project.') + .requiredOption('--app ', 'Path to application under test.') + .requiredOption('--device ', 'Real device to use for testing.') + .requiredOption( + '--emulator ', + 'Android emulator to use for testing.', + ) + .requiredOption('--test-app ', 'Path to test application.') + .action(async (args) => { + try { + const options = new EspressoOptions( + args.app, + args.testApp, + args.device, + args.emulator, + ); + const credentials = await auth.getCredentials(); + if (credentials === null) { + throw new Error('Please specify credentials'); + } + const espresso = new Espresso(credentials, options); + await espresso.run(); + } catch (err: any) { + logger.error(`Espresso error: ${err.message}`); + } + }) + .showHelpAfterError(true); + +program + .command('maestro') + .description('Bootstrap a Maestro project.') + .requiredOption('--app ', 'Path to application under test.') + .requiredOption( + '--device ', + 'Android emulator or iOS Simulator to use for testing.', + ) + .requiredOption('--test-app ', 'Path to test application.') + .action(async (args) => { + try { + const options = new MaestroOptions( + args.app, + args.testApp, + args.device, + args.emulator, + ); + const credentials = await auth.getCredentials(); + if (credentials === null) { + throw new Error('Please specify credentials'); + } + const maestto = new Maestro(credentials, options); + await maestto.run(); + } catch (err: any) { + logger.error(`Maestro error: ${err.message}`); + } + }) + .showHelpAfterError(true); + +program + .command('xcuitest') + .description('Bootstrap an XCUITest project.') + .requiredOption('--app ', 'Path to application under test.') + .requiredOption('--device ', 'Real device to use for testing.') + .requiredOption('--test-app ', 'Path to test application.') + .action(async (args) => { + try { + const options = new XCUITestOptions(args.app, args.testApp, args.device); + const credentials = await auth.getCredentials(); + if (credentials === null) { + throw new Error('Please specify credentials'); + } + const xcuitest = new XCUITest(credentials, options); + await xcuitest.run(); + } catch (err: any) { + logger.error(`XCUITest error: ${err.message}`); + } + }); + +export default program; diff --git a/src/index.ts b/src/index.ts index d9218d5..efec73d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,74 +1,2 @@ -import { Command } from 'commander'; -import logger from './logger'; -import auth from './auth'; -import Espresso from './providers/espresso'; -import EspressoOptions from './models/espresso_options'; -import XCUITestOptions from './models/xcuitest_options'; -import XCUITest from './providers/xcuitest'; -import packageJson from '../package.json'; - -const program = new Command(); - -program - .version(packageJson.version) - .description( - 'TestingBotCTL is a CLI-tool to run Espresso, XCUITest and Maestro tests in the TestingBot cloud', - ); - -program - .command('espresso') - .description('Bootstrap an Espresso project.') - .requiredOption('--app ', 'Path to application under test.') - .requiredOption('--device ', 'Real device to use for testing.') - .requiredOption( - '--emulator ', - 'Android emulator to use for testing.', - ) - .requiredOption('--test-app ', 'Path to test application.') - .action(async (args) => { - try { - const options = new EspressoOptions( - args.app, - args.testApp, - args.device, - args.emulator, - ); - const credentials = await auth.getCredentials(); - if (credentials === null) { - throw new Error('Please specify credentials'); - } - const espresso = new Espresso(credentials, options); - await espresso.run(); - } catch (err: any) { - logger.error(`Espresso error: ${err.message}`); - process.exit(1); - } - }) - .showHelpAfterError(true); - -program - .command('xcuitest') - .description('Bootstrap an XCUITest project.') - .requiredOption('--app ', 'Path to application under test.') - .requiredOption('--device ', 'Real device to use for testing.') - .requiredOption('--test-app ', 'Path to test application.') - .action(async (args) => { - try { - const options = new XCUITestOptions(args.app, args.testApp, args.device); - const credentials = await auth.getCredentials(); - if (credentials === null) { - throw new Error('Please specify credentials'); - } - const xcuitest = new XCUITest(credentials, options); - await xcuitest.run(); - } catch (err: any) { - logger.error(`XCUITest error: ${err.message}`); - process.exit(1); - } - }); - +import program from './cli'; program.parse(process.argv); - -auth.getCredentials().then((credentials: any) => { - logger.info(credentials.toString()); -}); diff --git a/src/models/maestro_options.ts b/src/models/maestro_options.ts new file mode 100644 index 0000000..b51142b --- /dev/null +++ b/src/models/maestro_options.ts @@ -0,0 +1,34 @@ +export default class MaestroOptions { + private _app: string; + private _testApp: string; + private _device: string; + private _emulator: string; + + public constructor( + app: string, + testApp: string, + device: string, + emulator: string, + ) { + this._app = app; + this._testApp = testApp; + this._device = device; + this._emulator = emulator; + } + + public get app(): string { + return this._app; + } + + public get testApp(): string { + return this._testApp; + } + + public get device(): string { + return this._device; + } + + public get emulator(): string { + return this._emulator; + } +} diff --git a/src/providers/maestro.ts b/src/providers/maestro.ts new file mode 100644 index 0000000..e298b7c --- /dev/null +++ b/src/providers/maestro.ts @@ -0,0 +1,171 @@ +import MaestroOptions from '../models/maestro_options'; +import logger from '../logger'; +import Credentials from '../models/credentials'; +import axios from 'axios'; +import fs from 'node:fs'; +import path from 'node:path'; +import FormData from 'form-data'; +import TestingBotError from '../models/testingbot_error'; +import utils from '../utils'; + +export default class Maestro { + private readonly URL = 'https://api.testingbot.com/v1/app-automate/maestro'; + private credentials: Credentials; + private options: MaestroOptions; + + private appId: number | undefined = undefined; + + public constructor(credentials: Credentials, options: MaestroOptions) { + this.credentials = credentials; + this.options = options; + } + + private async validate(): Promise { + if (this.options.app === undefined) { + throw new TestingBotError(`app option is required`); + } + + try { + await fs.promises.access(this.options.app, fs.constants.R_OK); + } catch (err) { + throw new TestingBotError( + `Provided app path does not exist ${this.options.app}`, + ); + } + + if (this.options.testApp === undefined) { + throw new TestingBotError(`testApp option is required`); + } + + try { + await fs.promises.access(this.options.testApp, fs.constants.R_OK); + } catch (err) { + throw new TestingBotError( + `testApp path does not exist ${this.options.testApp}`, + ); + } + + if ( + this.options.device === undefined && + this.options.emulator === undefined + ) { + throw new TestingBotError(`Please specify either a device or emulator`); + } + + return true; + } + + public async run() { + if (!(await this.validate())) { + return; + } + try { + logger.info('Uploading Maestro App'); + await this.uploadApp(); + + logger.info('Uploading Maestro Test App'); + await this.uploadTestApp(); + + logger.info('Running Maestro Tests'); + await this.runTests(); + } catch (error: any) { + logger.error(error.message); + } + } + + private async uploadApp() { + const fileName = path.basename(this.options.app); + const fileStream = fs.createReadStream(this.options.app); + + const formData = new FormData(); + formData.append('file', fileStream); + const response = await axios.post(`${this.URL}/app`, formData, { + headers: { + 'Content-Type': 'application/vnd.android.package-archive', + 'Content-Disposition': `attachment; filename=${fileName}`, + 'User-Agent': utils.getUserAgent(), + }, + auth: { + username: this.credentials.userName, + password: this.credentials.accessKey, + }, + }); + + const result = response.data; + if (result.id) { + this.appId = result.id; + } else { + throw new TestingBotError(`Uploading app failed: ${result.error}`); + } + + return true; + } + + private async uploadTestApp() { + const fileName = path.basename(this.options.testApp); + const fileStream = fs.createReadStream(this.options.testApp); + + const formData = new FormData(); + formData.append('file', fileStream); + const response = await axios.post( + `${this.URL}/${this.appId}/tests`, + formData, + { + headers: { + 'Content-Type': 'application/zip,', + 'Content-Disposition': `attachment; filename=${fileName}`, + 'User-Agent': utils.getUserAgent(), + }, + auth: { + username: this.credentials.userName, + password: this.credentials.accessKey, + }, + }, + ); + + const result = response.data; + if (!result.id) { + throw new TestingBotError(`Uploading test app failed: ${result.error}`); + } + + return true; + } + + private async runTests() { + try { + const response = await axios.post( + `${this.URL}/${this.appId}/run`, + { + capabilities: [ + { + deviceName: this.options.emulator, + }, + ], + }, + { + headers: { + 'Content-Type': 'application/json', + 'User-Agent': utils.getUserAgent(), + }, + auth: { + username: this.credentials.userName, + password: this.credentials.accessKey, + }, + }, + ); + + const result = response.data; + if (result.success === false) { + throw new TestingBotError(`Running Maestro test failed`, { + cause: result.error, + }); + } + + return true; + } catch (error) { + throw new TestingBotError(`Running Maestro test failed`, { + cause: error, + }); + } + } +} diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..3cd6726 --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,183 @@ +import program from './../src/cli'; +import logger from './../src/logger'; +import auth from './../src/auth'; +import Espresso from './../src/providers/espresso'; +import XCUITest from './../src/providers/xcuitest'; +import Maestro from './../src/providers/maestro'; + +jest.mock('./../src/logger'); +jest.mock('./../src/auth'); +jest.mock('./../src/providers/espresso'); +jest.mock('./../src/providers/xcuitest'); +jest.mock('./../src/providers/maestro'); + +const mockGetCredentials = auth.getCredentials as jest.Mock; + +describe('TestingBotCTL CLI', () => { + let mockEspressoRun: jest.Mock; + let mockMaestroRun: jest.Mock; + let mockXCUITestRun: jest.Mock; + + beforeEach(() => { + mockEspressoRun = jest.fn(); + Espresso.prototype.run = mockEspressoRun; + + mockMaestroRun = jest.fn(); + Maestro.prototype.run = mockMaestroRun; + + mockXCUITestRun = jest.fn(); + XCUITest.prototype.run = mockXCUITestRun; + + jest + .spyOn(process, 'exit') + .mockImplementation((code?: number | undefined) => { + throw new Error(`process.exit called with code: ${code}`); + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('espresso command should call espresso.run() with valid options', async () => { + mockGetCredentials.mockResolvedValue({ apiKey: 'test-api-key' }); + + await program.parseAsync([ + 'node', + 'cli', + 'espresso', + '--app', + 'app.apk', + '--device', + 'device-1', + '--emulator', + 'emulator-1', + '--test-app', + 'test-app.apk', + ]); + + expect(mockEspressoRun).toHaveBeenCalledTimes(1); + expect(mockEspressoRun).toHaveBeenCalledWith(); + }); + + test('maestro command should call maestro.run() with valid options', async () => { + mockGetCredentials.mockResolvedValue({ apiKey: 'test-api-key' }); + + await program.parseAsync([ + 'node', + 'cli', + 'maestro', + '--app', + 'app.apk', + '--device', + 'device-1', + '--test-app', + 'test-app.apk', + ]); + + expect(mockMaestroRun).toHaveBeenCalledTimes(1); + }); + + test('xcuitest command should call xcuitest.run() with valid options', async () => { + mockGetCredentials.mockResolvedValue({ apiKey: 'test-api-key' }); + + await program.parseAsync([ + 'node', + 'cli', + 'xcuitest', + '--app', + 'app.ipa', + '--device', + 'device-1', + '--test-app', + 'test-app.ipa', + ]); + + expect(mockXCUITestRun).toHaveBeenCalledTimes(1); + }); + + test('espresso command should handle missing credentials', async () => { + mockGetCredentials.mockResolvedValue(null); + + const mockError = jest.fn(); + logger.error = mockError; + + await program.parseAsync([ + 'node', + 'cli', + 'espresso', + '--app', + 'app.apk', + '--device', + 'device-1', + '--emulator', + 'emulator-1', + '--test-app', + 'test-app.apk', + ]); + + expect(mockError).toHaveBeenCalledWith( + 'Espresso error: Please specify credentials', + ); + }); + + test('maestro command should handle missing credentials', async () => { + mockGetCredentials.mockResolvedValue(null); + + const mockError = jest.fn(); + logger.error = mockError; + + await program.parseAsync([ + 'node', + 'cli', + 'maestro', + '--app', + 'app.apk', + '--device', + 'device-1', + '--test-app', + 'test-app.apk', + ]); + + expect(mockError).toHaveBeenCalledWith( + 'Maestro error: Please specify credentials', + ); + }); + + test('xcuitest command should handle missing credentials', async () => { + mockGetCredentials.mockResolvedValue(null); + + const mockError = jest.fn(); + logger.error = mockError; + + await program.parseAsync([ + 'node', + 'cli', + 'xcuitest', + '--app', + 'app.ipa', + '--device', + 'device-1', + '--test-app', + 'test-app.ipa', + ]); + + expect(mockError).toHaveBeenCalledWith( + 'XCUITest error: Please specify credentials', + ); + }); + + test('unknown command should show help', async () => { + const exitSpy = jest + .spyOn(process, 'exit') + .mockImplementation((code?: number | undefined) => { + throw new Error(`process.exit called with code: ${code}`); + }); + + await expect( + program.parseAsync(['node', 'cli', 'unknown']), + ).rejects.toThrow('process.exit called with code: 1'); + + exitSpy.mockRestore(); + }); +}); diff --git a/tests/providers/maestro.test.ts b/tests/providers/maestro.test.ts new file mode 100644 index 0000000..810b0c9 --- /dev/null +++ b/tests/providers/maestro.test.ts @@ -0,0 +1,124 @@ +import Maestro from '../../src/providers/maestro'; +import MaestroOptions from '../../src/models/maestro_options'; +import TestingBotError from '../../src/models/testingbot_error'; +import fs from 'node:fs'; +import axios from 'axios'; +import { Readable } from 'node:stream'; +import Credentials from '../../src/models/credentials'; + +jest.mock('axios'); +jest.mock('../../src/utils'); +jest.mock('node:fs', () => ({ + ...jest.requireActual('fs'), + promises: { + ...jest.requireActual('fs').promises, + access: jest.fn(), + }, +})); + +describe('Maestro', () => { + let maestro: Maestro; + const mockCredentials = new Credentials('testUser', 'testKey'); + + const mockOptions: MaestroOptions = new MaestroOptions( + 'path/to/app.apk', + 'path/to/testApp.zip', + 'Test Device', + 'Test Emulator', + ); + + beforeEach(() => { + maestro = new Maestro(mockCredentials, mockOptions); + }); + + describe('Validation', () => { + it('should pass validation when app, testApp, and device are provided', async () => { + fs.promises.access = jest + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); + + await expect(maestro['validate']()).resolves.toBe(true); + }); + }); + + describe('Upload App', () => { + it('should successfully upload an app and set appId', async () => { + const mockFileStream = new Readable(); + mockFileStream._read = jest.fn(); + + fs.createReadStream = jest.fn().mockReturnValue(mockFileStream); + + const mockResponse = { + data: { + id: '1234', + }, + }; + + axios.post = jest.fn().mockResolvedValueOnce(mockResponse); + + await expect(maestro['uploadApp']()).resolves.toBe(true); + expect(fs.createReadStream).toHaveBeenCalledWith(mockOptions.app); + }); + + it('should throw an error if app upload fails', async () => { + const mockResponse = { data: { error: 'Upload failed' } }; + axios.post = jest.fn().mockResolvedValueOnce(mockResponse); + + await expect(maestro['uploadApp']()).rejects.toThrow( + new TestingBotError('Uploading app failed: Upload failed'), + ); + }); + }); + + describe('Upload Test App', () => { + it('should successfully upload the test app', async () => { + const mockFileStream = new Readable(); + mockFileStream._read = jest.fn(); + + fs.createReadStream = jest.fn().mockReturnValue(mockFileStream); + + const mockResponse = { + data: { + id: '1234', + }, + }; + + axios.post = jest.fn().mockResolvedValueOnce(mockResponse); + + await expect(maestro['uploadTestApp']()).resolves.toBe(true); + expect(fs.createReadStream).toHaveBeenCalledWith(mockOptions.testApp); + }); + + it('should throw an error if test app upload fails', async () => { + const mockResponse = { data: { error: 'Test app upload failed' } }; + axios.post = jest.fn().mockResolvedValueOnce(mockResponse); + + await expect(maestro['uploadTestApp']()).rejects.toThrow( + new TestingBotError( + 'Uploading test app failed: Test app upload failed', + ), + ); + }); + }); + + describe('Run Tests', () => { + it('should successfully run the tests', async () => { + const mockResponse = { data: { success: true } }; + axios.post = jest.fn().mockResolvedValueOnce(mockResponse); + + await expect(maestro['runTests']()).resolves.toBe(true); + }); + + it('should throw an error if running tests fails', async () => { + const mockError = new Error('Test failed'); + axios.post = jest.fn().mockRejectedValueOnce(mockError); + + await expect(maestro['runTests']()).rejects.toThrow( + new TestingBotError('Running Maestro test failed', { + cause: mockError, + }), + ); + }); + }); +});