From 7bff391233b134ef7eed799c878d377efe749724 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Mon, 27 Apr 2026 20:34:48 +0200 Subject: [PATCH 1/5] feat: playwright tracing --- e2e/mobilewright.config.ts | 1 + package-lock.json | 34 ++- packages/mobilewright-core/package.json | 6 +- packages/mobilewright-core/src/device.ts | 10 +- packages/mobilewright-core/src/expect.ts | 61 ++-- packages/mobilewright-core/src/index.ts | 1 + .../mobilewright-core/src/locator.test.ts | 18 -- packages/mobilewright-core/src/locator.ts | 65 ++-- packages/mobilewright-core/src/screen.ts | 45 ++- packages/mobilewright-core/src/tracing.ts | 287 ++++++++++++++++++ packages/mobilewright/src/config.ts | 2 + packages/test/src/fixtures.ts | 38 ++- 12 files changed, 457 insertions(+), 111 deletions(-) create mode 100644 packages/mobilewright-core/src/tracing.ts 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/mobilewright-core/package.json b/packages/mobilewright-core/package.json index 0626faf..524e8b1 100644 --- a/packages/mobilewright-core/package.json +++ b/packages/mobilewright-core/package.json @@ -31,6 +31,10 @@ "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" } } diff --git a/packages/mobilewright-core/src/device.ts b/packages/mobilewright-core/src/device.ts index 4350338..3294f94 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 type { 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; } + setTracer(tracer: Tracer): void { + this._tracer = tracer; + tracer.setDriver(this.driver); + this._screen = null; // reset so next access picks up 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..655faad 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,14 @@ 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 => { - try { - lastValue = await this.locator.getValue({ timeout: 0 }); - return lastValue; - } catch (e) { - if (!(e instanceof LocatorError)) { - throw e; - } - return null; - } + async () => { + try { lastValue = await this.locator.getValue({ timeout: 0 }); } catch { 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.test.ts b/packages/mobilewright-core/src/locator.test.ts index ffd2144..23c8aba 100644 --- a/packages/mobilewright-core/src/locator.test.ts +++ b/packages/mobilewright-core/src/locator.test.ts @@ -136,24 +136,6 @@ test.describe('Locator', () => { }); }); - test.describe('swipe', () => { - test('swipes from element center in the given direction', async () => { - const driver = createMockDriver(hierarchy); - const locator = new Locator(driver, { kind: 'label', value: 'Submit' }); - - await locator.swipe({ direction: 'left' }); - - expect(driver._tracker.swipeCalls).toEqual([['left', { startX: 120, startY: 125 }]]); - }); - - test('throws LocatorError when element not found', async () => { - const driver = createMockDriver(hierarchy); - const locator = new Locator(driver, { kind: 'label', value: 'Nonexistent' }, { timeout: 200 }); - - await expect(locator.swipe({ direction: 'left' })).rejects.toThrow(LocatorError); - }); - }); - test.describe('fill', () => { test('taps to focus then types text', async () => { const driver = createMockDriver(hierarchy); diff --git a/packages/mobilewright-core/src/locator.ts b/packages/mobilewright-core/src/locator.ts index 1a126fb..c0d3bf2 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,23 +153,15 @@ 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()); }); } - async swipe(opts: { direction: SwipeDirection; timeout?: number }): Promise { - return this._step(`locator.swipe(${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 }); - }); - } - 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..01d5b7e --- /dev/null +++ b/packages/mobilewright-core/src/tracing.ts @@ -0,0 +1,287 @@ +import { createHash } from 'node:crypto'; +import { createWriteStream } from 'node:fs'; +import { mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import yazl from 'yazl'; +import type { MobilewrightDriver } from '@mobilewright/protocol'; + +// ─── 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; + stepId?: string; + parentId?: string; + stack?: StackFrame[]; +} + +interface AfterActionEvent { + type: 'after'; + callId: string; + endTime: number; + error?: { message: string; stack?: string }; + attachments?: TraceAttachment[]; +} + +interface ScreencastFrameEvent { + type: 'screencast-frame'; + pageId: string; + sha1: string; + width: number; + height: number; + timestamp: number; + frameSwapWallTime?: number; +} + +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 + | ErrorEvent; + +// ─── Tracer ───────────────────────────────────────────────────── + +export class Tracer { + private events: TraceEvent[] = []; + private resources: Map = new Map(); + private callCounter = 0; + private startMonotonic: number; + private driver: MobilewrightDriver | null = null; + + constructor() { + 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', + }); + } + + setDriver(driver: MobilewrightDriver): void { + this.driver = driver; + } + + 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<{ sha1: string; width: number; height: number } | null> { + if (!this.driver) { + return null; + } + + try { + const screenshot = await this.driver.screenshot(); + 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; + } + + async wrapAction( + className: string, + method: string, + params: Record, + fn: () => Promise, + ): Promise { + const callId = this.nextCallId(); + const stack = this.captureStack(); + + // Before screenshot + const beforeScreenshot = await this.captureScreenshot(); + if (beforeScreenshot) { + this.events.push({ + type: 'screencast-frame', + pageId: 'device@1', + sha1: beforeScreenshot.sha1, + width: beforeScreenshot.width, + height: beforeScreenshot.height, + timestamp: this.monotonicTime(), + frameSwapWallTime: Date.now(), + }); + } + + // Before event + this.events.push({ + type: 'before', + callId, + startTime: this.monotonicTime(), + class: className, + method, + params, + stack, + }); + + try { + const result = await fn(); + + // After screenshot + const afterScreenshot = await this.captureScreenshot(); + if (afterScreenshot) { + this.events.push({ + type: 'screencast-frame', + pageId: 'device@1', + sha1: afterScreenshot.sha1, + width: afterScreenshot.width, + height: afterScreenshot.height, + timestamp: this.monotonicTime(), + frameSwapWallTime: Date.now(), + }); + } + + // After event + this.events.push({ + type: 'after', + callId, + endTime: this.monotonicTime(), + }); + + return result; + } catch (error) { + // After screenshot on failure + const errorScreenshot = await this.captureScreenshot(); + if (errorScreenshot) { + this.events.push({ + type: 'screencast-frame', + pageId: 'device@1', + sha1: errorScreenshot.sha1, + width: errorScreenshot.width, + height: errorScreenshot.height, + timestamp: this.monotonicTime(), + frameSwapWallTime: Date.now(), + }); + } + + const err = error instanceof Error ? error : new Error(String(error)); + + this.events.push({ + type: 'after', + callId, + endTime: this.monotonicTime(), + error: { + message: err.message, + stack: err.stack, + }, + }); + + throw error; + } + } + + async save(outputPath: string): Promise { + await mkdir(dirname(outputPath), { recursive: true }); + + const zipFile = new yazl.ZipFile(); + + // Add trace events as NDJSON + const traceContent = this.events.map(e => JSON.stringify(e)).join('\n'); + zipFile.addBuffer(Buffer.from(traceContent), 'trace.trace'); + + // Add empty network trace + zipFile.addBuffer(Buffer.from(''), 'trace.network'); + + // Add screenshot resources + for (const [sha1, data] of this.resources) { + zipFile.addBuffer(data, `resources/${sha1}`); + } + + // Write ZIP to disk + await new Promise((resolve, reject) => { + zipFile.end(undefined, () => { + const stream = createWriteStream(outputPath); + zipFile.outputStream.pipe(stream); + stream.on('close', resolve); + stream.on('error', reject); + }); + }); + } +} diff --git a/packages/mobilewright/src/config.ts b/packages/mobilewright/src/config.ts index eec5e51..ff20c8a 100644 --- a/packages/mobilewright/src/config.ts +++ b/packages/mobilewright/src/config.ts @@ -121,6 +121,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/test/src/fixtures.ts b/packages/test/src/fixtures.ts index 593b5de..655e0a8 100644 --- a/packages/test/src/fixtures.ts +++ b/packages/test/src/fixtures.ts @@ -12,9 +12,11 @@ import { toArray, type DevicePoolClient, } from 'mobilewright'; -import { expect } from '@mobilewright/core'; +import { expect, Tracer } from '@mobilewright/core'; import type { Device, Screen } from '@mobilewright/core'; +type TraceMode = 'on' | 'off' | 'retain-on-failure' | 'on-first-retry'; + const debug = createDebug('mw:test:fixtures'); const ZIP_MAGIC = Buffer.from([0x50, 0x4B, 0x03, 0x04]); @@ -100,6 +102,7 @@ export const test = base.extend({ throw new Error(`Unsupported platform: "${merged.platform}". Must be "ios" or "android".`); } + for (const appPath of toArray(merged.installApps)) { assertValidZipFile(appPath); } @@ -185,10 +188,41 @@ 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'; + const shouldTrace = traceMode === 'on' + || traceMode === 'retain-on-failure' + || (traceMode === 'on-first-retry' && testInfo.retry === 1); + + let tracer: Tracer | null = null; + if (shouldTrace) { + tracer = new Tracer(); + device.setTracer(tracer); + } await use(device.screen); + // ── Teardown: tracing ──────────────────────────────────── + if (tracer) { + const failed = testInfo.status !== testInfo.expectedStatus; + const shouldSaveTrace = traceMode === 'on' + || (traceMode === 'retain-on-failure' && failed) + || (traceMode === 'on-first-retry' && testInfo.retry === 1); + + if (shouldSaveTrace) { + try { + await mkdir(testInfo.outputDir, { recursive: true }); + const tracePath = join(testInfo.outputDir, 'trace.zip'); + await tracer.save(tracePath); + await testInfo.attach('trace', { path: tracePath, contentType: 'application/zip' }); + } catch { + // Best effort + } + } + } + + // ── Teardown: video ────────────────────────────────────── if (shouldRecord) { try { const result = await device.stopRecording(); From 2575c26990040d344b4dfe53e3637bf9e069ed80 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Wed, 20 May 2026 09:06:19 +0200 Subject: [PATCH 2/5] feat: add uploadTimeout fixture --- packages/driver-mobilenext/src/driver.ts | 40 +++++-- packages/mobilewright-core/src/tracing.ts | 132 ++++++++++++++-------- packages/mobilewright/src/config.ts | 2 + packages/mobilewright/src/launchers.ts | 6 +- packages/test/src/fixtures.ts | 3 + 5 files changed, 128 insertions(+), 55 deletions(-) diff --git a/packages/driver-mobilenext/src/driver.ts b/packages/driver-mobilenext/src/driver.ts index 9ef9e52..d35d87d 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,16 +496,36 @@ 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); + }); + 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); + + let response!: Response; + try { + 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); + } finally { + clearInterval(progressInterval); + } if (!response.ok) { throw new Error(`Upload failed with status ${response.status}`); } diff --git a/packages/mobilewright-core/src/tracing.ts b/packages/mobilewright-core/src/tracing.ts index 01d5b7e..e2e1a89 100644 --- a/packages/mobilewright-core/src/tracing.ts +++ b/packages/mobilewright-core/src/tracing.ts @@ -27,6 +27,8 @@ interface BeforeActionEvent { class: string; method: string; params: Record; + pageId?: string; + beforeSnapshot?: string; stepId?: string; parentId?: string; stack?: StackFrame[]; @@ -36,6 +38,7 @@ interface AfterActionEvent { type: 'after'; callId: string; endTime: number; + afterSnapshot?: string; error?: { message: string; stack?: string }; attachments?: TraceAttachment[]; } @@ -50,6 +53,30 @@ interface ScreencastFrameEvent { 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; @@ -75,8 +102,11 @@ type TraceEvent = | BeforeActionEvent | AfterActionEvent | ScreencastFrameEvent + | FrameSnapshotEvent | ErrorEvent; +type ScreenshotCapture = { sha1: string; width: number; height: number }; + // ─── Tracer ───────────────────────────────────────────────────── export class Tracer { @@ -126,7 +156,7 @@ export class Tracer { return hash; } - private async captureScreenshot(): Promise<{ sha1: string; width: number; height: number } | null> { + private async captureScreenshot(): Promise { if (!this.driver) { return null; } @@ -167,6 +197,47 @@ export class Tracer { 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: [ + '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' }], + ], + ], + resourceOverrides: [{ url: 'screenshot.png', sha1: shot.sha1 }], + viewport: { width: shot.width, height: shot.height }, + isMainFrame: true, + }, + }); + } + async wrapAction( className: string, method: string, @@ -176,21 +247,12 @@ export class Tracer { const callId = this.nextCallId(); const stack = this.captureStack(); - // Before screenshot - const beforeScreenshot = await this.captureScreenshot(); - if (beforeScreenshot) { - this.events.push({ - type: 'screencast-frame', - pageId: 'device@1', - sha1: beforeScreenshot.sha1, - width: beforeScreenshot.width, - height: beforeScreenshot.height, - timestamp: this.monotonicTime(), - frameSwapWallTime: Date.now(), - }); + const beforeShot = await this.captureScreenshot(); + if (beforeShot) { + this.pushScreencastFrame(beforeShot); + this.pushFrameSnapshot(`before@${callId}`, callId, beforeShot); } - // Before event this.events.push({ type: 'before', callId, @@ -199,46 +261,32 @@ export class Tracer { method, params, stack, + pageId: 'device@1', + ...(beforeShot && { beforeSnapshot: `before@${callId}` }), }); try { const result = await fn(); - // After screenshot - const afterScreenshot = await this.captureScreenshot(); - if (afterScreenshot) { - this.events.push({ - type: 'screencast-frame', - pageId: 'device@1', - sha1: afterScreenshot.sha1, - width: afterScreenshot.width, - height: afterScreenshot.height, - timestamp: this.monotonicTime(), - frameSwapWallTime: Date.now(), - }); + const afterShot = await this.captureScreenshot(); + if (afterShot) { + this.pushScreencastFrame(afterShot); + this.pushFrameSnapshot(`after@${callId}`, callId, afterShot); } - // After event this.events.push({ type: 'after', callId, endTime: this.monotonicTime(), + ...(afterShot && { afterSnapshot: `after@${callId}` }), }); return result; } catch (error) { - // After screenshot on failure - const errorScreenshot = await this.captureScreenshot(); - if (errorScreenshot) { - this.events.push({ - type: 'screencast-frame', - pageId: 'device@1', - sha1: errorScreenshot.sha1, - width: errorScreenshot.width, - height: errorScreenshot.height, - timestamp: this.monotonicTime(), - frameSwapWallTime: Date.now(), - }); + const errorShot = await this.captureScreenshot(); + if (errorShot) { + this.pushScreencastFrame(errorShot); + this.pushFrameSnapshot(`after@${callId}`, callId, errorShot); } const err = error instanceof Error ? error : new Error(String(error)); @@ -247,6 +295,7 @@ export class Tracer { type: 'after', callId, endTime: this.monotonicTime(), + ...(errorShot && { afterSnapshot: `after@${callId}` }), error: { message: err.message, stack: err.stack, @@ -262,19 +311,14 @@ export class Tracer { const zipFile = new yazl.ZipFile(); - // Add trace events as NDJSON const traceContent = this.events.map(e => JSON.stringify(e)).join('\n'); zipFile.addBuffer(Buffer.from(traceContent), 'trace.trace'); - - // Add empty network trace zipFile.addBuffer(Buffer.from(''), 'trace.network'); - // Add screenshot resources for (const [sha1, data] of this.resources) { zipFile.addBuffer(data, `resources/${sha1}`); } - // Write ZIP to disk await new Promise((resolve, reject) => { zipFile.end(undefined, () => { const stream = createWriteStream(outputPath); diff --git a/packages/mobilewright/src/config.ts b/packages/mobilewright/src/config.ts index ff20c8a..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. */ 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); } From 73bf811e61fa91fb0933e33dd1da9bae1984568b Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 26 May 2026 22:15:26 +0200 Subject: [PATCH 3/5] feat: restore locator.swipe() with tracer support --- packages/mobilewright-core/src/locator.test.ts | 18 ++++++++++++++++++ packages/mobilewright-core/src/locator.ts | 8 ++++++++ 2 files changed, 26 insertions(+) diff --git a/packages/mobilewright-core/src/locator.test.ts b/packages/mobilewright-core/src/locator.test.ts index 23c8aba..ffd2144 100644 --- a/packages/mobilewright-core/src/locator.test.ts +++ b/packages/mobilewright-core/src/locator.test.ts @@ -136,6 +136,24 @@ test.describe('Locator', () => { }); }); + test.describe('swipe', () => { + test('swipes from element center in the given direction', async () => { + const driver = createMockDriver(hierarchy); + const locator = new Locator(driver, { kind: 'label', value: 'Submit' }); + + await locator.swipe({ direction: 'left' }); + + expect(driver._tracker.swipeCalls).toEqual([['left', { startX: 120, startY: 125 }]]); + }); + + test('throws LocatorError when element not found', async () => { + const driver = createMockDriver(hierarchy); + const locator = new Locator(driver, { kind: 'label', value: 'Nonexistent' }, { timeout: 200 }); + + await expect(locator.swipe({ direction: 'left' })).rejects.toThrow(LocatorError); + }); + }); + test.describe('fill', () => { test('taps to focus then types text', async () => { const driver = createMockDriver(hierarchy); diff --git a/packages/mobilewright-core/src/locator.ts b/packages/mobilewright-core/src/locator.ts index c0d3bf2..555ebf1 100644 --- a/packages/mobilewright-core/src/locator.ts +++ b/packages/mobilewright-core/src/locator.ts @@ -160,6 +160,14 @@ export class Locator { }); } + async swipe(opts: { direction: SwipeDirection; timeout?: number }): Promise { + 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 }); + }); + } + async scrollIntoViewIfNeeded(opts?: ScrollIntoViewOptions): Promise { return this._wrapAction('scrollIntoViewIfNeeded', { direction: opts?.direction, maxSwipes: opts?.maxSwipes }, async () => { const maxSwipes = opts?.maxSwipes ?? 10; From ebfdc78ea8ba53aec53b2d625c6418ead77dad64 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 26 May 2026 23:01:41 +0200 Subject: [PATCH 4/5] fix: correct error handling in toHaveValue, upload fetch, and yazl save --- packages/driver-mobilenext/src/driver.ts | 10 +++++----- packages/mobilewright-core/src/expect.ts | 9 ++++++++- packages/mobilewright-core/src/tracing.ts | 13 +++++-------- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/driver-mobilenext/src/driver.ts b/packages/driver-mobilenext/src/driver.ts index d35d87d..59415f7 100644 --- a/packages/driver-mobilenext/src/driver.ts +++ b/packages/driver-mobilenext/src/driver.ts @@ -511,9 +511,8 @@ export class MobileNextDriver implements MobilewrightDriver { debug('upload progress %s: %d/%d bytes (%d%%)', filename, bytesUploaded, fileInfo.size, pct); }, 10_000); - let response!: Response; try { - response = await fetch(upload.uploadUrl, { + const response = await fetch(upload.uploadUrl, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream', @@ -523,12 +522,13 @@ export class MobileNextDriver implements MobilewrightDriver { duplex: 'half', signal: AbortSignal.timeout(uploadTimeout), } as RequestInit); + + if (!response.ok) { + throw new Error(`Upload failed with status ${response.status}`); + } } finally { clearInterval(progressInterval); } - if (!response.ok) { - throw new Error(`Upload failed with status ${response.status}`); - } debug('upload complete, installing app (uploadId=%s)', upload.uploadId); await this.call('device.apps.install', { uploadId: upload.uploadId }); diff --git a/packages/mobilewright-core/src/expect.ts b/packages/mobilewright-core/src/expect.ts index 655faad..b5d9b07 100644 --- a/packages/mobilewright-core/src/expect.ts +++ b/packages/mobilewright-core/src/expect.ts @@ -164,7 +164,14 @@ class LocatorAssertions { let lastValue = ''; await this.retryAssertion( async () => { - try { lastValue = await this.locator.getValue({ timeout: 0 }); } catch { lastValue = ''; } + try { + lastValue = await this.locator.getValue({ timeout: 0 }); + } catch (e) { + if (!(e instanceof LocatorError)) { + throw e; + } + lastValue = ''; + } return lastValue; }, (value) => { diff --git a/packages/mobilewright-core/src/tracing.ts b/packages/mobilewright-core/src/tracing.ts index e2e1a89..3785189 100644 --- a/packages/mobilewright-core/src/tracing.ts +++ b/packages/mobilewright-core/src/tracing.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { createWriteStream } from 'node:fs'; +import { pipeline } from 'node:stream/promises'; import { mkdir } from 'node:fs/promises'; import { dirname } from 'node:path'; import yazl from 'yazl'; @@ -319,13 +320,9 @@ export class Tracer { zipFile.addBuffer(data, `resources/${sha1}`); } - await new Promise((resolve, reject) => { - zipFile.end(undefined, () => { - const stream = createWriteStream(outputPath); - zipFile.outputStream.pipe(stream); - stream.on('close', resolve); - stream.on('error', reject); - }); - }); + const writeStream = createWriteStream(outputPath); + const pipelinePromise = pipeline(zipFile.outputStream, writeStream); + zipFile.end(); + await pipelinePromise; } } From d9bfdcc8b5dc622e0bda2aa54b948d15742c069e Mon Sep 17 00:00:00 2001 From: gmegidish Date: Wed, 27 May 2026 09:57:38 +0200 Subject: [PATCH 5/5] refactor: split Tracer responsibilities, inject screenshot fn, move zip persistence to test package --- packages/mobilewright-core/package.json | 6 +- packages/mobilewright-core/src/device.ts | 10 +- packages/mobilewright-core/src/tracing.ts | 111 +++++++--------------- packages/test/package.json | 6 +- packages/test/src/fixtures.ts | 36 +++---- packages/test/src/saveTrace.ts | 23 +++++ 6 files changed, 89 insertions(+), 103 deletions(-) create mode 100644 packages/test/src/saveTrace.ts diff --git a/packages/mobilewright-core/package.json b/packages/mobilewright-core/package.json index 524e8b1..0626faf 100644 --- a/packages/mobilewright-core/package.json +++ b/packages/mobilewright-core/package.json @@ -31,10 +31,6 @@ "dependencies": { "@mobilewright/protocol": "^0.0.1", "debug": "^4.4.3", - "sharp": "^0.34.5", - "yazl": "^3.3.1" - }, - "devDependencies": { - "@types/yazl": "^3.3.1" + "sharp": "^0.34.5" } } diff --git a/packages/mobilewright-core/src/device.ts b/packages/mobilewright-core/src/device.ts index 3294f94..36a0bb9 100644 --- a/packages/mobilewright-core/src/device.ts +++ b/packages/mobilewright-core/src/device.ts @@ -12,7 +12,7 @@ import type { import { Screen } from './screen.js'; import type { LocatorOptions } from './locator.js'; import { retryUntil } from './poll.js'; -import type { Tracer } from './tracing.js'; +import { Tracer } from './tracing.js'; const debug = createDebug('mw:device'); @@ -34,10 +34,10 @@ export class Device { this.opts = opts; } - setTracer(tracer: Tracer): void { - this._tracer = tracer; - tracer.setDriver(this.driver); - this._screen = null; // reset so next access picks up tracer + 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. */ diff --git a/packages/mobilewright-core/src/tracing.ts b/packages/mobilewright-core/src/tracing.ts index 3785189..93f1626 100644 --- a/packages/mobilewright-core/src/tracing.ts +++ b/packages/mobilewright-core/src/tracing.ts @@ -1,10 +1,4 @@ import { createHash } from 'node:crypto'; -import { createWriteStream } from 'node:fs'; -import { pipeline } from 'node:stream/promises'; -import { mkdir } from 'node:fs/promises'; -import { dirname } from 'node:path'; -import yazl from 'yazl'; -import type { MobilewrightDriver } from '@mobilewright/protocol'; // ─── Playwright-compatible trace event types ──────────────────── @@ -108,6 +102,16 @@ type TraceEvent = 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 { @@ -115,9 +119,8 @@ export class Tracer { private resources: Map = new Map(); private callCounter = 0; private startMonotonic: number; - private driver: MobilewrightDriver | null = null; - constructor() { + constructor(private readonly takeScreenshot: () => Promise) { this.startMonotonic = Date.now(); this.events.push({ @@ -133,8 +136,12 @@ export class Tracer { }); } - setDriver(driver: MobilewrightDriver): void { - this.driver = driver; + serializeEvents(): string { + return this.events.map(e => JSON.stringify(e)).join('\n'); + } + + get resourceEntries(): ReadonlyMap { + return this.resources; } private monotonicTime(): number { @@ -158,12 +165,8 @@ export class Tracer { } private async captureScreenshot(): Promise { - if (!this.driver) { - return null; - } - try { - const screenshot = await this.driver.screenshot(); + const screenshot = await this.takeScreenshot(); const sharp = (await import('sharp')).default; const metadata = await sharp(screenshot).metadata(); const sha1 = this.addResource(screenshot); @@ -223,15 +226,7 @@ export class Tracer { wallTime: Date.now(), collectionTime: 0, doctype: 'html', - html: [ - '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' }], - ], - ], + html: DEVICE_FRAME_HTML, resourceOverrides: [{ url: 'screenshot.png', sha1: shot.sha1 }], viewport: { width: shot.width, height: shot.height }, isMainFrame: true, @@ -239,6 +234,21 @@ export class Tracer { }); } + 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, @@ -268,61 +278,12 @@ export class Tracer { try { const result = await fn(); - - const afterShot = await this.captureScreenshot(); - if (afterShot) { - this.pushScreencastFrame(afterShot); - this.pushFrameSnapshot(`after@${callId}`, callId, afterShot); - } - - this.events.push({ - type: 'after', - callId, - endTime: this.monotonicTime(), - ...(afterShot && { afterSnapshot: `after@${callId}` }), - }); - + await this.recordAfterSnapshot(callId); return result; } catch (error) { - const errorShot = await this.captureScreenshot(); - if (errorShot) { - this.pushScreencastFrame(errorShot); - this.pushFrameSnapshot(`after@${callId}`, callId, errorShot); - } - const err = error instanceof Error ? error : new Error(String(error)); - - this.events.push({ - type: 'after', - callId, - endTime: this.monotonicTime(), - ...(errorShot && { afterSnapshot: `after@${callId}` }), - error: { - message: err.message, - stack: err.stack, - }, - }); - + await this.recordAfterSnapshot(callId, err); throw error; } } - - async save(outputPath: string): Promise { - await mkdir(dirname(outputPath), { recursive: true }); - - const zipFile = new yazl.ZipFile(); - - const traceContent = this.events.map(e => JSON.stringify(e)).join('\n'); - zipFile.addBuffer(Buffer.from(traceContent), 'trace.trace'); - zipFile.addBuffer(Buffer.from(''), 'trace.network'); - - for (const [sha1, data] of this.resources) { - zipFile.addBuffer(data, `resources/${sha1}`); - } - - const writeStream = createWriteStream(outputPath); - const pipelinePromise = pipeline(zipFile.outputStream, writeStream); - zipFile.end(); - await pipelinePromise; - } } diff --git a/packages/test/package.json b/packages/test/package.json index c68528f..23d80ce 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -33,6 +33,10 @@ "@mobilewright/protocol": "^0.0.1", "@playwright/test": "1.58.2", "debug": "^4.4.3", - "mobilewright": "^0.0.1" + "mobilewright": "^0.0.1", + "yazl": "^3.3.1" + }, + "devDependencies": { + "@types/yazl": "^3.3.1" } } diff --git a/packages/test/src/fixtures.ts b/packages/test/src/fixtures.ts index aaf50c2..6b4127f 100644 --- a/packages/test/src/fixtures.ts +++ b/packages/test/src/fixtures.ts @@ -12,11 +12,22 @@ import { toArray, type DevicePoolClient, } from 'mobilewright'; -import { expect, Tracer } from '@mobilewright/core'; -import type { Device, Screen } from '@mobilewright/core'; +import { expect } from '@mobilewright/core'; +import type { Device, Screen, Tracer } from '@mobilewright/core'; +import { saveTrace } from './saveTrace.js'; type TraceMode = 'on' | 'off' | 'retain-on-failure' | 'on-first-retry'; +function tracingIsActive(mode: TraceMode, retry: number): boolean { + return mode === 'on' || mode === 'retain-on-failure' || (mode === 'on-first-retry' && retry === 1); +} + +function shouldPersistTrace(mode: TraceMode, retry: number, testFailed: boolean): boolean { + return mode === 'on' + || (mode === 'retain-on-failure' && testFailed) + || (mode === 'on-first-retry' && retry === 1); +} + const debug = createDebug('mw:test:fixtures'); const ZIP_MAGIC = Buffer.from([0x50, 0x4B, 0x03, 0x04]); @@ -102,7 +113,6 @@ export const test = base.extend({ throw new Error(`Unsupported platform: "${merged.platform}". Must be "ios" or "android".`); } - for (const appPath of toArray(merged.installApps)) { assertValidZipFile(appPath); } @@ -194,14 +204,10 @@ export const test = base.extend({ // ── Tracing ────────────────────────────────────────────── const config = await loadConfig(process.cwd(), testInfo.config.configFile); const traceMode: TraceMode = config.trace ?? 'off'; - const shouldTrace = traceMode === 'on' - || traceMode === 'retain-on-failure' - || (traceMode === 'on-first-retry' && testInfo.retry === 1); let tracer: Tracer | null = null; - if (shouldTrace) { - tracer = new Tracer(); - device.setTracer(tracer); + if (tracingIsActive(traceMode, testInfo.retry)) { + tracer = device.enableTracing(); } await use(device.screen); @@ -209,18 +215,14 @@ export const test = base.extend({ // ── Teardown: tracing ──────────────────────────────────── if (tracer) { const failed = testInfo.status !== testInfo.expectedStatus; - const shouldSaveTrace = traceMode === 'on' - || (traceMode === 'retain-on-failure' && failed) - || (traceMode === 'on-first-retry' && testInfo.retry === 1); - - if (shouldSaveTrace) { + if (shouldPersistTrace(traceMode, testInfo.retry, failed)) { try { await mkdir(testInfo.outputDir, { recursive: true }); const tracePath = join(testInfo.outputDir, 'trace.zip'); - await tracer.save(tracePath); + await saveTrace(tracer, tracePath); await testInfo.attach('trace', { path: tracePath, contentType: 'application/zip' }); - } catch { - // Best effort + } catch (err) { + debug('failed to save trace: %o', err); } } } 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; +}