Skip to content
Open
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
1 change: 1 addition & 0 deletions e2e/mobilewright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ function resolveDriver(): DriverConfig {
const config: MobilewrightConfig = defineConfig({
testDir: './src',
testMatch: '**/*.test.ts',
trace: 'on',
retries: 0,
timeout: 60_000,
platform: 'ios',
Expand Down
34 changes: 33 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

44 changes: 33 additions & 11 deletions packages/driver-mobilenext/src/driver.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createReadStream, openSync, readSync, closeSync } from 'node:fs';
import { stat } from 'node:fs/promises';
import { basename } from 'node:path';
import { Transform } from 'node:stream';
import createDebug from 'debug';
import type {
AppInfo,
Expand Down Expand Up @@ -92,6 +93,7 @@ interface MobileNextDevicesResponse {
export interface MobileNextDriverOptions {
region?: string;
apiKey?: string;
uploadTimeout?: number;
}

const VALID_PLATFORMS = new Set<string>(['ios', 'android']);
Expand Down Expand Up @@ -494,18 +496,38 @@ export class MobileNextDriver implements MobilewrightDriver {
});

debug('uploading %s to S3 (uploadId=%s)', filename, upload.uploadId);
const body = createReadStream(filePath);
const response = await fetch(upload.uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': String(fileInfo.size),
let bytesUploaded = 0;
const tracker = new Transform({
transform(chunk: Buffer, _encoding, callback) {
bytesUploaded += chunk.length;
callback(null, chunk);
},
body,
duplex: 'half',
} as RequestInit);
if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`);
});
createReadStream(filePath).pipe(tracker);

const uploadTimeout = this.options.uploadTimeout ?? 300_000;
const progressInterval = setInterval(() => {
const pct = Math.round((bytesUploaded / fileInfo.size) * 100);
debug('upload progress %s: %d/%d bytes (%d%%)', filename, bytesUploaded, fileInfo.size, pct);
}, 10_000);

try {
const response = await fetch(upload.uploadUrl, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': String(fileInfo.size),
},
body: tracker,
duplex: 'half',
signal: AbortSignal.timeout(uploadTimeout),
} as RequestInit);

if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`);
}
} finally {
clearInterval(progressInterval);
}
debug('upload complete, installing app (uploadId=%s)', upload.uploadId);

Expand Down
10 changes: 9 additions & 1 deletion packages/mobilewright-core/src/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
import { Screen } from './screen.js';
import type { LocatorOptions } from './locator.js';
import { retryUntil } from './poll.js';
import { Tracer } from './tracing.js';

const debug = createDebug('mw:device');

Expand All @@ -26,12 +27,19 @@ export class Device {
private cleanupCallbacks: Array<() => Promise<void>> = [];
private _screen: Screen | null = null;
private readonly opts: DeviceOptions;
private _tracer: Tracer | null = null;

constructor(driver: MobilewrightDriver, opts: DeviceOptions = {}) {
this.driver = driver;
this.opts = opts;
}

enableTracing(): Tracer {
this._tracer = new Tracer(() => this.driver.screenshot());
this._screen = null;
return this._tracer;
}

/** Register a callback to run on close(). Used by launchers for cleanup. */
onClose(callback: () => Promise<void>): void {
this.cleanupCallbacks.push(callback);
Expand All @@ -57,7 +65,7 @@ export class Device {
}

get screen(): Screen {
this._screen ??= new Screen(this.driver, this.opts.locatorDefaults);
this._screen ??= new Screen(this.driver, this.opts.locatorDefaults, this._tracer);
return this._screen;
}

Expand Down
54 changes: 25 additions & 29 deletions packages/mobilewright-core/src/expect.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Locator } from './locator.js';
import { LocatorError } from './locator.js';
import { retryUntil } from './poll.js';
import { filterStack, captureLocation } from './stackTrace.js';
import { filterStack } from './stackTrace.js';

const DEFAULT_TIMEOUT = 5_000;

Expand Down Expand Up @@ -33,28 +33,27 @@ class LocatorAssertions {
private readonly negated: boolean,
) {}

get not(): LocatorAssertions {
return new LocatorAssertions(this.locator, !this.negated);
private async _wrapAssertion<T>(method: string, params: Record<string, unknown>, fn: () => Promise<T>): Promise<T> {
const tracer = this.locator._tracer;
if (!tracer) {
return fn();
}
const label = this.negated ? `not.${method}` : method;
return tracer.wrapAction('Expect', label, params, fn);
}

private _wrapAssertion<T>(method: string, fn: () => Promise<T>): Promise<T> {
const stepFn = this.locator._stepFn;
const title = this.negated ? `expect.not.${method}()` : `expect.${method}()`;
if (stepFn) {
const location = captureLocation();
return stepFn(title, fn as () => Promise<unknown>, location) as Promise<T>;
}
return fn();
get not(): LocatorAssertions {
return new LocatorAssertions(this.locator, !this.negated);
}

async toBeVisible(opts?: ExpectOptions): Promise<void> {
return this._wrapAssertion('toBeVisible', async () => {
return this._wrapAssertion('toBeVisible', {}, async () => {
await this.assertBoolean('visible', () => this.locator.isVisible({ timeout: 0 }), opts);
});
}

async toBeHidden(opts?: ExpectOptions): Promise<void> {
return this._wrapAssertion('toBeHidden', async () => {
return this._wrapAssertion('toBeHidden', {}, async () => {
await this.assertBoolean('hidden', async () => {
const visible = await this.locator.isVisible({ timeout: 0 });
return !visible;
Expand All @@ -63,13 +62,13 @@ class LocatorAssertions {
}

async toBeEnabled(opts?: ExpectOptions): Promise<void> {
return this._wrapAssertion('toBeEnabled', async () => {
return this._wrapAssertion('toBeEnabled', {}, async () => {
await this.assertBoolean('enabled', () => this.locator.isEnabled({ timeout: 0 }), opts);
});
}

async toBeDisabled(opts?: ExpectOptions): Promise<void> {
return this._wrapAssertion('toBeDisabled', async () => {
return this._wrapAssertion('toBeDisabled', {}, async () => {
await this.assertBoolean('disabled', async () => {
const enabled = await this.locator.isEnabled({ timeout: 0 });
return !enabled;
Expand All @@ -78,25 +77,25 @@ class LocatorAssertions {
}

async toBeSelected(opts?: ExpectOptions): Promise<void> {
return this._wrapAssertion('toBeSelected', async () => {
return this._wrapAssertion('toBeSelected', {}, async () => {
await this.assertBoolean('selected', () => this.locator.isSelected({ timeout: 0 }), opts);
});
}

async toBeFocused(opts?: ExpectOptions): Promise<void> {
return this._wrapAssertion('toBeFocused', async () => {
return this._wrapAssertion('toBeFocused', {}, async () => {
await this.assertBoolean('focused', () => this.locator.isFocused({ timeout: 0 }), opts);
});
}

async toBeChecked(opts?: ExpectOptions): Promise<void> {
return this._wrapAssertion('toBeChecked', async () => {
return this._wrapAssertion('toBeChecked', {}, async () => {
await this.assertBoolean('checked', () => this.locator.isChecked({ timeout: 0 }), opts);
});
}

async toHaveText(expected: string | RegExp, opts?: ExpectOptions): Promise<void> {
return this._wrapAssertion('toHaveText', async () => {
return this._wrapAssertion('toHaveText', { expected: String(expected) }, async () => {
await this.assertText(
(text) => expected instanceof RegExp ? expected.test(text) : text === expected,
expected, opts,
Expand All @@ -105,7 +104,7 @@ class LocatorAssertions {
}

async toContainText(expected: string, opts?: ExpectOptions): Promise<void> {
return this._wrapAssertion('toContainText', async () => {
return this._wrapAssertion('toContainText', { expected }, async () => {
await this.assertText(
(text) => text.includes(expected),
expected, opts,
Expand All @@ -114,7 +113,7 @@ class LocatorAssertions {
}

async toHaveCount(expected: number, opts?: ExpectOptions): Promise<void> {
return this._wrapAssertion('toHaveCount', async () => {
return this._wrapAssertion('toHaveCount', { expected }, async () => {
let lastCount = 0;
await this.retryAssertion(
async () => { lastCount = await this.locator.count(); return lastCount; },
Expand All @@ -131,7 +130,7 @@ class LocatorAssertions {
}

async toBeEmpty(opts?: ExpectOptions): Promise<void> {
return this._wrapAssertion('toBeEmpty', async () => {
return this._wrapAssertion('toBeEmpty', {}, async () => {
let lastValue = '';
await this.retryAssertion(
async (): Promise<string | null> => {
Expand Down Expand Up @@ -161,24 +160,21 @@ class LocatorAssertions {
}

async toHaveValue(expected: string | RegExp, opts?: ExpectOptions): Promise<void> {
return this._wrapAssertion('toHaveValue', async () => {
return this._wrapAssertion('toHaveValue', { expected: String(expected) }, async () => {
let lastValue = '';
await this.retryAssertion(
async (): Promise<string | null> => {
async () => {
try {
lastValue = await this.locator.getValue({ timeout: 0 });
return lastValue;
} catch (e) {
if (!(e instanceof LocatorError)) {
throw e;
}
return null;
lastValue = '';
}
return lastValue;
},
(value) => {
if (value === null) {
return false;
}
const matches = expected instanceof RegExp ? expected.test(value) : value === expected;
return this.negated ? !matches : matches;
},
Expand Down
1 change: 1 addition & 0 deletions packages/mobilewright-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export { expect, ExpectError, type ExpectOptions } from './expect.js';
export { queryAll, type LocatorStrategy } from './query-engine.js';
export { sleep } from './sleep.js';
export type { HardwareButton } from '@mobilewright/protocol';
export { Tracer } from './tracing.js';
Loading