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..5f241bd --- /dev/null +++ b/packages/driver-mobilenext/src/git-info.test.ts @@ -0,0 +1,115 @@ +import { test, expect } from '@playwright/test'; +import { getGitInfo, normalizeRepoUrl } from './git-info.js'; + +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 = {}; + const keysToManage = [...new Set([...ALL_CI_KEYS, ...Object.keys(vars)])]; + for (const key of keysToManage) { + saved[key] = process.env[key]; + delete process.env[key]; + } + for (const [key, value] of Object.entries(vars)) { + process.env[key] = value; + } + try { + fn(); + } finally { + for (const key of keysToManage) { + 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', () => { + withCIEnv({ + 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', () => { + withCIEnv({ + 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', () => { + withCIEnv({ + 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. + withCIEnv({}, () => { + const info = getGitInfo(); + expect(typeof info.branch).toBe('string'); + expect(typeof info.commitSha).toBe('string'); + expect(info.commitSha).toHaveLength(40); + }); +}); + +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..baa90a9 --- /dev/null +++ b/packages/driver-mobilenext/src/git-info.ts @@ -0,0 +1,158 @@ +import { execFileSync } from 'node:child_process'; + +export interface GitInfo { + repoUrl?: string; + branch?: string; + commitSha?: string; + authorName?: string; + commitMessage?: string; +} + +function runGit(args: string[]): string | undefined { + try { + return execFileSync('git', args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim() || 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 6aa9df8..b052d56 100644 --- a/packages/driver-mobilenext/src/index.ts +++ b/packages/driver-mobilenext/src/index.ts @@ -1,2 +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.test.ts b/packages/driver-mobilenext/src/upload-client.test.ts new file mode 100644 index 0000000..2b0be36 --- /dev/null +++ b/packages/driver-mobilenext/src/upload-client.test.ts @@ -0,0 +1,289 @@ +import { test, expect } from '@playwright/test'; +import { uploadTestResult } from './upload-client.js'; + +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: 'report.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 { mockFetch, calls } = makeMockFetch('result-abc'); + + await uploadTestResult({ + apiKey: 'mob_test_key', + report: { tests: [] }, + userAgent: 'mobilewright/1.2.3', + _fetchFn: mockFetch, + }); + + 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).toBe('mobilewright/1.2.3'); + expect(createCall?.headers['Authorization']).toBe('Bearer mob_test_key'); + expect(createCall?.headers['Content-Type']).toBe('application/json'); +}); + +test('uses provided name in the create test result request', async () => { + const { mockFetch, calls } = makeMockFetch('result-abc'); + + await uploadTestResult({ + apiKey: 'mob_key', + report: {}, + userAgent: 'mobilewright/test', + 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'); +}); + +test('uploads report.json as multipart FormData to the asset endpoint', async () => { + const { mockFetch, calls } = makeMockFetch('result-abc'); + + await uploadTestResult({ + apiKey: 'mob_test_key', + report: { tests: [] }, + userAgent: 'mobilewright/test', + _fetchFn: mockFetch, + }); + + 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'); +}); + +test('returns the dashboard URL for the created test result', async () => { + const { mockFetch } = makeMockFetch('my-test-id-123'); + + const result = await uploadTestResult({ + apiKey: 'mob_key', + report: {}, + userAgent: 'mobilewright/test', + _fetchFn: mockFetch, + }); + + expect(result.url).toBe('https://app.mobilenext.ai/dashboard/test-results/my-test-id-123'); +}); + +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('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', + report: {}, + userAgent: 'mobilewright/test', + _fetchFn: failingFetch as unknown as typeof fetch, + }), + ).rejects.toThrow('401'); +}); + +test('throws when asset upload API returns a non-2xx status', async () => { + 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', + report: {}, + userAgent: 'mobilewright/test', + _fetchFn: mockFetch as unknown as typeof fetch, + }), + ).rejects.toThrow('500'); +}); + +test('uploads inline attachment bodies as separate assets before report.json', async () => { + const pngBase64 = Buffer.from('fake-png-data').toString('base64'); + const report = { + suites: [{ specs: [{ tests: [{ results: [{ attachments: [ + { name: 'screenshot', contentType: 'image/png', body: pngBase64 }, + ] }] }] }] }], + }; + + 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', + 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); +}); + +test('removes body and sets assetId in the uploaded report.json', async () => { + const pngBase64 = Buffer.from('fake-png-data').toString('base64'); + const report = { + suites: [{ specs: [{ tests: [{ results: [{ attachments: [ + { name: 'screenshot', contentType: 'image/png', body: pngBase64 }, + ] }] }] }] }], + }; + + 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', + report, + userAgent: 'mobilewright/test', + _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'); +}); + +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 { mockFetch } = makeMockFetch('result-abc'); + + await uploadTestResult({ + apiKey: 'mob_key', + report, + userAgent: 'mobilewright/test', + _fetchFn: mockFetch, + }); + + expect(report.suites[0].specs[0].tests[0].results[0].attachments[0].body).toBe(pngBase64); +}); + +test('leaves path-based attachments unchanged', async () => { + const report = { + suites: [{ specs: [{ tests: [{ results: [{ attachments: [ + { name: 'video', contentType: 'video/mp4', path: '/some/path/video.mp4' }, + ] }] }] }] }], + }; + + 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', + 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); +}); diff --git a/packages/driver-mobilenext/src/upload-client.ts b/packages/driver-mobilenext/src/upload-client.ts new file mode 100644 index 0000000..438d2b5 --- /dev/null +++ b/packages/driver-mobilenext/src/upload-client.ts @@ -0,0 +1,150 @@ +import { randomUUID } from 'node:crypto'; +import createDebug from 'debug'; +import type { GitInfo } from './git-info.js'; + +const debug = createDebug('mw:reporter:upload'); + +const BASE_URL = 'https://api.mobilenext.ai'; +const DASHBOARD_BASE_URL = 'https://app.mobilenext.ai'; + +export interface UploadTestResultParams { + apiKey: string; + report: Record; + userAgent: string; + gitInfo?: GitInfo; + name?: string; + tags?: string[]; + environment?: string; + _fetchFn?: typeof fetch; +} + +interface TestResultResponse { + id: string; + name: string; + userAgent: string; + createdAt: string; +} + +interface AssetResponse { + id: string; + name: string; + contentType: string; + size: number; + createdAt: string; +} + +const CONTENT_TYPE_EXTENSIONS: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'image/gif': 'gif', +}; + +function extensionForContentType(contentType: string): string { + return CONTENT_TYPE_EXTENSIONS[contentType] ?? 'bin'; +} + +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; + } + 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 uploadAndReplace(value); + } + } + return uploadAndReplace; +} + +export async function uploadTestResult(params: UploadTestResultParams): Promise<{ url: string }> { + const fetchFn = params._fetchFn ?? fetch; + const hasGitInfo = params.gitInfo !== undefined && Object.values(params.gitInfo).some(v => v !== undefined); + + 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: { + 'Authorization': `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: params.name ?? 'Test Run', + userAgent: params.userAgent, + ...(hasGitInfo ? { git: params.gitInfo } : {}), + }), + }); + + if (!createRes.ok) { + throw new Error(`Failed to create test result: ${createRes.status}`); + } + + const testResult = await createRes.json() as TestResultResponse; + debug('test result created id=%s', testResult.id); + + // 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', fileSizeKB); + + const form = new FormData(); + form.append('name', 'report.json'); + form.append('file', new Blob([modifiedBuffer], { 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 report.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}` }; +} diff --git a/packages/mobilewright/src/config.test.ts b/packages/mobilewright/src/config.test.ts index 3668e9a..bd2b44e 100644 --- a/packages/mobilewright/src/config.test.ts +++ b/packages/mobilewright/src/config.test.ts @@ -52,3 +52,116 @@ 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 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: { + 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: { + 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..eec5e51 100644 --- a/packages/mobilewright/src/config.ts +++ b/packages/mobilewright/src/config.ts @@ -2,6 +2,8 @@ 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 { randomUUID } from 'node:crypto'; const _require = createRequire(import.meta.url); @@ -49,10 +51,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; @@ -120,6 +130,50 @@ 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 testResult = mobileNextDriver.testResult; + if (!testResult || testResult.uploadReport === 'off') { + return config; + } + + const jsonResultsPath = 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, + 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'); @@ -127,12 +181,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 = [ 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..80924b4 --- /dev/null +++ b/packages/mobilewright/src/reporters/mobilenext-upload.test.ts @@ -0,0 +1,231 @@ +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'; + +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) => { + uploadCalled = true; + return { url: 'file:///tmp/fake' }; + }; + + const reporter = new MobileNextUploadReporter({ + apiKey: 'key', + jsonResultsPath: '/tmp/results.json', + testResult: { uploadReport: 'on-failure' }, + _uploadFn: spyUpload, + }); + + reporter.onBegin({} as FullConfig, suiteWithTests(1)); + 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 () => { + const { path, cleanup } = makeTempResultsFile(); + let uploadCalled = false; + const spyUpload = async (_params: UploadTestResultParams) => { + uploadCalled = true; + return { url: 'file:///tmp/fake' }; + }; + + const reporter = new MobileNextUploadReporter({ + apiKey: 'key', + jsonResultsPath: path, + testResult: { uploadReport: 'on-failure' }, + _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); + 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; + return { url: 'file:///tmp/fake' }; + }; + + const reporter = new MobileNextUploadReporter({ + apiKey: 'key', + jsonResultsPath: path, + testResult: { uploadReport: 'on-failure' }, + _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); + 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; + return { url: 'file:///tmp/fake' }; + }; + + const reporter = new MobileNextUploadReporter({ + apiKey: 'key', + jsonResultsPath: path, + testResult: {}, + _uploadFn: spyUpload, + }); + + 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; + return { url: 'file:///tmp/fake' }; + }; + + const reporter = new MobileNextUploadReporter({ + apiKey: 'key', + jsonResultsPath: path, + testResult: { uploadReport: 'on' }, + _uploadFn: spyUpload, + }); + + 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 () => { + 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: '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', + testResult: { uploadReport: 'on' }, + _uploadFn: spyUpload, + }); + + await reporter.onEnd({ status: 'failed' } as FullResult); + expect(uploadCalled).toBe(false); +}); + +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; + return { url: 'file:///tmp/fake' }; + }; + + const reporter = new MobileNextUploadReporter({ + apiKey: 'my-secret-key', + jsonResultsPath: path, + testResult: { + uploadReport: 'on', + name: 'Nightly Suite', + tags: ['ci', 'nightly'], + environment: 'staging', + }, + _uploadFn: spyUpload, + }); + + reporter.onBegin({} as FullConfig, suiteWithTests(1)); + await reporter.onEnd({ status: 'passed' } as FullResult); + + expect(capturedParams?.apiKey).toBe('my-secret-key'); + 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: 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 new file mode 100644 index 0000000..7315b66 --- /dev/null +++ b/packages/mobilewright/src/reporters/mobilenext-upload.ts @@ -0,0 +1,72 @@ +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, 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; + testResult: MobileNextTestResultConfig; + _uploadFn?: UploadFn; +} + +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; + } + } + + async onEnd(_result: FullResult): Promise { + if (!this.hasTests) { + 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, + report, + userAgent, + gitInfo, + 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}`); + } + } +}