Skip to content

Commit 310c89a

Browse files
Maestro improvements
1 parent b51f7a0 commit 310c89a

File tree

10 files changed

+1696
-113
lines changed

10 files changed

+1696
-113
lines changed

src/auth.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,47 @@ import os from 'node:os';
33
import path from 'node:path';
44
import Credentials from './models/credentials';
55

6+
export interface AuthOptions {
7+
apiKey?: string;
8+
apiSecret?: string;
9+
}
10+
611
export default class Auth {
7-
public static async getCredentials(): Promise<Credentials | null> {
12+
/**
13+
* Get credentials from multiple sources in order of precedence:
14+
* 1. CLI options (--api-key, --api-secret)
15+
* 2. Environment variables (TB_KEY, TB_SECRET)
16+
* 3. ~/.testingbot file
17+
*/
18+
public static async getCredentials(
19+
options?: AuthOptions,
20+
): Promise<Credentials | null> {
21+
// 1. Check CLI options first (highest precedence)
22+
if (options?.apiKey && options?.apiSecret) {
23+
return new Credentials(options.apiKey, options.apiSecret);
24+
}
25+
26+
// 2. Check environment variables
27+
const envKey = process.env.TB_KEY;
28+
const envSecret = process.env.TB_SECRET;
29+
if (envKey && envSecret) {
30+
return new Credentials(envKey, envSecret);
31+
}
32+
33+
// 3. Fall back to ~/.testingbot file
34+
return this.getCredentialsFromFile();
35+
}
36+
37+
private static async getCredentialsFromFile(): Promise<Credentials | null> {
838
try {
939
const savedCredentials = (
1040
await fs.promises.readFile(path.join(os.homedir(), '.testingbot'))
1141
).toString();
1242
if (savedCredentials.length > 0) {
13-
const [userName, accessKey] = savedCredentials.split(':');
14-
return new Credentials(userName, accessKey);
43+
const [userName, accessKey] = savedCredentials.trim().split(':');
44+
if (userName && accessKey) {
45+
return new Credentials(userName, accessKey);
46+
}
1547
}
1648
return null;
1749
} catch {

src/cli.ts

Lines changed: 146 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import { Command } from 'commander';
22
import logger from './logger';
3-
import auth from './auth';
3+
import Auth from './auth';
44
import Espresso from './providers/espresso';
55
import EspressoOptions from './models/espresso_options';
66
import XCUITestOptions from './models/xcuitest_options';
77
import XCUITest from './providers/xcuitest';
88
import packageJson from '../package.json';
9-
import MaestroOptions from './models/maestro_options';
9+
import MaestroOptions, {
10+
Orientation,
11+
ThrottleNetwork,
12+
} from './models/maestro_options';
1013
import Maestro from './providers/maestro';
1114

1215
const program = new Command();
@@ -24,9 +27,11 @@ program
2427
.requiredOption('--device <device>', 'Real device to use for testing.')
2528
.requiredOption(
2629
'--emulator <emulator>',
27-
'Android emulator to use for testing.',
30+
'Android emulator/device to use for testing.',
2831
)
2932
.requiredOption('--test-app <string>', 'Path to test application.')
33+
.option('--api-key <key>', 'TestingBot API key.')
34+
.option('--api-secret <secret>', 'TestingBot API secret.')
3035
.action(async (args) => {
3136
try {
3237
const options = new EspressoOptions(
@@ -35,9 +40,14 @@ program
3540
args.device,
3641
args.emulator,
3742
);
38-
const credentials = await auth.getCredentials();
43+
const credentials = await Auth.getCredentials({
44+
apiKey: args.apiKey,
45+
apiSecret: args.apiSecret,
46+
});
3947
if (credentials === null) {
40-
throw new Error('Please specify credentials');
48+
throw new Error(
49+
'Please specify credentials via --api-key/--api-secret, TB_KEY/TB_SECRET environment variables, or ~/.testingbot file',
50+
);
4151
}
4252
const espresso = new Espresso(credentials, options);
4353
await espresso.run();
@@ -51,24 +61,132 @@ program
5161

5262
program
5363
.command('maestro')
54-
.description('Bootstrap a Maestro project.')
55-
.requiredOption('--app <string>', 'Path to application under test.')
56-
.requiredOption(
64+
.description('Run Maestro flows on TestingBot.')
65+
.argument(
66+
'[appFile]',
67+
'Path to application under test (.apk, .ipa, .app or .zip)',
68+
)
69+
.argument(
70+
'[flows]',
71+
'Path to flow file (.yaml/.yml), directory, .zip or glob pattern',
72+
)
73+
// App and flows options
74+
.option(
75+
'--app <string>',
76+
'Path to application under test (.apk, .ipa, .app, or .zip).',
77+
)
78+
.option(
79+
'--flows <string>',
80+
'Path to flow file (.yaml/.yml), directory of flows, .zip file or glob pattern.',
81+
)
82+
// Device configuration
83+
.option(
5784
'--device <device>',
58-
'Android emulator or iOS Simulator to use for testing.',
85+
'Device name to use for testing (e.g., "Pixel 8", "iPhone 15"). If not specified, uses "*" for any available device.',
5986
)
60-
.requiredOption('--test-app <string>', 'Path to test application.')
61-
.action(async (args) => {
87+
.option(
88+
'--platform <platform>',
89+
'Platform name: Android or iOS.',
90+
(val) => val as 'Android' | 'iOS',
91+
)
92+
.option('--version <version>', 'OS version (e.g., "14", "17.2").')
93+
.option(
94+
'--orientation <orientation>',
95+
'Screen orientation: PORTRAIT or LANDSCAPE.',
96+
(val) => val.toUpperCase() as Orientation,
97+
)
98+
.option('--locale <locale>', 'Device locale (e.g., "en_US", "de_DE").')
99+
.option(
100+
'--timezone <timezone>',
101+
'Device timezone (e.g., "America/New_York", "Europe/London").',
102+
)
103+
// Test metadata
104+
.option('--name <name>', 'Test name for identification in dashboard.')
105+
.option('--build <build>', 'Build identifier for grouping test runs.')
106+
// Network and geo
107+
.option(
108+
'--throttle-network <speed>',
109+
'Network throttling: 4G, 3G, Edge, airplane, or disable.',
110+
(val) => val as ThrottleNetwork,
111+
)
112+
.option(
113+
'--geo-country-code <code>',
114+
'Geographic IP location (ISO country code, e.g., "US", "DE").',
115+
)
116+
// Flow filtering
117+
.option(
118+
'--include-tags <tags>',
119+
'Only run flows with these tags (comma-separated).',
120+
(val) => val.split(',').map((t) => t.trim()),
121+
)
122+
.option(
123+
'--exclude-tags <tags>',
124+
'Exclude flows with these tags (comma-separated).',
125+
(val) => val.split(',').map((t) => t.trim()),
126+
)
127+
// Environment variables
128+
.option(
129+
'-e, --env <KEY=VALUE>',
130+
'Environment variable to pass to Maestro flows (can be used multiple times).',
131+
(val: string, acc: string[]) => {
132+
acc.push(val);
133+
return acc;
134+
},
135+
[] as string[],
136+
)
137+
// Authentication
138+
.option('--api-key <key>', 'TestingBot API key.')
139+
.option('--api-secret <secret>', 'TestingBot API secret.')
140+
.action(async (appFileArg, flowsArg, args) => {
62141
try {
63-
const options = new MaestroOptions(
64-
args.app,
65-
args.testApp,
66-
args.device,
67-
args.emulator,
68-
);
69-
const credentials = await auth.getCredentials();
142+
// Positional arguments take precedence, fall back to options
143+
const app = appFileArg || args.app;
144+
const flows = flowsArg || args.flows;
145+
146+
if (!app) {
147+
throw new Error(
148+
'App file is required. Provide it as first argument or use --app option.',
149+
);
150+
}
151+
if (!flows) {
152+
throw new Error(
153+
'Flows path is required. Provide it as second argument or use --flows option.',
154+
);
155+
}
156+
157+
// Parse environment variables from -e KEY=VALUE format
158+
const env: Record<string, string> = {};
159+
for (const envVar of args.env || []) {
160+
const eqIndex = envVar.indexOf('=');
161+
if (eqIndex > 0) {
162+
const key = envVar.substring(0, eqIndex);
163+
const value = envVar.substring(eqIndex + 1);
164+
env[key] = value;
165+
}
166+
}
167+
168+
const options = new MaestroOptions(app, flows, args.device, {
169+
includeTags: args.includeTags,
170+
excludeTags: args.excludeTags,
171+
platformName: args.platform,
172+
version: args.version,
173+
name: args.name,
174+
build: args.build,
175+
orientation: args.orientation,
176+
locale: args.locale,
177+
timeZone: args.timezone,
178+
throttleNetwork: args.throttleNetwork,
179+
geoCountryCode: args.geoCountryCode,
180+
env: Object.keys(env).length > 0 ? env : undefined,
181+
});
182+
const credentials = await Auth.getCredentials({
183+
apiKey: args.apiKey,
184+
apiSecret: args.apiSecret,
185+
});
70186
if (credentials === null) {
71-
throw new Error('Please specify credentials');
187+
throw new Error(
188+
'Please specify credentials via --api-key/--api-secret, TB_KEY/TB_SECRET environment variables, or ~/.testingbot file',
189+
);
72190
}
73191
const maestro = new Maestro(credentials, options);
74192
await maestro.run();
@@ -86,12 +204,19 @@ program
86204
.requiredOption('--app <string>', 'Path to application under test.')
87205
.requiredOption('--device <device>', 'Real device to use for testing.')
88206
.requiredOption('--test-app <string>', 'Path to test application.')
207+
.option('--api-key <key>', 'TestingBot API key.')
208+
.option('--api-secret <secret>', 'TestingBot API secret.')
89209
.action(async (args) => {
90210
try {
91211
const options = new XCUITestOptions(args.app, args.testApp, args.device);
92-
const credentials = await auth.getCredentials();
212+
const credentials = await Auth.getCredentials({
213+
apiKey: args.apiKey,
214+
apiSecret: args.apiSecret,
215+
});
93216
if (credentials === null) {
94-
throw new Error('Please specify credentials');
217+
throw new Error(
218+
'Please specify credentials via --api-key/--api-secret, TB_KEY/TB_SECRET environment variables, or ~/.testingbot file',
219+
);
95220
}
96221
const xcuitest = new XCUITest(credentials, options);
97222
await xcuitest.run();

0 commit comments

Comments
 (0)