diff --git a/lib/adapters/config/playwright.ts b/lib/adapters/config/playwright.ts new file mode 100644 index 000000000..bf12a858c --- /dev/null +++ b/lib/adapters/config/playwright.ts @@ -0,0 +1,115 @@ +import path from 'node:path'; +import crypto from 'node:crypto'; +import _ from 'lodash'; + +import type {ConfigAdapter} 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(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; + } + + get browserIds(): string[] { + return this._browserIds; + } + + // used from pwt - /~https://github.com/microsoft/playwright/blob/v1.45.1/packages/playwright/src/worker/testInfo.ts#L452-L473 + 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)); + } +} + +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'); +} diff --git a/lib/adapters/test-collection/playwright.ts b/lib/adapters/test-collection/playwright.ts new file mode 100644 index 000000000..c069f2ee6 --- /dev/null +++ b/lib/adapters/test-collection/playwright.ts @@ -0,0 +1,21 @@ +import {PlaywrightTestAdapter, type PwtRawTest} from '../test/playwright'; +import type {TestCollectionAdapter} from './'; + +export class PlaywrightTestCollectionAdapter implements TestCollectionAdapter { + private _testAdapters: PlaywrightTestAdapter[]; + + static create( + this: new (tests: PwtRawTest[]) => T, + tests: PwtRawTest[] + ): T { + return new this(tests); + } + + constructor(tests: PwtRawTest[]) { + this._testAdapters = tests.map(test => PlaywrightTestAdapter.create(test)); + } + + get tests(): PlaywrightTestAdapter[] { + return this._testAdapters; + } +} diff --git a/lib/adapters/test-collection/testplane.ts b/lib/adapters/test-collection/testplane.ts index 78880b10c..d4d252eaa 100644 --- a/lib/adapters/test-collection/testplane.ts +++ b/lib/adapters/test-collection/testplane.ts @@ -1,5 +1,5 @@ import {TestplaneTestAdapter} from '../test/testplane'; -import type {TestCollectionAdapter} from './index'; +import type {TestCollectionAdapter} from './'; import type {TestCollection} from 'testplane'; export class TestplaneTestCollectionAdapter implements TestCollectionAdapter { diff --git a/lib/adapters/test-result/playwright.ts b/lib/adapters/test-result/playwright.ts index 2a55d2657..56c69ec57 100644 --- a/lib/adapters/test-result/playwright.ts +++ b/lib/adapters/test-result/playwright.ts @@ -6,20 +6,27 @@ import stripAnsi from 'strip-ansi'; import {ReporterTestResult} from './index'; import {getError, getShortMD5, isImageDiffError, isNoRefImageError} from '../../common-utils'; -import {ERROR, FAIL, DEFAULT_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 = TestError & {meta?: T & {type: string}}; @@ -32,7 +39,7 @@ export enum PwtTestStatus { FAILED = 'failed', TIMED_OUT = 'timedOut', INTERRUPTED = 'interrupted', - SKIPPED = 'skipped', + SKIPPED = 'skipped' } export enum ImageTitleEnding { @@ -42,13 +49,25 @@ export enum ImageTitleEnding { Previous = '-previous.png' } +export interface TestResultWithGuiStatus extends Omit { + status: PlaywrightTestResult['status'] | TestStatus.RUNNING | TestStatus.UPDATED; +} + const ANY_IMAGE_ENDING_REGEXP = new RegExp(Object.values(ImageTitleEnding).map(ending => `${ending}$`).join('|')); export const DEFAULT_DIFF_OPTIONS = { diffColor: '#ff00ff' } satisfies Partial; -export const getStatus = (result: PlaywrightTestResult): TestStatus => { +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; } @@ -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} : {}) }; }; @@ -136,6 +156,15 @@ export class PlaywrightTestResultAdapter implements ReporterTestResult { private readonly _testResult: PlaywrightTestResult; private _attempt: number; + static create( + 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; @@ -165,7 +194,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; @@ -230,6 +259,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, diff --git a/lib/adapters/test/index.ts b/lib/adapters/test/index.ts index 4296a8d37..0638ca4ce 100644 --- a/lib/adapters/test/index.ts +++ b/lib/adapters/test/index.ts @@ -20,6 +20,8 @@ export interface TestAdapter { readonly silentlySkipped: boolean; readonly browserId: string; readonly fullName: string; + readonly file: string; + readonly titlePath: string[]; createTestResult(opts: CreateTestResultOpts): ReporterTestResult; } diff --git a/lib/adapters/test/playwright.ts b/lib/adapters/test/playwright.ts new file mode 100644 index 000000000..a180eb005 --- /dev/null +++ b/lib/adapters/test/playwright.ts @@ -0,0 +1,137 @@ +import type {TestCase, TestResult} from '@playwright/test/reporter'; + +import {PlaywrightTestResultAdapter, ImageTitleEnding, type PlaywrightAttachment} from '../test-result/playwright'; +import {UNKNOWN_ATTEMPT} from '../../constants'; + +import type {TestAdapter, CreateTestResultOpts} from './'; +import type {ReporterTestResult} from '../test-result'; +import type {AssertViewResult, ImageFile, RefImageFile} from '../../types'; +import type {ImageDiffError} from '../../errors'; + +export type PwtRawTest = { + file: string; + browserName: string; + title: string; + titlePath: string[]; +} + +export class PlaywrightTestAdapter implements TestAdapter { + private _test: PwtRawTest; + + static create(this: new (test: PwtRawTest) => T, test: PwtRawTest): T { + return new this(test); + } + + constructor(test: PwtRawTest) { + this._test = test; + } + + get original(): PwtRawTest { + return this._test; + } + + get id(): string { + return this._test.title; + } + + get pending(): boolean { + return false; + } + + get disabled(): boolean { + return false; + } + + get silentlySkipped(): boolean { + return false; + } + + get browserId(): string { + return this._test.browserName; + } + + get fullName(): string { + return this._test.title; + } + + get file(): string { + return this._test.file; + } + + get titlePath(): string[] { + return this._test.titlePath; + } + + createTestResult(opts: CreateTestResultOpts): ReporterTestResult { + const {status, attempt = UNKNOWN_ATTEMPT, assertViewResults = []} = opts; + + const testCase = { + titlePath: () => ['', this._test.browserName, this._test.file, ...this._test.titlePath], + title: this._test.title, + annotations: [], + location: { + file: this._test.file + }, + parent: { + project: () => ({ + name: this._test.browserName + }) + } + } as unknown as TestCase; + + const attachments = assertViewResults.map(assertViewResult => { + const attachmentByState = [generateExpectedAttachment(assertViewResult, assertViewResult.refImg)]; + + if ((assertViewResult as ImageDiffError).currImg) { + attachmentByState.push(generateActualAttachment(assertViewResult, (assertViewResult as ImageDiffError).currImg)); + } + + if ((assertViewResult as ImageDiffError).diffImg) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + attachmentByState.push(generateDiffAttachment(assertViewResult, (assertViewResult as ImageDiffError).diffImg!)); + } + + return attachmentByState; + }).flat(); + + const result = { + attachments, + status, + steps: [], + startTime: new Date() + } as unknown as TestResult; + + return PlaywrightTestResultAdapter.create(testCase, result, attempt); + } +} + +function generateExpectedAttachment(assertViewResult: AssertViewResult, imageFile: ImageFile): PlaywrightAttachment { + return { + name: `${assertViewResult.stateName}${ImageTitleEnding.Expected}`, + relativePath: (assertViewResult.refImg as RefImageFile).relativePath, + ...generateAttachment(assertViewResult, imageFile) + }; +} + +function generateActualAttachment(assertViewResult: AssertViewResult, imageFile: ImageFile): PlaywrightAttachment { + return { + name: `${assertViewResult.stateName}${ImageTitleEnding.Actual}`, + ...generateAttachment(assertViewResult, imageFile) + }; +} + +function generateDiffAttachment(assertViewResult: AssertViewResult, imageFile: ImageFile): PlaywrightAttachment { + return { + name: `${assertViewResult.stateName}${ImageTitleEnding.Diff}`, + ...generateAttachment(assertViewResult, imageFile) + }; +} + +function generateAttachment(assertViewResult: AssertViewResult, imageFile: ImageFile): Pick { + return { + path: imageFile.path, + contentType: 'image/png', + size: imageFile.size, + isUpdated: assertViewResult.isUpdated + }; +} diff --git a/lib/adapters/test/testplane.ts b/lib/adapters/test/testplane.ts index bec5c3c05..9ce6d119c 100644 --- a/lib/adapters/test/testplane.ts +++ b/lib/adapters/test/testplane.ts @@ -1,5 +1,5 @@ import {TestplaneTestResultAdapter} from '../test-result/testplane'; -import {UNKNOWN_ATTEMPT} from '../../constants'; +import {DEFAULT_TITLE_DELIMITER, UNKNOWN_ATTEMPT} from '../../constants'; import type {TestAdapter, CreateTestResultOpts} from './'; import type {Test, Suite} from 'testplane'; @@ -44,6 +44,14 @@ export class TestplaneTestAdapter implements TestAdapter { return this._test.fullTitle(); } + get file(): string { + return this._test.file; + } + + get titlePath(): string[] { + return this._test.fullTitle().split(DEFAULT_TITLE_DELIMITER); + } + createTestResult(opts: CreateTestResultOpts): ReporterTestResult { const {status, assertViewResults, error, sessionId, meta, attempt = UNKNOWN_ATTEMPT} = opts; const test = this._test.clone(); diff --git a/lib/adapters/tool/index.ts b/lib/adapters/tool/index.ts index 8f8c5091b..50fba31c3 100644 --- a/lib/adapters/tool/index.ts +++ b/lib/adapters/tool/index.ts @@ -45,7 +45,9 @@ export const makeToolAdapter = async (opts: ToolAdapterOptionsFromCli): Promise< return TestplaneToolAdapter.create(opts); } else if (opts.toolName === ToolName.Playwright) { - throw new Error('Playwright is not supported yet'); + const {PlaywrightToolAdapter} = await import('./playwright'); + + return PlaywrightToolAdapter.create(opts); } else { throw new Error(`Tool adapter with name: "${opts.toolName}" is not supported`); } diff --git a/lib/adapters/tool/playwright/index.ts b/lib/adapters/tool/playwright/index.ts new file mode 100644 index 000000000..fbea67b58 --- /dev/null +++ b/lib/adapters/tool/playwright/index.ts @@ -0,0 +1,315 @@ +import path from 'node:path'; +import os from 'node:os'; +import {spawn} from 'node:child_process'; + +import npmWhich from 'npm-which'; +import PQueue from 'p-queue'; +import _ from 'lodash'; + +import {PlaywrightConfigAdapter, DEFAULT_BROWSER_ID} from '../../config/playwright'; +import {PlaywrightTestCollectionAdapter} from '../../test-collection/playwright'; +import {parseConfig} from '../../../config'; +import {HtmlReporter} from '../../../plugin-api'; +import {setupTransformHook} from './transformer'; +import {ToolName, UNKNOWN_ATTEMPT, PWT_TITLE_DELIMITER, DEFAULT_TITLE_DELIMITER, TestStatus} from '../../../constants'; +import {ClientEvents} from '../../../gui/constants'; +import {GuiApi} from '../../../gui/api'; +import {PlaywrightTestResultAdapter} from '../../test-result/playwright'; +import ipc from './ipc'; +import pkg from '../../../../package.json'; +import {logger} from '../../../common-utils'; + +import type {ToolAdapter, ToolAdapterOptionsFromCli} from '../index'; +import type {GuiReportBuilder} from '../../../report-builder/gui'; +import type {EventSource} from '../../../gui/event-source'; +import type {PwtRawTest} from '../../test/playwright'; +import type {ReporterConfig} from '../../../types'; +import type {TestSpec} from '../types'; +import type {FullConfig, TestCase, TestResult} from '@playwright/test/reporter'; +import type {TestBranch} from '../../../tests-tree-builder/gui'; +import type {PwtEventMessage} from './reporter'; + +export const DEFAULT_CONFIG_PATHS = [ + `${ToolName.Playwright}.config.ts`, + `${ToolName.Playwright}.config.js`, + `${ToolName.Playwright}.config.mts`, + `${ToolName.Playwright}.config.mjs`, + `${ToolName.Playwright}.config.cts`, + `${ToolName.Playwright}.config.cjs` +]; + +export class PlaywrightToolAdapter implements ToolAdapter { + private _toolName: ToolName; + private _configPath: string; + private _config: PlaywrightConfigAdapter; + private _reporterConfig: ReporterConfig; + private _hasProjectsInConfig: boolean; + private _htmlReporter: HtmlReporter; + private _pwtBinaryPath: string; + private _reportBuilder!: GuiReportBuilder; + private _eventSource!: EventSource; + private _guiApi?: GuiApi; + + static create( + this: new (options: ToolAdapterOptionsFromCli) => PlaywrightToolAdapter, + options: ToolAdapterOptionsFromCli + ): PlaywrightToolAdapter { + return new this(options); + } + + constructor(opts: ToolAdapterOptionsFromCli) { + const {config, configPath} = readPwtConfig(opts); + + this._configPath = configPath; + this._config = PlaywrightConfigAdapter.create(config); + this._toolName = opts.toolName; + + const pluginOpts = getPluginOptions(this._config.original); + this._reporterConfig = parseConfig(pluginOpts); + + this._hasProjectsInConfig = !_.isEmpty(this._config.original.projects); + + this._htmlReporter = HtmlReporter.create(this._reporterConfig, {toolName: ToolName.Playwright}); + this._pwtBinaryPath = npmWhich.sync(ToolName.Playwright, {cwd: process.cwd()}); + } + + get configPath(): string { + return this._configPath; + } + + get toolName(): ToolName { + return this._toolName; + } + + get config(): PlaywrightConfigAdapter { + return this._config; + } + + get reporterConfig(): ReporterConfig { + return this._reporterConfig; + } + + get htmlReporter(): HtmlReporter { + return this._htmlReporter; + } + + get guiApi(): GuiApi | undefined { + return this._guiApi; + } + + initGuiApi(): void { + this._guiApi = GuiApi.create(); + } + + async readTests(): Promise { + const stdout = await new Promise((resolve, reject) => { + // specify default browser in order to get correct stdout with browser name + const browserArgs = this._hasProjectsInConfig ? [] : ['--browser', DEFAULT_BROWSER_ID]; + + const child = spawn(this._pwtBinaryPath, ['test', '--list', '--reporter', 'list', '--config', this._configPath, ...browserArgs]); + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => stdout += data); + child.stderr.on('data', (data) => stderr += data); + + child.on('error', (error) => reject(error)); + + child.on('exit', (code) => { + if (code !== 0) { + return reject(new Error(`Playwright process with reading tests exited with code: ${code}, stderr: ${stderr}`)); + } + + resolve(stdout); + }); + }); + + const stdoutByLine = stdout.split('\n').map(v => v.trim()); + const startIndex = stdoutByLine.findIndex(v => v === 'Listing tests:'); + const endIndex = stdoutByLine.findIndex(v => /total: \d+ tests? in \d+ file/i.test(v)); + + const tests = stdoutByLine.slice(startIndex + 1, endIndex).map(line => { + const [browserName, file, ...titlePath] = line.split(PWT_TITLE_DELIMITER); + + return { + browserName: browserName.slice(1, -1), + file: file.split(':')[0], + title: titlePath.join(DEFAULT_TITLE_DELIMITER), + titlePath + } as PwtRawTest; + }); + + return PlaywrightTestCollectionAdapter.create(tests); + } + + async run(_testCollection: PlaywrightTestCollectionAdapter, tests: TestSpec[] = []): Promise { + return this._runTests(tests); + } + + async runWithoutRetries(_testCollection: PlaywrightTestCollectionAdapter, tests: TestSpec[] = []): Promise { + return this._runTests(tests, ['--retries', '0']); + } + + private async _runTests(tests: TestSpec[] = [], runArgs: string[] = []): Promise { + if (!this._reportBuilder || !this._eventSource) { + throw new Error('"reportBuilder" and "eventSource" instances must be initialize before run tests'); + } + + const queue = new PQueue({concurrency: os.cpus().length}); + + return new Promise((resolve, reject) => { + const args = ([] as string[]).concat(prepareRunArgs(tests, this._configPath, this._hasProjectsInConfig), runArgs); + const child = spawn(this._pwtBinaryPath, args, { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'] + }); + + ipc.on(ClientEvents.BEGIN_STATE, (data) => { + data.result.status = TestStatus.RUNNING; + + queue.add(async () => { + const testBranch = await registerTestResult(data, this._reportBuilder); + + this._eventSource.emit(data.event, testBranch); + }).catch(reject); + }, child); + + ipc.on(ClientEvents.TEST_RESULT, (data) => { + queue.add(async () => { + const testBranch = await registerTestResult(data, this._reportBuilder); + + this._eventSource.emit(data.event, testBranch); + }).catch(reject); + }, child); + + ipc.on(ClientEvents.END, (data) => { + queue.onIdle().then(() => this._eventSource.emit(data.event)); + }, child); + + child.on('error', (data) => reject(data)); + + child.on('exit', (code) => { + queue.onIdle().then(() => resolve(!code)); + }); + }); + } + + // Can't handle test results here because pwt does not provide api for this, so save instances and use them in custom reporter + handleTestResults(reportBuilder: GuiReportBuilder, eventSource: EventSource): void { + this._reportBuilder = reportBuilder; + this._eventSource = eventSource; + } + + updateReference(): void {} + + halt(err: Error): void { + logger.error(err); + process.exit(1); + } +} + +function readPwtConfig(opts: ToolAdapterOptionsFromCli): {configPath: string, config: FullConfig} { + const configPaths = opts.configPath ? [opts.configPath] : DEFAULT_CONFIG_PATHS; + let originalConfig!: FullConfig; + let resolvedConfigPath!: string; + + const revertTransformHook = setupTransformHook(); + + for (const configPath of configPaths) { + try { + resolvedConfigPath = path.resolve(configPath); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const configModule = require(resolvedConfigPath); + originalConfig = configModule.__esModule ? configModule.default : configModule; + + break; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'MODULE_NOT_FOUND') { + throw err; + } + } + } + + revertTransformHook(); + + if (!originalConfig) { + throw new Error(`Unable to read config from paths: ${configPaths.join(', ')}`); + } + + return {config: originalConfig, configPath: resolvedConfigPath}; +} + +async function registerTestResult(eventMsg: PwtEventMessage, reportBuilder: GuiReportBuilder): Promise { + const {test, result, browserName, titlePath} = eventMsg; + + const testCase = { + ...test, + titlePath: () => titlePath, + parent: { + ...test.parent, + project: () => ({ + name: browserName + }) + } + } as TestCase; + + const testResult = { + ...result, + startTime: new Date(result.startTime) + } as TestResult; + + const formattedResultWithoutAttempt = PlaywrightTestResultAdapter.create(testCase, testResult, UNKNOWN_ATTEMPT); + const formattedResult = await reportBuilder.addTestResult(formattedResultWithoutAttempt); + + return reportBuilder.getTestBranch(formattedResult.id); +} + +function prepareRunArgs(tests: TestSpec[], configPath: string, hasProjectsInConfig: boolean): string[] { + const testNames = new Set(); + const browserNames = new Set(); + + for (const {testName, browserName} of tests) { + testNames.add(testName); + browserNames.add(browserName); + } + + const args = ['test', '--reporter', path.resolve(__dirname, './reporter'), '--config', configPath]; + + if (testNames.size > 0) { + args.push('--grep', Array.from(testNames).map(escapeRegExp).join('|')); + } + + if (browserNames.size > 0) { + const projectArgs = Array.from(browserNames).flatMap(broName => [hasProjectsInConfig ? '--project' : '--browser', broName]); + args.push(...projectArgs); + } else if (!hasProjectsInConfig) { + args.push(...['--browser', DEFAULT_BROWSER_ID]); + } + + return args; +} + +function getPluginOptions(config: PlaywrightConfigAdapter['original']): Partial { + const {reporter: reporters} = config; + + if (!_.isArray(reporters)) { + return {}; + } + + for (const reporter of reporters) { + if (_.isString(reporter)) { + continue; + } + + const [reporterName, reporterOpts = {}] = reporter; + + if (reporterName === `${pkg.name}/${ToolName.Playwright}`) { + return reporterOpts; + } + } + + return {}; +} + +function escapeRegExp(text: string): string { + return text.replace(/[-[\]{}()*+?.,\\^$|#]/g, '\\$&'); +} diff --git a/lib/adapters/tool/playwright/ipc.ts b/lib/adapters/tool/playwright/ipc.ts new file mode 100644 index 000000000..b54d55c2d --- /dev/null +++ b/lib/adapters/tool/playwright/ipc.ts @@ -0,0 +1,17 @@ +import _ from 'lodash'; +import type {ChildProcess} from 'node:child_process'; + +export default { + emit: (event: string, data: Record = {}): void => { + process.send && process.send({event, ...data}); + }, + on: >(event: string, handler: (msg: T & {event: string}) => void, proc: ChildProcess | NodeJS.Process = process): void => { + proc.on('message', (msg: T & {event: string}) => { + if (event !== _.get(msg, 'event')) { + return; + } + + handler(msg); + }); + } +}; diff --git a/lib/adapters/tool/playwright/reporter.ts b/lib/adapters/tool/playwright/reporter.ts new file mode 100644 index 000000000..32c23a547 --- /dev/null +++ b/lib/adapters/tool/playwright/reporter.ts @@ -0,0 +1,36 @@ +import stringify from 'json-stringify-safe'; +import ipc from './ipc'; +import {ClientEvents} from '../../../gui/constants'; + +import type {Reporter, TestCase} from '@playwright/test/reporter'; +import type {TestResultWithGuiStatus} from '../../test-result/playwright'; + +export type PwtEventMessage = { + test: TestCase; + result: TestResultWithGuiStatus; + browserName: string; + titlePath: string[]; +}; + +export default class MyReporter implements Reporter { + onTestBegin(test: TestCase, result: TestResultWithGuiStatus): void { + ipc.emit(ClientEvents.BEGIN_STATE, getEmittedData(test, result)); + } + + onTestEnd(test: TestCase, result: TestResultWithGuiStatus): void { + ipc.emit(ClientEvents.TEST_RESULT, getEmittedData(test, result)); + } + + onEnd(): void { + ipc.emit(ClientEvents.END); + } +} + +function getEmittedData(test: TestCase, result: TestResultWithGuiStatus): PwtEventMessage { + return { + test: JSON.parse(stringify(test)), + result: JSON.parse(stringify(result)), + browserName: test.parent.project()?.name || '', + titlePath: test.titlePath() + }; +} diff --git a/lib/adapters/tool/playwright/transformer.ts b/lib/adapters/tool/playwright/transformer.ts new file mode 100644 index 000000000..0d10b082a --- /dev/null +++ b/lib/adapters/tool/playwright/transformer.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +export const setupTransformHook: () => VoidFunction = require('../../../bundle').setupTransformHook; diff --git a/lib/cli/commands/merge-reports.js b/lib/cli/commands/merge-reports.js index fea862ca4..fb155ed1f 100644 --- a/lib/cli/commands/merge-reports.js +++ b/lib/cli/commands/merge-reports.js @@ -3,10 +3,13 @@ const {commands} = require('..'); const mergeReports = require('../../merge-reports'); const {logError} = require('../../server-utils'); +const {ToolName} = require('../../constants'); const {MERGE_REPORTS: commandName} = commands; module.exports = (program, toolAdapter) => { + const {toolName} = toolAdapter; + program .command(`${commandName} [paths...]`) .allowUnknownOption() @@ -14,6 +17,10 @@ module.exports = (program, toolAdapter) => { .option('-d, --destination ', 'path to directory with merged report', toolAdapter.reporterConfig.path) .option('-h, --header
', 'http header for databaseUrls.json files from source paths', collect, []) .action(async (paths, options) => { + if (toolName !== ToolName.Testplane) { + throw new Error(`CLI command "${commandName}" supports only "${ToolName.Testplane}" tool`); + } + try { const {destination: destPath, header: headers} = options; diff --git a/lib/cli/commands/remove-unused-screens/index.js b/lib/cli/commands/remove-unused-screens/index.js index 22b3d0999..6c8284a3a 100644 --- a/lib/cli/commands/remove-unused-screens/index.js +++ b/lib/cli/commands/remove-unused-screens/index.js @@ -12,6 +12,7 @@ const {commands} = require('../..'); const {getTestsFromFs, findScreens, askQuestion, identifyOutdatedScreens, identifyUnusedScreens, removeScreens} = require('./utils'); const {DATABASE_URLS_JSON_NAME, LOCAL_DATABASE_NAME} = require('../../../constants/database'); const {logger} = require('../../../common-utils'); +const {ToolName} = require('../../../constants'); const {REMOVE_UNUSED_SCREENS: commandName} = commands; @@ -30,7 +31,7 @@ function proxyTool() { } module.exports = (program, toolAdapter) => { - const toolName = program.name?.() || 'hermione'; + const {toolName} = toolAdapter; program .command(commandName) @@ -39,6 +40,10 @@ module.exports = (program, toolAdapter) => { .option('--skip-questions', 'do not ask questions during execution (default values will be used)') .on('--help', () => logger.log(getHelpMessage(toolName))) .action(async (options) => { + if (toolName !== ToolName.Testplane) { + throw new Error(`CLI command "${commandName}" supports only "${ToolName.Testplane}" tool`); + } + try { proxyTool(); diff --git a/package-lock.json b/package-lock.json index 6dd97378f..c809f8ee2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "lodash": "^4.17.4", "looks-same": "^8.2.1", "nested-error-stacks": "^2.1.0", + "npm-which": "^3.0.1", "opener": "^1.4.3", "ora": "^5.4.1", "p-queue": "^5.0.0", @@ -64,6 +65,7 @@ "@types/better-sqlite3": "^7.6.4", "@types/bluebird": "^3.5.3", "@types/chai": "^4.3.5", + "@types/chai-as-promised": "^7.1.1", "@types/debug": "^4.1.8", "@types/enzyme": "^3.10.13", "@types/escape-html": "^1.0.4", @@ -73,6 +75,7 @@ "@types/json-stringify-safe": "^5.0.2", "@types/lodash": "^4.14.195", "@types/nested-error-stacks": "^2.1.0", + "@types/npm-which": "^3.0.3", "@types/opener": "^1.4.0", "@types/proxyquire": "^1.3.28", "@types/sinon": "^4.3.3", @@ -3714,6 +3717,15 @@ "integrity": "sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==", "dev": true }, + "node_modules/@types/chai-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@types/chai-as-promised/-/chai-as-promised-7.1.1.tgz", + "integrity": "sha512-dberBxQW/XWv6BMj0su1lV9/C9AUx5Hqu2pisuS6S4YK/Qt6vurcj/BmcbEsobIWWCQzhesNY8k73kIxx4X7Mg==", + "dev": true, + "dependencies": { + "@types/chai": "*" + } + }, "node_modules/@types/cheerio": { "version": "0.22.31", "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.31.tgz", @@ -3984,6 +3996,15 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, + "node_modules/@types/npm-which": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/npm-which/-/npm-which-3.0.3.tgz", + "integrity": "sha512-RwK8/EXlY1/Mela2oJAqLcGSti5poulpwo3LnuhHrp1ZUMnrCRruTaw372BTyMTWUidxhe4Tva3dpM2ImBV9Cw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/opener": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@types/opener/-/opener-1.4.0.tgz", @@ -18429,8 +18450,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" }, "node_modules/isobject": { "version": "3.0.1", @@ -20908,6 +20928,20 @@ "url": "/~https://github.com/sponsors/sindresorhus" } }, + "node_modules/npm-path": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/npm-path/-/npm-path-2.0.4.tgz", + "integrity": "sha512-IFsj0R9C7ZdR5cP+ET342q77uSRdtWOlWpih5eC+lu29tIDbNEgDbzgVJ5UFvYHWhxDZ5TFkJafFioO0pPQjCw==", + "dependencies": { + "which": "^1.2.10" + }, + "bin": { + "npm-path": "bin/npm-path" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/npm-run-all": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", @@ -21061,6 +21095,27 @@ "node": ">=4" } }, + "node_modules/npm-which": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/npm-which/-/npm-which-3.0.1.tgz", + "integrity": "sha512-CM8vMpeFQ7MAPin0U3wzDhSGV0hMHNwHU0wjo402IVizPDrs45jSfSuoC+wThevY88LQti8VvaAnqYAeVy3I1A==", + "dependencies": { + "commander": "^2.9.0", + "npm-path": "^2.0.2", + "which": "^1.2.10" + }, + "bin": { + "npm-which": "bin/npm-which.js" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/npm-which/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, "node_modules/nth-check": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz", @@ -30732,7 +30787,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -33728,6 +33782,15 @@ "integrity": "sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==", "dev": true }, + "@types/chai-as-promised": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@types/chai-as-promised/-/chai-as-promised-7.1.1.tgz", + "integrity": "sha512-dberBxQW/XWv6BMj0su1lV9/C9AUx5Hqu2pisuS6S4YK/Qt6vurcj/BmcbEsobIWWCQzhesNY8k73kIxx4X7Mg==", + "dev": true, + "requires": { + "@types/chai": "*" + } + }, "@types/cheerio": { "version": "0.22.31", "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.31.tgz", @@ -34000,6 +34063,15 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, + "@types/npm-which": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/npm-which/-/npm-which-3.0.3.tgz", + "integrity": "sha512-RwK8/EXlY1/Mela2oJAqLcGSti5poulpwo3LnuhHrp1ZUMnrCRruTaw372BTyMTWUidxhe4Tva3dpM2ImBV9Cw==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/opener": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@types/opener/-/opener-1.4.0.tgz", @@ -45108,8 +45180,7 @@ "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", - "dev": true + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" }, "isobject": { "version": "3.0.1", @@ -47053,6 +47124,14 @@ "integrity": "sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==", "dev": true }, + "npm-path": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/npm-path/-/npm-path-2.0.4.tgz", + "integrity": "sha512-IFsj0R9C7ZdR5cP+ET342q77uSRdtWOlWpih5eC+lu29tIDbNEgDbzgVJ5UFvYHWhxDZ5TFkJafFioO0pPQjCw==", + "requires": { + "which": "^1.2.10" + } + }, "npm-run-all": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", @@ -47173,6 +47252,23 @@ "path-key": "^2.0.0" } }, + "npm-which": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/npm-which/-/npm-which-3.0.1.tgz", + "integrity": "sha512-CM8vMpeFQ7MAPin0U3wzDhSGV0hMHNwHU0wjo402IVizPDrs45jSfSuoC+wThevY88LQti8VvaAnqYAeVy3I1A==", + "requires": { + "commander": "^2.9.0", + "npm-path": "^2.0.2", + "which": "^1.2.10" + }, + "dependencies": { + "commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + } + } + }, "nth-check": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.0.1.tgz", @@ -54705,7 +54801,6 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, "requires": { "isexe": "^2.0.0" } diff --git a/package.json b/package.json index 0d12ab72d..34b5448f0 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "lodash": "^4.17.4", "looks-same": "^8.2.1", "nested-error-stacks": "^2.1.0", + "npm-which": "^3.0.1", "opener": "^1.4.3", "ora": "^5.4.1", "p-queue": "^5.0.0", @@ -125,6 +126,7 @@ "@types/better-sqlite3": "^7.6.4", "@types/bluebird": "^3.5.3", "@types/chai": "^4.3.5", + "@types/chai-as-promised": "^7.1.1", "@types/debug": "^4.1.8", "@types/enzyme": "^3.10.13", "@types/escape-html": "^1.0.4", @@ -134,6 +136,7 @@ "@types/json-stringify-safe": "^5.0.2", "@types/lodash": "^4.14.195", "@types/nested-error-stacks": "^2.1.0", + "@types/npm-which": "^3.0.3", "@types/opener": "^1.4.0", "@types/proxyquire": "^1.3.28", "@types/sinon": "^4.3.3", diff --git a/test/unit/lib/adapters/config/playwright.ts b/test/unit/lib/adapters/config/playwright.ts new file mode 100644 index 000000000..0443e0717 --- /dev/null +++ b/test/unit/lib/adapters/config/playwright.ts @@ -0,0 +1,208 @@ +import path from 'node:path'; +import sinon from 'sinon'; + +import {PlaywrightConfigAdapter, DEFAULT_BROWSER_ID, type PwtConfig, type PwtProject} from '../../../../../lib/adapters/config/playwright'; +import {PlaywrightTestAdapter, type PwtRawTest} from '../../../../../lib/adapters/test/playwright'; + +describe('lib/adapters/config/testplane', () => { + const sandbox = sinon.createSandbox(); + + afterEach(() => sandbox.restore()); + + describe('original', () => { + it('should return original config', () => { + const config = {} as PwtConfig; + + assert.equal(PlaywrightConfigAdapter.create(config).original, config); + }); + }); + + describe('tolerance', () => { + it('should return default value', () => { + assert.equal(PlaywrightConfigAdapter.create({} as PwtConfig).tolerance, 2.3); + }); + }); + + describe('antialiasingTolerance', () => { + it('should return default value', () => { + assert.equal(PlaywrightConfigAdapter.create({} as PwtConfig).antialiasingTolerance, 4); + }); + }); + + describe('browserIds', () => { + it('should return browsers from "projects" field', () => { + const config = { + projects: [ + {name: 'yabro1'}, + {name: 'yabro2'} + ] + } as PwtConfig; + + assert.deepEqual(PlaywrightConfigAdapter.create(config).browserIds, ['yabro1', 'yabro2']); + }); + + it('should return default browser if "projects" are not specified', () => { + assert.deepEqual(PlaywrightConfigAdapter.create({} as PwtConfig).browserIds, [DEFAULT_BROWSER_ID]); + }); + }); + + describe('getScreenshotPath', () => { + const mkConfig_ = ({cfg, prjCfg}: {cfg?: Partial, prjCfg?: Partial} = {cfg: {}, prjCfg: {}}): PwtConfig => ({ + snapshotPathTemplate: 'cfg_{testDir}/{snapshotDir}/{snapshotSuffix}/{testFileDir}/{platform}/{projectName}/{testName}/{testFileName}/{testFilePath}/{arg}/{ext}', + testDir: './cfg_tests', + snapshotDir: './cfg_snapshots', + ...cfg, + projects: [{ + name: 'prj_bro', + snapshotPathTemplate: 'prj_{testDir}/{snapshotDir}/{snapshotSuffix}/{testFileDir}/{platform}/{projectName}/{testName}/{testFileName}/{testFilePath}/{arg}/{ext}', + testDir: './prj_tests', + snapshotDir: './prj_snapshots', + ...prjCfg + }] + } as PwtConfig); + + const mkTestAdapter_ = (opts: Partial = {}): PlaywrightTestAdapter => { + const test = { + file: 'default-path.ts', + browserName: 'default-bro', + title: 'suite test', + titlePath: ['suite', 'test'], + ...opts + }; + + return PlaywrightTestAdapter.create(test); + }; + + describe('use options from project config', () => { + it('should return screenshot path with "testDir"', () => { + const config = mkConfig_({prjCfg: { + name: 'yabro', + snapshotPathTemplate: '{testDir}', + testDir: './tests' + }}); + const testAdapter = mkTestAdapter_({browserName: 'yabro'}); + const configAdapter = PlaywrightConfigAdapter.create(config); + + assert.equal(configAdapter.getScreenshotPath(testAdapter, 'plain'), path.resolve('./tests')); + }); + + it('should return screenshot path with "snapshotDir"', () => { + const config = mkConfig_({prjCfg: { + name: 'yabro', + snapshotPathTemplate: '{snapshotDir}', + snapshotDir: './snapshots' + }}); + const testAdapter = mkTestAdapter_({browserName: 'yabro'}); + const configAdapter = PlaywrightConfigAdapter.create(config); + + assert.equal(configAdapter.getScreenshotPath(testAdapter, 'plain'), path.resolve('./snapshots')); + }); + + ['snapshotSuffix', 'platform'].forEach(fieldName => { + it(`should return screenshot path with "${fieldName}"`, () => { + const config = mkConfig_({prjCfg: { + name: 'yabro', + snapshotPathTemplate: `{${fieldName}}` + }}); + const testAdapter = mkTestAdapter_({browserName: 'yabro'}); + const configAdapter = PlaywrightConfigAdapter.create(config); + + assert.equal(configAdapter.getScreenshotPath(testAdapter, 'plain'), path.resolve(process.platform)); + }); + }); + + it('should return screenshot path with "testFileDir"', () => { + const config = mkConfig_({prjCfg: { + name: 'yabro', + snapshotPathTemplate: '{testFileDir}' + }}); + const testAdapter = mkTestAdapter_({browserName: 'yabro', file: './dir/file.test.ts'}); + const configAdapter = PlaywrightConfigAdapter.create(config); + + assert.equal(configAdapter.getScreenshotPath(testAdapter, 'plain'), path.resolve('./dir')); + }); + + it('should return screenshot path with "projectName"', () => { + const config = mkConfig_({prjCfg: { + name: 'yabro/123', + snapshotPathTemplate: '{projectName}' + }}); + const testAdapter = mkTestAdapter_({browserName: 'yabro/123'}); + const configAdapter = PlaywrightConfigAdapter.create(config); + + assert.equal(configAdapter.getScreenshotPath(testAdapter, 'plain'), path.resolve('yabro-123')); + }); + + it('should return screenshot path with "testName"', () => { + const config = mkConfig_({prjCfg: { + name: 'yabro', + snapshotPathTemplate: '{testName}' + }}); + const testAdapter = mkTestAdapter_({browserName: 'yabro', titlePath: ['foo', 'bar']}); + const configAdapter = PlaywrightConfigAdapter.create(config); + + assert.equal(configAdapter.getScreenshotPath(testAdapter, 'plain'), path.resolve('foo-bar')); + }); + + it('should return screenshot path with "testFileName"', () => { + const config = mkConfig_({prjCfg: { + name: 'yabro', + snapshotPathTemplate: '{testFileName}' + }}); + const testAdapter = mkTestAdapter_({browserName: 'yabro', file: './dir/file.test.ts'}); + const configAdapter = PlaywrightConfigAdapter.create(config); + + assert.equal(configAdapter.getScreenshotPath(testAdapter, 'plain'), path.resolve('file.test.ts')); + }); + + it('should return screenshot path with "testFilePath"', () => { + const config = mkConfig_({prjCfg: { + name: 'yabro', + snapshotPathTemplate: '{testFilePath}' + }}); + const testAdapter = mkTestAdapter_({browserName: 'yabro', file: './dir/file.test.ts'}); + const configAdapter = PlaywrightConfigAdapter.create(config); + + assert.equal(configAdapter.getScreenshotPath(testAdapter, 'plain'), path.resolve('./dir/file.test.ts')); + }); + + it('should return screenshot path with "arg"', () => { + const config = mkConfig_({prjCfg: { + name: 'yabro', + snapshotPathTemplate: '{arg}' + }}); + const testAdapter = mkTestAdapter_({browserName: 'yabro'}); + const configAdapter = PlaywrightConfigAdapter.create(config); + + assert.equal(configAdapter.getScreenshotPath(testAdapter, 'first/plain'), path.resolve('first/plain')); + }); + + it('should return screenshot path with "ext"', () => { + const config = mkConfig_({prjCfg: { + name: 'yabro', + snapshotPathTemplate: '{ext}' + }}); + const testAdapter = mkTestAdapter_({browserName: 'yabro'}); + const configAdapter = PlaywrightConfigAdapter.create(config); + + assert.equal(configAdapter.getScreenshotPath(testAdapter, 'plain'), path.resolve('.png')); + }); + }); + + it('should return screenshot path with options from main config', () => { + const config = mkConfig_({cfg: { + snapshotPathTemplate: '{testDir}/{snapshotDir}/{snapshotSuffix}/{testFileDir}/{platform}/{projectName}/{testName}/{testFileName}/{testFilePath}/{arg}/{ext}', + testDir: './tests', + snapshotDir: './snapshots' + }}); + const testAdapter = mkTestAdapter_({browserName: 'yabro/123', file: './dir/file.test.ts', titlePath: ['foo', 'bar']}); + const configAdapter = PlaywrightConfigAdapter.create(config); + + assert.equal( + configAdapter.getScreenshotPath(testAdapter, 'first/plain'), + `${path.resolve('./tests')}${path.resolve('./snapshots')}/${process.platform}/dir/` + + `${process.platform}/yabro-123/foo-bar/file.test.ts/dir/file.test.ts/first/plain/.png` + ); + }); + }); +}); diff --git a/test/unit/lib/adapters/test-collection/playwright.ts b/test/unit/lib/adapters/test-collection/playwright.ts new file mode 100644 index 000000000..8c2856e9a --- /dev/null +++ b/test/unit/lib/adapters/test-collection/playwright.ts @@ -0,0 +1,34 @@ +import sinon from 'sinon'; +import {PlaywrightTestCollectionAdapter} from '../../../../../lib/adapters/test-collection/playwright'; +import {PlaywrightTestAdapter, type PwtRawTest} from '../../../../../lib/adapters/test/playwright'; + +describe('lib/adapters/test-collection/playwright', () => { + const sandbox = sinon.createSandbox(); + + const mkTest_ = (opts: Partial = {}): PwtRawTest => ({ + file: 'default-path.ts', + browserName: 'default-bro', + title: 'suite test', + titlePath: ['suite', 'test'], + ...opts + }); + + afterEach(() => sandbox.restore()); + + describe('tests', () => { + it('should return tests', () => { + const test1 = mkTest_({browserName: 'yabro1'}); + const testAdapter1 = {} as PlaywrightTestAdapter; + const test2 = mkTest_({browserName: 'yabro2'}); + const testAdapter2 = {} as PlaywrightTestAdapter; + + sandbox.stub(PlaywrightTestAdapter, 'create') + .withArgs(test1).returns(testAdapter1) + .withArgs(test2).returns(testAdapter2); + + const testCollectionAdapter = PlaywrightTestCollectionAdapter.create([test1, test2]); + + assert.deepEqual(testCollectionAdapter.tests, [testAdapter1, testAdapter2]); + }); + }); +}); diff --git a/test/unit/lib/adapters/test/playwright.ts b/test/unit/lib/adapters/test/playwright.ts new file mode 100644 index 000000000..686446264 --- /dev/null +++ b/test/unit/lib/adapters/test/playwright.ts @@ -0,0 +1,238 @@ +import sinon, {type SinonStub} from 'sinon'; +import type {TestCase, TestResult, FullProject} from '@playwright/test/reporter'; + +import {PlaywrightTestAdapter, type PwtRawTest} from '../../../../../lib/adapters/test/playwright'; +import {PlaywrightTestResultAdapter, type PlaywrightAttachment} from '../../../../../lib/adapters/test-result/playwright'; +import {TestStatus, UNKNOWN_ATTEMPT} from '../../../../../lib/constants'; +import type {ImageDiffError} from '../../../../../lib/errors'; + +describe('lib/adapters/test/playwright', () => { + const sandbox = sinon.createSandbox(); + + const mkTest_ = (opts: Partial = {}): PwtRawTest => ({ + file: 'default-path.ts', + browserName: 'default-bro', + title: 'suite test', + titlePath: ['suite', 'test'], + ...opts + }); + + afterEach(() => sandbox.restore()); + + describe('original', () => { + it('shoult return original test instance', () => { + const test = mkTest_(); + + assert.equal(PlaywrightTestAdapter.create(test).original, test); + }); + }); + + describe('id', () => { + it('should return title as "id" from original test', () => { + const test = mkTest_({title: 'title'}); + + assert.equal(PlaywrightTestAdapter.create(test).id, 'title'); + }); + }); + + (['pending', 'disabled', 'silentlySkipped'] as const).forEach(fieldName => { + describe(fieldName, () => { + it('should return false by default', () => { + assert.isFalse(PlaywrightTestAdapter.create(mkTest_())[fieldName]); + }); + }); + }); + + describe('browserId', () => { + it('should return browser name from original test', () => { + const test = mkTest_({browserName: 'yabro'}); + + assert.equal(PlaywrightTestAdapter.create(test).browserId, 'yabro'); + }); + }); + + describe('fullName', () => { + it('should return title from original test', () => { + const test = mkTest_({title: 'title'}); + + assert.equal(PlaywrightTestAdapter.create(test).fullName, 'title'); + }); + }); + + describe('file', () => { + it('should return file from original test', () => { + const test = mkTest_({file: 'some-file.ts'}); + + assert.equal(PlaywrightTestAdapter.create(test).file, 'some-file.ts'); + }); + }); + + describe('titlePath', () => { + it('should return titlePath from original test', () => { + const test = mkTest_({titlePath: ['suite', 'test']}); + + assert.deepEqual(PlaywrightTestAdapter.create(test).titlePath, ['suite', 'test']); + }); + }); + + describe('createTestResult', () => { + beforeEach(() => { + sandbox.stub(PlaywrightTestResultAdapter, 'create').returns({}); + }); + + it('should create test result adapter with generated test case', () => { + const test = mkTest_({ + file: 'some-path.ts', + browserName: 'yabro', + title: 'suite test', + titlePath: ['suite', 'test'] + }); + const testAdapter = PlaywrightTestAdapter.create(test); + + testAdapter.createTestResult({status: TestStatus.SUCCESS}); + const testCase = (PlaywrightTestResultAdapter.create as SinonStub).args[0][0] as unknown as TestCase; + + assert.calledOnceWith(PlaywrightTestResultAdapter.create as SinonStub, { + titlePath: sinon.match.func, + title: 'suite test', + annotations: [], + location: { + file: 'some-path.ts' + }, + parent: { + project: sinon.match.func + } + }, sinon.match.any, UNKNOWN_ATTEMPT); + assert.deepEqual(testCase.titlePath(), ['', 'yabro', 'some-path.ts', 'suite', 'test']); + assert.deepEqual(testCase.parent.project(), {name: 'yabro'} as FullProject); + }); + + describe('generate test result', () => { + it('should create test result adapter with generated test result', () => { + const testAdapter = PlaywrightTestAdapter.create(mkTest_()); + + testAdapter.createTestResult({status: TestStatus.SUCCESS, attempt: 100500}); + + assert.calledOnceWith(PlaywrightTestResultAdapter.create as SinonStub, sinon.match.any, { + attachments: [], + status: TestStatus.SUCCESS, + steps: [], + startTime: sinon.match.date + }, 100500); + }); + + describe('correctly handle attachments', () => { + const refImg = { + path: '/root/images/ref.png', + relativePath: 'images/ref.png', + size: { + width: 100, + height: 200 + } + }; + + it('should create test result with expected attachment', () => { + const testAdapter = PlaywrightTestAdapter.create(mkTest_()); + const assertViewResult = { + stateName: 'plain', + refImg, + isUpdated: true + }; + + testAdapter.createTestResult({status: TestStatus.UPDATED, assertViewResults: [assertViewResult]}); + const testResult = (PlaywrightTestResultAdapter.create as SinonStub).args[0][1] as unknown as TestResult; + + assert.deepEqual(testResult.attachments as PlaywrightAttachment[], [{ + name: 'plain-expected.png', + path: '/root/images/ref.png', + relativePath: 'images/ref.png', + contentType: 'image/png', + size: {width: 100, height: 200}, + isUpdated: true + }]); + }); + + it('should create test result with actual attachment', () => { + const testAdapter = PlaywrightTestAdapter.create(mkTest_()); + const assertViewResult = { + stateName: 'plain', + refImg, + currImg: { + path: '/root/images/actual.png', + size: { + width: 100, + height: 200 + } + }, + isUpdated: false + } as unknown as ImageDiffError; + + testAdapter.createTestResult({status: TestStatus.FAIL, assertViewResults: [assertViewResult]}); + const testResult = (PlaywrightTestResultAdapter.create as SinonStub).args[0][1] as unknown as TestResult; + + assert.deepEqual(testResult.attachments[1] as PlaywrightAttachment, { + name: 'plain-actual.png', + path: '/root/images/actual.png', + contentType: 'image/png', + size: {width: 100, height: 200}, + isUpdated: false + }); + }); + + it('should create test result with actual attachment', () => { + const testAdapter = PlaywrightTestAdapter.create(mkTest_()); + const assertViewResult = { + stateName: 'plain', + refImg, + currImg: { + path: '/root/images/actual.png', + size: { + width: 100, + height: 200 + } + }, + isUpdated: false + } as unknown as ImageDiffError; + + testAdapter.createTestResult({status: TestStatus.FAIL, assertViewResults: [assertViewResult]}); + const testResult = (PlaywrightTestResultAdapter.create as SinonStub).args[0][1] as unknown as TestResult; + + assert.deepEqual(testResult.attachments[1] as PlaywrightAttachment, { + name: 'plain-actual.png', + path: '/root/images/actual.png', + contentType: 'image/png', + size: {width: 100, height: 200}, + isUpdated: false + }); + }); + + it('should create test result with diff attachment', () => { + const testAdapter = PlaywrightTestAdapter.create(mkTest_()); + const assertViewResult = { + stateName: 'plain', + refImg, + diffImg: { + path: '/root/images/diff.png', + size: { + width: 100, + height: 200 + } + }, + isUpdated: false + } as unknown as ImageDiffError; + + testAdapter.createTestResult({status: TestStatus.FAIL, assertViewResults: [assertViewResult]}); + const testResult = (PlaywrightTestResultAdapter.create as SinonStub).args[0][1] as unknown as TestResult; + + assert.deepEqual(testResult.attachments[1] as PlaywrightAttachment, { + name: 'plain-diff.png', + path: '/root/images/diff.png', + contentType: 'image/png', + size: {width: 100, height: 200}, + isUpdated: false + }); + }); + }); + }); + }); +}); diff --git a/test/unit/lib/adapters/tool/playwright/index.ts b/test/unit/lib/adapters/tool/playwright/index.ts new file mode 100644 index 000000000..77a5deec7 --- /dev/null +++ b/test/unit/lib/adapters/tool/playwright/index.ts @@ -0,0 +1,607 @@ +import path from 'node:path'; +import childProcess, {type ChildProcessWithoutNullStreams} from 'node:child_process'; +import {EventEmitter} from 'node:events'; +import type {Readable} from 'node:stream'; + +import P from 'bluebird'; +import {FullConfig} from '@playwright/test/reporter'; +import proxyquire from 'proxyquire'; +import sinon, {SinonStub} from 'sinon'; +import npmWhich from 'npm-which'; + +import {DEFAULT_CONFIG_PATHS, type PlaywrightToolAdapter} from '../../../../../../lib/adapters/tool/playwright'; +import {PlaywrightTestCollectionAdapter} from '../../../../../../lib/adapters/test-collection/playwright'; +import {PlaywrightTestResultAdapter} from '../../../../../../lib/adapters/test-result/playwright'; +import {DEFAULT_BROWSER_ID} from '../../../../../../lib/adapters/config/playwright'; +import {HtmlReporter} from '../../../../../../lib/plugin-api'; +import {ToolName, UNKNOWN_ATTEMPT, TestStatus} from '../../../../../../lib/constants'; +import {ClientEvents} from '../../../../../../lib/gui/constants'; + +import type {ReporterConfig} from '../../../../../../lib/types'; +import type {TestSpec} from '../../../../../../lib/adapters/tool/types'; +import type {ToolAdapterOptionsFromCli} from '../../../../../../lib/adapters/tool'; +import type {GuiReportBuilder} from '../../../../../../lib/report-builder/gui'; +import type {EventSource} from '../../../../../../lib/gui/event-source'; + +describe('lib/adapters/tool/playwright/index', () => { + const sandbox = sinon.sandbox.create(); + + let PlaywrightToolAdapter: typeof import('../../../../../../lib/adapters/tool/playwright').PlaywrightToolAdapter; + let parseConfigStub: SinonStub; + let setupTransformHookStub: SinonStub; + let ipcStub: EventEmitter; + + const proxyquirePwtToolAdapter = (stubs: Record = {}): typeof PlaywrightToolAdapter => { + return proxyquire.noCallThru().load('../../../../../../lib/adapters/tool/playwright', { + '../../../config': {parseConfig: parseConfigStub}, + './transformer': {setupTransformHook: setupTransformHookStub}, + './ipc': ipcStub, + ...stubs + }).PlaywrightToolAdapter; + }; + + const createPwtToolAdapter = (opts: ToolAdapterOptionsFromCli, config: FullConfig = {} as FullConfig): PlaywrightToolAdapter => { + PlaywrightToolAdapter = proxyquirePwtToolAdapter(opts.configPath ? {[path.resolve(opts.configPath)]: config} : {}); + + return PlaywrightToolAdapter.create(opts); + }; + + const mkSpawnInstance_ = (): ChildProcessWithoutNullStreams => { + const instance = new EventEmitter() as ChildProcessWithoutNullStreams; + instance.stdout = new EventEmitter() as Readable; + instance.stderr = new EventEmitter() as Readable; + + return instance; + }; + + const mkReportBuilder_ = (): GuiReportBuilder => ({ + addTestResult: sinon.stub().resolves({}), + getTestBranch: sinon.stub().returns({}) + } as unknown as GuiReportBuilder); + + beforeEach(() => { + sandbox.stub(HtmlReporter, 'create').returns({}); + sandbox.stub(PlaywrightTestCollectionAdapter, 'create').returns({}); + sandbox.stub(PlaywrightTestResultAdapter, 'create').returns({}); + sandbox.stub(npmWhich, 'sync').returns('/default/node_modules/.bin/playwright'); + sandbox.stub(childProcess, 'spawn').returns(mkSpawnInstance_()); + + ipcStub = new EventEmitter(); + parseConfigStub = sandbox.stub(); + setupTransformHookStub = sandbox.stub().returns(sinon.stub()); + }); + + afterEach(() => sandbox.restore()); + + describe('constructor', () => { + describe('should read config', () => { + it('passed by user', () => { + const configPath = './pwt.config.ts'; + + const toolAdapter = createPwtToolAdapter({toolName: ToolName.Playwright, configPath}); + + assert.equal(toolAdapter.configPath, path.resolve(configPath)); + }); + + DEFAULT_CONFIG_PATHS.forEach(configPath => { + it(`from "${configPath}" by default`, () => { + const stubs = {[path.resolve(configPath)]: {}}; + const PlaywrightToolAdapter = proxyquirePwtToolAdapter(stubs); + + const toolAdapter = PlaywrightToolAdapter.create({toolName: ToolName.Playwright}); + + assert.equal(toolAdapter.configPath, path.resolve(configPath)); + }); + }); + }); + + it('should throw error if config file is not found', () => { + assert.throws( + () => createPwtToolAdapter({toolName: ToolName.Playwright}), + `Unable to read config from paths: ${DEFAULT_CONFIG_PATHS.join(', ')}` + ); + }); + + describe('parse options from "html-reporter/playwright" reporter', () => { + describe('should call parser with empty opts if "reporter" option', () => { + it('does not exists in config', () => { + const config = {} as unknown as FullConfig; + + createPwtToolAdapter({toolName: ToolName.Playwright, configPath: './pwt.config.ts'}, config); + + assert.calledOnceWith(parseConfigStub, {}); + }); + + it('specified as string', () => { + const config = {reporter: 'html-reporter/playwright'} as unknown as FullConfig; + + createPwtToolAdapter({toolName: ToolName.Playwright, configPath: './pwt.config.ts'}, config); + + assert.calledOnceWith(parseConfigStub, {}); + }); + + it('specified as string inside array', () => { + const config = {reporter: [['line'], ['html-reporter/playwright']]} as unknown as FullConfig; + + createPwtToolAdapter({toolName: ToolName.Playwright, configPath: './pwt.config.ts'}, config); + + assert.calledOnceWith(parseConfigStub, {}); + }); + }); + + it('should call parser with specified opts', () => { + const pluginOpts = { + enabled: true, + path: 'playwright-report' + }; + const config = {reporter: [['html-reporter/playwright', pluginOpts]]} as unknown as FullConfig; + + createPwtToolAdapter({toolName: ToolName.Playwright, configPath: './pwt.config.ts'}, config); + + assert.calledOnceWith(parseConfigStub, pluginOpts); + }); + }); + + it('should init htmlReporter instance with parsed reporter config', () => { + const reporterConfig = {path: 'some/path'} as ReporterConfig; + parseConfigStub.returns(reporterConfig); + + createPwtToolAdapter({toolName: ToolName.Playwright, configPath: './pwt.config.ts'}); + + assert.calledOnceWith(HtmlReporter.create as SinonStub, reporterConfig, {toolName: ToolName.Playwright}); + }); + + it('should find pwt binary file', async () => { + createPwtToolAdapter({toolName: ToolName.Playwright, configPath: './pwt.config.ts'}); + + (npmWhich.sync as SinonStub).calledOnceWith(ToolName.Playwright, {cwd: process.cwd()}); + }); + }); + + describe('readTests', () => { + const pwtBinaryPath = '/node_modules/.bin/playwright'; + const configPath = './default-pwt.config.ts'; + let spawnProc: ChildProcessWithoutNullStreams; + + const readTests_ = async (config: FullConfig = {} as FullConfig): Promise => { + const toolAdapter = createPwtToolAdapter({toolName: ToolName.Playwright, configPath}, config); + + return toolAdapter.readTests(); + }; + + beforeEach(() => { + (npmWhich.sync as SinonStub).withArgs(ToolName.Playwright, {cwd: process.cwd()}).returns(pwtBinaryPath); + + spawnProc = mkSpawnInstance_(); + (childProcess.spawn as SinonStub).returns(spawnProc); + }); + + describe('should run pwt binary with correct arguments', () => { + it('if "projects" field is specified', async () => { + const config = { + projects: [ + {name: 'chrome'} + ] + } as unknown as FullConfig; + + P.delay(10).then(() => spawnProc.emit('exit', 0)); + await readTests_(config); + + assert.calledOnceWith(childProcess.spawn as SinonStub, pwtBinaryPath, ['test', '--list', '--reporter', 'list', '--config', path.resolve(configPath)]); + }); + + it('if "projects" field is not specified', async () => { + const config = {} as unknown as FullConfig; + + P.delay(10).then(() => spawnProc.emit('exit', 0)); + await readTests_(config); + + assert.calledOnceWith( + childProcess.spawn as SinonStub, + pwtBinaryPath, + ['test', '--list', '--reporter', 'list', '--config', path.resolve(configPath), '--browser', DEFAULT_BROWSER_ID]); + }); + }); + + it('should create test collection with empty tests if process did not write anything to stdout', async () => { + P.delay(10).then(() => spawnProc.emit('exit', 0)); + await readTests_(); + + assert.calledOnceWith(PlaywrightTestCollectionAdapter.create as SinonStub, []); + }); + + it('should create test collection with read tests if process did not write anything to stdout', async () => { + const browserName = 'yabro'; + const file = 'example.spec.ts'; + + P.delay(10).then(() => { + spawnProc.stdout.emit('data', 'Listing tests:\n'); + spawnProc.stdout.emit('data', ` [${browserName}] › ${file}:4:5 › suite › test #1\n`); + spawnProc.stdout.emit('data', ` [${browserName}] › ${file}:16:5 › suite › test #2\n`); + spawnProc.stdout.emit('data', 'Total: 2 tests in 1 file'); + + spawnProc.emit('exit', 0); + }); + await readTests_(); + + assert.calledOnceWith(PlaywrightTestCollectionAdapter.create as SinonStub, [ + { + browserName, + file, + title: 'suite test #1', + titlePath: ['suite', 'test #1'] + }, + { + browserName, + file, + title: 'suite test #2', + titlePath: ['suite', 'test #2'] + } + ]); + }); + + it('should return test collection adapter', async () => { + const testCollectionAdapter = {}; + (PlaywrightTestCollectionAdapter.create as SinonStub).withArgs([]).returns(testCollectionAdapter); + + P.delay(10).then(() => spawnProc.emit('exit', 0)); + const res = await readTests_(); + + assert.deepEqual(res, testCollectionAdapter); + }); + + it('should throw error if read tests process emit "error" event', async () => { + const error = new Error('o.O'); + + P.delay(10).then(() => spawnProc.emit('error', error)); + + await assert.isRejected( + readTests_(), + error + ); + }); + + it('should throw error if process exited with code greater than zero', async () => { + const stderr = 'o.O'; + const code = 1; + + P.delay(10).then(() => { + spawnProc.stderr.emit('data', stderr); + spawnProc.emit('exit', code); + }); + + await assert.isRejected( + readTests_(), + `Playwright process with reading tests exited with code: ${code}, stderr: ${stderr}` + ); + }); + }); + + describe('run', () => { + const pwtBinaryPath = '/node_modules/.bin/playwright'; + const configPath = './default-pwt.config.ts'; + let spawnProc: ChildProcessWithoutNullStreams; + let eventSource: EventSource; + + const run_ = async (opts: {tests?: TestSpec[], config?: FullConfig, reportBuilder?: GuiReportBuilder} = {}): Promise => { + const {tests = [], config = {} as FullConfig, reportBuilder = mkReportBuilder_()} = opts; + const toolAdapter = createPwtToolAdapter({toolName: ToolName.Playwright, configPath}, config); + + toolAdapter.handleTestResults(reportBuilder, eventSource); + + return toolAdapter.run(PlaywrightTestCollectionAdapter.create([]), tests); + }; + + beforeEach(() => { + (npmWhich.sync as SinonStub).withArgs(ToolName.Playwright, {cwd: process.cwd()}).returns(pwtBinaryPath); + + eventSource = {emit: sinon.stub()} as unknown as EventSource; + + spawnProc = mkSpawnInstance_(); + (childProcess.spawn as SinonStub).returns(spawnProc); + }); + + it('should throw if "reportBuilder" and "eventSource" instances are not specified', async () => { + const toolAdapter = createPwtToolAdapter({toolName: ToolName.Playwright, configPath}); + + await assert.isRejected( + toolAdapter.run(PlaywrightTestCollectionAdapter.create([]), []), + '"reportBuilder" and "eventSource" instances must be initialize before run tests' + ); + }); + + describe('should run pwt binary with correct arguments', () => { + it('if "projects" field is specified in config, but tests not passed', async () => { + const config = { + projects: [ + {name: 'chrome'} + ] + } as unknown as FullConfig; + + P.delay(10).then(() => spawnProc.emit('exit', 0)); + await run_({config}); + + assert.calledOnceWith( + childProcess.spawn as SinonStub, + pwtBinaryPath, + [ + 'test', + '--reporter', path.resolve('./lib/adapters/tool/playwright/reporter'), + '--config', path.resolve(configPath) + ], + { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'] + } + ); + }); + + it('if "projects" field is specified in config and tests passed', async () => { + const config = { + projects: [ + {name: 'chrome'}, + {name: 'firefox'} + ] + } as unknown as FullConfig; + const tests = [ + {testName: 'foo', browserName: 'chrome'}, + {testName: 'bar', browserName: 'firefox'} + ]; + + P.delay(10).then(() => spawnProc.emit('exit', 0)); + await run_({tests, config}); + + assert.calledOnceWith( + childProcess.spawn as SinonStub, + pwtBinaryPath, + [ + 'test', + '--reporter', path.resolve('./lib/adapters/tool/playwright/reporter'), + '--config', path.resolve(configPath), + '--grep', 'foo|bar', + '--project', 'chrome', + '--project', 'firefox' + ], + { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'] + } + ); + }); + + it('if "projects" field not specified in config and tests not passed', async () => { + P.delay(10).then(() => spawnProc.emit('exit', 0)); + await run_(); + + assert.calledOnceWith( + childProcess.spawn as SinonStub, + pwtBinaryPath, + [ + 'test', + '--reporter', path.resolve('./lib/adapters/tool/playwright/reporter'), + '--config', path.resolve(configPath), + '--browser', DEFAULT_BROWSER_ID + ], + { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'] + } + ); + }); + + it('if "projects" field not specified in config, but tests passed', async () => { + const tests = [ + {testName: 'foo', browserName: DEFAULT_BROWSER_ID} + ]; + + P.delay(10).then(() => spawnProc.emit('exit', 0)); + await run_({tests}); + + assert.calledOnceWith( + childProcess.spawn as SinonStub, + pwtBinaryPath, + [ + 'test', + '--reporter', path.resolve('./lib/adapters/tool/playwright/reporter'), + '--config', path.resolve(configPath), + '--grep', 'foo', + '--browser', DEFAULT_BROWSER_ID + ], + { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'] + } + ); + }); + }); + + it('should escape special characters in "grep" option', async () => { + const tests = [ + {testName: '(a) [b] {c} -d\\', browserName: DEFAULT_BROWSER_ID}, + {testName: '|e* +f? .g, ^h$ *', browserName: DEFAULT_BROWSER_ID} + ]; + + P.delay(10).then(() => spawnProc.emit('exit', 0)); + await run_({tests}); + + assert.calledOnceWith( + childProcess.spawn as SinonStub, + pwtBinaryPath, + sinon.match.array.contains([ + '--grep', '\\(a\\) \\[b\\] \\{c\\} \\-d\\\\|\\|e\\* \\+f\\? \\.g\\, \\^h\\$ \\*' + ]) + ); + }); + + ([ClientEvents.BEGIN_STATE, ClientEvents.TEST_RESULT] as const).forEach((eventName) => { + describe(`"${eventName}" event`, () => { + it('should create test result adapter', async () => { + const timestamp = Date.now(); + + P.delay(10).then(() => { + ipcStub.emit(eventName, { + test: { + foo: 1, + parent: { + baz: 2 + } + }, + result: { + baz: 3, + startTime: timestamp + }, + browserName: 'yabro', + titlePath: ['suite', 'test'], + event: eventName + }); + spawnProc.emit('exit', 0); + }); + await run_(); + + const testCase = (PlaywrightTestResultAdapter.create as SinonStub).lastCall.args[0]; + + assert.calledOnceWith( + PlaywrightTestResultAdapter.create as SinonStub, + { + foo: 1, + parent: { + baz: 2, + project: sinon.match.func + }, + titlePath: sinon.match.func + }, + { + baz: 3, + startTime: new Date(timestamp), + ...(eventName === ClientEvents.BEGIN_STATE ? {status: TestStatus.RUNNING} : {}) + }, + UNKNOWN_ATTEMPT + ); + assert.deepEqual(testCase.titlePath(), ['suite', 'test']); + assert.deepEqual(testCase.parent.project(), {name: 'yabro'}); + }); + + it('should add test result to report builder', async () => { + const reportBuilder = mkReportBuilder_(); + const testResultAdapter = {}; + (PlaywrightTestResultAdapter.create as SinonStub).returns(testResultAdapter); + + P.delay(10).then(() => { + ipcStub.emit(eventName, { + test: {}, result: {}, browserName: '', titlePath: [], event: eventName + }); + spawnProc.emit('exit', 0); + }); + await run_({reportBuilder}); + + assert.calledOnceWith(reportBuilder.addTestResult as SinonStub, testResultAdapter); + }); + + it('should emit event for client with correct data', async () => { + const reportBuilder = mkReportBuilder_(); + const testBranch = {}; + (reportBuilder.addTestResult as SinonStub).resolves({id: 'foo'}); + (reportBuilder.getTestBranch as SinonStub).withArgs('foo').returns(testBranch); + + P.delay(10).then(() => { + ipcStub.emit(eventName, { + test: {}, result: {}, browserName: '', titlePath: [], event: eventName + }); + spawnProc.emit('exit', 0); + }); + await run_({reportBuilder}); + + assert.calledOnceWith(eventSource.emit as SinonStub, eventName, testBranch); + }); + }); + }); + + describe(`"${ClientEvents.END}" event`, () => { + it('should emit event for client with correct data', async () => { + const reportBuilder = mkReportBuilder_(); + + P.delay(10).then(() => { + ipcStub.emit(ClientEvents.END, {event: ClientEvents.END}); + spawnProc.emit('exit', 0); + }); + await run_({reportBuilder}); + + assert.calledOnceWith(eventSource.emit as SinonStub, ClientEvents.END); + }); + }); + + it('should throw error if run tests process emit "error" event', async () => { + const error = new Error('o.O'); + + P.delay(10).then(() => spawnProc.emit('error', error)); + + await assert.isRejected( + run_(), + error + ); + }); + + describe('should resolve run tests with', () => { + it('"true" if exit code = 0', async () => { + const code = 0; + + P.delay(10).then(() => spawnProc.emit('exit', code)); + const result = await run_(); + + assert.isTrue(result); + }); + + it('"false" if exit code != 0', async () => { + const code = 123; + + P.delay(10).then(() => spawnProc.emit('exit', code)); + const result = await run_(); + + assert.isFalse(result); + }); + }); + }); + + describe('runWithoutRetries', () => { + const pwtBinaryPath = '/node_modules/.bin/playwright'; + const configPath = './default-pwt.config.ts'; + let spawnProc: ChildProcessWithoutNullStreams; + let eventSource: EventSource; + + beforeEach(() => { + (npmWhich.sync as SinonStub).withArgs(ToolName.Playwright, {cwd: process.cwd()}).returns(pwtBinaryPath); + + eventSource = {emit: sinon.stub()} as unknown as EventSource; + + spawnProc = mkSpawnInstance_(); + (childProcess.spawn as SinonStub).returns(spawnProc); + }); + + it('should run pwt binary with disabled retries', async () => { + const config = { + projects: [{name: 'yabro'}] + } as unknown as FullConfig; + const tests = [ + {testName: 'foo', browserName: 'yabro'} + ]; + + const toolAdapter = createPwtToolAdapter({toolName: ToolName.Playwright, configPath}, config); + toolAdapter.handleTestResults(mkReportBuilder_(), eventSource); + + P.delay(10).then(() => spawnProc.emit('exit', 0)); + await toolAdapter.runWithoutRetries(PlaywrightTestCollectionAdapter.create([]), tests); + + assert.calledOnceWith( + childProcess.spawn as SinonStub, + pwtBinaryPath, + [ + 'test', + '--reporter', path.resolve('./lib/adapters/tool/playwright/reporter'), + '--config', path.resolve(configPath), + '--grep', 'foo', + '--project', 'yabro', + '--retries', '0' + ], + { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'] + } + ); + }); + }); +}); diff --git a/test/unit/utils.js b/test/unit/utils.js index d95bee0cd..1b42ce122 100644 --- a/test/unit/utils.js +++ b/test/unit/utils.js @@ -3,6 +3,7 @@ const _ = require('lodash'); const EventEmitter2 = require('eventemitter2'); const {HtmlReporter} = require('lib/plugin-api'); +const {ToolName} = require('lib/constants'); function stubConfig(config = {}) { const browsers = config.browsers || {}; @@ -58,6 +59,7 @@ function stubToolAdapter({ config = stubConfig(), reporterConfig = stubReporterConfig(), testCollection = {tests: []}, htmlReporter } = {}) { const toolAdapter = { + toolName: ToolName.Testplane, config, reporterConfig, htmlReporter: htmlReporter || sinon.createStubInstance(HtmlReporter),