diff --git a/README.md b/README.md index e6fe62d..80e6052 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ testingbot maestro [options] |--------|-------------| | `--app ` | Path to the application under test (alternative to the positional `app` argument) | | `--other-app ` | Additional companion app to install on the device alongside `--app`. Accepts a local file path (`.apk`, `.ipa`, `.app`, `.zip`) **or** a `tb://` / `http(s)://...` URL — local paths are uploaded; URLs are passed through to the run as-is. Repeatable, **max 4** entries. | +| `--app-binary-id ` | 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:** @@ -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 `** + +| 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). diff --git a/src/cli.ts b/src/cli.ts index 6c18037..b41f142 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -370,6 +370,11 @@ program '--app ', 'Path to application under test (.apk, .ipa, .app, or .zip).', ) + .option( + '--app-binary-id ', + '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 ', 'Additional app to install alongside --app (.apk, .ipa, .app, or .zip). Repeatable, max 4.', @@ -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 || []; @@ -558,7 +563,8 @@ program } const missing: string[] = []; - if (!app) missing.push(' or --app'); + if (!app && args.appBinaryId == null) + missing.push(', --app or --app-binary-id'); if (flows.length === 0) missing.push( ' (one or more flow files, directories, or globs)', @@ -651,6 +657,7 @@ program groups: args.groups, metadata, otherApps, + appBinaryId: args.appBinaryId, }); if (args.debug) { enableDebugLogging(); @@ -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('', '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 diff --git a/src/models/maestro_options.ts b/src/models/maestro_options.ts index 1e72faf..20cab1f 100644 --- a/src/models/maestro_options.ts +++ b/src/models/maestro_options.ts @@ -69,6 +69,7 @@ export default class MaestroOptions { } private _app: string; + private _appBinaryId?: number; private _flows: string[]; private _otherApps: string[]; private _device?: string; @@ -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) { @@ -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; } diff --git a/src/providers/maestro.ts b/src/providers/maestro.ts index f3f06a9..b95d87d 100644 --- a/src/providers/maestro.ts +++ b/src/providers/maestro.ts @@ -176,12 +176,12 @@ export default class Maestro extends BaseProvider { '.zip', ]; - private async validate(): Promise { - if (this.options.app === undefined) { + /** Rejects an app path that is missing, has an unsupported extension, or is unreadable. */ + private async validateAppFile(): Promise { + 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( @@ -190,6 +190,24 @@ export default class Maestro extends BaseProvider { ); } + 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 { + 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`); } @@ -221,13 +239,7 @@ export default class Maestro extends BaseProvider { } // Build list of all file checks to run in parallel - const fileChecks: Promise[] = [ - 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[] = []; for (const otherAppEntry of otherApps) { if (Maestro.isOtherAppUrl(otherAppEntry)) { @@ -331,11 +343,17 @@ export default class Maestro extends BaseProvider { 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, @@ -406,6 +424,11 @@ export default class Maestro extends BaseProvider { 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'); @@ -490,7 +513,84 @@ export default class Maestro extends BaseProvider { } } + /** + * `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 { + 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; diff --git a/tests/cli.test.ts b/tests/cli.test.ts index bafb18d..76cf115 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1357,6 +1357,143 @@ describe('TestingBotCTL CLI', () => { }); }); + describe('upload command and --app-binary-id', () => { + let mockUploadOnly: jest.Mock; + let stdoutSpy: jest.SpyInstance; + + beforeEach(() => { + mockGetCredentials.mockResolvedValue({ apiKey: 'test-api-key' }); + mockUploadOnly = jest.fn(); + Maestro.prototype.uploadOnly = mockUploadOnly; + stdoutSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('upload stores the app and prints the reusable project id', async () => { + mockUploadOnly.mockResolvedValue({ success: true, appId: 4321 }); + await program.parseAsync(['node', 'cli', 'upload', 'app.apk']); + expect(mockUploadOnly).toHaveBeenCalledTimes(1); + const opts = lastConstructorOptions<{ + app: string; + flows: string[]; + ignoreChecksumCheck: boolean; + }>(Maestro); + expect(opts.app).toBe('app.apk'); + expect(opts.flows).toEqual([]); + expect(opts.ignoreChecksumCheck).toBe(false); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('Project ID: 4321'), + ); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('--app-binary-id 4321'), + ); + expect(process.exitCode).toBe(0); + }); + + test('upload --json emits the project id and url', async () => { + mockUploadOnly.mockResolvedValue({ success: true, appId: 4321 }); + await program.parseAsync([ + 'node', + 'cli', + 'upload', + 'app.apk', + '--ignore-checksum-check', + '--json', + ]); + expect( + lastConstructorOptions<{ + ignoreChecksumCheck: boolean; + quiet: boolean; + }>(Maestro), + ).toMatchObject({ ignoreChecksumCheck: true, quiet: true }); + expect(JSON.parse(String(stdoutSpy.mock.calls[0][0]))).toEqual({ + provider: 'maestro', + appId: 4321, + file: 'app.apk', + url: 'https://testingbot.com/members/maestro/4321', + }); + expect(process.exitCode).toBe(0); + }); + + test('upload exits 1 when the provider reports a failure', async () => { + mockUploadOnly.mockResolvedValue({ success: false, error: 'bad app' }); + await program.parseAsync(['node', 'cli', 'upload', 'app.apk', '--json']); + expect(JSON.parse(String(stdoutSpy.mock.calls[0][0]))).toMatchObject({ + outcome: 'error', + error: 'bad app', + }); + expect(process.exitCode).toBe(1); + }); + + test('upload requires an app file argument', async () => { + await expect( + program.parseAsync(['node', 'cli', 'upload']), + ).rejects.toThrow('process.exit called with code: 1'); + expect(mockUploadOnly).not.toHaveBeenCalled(); + }); + + test('maestro --app-binary-id treats every positional as a flow', async () => { + mockMaestroRun.mockResolvedValue({ + success: true, + outcome: 'passed', + runs: [], + }); + await program.parseAsync([ + 'node', + 'cli', + 'maestro', + '--app-binary-id', + '4321', + './flows', + './more', + ]); + const opts = lastConstructorOptions<{ + app: string; + flows: string[]; + appBinaryId?: number; + }>(Maestro); + expect(opts.appBinaryId).toBe(4321); + expect(opts.app).toBe(''); + expect(opts.flows).toEqual(['./flows', './more']); + expect(mockMaestroRun).toHaveBeenCalledTimes(1); + }); + + test('maestro --app-binary-id still requires flows', async () => { + await program.parseAsync([ + 'node', + 'cli', + 'maestro', + '--app-binary-id', + '4321', + ]); + expect(mockMaestroRun).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining(''), + ); + expect(process.exitCode).toBe(1); + }); + + test('maestro --app-binary-id rejects a non-numeric id', async () => { + jest.spyOn(process.stderr, 'write').mockImplementation(() => true); + await expect( + program.parseAsync([ + 'node', + 'cli', + 'maestro', + '--app-binary-id', + 'abc', + './flows', + ]), + ).rejects.toThrow('process.exit called with code: 1'); + expect(mockMaestroRun).not.toHaveBeenCalled(); + }); + }); + test('unknown command should show help', async () => { const exitSpy = jest .spyOn(process, 'exit') diff --git a/tests/providers/maestro.test.ts b/tests/providers/maestro.test.ts index 17d8970..3cdf121 100644 --- a/tests/providers/maestro.test.ts +++ b/tests/providers/maestro.test.ts @@ -7029,4 +7029,125 @@ onFlowStart: expect((axios.get as jest.Mock).mock.calls[0][1].params).toEqual({}); }); }); + + describe('--app-binary-id and uploadOnly()', () => { + const reuseOptions = (extra: Record = {}) => + new MaestroOptions('', 'path/to/flows', 'Pixel 6', { + appBinaryId: 4321, + ...extra, + }); + + it('validate() skips the app file when reusing an app', async () => { + const m = new Maestro(mockCredentials, reuseOptions()); + fs.promises.access = jest.fn().mockResolvedValue(undefined); + fs.promises.stat = jest.fn().mockResolvedValue({ + isFile: () => false, + isDirectory: () => true, + }); + await expect(m['validate']()).resolves.toBe(true); + expect(fs.promises.access).not.toHaveBeenCalledWith( + '', + expect.anything(), + ); + }); + + it('validate() rejects an app file combined with --app-binary-id', async () => { + const m = new Maestro( + mockCredentials, + new MaestroOptions('path/to/app.apk', 'path/to/flows', 'Pixel 6', { + appBinaryId: 4321, + }), + ); + await expect(m['validate']()).rejects.toThrow( + 'Pass either an app file or --app-binary-id, not both.', + ); + }); + + it('uploadApp() reuses the app via POST /app/:id/reuse and adopts the platform', async () => { + const m = new Maestro(mockCredentials, reuseOptions()); + axios.post = jest.fn().mockResolvedValue({ + data: { id: 9999, source_id: 4321, platform: 'Android' }, + headers: {}, + }); + await m['uploadApp'](); + expect(axios.post).toHaveBeenCalledWith( + 'https://api.testingbot.com/v1/app-automate/maestro/app/4321/reuse', + {}, + expect.objectContaining({ + auth: { username: 'testUser', password: 'testKey' }, + }), + ); + expect(m['appId']).toBe(9999); + expect(m['detectedPlatform']).toBe('Android'); + expect( + m['options'].getCapabilities(m['detectedPlatform']).platformName, + ).toBe('Android'); + }); + + it('uploadApp() keeps an explicit --platform over the server-reported one', async () => { + const m = new Maestro( + mockCredentials, + reuseOptions({ platformName: 'iOS' }), + ); + axios.post = jest.fn().mockResolvedValue({ + data: { id: 9999, source_id: 4321, platform: 'Android' }, + headers: {}, + }); + await m['uploadApp'](); + expect(m['detectedPlatform']).toBeUndefined(); + expect(m['options'].getCapabilities(undefined).platformName).toBe('iOS'); + }); + + it('uploadApp() fails clearly when the platform is unknown and --platform is absent', async () => { + const m = new Maestro(mockCredentials, reuseOptions()); + axios.post = jest.fn().mockResolvedValue({ + data: { id: 9999, source_id: 4321, platform: null }, + headers: {}, + }); + await expect(m['uploadApp']()).rejects.toThrow( + 'Pass --platform Android|iOS', + ); + }); + + it('dry run reports the reuse endpoint instead of an upload', async () => { + const m = new Maestro(mockCredentials, reuseOptions({ dryRun: true })); + m['validate'] = jest.fn().mockResolvedValue(true); + m['collectFlows'] = jest.fn().mockResolvedValue(null); + m['printDryRunSummary'] = jest.fn(); + const result = await m.run(); + expect(result.outcome).toBe('dry-run'); + const summary = (m['printDryRunSummary'] as jest.Mock).mock.calls[0][0]; + expect(summary.uploads[0]).toEqual({ + label: 'App', + filePath: '(reuse app of project 4321)', + endpoint: + 'https://api.testingbot.com/v1/app-automate/maestro/app/4321/reuse', + }); + }); + + it('uploadOnly() validates, uploads and returns the project id', async () => { + maestro['validateAppFile'] = jest.fn().mockResolvedValue(undefined); + maestro['ensureConnectivity'] = jest.fn().mockResolvedValue(undefined); + maestro['uploadApp'] = jest.fn().mockImplementation(async () => { + maestro['appId'] = 777; + return true; + }); + await expect(maestro.uploadOnly()).resolves.toEqual({ + success: true, + appId: 777, + }); + }); + + it('uploadOnly() reports validation failures as an error result', async () => { + const m = new Maestro( + mockCredentials, + new MaestroOptions('path/to/app.txt', [], undefined, {}), + ); + const result = await m.uploadOnly(); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toContain('Unsupported app file format: .txt'); + } + }); + }); });