Skip to content

Commit

Permalink
feat: support playwright in gui mode
Browse files Browse the repository at this point in the history
  • Loading branch information
DudaGod committed Jul 4, 2024
1 parent ae31741 commit 3274f33
Show file tree
Hide file tree
Showing 40 changed files with 3,317 additions and 2,144 deletions.
121 changes: 121 additions & 0 deletions lib/adapters/config/playwright.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import path from 'node:path';
import crypto from 'node:crypto';
import _ from 'lodash';

import type {ConfigAdapter, BrowserConfigAdapter} from './index';
import type {FullConfig, FullProject} from '@playwright/test/reporter';
import type {TestAdapter} from '../test';

export type PwtProject = FullProject & {
snapshotPathTemplate?: string;
};

export type PwtConfig = FullConfig & {
testDir?: string;
snapshotDir?: string;
snapshotPathTemplate?: string;
projects?: PwtProject[]
}

export const DEFAULT_BROWSER_ID = 'chromium';
const DEFAULT_SNAPSHOT_PATH_TEMPLATE = '{snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}';

export class PlaywrightConfigAdapter implements ConfigAdapter {
private _config: PwtConfig;
private _browserIds: string[];

static create<T extends PlaywrightConfigAdapter>(this: new (config: PwtConfig) => T, config: PwtConfig): T {
return new this(config);
}

constructor(config: PwtConfig) {
this._config = config;
this._browserIds = _.isEmpty(this._config.projects) ? [DEFAULT_BROWSER_ID] : this._config.projects.map(prj => prj.name).filter(Boolean);
}

get original(): PwtConfig {
return this._config;
}

get tolerance(): number {
return 2.3;
}

get antialiasingTolerance(): number {
return 4;
}

getScreenshotPath(test: TestAdapter, stateName: string): string {
const subPath = `${stateName}.png`;
const parsedSubPath = path.parse(subPath);
const parsedRelativeTestFilePath = path.parse(test.file);

const currProject = (this._config.projects || []).find(prj => prj.name === test.browserId) as PwtProject || {};
const projectNamePathSegment = sanitizeForFilePath(test.browserId);

const snapshotPathTemplate = currProject.snapshotPathTemplate || this._config.snapshotPathTemplate || DEFAULT_SNAPSHOT_PATH_TEMPLATE;

const testDir = path.resolve(currProject.testDir || this._config.testDir || '');
let snapshotDir = currProject.snapshotDir || this._config.snapshotDir;
snapshotDir = snapshotDir ? path.resolve(snapshotDir) : testDir;

const snapshotSuffix = process.platform;

const snapshotPath = snapshotPathTemplate
.replace(/\{(.)?testDir\}/g, '$1' + testDir)
.replace(/\{(.)?snapshotDir\}/g, '$1' + snapshotDir)
.replace(/\{(.)?snapshotSuffix\}/g, snapshotSuffix ? '$1' + snapshotSuffix : '')
.replace(/\{(.)?testFileDir\}/g, '$1' + parsedRelativeTestFilePath.dir)
.replace(/\{(.)?platform\}/g, '$1' + process.platform)
.replace(/\{(.)?projectName\}/g, projectNamePathSegment ? '$1' + projectNamePathSegment : '')
.replace(/\{(.)?testName\}/g, '$1' + fsSanitizedTestName(test.titlePath))
.replace(/\{(.)?testFileName\}/g, '$1' + parsedRelativeTestFilePath.base)
.replace(/\{(.)?testFilePath\}/g, '$1' + test.file)
.replace(/\{(.)?arg\}/g, '$1' + path.join(parsedSubPath.dir, parsedSubPath.name))
.replace(/\{(.)?ext\}/g, parsedSubPath.ext ? '$1' + parsedSubPath.ext : '');

return path.normalize(path.resolve(snapshotPath));
}

getBrowserIds(): string[] {
return this._browserIds;
}

forBrowser(browserId: string): BrowserConfigAdapter {
return {
id: browserId,
retry: 0
};
}
}

function sanitizeForFilePath(s: string): string {
// eslint-disable-next-line no-control-regex
return s.replace(/[\x00-\x2C\x2E-\x2F\x3A-\x40\x5B-\x60\x7B-\x7F]+/g, '-');
}

function fsSanitizedTestName(titlePath: string[]): string {
const fullTitleWithoutSpec = titlePath.join(' ');

return sanitizeForFilePath(trimLongString(fullTitleWithoutSpec));
}

function trimLongString(s: string, length = 100): string {
if (s.length <= length) {
return s;
}

const hash = calculateSha1(s);
const middle = `-${hash.substring(0, 5)}-`;
const start = Math.floor((length - middle.length) / 2);
const end = length - middle.length - start;

return s.substring(0, start) + middle + s.slice(-end);
}

function calculateSha1(buffer: Buffer | string): string {
const hash = crypto.createHash('sha1');
hash.update(buffer);

return hash.digest('hex');
}
23 changes: 23 additions & 0 deletions lib/adapters/test-collection/playwright.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {PlaywrightTestAdapter, type PwtRawTest} from '../test/playwright';
import type {TestCollectionAdapter, TestsCallback} from './index';

export class PlaywrightTestCollectionAdapter implements TestCollectionAdapter {
private _tests: PwtRawTest[];

static create<T>(
this: new (tests: PwtRawTest[]) => T,
tests: PwtRawTest[]
): T {
return new this(tests);
}

constructor(tests: PwtRawTest[]) {
this._tests = tests;
}

eachTest(cb: TestsCallback): void {
for (const test of this._tests) {
cb(PlaywrightTestAdapter.create(test), test.browserName);
}
}
}
57 changes: 48 additions & 9 deletions lib/adapters/test-result/playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,27 @@ import stripAnsi from 'strip-ansi';

import {ReporterTestResult} from './index';
import {getError, getShortMD5, isImageDiffError, isNoRefImageError} from '../../common-utils';
import {ERROR, FAIL, PWT_TITLE_DELIMITER, SUCCESS, TestStatus} from '../../constants';
import {ERROR, FAIL, SUCCESS, UPDATED, TestStatus, DEFAULT_TITLE_DELIMITER} from '../../constants';
import {ErrorName} from '../../errors';
import {
DiffOptions,
ErrorDetails,
ImageFile,
ImageInfoDiff,
ImageInfoFull, ImageInfoNoRef, ImageInfoPageError, ImageInfoPageSuccess, ImageInfoSuccess,
ImageInfoFull, ImageInfoNoRef, ImageInfoPageError, ImageInfoPageSuccess, ImageInfoSuccess, ImageInfoUpdated,
ImageSize,
TestError
} from '../../types';
import type {CoordBounds} from 'looks-same';

export type PlaywrightAttachment = PlaywrightTestResult['attachments'][number];
export type PlaywrightAttachment = PlaywrightTestResult['attachments'][number] & {
relativePath?: string,
size?: {
width: number;
height: number;
},
isUpdated?: boolean
};

type ExtendedError<T> = TestError & {meta?: T & {type: string}};

Expand All @@ -32,7 +39,7 @@ export enum PwtTestStatus {
FAILED = 'failed',
TIMED_OUT = 'timedOut',
INTERRUPTED = 'interrupted',
SKIPPED = 'skipped',
SKIPPED = 'skipped'
}

export enum ImageTitleEnding {
Expand All @@ -48,7 +55,19 @@ export const DEFAULT_DIFF_OPTIONS = {
diffColor: '#ff00ff'
} satisfies Partial<DiffOptions>;

export const getStatus = (result: PlaywrightTestResult): TestStatus => {
export interface TestResultWithGuiStatus extends Omit<PlaywrightTestResult, 'status'> {
status: PlaywrightTestResult['status'] | TestStatus.RUNNING | TestStatus.UPDATED;
}

export const getStatus = (result: TestResultWithGuiStatus): TestStatus => {
if (result.status === TestStatus.RUNNING) {
return TestStatus.RUNNING;
}

if (result.status === TestStatus.UPDATED) {
return TestStatus.UPDATED;
}

if (result.status === PwtTestStatus.PASSED) {
return TestStatus.SUCCESS;
}
Expand Down Expand Up @@ -127,7 +146,8 @@ const getImageData = (attachment: PlaywrightAttachment | undefined): ImageFile |

return {
path: attachment.path as string,
size: _.pick(sizeOf(attachment.path as string), ['height', 'width']) as ImageSize
size: !attachment.size ? _.pick(sizeOf(attachment.path as string), ['height', 'width']) as ImageSize : attachment.size,
...(attachment.relativePath ? {relativePath: attachment.relativePath} : {})
};
};

Expand All @@ -136,6 +156,15 @@ export class PlaywrightTestResultAdapter implements ReporterTestResult {
private readonly _testResult: PlaywrightTestResult;
private _attempt: number;

static create<T extends PlaywrightTestResultAdapter>(
this: new (testCase: PlaywrightTestCase, testResult: PlaywrightTestResult, attempt: number) => T,
testCase: PlaywrightTestCase,
testResult: PlaywrightTestResult,
attempt: number
): T {
return new this(testCase, testResult, attempt);
}

constructor(testCase: PlaywrightTestCase, testResult: PlaywrightTestResult, attempt: number) {
this._testCase = testCase;
this._testResult = testResult;
Expand All @@ -157,6 +186,7 @@ export class PlaywrightTestResultAdapter implements ReporterTestResult {

get error(): TestError | undefined {
const message = extractErrorMessage(this._testResult);

if (message) {
const result: TestError = {name: ErrorName.GENERAL_ERROR, message};

Expand All @@ -165,7 +195,7 @@ export class PlaywrightTestResultAdapter implements ReporterTestResult {
result.stack = stack;
}

if (message.includes('snapshot doesn\'t exist') && message.includes('.png')) {
if (/snapshot .*doesn't exist/.test(message) && message.includes('.png')) {
result.name = ErrorName.NO_REF_IMAGE;
} else if (message.includes('Screenshot comparison failed')) {
result.name = ErrorName.IMAGE_DIFF;
Expand All @@ -185,15 +215,15 @@ export class PlaywrightTestResultAdapter implements ReporterTestResult {
}

get fullName(): string {
return this.testPath.join(PWT_TITLE_DELIMITER);
return this.testPath.join(DEFAULT_TITLE_DELIMITER);
}

get history(): string[] {
return this._testResult.steps.map(step => `${step.title} <- ${step.duration}ms\n`);
}

get id(): string {
return this.testPath.concat(this.browserId, this.attempt.toString()).join(' ');
return this.testPath.concat(this.browserId, this.attempt.toString()).join(DEFAULT_TITLE_DELIMITER);
}

get imageDir(): string {
Expand Down Expand Up @@ -230,6 +260,14 @@ export class PlaywrightTestResultAdapter implements ReporterTestResult {
error: _.pick(error, ['message', 'name', 'stack']),
actualImg
} satisfies ImageInfoNoRef;
} else if (expectedAttachment?.isUpdated && expectedImg && actualImg) {
return {
status: UPDATED,
stateName: state,
refImg: _.clone(expectedImg),
expectedImg,
actualImg
} satisfies ImageInfoUpdated;
} else if (!error && expectedImg) {
return {
status: SUCCESS,
Expand Down Expand Up @@ -281,6 +319,7 @@ export class PlaywrightTestResultAdapter implements ReporterTestResult {

get status(): TestStatus {
const status = getStatus(this._testResult);

if (status === TestStatus.FAIL) {
if (isNoRefImageError(this.error) || isImageDiffError(this.error)) {
return FAIL;
Expand Down
18 changes: 17 additions & 1 deletion lib/adapters/test/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
import {TestStatus} from '../../constants';
import type {ReporterTestResult} from '../test-result';
import type {AssertViewResult} from '../../types';

export interface CreateTestResultOpts {
status: TestStatus;
attempt?: number;
assertViewResults?: AssertViewResult[];
error?: Error;
sessionId?: string;
meta?: {
url?: string;
}
}

export interface TestAdapter {
readonly id: string;
readonly pending: boolean;
readonly disabled: boolean;
readonly browserId: string;
readonly file: string;
readonly title: string;
readonly titlePath: string[];

clone(): TestAdapter;
fullTitle(): string;
isSilentlySkipped(): boolean;
formatTestResult(status: TestStatus, attempt?: number): ReporterTestResult;
// TODO: rename to mkTestResult ???
createTestResult(opts: CreateTestResultOpts): ReporterTestResult;
}
Loading

0 comments on commit 3274f33

Please sign in to comment.