From a8956bc206154f5fa8221c381f0902de5e3be47e Mon Sep 17 00:00:00 2001 From: Jochen Delabie Date: Wed, 2 Sep 2026 10:38:13 +0200 Subject: [PATCH] cli: add upload command and --app-binary-id app reuse (TB-373) testingbot upload stores an app once (checksum-deduplicated) and prints a Project ID. testingbot maestro --app-binary-id ./flows then skips the upload: the CLI calls POST /app/:id/reuse, which clones a fresh project around the stored binary, and adopts the platform the server reports unless --platform is given. Every maestro run now prints its Project ID after the upload so any earlier run can be reused the same way. Requires the matching API change in the web repo (feat/TB-373-maestro-reuse-app). --- README.md | 26 ++++++ src/cli.ts | 84 +++++++++++++++++++- src/models/maestro_options.ts | 11 +++ src/providers/maestro.ts | 130 ++++++++++++++++++++++++++---- tests/cli.test.ts | 137 ++++++++++++++++++++++++++++++++ tests/providers/maestro.test.ts | 121 ++++++++++++++++++++++++++++ 6 files changed, 490 insertions(+), 19 deletions(-) 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'); + } + }); + }); });