Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
50caf31
feat(config): add MobileNextTestResultConfig with uploadReport, name,…
gmegidish May 22, 2026
33a1568
feat(reporters): add uploadTestResult stub that copies artifacts to t…
gmegidish May 22, 2026
c8a3a69
feat(reporters): add MobileNextUploadReporter with on/off/on-failure …
gmegidish May 22, 2026
6c038ed
feat(config): auto-inject MobileNextUploadReporter when mobilenext dr…
gmegidish May 22, 2026
f5dd69a
Merge branch 'main' into feat/test-result-upload
gmegidish May 23, 2026
e33dff0
test(reporters): rewrite upload-client tests for real API calls
gmegidish May 24, 2026
23ab1f0
feat(reporters): upload results.json to MobileNext API instead of /tmp
gmegidish May 24, 2026
fb427be
feat(reporters): add debug logging to upload-client
gmegidish May 24, 2026
56123e6
refactor(reporters): move upload-client to driver-mobilenext
gmegidish May 24, 2026
85acbf3
fix(reporters): rename uploaded asset from results.json to report.json
gmegidish May 24, 2026
57740aa
fix(reporters): skip upload when no tests were collected
gmegidish May 24, 2026
9fad924
feat(reporters): log file size and periodic progress during upload
gmegidish May 24, 2026
f48b89c
feat(reporters): extract inline attachment bodies as separate assets …
gmegidish May 24, 2026
3440283
feat(upload): collect git metadata and include in test result POST re…
gmegidish May 26, 2026
37f034d
fix(test): clear all CI env vars before each provider test to prevent…
gmegidish May 26, 2026
dbc3ff0
feat(config): make uploadReport default to on instead of requiring op…
gmegidish May 26, 2026
f19ba9d
fix(git-info): use execFileSync to avoid shell injection risk
gmegidish May 26, 2026
12ebb49
updates
gmegidish May 26, 2026
9b10de9
Merge remote-tracking branch 'origin/main' into feat/test-result-upload
gmegidish May 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions packages/driver-mobilenext/src/git-info.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>, fn: () => void): void {
const saved: Record<string, string | undefined> = {};
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();
});
158 changes: 158 additions & 0 deletions packages/driver-mobilenext/src/git-info.ts
Original file line number Diff line number Diff line change
@@ -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 ?? {};
}
2 changes: 2 additions & 0 deletions packages/driver-mobilenext/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading