chore: another iteration on gitCommit/gitDiff props (#34926)

This commit is contained in:
Pavel Feldman 2025-02-26 08:40:30 -08:00 committed by GitHub
parent 17c4d8e5ec
commit cd23a224f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 261 additions and 205 deletions

View file

@ -239,7 +239,10 @@ export default defineConfig({
Metadata contains key-value pairs to be included in the report. For example, HTML report will display it as key-value pairs, and JSON report will include metadata serialized as json. Metadata contains key-value pairs to be included in the report. For example, HTML report will display it as key-value pairs, and JSON report will include metadata serialized as json.
Providing `'git.commit.info': {}` property will populate it with the git commit details. This is useful for CI/CD environments. * Providing `gitCommit: 'generate'` property will populate it with the git commit details.
* Providing `gitDiff: 'generate'` property will populate it with the git diff details.
On selected CI providers, both will be generated automatically. Specifying values will prevent the automatic generation.
**Usage** **Usage**

View file

@ -20,32 +20,10 @@ import './common.css';
import './theme.css'; import './theme.css';
import './metadataView.css'; import './metadataView.css';
import type { Metadata } from '@playwright/test'; import type { Metadata } from '@playwright/test';
import type { GitCommitInfo } from '@testIsomorphic/types'; import type { CIInfo, GitCommitInfo, MetadataWithCommitInfo } from '@testIsomorphic/types';
import { CopyToClipboardContainer } from './copyToClipboard'; import { CopyToClipboardContainer } from './copyToClipboard';
import { linkifyText } from '@web/renderUtils'; import { linkifyText } from '@web/renderUtils';
type MetadataEntries = [string, unknown][];
export const MetadataContext = React.createContext<MetadataEntries>([]);
export function MetadataProvider({ metadata, children }: React.PropsWithChildren<{ metadata: Metadata }>) {
const entries = React.useMemo(() => {
// TODO: do not plumb actualWorkers through metadata.
return Object.entries(metadata).filter(([key]) => key !== 'actualWorkers');
}, [metadata]);
return <MetadataContext.Provider value={entries}>{children}</MetadataContext.Provider>;
}
export function useMetadata() {
return React.useContext(MetadataContext);
}
export function useGitCommitInfo() {
const metadataEntries = useMetadata();
return metadataEntries.find(([key]) => key === 'git.commit.info')?.[1] as GitCommitInfo | undefined;
}
class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error: Error | null, errorInfo: React.ErrorInfo | null }> { class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error: Error | null, errorInfo: React.ErrorInfo | null }> {
override state: { error: Error | null, errorInfo: React.ErrorInfo | null } = { override state: { error: Error | null, errorInfo: React.ErrorInfo | null } = {
error: null, error: null,
@ -72,23 +50,22 @@ class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error
} }
} }
export const MetadataView = () => { export const MetadataView: React.FC<{ metadata: Metadata }> = params => {
return <ErrorBoundary><InnerMetadataView/></ErrorBoundary>; return <ErrorBoundary><InnerMetadataView metadata={params.metadata}/></ErrorBoundary>;
}; };
const InnerMetadataView = () => { const InnerMetadataView: React.FC<{ metadata: Metadata }> = params => {
const metadataEntries = useMetadata(); const commitInfo = params.metadata as MetadataWithCommitInfo;
const gitCommitInfo = useGitCommitInfo(); const otherEntries = Object.entries(params.metadata).filter(([key]) => !ignoreKeys.has(key));
const entries = metadataEntries.filter(([key]) => key !== 'git.commit.info'); const hasMetadata = commitInfo.ci || commitInfo.gitCommit || otherEntries.length > 0;
if (!gitCommitInfo && !entries.length) if (!hasMetadata)
return null; return;
return <div className='metadata-view'> return <div className='metadata-view'>
{gitCommitInfo && <> {commitInfo.ci && <CiInfoView info={commitInfo.ci}/>}
<GitCommitInfoView info={gitCommitInfo}/> {commitInfo.gitCommit && <GitCommitInfoView link={commitInfo.ci?.commitHref} info={commitInfo.gitCommit}/>}
{entries.length > 0 && <div className='metadata-separator' />} {otherEntries.length > 0 && (commitInfo.gitCommit || commitInfo.ci) && <div className='metadata-separator' />}
</>}
<div className='metadata-section metadata-properties' role='list'> <div className='metadata-section metadata-properties' role='list'>
{entries.map(([propertyName, value]) => { {otherEntries.map(([propertyName, value]) => {
const valueString = typeof value !== 'object' || value === null || value === undefined ? String(value) : JSON.stringify(value); const valueString = typeof value !== 'object' || value === null || value === undefined ? String(value) : JSON.stringify(value);
const trimmedValue = valueString.length > 1000 ? valueString.slice(0, 1000) + '\u2026' : valueString; const trimmedValue = valueString.length > 1000 ? valueString.slice(0, 1000) + '\u2026' : valueString;
return ( return (
@ -104,20 +81,24 @@ const InnerMetadataView = () => {
</div>; </div>;
}; };
const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => { const CiInfoView: React.FC<{ info: CIInfo }> = ({ info }) => {
const email = info.revision?.email ? ` <${info.revision?.email}>` : ''; const link = info.commitHref;
const author = `${info.revision?.author || ''}${email}`; return <div className='metadata-section' role='list'>
<div role='listitem'>
<a href={link} target='_blank' rel='noopener noreferrer' title={link}>
{link}
</a>
</div>
</div>;
};
let subject = info.revision?.subject || ''; const GitCommitInfoView: React.FC<{ link?: string, info: GitCommitInfo }> = ({ link, info }) => {
let link = info.revision?.link; const subject = info.subject;
const email = ` <${info.author.email}>`;
const author = `${info.author.name}${email}`;
const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(info.committer.time);
const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(info.committer.time);
if (info.pull_request?.link && info.pull_request?.title) {
subject = info.pull_request?.title;
link = info.pull_request?.link;
}
const shortTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'medium' }).format(info.revision?.timestamp);
const longTimestamp = Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'long' }).format(info.revision?.timestamp);
return <div className='metadata-section' role='list'> return <div className='metadata-section' role='list'>
<div role='listitem'> <div role='listitem'>
{link ? ( {link ? (
@ -131,12 +112,13 @@ const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => {
<div role='listitem' className='hbox'> <div role='listitem' className='hbox'>
<span className='mr-1'>{author}</span> <span className='mr-1'>{author}</span>
<span title={longTimestamp}> on {shortTimestamp}</span> <span title={longTimestamp}> on {shortTimestamp}</span>
{info.ci?.link && (
<>
<span className='mx-2'>·</span>
<a href={info.ci?.link} target='_blank' rel='noopener noreferrer' title='CI/CD logs'>Logs</a>
</>
)}
</div> </div>
</div>; </div>;
}; };
const ignoreKeys = new Set(['ci', 'gitCommit', 'gitDiff', 'actualWorkers']);
export const isMetadataEmpty = (metadata: MetadataWithCommitInfo): boolean => {
const otherEntries = Object.entries(metadata).filter(([key]) => !ignoreKeys.has(key));
return !metadata.ci && !metadata.gitCommit && !otherEntries.length;
};

View file

@ -0,0 +1,29 @@
/*
Copyright (c) Microsoft Corporation.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from 'react';
import type { HTMLReport } from './types';
const HTMLReportContext = React.createContext<HTMLReport | undefined>(undefined);
export function HTMLReportContextProvider({ report, children }: React.PropsWithChildren<{ report: HTMLReport | undefined }>) {
return <HTMLReportContext.Provider value={report}>{children}</HTMLReportContext.Provider>;
}
export function useHTMLReport() {
return React.useContext(HTMLReportContext);
}

View file

@ -26,7 +26,7 @@ import './reportView.css';
import { TestCaseView } from './testCaseView'; import { TestCaseView } from './testCaseView';
import { TestFilesHeader, TestFilesView } from './testFilesView'; import { TestFilesHeader, TestFilesView } from './testFilesView';
import './theme.css'; import './theme.css';
import { MetadataProvider } from './metadataView'; import { HTMLReportContextProvider } from './reportContext';
declare global { declare global {
interface Window { interface Window {
@ -73,7 +73,7 @@ export const ReportView: React.FC<{
return result; return result;
}, [report, filter]); }, [report, filter]);
return <MetadataProvider metadata={report?.json().metadata ?? {}}><div className='htmlreport vbox px-4 pb-4'> return <HTMLReportContextProvider report={report?.json()}><div className='htmlreport vbox px-4 pb-4'>
<main> <main>
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>} {report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
<Route predicate={testFilesRoutePredicate}> <Route predicate={testFilesRoutePredicate}>
@ -89,7 +89,7 @@ export const ReportView: React.FC<{
{!!report && <TestCaseViewLoader report={report} tests={filteredTests.tests} testIdToFileIdMap={testIdToFileIdMap} />} {!!report && <TestCaseViewLoader report={report} tests={filteredTests.tests} testIdToFileIdMap={testIdToFileIdMap} />}
</Route> </Route>
</main> </main>
</div></MetadataProvider>; </div></HTMLReportContextProvider>;
}; };
const TestCaseViewLoader: React.FC<{ const TestCaseViewLoader: React.FC<{

View file

@ -21,9 +21,14 @@ import type { ImageDiff } from '@web/shared/imageDiffView';
import { ImageDiffView } from '@web/shared/imageDiffView'; import { ImageDiffView } from '@web/shared/imageDiffView';
import type { TestResult } from './types'; import type { TestResult } from './types';
import { fixTestPrompt } from '@web/components/prompts'; import { fixTestPrompt } from '@web/components/prompts';
import { useGitCommitInfo } from './metadataView'; import { useHTMLReport } from './reportContext';
import type { MetadataWithCommitInfo } from '@playwright/isomorphic/types';
export const TestErrorView: React.FC<{ error: string; testId?: string; result?: TestResult }> = ({ error, testId, result }) => { export const TestErrorView: React.FC<{
error: string;
testId?: string;
result?: TestResult
}> = ({ error, testId, result }) => {
return ( return (
<CodeSnippet code={error} testId={testId}> <CodeSnippet code={error} testId={testId}>
<div style={{ float: 'right', margin: 10 }}> <div style={{ float: 'right', margin: 10 }}>
@ -47,12 +52,13 @@ const PromptButton: React.FC<{
error: string; error: string;
result?: TestResult; result?: TestResult;
}> = ({ error, result }) => { }> = ({ error, result }) => {
const gitCommitInfo = useGitCommitInfo(); const report = useHTMLReport();
const commitInfo = report?.metadata as MetadataWithCommitInfo | undefined;
const prompt = React.useMemo(() => fixTestPrompt( const prompt = React.useMemo(() => fixTestPrompt(
error, error,
gitCommitInfo?.pull_request?.diff ?? gitCommitInfo?.revision?.diff, commitInfo?.gitDiff,
result?.attachments.find(a => a.name === 'pageSnapshot')?.body result?.attachments.find(a => a.name === 'pageSnapshot')?.body
), [gitCommitInfo, result, error]); ), [commitInfo, result, error]);
const [copied, setCopied] = React.useState(false); const [copied, setCopied] = React.useState(false);

View file

@ -22,7 +22,7 @@ import { msToString } from './utils';
import { AutoChip } from './chip'; import { AutoChip } from './chip';
import { TestErrorView } from './testErrorView'; import { TestErrorView } from './testErrorView';
import * as icons from './icons'; import * as icons from './icons';
import { MetadataView, useMetadata } from './metadataView'; import { isMetadataEmpty, MetadataView } from './metadataView';
export const TestFilesView: React.FC<{ export const TestFilesView: React.FC<{
tests: TestFileSummary[], tests: TestFileSummary[],
@ -67,13 +67,12 @@ export const TestFilesHeader: React.FC<{
metadataVisible: boolean, metadataVisible: boolean,
toggleMetadataVisible: () => void, toggleMetadataVisible: () => void,
}> = ({ report, filteredStats, metadataVisible, toggleMetadataVisible }) => { }> = ({ report, filteredStats, metadataVisible, toggleMetadataVisible }) => {
const metadataEntries = useMetadata();
if (!report) if (!report)
return null; return null;
return <> return <>
<div className='mx-1' style={{ display: 'flex', marginTop: 10 }}> <div className='mx-1' style={{ display: 'flex', marginTop: 10 }}>
<div className='test-file-header-info'> <div className='test-file-header-info'>
{metadataEntries.length > 0 && <div className='metadata-toggle' role='button' onClick={toggleMetadataVisible} title={metadataVisible ? 'Hide metadata' : 'Show metadata'}> {!isMetadataEmpty(report.metadata) && <div className='metadata-toggle' role='button' onClick={toggleMetadataVisible} title={metadataVisible ? 'Hide metadata' : 'Show metadata'}>
{metadataVisible ? icons.downArrow() : icons.rightArrow()}Metadata {metadataVisible ? icons.downArrow() : icons.rightArrow()}Metadata
</div>} </div>}
{report.projectNames.length === 1 && !!report.projectNames[0] && <div data-testid='project-name'>Project: {report.projectNames[0]}</div>} {report.projectNames.length === 1 && !!report.projectNames[0] && <div data-testid='project-name'>Project: {report.projectNames[0]}</div>}
@ -83,7 +82,7 @@ export const TestFilesHeader: React.FC<{
<div data-testid='overall-time' style={{ color: 'var(--color-fg-subtle)', marginRight: '10px' }}>{report ? new Date(report.startTime).toLocaleString() : ''}</div> <div data-testid='overall-time' style={{ color: 'var(--color-fg-subtle)', marginRight: '10px' }}>{report ? new Date(report.startTime).toLocaleString() : ''}</div>
<div data-testid='overall-duration' style={{ color: 'var(--color-fg-subtle)' }}>Total time: {msToString(report.duration ?? 0)}</div> <div data-testid='overall-duration' style={{ color: 'var(--color-fg-subtle)' }}>Total time: {msToString(report.duration ?? 0)}</div>
</div> </div>
{metadataVisible && <MetadataView/>} {metadataVisible && <MetadataView metadata={report.metadata}/>}
{!!report.errors.length && <AutoChip header='Errors' dataTestId='report-errors'> {!!report.errors.length && <AutoChip header='Errors' dataTestId='report-errors'>
{report.errors.map((error, index) => <TestErrorView key={'test-report-error-message-' + index} error={error}></TestErrorView>)} {report.errors.map((error, index) => <TestErrorView key={'test-report-error-message-' + index} error={error}></TestErrorView>)}
</AutoChip>} </AutoChip>}

View file

@ -14,23 +14,40 @@
* limitations under the License. * limitations under the License.
*/ */
export interface GitCommitInfo { export type GitCommitInfo = {
revision?: { shortHash: string;
id?: string; hash: string;
author?: string; subject: string;
email?: string; body: string;
subject?: string; author: {
timestamp?: number; name: string;
link?: string; email: string;
diff?: string; time: number;
}, };
pull_request?: { committer: {
link?: string; name: string;
diff?: string; email: string
base?: string; time: number;
title?: string; };
}, branch: string;
ci?: { };
link?: string;
} export type CIInfo = {
} commitHref: string;
buildHref?: string;
commitHash?: string;
baseHash?: string;
branch?: string;
};
export type UserMetadataWithCommitInfo = {
ci?: CIInfo;
gitCommit?: GitCommitInfo | 'generate';
gitDiff?: string | 'generate';
};
export type MetadataWithCommitInfo = {
ci?: CIInfo;
gitCommit?: GitCommitInfo;
gitDiff?: string;
};

View file

@ -14,119 +14,139 @@
* limitations under the License. * limitations under the License.
*/ */
import fs from 'fs'; import { spawnAsync } from 'playwright-core/lib/utils';
import { createGuid, spawnAsync } from 'playwright-core/lib/utils';
import type { TestRunnerPlugin } from './'; import type { TestRunnerPlugin } from './';
import type { FullConfig } from '../../types/testReporter'; import type { FullConfig } from '../../types/testReporter';
import type { FullConfigInternal } from '../common/config'; import type { FullConfigInternal } from '../common/config';
import type { GitCommitInfo } from '../isomorphic/types'; import type { GitCommitInfo, CIInfo, UserMetadataWithCommitInfo } from '../isomorphic/types';
const GIT_OPERATIONS_TIMEOUT_MS = 1500; const GIT_OPERATIONS_TIMEOUT_MS = 3000;
export const addGitCommitInfoPlugin = (fullConfig: FullConfigInternal) => { export const addGitCommitInfoPlugin = (fullConfig: FullConfigInternal) => {
const commitProperty = fullConfig.config.metadata['git.commit.info']; fullConfig.plugins.push({ factory: gitCommitInfoPlugin });
if (commitProperty && typeof commitProperty === 'object' && Object.keys(commitProperty).length === 0)
fullConfig.plugins.push({ factory: gitCommitInfo });
}; };
export const gitCommitInfo = (options?: GitCommitInfoPluginOptions): TestRunnerPlugin => { type GitCommitInfoPluginOptions = {
directory?: string;
};
export const gitCommitInfoPlugin = (options?: GitCommitInfoPluginOptions): TestRunnerPlugin => {
return { return {
name: 'playwright:git-commit-info', name: 'playwright:git-commit-info',
setup: async (config: FullConfig, configDir: string) => { setup: async (config: FullConfig, configDir: string) => {
const commitInfo = await linksFromEnv(); const metadata = config.metadata as UserMetadataWithCommitInfo;
await enrichStatusFromCLI(options?.directory || configDir, commitInfo); const ci = ciInfo();
config.metadata = config.metadata || {}; if (!metadata.ci && ci)
config.metadata['git.commit.info'] = commitInfo; metadata.ci = ci;
if ((ci && !metadata.gitCommit) || metadata.gitCommit === 'generate') {
const git = await gitCommitInfo(options?.directory || configDir).catch(e => {
// eslint-disable-next-line no-console
console.error('Failed to get git commit info', e);
});
if (git)
metadata.gitCommit = git;
}
if ((ci && !metadata.gitDiff) || metadata.gitDiff === 'generate') {
const diffResult = await gitDiff(options?.directory || configDir, ci).catch(e => {
// eslint-disable-next-line no-console
console.error('Failed to get git diff', e);
});
if (diffResult)
metadata.gitDiff = diffResult;
}
}, },
}; };
}; };
interface GitCommitInfoPluginOptions { function ciInfo(): CIInfo | undefined {
directory?: string; if (process.env.GITHUB_ACTIONS) {
return {
commitHref: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/commit/${process.env.GITHUB_SHA}`,
buildHref: `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`,
commitHash: process.env.GITHUB_SHA,
baseHash: process.env.GITHUB_BASE_REF,
branch: process.env.GITHUB_REF_NAME,
};
}
if (process.env.GITLAB_CI) {
return {
commitHref: `${process.env.CI_PROJECT_URL}/-/commit/${process.env.CI_COMMIT_SHA}`,
buildHref: process.env.CI_JOB_URL,
commitHash: process.env.CI_COMMIT_SHA,
baseHash: process.env.CI_COMMIT_BEFORE_SHA,
branch: process.env.CI_COMMIT_REF_NAME,
};
}
if (process.env.JENKINS_URL && process.env.BUILD_URL) {
return {
commitHref: process.env.BUILD_URL,
commitHash: process.env.GIT_COMMIT,
baseHash: process.env.GIT_PREVIOUS_COMMIT,
branch: process.env.GIT_BRANCH,
};
}
// Open to PRs.
} }
async function linksFromEnv(): Promise<GitCommitInfo> { async function gitCommitInfo(gitDir: string): Promise<GitCommitInfo | undefined> {
const out: GitCommitInfo = {}; const separator = `---786eec917292---`;
// Jenkins: https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables const tokens = [
if (process.env.BUILD_URL) { '%H', // commit hash
out.ci = out.ci || {}; '%h', // abbreviated commit hash
out.ci.link = process.env.BUILD_URL; '%s', // subject
} '%B', // raw body (unwrapped subject and body)
// GitLab: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html '%an', // author name
if (process.env.CI_PROJECT_URL && process.env.CI_COMMIT_SHA) { '%ae', // author email
out.revision = out.revision || {}; '%at', // author date, UNIX timestamp
out.revision.link = `${process.env.CI_PROJECT_URL}/-/commit/${process.env.CI_COMMIT_SHA}`; '%cn', // committer name
} '%ce', // committer email
if (process.env.CI_JOB_URL) { '%ct', // committer date, UNIX timestamp
out.ci = out.ci || {}; '', // branch
out.ci.link = process.env.CI_JOB_URL; ];
}
// GitHub: https://docs.github.com/en/actions/learn-github-actions/environment-variables#default-environment-variables
if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_SHA) {
out.revision = out.revision || {};
out.revision.link = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/commit/${process.env.GITHUB_SHA}`;
}
if (process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID) {
out.ci = out.ci || {};
out.ci.link = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
}
if (process.env.GITHUB_EVENT_PATH) {
try {
const json = JSON.parse(await fs.promises.readFile(process.env.GITHUB_EVENT_PATH, 'utf8'));
if (json.pull_request) {
out.pull_request = out.pull_request || {};
out.pull_request.title = json.pull_request.title;
out.pull_request.link = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/pull/${json.pull_request.number}`;
out.pull_request.base = json.pull_request.base.ref;
}
} catch {
}
}
return out;
}
async function enrichStatusFromCLI(gitDir: string, commitInfo: GitCommitInfo) {
const separator = `:${createGuid().slice(0, 4)}:`;
const commitInfoResult = await spawnAsync( const commitInfoResult = await spawnAsync(
'git', `git log -1 --pretty=format:"${tokens.join(separator)}" && git rev-parse --abbrev-ref HEAD`, [],
['show', '-s', `--format=%H${separator}%s${separator}%an${separator}%ae${separator}%ct`, 'HEAD'], { stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS, shell: true }
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
); );
if (commitInfoResult.code) if (commitInfoResult.code)
return; return undefined;
const showOutput = commitInfoResult.stdout.trim(); const showOutput = commitInfoResult.stdout.trim();
const [id, subject, author, email, rawTimestamp] = showOutput.split(separator); const [hash, shortHash, subject, body, authorName, authorEmail, authorTime, committerName, committerEmail, committerTime, branch] = showOutput.split(separator);
let timestamp: number = Number.parseInt(rawTimestamp, 10);
timestamp = Number.isInteger(timestamp) ? timestamp * 1000 : 0;
commitInfo.revision = { return {
...commitInfo.revision, shortHash,
id, hash,
author,
email,
subject, subject,
timestamp, body,
author: {
name: authorName,
email: authorEmail,
time: +authorTime * 1000,
},
committer: {
name: committerName,
email: committerEmail,
time: +committerTime * 1000,
},
branch: branch.trim(),
}; };
}
async function gitDiff(gitDir: string, ci?: CIInfo): Promise<string | undefined> {
const diffLimit = 100_000;
const baseHash = ci?.baseHash ?? 'HEAD~1';
const diffLimit = 1_000_000; // 1MB
if (commitInfo.pull_request?.base) {
const pullDiffResult = await spawnAsync( const pullDiffResult = await spawnAsync(
'git', 'git',
['diff', commitInfo.pull_request?.base], ['diff', baseHash],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS } { stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
); );
if (!pullDiffResult.code) if (!pullDiffResult.code)
commitInfo.pull_request!.diff = pullDiffResult.stdout.substring(0, diffLimit); return pullDiffResult.stdout.substring(0, diffLimit);
} else {
const diffResult = await spawnAsync(
'git',
['diff', 'HEAD~1'],
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
);
if (!diffResult.code)
commitInfo.revision!.diff = diffResult.stdout.substring(0, diffLimit);
}
} }

View file

@ -35,4 +35,3 @@ export type TestRunnerPluginRegistration = {
}; };
export { webServer } from './webServerPlugin'; export { webServer } from './webServerPlugin';
export { gitCommitInfo } from './gitCommitInfoPlugin';

View file

@ -1284,9 +1284,11 @@ interface TestConfig<TestArgs = {}, WorkerArgs = {}> {
/** /**
* Metadata contains key-value pairs to be included in the report. For example, HTML report will display it as * Metadata contains key-value pairs to be included in the report. For example, HTML report will display it as
* key-value pairs, and JSON report will include metadata serialized as json. * key-value pairs, and JSON report will include metadata serialized as json.
* - Providing `gitCommit: 'generate'` property will populate it with the git commit details.
* - Providing `gitDiff: 'generate'` property will populate it with the git diff details.
* *
* Providing `'git.commit.info': {}` property will populate it with the git commit details. This is useful for CI/CD * On selected CI providers, both will be generated automatically. Specifying values will prevent the automatic
* environments. * generation.
* *
* **Usage** * **Usage**
* *

View file

@ -24,20 +24,20 @@ import type { StackFrame } from '@protocol/channels';
import { CopyToClipboardTextButton } from './copyToClipboard'; import { CopyToClipboardTextButton } from './copyToClipboard';
import { attachmentURL } from './attachmentsTab'; import { attachmentURL } from './attachmentsTab';
import { fixTestPrompt } from '@web/components/prompts'; import { fixTestPrompt } from '@web/components/prompts';
import type { GitCommitInfo } from '@testIsomorphic/types'; import type { MetadataWithCommitInfo } from '@testIsomorphic/types';
import { AIConversation } from './aiConversation'; import { AIConversation } from './aiConversation';
import { ToolbarButton } from '@web/components/toolbarButton'; import { ToolbarButton } from '@web/components/toolbarButton';
import { useIsLLMAvailable, useLLMChat } from './llm'; import { useIsLLMAvailable, useLLMChat } from './llm';
import { useAsyncMemo } from '@web/uiUtils'; import { useAsyncMemo } from '@web/uiUtils';
const GitCommitInfoContext = React.createContext<GitCommitInfo | undefined>(undefined); const CommitInfoContext = React.createContext<MetadataWithCommitInfo | undefined>(undefined);
export function GitCommitInfoProvider({ children, gitCommitInfo }: React.PropsWithChildren<{ gitCommitInfo: GitCommitInfo }>) { export function CommitInfoProvider({ children, commitInfo }: React.PropsWithChildren<{ commitInfo: MetadataWithCommitInfo }>) {
return <GitCommitInfoContext.Provider value={gitCommitInfo}>{children}</GitCommitInfoContext.Provider>; return <CommitInfoContext.Provider value={commitInfo}>{children}</CommitInfoContext.Provider>;
} }
export function useGitCommitInfo() { export function useCommitInfo() {
return React.useContext(GitCommitInfoContext); return React.useContext(CommitInfoContext);
} }
function usePageSnapshot(actions: modelUtil.ActionTraceEventInContext[]) { function usePageSnapshot(actions: modelUtil.ActionTraceEventInContext[]) {
@ -100,8 +100,7 @@ export function useErrorsTabModel(model: modelUtil.MultiTraceModel | undefined):
function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSource }: { message: string, error: ErrorDescription, errorId: string, sdkLanguage: Language, pageSnapshot?: string, revealInSource: (error: ErrorDescription) => void }) { function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSource }: { message: string, error: ErrorDescription, errorId: string, sdkLanguage: Language, pageSnapshot?: string, revealInSource: (error: ErrorDescription) => void }) {
const [showLLM, setShowLLM] = React.useState(false); const [showLLM, setShowLLM] = React.useState(false);
const llmAvailable = useIsLLMAvailable(); const llmAvailable = useIsLLMAvailable();
const gitCommitInfo = useGitCommitInfo(); const metadata = useCommitInfo();
const diff = gitCommitInfo?.pull_request?.diff ?? gitCommitInfo?.revision?.diff;
let location: string | undefined; let location: string | undefined;
let longLocation: string | undefined; let longLocation: string | undefined;
@ -127,8 +126,8 @@ function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSou
</div>} </div>}
<span style={{ position: 'absolute', right: '5px' }}> <span style={{ position: 'absolute', right: '5px' }}>
{llmAvailable {llmAvailable
? <FixWithAIButton conversationId={errorId} onChange={setShowLLM} value={showLLM} error={message} diff={diff} pageSnapshot={pageSnapshot} /> ? <FixWithAIButton conversationId={errorId} onChange={setShowLLM} value={showLLM} error={message} diff={metadata?.gitDiff} pageSnapshot={pageSnapshot} />
: <CopyPromptButton error={message} pageSnapshot={pageSnapshot} diff={diff} />} : <CopyPromptButton error={message} pageSnapshot={pageSnapshot} diff={metadata?.gitDiff} />}
</span> </span>
</div> </div>

View file

@ -37,8 +37,9 @@ import { TestListView } from './uiModeTestListView';
import { TraceView } from './uiModeTraceView'; import { TraceView } from './uiModeTraceView';
import { SettingsView } from './settingsView'; import { SettingsView } from './settingsView';
import { DefaultSettingsView } from './defaultSettingsView'; import { DefaultSettingsView } from './defaultSettingsView';
import { GitCommitInfoProvider } from './errorsTab'; import { CommitInfoProvider } from './errorsTab';
import { LLMProvider } from './llm'; import { LLMProvider } from './llm';
import type { MetadataWithCommitInfo } from '@testIsomorphic/types';
let xtermSize = { cols: 80, rows: 24 }; let xtermSize = { cols: 80, rows: 24 };
const xtermDataSource: XtermDataSource = { const xtermDataSource: XtermDataSource = {
@ -432,7 +433,7 @@ export const UIModeView: React.FC<{}> = ({
<XtermWrapper source={xtermDataSource}></XtermWrapper> <XtermWrapper source={xtermDataSource}></XtermWrapper>
</div> </div>
<div className={clsx('vbox', isShowingOutput && 'hidden')}> <div className={clsx('vbox', isShowingOutput && 'hidden')}>
<GitCommitInfoProvider gitCommitInfo={testModel?.config.metadata['git.commit.info']}> <CommitInfoProvider commitInfo={testModel?.config.metadata as MetadataWithCommitInfo}>
<TraceView <TraceView
pathSeparator={queryParams.pathSeparator} pathSeparator={queryParams.pathSeparator}
item={selectedItem} item={selectedItem}
@ -440,7 +441,7 @@ export const UIModeView: React.FC<{}> = ({
revealSource={revealSource} revealSource={revealSource}
onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })} onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })}
/> />
</GitCommitInfoProvider> </CommitInfoProvider>
</div> </div>
</div>} </div>}
sidebar={<div className='vbox ui-mode-sidebar'> sidebar={<div className='vbox ui-mode-sidebar'>

View file

@ -1187,12 +1187,12 @@ for (const useIntermediateMergeReport of [true, false] as const) {
]); ]);
}); });
test('should include metadata with git.commit.info', async ({ runInlineTest, writeFiles, showReport, page }) => { test('should include metadata with gitCommit', async ({ runInlineTest, writeFiles, showReport, page }) => {
const files = { const files = {
'uncommitted.txt': `uncommitted file`, 'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': ` 'playwright.config.ts': `
export default { export default {
metadata: { 'git.commit.info': {}, foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] } metadata: { foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] }
}; };
`, `,
'example.spec.ts': ` 'example.spec.ts': `
@ -1219,6 +1219,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
const result = await runInlineTest(files, { reporter: 'dot,html' }, { const result = await runInlineTest(files, { reporter: 'dot,html' }, {
PLAYWRIGHT_HTML_OPEN: 'never', PLAYWRIGHT_HTML_OPEN: 'never',
GITHUB_ACTIONS: '1',
GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test', GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test',
GITHUB_SERVER_URL: 'https://playwright.dev', GITHUB_SERVER_URL: 'https://playwright.dev',
GITHUB_SHA: 'example-sha', GITHUB_SHA: 'example-sha',
@ -1240,12 +1241,12 @@ for (const useIntermediateMergeReport of [true, false] as const) {
`); `);
}); });
test('should include metadata with git.commit.info on GHA', async ({ runInlineTest, writeFiles, showReport, page }) => { test('should include metadata on GHA', async ({ runInlineTest, writeFiles, showReport, page }) => {
const files = { const files = {
'uncommitted.txt': `uncommitted file`, 'uncommitted.txt': `uncommitted file`,
'playwright.config.ts': ` 'playwright.config.ts': `
export default { export default {
metadata: { 'git.commit.info': {}, foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] } metadata: { foo: 'value1', bar: { prop: 'value2' }, baz: ['value3', 123] }
}; };
`, `,
'example.spec.ts': ` 'example.spec.ts': `
@ -1281,6 +1282,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
const result = await runInlineTest(files, { reporter: 'dot,html' }, { const result = await runInlineTest(files, { reporter: 'dot,html' }, {
PLAYWRIGHT_HTML_OPEN: 'never', PLAYWRIGHT_HTML_OPEN: 'never',
GITHUB_ACTIONS: '1',
GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test', GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test',
GITHUB_RUN_ID: 'example-run-id', GITHUB_RUN_ID: 'example-run-id',
GITHUB_SERVER_URL: 'https://playwright.dev', GITHUB_SERVER_URL: 'https://playwright.dev',
@ -1295,10 +1297,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
await expect(page.locator('.metadata-view')).toMatchAriaSnapshot(` await expect(page.locator('.metadata-view')).toMatchAriaSnapshot(`
- list: - list:
- listitem: - listitem:
- link "My PR" - link "https://playwright.dev/microsoft/playwright-example-for-test/commit/example-sha"
- listitem:
- text: /William <shakespeare@example\\.local>/
- link "Logs"
- list: - list:
- listitem: "foo : value1" - listitem: "foo : value1"
- listitem: "bar : {\\"prop\\":\\"value2\\"}" - listitem: "bar : {\\"prop\\":\\"value2\\"}"
@ -1306,7 +1305,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
`); `);
}); });
test('should not include git metadata w/o git.commit.info', async ({ runInlineTest, showReport, page }) => { test('should not include git metadata w/o gitCommit', async ({ runInlineTest, showReport, page }) => {
const result = await runInlineTest({ const result = await runInlineTest({
'playwright.config.ts': ` 'playwright.config.ts': `
export default {}; export default {};
@ -1330,7 +1329,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
'playwright.config.ts': ` 'playwright.config.ts': `
export default { export default {
metadata: { metadata: {
'git.commit.info': { revision: { timestamp: 'hi' } } gitCommit: { author: { date: 'hi' } }
}, },
}; };
`, `,
@ -2765,7 +2764,6 @@ for (const useIntermediateMergeReport of [true, false] as const) {
'playwright.config.ts': ` 'playwright.config.ts': `
export default { export default {
metadata: { metadata: {
'git.commit.info': {},
foo: 'value1', foo: 'value1',
bar: { prop: 'value2' }, bar: { prop: 'value2' },
baz: ['value3', 123] baz: ['value3', 123]
@ -2799,6 +2797,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
const result = await runInlineTest(files, { reporter: 'dot,html' }, { const result = await runInlineTest(files, { reporter: 'dot,html' }, {
PLAYWRIGHT_HTML_OPEN: 'never', PLAYWRIGHT_HTML_OPEN: 'never',
GITHUB_ACTIONS: '1',
GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test', GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test',
GITHUB_RUN_ID: 'example-run-id', GITHUB_RUN_ID: 'example-run-id',
GITHUB_SERVER_URL: 'https://playwright.dev', GITHUB_SERVER_URL: 'https://playwright.dev',

View file

@ -21,14 +21,13 @@ test('should render html report git info metadata', async ({ runUITest }) => {
'reporter.ts': ` 'reporter.ts': `
module.exports = class Reporter { module.exports = class Reporter {
onBegin(config, suite) { onBegin(config, suite) {
console.log('ci.link:', config.metadata['git.commit.info'].ci.link); console.log('ci.link:', config.metadata['ci'].commitHref);
} }
} }
`, `,
'playwright.config.ts': ` 'playwright.config.ts': `
import { defineConfig } from '@playwright/test'; import { defineConfig } from '@playwright/test';
export default defineConfig({ export default defineConfig({
metadata: { 'git.commit.info': {} },
reporter: './reporter.ts', reporter: './reporter.ts',
}); });
`, `,
@ -37,6 +36,7 @@ test('should render html report git info metadata', async ({ runUITest }) => {
test('should work', async ({}) => {}); test('should work', async ({}) => {});
` `
}, { }, {
JENKINS_URL: '1',
BUILD_URL: 'https://playwright.dev', BUILD_URL: 'https://playwright.dev',
}); });