Skip to content

Commit 266dda4

Browse files
committed
feat: add uploadTimeout fixture
1 parent 00f77f5 commit 266dda4

5 files changed

Lines changed: 129 additions & 56 deletions

File tree

packages/driver-mobile-use/src/driver.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createReadStream, openSync, readSync, closeSync } from 'node:fs';
22
import { stat } from 'node:fs/promises';
33
import { basename } from 'node:path';
4+
import { Transform } from 'node:stream';
45
import createDebug from 'debug';
56
import type {
67
AppInfo,
@@ -92,6 +93,7 @@ interface MobileUseDevicesResponse {
9293
export interface MobileUseDriverOptions {
9394
region?: string;
9495
apiKey?: string;
96+
uploadTimeout?: number;
9597
}
9698

9799
const VALID_PLATFORMS = new Set<string>(['ios', 'android']);
@@ -494,16 +496,36 @@ export class MobileUseDriver implements MobilewrightDriver {
494496
});
495497

496498
debug('uploading %s to S3 (uploadId=%s)', filename, upload.uploadId);
497-
const body = createReadStream(filePath);
498-
const response = await fetch(upload.uploadUrl, {
499-
method: 'PUT',
500-
headers: {
501-
'Content-Type': 'application/octet-stream',
502-
'Content-Length': String(fileInfo.size),
499+
let bytesUploaded = 0;
500+
const tracker = new Transform({
501+
transform(chunk: Buffer, _encoding, callback) {
502+
bytesUploaded += chunk.length;
503+
callback(null, chunk);
503504
},
504-
body,
505-
duplex: 'half',
506-
} as RequestInit);
505+
});
506+
createReadStream(filePath).pipe(tracker);
507+
508+
const uploadTimeout = this.options.uploadTimeout ?? 300_000;
509+
const progressInterval = setInterval(() => {
510+
const pct = Math.round((bytesUploaded / fileInfo.size) * 100);
511+
debug('upload progress %s: %d/%d bytes (%d%%)', filename, bytesUploaded, fileInfo.size, pct);
512+
}, 10_000);
513+
514+
let response!: Response;
515+
try {
516+
response = await fetch(upload.uploadUrl, {
517+
method: 'PUT',
518+
headers: {
519+
'Content-Type': 'application/octet-stream',
520+
'Content-Length': String(fileInfo.size),
521+
},
522+
body: tracker,
523+
duplex: 'half',
524+
signal: AbortSignal.timeout(uploadTimeout),
525+
} as RequestInit);
526+
} finally {
527+
clearInterval(progressInterval);
528+
}
507529
if (!response.ok) {
508530
throw new Error(`Upload failed with status ${response.status}`);
509531
}

packages/mobilewright-core/src/tracing.ts

Lines changed: 88 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ interface BeforeActionEvent {
2727
class: string;
2828
method: string;
2929
params: Record<string, unknown>;
30+
pageId?: string;
31+
beforeSnapshot?: string;
3032
stepId?: string;
3133
parentId?: string;
3234
stack?: StackFrame[];
@@ -36,6 +38,7 @@ interface AfterActionEvent {
3638
type: 'after';
3739
callId: string;
3840
endTime: number;
41+
afterSnapshot?: string;
3942
error?: { message: string; stack?: string };
4043
attachments?: TraceAttachment[];
4144
}
@@ -50,6 +53,30 @@ interface ScreencastFrameEvent {
5053
frameSwapWallTime?: number;
5154
}
5255

56+
// NodeSnapshot uses Playwright's compact array format:
57+
// string → text node
58+
// [tagName] | [tagName, attrs, ...children] → element node
59+
type NodeSnapshot = string | unknown[];
60+
61+
interface FrameSnapshotEvent {
62+
type: 'frame-snapshot';
63+
snapshot: {
64+
snapshotName: string;
65+
callId: string;
66+
pageId: string;
67+
frameId: string;
68+
frameUrl: string;
69+
timestamp: number;
70+
wallTime: number;
71+
collectionTime: number;
72+
doctype: string;
73+
html: NodeSnapshot;
74+
resourceOverrides: Array<{ url: string; sha1: string }>;
75+
viewport: { width: number; height: number };
76+
isMainFrame: boolean;
77+
};
78+
}
79+
5380
interface ErrorEvent {
5481
type: 'error';
5582
message: string;
@@ -75,8 +102,11 @@ type TraceEvent =
75102
| BeforeActionEvent
76103
| AfterActionEvent
77104
| ScreencastFrameEvent
105+
| FrameSnapshotEvent
78106
| ErrorEvent;
79107

108+
type ScreenshotCapture = { sha1: string; width: number; height: number };
109+
80110
// ─── Tracer ─────────────────────────────────────────────────────
81111

82112
export class Tracer {
@@ -126,7 +156,7 @@ export class Tracer {
126156
return hash;
127157
}
128158

129-
private async captureScreenshot(): Promise<{ sha1: string; width: number; height: number } | null> {
159+
private async captureScreenshot(): Promise<ScreenshotCapture | null> {
130160
if (!this.driver) {
131161
return null;
132162
}
@@ -167,6 +197,47 @@ export class Tracer {
167197
return frames;
168198
}
169199

200+
private pushScreencastFrame(shot: ScreenshotCapture): void {
201+
this.events.push({
202+
type: 'screencast-frame',
203+
pageId: 'device@1',
204+
sha1: shot.sha1,
205+
width: shot.width,
206+
height: shot.height,
207+
timestamp: this.monotonicTime(),
208+
frameSwapWallTime: Date.now(),
209+
});
210+
}
211+
212+
private pushFrameSnapshot(snapshotName: string, callId: string, shot: ScreenshotCapture): void {
213+
this.events.push({
214+
type: 'frame-snapshot',
215+
snapshot: {
216+
snapshotName,
217+
callId,
218+
pageId: 'device@1',
219+
frameId: 'device@1',
220+
frameUrl: 'mobilewright://device',
221+
timestamp: this.monotonicTime(),
222+
wallTime: Date.now(),
223+
collectionTime: 0,
224+
doctype: 'html',
225+
html: [
226+
'HTML', {},
227+
['HEAD', {},
228+
['STYLE', {}, 'body{margin:0;padding:0;background:#000}img{width:100%;height:100%;object-fit:contain;display:block}'],
229+
],
230+
['BODY', {},
231+
['IMG', { src: 'screenshot.png' }],
232+
],
233+
],
234+
resourceOverrides: [{ url: 'screenshot.png', sha1: shot.sha1 }],
235+
viewport: { width: shot.width, height: shot.height },
236+
isMainFrame: true,
237+
},
238+
});
239+
}
240+
170241
async wrapAction<T>(
171242
className: string,
172243
method: string,
@@ -176,21 +247,12 @@ export class Tracer {
176247
const callId = this.nextCallId();
177248
const stack = this.captureStack();
178249

179-
// Before screenshot
180-
const beforeScreenshot = await this.captureScreenshot();
181-
if (beforeScreenshot) {
182-
this.events.push({
183-
type: 'screencast-frame',
184-
pageId: 'device@1',
185-
sha1: beforeScreenshot.sha1,
186-
width: beforeScreenshot.width,
187-
height: beforeScreenshot.height,
188-
timestamp: this.monotonicTime(),
189-
frameSwapWallTime: Date.now(),
190-
});
250+
const beforeShot = await this.captureScreenshot();
251+
if (beforeShot) {
252+
this.pushScreencastFrame(beforeShot);
253+
this.pushFrameSnapshot(`before@${callId}`, callId, beforeShot);
191254
}
192255

193-
// Before event
194256
this.events.push({
195257
type: 'before',
196258
callId,
@@ -199,46 +261,32 @@ export class Tracer {
199261
method,
200262
params,
201263
stack,
264+
pageId: 'device@1',
265+
...(beforeShot && { beforeSnapshot: `before@${callId}` }),
202266
});
203267

204268
try {
205269
const result = await fn();
206270

207-
// After screenshot
208-
const afterScreenshot = await this.captureScreenshot();
209-
if (afterScreenshot) {
210-
this.events.push({
211-
type: 'screencast-frame',
212-
pageId: 'device@1',
213-
sha1: afterScreenshot.sha1,
214-
width: afterScreenshot.width,
215-
height: afterScreenshot.height,
216-
timestamp: this.monotonicTime(),
217-
frameSwapWallTime: Date.now(),
218-
});
271+
const afterShot = await this.captureScreenshot();
272+
if (afterShot) {
273+
this.pushScreencastFrame(afterShot);
274+
this.pushFrameSnapshot(`after@${callId}`, callId, afterShot);
219275
}
220276

221-
// After event
222277
this.events.push({
223278
type: 'after',
224279
callId,
225280
endTime: this.monotonicTime(),
281+
...(afterShot && { afterSnapshot: `after@${callId}` }),
226282
});
227283

228284
return result;
229285
} catch (error) {
230-
// After screenshot on failure
231-
const errorScreenshot = await this.captureScreenshot();
232-
if (errorScreenshot) {
233-
this.events.push({
234-
type: 'screencast-frame',
235-
pageId: 'device@1',
236-
sha1: errorScreenshot.sha1,
237-
width: errorScreenshot.width,
238-
height: errorScreenshot.height,
239-
timestamp: this.monotonicTime(),
240-
frameSwapWallTime: Date.now(),
241-
});
286+
const errorShot = await this.captureScreenshot();
287+
if (errorShot) {
288+
this.pushScreencastFrame(errorShot);
289+
this.pushFrameSnapshot(`after@${callId}`, callId, errorShot);
242290
}
243291

244292
const err = error instanceof Error ? error : new Error(String(error));
@@ -247,6 +295,7 @@ export class Tracer {
247295
type: 'after',
248296
callId,
249297
endTime: this.monotonicTime(),
298+
...(errorShot && { afterSnapshot: `after@${callId}` }),
250299
error: {
251300
message: err.message,
252301
stack: err.stack,
@@ -262,19 +311,14 @@ export class Tracer {
262311

263312
const zipFile = new yazl.ZipFile();
264313

265-
// Add trace events as NDJSON
266314
const traceContent = this.events.map(e => JSON.stringify(e)).join('\n');
267315
zipFile.addBuffer(Buffer.from(traceContent), 'trace.trace');
268-
269-
// Add empty network trace
270316
zipFile.addBuffer(Buffer.from(''), 'trace.network');
271317

272-
// Add screenshot resources
273318
for (const [sha1, data] of this.resources) {
274319
zipFile.addBuffer(data, `resources/${sha1}`);
275320
}
276321

277-
// Write ZIP to disk
278322
await new Promise<void>((resolve, reject) => {
279323
zipFile.end(undefined, () => {
280324
const stream = createWriteStream(outputPath);

packages/mobilewright/src/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ export interface MobilewrightConfig {
9393
outputDir?: string;
9494
/** Global timeout for tests (ms). */
9595
timeout?: number;
96+
/** Timeout for app uploads in milliseconds. Default: 300000 (5 minutes). */
97+
uploadTimeout?: number;
9698
/** Global timeout for locators (ms). */
9799
actionTimeout?: number;
98100
/** Maximum retry count for flaky tests. */

packages/mobilewright/src/launchers.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export interface ConnectDeviceParams {
3030
driverConfig?: DriverConfig;
3131
url?: string;
3232
timeout?: number;
33+
uploadTimeout?: number;
3334
}
3435

3536
export interface FindDeviceParams {
@@ -40,11 +41,12 @@ export interface FindDeviceParams {
4041
url?: string;
4142
}
4243

43-
export function createDriver(driverConfig?: DriverConfig, url?: string): MobilewrightDriver {
44+
export function createDriver(driverConfig?: DriverConfig, url?: string, uploadTimeout?: number): MobilewrightDriver {
4445
if (driverConfig?.type === 'mobile-use') {
4546
return new MobileUseDriver({
4647
region: driverConfig.region,
4748
apiKey: driverConfig.apiKey,
49+
uploadTimeout,
4850
});
4951
}
5052
return new MobilecliDriver({ url });
@@ -54,7 +56,7 @@ export async function connectDevice(params: ConnectDeviceParams): Promise<Device
5456
// URL is baked into the driver at construction time; don't override it here.
5557
// Passing mobilecli's default URL into MobileUseDriver.connect() would send
5658
// requests to the wrong server.
57-
const driver = createDriver(params.driverConfig, params.url);
59+
const driver = createDriver(params.driverConfig, params.url, params.uploadTimeout);
5860
const device = new Device(driver);
5961
await device.connect({
6062
platform: params.platform,

packages/test/src/fixtures.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,16 @@ export const test = base.extend<MobilewrightTestFixtures>({
143143
driverConfig: merged.driver,
144144
url: merged.url,
145145
timeout: merged.timeout,
146+
uploadTimeout: merged.uploadTimeout,
146147
});
147148
debug('connected to device %s', handle.deviceId);
148149

149150
try {
151+
const uploadTimeout = merged.uploadTimeout ?? 300_000;
150152
for (const appPath of toArray(merged.installApps)) {
151153
const installed = await client.isAppInstalled(handle.allocationId, appPath);
152154
if (!installed) {
155+
testInfo.setTimeout(testInfo.timeout + uploadTimeout);
153156
await device.installApp(appPath);
154157
await client.recordAppInstalled(handle.allocationId, appPath);
155158
}
@@ -188,7 +191,7 @@ export const test = base.extend<MobilewrightTestFixtures>({
188191
}
189192

190193
// ── Tracing ──────────────────────────────────────────────
191-
const config = await loadConfig();
194+
const config = await loadConfig(process.cwd(), testInfo.config.configFile);
192195
const traceMode: TraceMode = config.trace ?? 'off';
193196
const shouldTrace = traceMode === 'on'
194197
|| traceMode === 'retain-on-failure'

0 commit comments

Comments
 (0)