From 65ef51ae55116ec0f942fd1254f83ea2d0c7f980 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Mon, 27 Apr 2026 20:34:48 +0200 Subject: [PATCH] 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 | 101 +++++--- packages/mobilewright-core/src/index.ts | 1 + packages/mobilewright-core/src/locator.ts | 106 +++++--- packages/mobilewright-core/src/screen.ts | 43 +++- packages/mobilewright-core/src/tracing.ts | 287 ++++++++++++++++++++++ packages/mobilewright/src/config.ts | 2 + packages/test/src/fixtures.ts | 40 ++- 11 files changed, 540 insertions(+), 91 deletions(-) create mode 100644 packages/mobilewright-core/src/tracing.ts diff --git a/e2e/mobilewright.config.ts b/e2e/mobilewright.config.ts index 3a47172..a089ad9 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, driver: resolveDriver(), diff --git a/package-lock.json b/package-lock.json index 5902dfa..936e298 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1012,6 +1012,25 @@ "@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/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", @@ -1315,6 +1334,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" + } + }, "packages/driver-mobile-use": { "name": "@mobilewright/driver-mobile-use", "version": "0.0.1", @@ -1379,7 +1407,11 @@ "license": "Apache-2.0", "dependencies": { "@mobilewright/protocol": "^0.0.1", - "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 09a441a..4324017 100644 --- a/packages/mobilewright-core/package.json +++ b/packages/mobilewright-core/package.json @@ -30,6 +30,10 @@ ], "dependencies": { "@mobilewright/protocol": "^0.0.1", - "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 4426837..d15fd11 100644 --- a/packages/mobilewright-core/src/device.ts +++ b/packages/mobilewright-core/src/device.ts @@ -10,6 +10,7 @@ import type { } from '@mobilewright/protocol'; import { Screen } from './screen.js'; import type { LocatorOptions } from './locator.js'; +import type { Tracer } from './tracing.js'; export interface DeviceOptions { locatorDefaults?: LocatorOptions; @@ -20,12 +21,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); @@ -51,7 +59,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 75e9f2f..a33df91 100644 --- a/packages/mobilewright-core/src/expect.ts +++ b/packages/mobilewright-core/src/expect.ts @@ -32,74 +32,103 @@ class LocatorAssertions { private readonly negated: boolean, ) {} + 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); + } + get not(): LocatorAssertions { return new LocatorAssertions(this.locator, !this.negated); } async toBeVisible(opts?: ExpectOptions): Promise { - await this.assertBoolean('visible', () => this.locator.isVisible({ timeout: 0 }), opts); + return this._wrapAssertion('toBeVisible', {}, async () => { + await this.assertBoolean('visible', () => this.locator.isVisible({ timeout: 0 }), opts); + }); } async toBeHidden(opts?: ExpectOptions): Promise { - await this.assertBoolean('hidden', async () => { - const visible = await this.locator.isVisible({ timeout: 0 }); - return !visible; - }, opts); + return this._wrapAssertion('toBeHidden', {}, async () => { + await this.assertBoolean('hidden', async () => { + const visible = await this.locator.isVisible({ timeout: 0 }); + return !visible; + }, opts); + }); } async toBeEnabled(opts?: ExpectOptions): Promise { - await this.assertBoolean('enabled', () => this.locator.isEnabled({ timeout: 0 }), opts); + return this._wrapAssertion('toBeEnabled', {}, async () => { + await this.assertBoolean('enabled', () => this.locator.isEnabled({ timeout: 0 }), opts); + }); } async toBeDisabled(opts?: ExpectOptions): Promise { - await this.assertBoolean('disabled', async () => { - const enabled = await this.locator.isEnabled({ timeout: 0 }); - return !enabled; - }, opts); + return this._wrapAssertion('toBeDisabled', {}, async () => { + await this.assertBoolean('disabled', async () => { + const enabled = await this.locator.isEnabled({ timeout: 0 }); + return !enabled; + }, opts); + }); } async toBeSelected(opts?: ExpectOptions): Promise { - await this.assertBoolean('selected', () => this.locator.isSelected({ timeout: 0 }), opts); + return this._wrapAssertion('toBeSelected', {}, async () => { + await this.assertBoolean('selected', () => this.locator.isSelected({ timeout: 0 }), opts); + }); } async toBeFocused(opts?: ExpectOptions): Promise { - await this.assertBoolean('focused', () => this.locator.isFocused({ timeout: 0 }), opts); + return this._wrapAssertion('toBeFocused', {}, async () => { + await this.assertBoolean('focused', () => this.locator.isFocused({ timeout: 0 }), opts); + }); } async toBeChecked(opts?: ExpectOptions): Promise { - await this.assertBoolean('checked', () => this.locator.isChecked({ timeout: 0 }), opts); + return this._wrapAssertion('toBeChecked', {}, async () => { + await this.assertBoolean('checked', () => this.locator.isChecked({ timeout: 0 }), opts); + }); } async toHaveText(expected: string | RegExp, opts?: ExpectOptions): Promise { - await this.assertText( - (text) => expected instanceof RegExp ? expected.test(text) : text === expected, - expected, opts, - ); + return this._wrapAssertion('toHaveText', { expected: String(expected) }, async () => { + await this.assertText( + (text) => expected instanceof RegExp ? expected.test(text) : text === expected, + expected, opts, + ); + }); } async toContainText(expected: string, opts?: ExpectOptions): Promise { - await this.assertText( - (text) => text.includes(expected), - expected, opts, - ); + return this._wrapAssertion('toContainText', { expected }, async () => { + await this.assertText( + (text) => text.includes(expected), + expected, opts, + ); + }); } async toHaveValue(expected: string | RegExp, opts?: ExpectOptions): Promise { - let lastValue = ''; - await this.retryUntil( - async () => { - try { lastValue = await this.locator.getValue({ timeout: 0 }); } catch { lastValue = ''; } - return lastValue; - }, - (value) => { - const matches = expected instanceof RegExp ? expected.test(value) : value === expected; - return this.negated ? !matches : matches; - }, - opts?.timeout ?? DEFAULT_TIMEOUT, - () => this.negated - ? `Expected element NOT to have value "${expected}", but got "${lastValue}"` - : `Expected element to have value "${expected}", but got "${lastValue}"`, - ); + return this._wrapAssertion('toHaveValue', { expected: String(expected) }, async () => { + let lastValue = ''; + await this.retryUntil( + async () => { + try { lastValue = await this.locator.getValue({ timeout: 0 }); } catch { lastValue = ''; } + return lastValue; + }, + (value) => { + const matches = expected instanceof RegExp ? expected.test(value) : value === expected; + return this.negated ? !matches : matches; + }, + opts?.timeout ?? DEFAULT_TIMEOUT, + () => this.negated + ? `Expected element NOT to have value "${expected}", but got "${lastValue}"` + : `Expected element to have value "${expected}", but got "${lastValue}"`, + ); + }); } private async assertBoolean( diff --git a/packages/mobilewright-core/src/index.ts b/packages/mobilewright-core/src/index.ts index 09f0fcd..614a9dc 100644 --- a/packages/mobilewright-core/src/index.ts +++ b/packages/mobilewright-core/src/index.ts @@ -4,3 +4,4 @@ export { Device, type DeviceOptions } from './device.js'; export { expect, ExpectError, type ExpectOptions } from './expect.js'; export { queryAll, type LocatorStrategy } from './query-engine.js'; export { sleep } from './sleep.js'; +export { Tracer } from './tracing.js'; diff --git a/packages/mobilewright-core/src/locator.ts b/packages/mobilewright-core/src/locator.ts index bdb5bb3..ee3e59d 100644 --- a/packages/mobilewright-core/src/locator.ts +++ b/packages/mobilewright-core/src/locator.ts @@ -2,6 +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 type { Tracer } from './tracing.js'; export interface LocatorOptions { timeout?: number; @@ -22,15 +23,27 @@ 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); } + 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 _wrapAction(method: string, params: Record, fn: () => Promise): Promise { + if (!this._tracer) { + return fn(); + } + return this._tracer.wrapAction('Locator', method, params, fn); + } // ─── Chaining ──────────────────────────────────────────────── @@ -63,6 +76,7 @@ export class Locator { this.driver, { kind: 'chain', parent: this.strategy, child: childStrategy }, this.options, + this._tracer, ); } @@ -81,6 +95,7 @@ export class Locator { this.driver, { kind: 'nth', parent: this.strategy, index }, this.options, + this._tracer, ); } @@ -97,6 +112,7 @@ export class Locator { this.driver, { kind: 'nth', parent: this.strategy, index: i }, this.options, + this._tracer, ), ); } @@ -104,60 +120,72 @@ export class Locator { // ─── Actions ───────────────────────────────────────────────── async tap(opts?: { timeout?: number }): Promise { - const node = await this.resolveActionable(opts?.timeout); - const { x, y } = centerOf(node.bounds); - await this.driver.tap(x, y); + return this._wrapAction('tap', {}, async () => { + const node = await this.resolveActionable(opts?.timeout); + const { x, y } = centerOf(node.bounds); + await this.driver.tap(x, y); + }); } async doubleTap(opts?: { timeout?: number }): Promise { - const node = await this.resolveActionable(opts?.timeout); - const { x, y } = centerOf(node.bounds); - await this.driver.doubleTap(x, y); + return this._wrapAction('doubleTap', {}, async () => { + const node = await this.resolveActionable(opts?.timeout); + const { x, y } = centerOf(node.bounds); + await this.driver.doubleTap(x, y); + }); } async longPress(opts?: { timeout?: number; duration?: number }): Promise { - const node = await this.resolveActionable(opts?.timeout); - const { x, y } = centerOf(node.bounds); - await this.driver.longPress(x, y, opts?.duration); + 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); + }); } async fill(text: string, opts?: { timeout?: number }): Promise { - const node = await this.resolveActionable(opts?.timeout); - const { x, y } = centerOf(node.bounds); - // Tap to focus, then type - await this.driver.tap(x, y); - await this.driver.typeText(text); + return this._wrapAction('fill', { text }, async () => { + const node = await this.resolveActionable(opts?.timeout); + const { x, y } = centerOf(node.bounds); + // Tap to focus, then type + await this.driver.tap(x, y); + await this.driver.typeText(text); + }); } async screenshot(opts?: { timeout?: number }): Promise { - const node = await this.resolveVisible(opts?.timeout); - const fullScreenshot = await this.driver.screenshot(); - return cropToElement(fullScreenshot, node.bounds, await this.driver.getScreenSize()); + 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 scrollIntoViewIfNeeded(opts?: ScrollIntoViewOptions): Promise { - const maxSwipes = opts?.maxSwipes ?? 10; - const direction: SwipeDirection = opts?.direction ?? 'up'; - const screenSize = await this.driver.getScreenSize(); - const POST_SWIPE_SETTLE = 200; - - for (let i = 0; i < maxSwipes; i++) { - const roots = await this.driver.getViewHierarchy(); - const node = queryAll(roots, this.strategy)[0] ?? null; + 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(); + const POST_SWIPE_SETTLE = 200; + + for (let i = 0; i < maxSwipes; i++) { + const roots = await this.driver.getViewHierarchy(); + const node = queryAll(roots, this.strategy)[0] ?? null; + + if (node && isWithinViewport(node.bounds, screenSize)) { + return; + } - if (node && isWithinViewport(node.bounds, screenSize)) { - return; + const swipeDirection = node ? swipeDirectionToReveal(node.bounds, screenSize) : direction; + await this.driver.swipe(swipeDirection); + await sleep(POST_SWIPE_SETTLE); } - const swipeDirection = node ? swipeDirectionToReveal(node.bounds, screenSize) : direction; - await this.driver.swipe(swipeDirection); - await sleep(POST_SWIPE_SETTLE); - } - - throw new LocatorError( - `Element not scrolled into view after ${maxSwipes} swipes`, - this.strategy, - ); + throw new LocatorError( + `Element not scrolled into view after ${maxSwipes} swipes`, + this.strategy, + ); + }); } // ─── Queries (with auto-wait for visibility) ───────────────── diff --git a/packages/mobilewright-core/src/screen.ts b/packages/mobilewright-core/src/screen.ts index 88c5eb9..c369822 100644 --- a/packages/mobilewright-core/src/screen.ts +++ b/packages/mobilewright-core/src/screen.ts @@ -9,15 +9,26 @@ import type { ViewNode, } from '@mobilewright/protocol'; 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); + } + + 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) ───────── @@ -45,31 +56,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 7127ca0..f26628e 100644 --- a/packages/mobilewright/src/config.ts +++ b/packages/mobilewright/src/config.ts @@ -103,6 +103,8 @@ export interface MobilewrightConfig { globalTeardown?: string; /** Multi-device / multi-platform project matrix. */ projects?: MobilewrightProjectConfig[]; + /** Trace recording mode. Default: 'off'. */ + trace?: 'on' | 'off' | 'retain-on-failure' | 'on-first-retry'; } /** Type-safe config helper for mobilewright.config.ts files. */ diff --git a/packages/test/src/fixtures.ts b/packages/test/src/fixtures.ts index f22ab98..6c4915e 100644 --- a/packages/test/src/fixtures.ts +++ b/packages/test/src/fixtures.ts @@ -2,9 +2,11 @@ import { test as base } from '@playwright/test'; import { mkdir, readFile, unlink } from 'node:fs/promises'; import { join } from 'node:path'; import { ios, android, loadConfig } 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'; + type MobilewrightTestFixtures = { screen: Screen; bundleId: string | undefined; @@ -35,7 +37,7 @@ export const test = base.extend { + // ── Video recording ────────────────────────────────────── const videoMode = typeof video === 'object' ? video.mode : video; const shouldRecord = videoMode === 'on' || videoMode === 'retain-on-failure'; const videoPath = shouldRecord @@ -58,8 +61,41 @@ export const test = base.extend