Skip to content

Commit e5ed3b0

Browse files
maestro provider
1 parent 0dcf2cd commit e5ed3b0

File tree

7 files changed

+613
-74
lines changed

7 files changed

+613
-74
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"clean": "rm -rf dist",
1010
"start": "node dist/index.js",
1111
"format": "prettier --write '**/*.{js,ts}'",
12-
"test": "jest --detectOpenHandles",
12+
"test": "jest",
1313
"release": "release-it --github.release",
1414
"release:ci": "npm run release -- --ci --npm.skipChecks --no-git.requireCleanWorkingDir",
1515
"release:patch": "npm run release -- patch",

src/cli.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Command } from 'commander';
2+
import logger from './logger';
3+
import auth from './auth';
4+
import Espresso from './providers/espresso';
5+
import EspressoOptions from './models/espresso_options';
6+
import XCUITestOptions from './models/xcuitest_options';
7+
import XCUITest from './providers/xcuitest';
8+
import packageJson from '../package.json';
9+
import MaestroOptions from './models/maestro_options';
10+
import Maestro from './providers/maestro';
11+
12+
const program = new Command();
13+
14+
program
15+
.version(packageJson.version)
16+
.description(
17+
'TestingBotCTL is a CLI-tool to run Espresso, XCUITest and Maestro tests in the TestingBot cloud',
18+
);
19+
20+
program
21+
.command('espresso')
22+
.description('Bootstrap an Espresso project.')
23+
.requiredOption('--app <string>', 'Path to application under test.')
24+
.requiredOption('--device <device>', 'Real device to use for testing.')
25+
.requiredOption(
26+
'--emulator <emulator>',
27+
'Android emulator to use for testing.',
28+
)
29+
.requiredOption('--test-app <string>', 'Path to test application.')
30+
.action(async (args) => {
31+
try {
32+
const options = new EspressoOptions(
33+
args.app,
34+
args.testApp,
35+
args.device,
36+
args.emulator,
37+
);
38+
const credentials = await auth.getCredentials();
39+
if (credentials === null) {
40+
throw new Error('Please specify credentials');
41+
}
42+
const espresso = new Espresso(credentials, options);
43+
await espresso.run();
44+
} catch (err: any) {
45+
logger.error(`Espresso error: ${err.message}`);
46+
}
47+
})
48+
.showHelpAfterError(true);
49+
50+
program
51+
.command('maestro')
52+
.description('Bootstrap a Maestro project.')
53+
.requiredOption('--app <string>', 'Path to application under test.')
54+
.requiredOption(
55+
'--device <device>',
56+
'Android emulator or iOS Simulator to use for testing.',
57+
)
58+
.requiredOption('--test-app <string>', 'Path to test application.')
59+
.action(async (args) => {
60+
try {
61+
const options = new MaestroOptions(
62+
args.app,
63+
args.testApp,
64+
args.device,
65+
args.emulator,
66+
);
67+
const credentials = await auth.getCredentials();
68+
if (credentials === null) {
69+
throw new Error('Please specify credentials');
70+
}
71+
const maestto = new Maestro(credentials, options);
72+
await maestto.run();
73+
} catch (err: any) {
74+
logger.error(`Maestro error: ${err.message}`);
75+
}
76+
})
77+
.showHelpAfterError(true);
78+
79+
program
80+
.command('xcuitest')
81+
.description('Bootstrap an XCUITest project.')
82+
.requiredOption('--app <string>', 'Path to application under test.')
83+
.requiredOption('--device <device>', 'Real device to use for testing.')
84+
.requiredOption('--test-app <string>', 'Path to test application.')
85+
.action(async (args) => {
86+
try {
87+
const options = new XCUITestOptions(args.app, args.testApp, args.device);
88+
const credentials = await auth.getCredentials();
89+
if (credentials === null) {
90+
throw new Error('Please specify credentials');
91+
}
92+
const xcuitest = new XCUITest(credentials, options);
93+
await xcuitest.run();
94+
} catch (err: any) {
95+
logger.error(`XCUITest error: ${err.message}`);
96+
}
97+
});
98+
99+
export default program;

src/index.ts

Lines changed: 1 addition & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,2 @@
1-
import { Command } from 'commander';
2-
import logger from './logger';
3-
import auth from './auth';
4-
import Espresso from './providers/espresso';
5-
import EspressoOptions from './models/espresso_options';
6-
import XCUITestOptions from './models/xcuitest_options';
7-
import XCUITest from './providers/xcuitest';
8-
import packageJson from '../package.json';
9-
10-
const program = new Command();
11-
12-
program
13-
.version(packageJson.version)
14-
.description(
15-
'TestingBotCTL is a CLI-tool to run Espresso, XCUITest and Maestro tests in the TestingBot cloud',
16-
);
17-
18-
program
19-
.command('espresso')
20-
.description('Bootstrap an Espresso project.')
21-
.requiredOption('--app <string>', 'Path to application under test.')
22-
.requiredOption('--device <device>', 'Real device to use for testing.')
23-
.requiredOption(
24-
'--emulator <emulator>',
25-
'Android emulator to use for testing.',
26-
)
27-
.requiredOption('--test-app <string>', 'Path to test application.')
28-
.action(async (args) => {
29-
try {
30-
const options = new EspressoOptions(
31-
args.app,
32-
args.testApp,
33-
args.device,
34-
args.emulator,
35-
);
36-
const credentials = await auth.getCredentials();
37-
if (credentials === null) {
38-
throw new Error('Please specify credentials');
39-
}
40-
const espresso = new Espresso(credentials, options);
41-
await espresso.run();
42-
} catch (err: any) {
43-
logger.error(`Espresso error: ${err.message}`);
44-
process.exit(1);
45-
}
46-
})
47-
.showHelpAfterError(true);
48-
49-
program
50-
.command('xcuitest')
51-
.description('Bootstrap an XCUITest project.')
52-
.requiredOption('--app <string>', 'Path to application under test.')
53-
.requiredOption('--device <device>', 'Real device to use for testing.')
54-
.requiredOption('--test-app <string>', 'Path to test application.')
55-
.action(async (args) => {
56-
try {
57-
const options = new XCUITestOptions(args.app, args.testApp, args.device);
58-
const credentials = await auth.getCredentials();
59-
if (credentials === null) {
60-
throw new Error('Please specify credentials');
61-
}
62-
const xcuitest = new XCUITest(credentials, options);
63-
await xcuitest.run();
64-
} catch (err: any) {
65-
logger.error(`XCUITest error: ${err.message}`);
66-
process.exit(1);
67-
}
68-
});
69-
1+
import program from './cli';
702
program.parse(process.argv);
71-
72-
auth.getCredentials().then((credentials: any) => {
73-
logger.info(credentials.toString());
74-
});

src/models/maestro_options.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export default class MaestroOptions {
2+
private _app: string;
3+
private _testApp: string;
4+
private _device: string;
5+
private _emulator: string;
6+
7+
public constructor(
8+
app: string,
9+
testApp: string,
10+
device: string,
11+
emulator: string,
12+
) {
13+
this._app = app;
14+
this._testApp = testApp;
15+
this._device = device;
16+
this._emulator = emulator;
17+
}
18+
19+
public get app(): string {
20+
return this._app;
21+
}
22+
23+
public get testApp(): string {
24+
return this._testApp;
25+
}
26+
27+
public get device(): string {
28+
return this._device;
29+
}
30+
31+
public get emulator(): string {
32+
return this._emulator;
33+
}
34+
}

src/providers/maestro.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import MaestroOptions from '../models/maestro_options';
2+
import logger from '../logger';
3+
import Credentials from '../models/credentials';
4+
import axios from 'axios';
5+
import fs from 'node:fs';
6+
import path from 'node:path';
7+
import FormData from 'form-data';
8+
import TestingBotError from '../models/testingbot_error';
9+
import utils from '../utils';
10+
11+
export default class Maestro {
12+
private readonly URL = 'https://api.testingbot.com/v1/app-automate/maestro';
13+
private credentials: Credentials;
14+
private options: MaestroOptions;
15+
16+
private appId: number | undefined = undefined;
17+
18+
public constructor(credentials: Credentials, options: MaestroOptions) {
19+
this.credentials = credentials;
20+
this.options = options;
21+
}
22+
23+
private async validate(): Promise<boolean> {
24+
if (this.options.app === undefined) {
25+
throw new TestingBotError(`app option is required`);
26+
}
27+
28+
try {
29+
await fs.promises.access(this.options.app, fs.constants.R_OK);
30+
} catch (err) {
31+
throw new TestingBotError(
32+
`Provided app path does not exist ${this.options.app}`,
33+
);
34+
}
35+
36+
if (this.options.testApp === undefined) {
37+
throw new TestingBotError(`testApp option is required`);
38+
}
39+
40+
try {
41+
await fs.promises.access(this.options.testApp, fs.constants.R_OK);
42+
} catch (err) {
43+
throw new TestingBotError(
44+
`testApp path does not exist ${this.options.testApp}`,
45+
);
46+
}
47+
48+
if (
49+
this.options.device === undefined &&
50+
this.options.emulator === undefined
51+
) {
52+
throw new TestingBotError(`Please specify either a device or emulator`);
53+
}
54+
55+
return true;
56+
}
57+
58+
public async run() {
59+
if (!(await this.validate())) {
60+
return;
61+
}
62+
try {
63+
logger.info('Uploading Maestro App');
64+
await this.uploadApp();
65+
66+
logger.info('Uploading Maestro Test App');
67+
await this.uploadTestApp();
68+
69+
logger.info('Running Maestro Tests');
70+
await this.runTests();
71+
} catch (error: any) {
72+
logger.error(error.message);
73+
}
74+
}
75+
76+
private async uploadApp() {
77+
const fileName = path.basename(this.options.app);
78+
const fileStream = fs.createReadStream(this.options.app);
79+
80+
const formData = new FormData();
81+
formData.append('file', fileStream);
82+
const response = await axios.post(`${this.URL}/app`, formData, {
83+
headers: {
84+
'Content-Type': 'application/vnd.android.package-archive',
85+
'Content-Disposition': `attachment; filename=${fileName}`,
86+
'User-Agent': utils.getUserAgent(),
87+
},
88+
auth: {
89+
username: this.credentials.userName,
90+
password: this.credentials.accessKey,
91+
},
92+
});
93+
94+
const result = response.data;
95+
if (result.id) {
96+
this.appId = result.id;
97+
} else {
98+
throw new TestingBotError(`Uploading app failed: ${result.error}`);
99+
}
100+
101+
return true;
102+
}
103+
104+
private async uploadTestApp() {
105+
const fileName = path.basename(this.options.testApp);
106+
const fileStream = fs.createReadStream(this.options.testApp);
107+
108+
const formData = new FormData();
109+
formData.append('file', fileStream);
110+
const response = await axios.post(
111+
`${this.URL}/${this.appId}/tests`,
112+
formData,
113+
{
114+
headers: {
115+
'Content-Type': 'application/zip,',
116+
'Content-Disposition': `attachment; filename=${fileName}`,
117+
'User-Agent': utils.getUserAgent(),
118+
},
119+
auth: {
120+
username: this.credentials.userName,
121+
password: this.credentials.accessKey,
122+
},
123+
},
124+
);
125+
126+
const result = response.data;
127+
if (!result.id) {
128+
throw new TestingBotError(`Uploading test app failed: ${result.error}`);
129+
}
130+
131+
return true;
132+
}
133+
134+
private async runTests() {
135+
try {
136+
const response = await axios.post(
137+
`${this.URL}/${this.appId}/run`,
138+
{
139+
capabilities: [
140+
{
141+
deviceName: this.options.emulator,
142+
},
143+
],
144+
},
145+
{
146+
headers: {
147+
'Content-Type': 'application/json',
148+
'User-Agent': utils.getUserAgent(),
149+
},
150+
auth: {
151+
username: this.credentials.userName,
152+
password: this.credentials.accessKey,
153+
},
154+
},
155+
);
156+
157+
const result = response.data;
158+
if (result.success === false) {
159+
throw new TestingBotError(`Running Maestro test failed`, {
160+
cause: result.error,
161+
});
162+
}
163+
164+
return true;
165+
} catch (error) {
166+
throw new TestingBotError(`Running Maestro test failed`, {
167+
cause: error,
168+
});
169+
}
170+
}
171+
}

0 commit comments

Comments
 (0)