Skip to content
Closed
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
5 changes: 5 additions & 0 deletions packages/contracts/src/app-deployment-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ export type AppDeploymentSource =
export type MaterializedAppSource = Readonly<{
archivePath?: string;
installablePath: string;
/**
* The single file a hosted provider uploads for this build, named by the materializer that knows
* its format. Absent when the installable is already that file or no such file exists.
*/
uploadPath?: string;
bundleId?: string;
packageName?: string;
appName?: string;
Expand Down
27 changes: 26 additions & 1 deletion packages/platform-apple/src/core/install-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import path from 'node:path';
import type { LocalInstallSource } from '@agent-device/kernel/contracts';
import { readIosBundleInfo } from './bundle-info.ts';
import { AppError } from '@agent-device/kernel/errors';
import { extractArchiveSafely, ArchiveBudget } from '@agent-device/host-kit/archive';
import {
archiveTypeFromPath,
extractArchiveSafely,
ArchiveBudget,
} from '@agent-device/host-kit/archive';

import {
installArtifactArchiveBudget,
Expand Down Expand Up @@ -32,6 +36,7 @@ type IosPayloadAppBundle = {
export type PreparedIosInstallArtifact = {
archivePath?: string;
installablePath: string;
uploadPath?: string;
bundleId?: string;
appName?: string;
cleanup: () => Promise<void>;
Expand Down Expand Up @@ -70,9 +75,11 @@ async function prepareIosInstallArtifactInScope(
? materialized.installablePath
: undefined);

const uploadPath = iosUploadPath(materialized);
return {
archivePath,
installablePath: resolvedInstallable.installPath,
...(uploadPath ? { uploadPath } : {}),
bundleId: bundleInfo.bundleId,
appName: bundleInfo.appName,
cleanup: async () => {
Expand All @@ -92,6 +99,24 @@ async function prepareIosInstallArtifactInScope(

export { readIosBundleInfo } from './bundle-info.ts';

/**
* The installable is an extracted `.app` directory, which no hosted upload API accepts. The file
* that carries it is the `.ipa` it was unpacked from, or the zip the `.app` was extracted from
* directly; an outer archive that merely wrapped either is never it.
*/
function iosUploadPath(materialized: {
containingArchivePath?: string;
installablePath: string;
}): string | undefined {
if (materialized.installablePath.toLowerCase().endsWith('.ipa')) {
return materialized.installablePath;
}
const { containingArchivePath } = materialized;
return containingArchivePath && archiveTypeFromPath(containingArchivePath) === 'zip'
? containingArchivePath
: undefined;
}

async function resolveIosInstallablePath(
appPath: string,
options?: InstallIosArtifactOptions,
Expand Down
14 changes: 14 additions & 0 deletions packages/provider-webdriver/src/artifact-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,17 @@ export function unavailableCloudArtifactsResult(options: {
message: options.error instanceof Error ? options.error.message : String(options.error),
};
}

/** A ready URL artifact read off a provider's session-details record, or nothing when the field is absent. */
export function urlArtifactFromDetails(
provider: string,
providerSessionId: string,
details: Record<string, unknown>,
field: string,
kind: CloudArtifact['kind'],
name: string,
): CloudArtifact | undefined {
const url = details[field];
if (typeof url !== 'string' || url.length === 0) return undefined;
return { provider, providerSessionId, kind, name, url, availability: 'ready' };
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import path from 'node:path';
import { AppError } from '@agent-device/kernel/errors';
import { agentDeviceRequestHeaders } from './request-headers.ts';
import { basicAuthHeader } from './webdriver-utils.ts';
import { fetchProviderVerificationJson, sameOsVersion } from './webdriver-utils.ts';
import type {
CloudWebDriverConnectionVerification,
CloudWebDriverConnectionVerificationOptions,
Expand Down Expand Up @@ -105,42 +104,15 @@ async function fetchBrowserStackJson(
auth: { username: string; accessKey: string },
clientVersion: string,
): Promise<unknown> {
try {
const response = await fetch(endpoint, {
headers: {
...agentDeviceRequestHeaders(clientVersion),
Authorization: basicAuthHeader(auth),
},
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
const unauthorized = response.status === 401 || response.status === 403;
throw new AppError(
unauthorized ? 'UNAUTHORIZED' : 'COMMAND_FAILED',
'BrowserStack rejected connection verification.',
{
status: response.status,
hint: unauthorized
? 'Check BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY.'
: 'Retry connect or check the BrowserStack service status.',
},
);
}
return (await response.json()) as unknown;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError(
'COMMAND_FAILED',
'BrowserStack connection verification failed.',
{ hint: 'Check network access to api-cloud.browserstack.com and retry connect.' },
error,
);
}
}

function sameOsVersion(left: string, right: string): boolean {
const normalize = (value: string) => value.replace(/(?:\.0)+$/, '');
return normalize(left) === normalize(right);
return await fetchProviderVerificationJson(endpoint, {
clientVersion,
auth,
hints: {
service: 'BrowserStack',
unauthorizedHint: 'Check BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY.',
networkHint: 'Check network access to api-cloud.browserstack.com and retry connect.',
},
});
}

function readBrowserStackDevices(
Expand Down
16 changes: 2 additions & 14 deletions packages/provider-webdriver/src/browserstack-device-features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type CloudProviderProfileFields,
} from '@agent-device/contracts/remote';
import { AppError } from '@agent-device/kernel/errors';
import { requireProviderDeviceOrientation } from './webdriver-utils.ts';
import type { CloudWebDriverPlatform } from './runtime.ts';

/**
Expand Down Expand Up @@ -199,26 +200,13 @@ function assignStringField(
value: string,
): void {
if (spec.field === 'providerDeviceOrientation') {
fields.providerDeviceOrientation = requireDeviceOrientation(spec, value);
fields.providerDeviceOrientation = requireProviderDeviceOrientation(spec, value);
return;
}
if (spec.field === 'providerNoResignApp') return;
fields[spec.field] = value;
}

function requireDeviceOrientation(
spec: BrowserStackDeviceFeatureSpec,
value: string,
): (typeof PROVIDER_DEVICE_ORIENTATIONS)[number] {
const match = PROVIDER_DEVICE_ORIENTATIONS.find((orientation) => orientation === value);
if (match) return match;
throw new AppError('INVALID_ARGS', `Invalid ${spec.flag} value: ${value}.`, {
hint: `Use ${PROVIDER_DEVICE_ORIENTATIONS.join('|')}.`,
flag: spec.flag,
capability: spec.capability,
});
}

function requireSupportedPlatform(
spec: BrowserStackDeviceFeatureSpec,
platform: CloudWebDriverPlatform,
Expand Down
142 changes: 140 additions & 2 deletions packages/provider-webdriver/src/browserstack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import assert from 'node:assert/strict';
import { promises as fs } from 'node:fs';

import path from 'node:path';
import { afterEach, test } from 'vitest';
import { uploadBrowserStackApp } from './browserstack.ts';
import { afterEach, test, vi } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import {
listBrowserStackCloudArtifacts,
resolveBrowserStackAppReference,
uploadBrowserStackApp,
} from './browserstack.ts';
import { mkdtempForTest } from './tmp-dir.fixtures.ts';

const realFetch = globalThis.fetch;
Expand Down Expand Up @@ -46,3 +51,136 @@ test('BrowserStack upload aborts while the provider request is in flight', async
await fs.rm(tempDir, { recursive: true, force: true });
}
});

const upload = { clientVersion: '0.0.0-test', username: 'user', accessKey: 'key' };

test('BrowserStack upload sends the file field and fails typed on a gateway error page', async () => {
const tempDir = await mkdtempForTest('agent-device-browserstack-upload-error-');
const appPath = path.join(tempDir, 'App.apk');
try {
await fs.writeFile(appPath, 'placeholder');
globalThis.fetch = async (_input, init) => {
assert.ok(init?.body instanceof FormData);
assert.ok(init.body.get('file') instanceof Blob);
return new Response('<html>502 Bad Gateway</html>', { status: 502 });
};
await assert.rejects(uploadBrowserStackApp(appPath, upload), (error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'COMMAND_FAILED');
assert.equal(error.message, 'BrowserStack app upload failed.');
assert.equal(error.details?.status, 502);
return true;
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});

test('BrowserStack passes bs:// ids and URLs to the hub and uploads only local paths', async () => {
const tempDir = await mkdtempForTest('agent-device-browserstack-resolve-');
try {
await fs.writeFile(path.join(tempDir, 'App.apk'), 'placeholder');
const fetched: string[] = [];
globalThis.fetch = async (input) => {
fetched.push(String(input));
return new Response(JSON.stringify({ app_url: 'bs://uploaded' }), { status: 200 });
};
const resolve = async (app: string) =>
await resolveBrowserStackAppReference(app, { ...upload, cwd: tempDir });

assert.equal(await resolve('bs://preuploaded'), 'bs://preuploaded');
assert.equal(await resolve('https://builds.example/App.apk'), 'https://builds.example/App.apk');
assert.equal(fetched.length, 0);
assert.equal(await resolve('App.apk'), 'bs://uploaded');
assert.deepEqual(fetched, ['https://api-cloud.browserstack.com/app-automate/upload']);
await assert.rejects(resolve('missing.apk'), (error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(
error.message,
'BrowserStack --provider-app must be a bs:// app id, URL, or existing local app path.',
);
return true;
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});

test('BrowserStack refuses a directory typed before any upload request on every route', async () => {
const tempDir = await mkdtempForTest('agent-device-browserstack-directory-');
const bundlePath = path.join(tempDir, 'App.app');
try {
await fs.mkdir(bundlePath);
const fetchSpy = vi.fn<typeof fetch>();
globalThis.fetch = fetchSpy;
const refusedTyped = (error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'INVALID_ARGS');
assert.equal(error.message, `BrowserStack can only upload a regular app file: ${bundlePath}`);
assert.equal(error.details?.provider, 'browserstack');
return true;
};

await assert.rejects(uploadBrowserStackApp(bundlePath, upload), refusedTyped);
await assert.rejects(
resolveBrowserStackAppReference('App.app', { ...upload, cwd: tempDir }),
refusedTyped,
);
assert.equal(fetchSpy.mock.calls.length, 0);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});

test('BrowserStack session details lookup has a deadline and fails typed', async () => {
const lookup = async () =>
await listBrowserStackCloudArtifacts('browserstack', 'SESSION1', upload);
// The fetch stays pending until the signal the lookup armed aborts, so only the 15 s deadline
// can end it; without that deadline the timeout spy is never called and the next assert fails.
const deadline = new AbortController();
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation(() => deadline.signal);
let started: () => void = () => {};
const fetchStarted = new Promise<void>((resolve) => {
started = resolve;
});
globalThis.fetch = async (_input, init) =>
await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener('abort', () => reject(init.signal?.reason), { once: true });
started();
});
try {
const pending = lookup();
await fetchStarted;
assert.deepEqual(timeoutSpy.mock.calls, [[15_000]]);
const timeout = new DOMException('The operation was aborted due to timeout', 'TimeoutError');
deadline.abort(timeout);
await assert.rejects(pending, (error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'COMMAND_FAILED');
assert.equal(error.message, 'BrowserStack session details lookup failed.');
assert.equal(error.cause, timeout);
return true;
});
} finally {
timeoutSpy.mockRestore();
}

const networkFailure = new TypeError('fetch failed');
globalThis.fetch = async () => {
throw networkFailure;
};
await assert.rejects(
lookup(),
(error: unknown) => error instanceof AppError && error.cause === networkFailure,
);

for (const body of ['<html>gateway</html>', '[]']) {
globalThis.fetch = async () => new Response(body, { status: 200 });
await assert.rejects(lookup(), (error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.code, 'COMMAND_FAILED');
assert.equal(error.details?.status, 200);
return true;
});
}
});
Loading