From ba0577e9b0e13c46721777cd6dcf73b7fd5e1663 Mon Sep 17 00:00:00 2001 From: Yaroslav Serhieiev Date: Thu, 26 Jun 2025 21:04:53 +0300 Subject: [PATCH] feat: artifacts in each step --- .gitignore | 1 - package-e2e/test.ts | 20 +++++ package.json | 4 +- src/index.ts | 93 ++------------------ src/listener.ts | 119 +++++++++++++++++++++++++ src/logs/index.ts | 1 + src/logs/log-buffer.ts | 174 +++++++++++++++++++++++++++++++++++++ src/screenshots/helpers.ts | 52 +++++++++++ src/screenshots/index.ts | 1 + src/steps/wrapWithSteps.ts | 110 +++++++++++++++++------ src/types.ts | 28 ++++++ 11 files changed, 489 insertions(+), 114 deletions(-) create mode 100644 src/listener.ts create mode 100644 src/logs/index.ts create mode 100644 src/logs/log-buffer.ts create mode 100644 src/screenshots/helpers.ts create mode 100644 src/screenshots/index.ts create mode 100644 src/types.ts diff --git a/.gitignore b/.gitignore index 6d60b2a..4786ea9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ # Logs -logs *.log npm-debug.log* yarn-debug.log* diff --git a/package-e2e/test.ts b/package-e2e/test.ts index 80f2f5f..6137de6 100644 --- a/package-e2e/test.ts +++ b/package-e2e/test.ts @@ -1,6 +1,7 @@ import type { ReporterOptions } from 'jest-allure2-reporter'; import listener from 'detox-allure2-adapter'; +import type { DetoxAllure2AdapterOptions, DetoxAllure2AdapterDeviceLogsOptions, DetoxAllure2AdapterDeviceScreenshotOptions } from 'detox-allure2-adapter'; import DetoxAllurePathBuilder from 'detox-allure2-adapter/path-builder'; import presetAllure from 'detox-allure2-adapter/preset-allure'; import presetDetox from 'detox-allure2-adapter/preset-detox'; @@ -10,6 +11,25 @@ function assertType(_actual: T): void { // no-op } +assertType(presetAllure); +assertType(presetDetox); + assertType(listener); assertType(new DetoxAllurePathBuilder()); assertType(presetAllure); +assertType({ + useSteps: true, + deviceLogs: true, + deviceScreenshots: true, +}); + +assertType({ + ios: () => true, + android: () => true, + override: true, + saveAll: true, +}); + +assertType({ + saveAll: true, +}); \ No newline at end of file diff --git a/package.json b/package.json index 77d1b86..81a0098 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,8 @@ "node 16" ], "dependencies": { - "archiver": "^6.0.1" + "archiver": "^6.0.1", + "logkitten": "^1.3.0", + "screenkitten": "^1.0.0" } } diff --git a/src/index.ts b/src/index.ts index 0475f2e..928e36a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,88 +1,7 @@ -import fs from 'node:fs'; -import path from 'node:path'; +export type { + DetoxAllure2AdapterOptions, + DetoxAllure2AdapterDeviceLogsOptions, + DetoxAllure2AdapterDeviceScreenshotOptions, +} from './types'; -// eslint-disable-next-line import/no-internal-modules -import detox from 'detox'; -// eslint-disable-next-line import/no-internal-modules -import { worker } from 'detox/internals'; -// eslint-disable-next-line import/no-internal-modules -import { allure, type MIMEInferer } from 'jest-allure2-reporter/api'; -// eslint-disable-next-line node/no-extraneous-import -import type { EnvironmentListenerFn } from 'jest-environment-emit'; - -import { createLogHandler, createZipHandler } from './file-handlers'; -import { wrapWithSteps } from './steps'; - -export type DetoxAllure2AdapterOptions = { - /** - * Whether to wrap device, element and other actions in Allure steps - * @default false - */ - useSteps?: boolean; -}; - -const listener: EnvironmentListenerFn = ( - { testEvents }, - { useSteps = false }: DetoxAllure2AdapterOptions = {}, -) => { - let logHandler: ReturnType; - let zipHandler: ReturnType; - let inferMimeType: MIMEInferer; - let $test: ReturnType | undefined; - let artifactsManager: any; - - testEvents - .on('setup', () => { - allure.$plug((context) => { - logHandler = createLogHandler(context); - zipHandler = createZipHandler(context); - inferMimeType = context.inferMimeType; - }); - - artifactsManager = (worker as any)._artifactsManager; - artifactsManager.on('trackArtifact', onTrackArtifact); - }) - .on('setup', async () => { - if (useSteps) wrapWithSteps(detox, worker, allure); - }) - .on('test_start', () => { - $test = allure.$bind(); - }) - .on('test_done', () => { - $test = undefined; - }) - .on('teardown', flushArtifacts, -1) - .on('test_environment_teardown', flushArtifacts, -1); - - async function flushArtifacts() { - await artifactsManager?._idlePromise; - artifactsManager = undefined; - } - - function onTrackArtifact(artifact: any) { - const $step = allure.$bind(); - const $$test = $test; - const originalSave = artifact.doSave.bind(artifact); - - artifact.doSave = async (artifactPath: string, ...args: unknown[]) => { - const result = await originalSave(artifactPath, ...args); - const isDirectory = fs.lstatSync(artifactPath).isDirectory(); - const isLog = path.extname(artifactPath) === '.log'; - const isVideo = !!inferMimeType({ sourcePath: artifactPath })?.startsWith('video/'); - const handler = isDirectory ? zipHandler : isLog ? logHandler : 'copy'; - const mimeType = isLog ? 'text/plain' : isDirectory ? 'application/zip' : undefined; - const $allure = (isLog || isVideo ? $$test : $step) ?? $step; - const name = path.basename(artifactPath); - - $allure.fileAttachment(artifactPath, { - name, - mimeType, - handler, - }); - - return result; - }; - } -}; - -export default listener; +export { listener as default } from './listener'; diff --git a/src/listener.ts b/src/listener.ts new file mode 100644 index 0000000..9b84b1c --- /dev/null +++ b/src/listener.ts @@ -0,0 +1,119 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +// eslint-disable-next-line import/no-internal-modules +import detox from 'detox'; +// eslint-disable-next-line import/no-internal-modules +import { worker } from 'detox/internals'; +// eslint-disable-next-line import/no-internal-modules +import { allure, type MIMEInferer } from 'jest-allure2-reporter/api'; +// eslint-disable-next-line node/no-extraneous-import +import type { EnvironmentListenerFn } from 'jest-environment-emit'; + +import { createLogHandler, createZipHandler } from './file-handlers'; +import { LogBuffer } from './logs'; +import { ScreenshotHelper } from './screenshots'; +import { wrapWithSteps } from './steps'; +import type { DetoxAllure2AdapterOptions } from './types'; + +export const listener: EnvironmentListenerFn = ( + { testEvents }, + { + useSteps = false, + deviceLogs = false, + deviceScreenshots = false, + }: DetoxAllure2AdapterOptions = {}, +) => { + let logHandler: ReturnType; + let zipHandler: ReturnType; + let inferMimeType: MIMEInferer; + let $test: ReturnType | undefined; + let artifactsManager: any; + let logs: LogBuffer | undefined; + let screenshots: ScreenshotHelper | undefined; + + testEvents + .on('setup', () => { + allure.$plug((context) => { + logHandler = createLogHandler(context); + zipHandler = createZipHandler(context); + inferMimeType = context.inferMimeType; + }); + + artifactsManager = (worker as any)._artifactsManager; + artifactsManager.on('trackArtifact', onTrackArtifact); + + if (deviceLogs) { + logs = new LogBuffer({ + device: detox.device, + options: deviceLogs, + }); + } + + if (deviceScreenshots) { + screenshots = new ScreenshotHelper({ + device: detox.device, + options: deviceScreenshots, + }); + } + }) + .on('setup', async () => { + if (useSteps) { + wrapWithSteps({ detox, worker, allure, logs, screenshots }); + } + }) + .on('test_start', () => { + $test = allure.$bind(); + logs?.attachBefore(allure); + }) + .on('hook_start', () => { + logs?.attachBefore(allure); + }) + .on('hook_failure', async () => { + await screenshots?.attachFailure(allure); + logs?.attachAfterFailure(allure); + }) + .on('hook_success', async () => { + await screenshots?.attachSuccess(allure); + logs?.attachAfterSuccess(allure); + }) + .on('test_done', async ({ event }) => { + await screenshots?.attach(allure, event.test.failing); + logs?.attachAfter(allure, event.test.failing); + $test = undefined; + }) + .on('teardown', flushArtifacts, -1) + .on('test_environment_teardown', flushArtifacts, -1); + + async function flushArtifacts() { + await artifactsManager?._idlePromise; + await logs?.close(); + artifactsManager = undefined; + logs = undefined; + } + + function onTrackArtifact(artifact: any) { + const $step = allure.$bind(); + const $$test = $test; + const originalSave = artifact.doSave.bind(artifact); + + artifact.doSave = async (artifactPath: string, ...args: unknown[]) => { + const result = await originalSave(artifactPath, ...args); + const isDirectory = fs.lstatSync(artifactPath).isDirectory(); + const isLog = path.extname(artifactPath) === '.log'; + const isVideo = !!inferMimeType({ sourcePath: artifactPath })?.startsWith('video/'); + const handler = isDirectory ? zipHandler : isLog ? logHandler : 'copy'; + const mimeType = isLog ? 'text/plain' : isDirectory ? 'application/zip' : undefined; + const $allure = (isLog || isVideo ? $$test : $step) ?? $step; + const name = path.basename(artifactPath); + + $allure.fileAttachment(artifactPath, { + name, + mimeType, + handler, + }); + + return result; + }; + } +}; diff --git a/src/logs/index.ts b/src/logs/index.ts new file mode 100644 index 0000000..126a80b --- /dev/null +++ b/src/logs/index.ts @@ -0,0 +1 @@ +export * from './log-buffer'; diff --git a/src/logs/log-buffer.ts b/src/logs/log-buffer.ts new file mode 100644 index 0000000..940ab67 --- /dev/null +++ b/src/logs/log-buffer.ts @@ -0,0 +1,174 @@ +// eslint-disable-next-line import/no-internal-modules +import type { AllureRuntime } from 'jest-allure2-reporter/api'; + +import type { Emitter, AndroidEntry, IosEntry, Entry } from 'logkitten'; +import { Level, logkitten } from 'logkitten'; +import type { DetoxAllure2AdapterDeviceLogsOptions } from '../types'; + +type AnyEntry = AndroidEntry & IosEntry; + +export interface LogBufferOptions { + device: Detox.Device; + options: true | DetoxAllure2AdapterDeviceLogsOptions; +} + +export interface StepLogRecorder { + attachBefore(allure: AllureRuntime): void; + attachAfter(allure: AllureRuntime, failed: boolean): void; + attachAfterSuccess(allure: AllureRuntime): void; + attachAfterFailure(allure: AllureRuntime): void; + resetPid(): void; + refreshPid(): void; + close(): Promise; +} + +export class LogBuffer implements StepLogRecorder { + private readonly _emitter: Emitter; + private _entries: AnyEntry[] = []; + private _purgatory: AnyEntry[] = []; + private _pid = Number.NaN; + private _options: DetoxAllure2AdapterDeviceLogsOptions; + + constructor(readonly _config: LogBufferOptions) { + const deviceId = this._config.device.id; + const platform = this._config.device.getPlatform(); + + this._options = typeof this._config.options === 'boolean' ? {} : this._config.options; + this._emitter = + platform === 'android' + ? logkitten({ + platform, + deviceId, + filter: this._androidFilter.bind(this), + }) + : logkitten({ + platform, + deviceId, + filter: this._iosFilter.bind(this), + }); + + this._emitter.on('entry', this._onEntry); + } + + public resetPid() { + this._pid = Number.NaN; + } + + public refreshPid() { + const processes = (this._config.device as any)._processes ?? {}; + this._pid = Number(Object.values(processes)[0]); + + if (Number.isFinite(this._pid) && this._purgatory.length > 0) { + this._entries = [...this._entries, ...this._purgatory.splice(0).filter(this._matchesPid)]; + } + } + + public flush(failed?: boolean): string { + if (this._entries.length === 0) { + return ''; + } + + const entries = this._entries.splice(0); + + // Check if we should save logs based on failure status + const saveAll = this._options.saveAll ?? false; + if (!saveAll && !failed) { + return ''; + } + + const result = entries + .map((entry) => { + const levelLetter = Level[entry.level as Level] || 'UNKNOWN'; + const tagOrCategory = entry.tag || `${entry.subsystem}:${entry.category}`; + const msg = entry.msg; + + return `${levelLetter}\t${tagOrCategory}\t${msg}`; + }) + .join('\n'); + + return result + '\n'; + } + + public async close() { + await this._emitter.close(); + } + + public attachBefore(allure: AllureRuntime) { + return this._attachLogs(allure, false, false); + } + + public attachAfter(allure: AllureRuntime, failed: boolean) { + return this._attachLogs(allure, failed, true); + } + + public attachAfterSuccess(allure: AllureRuntime) { + return this._attachLogs(allure, false, true); + } + + public attachAfterFailure(allure: AllureRuntime) { + return this._attachLogs(allure, true, true); + } + + private readonly _attachLogs = (allure: AllureRuntime, failed: boolean, after: boolean) => { + const content = this.flush(failed); + + if (content) { + const name = after ? 'app.log' : 'app-before.log'; + allure.attachment(name, content, 'text/plain'); + } + }; + + private readonly _onEntry = (entry: AnyEntry) => { + if (Number.isFinite(this._pid)) { + this._entries.push(entry); + } else { + this._purgatory.push(entry); + } + }; + + private readonly _matchesPid = (entry: Entry) => entry.pid === this._pid; + + private _iosFilter(entry: IosEntry): boolean { + const userFilter = this._options.ios; + const override = this._options.override; + if (!override && !this._defaultIosFilter(entry)) { + return false; + } + + return userFilter?.(entry) ?? true; + } + + private _androidFilter(entry: AndroidEntry): boolean { + const userFilter = this._options.android; + const override = this._options.override; + if (!override && !this._defaultAndroidFilter(entry)) { + return false; + } + + return userFilter?.(entry) ?? true; + } + + private readonly _defaultIosFilter = (entry: IosEntry) => { + if (entry.subsystem.startsWith('com.facebook.react.')) { + return true; + } + + if (entry.level >= Level.ERROR) { + return !entry.subsystem.startsWith('com.apple.'); // && !entry.msg.includes('(CFNetwork)'); + } + + return false; + }; + + private readonly _defaultAndroidFilter = (entry: AndroidEntry) => { + if (entry.tag.startsWith('Detox') || entry.tag.startsWith('React')) { + return true; + } + + if (entry.level >= Level.ERROR) { + return true; + } + + return false; + }; +} diff --git a/src/screenshots/helpers.ts b/src/screenshots/helpers.ts new file mode 100644 index 0000000..9da9bd0 --- /dev/null +++ b/src/screenshots/helpers.ts @@ -0,0 +1,52 @@ +// eslint-disable-next-line import/no-internal-modules +import type { AllureRuntime } from 'jest-allure2-reporter/api'; +import { screenkitten, type Screenkitten } from 'screenkitten'; + +import type { DetoxAllure2AdapterDeviceScreenshotOptions } from '../types'; + +export interface ScreenshotHelperConfig { + device: Detox.Device; + options: true | DetoxAllure2AdapterDeviceScreenshotOptions; +} + +export class ScreenshotHelper { + private readonly _device: Detox.Device; + private readonly _platform: 'ios' | 'android'; + private readonly _options: DetoxAllure2AdapterDeviceScreenshotOptions; + private readonly _kitten: Screenkitten; + + constructor({ device, options }: ScreenshotHelperConfig) { + this._device = device; + this._platform = device.getPlatform(); + this._options = typeof options === 'boolean' ? {} : options; + this._kitten = screenkitten({ + platform: this._platform, + onError: 'ignore', // Don't throw on errors, just log them + }); + } + + async attachFailure(allure: AllureRuntime) { + return this.attach(allure, true); + } + + async attachSuccess(allure: AllureRuntime) { + return this.attach(allure, false); + } + + async attach(allure: AllureRuntime, failed: boolean) { + if (this._options.saveAll) { + await this._attachScreenshot(allure, failed ? 'failure' : 'screenshot'); + } else if (failed) { + await this._attachScreenshot(allure, 'failure'); + } + } + + private async _attachScreenshot(allure: AllureRuntime, name = 'screenshot') { + const filePath = await this._kitten.takeScreenshot({ deviceId: this._device.id }); + + allure.fileAttachment(filePath, { + name: `${name}.png`, + handler: 'move', + }); + } +} diff --git a/src/screenshots/index.ts b/src/screenshots/index.ts new file mode 100644 index 0000000..c5f595c --- /dev/null +++ b/src/screenshots/index.ts @@ -0,0 +1 @@ +export * from './helpers'; diff --git a/src/steps/wrapWithSteps.ts b/src/steps/wrapWithSteps.ts index b7e0306..5231cee 100644 --- a/src/steps/wrapWithSteps.ts +++ b/src/steps/wrapWithSteps.ts @@ -1,47 +1,69 @@ // eslint-disable-next-line import/no-internal-modules import type { AllureRuntime } from 'jest-allure2-reporter/api'; +import { type StepLogRecorder } from '../logs'; +import { type ScreenshotHelper } from '../screenshots'; import { androidDescriptionMaker, iosDescriptionMaker } from './description-maker'; import type { StepDescriptionMaker } from './description-maker'; -export function wrapWithSteps(detox: typeof import('detox'), worker: any, allure: AllureRuntime) { - const { device } = detox; - - device.launchApp = allure.createStep('Launch app', [], device.launchApp); - device.relaunchApp = allure.createStep('Relaunch app', [], device.relaunchApp); - device.terminateApp = allure.createStep('Terminate app', [], device.terminateApp); - device.openURL = allure.createStep('Open URL', [], device.openURL); - device.reloadReactNative = allure.createStep( - 'Reload React Native bundle', - [], - device.reloadReactNative, - ); +export interface WrapWithStepsOptions { + detox: typeof import('detox'); + worker: any; + allure: AllureRuntime; + logs?: StepLogRecorder; + screenshots?: ScreenshotHelper; +} - device.sendToHome = allure.createStep('Send app to background', [], device.sendToHome); - device.setOrientation = allure.createStep('Set orientation', [], device.setOrientation); +export function wrapWithSteps(options: WrapWithStepsOptions) { + const { detox, worker, allure, logs, screenshots } = options; + const { device } = detox; + const platform = device.getPlatform(); - device.matchFace = allure.createStep('Match face', [], device.matchFace); - device.unmatchFace = allure.createStep('Unmatch face', [], device.unmatchFace); - device.matchFinger = allure.createStep('Match finger', [], device.matchFinger); - device.unmatchFinger = allure.createStep('Unmatch finger', [], device.unmatchFinger); + // Wrap device methods using the helper + wrapDeviceMethod(options, 'launchApp', 'Launch app'); + wrapDeviceMethod(options, 'relaunchApp', 'Relaunch app'); + wrapDeviceMethod(options, 'terminateApp', 'Terminate app'); + wrapDeviceMethod(options, 'openURL', 'Open URL'); + wrapDeviceMethod(options, 'reloadReactNative', 'Reload React Native bundle'); + wrapDeviceMethod(options, 'sendToHome', 'Send app to background'); + wrapDeviceMethod(options, 'setOrientation', 'Set orientation'); + wrapDeviceMethod(options, 'matchFace', 'Match face'); + wrapDeviceMethod(options, 'unmatchFace', 'Unmatch face'); + wrapDeviceMethod(options, 'matchFinger', 'Match finger'); + wrapDeviceMethod(options, 'unmatchFinger', 'Unmatch finger'); - const platform = device.getPlatform(); const descriptionMaker = initDescriptionMaker(platform); if (descriptionMaker) { const ws = worker._client._asyncWebSocket; const send = ws.send.bind(ws) as (...args: any[]) => Promise<{ type?: string }>; + const onActionSuccess = async () => { + await logs?.attachAfterSuccess(allure); + }; + const onActionFailure = async (shouldSetStatus: boolean) => { + if (shouldSetStatus) { + allure.status('failed'); + } + + await screenshots?.attachFailure(allure); + await logs?.attachAfterFailure(allure); + }; ws.send = async (...args: any[]) => { const desc = descriptionMaker(args[0]); return desc?.message - ? allure.step(desc.message, () => { + ? allure.step(desc.message, async () => { if (desc.args) allure.parameters(desc.args); - return send(...args).then((result: { type?: string }) => { - if (result?.type === 'testFailed') { - allure.status('failed'); - } + logs?.attachBefore(allure); + try { + const result = await send(...args); + const onActionDone = + result?.type === 'testFailed' ? onActionFailure : onActionSuccess; + await onActionDone(true); return result; - }); + } catch (error) { + await onActionFailure(false); + throw error; + } }) : send(...args); }; @@ -56,3 +78,41 @@ function initDescriptionMaker(platform: string): StepDescriptionMaker | undefine } return undefined; } + +const PID_CHANGING_METHODS = new Set(['launchApp', 'relaunchApp', 'openURL']); + +function wrapDeviceMethod( + { detox, allure, logs, screenshots }: WrapWithStepsOptions, + methodName: string, + stepDescription: string, +) { + const device = detox.device as any; + const originalMethod = device[methodName]; + if (typeof originalMethod !== 'function') return; + + device[methodName] = async (...args: any[]) => { + return await allure.step(stepDescription, async () => { + if (PID_CHANGING_METHODS.has(methodName)) { + logs?.resetPid(); + } + + try { + const result = await originalMethod.apply(device, args); + + if (PID_CHANGING_METHODS.has(methodName)) { + logs?.refreshPid(); + } + + await screenshots?.attach(allure, false); + await logs?.attachAfterSuccess(allure); + + return result; + } catch (error) { + await screenshots?.attachFailure(allure); + await logs?.attachAfterFailure(allure); + + throw error; // Re-throw the error + } + }); + }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..ba6f45e --- /dev/null +++ b/src/types.ts @@ -0,0 +1,28 @@ +import type { AndroidEntry, IosEntry } from 'logkitten'; + +export type DetoxAllure2AdapterOptions = { + /** + * Whether to wrap device, element and other actions in Allure steps + * @default false + */ + useSteps?: boolean; + /** + * Device logs configuration for per-step logging + */ + deviceLogs?: boolean | DetoxAllure2AdapterDeviceLogsOptions; + /** + * Device screenshots configuration for per-step logging + */ + deviceScreenshots?: boolean | DetoxAllure2AdapterDeviceScreenshotOptions; +}; + +export interface DetoxAllure2AdapterDeviceLogsOptions { + ios?: (entry: IosEntry) => boolean; + android?: (entry: AndroidEntry) => boolean; + override?: boolean; + saveAll?: boolean; +} + +export interface DetoxAllure2AdapterDeviceScreenshotOptions { + saveAll?: boolean; +}