From 50caf319b78b902f75710e793c1842e1df751e77 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Fri, 22 May 2026 15:53:43 +0200 Subject: [PATCH 01/17] feat(config): add MobileNextTestResultConfig with uploadReport, name, tags, environment --- packages/mobilewright/src/config.test.ts | 20 ++++++++++++++++++++ packages/mobilewright/src/config.ts | 8 ++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/mobilewright/src/config.test.ts b/packages/mobilewright/src/config.test.ts index 3668e9a..a7802dd 100644 --- a/packages/mobilewright/src/config.test.ts +++ b/packages/mobilewright/src/config.test.ts @@ -52,3 +52,23 @@ test('toArray wraps a single string into an array', () => { test('toArray returns the array unchanged when already an array', () => { expect(toArray(['app.apk', 'other.apk'])).toEqual(['app.apk', 'other.apk']); }); + +test('defineConfig accepts mobilenext driver with testResult config', () => { + const config = defineConfig({ + driver: { + type: 'mobilenext', + apiKey: 'test-key', + testResult: { + uploadReport: 'on', + name: 'My Suite', + tags: ['ci', 'nightly'], + environment: 'staging', + }, + }, + }); + const driver = config.driver as import('./config.js').DriverConfigMobileNext; + expect(driver.testResult?.uploadReport).toBe('on'); + expect(driver.testResult?.name).toBe('My Suite'); + expect(driver.testResult?.tags).toEqual(['ci', 'nightly']); + expect(driver.testResult?.environment).toBe('staging'); +}); diff --git a/packages/mobilewright/src/config.ts b/packages/mobilewright/src/config.ts index c9b9cc3..1c37c3f 100644 --- a/packages/mobilewright/src/config.ts +++ b/packages/mobilewright/src/config.ts @@ -49,10 +49,18 @@ export interface DriverConfigMobilecli { type: 'mobilecli'; } +export interface MobileNextTestResultConfig { + uploadReport?: 'on' | 'off' | 'on-failure'; + name?: string; + tags?: string[]; + environment?: string; +} + export interface DriverConfigMobileNext { type: 'mobilenext' | 'mobile-use'; region?: string; apiKey?: string; + testResult?: MobileNextTestResultConfig; } export type DriverConfig = DriverConfigMobilecli | DriverConfigMobileNext; From 33a156891e970f828002fcb0be3a475eb1029fc0 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Fri, 22 May 2026 15:59:24 +0200 Subject: [PATCH 02/17] feat(reporters): add uploadTestResult stub that copies artifacts to tmp dir --- .../src/reporters/upload-client.test.ts | 63 +++++++++++++++++++ .../src/reporters/upload-client.ts | 25 ++++++++ 2 files changed, 88 insertions(+) create mode 100644 packages/mobilewright/src/reporters/upload-client.test.ts create mode 100644 packages/mobilewright/src/reporters/upload-client.ts diff --git a/packages/mobilewright/src/reporters/upload-client.test.ts b/packages/mobilewright/src/reporters/upload-client.test.ts new file mode 100644 index 0000000..4322ff5 --- /dev/null +++ b/packages/mobilewright/src/reporters/upload-client.test.ts @@ -0,0 +1,63 @@ +import { test, expect } from '@playwright/test'; +import { writeFileSync, existsSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { uploadTestResult } from './upload-client.js'; + +test('copies json results to a new tmp dir and returns a file:// url', async () => { + const workDir = mkdtempSync(join(tmpdir(), 'mw-client-test-')); + const jsonPath = join(workDir, 'results.json'); + writeFileSync(jsonPath, '{"tests":[]}'); + + const result = await uploadTestResult({ + apiKey: 'test-key', + jsonResultsPath: jsonPath, + outputDir: join(workDir, 'nonexistent-dir'), + }); + + expect(result.url).toMatch(/^file:\/\//); + const uploadDir = result.url.replace('file://', ''); + expect(existsSync(join(uploadDir, 'results.json'))).toBe(true); + + rmSync(workDir, { recursive: true }); + rmSync(uploadDir, { recursive: true }); +}); + +test('copies outputDir artifacts into an artifacts/ subdirectory when outputDir exists', async () => { + const workDir = mkdtempSync(join(tmpdir(), 'mw-client-test-')); + const jsonPath = join(workDir, 'results.json'); + writeFileSync(jsonPath, '{}'); + const outputDir = join(workDir, 'test-results'); + mkdirSync(outputDir); + writeFileSync(join(outputDir, 'screenshot.png'), 'fake-png'); + + const result = await uploadTestResult({ + apiKey: 'test-key', + jsonResultsPath: jsonPath, + outputDir, + }); + + const uploadDir = result.url.replace('file://', ''); + expect(existsSync(join(uploadDir, 'artifacts', 'screenshot.png'))).toBe(true); + + rmSync(workDir, { recursive: true }); + rmSync(uploadDir, { recursive: true }); +}); + +test('skips artifacts copy when outputDir does not exist', async () => { + const workDir = mkdtempSync(join(tmpdir(), 'mw-client-test-')); + const jsonPath = join(workDir, 'results.json'); + writeFileSync(jsonPath, '{}'); + + const result = await uploadTestResult({ + apiKey: 'test-key', + jsonResultsPath: jsonPath, + outputDir: join(workDir, 'does-not-exist'), + }); + + const uploadDir = result.url.replace('file://', ''); + expect(existsSync(join(uploadDir, 'artifacts'))).toBe(false); + + rmSync(workDir, { recursive: true }); + rmSync(uploadDir, { recursive: true }); +}); diff --git a/packages/mobilewright/src/reporters/upload-client.ts b/packages/mobilewright/src/reporters/upload-client.ts new file mode 100644 index 0000000..1808d7f --- /dev/null +++ b/packages/mobilewright/src/reporters/upload-client.ts @@ -0,0 +1,25 @@ +import { mkdtempSync, cpSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; + +export interface UploadTestResultParams { + apiKey: string; + jsonResultsPath: string; + outputDir: string; + name?: string; + tags?: string[]; + environment?: string; +} + +export async function uploadTestResult(params: UploadTestResultParams): Promise<{ url: string }> { + const uploadDir = mkdtempSync(join(tmpdir(), `mobilewright-upload-${randomUUID()}-`)); + + cpSync(params.jsonResultsPath, join(uploadDir, 'results.json')); + + if (existsSync(params.outputDir)) { + cpSync(params.outputDir, join(uploadDir, 'artifacts'), { recursive: true }); + } + + return { url: `file://${uploadDir}` }; +} From c8a3a690ec2862a4f4d56a7cfd2f5384bd0051e6 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Fri, 22 May 2026 16:01:01 +0200 Subject: [PATCH 03/17] feat(reporters): add MobileNextUploadReporter with on/off/on-failure upload logic --- .../src/reporters/mobilenext-upload.test.ts | 129 ++++++++++++++++++ .../src/reporters/mobilenext-upload.ts | 51 +++++++ 2 files changed, 180 insertions(+) create mode 100644 packages/mobilewright/src/reporters/mobilenext-upload.test.ts create mode 100644 packages/mobilewright/src/reporters/mobilenext-upload.ts diff --git a/packages/mobilewright/src/reporters/mobilenext-upload.test.ts b/packages/mobilewright/src/reporters/mobilenext-upload.test.ts new file mode 100644 index 0000000..634654f --- /dev/null +++ b/packages/mobilewright/src/reporters/mobilenext-upload.test.ts @@ -0,0 +1,129 @@ +import { test, expect } from '@playwright/test'; +import type { TestResult, FullResult } from '@playwright/test/reporter'; +import MobileNextUploadReporter from './mobilenext-upload.js'; +import type { UploadTestResultParams } from './upload-client.js'; + +test('does not upload when uploadReport is on-failure and no tests failed', async () => { + let uploadCalled = false; + const spyUpload = async (_params: UploadTestResultParams) => { + uploadCalled = true; + return { url: 'file:///tmp/fake' }; + }; + + const reporter = new MobileNextUploadReporter({ + apiKey: 'key', + jsonResultsPath: '/tmp/results.json', + outputDir: '/tmp/test-results', + testResult: { uploadReport: 'on-failure' }, + _uploadFn: spyUpload, + }); + + const endResult = await reporter.onEnd({ status: 'passed' } as FullResult); + expect(uploadCalled).toBe(false); + expect(endResult).toBeUndefined(); +}); + +test('uploads when uploadReport is on-failure and a test failed', async () => { + let uploadCalled = false; + const spyUpload = async (_params: UploadTestResultParams) => { + uploadCalled = true; + return { url: 'file:///tmp/fake' }; + }; + + const reporter = new MobileNextUploadReporter({ + apiKey: 'key', + jsonResultsPath: '/tmp/results.json', + outputDir: '/tmp/test-results', + testResult: { uploadReport: 'on-failure' }, + _uploadFn: spyUpload, + }); + + reporter.onTestEnd({} as never, { status: 'failed' } as TestResult); + await reporter.onEnd({ status: 'failed' } as FullResult); + expect(uploadCalled).toBe(true); +}); + +test('uploads when uploadReport is on-failure and a test timed out', async () => { + let uploadCalled = false; + const spyUpload = async (_params: UploadTestResultParams) => { + uploadCalled = true; + return { url: 'file:///tmp/fake' }; + }; + + const reporter = new MobileNextUploadReporter({ + apiKey: 'key', + jsonResultsPath: '/tmp/results.json', + outputDir: '/tmp/test-results', + testResult: { uploadReport: 'on-failure' }, + _uploadFn: spyUpload, + }); + + reporter.onTestEnd({} as never, { status: 'timedOut' } as TestResult); + await reporter.onEnd({ status: 'failed' } as FullResult); + expect(uploadCalled).toBe(true); +}); + +test('always uploads when uploadReport is on regardless of test outcomes', async () => { + let uploadCalled = false; + const spyUpload = async (_params: UploadTestResultParams) => { + uploadCalled = true; + return { url: 'file:///tmp/fake' }; + }; + + const reporter = new MobileNextUploadReporter({ + apiKey: 'key', + jsonResultsPath: '/tmp/results.json', + outputDir: '/tmp/test-results', + testResult: { uploadReport: 'on' }, + _uploadFn: spyUpload, + }); + + await reporter.onEnd({ status: 'passed' } as FullResult); + expect(uploadCalled).toBe(true); +}); + +test('passes apiKey, name, tags, environment and paths to upload function', async () => { + let capturedParams: UploadTestResultParams | undefined; + const spyUpload = async (params: UploadTestResultParams) => { + capturedParams = params; + return { url: 'file:///tmp/fake' }; + }; + + const reporter = new MobileNextUploadReporter({ + apiKey: 'my-secret-key', + jsonResultsPath: '/tmp/r.json', + outputDir: '/tmp/artifacts', + testResult: { + uploadReport: 'on', + name: 'Nightly Suite', + tags: ['ci', 'nightly'], + environment: 'staging', + }, + _uploadFn: spyUpload, + }); + + await reporter.onEnd({ status: 'passed' } as FullResult); + + expect(capturedParams?.apiKey).toBe('my-secret-key'); + expect(capturedParams?.jsonResultsPath).toBe('/tmp/r.json'); + expect(capturedParams?.outputDir).toBe('/tmp/artifacts'); + expect(capturedParams?.name).toBe('Nightly Suite'); + expect(capturedParams?.tags).toEqual(['ci', 'nightly']); + expect(capturedParams?.environment).toBe('staging'); +}); + +test('does not throw when upload function rejects', async () => { + const failingUpload = async (_params: UploadTestResultParams): Promise<{ url: string }> => { + throw new Error('network error'); + }; + + const reporter = new MobileNextUploadReporter({ + apiKey: 'key', + jsonResultsPath: '/tmp/results.json', + outputDir: '/tmp/test-results', + testResult: { uploadReport: 'on' }, + _uploadFn: failingUpload, + }); + + await expect(reporter.onEnd({ status: 'passed' } as FullResult)).resolves.not.toThrow(); +}); diff --git a/packages/mobilewright/src/reporters/mobilenext-upload.ts b/packages/mobilewright/src/reporters/mobilenext-upload.ts new file mode 100644 index 0000000..cb4e5e7 --- /dev/null +++ b/packages/mobilewright/src/reporters/mobilenext-upload.ts @@ -0,0 +1,51 @@ +import type { Reporter, TestCase, TestResult, FullResult } from '@playwright/test/reporter'; +import type { MobileNextTestResultConfig } from '../config.js'; +import { uploadTestResult, type UploadTestResultParams } from './upload-client.js'; + +type UploadFn = (params: UploadTestResultParams) => Promise<{ url: string }>; + +interface MobileNextUploadReporterOptions { + apiKey: string; + jsonResultsPath: string; + outputDir: string; + testResult: MobileNextTestResultConfig; + _uploadFn?: UploadFn; +} + +export default class MobileNextUploadReporter implements Reporter { + private hasFailed = false; + private readonly options: MobileNextUploadReporterOptions; + + constructor(options: MobileNextUploadReporterOptions) { + this.options = options; + } + + onTestEnd(_test: TestCase, result: TestResult): void { + if (result.status === 'failed' || result.status === 'timedOut') { + this.hasFailed = true; + } + } + + async onEnd(_result: FullResult): Promise { + const { uploadReport } = this.options.testResult; + if (uploadReport === 'on-failure' && !this.hasFailed) { + return; + } + + const upload = this.options._uploadFn ?? uploadTestResult; + + try { + const uploadResult = await upload({ + apiKey: this.options.apiKey, + jsonResultsPath: this.options.jsonResultsPath, + outputDir: this.options.outputDir, + name: this.options.testResult.name, + tags: this.options.testResult.tags, + environment: this.options.testResult.environment, + }); + console.log(`\n Report uploaded: ${uploadResult.url}`); + } catch (err) { + console.warn(`\n [mobilewright] Failed to upload test results: ${err}`); + } + } +} From 6c038ed28f71e568d269468c6cbe5f97bb5b32db Mon Sep 17 00:00:00 2001 From: gmegidish Date: Fri, 22 May 2026 16:03:54 +0200 Subject: [PATCH 04/17] feat(config): auto-inject MobileNextUploadReporter when mobilenext driver has testResult configured --- packages/mobilewright/src/config.test.ts | 79 ++++++++++++++++++++++++ packages/mobilewright/src/config.ts | 52 +++++++++++++++- 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/packages/mobilewright/src/config.test.ts b/packages/mobilewright/src/config.test.ts index a7802dd..c157158 100644 --- a/packages/mobilewright/src/config.test.ts +++ b/packages/mobilewright/src/config.test.ts @@ -53,6 +53,85 @@ test('toArray returns the array unchanged when already an array', () => { expect(toArray(['app.apk', 'other.apk'])).toEqual(['app.apk', 'other.apk']); }); +test('defineConfig injects upload reporter when mobilenext driver has uploadReport on', () => { + const config = defineConfig({ + driver: { + type: 'mobilenext', + apiKey: 'test-key', + testResult: { uploadReport: 'on' }, + }, + }); + const reporters = config.reporter as Array<[string, unknown]>; + expect(Array.isArray(reporters)).toBe(true); + const paths = reporters.map((r) => r[0]); + expect(paths.some((p) => String(p).includes('mobilenext-upload'))).toBe(true); +}); + +test('defineConfig injects json reporter alongside upload reporter', () => { + const config = defineConfig({ + driver: { + type: 'mobilenext', + apiKey: 'key', + testResult: { uploadReport: 'on-failure' }, + }, + }); + const reporters = config.reporter as Array<[string, unknown]>; + const jsonEntry = reporters.find((r) => r[0] === 'json'); + expect(jsonEntry).toBeDefined(); + const opts = jsonEntry![1] as { outputFile: string }; + expect(opts.outputFile).toMatch(/mobilewright-results/); +}); + +test('defineConfig does not inject upload reporter when uploadReport is off', () => { + const config = defineConfig({ + driver: { + type: 'mobilenext', + apiKey: 'key', + testResult: { uploadReport: 'off' }, + }, + }); + if (Array.isArray(config.reporter)) { + const paths = config.reporter.map((r) => (Array.isArray(r) ? r[0] : r)); + expect(paths.some((p) => String(p).includes('mobilenext-upload'))).toBe(false); + } else { + expect(config.reporter).toBeUndefined(); + } +}); + +test('defineConfig does not inject upload reporter when testResult is absent', () => { + const config = defineConfig({ driver: { type: 'mobilenext', apiKey: 'key' } }); + expect(config.reporter).toBeUndefined(); +}); + +test('defineConfig does not inject upload reporter for mobilecli driver', () => { + const config = defineConfig({ driver: { type: 'mobilecli' } }); + expect(config.reporter).toBeUndefined(); +}); + +test('defineConfig preserves existing array reporters when injecting', () => { + const config = defineConfig({ + driver: { type: 'mobilenext', apiKey: 'key', testResult: { uploadReport: 'on' } }, + reporter: [['html'], ['list']], + }); + const reporters = config.reporter as Array<[string, unknown]>; + const names = reporters.map((r) => r[0]); + expect(names).toContain('html'); + expect(names).toContain('list'); + expect(names.some((n) => String(n).includes('mobilenext-upload'))).toBe(true); +}); + +test('defineConfig normalizes string reporter to array form before injecting', () => { + const config = defineConfig({ + driver: { type: 'mobilenext', apiKey: 'key', testResult: { uploadReport: 'on' } }, + reporter: 'html', + }); + const reporters = config.reporter as Array<[string, unknown]>; + expect(Array.isArray(reporters)).toBe(true); + const names = reporters.map((r) => r[0]); + expect(names).toContain('html'); + expect(names.some((n) => String(n).includes('mobilenext-upload'))).toBe(true); +}); + test('defineConfig accepts mobilenext driver with testResult config', () => { const config = defineConfig({ driver: { diff --git a/packages/mobilewright/src/config.ts b/packages/mobilewright/src/config.ts index 1c37c3f..c22e586 100644 --- a/packages/mobilewright/src/config.ts +++ b/packages/mobilewright/src/config.ts @@ -2,6 +2,9 @@ import { access } from 'node:fs/promises'; import { isAbsolute, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; const _require = createRequire(import.meta.url); @@ -128,6 +131,51 @@ export function toArray(value: T | T[] | undefined): T[] { return Array.isArray(value) ? value : [value]; } +function normalizeReporters( + reporter: MobilewrightConfig['reporter'], +): Array<[string] | [string, unknown]> { + if (!reporter) { + return []; + } + if (typeof reporter === 'string') { + return [[reporter]]; + } + return reporter; +} + +function injectUploadReporter(config: MobilewrightConfig): MobilewrightConfig { + const driver = config.driver; + if (!driver || (driver.type !== 'mobilenext' && driver.type !== 'mobile-use')) { + return config; + } + const mobileNextDriver = driver as DriverConfigMobileNext; + const uploadReport = mobileNextDriver.testResult?.uploadReport; + if (!uploadReport || uploadReport === 'off') { + return config; + } + + const jsonResultsPath = path.join( + os.tmpdir(), + `mobilewright-results-${randomUUID()}.json`, + ); + const uploadReporterPath = _require.resolve('./reporters/mobilenext-upload.js'); + const reporters = normalizeReporters(config.reporter); + + return { + ...config, + reporter: [ + ...reporters, + ['json', { outputFile: jsonResultsPath }], + [uploadReporterPath, { + apiKey: mobileNextDriver.apiKey ?? '', + jsonResultsPath, + outputDir: config.outputDir ?? 'test-results', + testResult: mobileNextDriver.testResult, + }], + ], + }; +} + /** Type-safe config helper for mobilewright.config.ts files. */ export function defineConfig(config: MobilewrightConfig): MobilewrightConfig { const ourSetup = _require.resolve('./device-pool/setup.js'); @@ -135,12 +183,14 @@ export function defineConfig(config: MobilewrightConfig): MobilewrightConfig { const userSetups = toArray(config.globalSetup); const userTeardowns = toArray(config.globalTeardown); - return { + const base: MobilewrightConfig = { workers: 1, ...config, globalSetup: userSetups.length > 0 ? [ourSetup, ...userSetups] : ourSetup, globalTeardown: userTeardowns.length > 0 ? [...userTeardowns, ourTeardown] : ourTeardown, }; + + return injectUploadReporter(base); } const CONFIG_FILES = [ From e33dff02739c3fb56e5adc3d8ed92edf77040483 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sun, 24 May 2026 21:09:26 +0200 Subject: [PATCH 05/17] test(reporters): rewrite upload-client tests for real API calls --- .../src/reporters/upload-client.test.ts | 155 ++++++++++++++---- 1 file changed, 127 insertions(+), 28 deletions(-) diff --git a/packages/mobilewright/src/reporters/upload-client.test.ts b/packages/mobilewright/src/reporters/upload-client.test.ts index 4322ff5..4ae4b76 100644 --- a/packages/mobilewright/src/reporters/upload-client.test.ts +++ b/packages/mobilewright/src/reporters/upload-client.test.ts @@ -1,63 +1,162 @@ import { test, expect } from '@playwright/test'; -import { writeFileSync, existsSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { uploadTestResult } from './upload-client.js'; -test('copies json results to a new tmp dir and returns a file:// url', async () => { - const workDir = mkdtempSync(join(tmpdir(), 'mw-client-test-')); +type FetchCall = { url: string; method: string; headers: Record; body: unknown }; + +function makeMockFetch(testResultId: string) { + const calls: FetchCall[] = []; + + const mockFetch = async (url: string | URL | Request, init?: RequestInit): Promise => { + const urlStr = String(url); + const headers = (init?.headers ?? {}) as Record; + calls.push({ url: urlStr, method: init?.method ?? 'GET', headers, body: init?.body }); + + if (urlStr.endsWith('/test-results')) { + return new Response( + JSON.stringify({ id: testResultId, name: 'Test Run', userAgent: 'mobilewright/0.0.1', createdAt: '2026-01-01T00:00:00Z' }), + { status: 201 }, + ); + } + return new Response( + JSON.stringify({ id: 'asset-1', name: 'results.json', contentType: 'application/json', size: 12, createdAt: '2026-01-01T00:00:00Z' }), + { status: 201 }, + ); + }; + + return { mockFetch: mockFetch as unknown as typeof fetch, calls }; +} + +test('sends POST to test-results endpoint with apiKey, name, and userAgent', async () => { + const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); const jsonPath = join(workDir, 'results.json'); writeFileSync(jsonPath, '{"tests":[]}'); + const { mockFetch, calls } = makeMockFetch('result-abc'); - const result = await uploadTestResult({ - apiKey: 'test-key', + await uploadTestResult({ + apiKey: 'mob_test_key', jsonResultsPath: jsonPath, - outputDir: join(workDir, 'nonexistent-dir'), + outputDir: join(workDir, 'artifacts'), + _fetchFn: mockFetch, }); - expect(result.url).toMatch(/^file:\/\//); - const uploadDir = result.url.replace('file://', ''); - expect(existsSync(join(uploadDir, 'results.json'))).toBe(true); + const createCall = calls.find(c => c.url === 'https://api.mobilenext.ai/api/v1/test-results'); + expect(createCall?.method).toBe('POST'); + const body = JSON.parse(createCall?.body as string); + expect(body.name).toBe('Test Run'); + expect(body.userAgent).toMatch(/^mobilewright\//); + expect(createCall?.headers['Authorization']).toBe('Bearer mob_test_key'); + expect(createCall?.headers['Content-Type']).toBe('application/json'); rmSync(workDir, { recursive: true }); - rmSync(uploadDir, { recursive: true }); }); -test('copies outputDir artifacts into an artifacts/ subdirectory when outputDir exists', async () => { - const workDir = mkdtempSync(join(tmpdir(), 'mw-client-test-')); +test('uses provided name in the create test result request', async () => { + const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); const jsonPath = join(workDir, 'results.json'); writeFileSync(jsonPath, '{}'); - const outputDir = join(workDir, 'test-results'); - mkdirSync(outputDir); - writeFileSync(join(outputDir, 'screenshot.png'), 'fake-png'); + const { mockFetch, calls } = makeMockFetch('result-abc'); - const result = await uploadTestResult({ - apiKey: 'test-key', + await uploadTestResult({ + apiKey: 'mob_key', + jsonResultsPath: jsonPath, + outputDir: join(workDir, 'artifacts'), + name: 'Nightly Suite', + _fetchFn: mockFetch, + }); + + const createCall = calls.find(c => c.url === 'https://api.mobilenext.ai/api/v1/test-results'); + const body = JSON.parse(createCall?.body as string); + expect(body.name).toBe('Nightly Suite'); + + rmSync(workDir, { recursive: true }); +}); + +test('uploads results.json as multipart FormData to the asset endpoint', async () => { + const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); + const jsonPath = join(workDir, 'results.json'); + writeFileSync(jsonPath, '{"tests":[]}'); + const { mockFetch, calls } = makeMockFetch('result-abc'); + + await uploadTestResult({ + apiKey: 'mob_test_key', jsonResultsPath: jsonPath, - outputDir, + outputDir: join(workDir, 'artifacts'), + _fetchFn: mockFetch, }); - const uploadDir = result.url.replace('file://', ''); - expect(existsSync(join(uploadDir, 'artifacts', 'screenshot.png'))).toBe(true); + const assetCall = calls.find(c => c.url.includes('/assets')); + expect(assetCall?.url).toBe('https://api.mobilenext.ai/api/v1/test-results/result-abc/assets'); + expect(assetCall?.method).toBe('POST'); + expect(assetCall?.body).toBeInstanceOf(FormData); + expect(assetCall?.headers['Authorization']).toBe('Bearer mob_test_key'); rmSync(workDir, { recursive: true }); - rmSync(uploadDir, { recursive: true }); }); -test('skips artifacts copy when outputDir does not exist', async () => { - const workDir = mkdtempSync(join(tmpdir(), 'mw-client-test-')); +test('returns the dashboard URL for the created test result', async () => { + const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); const jsonPath = join(workDir, 'results.json'); writeFileSync(jsonPath, '{}'); + const { mockFetch } = makeMockFetch('my-test-id-123'); const result = await uploadTestResult({ - apiKey: 'test-key', + apiKey: 'mob_key', jsonResultsPath: jsonPath, - outputDir: join(workDir, 'does-not-exist'), + outputDir: join(workDir, 'artifacts'), + _fetchFn: mockFetch, }); - const uploadDir = result.url.replace('file://', ''); - expect(existsSync(join(uploadDir, 'artifacts'))).toBe(false); + expect(result.url).toBe('https://app.mobilenext.ai/dashboard/test-results/my-test-id-123'); + + rmSync(workDir, { recursive: true }); +}); + +test('throws when create test result API returns a non-2xx status', async () => { + const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); + const jsonPath = join(workDir, 'results.json'); + writeFileSync(jsonPath, '{}'); + + const failingFetch = async (_url: string | URL | Request, _init?: RequestInit): Promise => + new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }); + + await expect( + uploadTestResult({ + apiKey: 'bad-key', + jsonResultsPath: jsonPath, + outputDir: join(workDir, 'artifacts'), + _fetchFn: failingFetch as unknown as typeof fetch, + }), + ).rejects.toThrow('401'); + + rmSync(workDir, { recursive: true }); +}); + +test('throws when asset upload API returns a non-2xx status', async () => { + const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); + const jsonPath = join(workDir, 'results.json'); + writeFileSync(jsonPath, '{}'); + + const mockFetch = async (url: string | URL | Request, _init?: RequestInit): Promise => { + if (String(url).endsWith('/test-results')) { + return new Response( + JSON.stringify({ id: 'result-abc', name: 'Test Run', userAgent: 'mobilewright/0.0.1', createdAt: '2026-01-01T00:00:00Z' }), + { status: 201 }, + ); + } + return new Response(JSON.stringify({ error: 'Server Error' }), { status: 500 }); + }; + + await expect( + uploadTestResult({ + apiKey: 'mob_key', + jsonResultsPath: jsonPath, + outputDir: join(workDir, 'artifacts'), + _fetchFn: mockFetch as unknown as typeof fetch, + }), + ).rejects.toThrow('500'); rmSync(workDir, { recursive: true }); - rmSync(uploadDir, { recursive: true }); }); From 23ab1f0c1025738b37f3aa16c2f95a5a3141bc06 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sun, 24 May 2026 21:24:41 +0200 Subject: [PATCH 06/17] feat(reporters): upload results.json to MobileNext API instead of /tmp --- .../src/reporters/upload-client.ts | 60 ++++++++++++++++--- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/packages/mobilewright/src/reporters/upload-client.ts b/packages/mobilewright/src/reporters/upload-client.ts index 1808d7f..4199436 100644 --- a/packages/mobilewright/src/reporters/upload-client.ts +++ b/packages/mobilewright/src/reporters/upload-client.ts @@ -1,7 +1,10 @@ -import { mkdtempSync, cpSync, existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; + +const _require = createRequire(import.meta.url); + +const BASE_URL = 'https://api.mobilenext.ai'; +const DASHBOARD_BASE_URL = 'https://app.mobilenext.ai'; export interface UploadTestResultParams { apiKey: string; @@ -10,16 +13,55 @@ export interface UploadTestResultParams { name?: string; tags?: string[]; environment?: string; + _fetchFn?: typeof fetch; +} + +interface TestResultResponse { + id: string; + name: string; + userAgent: string; + createdAt: string; } export async function uploadTestResult(params: UploadTestResultParams): Promise<{ url: string }> { - const uploadDir = mkdtempSync(join(tmpdir(), `mobilewright-upload-${randomUUID()}-`)); + const fetchFn = params._fetchFn ?? fetch; + const pkg = _require('../../package.json') as { version: string }; + const userAgent = `mobilewright/${pkg.version}`; + + const createRes = await fetchFn(`${BASE_URL}/api/v1/test-results`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: params.name ?? 'Test Run', + userAgent, + }), + }); + + if (!createRes.ok) { + throw new Error(`Failed to create test result: ${createRes.status}`); + } + + const testResult = await createRes.json() as TestResultResponse; + + const jsonContent = readFileSync(params.jsonResultsPath); + const form = new FormData(); + form.append('name', 'results.json'); + form.append('file', new Blob([jsonContent], { type: 'application/json' }), 'results.json'); - cpSync(params.jsonResultsPath, join(uploadDir, 'results.json')); + const uploadRes = await fetchFn(`${BASE_URL}/api/v1/test-results/${testResult.id}/assets`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${params.apiKey}`, + }, + body: form, + }); - if (existsSync(params.outputDir)) { - cpSync(params.outputDir, join(uploadDir, 'artifacts'), { recursive: true }); + if (!uploadRes.ok) { + throw new Error(`Failed to upload results.json: ${uploadRes.status}`); } - return { url: `file://${uploadDir}` }; + return { url: `${DASHBOARD_BASE_URL}/dashboard/test-results/${testResult.id}` }; } From fb427be7e005dca21b566645e84afccba7f941d6 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sun, 24 May 2026 21:43:10 +0200 Subject: [PATCH 07/17] feat(reporters): add debug logging to upload-client --- packages/mobilewright/src/reporters/upload-client.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/mobilewright/src/reporters/upload-client.ts b/packages/mobilewright/src/reporters/upload-client.ts index 4199436..38ce1a5 100644 --- a/packages/mobilewright/src/reporters/upload-client.ts +++ b/packages/mobilewright/src/reporters/upload-client.ts @@ -1,7 +1,9 @@ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; +import createDebug from 'debug'; const _require = createRequire(import.meta.url); +const debug = createDebug('mw:reporter:upload'); const BASE_URL = 'https://api.mobilenext.ai'; const DASHBOARD_BASE_URL = 'https://app.mobilenext.ai'; @@ -28,6 +30,7 @@ export async function uploadTestResult(params: UploadTestResultParams): Promise< const pkg = _require('../../package.json') as { version: string }; const userAgent = `mobilewright/${pkg.version}`; + debug('creating test result name=%s userAgent=%s', params.name ?? 'Test Run', userAgent); const createRes = await fetchFn(`${BASE_URL}/api/v1/test-results`, { method: 'POST', headers: { @@ -45,7 +48,9 @@ export async function uploadTestResult(params: UploadTestResultParams): Promise< } const testResult = await createRes.json() as TestResultResponse; + debug('test result created id=%s', testResult.id); + debug('uploading results.json path=%s', params.jsonResultsPath); const jsonContent = readFileSync(params.jsonResultsPath); const form = new FormData(); form.append('name', 'results.json'); @@ -63,5 +68,6 @@ export async function uploadTestResult(params: UploadTestResultParams): Promise< throw new Error(`Failed to upload results.json: ${uploadRes.status}`); } + debug('upload complete url=%s', `${DASHBOARD_BASE_URL}/dashboard/test-results/${testResult.id}`); return { url: `${DASHBOARD_BASE_URL}/dashboard/test-results/${testResult.id}` }; } From 56123e69fe2fe7f32c6c54b52cd28f077e4aa83a Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sun, 24 May 2026 21:57:30 +0200 Subject: [PATCH 08/17] refactor(reporters): move upload-client to driver-mobilenext --- packages/driver-mobilenext/src/index.ts | 1 + .../reporters => driver-mobilenext/src}/upload-client.test.ts | 0 .../src/reporters => driver-mobilenext/src}/upload-client.ts | 2 +- packages/mobilewright/src/reporters/mobilenext-upload.test.ts | 2 +- packages/mobilewright/src/reporters/mobilenext-upload.ts | 2 +- 5 files changed, 4 insertions(+), 3 deletions(-) rename packages/{mobilewright/src/reporters => driver-mobilenext/src}/upload-client.test.ts (100%) rename packages/{mobilewright/src/reporters => driver-mobilenext/src}/upload-client.ts (96%) diff --git a/packages/driver-mobilenext/src/index.ts b/packages/driver-mobilenext/src/index.ts index 6aa9df8..f2e0cc0 100644 --- a/packages/driver-mobilenext/src/index.ts +++ b/packages/driver-mobilenext/src/index.ts @@ -1,2 +1,3 @@ export { MobileNextDriver, DEFAULT_URL, type MobileNextDriverOptions, type MobileNextDeviceInfo } from './driver.js'; export { RpcClient } from './rpc-client.js'; +export { uploadTestResult, type UploadTestResultParams } from './upload-client.js'; diff --git a/packages/mobilewright/src/reporters/upload-client.test.ts b/packages/driver-mobilenext/src/upload-client.test.ts similarity index 100% rename from packages/mobilewright/src/reporters/upload-client.test.ts rename to packages/driver-mobilenext/src/upload-client.test.ts diff --git a/packages/mobilewright/src/reporters/upload-client.ts b/packages/driver-mobilenext/src/upload-client.ts similarity index 96% rename from packages/mobilewright/src/reporters/upload-client.ts rename to packages/driver-mobilenext/src/upload-client.ts index 38ce1a5..2de08fd 100644 --- a/packages/mobilewright/src/reporters/upload-client.ts +++ b/packages/driver-mobilenext/src/upload-client.ts @@ -27,7 +27,7 @@ interface TestResultResponse { export async function uploadTestResult(params: UploadTestResultParams): Promise<{ url: string }> { const fetchFn = params._fetchFn ?? fetch; - const pkg = _require('../../package.json') as { version: string }; + const pkg = _require('../package.json') as { version: string }; const userAgent = `mobilewright/${pkg.version}`; debug('creating test result name=%s userAgent=%s', params.name ?? 'Test Run', userAgent); diff --git a/packages/mobilewright/src/reporters/mobilenext-upload.test.ts b/packages/mobilewright/src/reporters/mobilenext-upload.test.ts index 634654f..a612537 100644 --- a/packages/mobilewright/src/reporters/mobilenext-upload.test.ts +++ b/packages/mobilewright/src/reporters/mobilenext-upload.test.ts @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import type { TestResult, FullResult } from '@playwright/test/reporter'; import MobileNextUploadReporter from './mobilenext-upload.js'; -import type { UploadTestResultParams } from './upload-client.js'; +import type { UploadTestResultParams } from '@mobilewright/driver-mobilenext'; test('does not upload when uploadReport is on-failure and no tests failed', async () => { let uploadCalled = false; diff --git a/packages/mobilewright/src/reporters/mobilenext-upload.ts b/packages/mobilewright/src/reporters/mobilenext-upload.ts index cb4e5e7..2c6f64a 100644 --- a/packages/mobilewright/src/reporters/mobilenext-upload.ts +++ b/packages/mobilewright/src/reporters/mobilenext-upload.ts @@ -1,6 +1,6 @@ import type { Reporter, TestCase, TestResult, FullResult } from '@playwright/test/reporter'; import type { MobileNextTestResultConfig } from '../config.js'; -import { uploadTestResult, type UploadTestResultParams } from './upload-client.js'; +import { uploadTestResult, type UploadTestResultParams } from '@mobilewright/driver-mobilenext'; type UploadFn = (params: UploadTestResultParams) => Promise<{ url: string }>; From 85acbf356fd80038a232d830466425291ac766e1 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sun, 24 May 2026 22:24:05 +0200 Subject: [PATCH 09/17] fix(reporters): rename uploaded asset from results.json to report.json --- packages/driver-mobilenext/src/upload-client.test.ts | 4 ++-- packages/driver-mobilenext/src/upload-client.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/driver-mobilenext/src/upload-client.test.ts b/packages/driver-mobilenext/src/upload-client.test.ts index 4ae4b76..77559fa 100644 --- a/packages/driver-mobilenext/src/upload-client.test.ts +++ b/packages/driver-mobilenext/src/upload-client.test.ts @@ -21,7 +21,7 @@ function makeMockFetch(testResultId: string) { ); } return new Response( - JSON.stringify({ id: 'asset-1', name: 'results.json', contentType: 'application/json', size: 12, createdAt: '2026-01-01T00:00:00Z' }), + JSON.stringify({ id: 'asset-1', name: 'report.json', contentType: 'application/json', size: 12, createdAt: '2026-01-01T00:00:00Z' }), { status: 201 }, ); }; @@ -74,7 +74,7 @@ test('uses provided name in the create test result request', async () => { rmSync(workDir, { recursive: true }); }); -test('uploads results.json as multipart FormData to the asset endpoint', async () => { +test('uploads report.json as multipart FormData to the asset endpoint', async () => { const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); const jsonPath = join(workDir, 'results.json'); writeFileSync(jsonPath, '{"tests":[]}'); diff --git a/packages/driver-mobilenext/src/upload-client.ts b/packages/driver-mobilenext/src/upload-client.ts index 2de08fd..5a7f4d3 100644 --- a/packages/driver-mobilenext/src/upload-client.ts +++ b/packages/driver-mobilenext/src/upload-client.ts @@ -50,11 +50,11 @@ export async function uploadTestResult(params: UploadTestResultParams): Promise< const testResult = await createRes.json() as TestResultResponse; debug('test result created id=%s', testResult.id); - debug('uploading results.json path=%s', params.jsonResultsPath); + debug('uploading report.json path=%s', params.jsonResultsPath); const jsonContent = readFileSync(params.jsonResultsPath); const form = new FormData(); - form.append('name', 'results.json'); - form.append('file', new Blob([jsonContent], { type: 'application/json' }), 'results.json'); + form.append('name', 'report.json'); + form.append('file', new Blob([jsonContent], { type: 'application/json' }), 'report.json'); const uploadRes = await fetchFn(`${BASE_URL}/api/v1/test-results/${testResult.id}/assets`, { method: 'POST', From 57740aab81e77366bb776e39e47d885403fdb638 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sun, 24 May 2026 22:27:16 +0200 Subject: [PATCH 10/17] fix(reporters): skip upload when no tests were collected --- .../src/reporters/mobilenext-upload.test.ts | 51 ++++++++++++++++++- .../src/reporters/mobilenext-upload.ts | 10 +++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/mobilewright/src/reporters/mobilenext-upload.test.ts b/packages/mobilewright/src/reporters/mobilenext-upload.test.ts index a612537..3587f04 100644 --- a/packages/mobilewright/src/reporters/mobilenext-upload.test.ts +++ b/packages/mobilewright/src/reporters/mobilenext-upload.test.ts @@ -1,8 +1,12 @@ import { test, expect } from '@playwright/test'; -import type { TestResult, FullResult } from '@playwright/test/reporter'; +import type { TestResult, FullResult, FullConfig, Suite } from '@playwright/test/reporter'; import MobileNextUploadReporter from './mobilenext-upload.js'; import type { UploadTestResultParams } from '@mobilewright/driver-mobilenext'; +function suiteWithTests(count: number): Suite { + return { allTests: () => new Array(count).fill({}) } as unknown as Suite; +} + test('does not upload when uploadReport is on-failure and no tests failed', async () => { let uploadCalled = false; const spyUpload = async (_params: UploadTestResultParams) => { @@ -18,6 +22,7 @@ test('does not upload when uploadReport is on-failure and no tests failed', asyn _uploadFn: spyUpload, }); + reporter.onBegin({} as FullConfig, suiteWithTests(1)); const endResult = await reporter.onEnd({ status: 'passed' } as FullResult); expect(uploadCalled).toBe(false); expect(endResult).toBeUndefined(); @@ -38,6 +43,7 @@ test('uploads when uploadReport is on-failure and a test failed', async () => { _uploadFn: spyUpload, }); + reporter.onBegin({} as FullConfig, suiteWithTests(1)); reporter.onTestEnd({} as never, { status: 'failed' } as TestResult); await reporter.onEnd({ status: 'failed' } as FullResult); expect(uploadCalled).toBe(true); @@ -58,6 +64,7 @@ test('uploads when uploadReport is on-failure and a test timed out', async () => _uploadFn: spyUpload, }); + reporter.onBegin({} as FullConfig, suiteWithTests(1)); reporter.onTestEnd({} as never, { status: 'timedOut' } as TestResult); await reporter.onEnd({ status: 'failed' } as FullResult); expect(uploadCalled).toBe(true); @@ -78,10 +85,50 @@ test('always uploads when uploadReport is on regardless of test outcomes', async _uploadFn: spyUpload, }); + reporter.onBegin({} as FullConfig, suiteWithTests(1)); await reporter.onEnd({ status: 'passed' } as FullResult); expect(uploadCalled).toBe(true); }); +test('does not upload when no tests were collected', async () => { + let uploadCalled = false; + const spyUpload = async (_params: UploadTestResultParams) => { + uploadCalled = true; + return { url: 'file:///tmp/fake' }; + }; + + const reporter = new MobileNextUploadReporter({ + apiKey: 'key', + jsonResultsPath: '/tmp/results.json', + outputDir: '/tmp/test-results', + testResult: { uploadReport: 'on' }, + _uploadFn: spyUpload, + }); + + reporter.onBegin({} as FullConfig, suiteWithTests(0)); + await reporter.onEnd({ status: 'failed' } as FullResult); + expect(uploadCalled).toBe(false); +}); + +test('does not upload when onBegin was never called', async () => { + let uploadCalled = false; + const spyUpload = async (_params: UploadTestResultParams) => { + uploadCalled = true; + return { url: 'file:///tmp/fake' }; + }; + + const reporter = new MobileNextUploadReporter({ + apiKey: 'key', + jsonResultsPath: '/tmp/results.json', + outputDir: '/tmp/test-results', + testResult: { uploadReport: 'on' }, + _uploadFn: spyUpload, + }); + + await reporter.onEnd({ status: 'failed' } as FullResult); + expect(uploadCalled).toBe(false); +}); + test('passes apiKey, name, tags, environment and paths to upload function', async () => { let capturedParams: UploadTestResultParams | undefined; const spyUpload = async (params: UploadTestResultParams) => { @@ -102,6 +149,7 @@ test('passes apiKey, name, tags, environment and paths to upload function', asyn _uploadFn: spyUpload, }); + reporter.onBegin({} as FullConfig, suiteWithTests(1)); await reporter.onEnd({ status: 'passed' } as FullResult); expect(capturedParams?.apiKey).toBe('my-secret-key'); @@ -125,5 +173,6 @@ test('does not throw when upload function rejects', async () => { _uploadFn: failingUpload, }); + reporter.onBegin({} as FullConfig, suiteWithTests(1)); await expect(reporter.onEnd({ status: 'passed' } as FullResult)).resolves.not.toThrow(); }); diff --git a/packages/mobilewright/src/reporters/mobilenext-upload.ts b/packages/mobilewright/src/reporters/mobilenext-upload.ts index 2c6f64a..42a71b9 100644 --- a/packages/mobilewright/src/reporters/mobilenext-upload.ts +++ b/packages/mobilewright/src/reporters/mobilenext-upload.ts @@ -1,4 +1,4 @@ -import type { Reporter, TestCase, TestResult, FullResult } from '@playwright/test/reporter'; +import type { Reporter, TestCase, TestResult, FullResult, FullConfig, Suite } from '@playwright/test/reporter'; import type { MobileNextTestResultConfig } from '../config.js'; import { uploadTestResult, type UploadTestResultParams } from '@mobilewright/driver-mobilenext'; @@ -14,12 +14,17 @@ interface MobileNextUploadReporterOptions { export default class MobileNextUploadReporter implements Reporter { private hasFailed = false; + private hasTests = false; private readonly options: MobileNextUploadReporterOptions; constructor(options: MobileNextUploadReporterOptions) { this.options = options; } + onBegin(_config: FullConfig, suite: Suite): void { + this.hasTests = suite.allTests().length > 0; + } + onTestEnd(_test: TestCase, result: TestResult): void { if (result.status === 'failed' || result.status === 'timedOut') { this.hasFailed = true; @@ -27,6 +32,9 @@ export default class MobileNextUploadReporter implements Reporter { } async onEnd(_result: FullResult): Promise { + if (!this.hasTests) { + return; + } const { uploadReport } = this.options.testResult; if (uploadReport === 'on-failure' && !this.hasFailed) { return; From 9fad924e48aaf4fc20a2af18fc05e077de282d10 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sun, 24 May 2026 22:32:33 +0200 Subject: [PATCH 11/17] feat(reporters): log file size and periodic progress during upload --- packages/driver-mobilenext/src/upload-client.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/driver-mobilenext/src/upload-client.ts b/packages/driver-mobilenext/src/upload-client.ts index 5a7f4d3..d63f73b 100644 --- a/packages/driver-mobilenext/src/upload-client.ts +++ b/packages/driver-mobilenext/src/upload-client.ts @@ -50,22 +50,28 @@ export async function uploadTestResult(params: UploadTestResultParams): Promise< const testResult = await createRes.json() as TestResultResponse; debug('test result created id=%s', testResult.id); - debug('uploading report.json path=%s', params.jsonResultsPath); const jsonContent = readFileSync(params.jsonResultsPath); + const fileSizeKB = (jsonContent.length / 1024).toFixed(1); + debug('uploading report.json size=%skB path=%s', fileSizeKB, params.jsonResultsPath); + const form = new FormData(); form.append('name', 'report.json'); form.append('file', new Blob([jsonContent], { type: 'application/json' }), 'report.json'); + const progressTimer = setInterval(() => { + debug('still uploading report.json...'); + }, 10_000); + const uploadRes = await fetchFn(`${BASE_URL}/api/v1/test-results/${testResult.id}/assets`, { method: 'POST', headers: { 'Authorization': `Bearer ${params.apiKey}`, }, body: form, - }); + }).finally(() => clearInterval(progressTimer)); if (!uploadRes.ok) { - throw new Error(`Failed to upload results.json: ${uploadRes.status}`); + throw new Error(`Failed to upload report.json: ${uploadRes.status}`); } debug('upload complete url=%s', `${DASHBOARD_BASE_URL}/dashboard/test-results/${testResult.id}`); From f48b89cd7442c058d873d1e04bf993d4566f8836 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Sun, 24 May 2026 22:46:12 +0200 Subject: [PATCH 12/17] feat(reporters): extract inline attachment bodies as separate assets before uploading report.json --- .../src/upload-client.test.ts | 151 ++++++++++++++++++ .../driver-mobilenext/src/upload-client.ts | 86 +++++++++- 2 files changed, 231 insertions(+), 6 deletions(-) diff --git a/packages/driver-mobilenext/src/upload-client.test.ts b/packages/driver-mobilenext/src/upload-client.test.ts index 77559fa..c8fa47e 100644 --- a/packages/driver-mobilenext/src/upload-client.test.ts +++ b/packages/driver-mobilenext/src/upload-client.test.ts @@ -160,3 +160,154 @@ test('throws when asset upload API returns a non-2xx status', async () => { rmSync(workDir, { recursive: true }); }); + +test('uploads inline attachment bodies as separate assets before report.json', async () => { + const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); + const jsonPath = join(workDir, 'results.json'); + const pngBase64 = Buffer.from('fake-png-data').toString('base64'); + const report = { + suites: [{ specs: [{ tests: [{ results: [{ attachments: [ + { name: 'screenshot', contentType: 'image/png', body: pngBase64 }, + ] }] }] }] }], + }; + writeFileSync(jsonPath, JSON.stringify(report)); + + let assetCallCount = 0; + const mockFetch = async (url: string | URL | Request, init?: RequestInit): Promise => { + const urlStr = String(url); + if (urlStr.endsWith('/test-results')) { + return new Response( + JSON.stringify({ id: 'result-abc', name: 'Test Run', userAgent: 'mobilewright/0.0.1', createdAt: '2026-01-01T00:00:00Z' }), + { status: 201 }, + ); + } + assetCallCount++; + return new Response( + JSON.stringify({ id: `asset-${assetCallCount}`, name: 'x', contentType: 'image/png', size: 10, createdAt: '2026-01-01T00:00:00Z' }), + { status: 201 }, + ); + }; + + await uploadTestResult({ + apiKey: 'mob_key', + jsonResultsPath: jsonPath, + outputDir: join(workDir, 'artifacts'), + _fetchFn: mockFetch as unknown as typeof fetch, + }); + + // 2 asset calls: one for the PNG attachment, one for report.json + expect(assetCallCount).toBe(2); + + rmSync(workDir, { recursive: true }); +}); + +test('removes body and sets assetId in the uploaded report.json', async () => { + const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); + const jsonPath = join(workDir, 'results.json'); + const pngBase64 = Buffer.from('fake-png-data').toString('base64'); + const report = { + suites: [{ specs: [{ tests: [{ results: [{ attachments: [ + { name: 'screenshot', contentType: 'image/png', body: pngBase64 }, + ] }] }] }] }], + }; + writeFileSync(jsonPath, JSON.stringify(report)); + + let assetCallCount = 0; + let capturedReportForm: FormData | undefined; + const mockFetch = async (url: string | URL | Request, init?: RequestInit): Promise => { + const urlStr = String(url); + if (urlStr.endsWith('/test-results')) { + return new Response( + JSON.stringify({ id: 'result-abc', name: 'Test Run', userAgent: 'mobilewright/0.0.1', createdAt: '2026-01-01T00:00:00Z' }), + { status: 201 }, + ); + } + assetCallCount++; + if (assetCallCount === 2) { + capturedReportForm = init?.body as FormData; + } + return new Response( + JSON.stringify({ id: `asset-${assetCallCount}`, name: 'x', contentType: 'image/png', size: 10, createdAt: '2026-01-01T00:00:00Z' }), + { status: 201 }, + ); + }; + + await uploadTestResult({ + apiKey: 'mob_key', + jsonResultsPath: jsonPath, + outputDir: join(workDir, 'artifacts'), + _fetchFn: mockFetch as unknown as typeof fetch, + }); + + const reportFile = capturedReportForm?.get('file') as File; + const parsedReport = JSON.parse(await reportFile.text()) as typeof report; + const att = parsedReport.suites[0].specs[0].tests[0].results[0].attachments[0] as Record; + expect(att['body']).toBeUndefined(); + expect(att['assetId']).toBe('asset-1'); + + rmSync(workDir, { recursive: true }); +}); + +test('does not modify the original json file on disk', async () => { + const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); + const jsonPath = join(workDir, 'results.json'); + const pngBase64 = Buffer.from('fake-png-data').toString('base64'); + const report = { + suites: [{ specs: [{ tests: [{ results: [{ attachments: [ + { name: 'screenshot', contentType: 'image/png', body: pngBase64 }, + ] }] }] }] }], + }; + const originalJson = JSON.stringify(report); + writeFileSync(jsonPath, originalJson); + const { mockFetch } = makeMockFetch('result-abc'); + + await uploadTestResult({ + apiKey: 'mob_key', + jsonResultsPath: jsonPath, + outputDir: join(workDir, 'artifacts'), + _fetchFn: mockFetch, + }); + + const { readFileSync: read } = await import('node:fs'); + expect(read(jsonPath, 'utf8')).toBe(originalJson); + + rmSync(workDir, { recursive: true }); +}); + +test('leaves path-based attachments unchanged', async () => { + const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); + const jsonPath = join(workDir, 'results.json'); + const report = { + suites: [{ specs: [{ tests: [{ results: [{ attachments: [ + { name: 'video', contentType: 'video/mp4', path: '/some/path/video.mp4' }, + ] }] }] }] }], + }; + writeFileSync(jsonPath, JSON.stringify(report)); + + let assetCallCount = 0; + const mockFetch = async (url: string | URL | Request, _init?: RequestInit): Promise => { + if (String(url).endsWith('/test-results')) { + return new Response( + JSON.stringify({ id: 'result-abc', name: 'Test Run', userAgent: 'mobilewright/0.0.1', createdAt: '2026-01-01T00:00:00Z' }), + { status: 201 }, + ); + } + assetCallCount++; + return new Response( + JSON.stringify({ id: 'asset-1', name: 'report.json', contentType: 'application/json', size: 10, createdAt: '2026-01-01T00:00:00Z' }), + { status: 201 }, + ); + }; + + await uploadTestResult({ + apiKey: 'mob_key', + jsonResultsPath: jsonPath, + outputDir: join(workDir, 'artifacts'), + _fetchFn: mockFetch as unknown as typeof fetch, + }); + + // Only 1 asset call: just report.json (no upload for path-based attachments) + expect(assetCallCount).toBe(1); + + rmSync(workDir, { recursive: true }); +}); diff --git a/packages/driver-mobilenext/src/upload-client.ts b/packages/driver-mobilenext/src/upload-client.ts index d63f73b..d150ac0 100644 --- a/packages/driver-mobilenext/src/upload-client.ts +++ b/packages/driver-mobilenext/src/upload-client.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; +import { randomUUID } from 'node:crypto'; import createDebug from 'debug'; const _require = createRequire(import.meta.url); @@ -25,6 +26,74 @@ interface TestResultResponse { createdAt: string; } +interface AssetResponse { + id: string; + name: string; + contentType: string; + size: number; + createdAt: string; +} + +function extensionForContentType(contentType: string): string { + const extensions: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'image/gif': 'gif', + }; + return extensions[contentType] ?? 'bin'; +} + +async function uploadAttachmentBodies( + obj: unknown, + testResultId: string, + apiKey: string, + fetchFn: typeof fetch, +): Promise { + if (!obj || typeof obj !== 'object') { return; } + if (Array.isArray(obj)) { + for (const item of obj) { + await uploadAttachmentBodies(item, testResultId, apiKey, fetchFn); + } + return; + } + const record = obj as Record; + if (Array.isArray(record['attachments'])) { + for (const att of record['attachments'] as Record[]) { + if (typeof att['body'] === 'string') { + const contentType = typeof att['contentType'] === 'string' ? att['contentType'] : 'application/octet-stream'; + const ext = extensionForContentType(contentType); + const assetName = `${randomUUID()}.${ext}`; + const buffer = Buffer.from(att['body'], 'base64'); + const sizeKB = (buffer.length / 1024).toFixed(1); + debug('uploading attachment name=%s contentType=%s size=%skB as %s', att['name'], contentType, sizeKB, assetName); + + const form = new FormData(); + form.append('name', assetName); + form.append('file', new Blob([buffer], { type: contentType }), assetName); + + const res = await fetchFn(`${BASE_URL}/api/v1/test-results/${testResultId}/assets`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${apiKey}` }, + body: form, + }); + + if (!res.ok) { + throw new Error(`Failed to upload attachment "${att['name'] as string}": ${res.status}`); + } + + const asset = await res.json() as AssetResponse; + delete att['body']; + att['assetId'] = asset.id; + debug('attachment uploaded assetId=%s', asset.id); + } + } + } + for (const value of Object.values(record)) { + await uploadAttachmentBodies(value, testResultId, apiKey, fetchFn); + } +} + export async function uploadTestResult(params: UploadTestResultParams): Promise<{ url: string }> { const fetchFn = params._fetchFn ?? fetch; const pkg = _require('../package.json') as { version: string }; @@ -50,13 +119,20 @@ export async function uploadTestResult(params: UploadTestResultParams): Promise< const testResult = await createRes.json() as TestResultResponse; debug('test result created id=%s', testResult.id); - const jsonContent = readFileSync(params.jsonResultsPath); - const fileSizeKB = (jsonContent.length / 1024).toFixed(1); + // Parse into a fresh in-memory copy — original file on disk is never modified + const rawJson = readFileSync(params.jsonResultsPath); + const report = JSON.parse(rawJson.toString()) as Record; + + await uploadAttachmentBodies(report, testResult.id, params.apiKey, fetchFn); + + const modifiedJson = JSON.stringify(report); + const modifiedBuffer = Buffer.from(modifiedJson); + const fileSizeKB = (modifiedBuffer.length / 1024).toFixed(1); debug('uploading report.json size=%skB path=%s', fileSizeKB, params.jsonResultsPath); const form = new FormData(); form.append('name', 'report.json'); - form.append('file', new Blob([jsonContent], { type: 'application/json' }), 'report.json'); + form.append('file', new Blob([modifiedBuffer], { type: 'application/json' }), 'report.json'); const progressTimer = setInterval(() => { debug('still uploading report.json...'); @@ -64,9 +140,7 @@ export async function uploadTestResult(params: UploadTestResultParams): Promise< const uploadRes = await fetchFn(`${BASE_URL}/api/v1/test-results/${testResult.id}/assets`, { method: 'POST', - headers: { - 'Authorization': `Bearer ${params.apiKey}`, - }, + headers: { 'Authorization': `Bearer ${params.apiKey}` }, body: form, }).finally(() => clearInterval(progressTimer)); From 3440283fd76cd553e5a5400e68ff3b2a245921ce Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 26 May 2026 10:32:29 +0200 Subject: [PATCH 13/17] feat(upload): collect git metadata and include in test result POST request --- .../driver-mobilenext/src/git-info.test.ts | 122 ++++++++++++++ packages/driver-mobilenext/src/git-info.ts | 159 ++++++++++++++++++ packages/driver-mobilenext/src/index.ts | 1 + .../driver-mobilenext/src/upload-client.ts | 5 + 4 files changed, 287 insertions(+) create mode 100644 packages/driver-mobilenext/src/git-info.test.ts create mode 100644 packages/driver-mobilenext/src/git-info.ts diff --git a/packages/driver-mobilenext/src/git-info.test.ts b/packages/driver-mobilenext/src/git-info.test.ts new file mode 100644 index 0000000..750c47e --- /dev/null +++ b/packages/driver-mobilenext/src/git-info.test.ts @@ -0,0 +1,122 @@ +import { test, expect } from '@playwright/test'; +import { getGitInfo, normalizeRepoUrl } from './git-info.js'; + +function withEnv(vars: Record, fn: () => void): void { + const saved: Record = {}; + for (const key of Object.keys(vars)) { + saved[key] = process.env[key]; + process.env[key] = vars[key]; + } + try { + fn(); + } finally { + for (const key of Object.keys(vars)) { + if (saved[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = saved[key]; + } + } + } +} + +test('normalizeRepoUrl converts SSH git@ URL to HTTPS', () => { + expect(normalizeRepoUrl('git@github.com:org/repo.git')).toBe('https://github.com/org/repo'); +}); + +test('normalizeRepoUrl converts ssh:// URL to HTTPS', () => { + expect(normalizeRepoUrl('ssh://git@github.com/org/repo.git')).toBe('https://github.com/org/repo'); +}); + +test('normalizeRepoUrl strips .git suffix from HTTPS URL', () => { + expect(normalizeRepoUrl('https://github.com/org/repo.git')).toBe('https://github.com/org/repo'); +}); + +test('normalizeRepoUrl leaves plain HTTPS URL unchanged', () => { + expect(normalizeRepoUrl('https://github.com/org/repo')).toBe('https://github.com/org/repo'); +}); + +test('getGitInfo reads GitHub Actions environment variables', () => { + withEnv({ + GITHUB_ACTIONS: 'true', + GITHUB_REPOSITORY: 'myorg/myrepo', + GITHUB_SHA: 'abc123def456', + GITHUB_REF_NAME: 'main', + GITHUB_ACTOR: 'octocat', + GITHUB_COMMIT_MESSAGE: 'feat: add feature', + }, () => { + const info = getGitInfo(); + expect(info.repoUrl).toBe('https://github.com/myorg/myrepo'); + expect(info.commitSha).toBe('abc123def456'); + expect(info.branch).toBe('main'); + expect(info.authorName).toBe('octocat'); + expect(info.commitMessage).toBe('feat: add feature'); + }); +}); + +test('getGitInfo reads GitLab CI environment variables', () => { + withEnv({ + GITLAB_CI: 'true', + CI_PROJECT_URL: 'https://gitlab.com/myorg/myrepo', + CI_COMMIT_SHA: 'deadbeef', + CI_COMMIT_REF_NAME: 'feature-branch', + GITLAB_USER_NAME: 'alice', + CI_COMMIT_MESSAGE: 'fix: bug', + }, () => { + const info = getGitInfo(); + expect(info.repoUrl).toBe('https://gitlab.com/myorg/myrepo'); + expect(info.commitSha).toBe('deadbeef'); + expect(info.branch).toBe('feature-branch'); + expect(info.authorName).toBe('alice'); + expect(info.commitMessage).toBe('fix: bug'); + }); +}); + +test('getGitInfo reads Azure DevOps environment variables and strips refs/heads/ prefix', () => { + withEnv({ + TF_BUILD: 'true', + BUILD_REPOSITORY_URI: 'https://dev.azure.com/org/project/_git/repo', + BUILD_SOURCEBRANCH: 'refs/heads/main', + BUILD_SOURCEVERSION: 'abc123', + BUILD_REQUESTEDFOR: 'Bob', + BUILD_SOURCEVERSIONMESSAGE: 'chore: update deps', + }, () => { + const info = getGitInfo(); + expect(info.branch).toBe('main'); + expect(info.repoUrl).toBe('https://dev.azure.com/org/project/_git/repo'); + expect(info.authorName).toBe('Bob'); + }); +}); + +test('getGitInfo falls back to local git when no CI env vars are set', () => { + // This test runs inside the mobilewright git repo, so local git should work. + const ciKeys = [ + 'GITHUB_ACTIONS', 'GITLAB_CI', 'JENKINS_URL', + 'CIRCLECI', 'TRAVIS', 'TF_BUILD', 'BITBUCKET_PIPELINE_UUID', + ]; + const saved: Record = {}; + for (const key of ciKeys) { + saved[key] = process.env[key]; + delete process.env[key]; + } + + try { + const info = getGitInfo(); + expect(typeof info.branch).toBe('string'); + expect(typeof info.commitSha).toBe('string'); + expect(info.commitSha).toHaveLength(40); + } finally { + for (const key of ciKeys) { + if (saved[key] !== undefined) { + process.env[key] = saved[key]; + } + } + } +}); + +test('getGitInfo returns empty object when not in a git repo and no CI env vars', () => { + // Simulate a non-git environment by checking we get an object (may be empty) + const info = getGitInfo(); + expect(typeof info).toBe('object'); + expect(info).not.toBeNull(); +}); diff --git a/packages/driver-mobilenext/src/git-info.ts b/packages/driver-mobilenext/src/git-info.ts new file mode 100644 index 0000000..94537c6 --- /dev/null +++ b/packages/driver-mobilenext/src/git-info.ts @@ -0,0 +1,159 @@ +import { execSync } from 'node:child_process'; + +export interface GitInfo { + repoUrl?: string; + branch?: string; + commitSha?: string; + authorName?: string; + commitMessage?: string; +} + +function runGit(args: string[]): string | undefined { + try { + const result = execSync(['git', ...args].join(' '), { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return result || undefined; + } catch { + return undefined; + } +} + +export function normalizeRepoUrl(url: string): string { + const sshMatch = url.match(/^git@([^:]+):(.+?)(?:\.git)?$/); + if (sshMatch) { + return `https://${sshMatch[1]}/${sshMatch[2]}`; + } + const sshProtocolMatch = url.match(/^ssh:\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/); + if (sshProtocolMatch) { + return `https://${sshProtocolMatch[1]}/${sshProtocolMatch[2]}`; + } + return url.replace(/\.git$/, ''); +} + +function getGitHubInfo(): GitInfo | undefined { + if (!process.env['GITHUB_ACTIONS']) { + return undefined; + } + const repo = process.env['GITHUB_REPOSITORY']; + return { + repoUrl: repo ? `https://github.com/${repo}` : undefined, + branch: process.env['GITHUB_REF_NAME'], + commitSha: process.env['GITHUB_SHA'], + authorName: process.env['GITHUB_ACTOR'], + commitMessage: process.env['GITHUB_COMMIT_MESSAGE'] ?? runGit(['log', '-1', '--format=%s']), + }; +} + +function getGitLabInfo(): GitInfo | undefined { + if (!process.env['GITLAB_CI']) { + return undefined; + } + return { + repoUrl: process.env['CI_PROJECT_URL'], + branch: process.env['CI_COMMIT_REF_NAME'], + commitSha: process.env['CI_COMMIT_SHA'], + authorName: process.env['GITLAB_USER_NAME'] ?? process.env['CI_COMMIT_AUTHOR'], + commitMessage: process.env['CI_COMMIT_MESSAGE'], + }; +} + +function getJenkinsInfo(): GitInfo | undefined { + if (!process.env['JENKINS_URL']) { + return undefined; + } + const rawUrl = process.env['GIT_URL']; + const branch = process.env['GIT_BRANCH'] ?? process.env['BRANCH_NAME'] ?? process.env['GIT_LOCAL_BRANCH']; + return { + repoUrl: rawUrl ? normalizeRepoUrl(rawUrl) : undefined, + branch, + commitSha: process.env['GIT_COMMIT'], + authorName: process.env['GIT_AUTHOR_NAME'], + commitMessage: runGit(['log', '-1', '--format=%s']), + }; +} + +function getCircleCIInfo(): GitInfo | undefined { + if (!process.env['CIRCLECI']) { + return undefined; + } + const username = process.env['CIRCLE_PROJECT_USERNAME']; + const reponame = process.env['CIRCLE_PROJECT_REPONAME']; + const vcsType = process.env['CIRCLE_VCS_TYPE'] ?? 'github'; + const host = vcsType === 'bitbucket' ? 'bitbucket.org' : 'github.com'; + return { + repoUrl: username && reponame ? `https://${host}/${username}/${reponame}` : undefined, + branch: process.env['CIRCLE_BRANCH'], + commitSha: process.env['CIRCLE_SHA1'], + authorName: process.env['CIRCLE_USERNAME'], + commitMessage: runGit(['log', '-1', '--format=%s']), + }; +} + +function getTravisInfo(): GitInfo | undefined { + if (!process.env['TRAVIS']) { + return undefined; + } + const slug = process.env['TRAVIS_REPO_SLUG']; + return { + repoUrl: slug ? `https://github.com/${slug}` : undefined, + branch: process.env['TRAVIS_PULL_REQUEST_BRANCH'] ?? process.env['TRAVIS_BRANCH'], + commitSha: process.env['TRAVIS_COMMIT'], + commitMessage: process.env['TRAVIS_COMMIT_MESSAGE'], + }; +} + +function getAzureDevOpsInfo(): GitInfo | undefined { + if (!process.env['TF_BUILD']) { + return undefined; + } + const rawBranch = process.env['SYSTEM_PULLREQUEST_SOURCEBRANCH'] ?? process.env['BUILD_SOURCEBRANCH']; + return { + repoUrl: process.env['BUILD_REPOSITORY_URI'], + branch: rawBranch?.replace('refs/heads/', ''), + commitSha: process.env['BUILD_SOURCEVERSION'], + authorName: process.env['BUILD_REQUESTEDFOR'], + commitMessage: process.env['BUILD_SOURCEVERSIONMESSAGE'], + }; +} + +function getBitbucketInfo(): GitInfo | undefined { + if (!process.env['BITBUCKET_PIPELINE_UUID']) { + return undefined; + } + const slug = process.env['BITBUCKET_REPO_FULL_NAME']; + return { + repoUrl: slug ? `https://bitbucket.org/${slug}` : undefined, + branch: process.env['BITBUCKET_BRANCH'], + commitSha: process.env['BITBUCKET_COMMIT'], + commitMessage: runGit(['log', '-1', '--format=%s']), + }; +} + +function getLocalGitInfo(): GitInfo | undefined { + const gitDir = runGit(['rev-parse', '--git-dir']); + if (!gitDir) { + return undefined; + } + const rawUrl = runGit(['config', '--get', 'remote.origin.url']); + return { + repoUrl: rawUrl ? normalizeRepoUrl(rawUrl) : undefined, + branch: runGit(['rev-parse', '--abbrev-ref', 'HEAD']), + commitSha: runGit(['rev-parse', 'HEAD']), + authorName: runGit(['log', '-1', '--format=%an']), + commitMessage: runGit(['log', '-1', '--format=%s']), + }; +} + +export function getGitInfo(): GitInfo { + const info = getGitHubInfo() + ?? getGitLabInfo() + ?? getJenkinsInfo() + ?? getCircleCIInfo() + ?? getTravisInfo() + ?? getAzureDevOpsInfo() + ?? getBitbucketInfo() + ?? getLocalGitInfo(); + return info ?? {}; +} diff --git a/packages/driver-mobilenext/src/index.ts b/packages/driver-mobilenext/src/index.ts index f2e0cc0..b052d56 100644 --- a/packages/driver-mobilenext/src/index.ts +++ b/packages/driver-mobilenext/src/index.ts @@ -1,3 +1,4 @@ export { MobileNextDriver, DEFAULT_URL, type MobileNextDriverOptions, type MobileNextDeviceInfo } from './driver.js'; export { RpcClient } from './rpc-client.js'; export { uploadTestResult, type UploadTestResultParams } from './upload-client.js'; +export { getGitInfo, normalizeRepoUrl, type GitInfo } from './git-info.js'; diff --git a/packages/driver-mobilenext/src/upload-client.ts b/packages/driver-mobilenext/src/upload-client.ts index d150ac0..00192b8 100644 --- a/packages/driver-mobilenext/src/upload-client.ts +++ b/packages/driver-mobilenext/src/upload-client.ts @@ -2,6 +2,7 @@ import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { randomUUID } from 'node:crypto'; import createDebug from 'debug'; +import { getGitInfo } from './git-info.js'; const _require = createRequire(import.meta.url); const debug = createDebug('mw:reporter:upload'); @@ -99,6 +100,9 @@ export async function uploadTestResult(params: UploadTestResultParams): Promise< const pkg = _require('../package.json') as { version: string }; const userAgent = `mobilewright/${pkg.version}`; + const gitInfo = getGitInfo(); + const hasGitInfo = Object.values(gitInfo).some(v => v !== undefined); + debug('creating test result name=%s userAgent=%s', params.name ?? 'Test Run', userAgent); const createRes = await fetchFn(`${BASE_URL}/api/v1/test-results`, { method: 'POST', @@ -109,6 +113,7 @@ export async function uploadTestResult(params: UploadTestResultParams): Promise< body: JSON.stringify({ name: params.name ?? 'Test Run', userAgent, + ...(hasGitInfo ? { git: gitInfo } : {}), }), }); From 37f034de730201987071690cda3d6eff836fc8a5 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 26 May 2026 10:36:05 +0200 Subject: [PATCH 14/17] fix(test): clear all CI env vars before each provider test to prevent interference --- .../driver-mobilenext/src/git-info.test.ts | 43 ++++++++----------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/packages/driver-mobilenext/src/git-info.test.ts b/packages/driver-mobilenext/src/git-info.test.ts index 750c47e..5f241bd 100644 --- a/packages/driver-mobilenext/src/git-info.test.ts +++ b/packages/driver-mobilenext/src/git-info.test.ts @@ -1,16 +1,25 @@ import { test, expect } from '@playwright/test'; import { getGitInfo, normalizeRepoUrl } from './git-info.js'; -function withEnv(vars: Record, fn: () => void): void { +const ALL_CI_KEYS = [ + 'GITHUB_ACTIONS', 'GITLAB_CI', 'JENKINS_URL', + 'CIRCLECI', 'TRAVIS', 'TF_BUILD', 'BITBUCKET_PIPELINE_UUID', +]; + +function withCIEnv(vars: Record, fn: () => void): void { const saved: Record = {}; - for (const key of Object.keys(vars)) { + const keysToManage = [...new Set([...ALL_CI_KEYS, ...Object.keys(vars)])]; + for (const key of keysToManage) { saved[key] = process.env[key]; - process.env[key] = vars[key]; + delete process.env[key]; + } + for (const [key, value] of Object.entries(vars)) { + process.env[key] = value; } try { fn(); } finally { - for (const key of Object.keys(vars)) { + for (const key of keysToManage) { if (saved[key] === undefined) { delete process.env[key]; } else { @@ -37,7 +46,7 @@ test('normalizeRepoUrl leaves plain HTTPS URL unchanged', () => { }); test('getGitInfo reads GitHub Actions environment variables', () => { - withEnv({ + withCIEnv({ GITHUB_ACTIONS: 'true', GITHUB_REPOSITORY: 'myorg/myrepo', GITHUB_SHA: 'abc123def456', @@ -55,7 +64,7 @@ test('getGitInfo reads GitHub Actions environment variables', () => { }); test('getGitInfo reads GitLab CI environment variables', () => { - withEnv({ + withCIEnv({ GITLAB_CI: 'true', CI_PROJECT_URL: 'https://gitlab.com/myorg/myrepo', CI_COMMIT_SHA: 'deadbeef', @@ -73,7 +82,7 @@ test('getGitInfo reads GitLab CI environment variables', () => { }); test('getGitInfo reads Azure DevOps environment variables and strips refs/heads/ prefix', () => { - withEnv({ + withCIEnv({ TF_BUILD: 'true', BUILD_REPOSITORY_URI: 'https://dev.azure.com/org/project/_git/repo', BUILD_SOURCEBRANCH: 'refs/heads/main', @@ -90,28 +99,12 @@ test('getGitInfo reads Azure DevOps environment variables and strips refs/heads/ test('getGitInfo falls back to local git when no CI env vars are set', () => { // This test runs inside the mobilewright git repo, so local git should work. - const ciKeys = [ - 'GITHUB_ACTIONS', 'GITLAB_CI', 'JENKINS_URL', - 'CIRCLECI', 'TRAVIS', 'TF_BUILD', 'BITBUCKET_PIPELINE_UUID', - ]; - const saved: Record = {}; - for (const key of ciKeys) { - saved[key] = process.env[key]; - delete process.env[key]; - } - - try { + withCIEnv({}, () => { const info = getGitInfo(); expect(typeof info.branch).toBe('string'); expect(typeof info.commitSha).toBe('string'); expect(info.commitSha).toHaveLength(40); - } finally { - for (const key of ciKeys) { - if (saved[key] !== undefined) { - process.env[key] = saved[key]; - } - } - } + }); }); test('getGitInfo returns empty object when not in a git repo and no CI env vars', () => { From dbc3ff0739cc88c6b8d0bfbe71dabce06b775eaa Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 26 May 2026 10:47:03 +0200 Subject: [PATCH 15/17] feat(config): make uploadReport default to on instead of requiring opt-in --- packages/mobilewright/src/config.test.ts | 14 +++++++++++++ packages/mobilewright/src/config.ts | 4 ++-- .../src/reporters/mobilenext-upload.test.ts | 20 +++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/mobilewright/src/config.test.ts b/packages/mobilewright/src/config.test.ts index c157158..bd2b44e 100644 --- a/packages/mobilewright/src/config.test.ts +++ b/packages/mobilewright/src/config.test.ts @@ -53,6 +53,20 @@ test('toArray returns the array unchanged when already an array', () => { expect(toArray(['app.apk', 'other.apk'])).toEqual(['app.apk', 'other.apk']); }); +test('defineConfig injects upload reporter by default when testResult is set without uploadReport', () => { + const config = defineConfig({ + driver: { + type: 'mobilenext', + apiKey: 'test-key', + testResult: {}, + }, + }); + const reporters = config.reporter as Array<[string, unknown]>; + expect(Array.isArray(reporters)).toBe(true); + const paths = reporters.map((r) => r[0]); + expect(paths.some((p) => String(p).includes('mobilenext-upload'))).toBe(true); +}); + test('defineConfig injects upload reporter when mobilenext driver has uploadReport on', () => { const config = defineConfig({ driver: { diff --git a/packages/mobilewright/src/config.ts b/packages/mobilewright/src/config.ts index c22e586..545d67d 100644 --- a/packages/mobilewright/src/config.ts +++ b/packages/mobilewright/src/config.ts @@ -149,8 +149,8 @@ function injectUploadReporter(config: MobilewrightConfig): MobilewrightConfig { return config; } const mobileNextDriver = driver as DriverConfigMobileNext; - const uploadReport = mobileNextDriver.testResult?.uploadReport; - if (!uploadReport || uploadReport === 'off') { + const testResult = mobileNextDriver.testResult; + if (!testResult || testResult.uploadReport === 'off') { return config; } diff --git a/packages/mobilewright/src/reporters/mobilenext-upload.test.ts b/packages/mobilewright/src/reporters/mobilenext-upload.test.ts index 3587f04..73f503d 100644 --- a/packages/mobilewright/src/reporters/mobilenext-upload.test.ts +++ b/packages/mobilewright/src/reporters/mobilenext-upload.test.ts @@ -70,6 +70,26 @@ test('uploads when uploadReport is on-failure and a test timed out', async () => expect(uploadCalled).toBe(true); }); +test('uploads by default when uploadReport is not set', async () => { + let uploadCalled = false; + const spyUpload = async (_params: UploadTestResultParams) => { + uploadCalled = true; + return { url: 'file:///tmp/fake' }; + }; + + const reporter = new MobileNextUploadReporter({ + apiKey: 'key', + jsonResultsPath: '/tmp/results.json', + outputDir: '/tmp/test-results', + testResult: {}, + _uploadFn: spyUpload, + }); + + reporter.onBegin({} as FullConfig, suiteWithTests(1)); + await reporter.onEnd({ status: 'passed' } as FullResult); + expect(uploadCalled).toBe(true); +}); + test('always uploads when uploadReport is on regardless of test outcomes', async () => { let uploadCalled = false; const spyUpload = async (_params: UploadTestResultParams) => { From f19ba9d91403286a83dcb95d80a4dfbc5d281f44 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 26 May 2026 11:36:55 +0200 Subject: [PATCH 16/17] fix(git-info): use execFileSync to avoid shell injection risk --- packages/driver-mobilenext/src/git-info.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/driver-mobilenext/src/git-info.ts b/packages/driver-mobilenext/src/git-info.ts index 94537c6..baa90a9 100644 --- a/packages/driver-mobilenext/src/git-info.ts +++ b/packages/driver-mobilenext/src/git-info.ts @@ -1,4 +1,4 @@ -import { execSync } from 'node:child_process'; +import { execFileSync } from 'node:child_process'; export interface GitInfo { repoUrl?: string; @@ -10,11 +10,10 @@ export interface GitInfo { function runGit(args: string[]): string | undefined { try { - const result = execSync(['git', ...args].join(' '), { + return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], - }).trim(); - return result || undefined; + }).trim() || undefined; } catch { return undefined; } From 12ebb4927225fbe767132812428bbd8386ae84f9 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 26 May 2026 15:25:50 +0200 Subject: [PATCH 17/17] updates --- .../src/upload-client.test.ts | 134 +++++++----------- .../driver-mobilenext/src/upload-client.ts | 132 ++++++++--------- packages/mobilewright/src/config.ts | 4 +- .../src/reporters/mobilenext-upload.test.ts | 69 ++++++--- .../src/reporters/mobilenext-upload.ts | 21 ++- 5 files changed, 186 insertions(+), 174 deletions(-) diff --git a/packages/driver-mobilenext/src/upload-client.test.ts b/packages/driver-mobilenext/src/upload-client.test.ts index c8fa47e..2b0be36 100644 --- a/packages/driver-mobilenext/src/upload-client.test.ts +++ b/packages/driver-mobilenext/src/upload-client.test.ts @@ -1,7 +1,4 @@ import { test, expect } from '@playwright/test'; -import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; import { uploadTestResult } from './upload-client.js'; type FetchCall = { url: string; method: string; headers: Record; body: unknown }; @@ -30,15 +27,12 @@ function makeMockFetch(testResultId: string) { } test('sends POST to test-results endpoint with apiKey, name, and userAgent', async () => { - const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); - const jsonPath = join(workDir, 'results.json'); - writeFileSync(jsonPath, '{"tests":[]}'); const { mockFetch, calls } = makeMockFetch('result-abc'); await uploadTestResult({ apiKey: 'mob_test_key', - jsonResultsPath: jsonPath, - outputDir: join(workDir, 'artifacts'), + report: { tests: [] }, + userAgent: 'mobilewright/1.2.3', _fetchFn: mockFetch, }); @@ -46,23 +40,18 @@ test('sends POST to test-results endpoint with apiKey, name, and userAgent', asy expect(createCall?.method).toBe('POST'); const body = JSON.parse(createCall?.body as string); expect(body.name).toBe('Test Run'); - expect(body.userAgent).toMatch(/^mobilewright\//); + expect(body.userAgent).toBe('mobilewright/1.2.3'); expect(createCall?.headers['Authorization']).toBe('Bearer mob_test_key'); expect(createCall?.headers['Content-Type']).toBe('application/json'); - - rmSync(workDir, { recursive: true }); }); test('uses provided name in the create test result request', async () => { - const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); - const jsonPath = join(workDir, 'results.json'); - writeFileSync(jsonPath, '{}'); const { mockFetch, calls } = makeMockFetch('result-abc'); await uploadTestResult({ apiKey: 'mob_key', - jsonResultsPath: jsonPath, - outputDir: join(workDir, 'artifacts'), + report: {}, + userAgent: 'mobilewright/test', name: 'Nightly Suite', _fetchFn: mockFetch, }); @@ -70,20 +59,15 @@ test('uses provided name in the create test result request', async () => { const createCall = calls.find(c => c.url === 'https://api.mobilenext.ai/api/v1/test-results'); const body = JSON.parse(createCall?.body as string); expect(body.name).toBe('Nightly Suite'); - - rmSync(workDir, { recursive: true }); }); test('uploads report.json as multipart FormData to the asset endpoint', async () => { - const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); - const jsonPath = join(workDir, 'results.json'); - writeFileSync(jsonPath, '{"tests":[]}'); const { mockFetch, calls } = makeMockFetch('result-abc'); await uploadTestResult({ apiKey: 'mob_test_key', - jsonResultsPath: jsonPath, - outputDir: join(workDir, 'artifacts'), + report: { tests: [] }, + userAgent: 'mobilewright/test', _fetchFn: mockFetch, }); @@ -92,53 +76,69 @@ test('uploads report.json as multipart FormData to the asset endpoint', async () expect(assetCall?.method).toBe('POST'); expect(assetCall?.body).toBeInstanceOf(FormData); expect(assetCall?.headers['Authorization']).toBe('Bearer mob_test_key'); - - rmSync(workDir, { recursive: true }); }); test('returns the dashboard URL for the created test result', async () => { - const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); - const jsonPath = join(workDir, 'results.json'); - writeFileSync(jsonPath, '{}'); const { mockFetch } = makeMockFetch('my-test-id-123'); const result = await uploadTestResult({ apiKey: 'mob_key', - jsonResultsPath: jsonPath, - outputDir: join(workDir, 'artifacts'), + report: {}, + userAgent: 'mobilewright/test', _fetchFn: mockFetch, }); expect(result.url).toBe('https://app.mobilenext.ai/dashboard/test-results/my-test-id-123'); +}); - rmSync(workDir, { recursive: true }); +test('includes git metadata in the create request when gitInfo is provided', async () => { + const { mockFetch, calls } = makeMockFetch('result-abc'); + + await uploadTestResult({ + apiKey: 'mob_key', + report: {}, + userAgent: 'mobilewright/test', + gitInfo: { branch: 'main', commitSha: 'abc123', authorName: 'alice' }, + _fetchFn: mockFetch, + }); + + const createCall = calls.find(c => c.url.endsWith('/test-results')); + const body = JSON.parse(createCall?.body as string); + expect(body.git.branch).toBe('main'); + expect(body.git.commitSha).toBe('abc123'); + expect(body.git.authorName).toBe('alice'); }); -test('throws when create test result API returns a non-2xx status', async () => { - const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); - const jsonPath = join(workDir, 'results.json'); - writeFileSync(jsonPath, '{}'); +test('omits git field when gitInfo is undefined', async () => { + const { mockFetch, calls } = makeMockFetch('result-abc'); + + await uploadTestResult({ + apiKey: 'mob_key', + report: {}, + userAgent: 'mobilewright/test', + _fetchFn: mockFetch, + }); + const createCall = calls.find(c => c.url.endsWith('/test-results')); + const body = JSON.parse(createCall?.body as string); + expect(body.git).toBeUndefined(); +}); + +test('throws when create test result API returns a non-2xx status', async () => { const failingFetch = async (_url: string | URL | Request, _init?: RequestInit): Promise => new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }); await expect( uploadTestResult({ apiKey: 'bad-key', - jsonResultsPath: jsonPath, - outputDir: join(workDir, 'artifacts'), + report: {}, + userAgent: 'mobilewright/test', _fetchFn: failingFetch as unknown as typeof fetch, }), ).rejects.toThrow('401'); - - rmSync(workDir, { recursive: true }); }); test('throws when asset upload API returns a non-2xx status', async () => { - const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); - const jsonPath = join(workDir, 'results.json'); - writeFileSync(jsonPath, '{}'); - const mockFetch = async (url: string | URL | Request, _init?: RequestInit): Promise => { if (String(url).endsWith('/test-results')) { return new Response( @@ -152,25 +152,20 @@ test('throws when asset upload API returns a non-2xx status', async () => { await expect( uploadTestResult({ apiKey: 'mob_key', - jsonResultsPath: jsonPath, - outputDir: join(workDir, 'artifacts'), + report: {}, + userAgent: 'mobilewright/test', _fetchFn: mockFetch as unknown as typeof fetch, }), ).rejects.toThrow('500'); - - rmSync(workDir, { recursive: true }); }); test('uploads inline attachment bodies as separate assets before report.json', async () => { - const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); - const jsonPath = join(workDir, 'results.json'); const pngBase64 = Buffer.from('fake-png-data').toString('base64'); const report = { suites: [{ specs: [{ tests: [{ results: [{ attachments: [ { name: 'screenshot', contentType: 'image/png', body: pngBase64 }, ] }] }] }] }], }; - writeFileSync(jsonPath, JSON.stringify(report)); let assetCallCount = 0; const mockFetch = async (url: string | URL | Request, init?: RequestInit): Promise => { @@ -190,27 +185,22 @@ test('uploads inline attachment bodies as separate assets before report.json', a await uploadTestResult({ apiKey: 'mob_key', - jsonResultsPath: jsonPath, - outputDir: join(workDir, 'artifacts'), + report, + userAgent: 'mobilewright/test', _fetchFn: mockFetch as unknown as typeof fetch, }); // 2 asset calls: one for the PNG attachment, one for report.json expect(assetCallCount).toBe(2); - - rmSync(workDir, { recursive: true }); }); test('removes body and sets assetId in the uploaded report.json', async () => { - const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); - const jsonPath = join(workDir, 'results.json'); const pngBase64 = Buffer.from('fake-png-data').toString('base64'); const report = { suites: [{ specs: [{ tests: [{ results: [{ attachments: [ { name: 'screenshot', contentType: 'image/png', body: pngBase64 }, ] }] }] }] }], }; - writeFileSync(jsonPath, JSON.stringify(report)); let assetCallCount = 0; let capturedReportForm: FormData | undefined; @@ -234,8 +224,8 @@ test('removes body and sets assetId in the uploaded report.json', async () => { await uploadTestResult({ apiKey: 'mob_key', - jsonResultsPath: jsonPath, - outputDir: join(workDir, 'artifacts'), + report, + userAgent: 'mobilewright/test', _fetchFn: mockFetch as unknown as typeof fetch, }); @@ -244,45 +234,33 @@ test('removes body and sets assetId in the uploaded report.json', async () => { const att = parsedReport.suites[0].specs[0].tests[0].results[0].attachments[0] as Record; expect(att['body']).toBeUndefined(); expect(att['assetId']).toBe('asset-1'); - - rmSync(workDir, { recursive: true }); }); -test('does not modify the original json file on disk', async () => { - const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); - const jsonPath = join(workDir, 'results.json'); +test('does not modify the caller\'s report object', async () => { const pngBase64 = Buffer.from('fake-png-data').toString('base64'); const report = { suites: [{ specs: [{ tests: [{ results: [{ attachments: [ { name: 'screenshot', contentType: 'image/png', body: pngBase64 }, ] }] }] }] }], }; - const originalJson = JSON.stringify(report); - writeFileSync(jsonPath, originalJson); const { mockFetch } = makeMockFetch('result-abc'); await uploadTestResult({ apiKey: 'mob_key', - jsonResultsPath: jsonPath, - outputDir: join(workDir, 'artifacts'), + report, + userAgent: 'mobilewright/test', _fetchFn: mockFetch, }); - const { readFileSync: read } = await import('node:fs'); - expect(read(jsonPath, 'utf8')).toBe(originalJson); - - rmSync(workDir, { recursive: true }); + expect(report.suites[0].specs[0].tests[0].results[0].attachments[0].body).toBe(pngBase64); }); test('leaves path-based attachments unchanged', async () => { - const workDir = mkdtempSync(join(tmpdir(), 'mw-upload-test-')); - const jsonPath = join(workDir, 'results.json'); const report = { suites: [{ specs: [{ tests: [{ results: [{ attachments: [ { name: 'video', contentType: 'video/mp4', path: '/some/path/video.mp4' }, ] }] }] }] }], }; - writeFileSync(jsonPath, JSON.stringify(report)); let assetCallCount = 0; const mockFetch = async (url: string | URL | Request, _init?: RequestInit): Promise => { @@ -301,13 +279,11 @@ test('leaves path-based attachments unchanged', async () => { await uploadTestResult({ apiKey: 'mob_key', - jsonResultsPath: jsonPath, - outputDir: join(workDir, 'artifacts'), + report, + userAgent: 'mobilewright/test', _fetchFn: mockFetch as unknown as typeof fetch, }); // Only 1 asset call: just report.json (no upload for path-based attachments) expect(assetCallCount).toBe(1); - - rmSync(workDir, { recursive: true }); }); diff --git a/packages/driver-mobilenext/src/upload-client.ts b/packages/driver-mobilenext/src/upload-client.ts index 00192b8..438d2b5 100644 --- a/packages/driver-mobilenext/src/upload-client.ts +++ b/packages/driver-mobilenext/src/upload-client.ts @@ -1,10 +1,7 @@ -import { readFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; import { randomUUID } from 'node:crypto'; import createDebug from 'debug'; -import { getGitInfo } from './git-info.js'; +import type { GitInfo } from './git-info.js'; -const _require = createRequire(import.meta.url); const debug = createDebug('mw:reporter:upload'); const BASE_URL = 'https://api.mobilenext.ai'; @@ -12,8 +9,9 @@ const DASHBOARD_BASE_URL = 'https://app.mobilenext.ai'; export interface UploadTestResultParams { apiKey: string; - jsonResultsPath: string; - outputDir: string; + report: Record; + userAgent: string; + gitInfo?: GitInfo; name?: string; tags?: string[]; environment?: string; @@ -35,75 +33,70 @@ interface AssetResponse { createdAt: string; } +const CONTENT_TYPE_EXTENSIONS: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'image/gif': 'gif', +}; + function extensionForContentType(contentType: string): string { - const extensions: Record = { - 'image/png': 'png', - 'image/jpeg': 'jpg', - 'image/webp': 'webp', - 'image/gif': 'gif', - }; - return extensions[contentType] ?? 'bin'; + return CONTENT_TYPE_EXTENSIONS[contentType] ?? 'bin'; } -async function uploadAttachmentBodies( - obj: unknown, - testResultId: string, - apiKey: string, - fetchFn: typeof fetch, -): Promise { - if (!obj || typeof obj !== 'object') { return; } - if (Array.isArray(obj)) { - for (const item of obj) { - await uploadAttachmentBodies(item, testResultId, apiKey, fetchFn); +function makeAttachmentUploader(testResultId: string, apiKey: string, fetchFn: typeof fetch) { + async function uploadAndReplace(obj: unknown): Promise { + if (!obj || typeof obj !== 'object') { return; } + if (Array.isArray(obj)) { + for (const item of obj) { + await uploadAndReplace(item); + } + return; } - return; - } - const record = obj as Record; - if (Array.isArray(record['attachments'])) { - for (const att of record['attachments'] as Record[]) { - if (typeof att['body'] === 'string') { - const contentType = typeof att['contentType'] === 'string' ? att['contentType'] : 'application/octet-stream'; - const ext = extensionForContentType(contentType); - const assetName = `${randomUUID()}.${ext}`; - const buffer = Buffer.from(att['body'], 'base64'); - const sizeKB = (buffer.length / 1024).toFixed(1); - debug('uploading attachment name=%s contentType=%s size=%skB as %s', att['name'], contentType, sizeKB, assetName); - - const form = new FormData(); - form.append('name', assetName); - form.append('file', new Blob([buffer], { type: contentType }), assetName); - - const res = await fetchFn(`${BASE_URL}/api/v1/test-results/${testResultId}/assets`, { - method: 'POST', - headers: { 'Authorization': `Bearer ${apiKey}` }, - body: form, - }); - - if (!res.ok) { - throw new Error(`Failed to upload attachment "${att['name'] as string}": ${res.status}`); + const record = obj as Record; + if (Array.isArray(record['attachments'])) { + for (const att of record['attachments'] as Record[]) { + if (typeof att['body'] === 'string') { + const contentType = typeof att['contentType'] === 'string' ? att['contentType'] : 'application/octet-stream'; + const ext = extensionForContentType(contentType); + const assetName = `${randomUUID()}.${ext}`; + const buffer = Buffer.from(att['body'], 'base64'); + const sizeKB = (buffer.length / 1024).toFixed(1); + debug('uploading attachment name=%s contentType=%s size=%skB as %s', att['name'], contentType, sizeKB, assetName); + + const form = new FormData(); + form.append('name', assetName); + form.append('file', new Blob([buffer], { type: contentType }), assetName); + + const res = await fetchFn(`${BASE_URL}/api/v1/test-results/${testResultId}/assets`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${apiKey}` }, + body: form, + }); + + if (!res.ok) { + throw new Error(`Failed to upload attachment "${att['name'] as string}": ${res.status}`); + } + + const asset = await res.json() as AssetResponse; + delete att['body']; + att['assetId'] = asset.id; + debug('attachment uploaded assetId=%s', asset.id); } - - const asset = await res.json() as AssetResponse; - delete att['body']; - att['assetId'] = asset.id; - debug('attachment uploaded assetId=%s', asset.id); } } + for (const value of Object.values(record)) { + await uploadAndReplace(value); + } } - for (const value of Object.values(record)) { - await uploadAttachmentBodies(value, testResultId, apiKey, fetchFn); - } + return uploadAndReplace; } export async function uploadTestResult(params: UploadTestResultParams): Promise<{ url: string }> { const fetchFn = params._fetchFn ?? fetch; - const pkg = _require('../package.json') as { version: string }; - const userAgent = `mobilewright/${pkg.version}`; + const hasGitInfo = params.gitInfo !== undefined && Object.values(params.gitInfo).some(v => v !== undefined); - const gitInfo = getGitInfo(); - const hasGitInfo = Object.values(gitInfo).some(v => v !== undefined); - - debug('creating test result name=%s userAgent=%s', params.name ?? 'Test Run', userAgent); + debug('creating test result name=%s userAgent=%s', params.name ?? 'Test Run', params.userAgent); const createRes = await fetchFn(`${BASE_URL}/api/v1/test-results`, { method: 'POST', headers: { @@ -112,8 +105,8 @@ export async function uploadTestResult(params: UploadTestResultParams): Promise< }, body: JSON.stringify({ name: params.name ?? 'Test Run', - userAgent, - ...(hasGitInfo ? { git: gitInfo } : {}), + userAgent: params.userAgent, + ...(hasGitInfo ? { git: params.gitInfo } : {}), }), }); @@ -124,16 +117,15 @@ export async function uploadTestResult(params: UploadTestResultParams): Promise< const testResult = await createRes.json() as TestResultResponse; debug('test result created id=%s', testResult.id); - // Parse into a fresh in-memory copy — original file on disk is never modified - const rawJson = readFileSync(params.jsonResultsPath); - const report = JSON.parse(rawJson.toString()) as Record; - - await uploadAttachmentBodies(report, testResult.id, params.apiKey, fetchFn); + // Deep-clone so attachment body replacement does not mutate the caller's object + const report = JSON.parse(JSON.stringify(params.report)) as Record; + const uploadAndReplace = makeAttachmentUploader(testResult.id, params.apiKey, fetchFn); + await uploadAndReplace(report); const modifiedJson = JSON.stringify(report); const modifiedBuffer = Buffer.from(modifiedJson); const fileSizeKB = (modifiedBuffer.length / 1024).toFixed(1); - debug('uploading report.json size=%skB path=%s', fileSizeKB, params.jsonResultsPath); + debug('uploading report.json size=%skB', fileSizeKB); const form = new FormData(); form.append('name', 'report.json'); diff --git a/packages/mobilewright/src/config.ts b/packages/mobilewright/src/config.ts index 545d67d..eec5e51 100644 --- a/packages/mobilewright/src/config.ts +++ b/packages/mobilewright/src/config.ts @@ -3,7 +3,6 @@ import { isAbsolute, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { createRequire } from 'node:module'; import os from 'node:os'; -import path from 'node:path'; import { randomUUID } from 'node:crypto'; const _require = createRequire(import.meta.url); @@ -154,7 +153,7 @@ function injectUploadReporter(config: MobilewrightConfig): MobilewrightConfig { return config; } - const jsonResultsPath = path.join( + const jsonResultsPath = join( os.tmpdir(), `mobilewright-results-${randomUUID()}.json`, ); @@ -169,7 +168,6 @@ function injectUploadReporter(config: MobilewrightConfig): MobilewrightConfig { [uploadReporterPath, { apiKey: mobileNextDriver.apiKey ?? '', jsonResultsPath, - outputDir: config.outputDir ?? 'test-results', testResult: mobileNextDriver.testResult, }], ], diff --git a/packages/mobilewright/src/reporters/mobilenext-upload.test.ts b/packages/mobilewright/src/reporters/mobilenext-upload.test.ts index 73f503d..80924b4 100644 --- a/packages/mobilewright/src/reporters/mobilenext-upload.test.ts +++ b/packages/mobilewright/src/reporters/mobilenext-upload.test.ts @@ -1,4 +1,7 @@ import { test, expect } from '@playwright/test'; +import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import type { TestResult, FullResult, FullConfig, Suite } from '@playwright/test/reporter'; import MobileNextUploadReporter from './mobilenext-upload.js'; import type { UploadTestResultParams } from '@mobilewright/driver-mobilenext'; @@ -7,6 +10,13 @@ function suiteWithTests(count: number): Suite { return { allTests: () => new Array(count).fill({}) } as unknown as Suite; } +function makeTempResultsFile(content: string = '{}'): { path: string; cleanup: () => void } { + const dir = mkdtempSync(join(tmpdir(), 'mw-reporter-test-')); + const filePath = join(dir, 'results.json'); + writeFileSync(filePath, content); + return { path: filePath, cleanup: () => rmSync(dir, { recursive: true }) }; +} + test('does not upload when uploadReport is on-failure and no tests failed', async () => { let uploadCalled = false; const spyUpload = async (_params: UploadTestResultParams) => { @@ -17,7 +27,6 @@ test('does not upload when uploadReport is on-failure and no tests failed', asyn const reporter = new MobileNextUploadReporter({ apiKey: 'key', jsonResultsPath: '/tmp/results.json', - outputDir: '/tmp/test-results', testResult: { uploadReport: 'on-failure' }, _uploadFn: spyUpload, }); @@ -29,6 +38,7 @@ test('does not upload when uploadReport is on-failure and no tests failed', asyn }); test('uploads when uploadReport is on-failure and a test failed', async () => { + const { path, cleanup } = makeTempResultsFile(); let uploadCalled = false; const spyUpload = async (_params: UploadTestResultParams) => { uploadCalled = true; @@ -37,8 +47,7 @@ test('uploads when uploadReport is on-failure and a test failed', async () => { const reporter = new MobileNextUploadReporter({ apiKey: 'key', - jsonResultsPath: '/tmp/results.json', - outputDir: '/tmp/test-results', + jsonResultsPath: path, testResult: { uploadReport: 'on-failure' }, _uploadFn: spyUpload, }); @@ -47,9 +56,11 @@ test('uploads when uploadReport is on-failure and a test failed', async () => { reporter.onTestEnd({} as never, { status: 'failed' } as TestResult); await reporter.onEnd({ status: 'failed' } as FullResult); expect(uploadCalled).toBe(true); + cleanup(); }); test('uploads when uploadReport is on-failure and a test timed out', async () => { + const { path, cleanup } = makeTempResultsFile(); let uploadCalled = false; const spyUpload = async (_params: UploadTestResultParams) => { uploadCalled = true; @@ -58,8 +69,7 @@ test('uploads when uploadReport is on-failure and a test timed out', async () => const reporter = new MobileNextUploadReporter({ apiKey: 'key', - jsonResultsPath: '/tmp/results.json', - outputDir: '/tmp/test-results', + jsonResultsPath: path, testResult: { uploadReport: 'on-failure' }, _uploadFn: spyUpload, }); @@ -68,9 +78,11 @@ test('uploads when uploadReport is on-failure and a test timed out', async () => reporter.onTestEnd({} as never, { status: 'timedOut' } as TestResult); await reporter.onEnd({ status: 'failed' } as FullResult); expect(uploadCalled).toBe(true); + cleanup(); }); test('uploads by default when uploadReport is not set', async () => { + const { path, cleanup } = makeTempResultsFile(); let uploadCalled = false; const spyUpload = async (_params: UploadTestResultParams) => { uploadCalled = true; @@ -79,8 +91,7 @@ test('uploads by default when uploadReport is not set', async () => { const reporter = new MobileNextUploadReporter({ apiKey: 'key', - jsonResultsPath: '/tmp/results.json', - outputDir: '/tmp/test-results', + jsonResultsPath: path, testResult: {}, _uploadFn: spyUpload, }); @@ -88,9 +99,11 @@ test('uploads by default when uploadReport is not set', async () => { reporter.onBegin({} as FullConfig, suiteWithTests(1)); await reporter.onEnd({ status: 'passed' } as FullResult); expect(uploadCalled).toBe(true); + cleanup(); }); test('always uploads when uploadReport is on regardless of test outcomes', async () => { + const { path, cleanup } = makeTempResultsFile(); let uploadCalled = false; const spyUpload = async (_params: UploadTestResultParams) => { uploadCalled = true; @@ -99,8 +112,7 @@ test('always uploads when uploadReport is on regardless of test outcomes', async const reporter = new MobileNextUploadReporter({ apiKey: 'key', - jsonResultsPath: '/tmp/results.json', - outputDir: '/tmp/test-results', + jsonResultsPath: path, testResult: { uploadReport: 'on' }, _uploadFn: spyUpload, }); @@ -108,6 +120,26 @@ test('always uploads when uploadReport is on regardless of test outcomes', async reporter.onBegin({} as FullConfig, suiteWithTests(1)); await reporter.onEnd({ status: 'passed' } as FullResult); expect(uploadCalled).toBe(true); + cleanup(); +}); + +test('does not upload when uploadReport is off', async () => { + let uploadCalled = false; + const spyUpload = async (_params: UploadTestResultParams) => { + uploadCalled = true; + return { url: 'file:///tmp/fake' }; + }; + + const reporter = new MobileNextUploadReporter({ + apiKey: 'key', + jsonResultsPath: '/tmp/results.json', + testResult: { uploadReport: 'off' }, + _uploadFn: spyUpload, + }); + + reporter.onBegin({} as FullConfig, suiteWithTests(1)); + await reporter.onEnd({ status: 'passed' } as FullResult); + expect(uploadCalled).toBe(false); }); test('does not upload when no tests were collected', async () => { @@ -120,7 +152,6 @@ test('does not upload when no tests were collected', async () => { const reporter = new MobileNextUploadReporter({ apiKey: 'key', jsonResultsPath: '/tmp/results.json', - outputDir: '/tmp/test-results', testResult: { uploadReport: 'on' }, _uploadFn: spyUpload, }); @@ -140,7 +171,6 @@ test('does not upload when onBegin was never called', async () => { const reporter = new MobileNextUploadReporter({ apiKey: 'key', jsonResultsPath: '/tmp/results.json', - outputDir: '/tmp/test-results', testResult: { uploadReport: 'on' }, _uploadFn: spyUpload, }); @@ -149,7 +179,8 @@ test('does not upload when onBegin was never called', async () => { expect(uploadCalled).toBe(false); }); -test('passes apiKey, name, tags, environment and paths to upload function', async () => { +test('passes apiKey, name, tags, environment, report, and userAgent to upload function', async () => { + const { path, cleanup } = makeTempResultsFile('{"suites":[]}'); let capturedParams: UploadTestResultParams | undefined; const spyUpload = async (params: UploadTestResultParams) => { capturedParams = params; @@ -158,8 +189,7 @@ test('passes apiKey, name, tags, environment and paths to upload function', asyn const reporter = new MobileNextUploadReporter({ apiKey: 'my-secret-key', - jsonResultsPath: '/tmp/r.json', - outputDir: '/tmp/artifacts', + jsonResultsPath: path, testResult: { uploadReport: 'on', name: 'Nightly Suite', @@ -173,26 +203,29 @@ test('passes apiKey, name, tags, environment and paths to upload function', asyn await reporter.onEnd({ status: 'passed' } as FullResult); expect(capturedParams?.apiKey).toBe('my-secret-key'); - expect(capturedParams?.jsonResultsPath).toBe('/tmp/r.json'); - expect(capturedParams?.outputDir).toBe('/tmp/artifacts'); expect(capturedParams?.name).toBe('Nightly Suite'); expect(capturedParams?.tags).toEqual(['ci', 'nightly']); expect(capturedParams?.environment).toBe('staging'); + expect(capturedParams?.userAgent).toMatch(/^mobilewright\//); + expect(capturedParams?.report).toEqual({ suites: [] }); + expect(typeof capturedParams?.gitInfo).toBe('object'); + cleanup(); }); test('does not throw when upload function rejects', async () => { + const { path, cleanup } = makeTempResultsFile(); const failingUpload = async (_params: UploadTestResultParams): Promise<{ url: string }> => { throw new Error('network error'); }; const reporter = new MobileNextUploadReporter({ apiKey: 'key', - jsonResultsPath: '/tmp/results.json', - outputDir: '/tmp/test-results', + jsonResultsPath: path, testResult: { uploadReport: 'on' }, _uploadFn: failingUpload, }); reporter.onBegin({} as FullConfig, suiteWithTests(1)); await expect(reporter.onEnd({ status: 'passed' } as FullResult)).resolves.not.toThrow(); + cleanup(); }); diff --git a/packages/mobilewright/src/reporters/mobilenext-upload.ts b/packages/mobilewright/src/reporters/mobilenext-upload.ts index 42a71b9..7315b66 100644 --- a/packages/mobilewright/src/reporters/mobilenext-upload.ts +++ b/packages/mobilewright/src/reporters/mobilenext-upload.ts @@ -1,13 +1,16 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import type { Reporter, TestCase, TestResult, FullResult, FullConfig, Suite } from '@playwright/test/reporter'; import type { MobileNextTestResultConfig } from '../config.js'; -import { uploadTestResult, type UploadTestResultParams } from '@mobilewright/driver-mobilenext'; +import { uploadTestResult, getGitInfo, type UploadTestResultParams } from '@mobilewright/driver-mobilenext'; + +const _require = createRequire(import.meta.url); type UploadFn = (params: UploadTestResultParams) => Promise<{ url: string }>; interface MobileNextUploadReporterOptions { apiKey: string; jsonResultsPath: string; - outputDir: string; testResult: MobileNextTestResultConfig; _uploadFn?: UploadFn; } @@ -36,17 +39,27 @@ export default class MobileNextUploadReporter implements Reporter { return; } const { uploadReport } = this.options.testResult; + if (uploadReport === 'off') { + return; + } if (uploadReport === 'on-failure' && !this.hasFailed) { return; } const upload = this.options._uploadFn ?? uploadTestResult; + const pkg = _require('../../package.json') as { version: string }; + const userAgent = `mobilewright/${pkg.version}`; + const rawContent = readFileSync(this.options.jsonResultsPath, 'utf8'); + const report = JSON.parse(rawContent) as Record; + const gitInfo = getGitInfo(); + try { const uploadResult = await upload({ apiKey: this.options.apiKey, - jsonResultsPath: this.options.jsonResultsPath, - outputDir: this.options.outputDir, + report, + userAgent, + gitInfo, name: this.options.testResult.name, tags: this.options.testResult.tags, environment: this.options.testResult.environment,