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
339 changes: 182 additions & 157 deletions README.md

Large diffs are not rendered by default.

51 changes: 43 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import Maestro from './providers/maestro';
import Login from './providers/login';
import Credentials from './models/credentials';
import path from 'node:path';
import { isAppUrl } from './utils/app_source';
import type { DeviceMatrixCell, RunMetadata } from './models/maestro_options';
import TestingBotError from './models/testingbot_error';
import { redirectLogsToStderr } from './logger';
Expand Down Expand Up @@ -160,6 +161,26 @@ function parseDeviceMatrix(
});
}

/**
* CI metadata available inside an EAS Build job (Expo). Only the commit hash
* is exposed as a first-class field; the build id, profile and platform go
* into the free-form metadata so the run can be traced back to the build.
*/
function easBuildMetadata(env: NodeJS.ProcessEnv): {
commitSha?: string;
custom: Record<string, string>;
} {
if (env.EAS_BUILD !== 'true' && !env.EAS_BUILD_ID) return { custom: {} };
const custom: Record<string, string> = {};
if (env.EAS_BUILD_ID) custom.easBuildId = env.EAS_BUILD_ID;
if (env.EAS_BUILD_PROFILE) custom.easBuildProfile = env.EAS_BUILD_PROFILE;
if (env.EAS_BUILD_PLATFORM) custom.easBuildPlatform = env.EAS_BUILD_PLATFORM;
return {
commitSha: env.EAS_BUILD_GIT_COMMIT_HASH || undefined,
custom,
};
}

/** Drops unset fields; returns undefined when nothing is set. */
function buildRunMetadata(fields: RunMetadata): RunMetadata | undefined {
const metadata: Record<string, unknown> = {};
Expand Down Expand Up @@ -543,6 +564,10 @@ program
'--app <path>',
'Path to application under test (.apk, .ipa, .app, or .zip).',
)
.option(
'--app-url <url>',
'Download the app from a URL instead of a local file (.apk, .ipa, .zip or an EAS iOS .tar.gz). All positional arguments are then flows.',
)
.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.',
Expand Down Expand Up @@ -792,8 +817,8 @@ program
let app: string;
let flows: string[];

if (args.app || args.appBinaryId != null) {
// With --app or --app-binary-id, every positional argument is a flow
if (args.app || args.appBinaryId != null || args.appUrl) {
// With --app, --app-url or --app-binary-id, every positional is a flow
app = args.app ?? '';
flows = appFileArg
? [appFileArg, ...(flowsArgs || [])]
Expand All @@ -806,8 +831,8 @@ program
flows = [...flows, ...aliasFlows];

const missing: string[] = [];
if (!app && args.appBinaryId == null)
missing.push('<appFile>, --app or --app-binary-id');
if (!app && args.appBinaryId == null && !args.appUrl)
missing.push('<appFile>, --app, --app-url or --app-binary-id');
if (flows.length === 0)
missing.push(
'<flows...> (one or more flow files, directories, or globs)',
Expand Down Expand Up @@ -855,14 +880,19 @@ program
}
}

const eas = easBuildMetadata(process.env);
const custom = {
...eas.custom,
...(parseKeyValues(args.metadata, '--metadata') ?? {}),
};
const metadata = buildRunMetadata({
commitSha: args.commitSha,
commitSha: args.commitSha ?? eas.commitSha,
pullRequestId: args.pullRequestId,
pullRequestUrl: args.prUrl,
repoName: args.repoName,
repoOwner: args.repoOwner,
branch: args.branch,
custom: parseKeyValues(args.metadata, '--metadata'),
custom: Object.keys(custom).length > 0 ? custom : undefined,
});

const options = new MaestroOptions(app, flows, args.device, {
Expand Down Expand Up @@ -903,6 +933,7 @@ program
metadata,
otherApps,
appBinaryId: args.appBinaryId,
appUrl: args.appUrl,
});
if (args.debug) {
enableDebugLogging();
Expand Down Expand Up @@ -1127,7 +1158,10 @@ withFlags(
.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)')
.argument(
'<appFile>',
'Path or http(s) URL of the app (.apk, .ipa, .app, .zip or an EAS iOS .tar.gz)',
)
.option(
'--ignore-checksum-check',
'Skip checksum verification and always upload the app.',
Expand All @@ -1148,7 +1182,8 @@ withFlags(
if (args.debug) enableDebugLogging();
const maestro = new Maestro(
credentials,
new MaestroOptions(appFile, [], undefined, {
new MaestroOptions(isAppUrl(appFile) ? '' : appFile, [], undefined, {
appUrl: isAppUrl(appFile) ? appFile : undefined,
quiet: args.quiet || jsonOptions.json || jsonOptions.jsonFile,
ignoreChecksumCheck: args.ignoreChecksumCheck,
debug: args.debug,
Expand Down
8 changes: 8 additions & 0 deletions src/models/maestro_options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export default class MaestroOptions {

private _app: string;
private _appBinaryId?: number;
private _appUrl?: string;
private _flows: string[];
private _otherApps: string[];
private _device?: string;
Expand Down Expand Up @@ -158,10 +159,12 @@ export default class MaestroOptions {
metadata?: RunMetadata;
otherApps?: string[];
appBinaryId?: number;
appUrl?: string;
},
) {
this._app = app;
this._appBinaryId = options?.appBinaryId;
this._appUrl = options?.appUrl;
this._flows = flows ? (Array.isArray(flows) ? flows : [flows]) : [];
this._otherApps = options?.otherApps ?? [];
if (this._otherApps.length > MAX_OTHER_APPS) {
Expand Down Expand Up @@ -231,6 +234,11 @@ export default class MaestroOptions {
return this._appBinaryId;
}

/** Download URL for the app under test (--app-url), used instead of `app`. */
public get appUrl(): string | undefined {
return this._appUrl;
}

public get flows(): string[] {
return this._flows;
}
Expand Down
131 changes: 114 additions & 17 deletions src/providers/maestro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ import pc from 'picocolors';
import BaseProvider, { ProviderResult } from './base_provider';
import type { JsonFlowResult, JsonRunResult } from '../utils/json_output';
import { junitToAllureResults, writeAllureResults } from '../utils/allure';
import {
APP_EXTENSIONS,
appExtension,
downloadApp,
extractAppBundle,
isAppUrl,
isSupportedAppExtension,
} from '../utils/app_source';
import { setTitle } from '../ui/terminal-title';
import { HTTP, SOCKET } from '../config/constants';

Expand Down Expand Up @@ -171,22 +179,39 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
super(credentials, options);
}

private static readonly SUPPORTED_APP_EXTENSIONS = [
'.apk',
'.apks',
'.ipa',
'.app',
'.zip',
];
private static readonly SUPPORTED_APP_EXTENSIONS = APP_EXTENSIONS;

/** Rejects an app path that is missing, has an unsupported extension, or is unreadable. */
// Local path of the app once a URL was downloaded or a .tar.gz extracted;
// the upload pipeline reads this instead of options.app.
private resolvedAppPath: string | undefined = undefined;
private appTempDirs: string[] = [];

/** The app path to upload: the materialized download/extraction, else the option. */
private get appPath(): string {
return this.resolvedAppPath ?? this.options.app;
}

/**
* Rejects an app path that is missing, has an unsupported extension, or is
* unreadable. With --app-url only the URL syntax is checked here; the file
* is validated after download.
*/
private async validateAppFile(): Promise<void> {
if (this.options.appUrl) {
if (!isAppUrl(this.options.appUrl)) {
throw new TestingBotError(
`Invalid --app-url: "${this.options.appUrl}" is not an http(s) URL.`,
);
}
return;
}

if (!this.options.app) {
throw new TestingBotError(`app option is required`);
}

const appExt = path.extname(this.options.app).toLowerCase();
if (!Maestro.SUPPORTED_APP_EXTENSIONS.includes(appExt)) {
if (!isSupportedAppExtension(this.options.app)) {
const appExt = appExtension(this.options.app);
throw new TestingBotError(
`Unsupported app file format: ${appExt || '(no extension)'}. ` +
`Supported formats: ${Maestro.SUPPORTED_APP_EXTENSIONS.join(', ')}`,
Expand All @@ -200,11 +225,68 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
});
}

/**
* Turns --app-url and .tar.gz inputs into a local app the upload pipeline
* understands: downloads the URL, then extracts a .tar.gz to its .app
* bundle. No-op for plain local files. Temp directories are removed by
* cleanupAppTemp() once the upload is done.
*/
private async materializeApp(): Promise<void> {
if (this.options.appBinaryId != null) return;

let current = this.options.app;
if (this.options.appUrl) {
const downloaded = await downloadApp(this.options.appUrl, {
quiet: this.options.quiet,
log: (message) => logger.info(message),
});
this.appTempDirs.push(downloaded.tmpDir);
current = downloaded.filePath;
if (!isSupportedAppExtension(current)) {
throw new TestingBotError(
`Downloaded file ${path.basename(current)} is not a supported app format (${APP_EXTENSIONS.join(', ')}).`,
);
}
}

if (appExtension(current) === '.tar.gz') {
if (!this.options.quiet) {
logger.info(`Extracting ${path.basename(current)}`);
}
const extracted = await extractAppBundle(current);
this.appTempDirs.push(extracted.tmpDir);
current = extracted.appPath;
if (!this.options.quiet) {
logger.info(`Found app bundle ${path.basename(current)}`);
}
}

this.resolvedAppPath = current === this.options.app ? undefined : current;
}

private async cleanupAppTemp(): Promise<void> {
const dirs = this.appTempDirs.splice(0);
await Promise.all(
dirs.map((dir) =>
fs.promises.rm(dir, { recursive: true, force: true }).catch((err) => {
logger.warn(
`Failed to clean up temporary app dir ${dir}: ${err instanceof Error ? err.message : err}`,
);
}),
),
);
}

private async validate(): Promise<boolean> {
const reusingApp = this.options.appBinaryId != null;
if (reusingApp && this.options.app) {
const sources = [
this.options.app ? 'an app file' : null,
this.options.appUrl ? '--app-url' : null,
reusingApp ? '--app-binary-id' : null,
].filter(Boolean);
if (sources.length > 1) {
throw new TestingBotError(
'Pass either an app file or --app-binary-id, not both.',
`Pass only one app source, not ${sources.join(' and ')}.`,
);
}
if (!reusingApp) {
Expand Down Expand Up @@ -325,7 +407,7 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
* Detect platform from app file content using magic bytes
*/
private async detectPlatform(): Promise<'Android' | 'iOS' | undefined> {
const appPath = this.options.app;
const appPath = this.appPath;
if (!appPath) return undefined;

return detectPlatformFromFile(appPath);
Expand Down Expand Up @@ -377,7 +459,9 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
}
: {
label: 'App',
filePath: this.options.app,
filePath: this.options.appUrl
? `${this.options.appUrl} (downloaded at run time)`
: this.options.app,
endpoint: `${this.URL}/app`,
},
...otherAppPaths.map((p, i) => ({
Expand Down Expand Up @@ -443,13 +527,22 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
// Quick connectivity check before starting uploads
await this.ensureConnectivity();

// Download --app-url / extract .tar.gz so detection and upload see a
// plain local app.
setTitle('maestro · preparing app');
await this.materializeApp();

// Detect platform from file content if not explicitly provided
if (!this.options.platformName) {
this.detectedPlatform = await this.detectPlatform();
}

setTitle('maestro · uploading app');
await this.uploadApp();
try {
await this.uploadApp();
} finally {
await this.cleanupAppTemp();
}
if (!this.options.quiet) {
logger.info(
`App ready. Project ID: ${this.appId} (reuse this app later with --app-binary-id ${this.appId})`,
Expand Down Expand Up @@ -520,6 +613,7 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
this.disconnectFromUpdateServer();
this.removeSignalHandlers();
await this.stopTunnel();
await this.cleanupAppTemp();
setTitle('maestro · ✘ error');

logger.error(error instanceof Error ? error.message : error);
Expand Down Expand Up @@ -549,6 +643,7 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
try {
await this.validateAppFile();
await this.ensureConnectivity();
await this.materializeApp();
await this.uploadApp();
if (this.appId == null) {
throw new TestingBotError('Upload did not return a project id');
Expand All @@ -558,6 +653,8 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
this.spinner.stop();
const result = this.errorResult(error);
return { success: false, error: result.error ?? 'Upload failed' };
} finally {
await this.cleanupAppTemp();
}
}

Expand Down Expand Up @@ -617,8 +714,8 @@ export default class Maestro extends BaseProvider<MaestroOptions> {
return true;
}

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

// If .app bundle (directory), zip it first
Expand Down
Loading
Loading