Skip to content

Commit 57b7b1a

Browse files
committed
refactor: action decorator
1 parent 0272cbd commit 57b7b1a

File tree

6 files changed

+97
-84
lines changed

6 files changed

+97
-84
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import type { Locator } from '@playwright/test'
22
import { Button } from '@shared/components/base'
33
import type { HoverOptions } from '@shared/entities'
4-
import { descriptiveHover } from '@shared/components/decorator'
4+
import { descriptive } from '@shared/components/decorator'
55

66
export class ExchangeButton extends Button {
77

88
constructor(rootLocator: Locator, componentName?: string, componentType?: string) {
99
super(rootLocator, componentName, componentType || 'exchange button')
1010
}
1111

12+
@descriptive('Hover', true)
1213
async hover(options?: HoverOptions): Promise<void> {
1314
const parentBtn = new Button(this.mainLocator.locator('..'), this.componentName, this.componentType)
14-
await descriptiveHover(parentBtn, options)
15+
await parentBtn.hover(options)
1516
}
1617
}

src/packages/shared/components/base/BaseComponent.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Locator } from '@playwright/test'
22
import type { ClickOptions, HoverOptions, TimeoutOption } from '@shared/entities'
3-
import { descriptiveClick, descriptiveFocus, descriptiveHover, descriptiveScroll } from '@shared/components/decorator'
3+
import { descriptive } from '@shared/components/decorator'
44

55
export class BaseComponent {
66

@@ -28,19 +28,23 @@ export class BaseComponent {
2828
this.componentType = componentType || 'component'
2929
}
3030

31+
@descriptive('Click')
3132
async click(options?: ClickOptions): Promise<void> {
32-
await descriptiveClick(this, options)
33+
await this.mainLocator.click(options)
3334
}
3435

36+
@descriptive('Hover', true)
3537
async hover(options?: HoverOptions): Promise<void> {
36-
await descriptiveHover(this, options)
38+
await this.mainLocator.hover(options)
3739
}
3840

41+
@descriptive('Scroll to visible')
3942
async scrollIntoViewIfNeeded(options?: TimeoutOption): Promise<void> {
40-
await descriptiveScroll(this, options)
43+
await this.mainLocator.scrollIntoViewIfNeeded(options)
4144
}
4245

43-
async focus(options?: HoverOptions): Promise<void> {
44-
await descriptiveFocus(this, options)
46+
@descriptive('Focus')
47+
async focus(options?: TimeoutOption): Promise<void> {
48+
await this.mainLocator.focus(options)
4549
}
4650
}
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { CheckOptions } from '@shared/entities'
22
import { BaseComponent } from './BaseComponent'
3-
import { descriptiveCheck, descriptiveUncheck } from '@shared/components/decorator'
3+
import { descriptive } from '@shared/components/decorator'
44
import type { Locator } from '@playwright/test'
55

66
export class Checkbox extends BaseComponent {
@@ -9,11 +9,13 @@ export class Checkbox extends BaseComponent {
99
super(rootLocator, componentName, componentType || 'checkbox')
1010
}
1111

12+
@descriptive('Check')
1213
async check(options?: CheckOptions): Promise<void> {
13-
await descriptiveCheck(this, options)
14+
await this.mainLocator.check(options)
1415
}
1516

17+
@descriptive('Uncheck')
1618
async uncheck(options?: CheckOptions): Promise<void> {
17-
await descriptiveUncheck(this, options)
19+
await this.mainLocator.uncheck(options)
1820
}
1921
}
Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Locator } from '@playwright/test'
2-
import { descriptiveClear, descriptiveFill, descriptiveHover, descriptiveType } from '@shared/components/decorator'
3-
import type { ClearOptions, FillOptions, HoverOptions, TypeOptions } from '@shared/entities'
2+
import { descriptive } from '@shared/components/decorator'
3+
import type { ClearOptions, FillOptions, TypeOptions } from '@shared/entities'
44
import { BaseComponent } from '../BaseComponent'
55
import { Content } from '../Content'
66
import { Button } from '../buttons/Button'
@@ -14,19 +14,18 @@ export class TextField extends BaseComponent {
1414
super(rootLocator, componentName, componentType || 'text field')
1515
}
1616

17-
async hover(options?: HoverOptions): Promise<void> {
18-
await descriptiveHover(this, options)
19-
}
20-
17+
@descriptive('Fill')
2118
async fill(value: string, options?: FillOptions): Promise<void> {
22-
await descriptiveFill(this, value, options)
19+
await this.mainLocator.fill(value, options)
2320
}
2421

22+
@descriptive('Type')
2523
async type(value: string, options?: TypeOptions): Promise<void> {
26-
await descriptiveType(this, value, options)
24+
await this.mainLocator.pressSequentially(value, options)
2725
}
2826

27+
@descriptive('Clear')
2928
async clear(options?: ClearOptions): Promise<void> {
30-
await descriptiveClear(this, options)
29+
await this.mainLocator.clear(options)
3130
}
3231
}
Lines changed: 54 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,57 @@
11
import { test as report } from '@playwright/test'
22
import type { BaseComponent } from '@shared/components/base'
3-
import type {
4-
CheckOptions,
5-
ClearOptions,
6-
ClickOptions,
7-
FillOptions,
8-
HoverOptions,
9-
TimeoutOption,
10-
TypeOptions,
11-
} from '@shared/entities'
12-
import { quoteName } from '@services/utils'
13-
14-
export async function descriptiveClick(component: BaseComponent, options?: ClickOptions): Promise<void> {
15-
await report.step(`Click ${quoteName(component.componentName)} ${component.componentType}`, async () => {
16-
await component.mainLocator.click(options)
17-
}, { box: true })
18-
}
19-
20-
export async function descriptiveHover(component: BaseComponent, options?: HoverOptions): Promise<void> {
21-
await report.step(`Hover ${quoteName(component.componentName)} ${component.componentType}`, async () => {
22-
await component.mainLocator.hover(options)
23-
await component.mainLocator.page().waitForTimeout(500) //WA: wait while extra tooltips disappear
24-
}, { box: true })
25-
}
26-
27-
export async function descriptiveFill(component: BaseComponent, value: string, options?: FillOptions): Promise<void> {
28-
await report.step(`Fill ${quoteName(component.componentName)} ${component.componentType} with "${value}"`, async () => {
29-
await component.mainLocator.fill(value, options)
30-
}, { box: true })
31-
}
32-
33-
export async function descriptiveType(component: BaseComponent, value: string, options?: TypeOptions): Promise<void> {
34-
await report.step(`Type "${value}" into ${quoteName(component.componentName)} ${component.componentType}`, async () => {
35-
await component.mainLocator.type(value, options)
36-
}, { box: true })
37-
}
38-
39-
export async function descriptiveClear(component: BaseComponent, options?: ClearOptions): Promise<void> {
40-
await report.step(`Clear ${quoteName(component.componentName)} ${component.componentType}`, async () => {
41-
await component.mainLocator.clear(options)
42-
}, { box: true })
43-
}
44-
45-
export async function descriptiveCheck(component: BaseComponent, options?: CheckOptions): Promise<void> {
46-
await report.step(`Check ${quoteName(component.componentName)} ${component.componentType}`, async () => {
47-
await component.mainLocator.check(options)
48-
}, { box: true })
49-
}
50-
51-
export async function descriptiveUncheck(component: BaseComponent, options?: CheckOptions): Promise<void> {
52-
await report.step(`Uncheck ${quoteName(component.componentName)} ${component.componentType}`, async () => {
53-
await component.mainLocator.uncheck(options)
54-
}, { box: true })
55-
}
56-
57-
export async function descriptiveScroll(component: BaseComponent, options?: TimeoutOption): Promise<void> {
58-
await report.step(`Scroll until ${quoteName(component.componentName)} ${component.componentType} to be visible`, async () => {
59-
await component.mainLocator.scrollIntoViewIfNeeded(options)
60-
}, { box: true })
61-
}
62-
63-
export async function descriptiveFocus(component: BaseComponent, options?: TimeoutOption): Promise<void> {
64-
await report.step(`Focus ${quoteName(component.componentName)} ${component.componentType}`, async () => {
65-
await component.mainLocator.focus(options)
66-
}, { box: true })
3+
import { handlePlaywrightError, quoteName } from '@services/utils'
4+
5+
const HOVER_TOOLTIP_TIMEOUT = 500
6+
7+
/**
8+
* A decorator factory that adds descriptive logging to methods in UI component classes.
9+
*
10+
* This decorator is designed specifically for Playwright test automation. It:
11+
* 1. Wraps the target method with Playwright's test.step() for improved test reporting
12+
* 2. Automatically generates a descriptive step title based on the action, component name, and component type
13+
* 3. Handles errors with descriptive messages that help identify the exact component and action that failed
14+
* 4. Optionally adds a wait period after actions to allow tooltips to appear (useful for hover actions)
15+
*
16+
* The decorator supports two argument patterns:
17+
* - Methods with no input value: e.g., click(), hover()
18+
* - Methods with a string value: e.g., type('text'), select('option')
19+
*
20+
* @param action - The action being performed, e.g. "Click", "Type", "Hover", etc.
21+
* @param waitForTooltip - When true, waits for HOVER_TOOLTIP_TIMEOUT milliseconds after the action
22+
* to allow UI elements like tooltips to appear. Defaults to false.
23+
* @returns A method decorator that can be applied to component class methods.
24+
*/
25+
export function descriptive(action: string, waitForTooltip = false) {
26+
return function <This extends BaseComponent, Args extends Array<unknown>>(
27+
target: (this: This, ...args: Args) => Promise<void>,
28+
) {
29+
return async function (this: This, ...args: Args): Promise<void> {
30+
// Check if first argument is a string (used for type/fill operations)
31+
const value = typeof args[0] === 'string' ? args[0] : undefined
32+
33+
// Create descriptive step title
34+
const componentDetails = `${quoteName(this.componentName)} ${this.componentType}`
35+
const stepTitle = value
36+
? `${action} "${value}" into ${componentDetails}`
37+
: `${action} ${componentDetails}`
38+
39+
await report.step(stepTitle, async () => {
40+
try {
41+
// Execute the original method
42+
await target.apply(this, args)
43+
44+
// Wait for tooltip if needed (e.g. for hover operations)
45+
if (waitForTooltip) {
46+
await this.mainLocator.page().waitForTimeout(HOVER_TOOLTIP_TIMEOUT)
47+
}
48+
} catch (error: unknown) {
49+
// Enhance error with context information
50+
const errorPrefix = `Failed to ${action}`
51+
const errorContext = value ? ` "${value}" into ${componentDetails}` : ` ${componentDetails}`
52+
handlePlaywrightError(error, `${errorPrefix}${errorContext}`)
53+
}
54+
}, { box: true })
55+
}
56+
}
6757
}

src/services/utils/errors.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,20 @@ export const stringifyError = (error: unknown): string => {
1717
export const getRestFailMsg = async (message: string, response: APIResponse): Promise<string> => {
1818
return `${message} has been failed\n${await getResponseDebugMsg(response)}`
1919
}
20+
21+
export const handlePlaywrightError = (error: unknown, message: string): void => {
22+
// Add component context to the error message
23+
const originalMessage = error instanceof Error ? error.message : String(error)
24+
const contextualMessage = `${message}: ${originalMessage}`
25+
26+
// Create a new error with the enhanced message but preserve the stack trace
27+
const enhancedError = new Error(contextualMessage)
28+
if (error instanceof Error) {
29+
enhancedError.stack = error.stack
30+
enhancedError.name = error.name
31+
// Copy any additional properties from the original error
32+
Object.assign(enhancedError, error)
33+
}
34+
35+
throw enhancedError
36+
}

0 commit comments

Comments
 (0)