Skip to content
Merged
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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ testingbot maestro <app> <flows...> [options]
|--------|-------------|
| `--app <path>` | Path to the application under test (alternative to the positional `app` argument) |
| `--other-app <path-or-url>` | Additional companion app to install on the device alongside `--app`. Accepts a local file path (`.apk`, `.ipa`, `.app`, `.zip`) **or** a `tb://<appkey>` / `http(s)://...` URL — local paths are uploaded; URLs are passed through to the run as-is. Repeatable, **max 4** entries. |
| `--app-binary-id <projectId>` | Reuse the app of a project uploaded earlier (`testingbot upload`, or any previous run's Project ID) instead of uploading one. Every positional argument is then a flow. The platform is taken from the stored app unless `--platform` is given |

**Device Options:**

Expand Down Expand Up @@ -297,6 +298,31 @@ once because another top-level flow calls it via `runFlow`.

---

### Upload once, run many times

`testingbot upload` pushes an app once and prints a Project ID. Later runs pass that ID with `--app-binary-id` and skip the upload entirely; each run still gets its own project and results.

```sh
testingbot upload app.apk
# Uploaded app.apk. Project ID: 4321
# Run flows against it with: testingbot maestro --app-binary-id 4321 ./flows

APP_ID=$(testingbot upload app.apk --json | jq -r .appId)
testingbot maestro --app-binary-id "$APP_ID" ./flows/smoke
testingbot maestro --app-binary-id "$APP_ID" ./flows/regression --device "Pixel 9"
```

Every `maestro` run also prints its Project ID after the app upload, so any previous run's ID works with `--app-binary-id` too. Unchanged binaries are deduplicated by checksum on upload as well; pass `--ignore-checksum-check` to force a fresh upload.

**`upload <appFile>`**

| Option | Description |
|--------|-------------|
| `--ignore-checksum-check` | Skip checksum verification and always upload the app |
| `-q, --quiet` | Suppress upload progress |

`--json` returns `{ provider, appId, file, url }`. Fails with exit code `1` if the upload was rejected.

### Status, artifacts and list

Commands for working with Maestro projects after they were started, typically together with `--async`. Every command accepts `--api-key` / `--api-secret`, `--debug`, and the `--json`, `--json-file`, `--json-file-name` output flags described under [JSON Output](#json-output).
Expand Down
84 changes: 80 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,11 @@ program
'--app <path>',
'Path to application under test (.apk, .ipa, .app, or .zip).',
)
.option(
'--app-binary-id <projectId>',
'Reuse the app of a project uploaded earlier (see "testingbot upload") instead of uploading one. All positional arguments are then flows.',
parseProjectId,
)
.option(
'--other-app <path>',
'Additional app to install alongside --app (.apk, .ipa, .app, or .zip). Repeatable, max 4.',
Expand Down Expand Up @@ -545,9 +550,9 @@ program
let app: string;
let flows: string[];

if (args.app) {
// If --app is specified, treat all positional arguments as flows
app = args.app;
if (args.app || args.appBinaryId != null) {
// With --app or --app-binary-id, every positional argument is a flow
app = args.app ?? '';
flows = appFileArg
? [appFileArg, ...(flowsArgs || [])]
: flowsArgs || [];
Expand All @@ -558,7 +563,8 @@ program
}

const missing: string[] = [];
if (!app) missing.push('<appFile> or --app');
if (!app && args.appBinaryId == null)
missing.push('<appFile>, --app or --app-binary-id');
if (flows.length === 0)
missing.push(
'<flows...> (one or more flow files, directories, or globs)',
Expand Down Expand Up @@ -651,6 +657,7 @@ program
groups: args.groups,
metadata,
otherApps,
appBinaryId: args.appBinaryId,
});
if (args.debug) {
enableDebugLogging();
Expand Down Expand Up @@ -868,6 +875,75 @@ function withFlags(
return command;
}

withFlags(
withFlags(
program
.command('upload')
.description(
'Upload a Maestro app once and get a project ID to reuse with "testingbot maestro --app-binary-id".',
)
.argument('<appFile>', 'Path to the app (.apk, .ipa, .app or .zip)')
.option(
'--ignore-checksum-check',
'Skip checksum verification and always upload the app.',
)
.option(
'-q, --quiet',
'Quieter console output without progress updates.',
),
JSON_FLAGS,
),
AUTH_FLAGS,
)
.action(async (appFile, args) => {
let jsonOptions: JsonOutputOptions | undefined;
try {
jsonOptions = jsonOptionsFrom(args);
const credentials = await requireCredentials(args);
if (args.debug) enableDebugLogging();
const maestro = new Maestro(
credentials,
new MaestroOptions(appFile, [], undefined, {
quiet: args.quiet || jsonOptions.json || jsonOptions.jsonFile,
ignoreChecksumCheck: args.ignoreChecksumCheck,
debug: args.debug,
}),
);
const result = await maestro.uploadOnly();
if (!result.success) {
await finishCommand(
{
provider: 'maestro',
outcome: 'error',
success: false,
error: result.error,
runs: [],
},
jsonOptions,
);
return;
}
const output = {
provider: 'maestro' as const,
appId: result.appId,
file: appFile,
url: `https://testingbot.com/members/maestro/${result.appId}`,
};
const written = await writeJsonOutput(output, jsonOptions);
if (!jsonOptions.json) {
logger.info(`App ready: ${appFile}. Project ID: ${result.appId}`);
logger.info(
`Run flows against it with: testingbot maestro --app-binary-id ${result.appId} ./flows`,
);
if (written) logger.info(`JSON results written to ${written}`);
}
process.exitCode = 0;
} catch (err) {
await failCommand('maestro', 'Upload', err, jsonOptions);
}
})
.showHelpAfterError(true);

withFlags(
withFlags(
program
Expand Down
11 changes: 11 additions & 0 deletions src/models/maestro_options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export default class MaestroOptions {
}

private _app: string;
private _appBinaryId?: number;
private _flows: string[];
private _otherApps: string[];
private _device?: string;
Expand Down Expand Up @@ -141,9 +142,11 @@ export default class MaestroOptions {
googlePlayStore?: boolean;
metadata?: RunMetadata;
otherApps?: string[];
appBinaryId?: number;
},
) {
this._app = app;
this._appBinaryId = options?.appBinaryId;
this._flows = flows ? (Array.isArray(flows) ? flows : [flows]) : [];
this._otherApps = options?.otherApps ?? [];
if (this._otherApps.length > MAX_OTHER_APPS) {
Expand Down Expand Up @@ -203,6 +206,14 @@ export default class MaestroOptions {
return this._app;
}

/**
* ID of an existing project whose uploaded app should be reused instead of
* uploading `app`. Set by --app-binary-id.
*/
public get appBinaryId(): number | undefined {
return this._appBinaryId;
}

public get flows(): string[] {
return this._flows;
}
Expand Down
130 changes: 115 additions & 15 deletions src/providers/maestro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,12 +176,12 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
'.zip',
];

private async validate(): Promise<boolean> {
if (this.options.app === undefined) {
/** Rejects an app path that is missing, has an unsupported extension, or is unreadable. */
private async validateAppFile(): Promise<void> {
if (!this.options.app) {
throw new TestingBotError(`app option is required`);
}

// Validate app file extension
const appExt = path.extname(this.options.app).toLowerCase();
if (!Maestro.SUPPORTED_APP_EXTENSIONS.includes(appExt)) {
throw new TestingBotError(
Expand All @@ -190,6 +190,24 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
);
}

await fs.promises.access(this.options.app, fs.constants.R_OK).catch(() => {
throw new TestingBotError(
`Provided app path does not exist ${this.options.app}`,
);
});
}

private async validate(): Promise<boolean> {
const reusingApp = this.options.appBinaryId != null;
if (reusingApp && this.options.app) {
throw new TestingBotError(
'Pass either an app file or --app-binary-id, not both.',
);
}
if (!reusingApp) {
await this.validateAppFile();
}

if (this.options.flows === undefined || this.options.flows.length === 0) {
throw new TestingBotError(`flows option is required`);
}
Expand Down Expand Up @@ -221,13 +239,7 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
}

// Build list of all file checks to run in parallel
const fileChecks: Promise<void>[] = [
fs.promises.access(this.options.app, fs.constants.R_OK).catch(() => {
throw new TestingBotError(
`Provided app path does not exist ${this.options.app}`,
);
}),
];
const fileChecks: Promise<void>[] = [];

for (const otherAppEntry of otherApps) {
if (Maestro.isOtherAppUrl(otherAppEntry)) {
Expand Down Expand Up @@ -331,11 +343,17 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
provider: 'Maestro',
apiUrl: this.URL,
uploads: [
{
label: 'App',
filePath: this.options.app,
endpoint: `${this.URL}/app`,
},
this.options.appBinaryId != null
? {
label: 'App',
filePath: `(reuse app of project ${this.options.appBinaryId})`,
endpoint: `${this.URL}/app/${this.options.appBinaryId}/reuse`,
}
: {
label: 'App',
filePath: this.options.app,
endpoint: `${this.URL}/app`,
},
...otherAppPaths.map((p, i) => ({
label: `Other App ${i + 1}`,
filePath: p,
Expand Down Expand Up @@ -406,6 +424,11 @@ export default class Maestro extends BaseProvider<MaestroOptions> {

setTitle('maestro · uploading app');
await this.uploadApp();
if (!this.options.quiet) {
logger.info(
`App ready. Project ID: ${this.appId} (reuse this app later with --app-binary-id ${this.appId})`,
);
}

if (this.options.otherApps.length > 0) {
setTitle('maestro · uploading other apps');
Expand Down Expand Up @@ -490,7 +513,84 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
}
}

/**
* `testingbot upload`: uploads (or dedupes) the app and returns the project
* id that can be passed to `--app-binary-id`. No flows, no run.
*/
public async uploadOnly(): Promise<
{ success: true; appId: number } | { success: false; error: string }
> {
try {
await this.validateAppFile();
await this.ensureConnectivity();
await this.uploadApp();
if (this.appId == null) {
throw new TestingBotError('Upload did not return a project id');
}
return { success: true, appId: this.appId };
} catch (error) {
this.spinner.stop();
const result = this.errorResult(error);
return { success: false, error: result.error ?? 'Upload failed' };
}
}

/**
* Creates a fresh project that shares the stored app of `sourceId`
* (--app-binary-id). The server reports the app's platform, which replaces
* file-based detection when --platform was not given.
*/
private async reuseApp(sourceId: number): Promise<void> {
if (!this.options.quiet) {
logger.info(`Reusing app of project ${sourceId}`);
}
const data = await this.withRetry(
`Reusing app of project ${sourceId}`,
async () => {
const response = await axios.post<{
id: number;
source_id: number;
platform: 'Android' | 'iOS' | null;
}>(
`${this.URL}/app/${sourceId}/reuse`,
{},
{
headers: {
'Content-Type': 'application/json',
'User-Agent': utils.getUserAgent(),
},
auth: {
username: this.credentials.userName,
password: this.credentials.accessKey,
},
},
);
return response.data;
},
).catch(async (error) => {
throw await this.handleErrorWithDiagnostics(
error,
`Failed to reuse the app of project ${sourceId}`,
);
});

this.appId = data.id;
if (!this.options.platformName) {
if (!data.platform) {
throw new TestingBotError(
`Could not determine the platform of project ${sourceId}. Pass --platform Android|iOS.`,
);
}
this.detectedPlatform = data.platform;
}
}

private async uploadApp() {
if (this.options.appBinaryId != null) {
await this.reuseApp(this.options.appBinaryId);
return true;
}

let appPath = this.options.app;
const ext = path.extname(appPath).toLowerCase();
let tempZipDir: string | null = null;
Expand Down
Loading
Loading