diff --git a/e2e/mobilewright.config.ts b/e2e/mobilewright.config.ts index 3c00d80..4ea8d16 100644 --- a/e2e/mobilewright.config.ts +++ b/e2e/mobilewright.config.ts @@ -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', diff --git a/package-lock.json b/package-lock.json index 136d6aa..6abafde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1231,6 +1231,16 @@ "@types/node": "*" } }, + "node_modules/@types/yazl": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@types/yazl/-/yazl-3.3.1.tgz", + "integrity": "sha512-DIWfCKpsTp6hE5BDBHV3+fIL/bLUF9Bv13iDrWnMlmhQpH67buNvI291ZauQ1xcccxK3FqQ9honnXpq4R8NMuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.59.3", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", @@ -1527,6 +1537,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -2500,6 +2519,15 @@ } } }, + "node_modules/yazl": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/yazl/-/yazl-3.3.1.tgz", + "integrity": "sha512-BbETDVWG+VcMUle37k5Fqp//7SDOK2/1+T7X8TD96M3D9G8jK5VLUdQVdVjGi8im7FGkazX7kk5hkU8X4L5Bng==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^1.0.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -2596,7 +2624,11 @@ "dependencies": { "@mobilewright/protocol": "^0.0.1", "debug": "^4.4.3", - "sharp": "^0.34.5" + "sharp": "^0.34.5", + "yazl": "^3.3.1" + }, + "devDependencies": { + "@types/yazl": "^3.3.1" }, "engines": { "node": ">=18" diff --git a/packages/driver-mobilenext/src/driver.ts b/packages/driver-mobilenext/src/driver.ts index 9ef9e52..59415f7 100644 --- a/packages/driver-mobilenext/src/driver.ts +++ b/packages/driver-mobilenext/src/driver.ts @@ -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, @@ -92,6 +93,7 @@ interface MobileNextDevicesResponse { export interface MobileNextDriverOptions { region?: string; apiKey?: string; + uploadTimeout?: number; } const VALID_PLATFORMS = new Set(['ios', 'android']); @@ -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); diff --git a/packages/mobilewright-core/src/device.ts b/packages/mobilewright-core/src/device.ts index 4350338..36a0bb9 100644 --- a/packages/mobilewright-core/src/device.ts +++ b/packages/mobilewright-core/src/device.ts @@ -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'); @@ -26,12 +27,19 @@ export class Device { private cleanupCallbacks: Array<() => Promise> = []; 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 { this.cleanupCallbacks.push(callback); @@ -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; } diff --git a/packages/mobilewright-core/src/expect.ts b/packages/mobilewright-core/src/expect.ts index 9abbaf3..b5d9b07 100644 --- a/packages/mobilewright-core/src/expect.ts +++ b/packages/mobilewright-core/src/expect.ts @@ -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; @@ -33,28 +33,27 @@ class LocatorAssertions { private readonly negated: boolean, ) {} - get not(): LocatorAssertions { - return new LocatorAssertions(this.locator, !this.negated); + private async _wrapAssertion(method: string, params: Record, fn: () => Promise): Promise { + 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(method: string, fn: () => Promise): Promise { - 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, location) as Promise; - } - return fn(); + get not(): LocatorAssertions { + return new LocatorAssertions(this.locator, !this.negated); } async toBeVisible(opts?: ExpectOptions): Promise { - 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 { - 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; @@ -63,13 +62,13 @@ class LocatorAssertions { } async toBeEnabled(opts?: ExpectOptions): Promise { - 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 { - 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; @@ -78,25 +77,25 @@ class LocatorAssertions { } async toBeSelected(opts?: ExpectOptions): Promise { - 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 { - 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 { - 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 { - 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, @@ -105,7 +104,7 @@ class LocatorAssertions { } async toContainText(expected: string, opts?: ExpectOptions): Promise { - return this._wrapAssertion('toContainText', async () => { + return this._wrapAssertion('toContainText', { expected }, async () => { await this.assertText( (text) => text.includes(expected), expected, opts, @@ -114,7 +113,7 @@ class LocatorAssertions { } async toHaveCount(expected: number, opts?: ExpectOptions): Promise { - 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; }, @@ -131,7 +130,7 @@ class LocatorAssertions { } async toBeEmpty(opts?: ExpectOptions): Promise { - return this._wrapAssertion('toBeEmpty', async () => { + return this._wrapAssertion('toBeEmpty', {}, async () => { let lastValue = ''; await this.retryAssertion( async (): Promise => { @@ -161,24 +160,21 @@ class LocatorAssertions { } async toHaveValue(expected: string | RegExp, opts?: ExpectOptions): Promise { - return this._wrapAssertion('toHaveValue', async () => { + return this._wrapAssertion('toHaveValue', { expected: String(expected) }, async () => { let lastValue = ''; await this.retryAssertion( - async (): Promise => { + 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; }, diff --git a/packages/mobilewright-core/src/index.ts b/packages/mobilewright-core/src/index.ts index f29a61a..ebce3f5 100644 --- a/packages/mobilewright-core/src/index.ts +++ b/packages/mobilewright-core/src/index.ts @@ -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'; diff --git a/packages/mobilewright-core/src/locator.ts b/packages/mobilewright-core/src/locator.ts index 1a126fb..555ebf1 100644 --- a/packages/mobilewright-core/src/locator.ts +++ b/packages/mobilewright-core/src/locator.ts @@ -2,9 +2,7 @@ import sharp from 'sharp'; import type { MobilewrightDriver, ViewNode, Bounds, SwipeDirection, ScreenSize } from '@mobilewright/protocol'; import { queryAll, type LocatorStrategy } from './query-engine.js'; import { sleep } from './sleep.js'; -import { captureLocation, type StepLocation } from './stackTrace.js'; - -export type StepFn = (title: string, fn: () => Promise, location: StepLocation | undefined) => Promise; +import type { Tracer } from './tracing.js'; export interface LocatorOptions { timeout?: number; @@ -25,24 +23,26 @@ const DEFAULT_STABILITY_DELAY = 50; export class Locator { /** Create a root locator that searches the entire view hierarchy. */ - static root(driver: MobilewrightDriver, options: LocatorOptions = {}): Locator { - return new Locator(driver, { kind: 'root' }, options); + static root(driver: MobilewrightDriver, options: LocatorOptions = {}, tracer?: Tracer | null): Locator { + return new Locator(driver, { kind: 'root' }, options, tracer ?? null); } - _stepFn: StepFn | null = null; + readonly _tracer: Tracer | null; constructor( private readonly driver: MobilewrightDriver, private readonly strategy: LocatorStrategy, private readonly options: LocatorOptions = {}, - ) {} + tracer: Tracer | null = null, + ) { + this._tracer = tracer; + } - private async _step(title: string, fn: () => Promise): Promise { - if (this._stepFn) { - const location = captureLocation(); - return this._stepFn(title, fn as () => Promise, location) as Promise; + private async _wrapAction(method: string, params: Record, fn: () => Promise): Promise { + if (!this._tracer) { + return fn(); } - return fn(); + return this._tracer.wrapAction('Locator', method, params, fn); } // ─── Chaining ──────────────────────────────────────────────── @@ -72,13 +72,12 @@ export class Locator { } private child(childStrategy: LocatorStrategy): Locator { - const loc = new Locator( + return new Locator( this.driver, { kind: 'chain', parent: this.strategy, child: childStrategy }, this.options, + this._tracer, ); - loc._stepFn = this._stepFn; - return loc; } // ─── Collection ────────────────────────────────────────────── @@ -92,13 +91,12 @@ export class Locator { } nth(index: number): Locator { - const loc = new Locator( + return new Locator( this.driver, { kind: 'nth', parent: this.strategy, index }, this.options, + this._tracer, ); - loc._stepFn = this._stepFn; - return loc; } async count(): Promise { @@ -109,21 +107,20 @@ export class Locator { async all(): Promise { const roots = await this.driver.getViewHierarchy(); const matches = queryAll(roots, this.strategy); - return matches.map((_, i) => { - const loc = new Locator( + return matches.map((_, i) => + new Locator( this.driver, { kind: 'nth', parent: this.strategy, index: i }, this.options, - ); - loc._stepFn = this._stepFn; - return loc; - }); + this._tracer, + ), + ); } // ─── Actions ───────────────────────────────────────────────── async tap(opts?: { timeout?: number }): Promise { - return this._step('locator.tap()', async () => { + return this._wrapAction('tap', {}, async () => { const node = await this.resolveActionable(opts?.timeout); const { x, y } = centerOf(node.bounds); await this.driver.tap(x, y); @@ -131,7 +128,7 @@ export class Locator { } async doubleTap(opts?: { timeout?: number }): Promise { - return this._step('locator.doubleTap()', async () => { + return this._wrapAction('doubleTap', {}, async () => { const node = await this.resolveActionable(opts?.timeout); const { x, y } = centerOf(node.bounds); await this.driver.doubleTap(x, y); @@ -139,7 +136,7 @@ export class Locator { } async longPress(opts?: { timeout?: number; duration?: number }): Promise { - return this._step('locator.longPress()', async () => { + return this._wrapAction('longPress', { duration: opts?.duration }, async () => { const node = await this.resolveActionable(opts?.timeout); const { x, y } = centerOf(node.bounds); await this.driver.longPress(x, y, opts?.duration); @@ -147,7 +144,7 @@ export class Locator { } async fill(text: string, opts?: { timeout?: number }): Promise { - return this._step(`locator.fill(${JSON.stringify(text)})`, async () => { + return this._wrapAction('fill', { text }, async () => { const node = await this.resolveActionable(opts?.timeout); const { x, y } = centerOf(node.bounds); await this.driver.tap(x, y); @@ -156,7 +153,7 @@ export class Locator { } async screenshot(opts?: { timeout?: number }): Promise { - return this._step('locator.screenshot()', async () => { + return this._wrapAction('screenshot', {}, async () => { const node = await this.resolveVisible(opts?.timeout); const fullScreenshot = await this.driver.screenshot(); return cropToElement(fullScreenshot, node.bounds, await this.driver.getScreenSize()); @@ -164,7 +161,7 @@ export class Locator { } async swipe(opts: { direction: SwipeDirection; timeout?: number }): Promise { - return this._step(`locator.swipe(${opts.direction})`, async () => { + return this._wrapAction('swipe', { direction: opts.direction }, async () => { const node = await this.resolveActionable(opts.timeout); const { x, y } = centerOf(node.bounds); await this.driver.swipe(opts.direction, { startX: x, startY: y }); @@ -172,7 +169,7 @@ export class Locator { } async scrollIntoViewIfNeeded(opts?: ScrollIntoViewOptions): Promise { - return this._step('locator.scrollIntoViewIfNeeded()', async () => { + return this._wrapAction('scrollIntoViewIfNeeded', { direction: opts?.direction, maxSwipes: opts?.maxSwipes }, async () => { const maxSwipes = opts?.maxSwipes ?? 10; const direction: SwipeDirection = opts?.direction ?? 'up'; const screenSize = await this.driver.getScreenSize(); diff --git a/packages/mobilewright-core/src/screen.ts b/packages/mobilewright-core/src/screen.ts index d55972f..05c8745 100644 --- a/packages/mobilewright-core/src/screen.ts +++ b/packages/mobilewright-core/src/screen.ts @@ -8,20 +8,27 @@ import type { SwipeOptions, ViewNode, } from '@mobilewright/protocol'; -import { Locator, type LocatorOptions, type StepFn } from './locator.js'; +import { Locator, type LocatorOptions } from './locator.js'; +import type { Tracer } from './tracing.js'; export class Screen { private readonly root: Locator; + private readonly _tracer: Tracer | null; constructor( private readonly driver: MobilewrightDriver, locatorDefaults: LocatorOptions = {}, + tracer?: Tracer | null, ) { - this.root = Locator.root(driver, locatorDefaults); + this._tracer = tracer ?? null; + this.root = Locator.root(driver, locatorDefaults, this._tracer); } - setStepFn(fn: StepFn): void { - this.root._stepFn = fn; + private async _wrapAction(method: string, params: Record, fn: () => Promise): Promise { + if (!this._tracer) { + return fn(); + } + return this._tracer.wrapAction('Screen', method, params, fn); } // ─── Locator factories (delegated to root locator) ───────── @@ -53,31 +60,41 @@ export class Screen { // ─── Direct screen actions ────────────────────────────────── async screenshot(opts?: ScreenshotOptions): Promise { - const buffer = await this.driver.screenshot(opts); - if (opts?.path) { - mkdirSync(dirname(opts.path), { recursive: true }); - writeFileSync(opts.path, buffer); - } - return buffer; + return this._wrapAction('screenshot', {}, async () => { + const buffer = await this.driver.screenshot(opts); + if (opts?.path) { + mkdirSync(dirname(opts.path), { recursive: true }); + writeFileSync(opts.path, buffer); + } + return buffer; + }); } async swipe( direction: SwipeDirection, opts?: SwipeOptions, ): Promise { - return this.driver.swipe(direction, opts); + return this._wrapAction('swipe', { direction, ...opts }, async () => { + await this.driver.swipe(direction, opts); + }); } async pressButton(button: HardwareButton): Promise { - return this.driver.pressButton(button); + return this._wrapAction('pressButton', { button }, async () => { + await this.driver.pressButton(button); + }); } async tap(x: number, y: number): Promise { - return this.driver.tap(x, y); + return this._wrapAction('tap', { x, y }, async () => { + await this.driver.tap(x, y); + }); } async goBack(): Promise { - return this.driver.pressButton('BACK'); + return this._wrapAction('goBack', {}, async () => { + await this.driver.pressButton('BACK'); + }); } // ─── View tree ────────────────────────────────────────────────── diff --git a/packages/mobilewright-core/src/tracing.ts b/packages/mobilewright-core/src/tracing.ts new file mode 100644 index 0000000..93f1626 --- /dev/null +++ b/packages/mobilewright-core/src/tracing.ts @@ -0,0 +1,289 @@ +import { createHash } from 'node:crypto'; + +// ─── Playwright-compatible trace event types ──────────────────── + +interface ContextOptionsEvent { + version: number; + type: 'context-options'; + origin: 'testRunner'; + browserName: string; + platform: string; + wallTime: number; + monotonicTime: number; + options: Record; + sdkLanguage: string; + title?: string; +} + +interface BeforeActionEvent { + type: 'before'; + callId: string; + startTime: number; + class: string; + method: string; + params: Record; + pageId?: string; + beforeSnapshot?: string; + stepId?: string; + parentId?: string; + stack?: StackFrame[]; +} + +interface AfterActionEvent { + type: 'after'; + callId: string; + endTime: number; + afterSnapshot?: string; + error?: { message: string; stack?: string }; + attachments?: TraceAttachment[]; +} + +interface ScreencastFrameEvent { + type: 'screencast-frame'; + pageId: string; + sha1: string; + width: number; + height: number; + timestamp: number; + frameSwapWallTime?: number; +} + +// NodeSnapshot uses Playwright's compact array format: +// string → text node +// [tagName] | [tagName, attrs, ...children] → element node +type NodeSnapshot = string | unknown[]; + +interface FrameSnapshotEvent { + type: 'frame-snapshot'; + snapshot: { + snapshotName: string; + callId: string; + pageId: string; + frameId: string; + frameUrl: string; + timestamp: number; + wallTime: number; + collectionTime: number; + doctype: string; + html: NodeSnapshot; + resourceOverrides: Array<{ url: string; sha1: string }>; + viewport: { width: number; height: number }; + isMainFrame: boolean; + }; +} + +interface ErrorEvent { + type: 'error'; + message: string; + stack?: StackFrame[]; +} + +interface StackFrame { + file: string; + line: number; + column: number; + function: string; +} + +interface TraceAttachment { + name: string; + contentType: string; + sha1?: string; + base64?: string; +} + +type TraceEvent = + | ContextOptionsEvent + | BeforeActionEvent + | AfterActionEvent + | ScreencastFrameEvent + | FrameSnapshotEvent + | ErrorEvent; + +type ScreenshotCapture = { sha1: string; width: number; height: number }; + +const DEVICE_FRAME_HTML: NodeSnapshot = [ + 'HTML', {}, + ['HEAD', {}, + ['STYLE', {}, 'body{margin:0;padding:0;background:#000}img{width:100%;height:100%;object-fit:contain;display:block}'], + ], + ['BODY', {}, + ['IMG', { src: 'screenshot.png' }], + ], +]; + +// ─── Tracer ───────────────────────────────────────────────────── + +export class Tracer { + private events: TraceEvent[] = []; + private resources: Map = new Map(); + private callCounter = 0; + private startMonotonic: number; + + constructor(private readonly takeScreenshot: () => Promise) { + this.startMonotonic = Date.now(); + + this.events.push({ + version: 8, + type: 'context-options', + origin: 'testRunner', + browserName: '', + platform: process.platform, + wallTime: Date.now(), + monotonicTime: 0, + options: {}, + sdkLanguage: 'javascript', + }); + } + + serializeEvents(): string { + return this.events.map(e => JSON.stringify(e)).join('\n'); + } + + get resourceEntries(): ReadonlyMap { + return this.resources; + } + + private monotonicTime(): number { + return Date.now() - this.startMonotonic; + } + + private nextCallId(): string { + return `call@${++this.callCounter}`; + } + + private sha1(data: Buffer): string { + return createHash('sha1').update(data).digest('hex'); + } + + private addResource(data: Buffer): string { + const hash = this.sha1(data); + if (!this.resources.has(hash)) { + this.resources.set(hash, data); + } + return hash; + } + + private async captureScreenshot(): Promise { + try { + const screenshot = await this.takeScreenshot(); + const sharp = (await import('sharp')).default; + const metadata = await sharp(screenshot).metadata(); + const sha1 = this.addResource(screenshot); + + return { + sha1, + width: metadata.width ?? 0, + height: metadata.height ?? 0, + }; + } catch { + return null; + } + } + + private captureStack(): StackFrame[] { + const err = new Error(); + const rawStack = err.stack?.split('\n').slice(3) ?? []; + const frames: StackFrame[] = []; + + for (const line of rawStack) { + const match = line.match(/at\s+(?:(.+?)\s+)?\(?(.+?):(\d+):(\d+)\)?/); + if (match) { + frames.push({ + function: match[1] ?? '', + file: match[2], + line: parseInt(match[3], 10), + column: parseInt(match[4], 10), + }); + } + } + + return frames; + } + + private pushScreencastFrame(shot: ScreenshotCapture): void { + this.events.push({ + type: 'screencast-frame', + pageId: 'device@1', + sha1: shot.sha1, + width: shot.width, + height: shot.height, + timestamp: this.monotonicTime(), + frameSwapWallTime: Date.now(), + }); + } + + private pushFrameSnapshot(snapshotName: string, callId: string, shot: ScreenshotCapture): void { + this.events.push({ + type: 'frame-snapshot', + snapshot: { + snapshotName, + callId, + pageId: 'device@1', + frameId: 'device@1', + frameUrl: 'mobilewright://device', + timestamp: this.monotonicTime(), + wallTime: Date.now(), + collectionTime: 0, + doctype: 'html', + html: DEVICE_FRAME_HTML, + resourceOverrides: [{ url: 'screenshot.png', sha1: shot.sha1 }], + viewport: { width: shot.width, height: shot.height }, + isMainFrame: true, + }, + }); + } + + private async recordAfterSnapshot(callId: string, error?: Error): Promise { + const shot = await this.captureScreenshot(); + if (shot) { + this.pushScreencastFrame(shot); + this.pushFrameSnapshot(`after@${callId}`, callId, shot); + } + this.events.push({ + type: 'after', + callId, + endTime: this.monotonicTime(), + ...(shot && { afterSnapshot: `after@${callId}` }), + ...(error && { error: { message: error.message, stack: error.stack } }), + }); + } + + async wrapAction( + className: string, + method: string, + params: Record, + fn: () => Promise, + ): Promise { + const callId = this.nextCallId(); + const stack = this.captureStack(); + + const beforeShot = await this.captureScreenshot(); + if (beforeShot) { + this.pushScreencastFrame(beforeShot); + this.pushFrameSnapshot(`before@${callId}`, callId, beforeShot); + } + + this.events.push({ + type: 'before', + callId, + startTime: this.monotonicTime(), + class: className, + method, + params, + stack, + pageId: 'device@1', + ...(beforeShot && { beforeSnapshot: `before@${callId}` }), + }); + + try { + const result = await fn(); + await this.recordAfterSnapshot(callId); + return result; + } catch (error) { + const err = error instanceof Error ? error : new Error(String(error)); + await this.recordAfterSnapshot(callId, err); + throw error; + } + } +} diff --git a/packages/mobilewright/src/config.ts b/packages/mobilewright/src/config.ts index eec5e51..31cf99c 100644 --- a/packages/mobilewright/src/config.ts +++ b/packages/mobilewright/src/config.ts @@ -103,6 +103,8 @@ export interface MobilewrightConfig { outputDir?: string; /** Global timeout for tests (ms). */ timeout?: number; + /** Timeout for app uploads in milliseconds. Default: 300000 (5 minutes). */ + uploadTimeout?: number; /** Global timeout for locators (ms). */ actionTimeout?: number; /** Maximum retry count for flaky tests. */ @@ -121,6 +123,8 @@ export interface MobilewrightConfig { globalTeardown?: string | string[]; /** Multi-device / multi-platform project matrix. */ projects?: MobilewrightProjectConfig[]; + /** Trace recording mode. Default: 'off'. */ + trace?: 'on' | 'off' | 'retain-on-failure' | 'on-first-retry'; } export function toArray(value: T | T[] | undefined): T[] { diff --git a/packages/mobilewright/src/launchers.ts b/packages/mobilewright/src/launchers.ts index 63e0c4e..5c69d47 100644 --- a/packages/mobilewright/src/launchers.ts +++ b/packages/mobilewright/src/launchers.ts @@ -30,6 +30,7 @@ export interface ConnectDeviceParams { driverConfig?: DriverConfig; url?: string; timeout?: number; + uploadTimeout?: number; } export interface FindDeviceParams { @@ -40,11 +41,12 @@ export interface FindDeviceParams { url?: string; } -export function createDriver(driverConfig?: DriverConfig, url?: string): MobilewrightDriver { +export function createDriver(driverConfig?: DriverConfig, url?: string, uploadTimeout?: number): MobilewrightDriver { if (driverConfig?.type === 'mobilenext' || driverConfig?.type === 'mobile-use') { return new MobileNextDriver({ region: driverConfig.region, apiKey: driverConfig.apiKey, + uploadTimeout, }); } return new MobilecliDriver({ url }); @@ -54,7 +56,7 @@ export async function connectDevice(params: ConnectDeviceParams): Promise({ driverConfig: merged.driver, url: merged.url, timeout: merged.timeout, + uploadTimeout: merged.uploadTimeout, }); debug('connected to device %s', handle.deviceId); try { + const uploadTimeout = merged.uploadTimeout ?? 300_000; for (const appPath of toArray(merged.installApps)) { const installed = await client.isAppInstalled(handle.allocationId, appPath); if (!installed) { + testInfo.setTimeout(testInfo.timeout + uploadTimeout); await device.installApp(appPath); await client.recordAppInstalled(handle.allocationId, appPath); } @@ -185,10 +201,33 @@ export const test = base.extend({ } } - device.screen.setStepFn((title, fn, location) => (base.step as any)(title, fn, { location })); + // ── Tracing ────────────────────────────────────────────── + const config = await loadConfig(process.cwd(), testInfo.config.configFile); + const traceMode: TraceMode = config.trace ?? 'off'; + + let tracer: Tracer | null = null; + if (tracingIsActive(traceMode, testInfo.retry)) { + tracer = device.enableTracing(); + } await use(device.screen); + // ── Teardown: tracing ──────────────────────────────────── + if (tracer) { + const failed = testInfo.status !== testInfo.expectedStatus; + if (shouldPersistTrace(traceMode, testInfo.retry, failed)) { + try { + await mkdir(testInfo.outputDir, { recursive: true }); + const tracePath = join(testInfo.outputDir, 'trace.zip'); + await saveTrace(tracer, tracePath); + await testInfo.attach('trace', { path: tracePath, contentType: 'application/zip' }); + } catch (err) { + debug('failed to save trace: %o', err); + } + } + } + + // ── Teardown: video ────────────────────────────────────── if (shouldRecord) { try { const result = await device.stopRecording(); diff --git a/packages/test/src/saveTrace.ts b/packages/test/src/saveTrace.ts new file mode 100644 index 0000000..8d65133 --- /dev/null +++ b/packages/test/src/saveTrace.ts @@ -0,0 +1,23 @@ +import { createWriteStream } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { pipeline } from 'node:stream/promises'; +import { dirname } from 'node:path'; +import yazl from 'yazl'; +import type { Tracer } from '@mobilewright/core'; + +export async function saveTrace(tracer: Tracer, outputPath: string): Promise { + await mkdir(dirname(outputPath), { recursive: true }); + + const zipFile = new yazl.ZipFile(); + zipFile.addBuffer(Buffer.from(tracer.serializeEvents()), 'trace.trace'); + zipFile.addBuffer(Buffer.from(''), 'trace.network'); + + for (const [sha1, data] of tracer.resourceEntries) { + zipFile.addBuffer(data, `resources/${sha1}`); + } + + const writeStream = createWriteStream(outputPath); + const pipelinePromise = pipeline(zipFile.outputStream, writeStream); + zipFile.end(); + await pipelinePromise; +}