chore: another iteration on gitCommit/gitDiff props (#34926)
This commit is contained in:
parent
17c4d8e5ec
commit
cd23a224f6
|
@ -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.
|
||||
|
||||
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**
|
||||
|
||||
|
|
|
@ -20,32 +20,10 @@ import './common.css';
|
|||
import './theme.css';
|
||||
import './metadataView.css';
|
||||
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 { 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 }> {
|
||||
override state: { error: Error | null, errorInfo: React.ErrorInfo | null } = {
|
||||
error: null,
|
||||
|
@ -72,23 +50,22 @@ class ErrorBoundary extends React.Component<React.PropsWithChildren<{}>, { error
|
|||
}
|
||||
}
|
||||
|
||||
export const MetadataView = () => {
|
||||
return <ErrorBoundary><InnerMetadataView/></ErrorBoundary>;
|
||||
export const MetadataView: React.FC<{ metadata: Metadata }> = params => {
|
||||
return <ErrorBoundary><InnerMetadataView metadata={params.metadata}/></ErrorBoundary>;
|
||||
};
|
||||
|
||||
const InnerMetadataView = () => {
|
||||
const metadataEntries = useMetadata();
|
||||
const gitCommitInfo = useGitCommitInfo();
|
||||
const entries = metadataEntries.filter(([key]) => key !== 'git.commit.info');
|
||||
if (!gitCommitInfo && !entries.length)
|
||||
return null;
|
||||
const InnerMetadataView: React.FC<{ metadata: Metadata }> = params => {
|
||||
const commitInfo = params.metadata as MetadataWithCommitInfo;
|
||||
const otherEntries = Object.entries(params.metadata).filter(([key]) => !ignoreKeys.has(key));
|
||||
const hasMetadata = commitInfo.ci || commitInfo.gitCommit || otherEntries.length > 0;
|
||||
if (!hasMetadata)
|
||||
return;
|
||||
return <div className='metadata-view'>
|
||||
{gitCommitInfo && <>
|
||||
<GitCommitInfoView info={gitCommitInfo}/>
|
||||
{entries.length > 0 && <div className='metadata-separator' />}
|
||||
</>}
|
||||
{commitInfo.ci && <CiInfoView info={commitInfo.ci}/>}
|
||||
{commitInfo.gitCommit && <GitCommitInfoView link={commitInfo.ci?.commitHref} info={commitInfo.gitCommit}/>}
|
||||
{otherEntries.length > 0 && (commitInfo.gitCommit || commitInfo.ci) && <div className='metadata-separator' />}
|
||||
<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 trimmedValue = valueString.length > 1000 ? valueString.slice(0, 1000) + '\u2026' : valueString;
|
||||
return (
|
||||
|
@ -104,20 +81,24 @@ const InnerMetadataView = () => {
|
|||
</div>;
|
||||
};
|
||||
|
||||
const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => {
|
||||
const email = info.revision?.email ? ` <${info.revision?.email}>` : '';
|
||||
const author = `${info.revision?.author || ''}${email}`;
|
||||
const CiInfoView: React.FC<{ info: CIInfo }> = ({ info }) => {
|
||||
const link = info.commitHref;
|
||||
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 || '';
|
||||
let link = info.revision?.link;
|
||||
const GitCommitInfoView: React.FC<{ link?: string, info: GitCommitInfo }> = ({ link, info }) => {
|
||||
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'>
|
||||
<div role='listitem'>
|
||||
{link ? (
|
||||
|
@ -131,12 +112,13 @@ const GitCommitInfoView: React.FC<{ info: GitCommitInfo }> = ({ info }) => {
|
|||
<div role='listitem' className='hbox'>
|
||||
<span className='mr-1'>{author}</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>;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -26,7 +26,7 @@ import './reportView.css';
|
|||
import { TestCaseView } from './testCaseView';
|
||||
import { TestFilesHeader, TestFilesView } from './testFilesView';
|
||||
import './theme.css';
|
||||
import { MetadataProvider } from './metadataView';
|
||||
import { HTMLReportContextProvider } from './reportContext';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
@ -73,7 +73,7 @@ export const ReportView: React.FC<{
|
|||
return result;
|
||||
}, [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>
|
||||
{report?.json() && <HeaderView stats={report.json().stats} filterText={filterText} setFilterText={setFilterText}></HeaderView>}
|
||||
<Route predicate={testFilesRoutePredicate}>
|
||||
|
@ -89,7 +89,7 @@ export const ReportView: React.FC<{
|
|||
{!!report && <TestCaseViewLoader report={report} tests={filteredTests.tests} testIdToFileIdMap={testIdToFileIdMap} />}
|
||||
</Route>
|
||||
</main>
|
||||
</div></MetadataProvider>;
|
||||
</div></HTMLReportContextProvider>;
|
||||
};
|
||||
|
||||
const TestCaseViewLoader: React.FC<{
|
||||
|
|
|
@ -21,9 +21,14 @@ import type { ImageDiff } from '@web/shared/imageDiffView';
|
|||
import { ImageDiffView } from '@web/shared/imageDiffView';
|
||||
import type { TestResult } from './types';
|
||||
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 (
|
||||
<CodeSnippet code={error} testId={testId}>
|
||||
<div style={{ float: 'right', margin: 10 }}>
|
||||
|
@ -47,12 +52,13 @@ const PromptButton: React.FC<{
|
|||
error: string;
|
||||
result?: TestResult;
|
||||
}> = ({ error, result }) => {
|
||||
const gitCommitInfo = useGitCommitInfo();
|
||||
const report = useHTMLReport();
|
||||
const commitInfo = report?.metadata as MetadataWithCommitInfo | undefined;
|
||||
const prompt = React.useMemo(() => fixTestPrompt(
|
||||
error,
|
||||
gitCommitInfo?.pull_request?.diff ?? gitCommitInfo?.revision?.diff,
|
||||
commitInfo?.gitDiff,
|
||||
result?.attachments.find(a => a.name === 'pageSnapshot')?.body
|
||||
), [gitCommitInfo, result, error]);
|
||||
), [commitInfo, result, error]);
|
||||
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ import { msToString } from './utils';
|
|||
import { AutoChip } from './chip';
|
||||
import { TestErrorView } from './testErrorView';
|
||||
import * as icons from './icons';
|
||||
import { MetadataView, useMetadata } from './metadataView';
|
||||
import { isMetadataEmpty, MetadataView } from './metadataView';
|
||||
|
||||
export const TestFilesView: React.FC<{
|
||||
tests: TestFileSummary[],
|
||||
|
@ -67,13 +67,12 @@ export const TestFilesHeader: React.FC<{
|
|||
metadataVisible: boolean,
|
||||
toggleMetadataVisible: () => void,
|
||||
}> = ({ report, filteredStats, metadataVisible, toggleMetadataVisible }) => {
|
||||
const metadataEntries = useMetadata();
|
||||
if (!report)
|
||||
return null;
|
||||
return <>
|
||||
<div className='mx-1' style={{ display: 'flex', marginTop: 10 }}>
|
||||
<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
|
||||
</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-duration' style={{ color: 'var(--color-fg-subtle)' }}>Total time: {msToString(report.duration ?? 0)}</div>
|
||||
</div>
|
||||
{metadataVisible && <MetadataView/>}
|
||||
{metadataVisible && <MetadataView metadata={report.metadata}/>}
|
||||
{!!report.errors.length && <AutoChip header='Errors' dataTestId='report-errors'>
|
||||
{report.errors.map((error, index) => <TestErrorView key={'test-report-error-message-' + index} error={error}></TestErrorView>)}
|
||||
</AutoChip>}
|
||||
|
|
|
@ -14,23 +14,40 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export interface GitCommitInfo {
|
||||
revision?: {
|
||||
id?: string;
|
||||
author?: string;
|
||||
email?: string;
|
||||
subject?: string;
|
||||
timestamp?: number;
|
||||
link?: string;
|
||||
diff?: string;
|
||||
},
|
||||
pull_request?: {
|
||||
link?: string;
|
||||
diff?: string;
|
||||
base?: string;
|
||||
title?: string;
|
||||
},
|
||||
ci?: {
|
||||
link?: string;
|
||||
}
|
||||
}
|
||||
export type GitCommitInfo = {
|
||||
shortHash: string;
|
||||
hash: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
author: {
|
||||
name: string;
|
||||
email: string;
|
||||
time: number;
|
||||
};
|
||||
committer: {
|
||||
name: string;
|
||||
email: string
|
||||
time: number;
|
||||
};
|
||||
branch: 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;
|
||||
};
|
||||
|
|
|
@ -14,119 +14,139 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
|
||||
import { createGuid, spawnAsync } from 'playwright-core/lib/utils';
|
||||
import { spawnAsync } from 'playwright-core/lib/utils';
|
||||
|
||||
import type { TestRunnerPlugin } from './';
|
||||
import type { FullConfig } from '../../types/testReporter';
|
||||
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) => {
|
||||
const commitProperty = fullConfig.config.metadata['git.commit.info'];
|
||||
if (commitProperty && typeof commitProperty === 'object' && Object.keys(commitProperty).length === 0)
|
||||
fullConfig.plugins.push({ factory: gitCommitInfo });
|
||||
fullConfig.plugins.push({ factory: gitCommitInfoPlugin });
|
||||
};
|
||||
|
||||
export const gitCommitInfo = (options?: GitCommitInfoPluginOptions): TestRunnerPlugin => {
|
||||
type GitCommitInfoPluginOptions = {
|
||||
directory?: string;
|
||||
};
|
||||
|
||||
export const gitCommitInfoPlugin = (options?: GitCommitInfoPluginOptions): TestRunnerPlugin => {
|
||||
return {
|
||||
name: 'playwright:git-commit-info',
|
||||
|
||||
setup: async (config: FullConfig, configDir: string) => {
|
||||
const commitInfo = await linksFromEnv();
|
||||
await enrichStatusFromCLI(options?.directory || configDir, commitInfo);
|
||||
config.metadata = config.metadata || {};
|
||||
config.metadata['git.commit.info'] = commitInfo;
|
||||
const metadata = config.metadata as UserMetadataWithCommitInfo;
|
||||
const ci = ciInfo();
|
||||
if (!metadata.ci && ci)
|
||||
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 {
|
||||
directory?: string;
|
||||
function ciInfo(): CIInfo | undefined {
|
||||
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> {
|
||||
const out: GitCommitInfo = {};
|
||||
// Jenkins: https://www.jenkins.io/doc/book/pipeline/jenkinsfile/#using-environment-variables
|
||||
if (process.env.BUILD_URL) {
|
||||
out.ci = out.ci || {};
|
||||
out.ci.link = process.env.BUILD_URL;
|
||||
}
|
||||
// GitLab: https://docs.gitlab.com/ee/ci/variables/predefined_variables.html
|
||||
if (process.env.CI_PROJECT_URL && process.env.CI_COMMIT_SHA) {
|
||||
out.revision = out.revision || {};
|
||||
out.revision.link = `${process.env.CI_PROJECT_URL}/-/commit/${process.env.CI_COMMIT_SHA}`;
|
||||
}
|
||||
if (process.env.CI_JOB_URL) {
|
||||
out.ci = out.ci || {};
|
||||
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)}:`;
|
||||
async function gitCommitInfo(gitDir: string): Promise<GitCommitInfo | undefined> {
|
||||
const separator = `---786eec917292---`;
|
||||
const tokens = [
|
||||
'%H', // commit hash
|
||||
'%h', // abbreviated commit hash
|
||||
'%s', // subject
|
||||
'%B', // raw body (unwrapped subject and body)
|
||||
'%an', // author name
|
||||
'%ae', // author email
|
||||
'%at', // author date, UNIX timestamp
|
||||
'%cn', // committer name
|
||||
'%ce', // committer email
|
||||
'%ct', // committer date, UNIX timestamp
|
||||
'', // branch
|
||||
];
|
||||
const commitInfoResult = await spawnAsync(
|
||||
'git',
|
||||
['show', '-s', `--format=%H${separator}%s${separator}%an${separator}%ae${separator}%ct`, 'HEAD'],
|
||||
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
|
||||
`git log -1 --pretty=format:"${tokens.join(separator)}" && git rev-parse --abbrev-ref HEAD`, [],
|
||||
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS, shell: true }
|
||||
);
|
||||
if (commitInfoResult.code)
|
||||
return;
|
||||
return undefined;
|
||||
const showOutput = commitInfoResult.stdout.trim();
|
||||
const [id, subject, author, email, rawTimestamp] = showOutput.split(separator);
|
||||
let timestamp: number = Number.parseInt(rawTimestamp, 10);
|
||||
timestamp = Number.isInteger(timestamp) ? timestamp * 1000 : 0;
|
||||
const [hash, shortHash, subject, body, authorName, authorEmail, authorTime, committerName, committerEmail, committerTime, branch] = showOutput.split(separator);
|
||||
|
||||
commitInfo.revision = {
|
||||
...commitInfo.revision,
|
||||
id,
|
||||
author,
|
||||
email,
|
||||
return {
|
||||
shortHash,
|
||||
hash,
|
||||
subject,
|
||||
timestamp,
|
||||
body,
|
||||
author: {
|
||||
name: authorName,
|
||||
email: authorEmail,
|
||||
time: +authorTime * 1000,
|
||||
},
|
||||
committer: {
|
||||
name: committerName,
|
||||
email: committerEmail,
|
||||
time: +committerTime * 1000,
|
||||
},
|
||||
branch: branch.trim(),
|
||||
};
|
||||
|
||||
const diffLimit = 1_000_000; // 1MB
|
||||
if (commitInfo.pull_request?.base) {
|
||||
const pullDiffResult = await spawnAsync(
|
||||
'git',
|
||||
['diff', commitInfo.pull_request?.base],
|
||||
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
|
||||
);
|
||||
if (!pullDiffResult.code)
|
||||
commitInfo.pull_request!.diff = 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);
|
||||
}
|
||||
}
|
||||
|
||||
async function gitDiff(gitDir: string, ci?: CIInfo): Promise<string | undefined> {
|
||||
const diffLimit = 100_000;
|
||||
const baseHash = ci?.baseHash ?? 'HEAD~1';
|
||||
|
||||
const pullDiffResult = await spawnAsync(
|
||||
'git',
|
||||
['diff', baseHash],
|
||||
{ stdio: 'pipe', cwd: gitDir, timeout: GIT_OPERATIONS_TIMEOUT_MS }
|
||||
);
|
||||
if (!pullDiffResult.code)
|
||||
return pullDiffResult.stdout.substring(0, diffLimit);
|
||||
}
|
||||
|
|
|
@ -35,4 +35,3 @@ export type TestRunnerPluginRegistration = {
|
|||
};
|
||||
|
||||
export { webServer } from './webServerPlugin';
|
||||
export { gitCommitInfo } from './gitCommitInfoPlugin';
|
||||
|
|
|
@ -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
|
||||
* 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
|
||||
* environments.
|
||||
* On selected CI providers, both will be generated automatically. Specifying values will prevent the automatic
|
||||
* generation.
|
||||
*
|
||||
* **Usage**
|
||||
*
|
||||
|
|
|
@ -24,20 +24,20 @@ import type { StackFrame } from '@protocol/channels';
|
|||
import { CopyToClipboardTextButton } from './copyToClipboard';
|
||||
import { attachmentURL } from './attachmentsTab';
|
||||
import { fixTestPrompt } from '@web/components/prompts';
|
||||
import type { GitCommitInfo } from '@testIsomorphic/types';
|
||||
import type { MetadataWithCommitInfo } from '@testIsomorphic/types';
|
||||
import { AIConversation } from './aiConversation';
|
||||
import { ToolbarButton } from '@web/components/toolbarButton';
|
||||
import { useIsLLMAvailable, useLLMChat } from './llm';
|
||||
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 }>) {
|
||||
return <GitCommitInfoContext.Provider value={gitCommitInfo}>{children}</GitCommitInfoContext.Provider>;
|
||||
export function CommitInfoProvider({ children, commitInfo }: React.PropsWithChildren<{ commitInfo: MetadataWithCommitInfo }>) {
|
||||
return <CommitInfoContext.Provider value={commitInfo}>{children}</CommitInfoContext.Provider>;
|
||||
}
|
||||
|
||||
export function useGitCommitInfo() {
|
||||
return React.useContext(GitCommitInfoContext);
|
||||
export function useCommitInfo() {
|
||||
return React.useContext(CommitInfoContext);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const [showLLM, setShowLLM] = React.useState(false);
|
||||
const llmAvailable = useIsLLMAvailable();
|
||||
const gitCommitInfo = useGitCommitInfo();
|
||||
const diff = gitCommitInfo?.pull_request?.diff ?? gitCommitInfo?.revision?.diff;
|
||||
const metadata = useCommitInfo();
|
||||
|
||||
let location: string | undefined;
|
||||
let longLocation: string | undefined;
|
||||
|
@ -127,8 +126,8 @@ function Error({ message, error, errorId, sdkLanguage, pageSnapshot, revealInSou
|
|||
</div>}
|
||||
<span style={{ position: 'absolute', right: '5px' }}>
|
||||
{llmAvailable
|
||||
? <FixWithAIButton conversationId={errorId} onChange={setShowLLM} value={showLLM} error={message} diff={diff} pageSnapshot={pageSnapshot} />
|
||||
: <CopyPromptButton error={message} pageSnapshot={pageSnapshot} diff={diff} />}
|
||||
? <FixWithAIButton conversationId={errorId} onChange={setShowLLM} value={showLLM} error={message} diff={metadata?.gitDiff} pageSnapshot={pageSnapshot} />
|
||||
: <CopyPromptButton error={message} pageSnapshot={pageSnapshot} diff={metadata?.gitDiff} />}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -37,8 +37,9 @@ import { TestListView } from './uiModeTestListView';
|
|||
import { TraceView } from './uiModeTraceView';
|
||||
import { SettingsView } from './settingsView';
|
||||
import { DefaultSettingsView } from './defaultSettingsView';
|
||||
import { GitCommitInfoProvider } from './errorsTab';
|
||||
import { CommitInfoProvider } from './errorsTab';
|
||||
import { LLMProvider } from './llm';
|
||||
import type { MetadataWithCommitInfo } from '@testIsomorphic/types';
|
||||
|
||||
let xtermSize = { cols: 80, rows: 24 };
|
||||
const xtermDataSource: XtermDataSource = {
|
||||
|
@ -432,7 +433,7 @@ export const UIModeView: React.FC<{}> = ({
|
|||
<XtermWrapper source={xtermDataSource}></XtermWrapper>
|
||||
</div>
|
||||
<div className={clsx('vbox', isShowingOutput && 'hidden')}>
|
||||
<GitCommitInfoProvider gitCommitInfo={testModel?.config.metadata['git.commit.info']}>
|
||||
<CommitInfoProvider commitInfo={testModel?.config.metadata as MetadataWithCommitInfo}>
|
||||
<TraceView
|
||||
pathSeparator={queryParams.pathSeparator}
|
||||
item={selectedItem}
|
||||
|
@ -440,7 +441,7 @@ export const UIModeView: React.FC<{}> = ({
|
|||
revealSource={revealSource}
|
||||
onOpenExternally={location => testServerConnection?.openNoReply({ location: { file: location.file, line: location.line, column: location.column } })}
|
||||
/>
|
||||
</GitCommitInfoProvider>
|
||||
</CommitInfoProvider>
|
||||
</div>
|
||||
</div>}
|
||||
sidebar={<div className='vbox ui-mode-sidebar'>
|
||||
|
|
|
@ -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 = {
|
||||
'uncommitted.txt': `uncommitted file`,
|
||||
'playwright.config.ts': `
|
||||
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': `
|
||||
|
@ -1219,6 +1219,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
|
|||
|
||||
const result = await runInlineTest(files, { reporter: 'dot,html' }, {
|
||||
PLAYWRIGHT_HTML_OPEN: 'never',
|
||||
GITHUB_ACTIONS: '1',
|
||||
GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test',
|
||||
GITHUB_SERVER_URL: 'https://playwright.dev',
|
||||
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 = {
|
||||
'uncommitted.txt': `uncommitted file`,
|
||||
'playwright.config.ts': `
|
||||
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': `
|
||||
|
@ -1281,6 +1282,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
|
|||
|
||||
const result = await runInlineTest(files, { reporter: 'dot,html' }, {
|
||||
PLAYWRIGHT_HTML_OPEN: 'never',
|
||||
GITHUB_ACTIONS: '1',
|
||||
GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test',
|
||||
GITHUB_RUN_ID: 'example-run-id',
|
||||
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(`
|
||||
- list:
|
||||
- listitem:
|
||||
- link "My PR"
|
||||
- listitem:
|
||||
- text: /William <shakespeare@example\\.local>/
|
||||
- link "Logs"
|
||||
- link "https://playwright.dev/microsoft/playwright-example-for-test/commit/example-sha"
|
||||
- list:
|
||||
- listitem: "foo : value1"
|
||||
- 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({
|
||||
'playwright.config.ts': `
|
||||
export default {};
|
||||
|
@ -1330,7 +1329,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
|
|||
'playwright.config.ts': `
|
||||
export default {
|
||||
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': `
|
||||
export default {
|
||||
metadata: {
|
||||
'git.commit.info': {},
|
||||
foo: 'value1',
|
||||
bar: { prop: 'value2' },
|
||||
baz: ['value3', 123]
|
||||
|
@ -2799,6 +2797,7 @@ for (const useIntermediateMergeReport of [true, false] as const) {
|
|||
|
||||
const result = await runInlineTest(files, { reporter: 'dot,html' }, {
|
||||
PLAYWRIGHT_HTML_OPEN: 'never',
|
||||
GITHUB_ACTIONS: '1',
|
||||
GITHUB_REPOSITORY: 'microsoft/playwright-example-for-test',
|
||||
GITHUB_RUN_ID: 'example-run-id',
|
||||
GITHUB_SERVER_URL: 'https://playwright.dev',
|
||||
|
|
|
@ -21,14 +21,13 @@ test('should render html report git info metadata', async ({ runUITest }) => {
|
|||
'reporter.ts': `
|
||||
module.exports = class Reporter {
|
||||
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': `
|
||||
import { defineConfig } from '@playwright/test';
|
||||
export default defineConfig({
|
||||
metadata: { 'git.commit.info': {} },
|
||||
reporter: './reporter.ts',
|
||||
});
|
||||
`,
|
||||
|
@ -37,6 +36,7 @@ test('should render html report git info metadata', async ({ runUITest }) => {
|
|||
test('should work', async ({}) => {});
|
||||
`
|
||||
}, {
|
||||
JENKINS_URL: '1',
|
||||
BUILD_URL: 'https://playwright.dev',
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in New Issue