@sridharkikkeri/playwright-common 1.0.19 → 1.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/create-healthedge-tests.js +64 -292
- package/package.json +2 -1
- package/src/core/api/ApiClient.ts +289 -0
- package/src/core/api/auth/AuthStrategy.ts +11 -0
- package/src/core/api/auth/CookieAuth.ts +36 -0
- package/src/core/api/auth/OAuth2Auth.ts +46 -0
- package/src/core/config/ConfigManager.ts +72 -0
- package/src/core/i18n/Localization.ts +34 -0
- package/src/core/i18n/en.json +5 -0
- package/src/core/pages/BasePage.ts +47 -0
- package/src/core/reporting/AllureUtil.ts +35 -0
- package/src/core/selfhealing/ActionOrchestrator.ts +117 -0
- package/src/core/selfhealing/ElementProfileStore.ts +76 -0
- package/src/core/selfhealing/LocatorHealing.ts +84 -0
- package/src/core/utils/ErrorUtils.ts +21 -0
- package/src/core/utils/Logger.ts +14 -0
- package/src/core/visual/VisualTesting.ts +95 -0
- package/src/core/wrappers/ElementWrapper.ts +211 -0
- package/src/fixtures/fixtures.ts +90 -0
- package/src/index.ts +17 -0
- package/src/quality/pages/LoginPage.ts +36 -0
- package/src/tests/visual.spec.ts +38 -0
- package/src/tests/visual.spec.ts-snapshots/header-element-darwin.png +0 -0
- package/src/tests/visual.spec.ts-snapshots/playwright-homepage-darwin.png +0 -0
- package/src/tests/visual.spec.ts-snapshots/viewport-masked-darwin.png +0 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { APIRequestContext, request, APIResponse } from '@playwright/test';
|
|
2
|
+
import { AuthStrategy } from './auth/AuthStrategy';
|
|
3
|
+
import { AllureUtil } from '../reporting/AllureUtil';
|
|
4
|
+
import { Logger } from '../utils/Logger';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
|
|
8
|
+
/* ======================================================
|
|
9
|
+
* Types
|
|
10
|
+
* ====================================================== */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* HTTP methods supported by the ApiClient.
|
|
14
|
+
*/
|
|
15
|
+
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Represents a file attachment for multipart requests.
|
|
19
|
+
*/
|
|
20
|
+
export interface FileAttachment {
|
|
21
|
+
/** The name of the form field. */
|
|
22
|
+
fieldName: string;
|
|
23
|
+
/** The absolute or relative path to the file. */
|
|
24
|
+
filePath: string;
|
|
25
|
+
/** Optional MIME type of the file. */
|
|
26
|
+
contentType?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Configuration options for an API request.
|
|
31
|
+
*/
|
|
32
|
+
export interface ApiRequestOptions {
|
|
33
|
+
/** Custom headers to include in the request. */
|
|
34
|
+
headers?: Record<string, string>;
|
|
35
|
+
/** URL query parameters. */
|
|
36
|
+
queryParams?: Record<string, string | number>;
|
|
37
|
+
/** Request body for POST, PUT, and PATCH requests. */
|
|
38
|
+
body?: any;
|
|
39
|
+
/** Optional files for multipart/form-data requests. */
|
|
40
|
+
files?: FileAttachment[];
|
|
41
|
+
/** Timeout in milliseconds for this specific request. */
|
|
42
|
+
timeoutMs?: number;
|
|
43
|
+
/** Number of times to retry the request if it fails. */
|
|
44
|
+
retries?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Standardized API response structure.
|
|
49
|
+
* @template T The expected type of the response body.
|
|
50
|
+
*/
|
|
51
|
+
export interface ApiResponse<T> {
|
|
52
|
+
/** HTTP status code. */
|
|
53
|
+
status: number;
|
|
54
|
+
/** Response headers. */
|
|
55
|
+
headers: Record<string, string>;
|
|
56
|
+
/** Decoded response body or null if parsing fails. */
|
|
57
|
+
body: T | null;
|
|
58
|
+
/** The raw Playwright APIResponse object. */
|
|
59
|
+
raw: APIResponse;
|
|
60
|
+
/** Time taken for the request in milliseconds. */
|
|
61
|
+
durationMs: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* ======================================================
|
|
65
|
+
* ApiClient
|
|
66
|
+
* ====================================================== */
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A generic API client built on top of Playwright's APIRequestContext.
|
|
70
|
+
* Provides features like automatic authentication, retries, and Allure reporting.
|
|
71
|
+
*/
|
|
72
|
+
export class ApiClient {
|
|
73
|
+
private context!: APIRequestContext;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param baseUrl The base URL for all requests.
|
|
77
|
+
* @param auth Optional authentication strategy.
|
|
78
|
+
* @param defaultTimeoutMs Default timeout for requests (default 30s).
|
|
79
|
+
* @param defaultRetries Default number of retries for failed requests (default 0).
|
|
80
|
+
*/
|
|
81
|
+
constructor(
|
|
82
|
+
private readonly baseUrl: string,
|
|
83
|
+
private readonly auth?: AuthStrategy,
|
|
84
|
+
private readonly defaultTimeoutMs = 30_000,
|
|
85
|
+
private readonly defaultRetries = 0
|
|
86
|
+
) { }
|
|
87
|
+
|
|
88
|
+
/* ------------------------------------------------------
|
|
89
|
+
* Lifecycle
|
|
90
|
+
* ------------------------------------------------------ */
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Initializes the API context and applies the authentication strategy if provided.
|
|
94
|
+
*/
|
|
95
|
+
async init(): Promise<void> {
|
|
96
|
+
this.context = await request.newContext({
|
|
97
|
+
baseURL: this.baseUrl
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (this.auth) {
|
|
101
|
+
await this.auth.apply(this.context);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Disposes of the API context, cleaning up resources.
|
|
107
|
+
*/
|
|
108
|
+
async dispose(): Promise<void> {
|
|
109
|
+
if (this.context) {
|
|
110
|
+
await this.context.dispose();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* ------------------------------------------------------
|
|
115
|
+
* Public HTTP methods
|
|
116
|
+
* ------------------------------------------------------ */
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Performs a GET request.
|
|
120
|
+
*/
|
|
121
|
+
get<T>(url: string, options?: ApiRequestOptions) {
|
|
122
|
+
return this.request<T>('GET', url, options);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Performs a POST request.
|
|
127
|
+
*/
|
|
128
|
+
post<T>(url: string, body?: any, options?: ApiRequestOptions) {
|
|
129
|
+
return this.request<T>('POST', url, { ...options, body });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Performs a PUT request.
|
|
134
|
+
*/
|
|
135
|
+
put<T>(url: string, body?: any, options?: ApiRequestOptions) {
|
|
136
|
+
return this.request<T>('PUT', url, { ...options, body });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Performs a PATCH request.
|
|
141
|
+
*/
|
|
142
|
+
patch<T>(url: string, body?: any, options?: ApiRequestOptions) {
|
|
143
|
+
return this.request<T>('PATCH', url, { ...options, body });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Performs a DELETE request.
|
|
148
|
+
*/
|
|
149
|
+
delete<T>(url: string, options?: ApiRequestOptions) {
|
|
150
|
+
return this.request<T>('DELETE', url, options);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/* ------------------------------------------------------
|
|
154
|
+
* Core request executor
|
|
155
|
+
* ------------------------------------------------------ */
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Core method to execute an HTTP request with retry logic.
|
|
159
|
+
* @param method The HTTP method to use.
|
|
160
|
+
* @param url The endpoint URL (relative to baseUrl).
|
|
161
|
+
* @param options Request configuration options.
|
|
162
|
+
* @returns A promise that resolves to an ApiResponse.
|
|
163
|
+
*/
|
|
164
|
+
async request<T>(
|
|
165
|
+
method: HttpMethod,
|
|
166
|
+
url: string,
|
|
167
|
+
options: ApiRequestOptions = {}
|
|
168
|
+
): Promise<ApiResponse<T>> {
|
|
169
|
+
|
|
170
|
+
const retries = options.retries ?? this.defaultRetries;
|
|
171
|
+
const timeout = options.timeoutMs ?? this.defaultTimeoutMs;
|
|
172
|
+
|
|
173
|
+
let lastError: any;
|
|
174
|
+
|
|
175
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
176
|
+
try {
|
|
177
|
+
return await this.executeOnce<T>(method, url, options, timeout);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
lastError = error;
|
|
180
|
+
Logger.error(
|
|
181
|
+
`[API] ${method} ${url} failed (attempt ${attempt + 1})`
|
|
182
|
+
);
|
|
183
|
+
if (attempt === retries) throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
throw lastError;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* ------------------------------------------------------
|
|
191
|
+
* Single attempt execution
|
|
192
|
+
* ------------------------------------------------------ */
|
|
193
|
+
|
|
194
|
+
private async executeOnce<T>(
|
|
195
|
+
method: HttpMethod,
|
|
196
|
+
url: string,
|
|
197
|
+
options: ApiRequestOptions,
|
|
198
|
+
timeout: number
|
|
199
|
+
): Promise<ApiResponse<T>> {
|
|
200
|
+
|
|
201
|
+
const start = Date.now();
|
|
202
|
+
|
|
203
|
+
const isMultipart = !!options.files?.length;
|
|
204
|
+
|
|
205
|
+
const response = await this.context.fetch(url, {
|
|
206
|
+
method,
|
|
207
|
+
headers: options.headers,
|
|
208
|
+
params: options.queryParams,
|
|
209
|
+
timeout,
|
|
210
|
+
data: isMultipart ? undefined : options.body,
|
|
211
|
+
multipart: isMultipart ? this.buildMultipart(options) : undefined
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
const duration = Date.now() - start;
|
|
215
|
+
|
|
216
|
+
const body = await this.safeParseBody<T>(response);
|
|
217
|
+
|
|
218
|
+
// ---------- Allure Logging ----------
|
|
219
|
+
AllureUtil.attachJson('API Request', {
|
|
220
|
+
method,
|
|
221
|
+
url,
|
|
222
|
+
headers: options.headers,
|
|
223
|
+
queryParams: options.queryParams,
|
|
224
|
+
body: options.body,
|
|
225
|
+
multipart: isMultipart
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
AllureUtil.attachJson('API Response', {
|
|
229
|
+
status: response.status(),
|
|
230
|
+
durationMs: duration,
|
|
231
|
+
body
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
if (response.status() >= 400) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`API failed ${response.status()} ${method} ${url}`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
status: response.status(),
|
|
242
|
+
headers: response.headers(),
|
|
243
|
+
body,
|
|
244
|
+
raw: response,
|
|
245
|
+
durationMs: duration
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/* ------------------------------------------------------
|
|
250
|
+
* Helpers
|
|
251
|
+
* ------------------------------------------------------ */
|
|
252
|
+
|
|
253
|
+
private buildMultipart(options: ApiRequestOptions) {
|
|
254
|
+
const parts: Record<string, any> = {};
|
|
255
|
+
|
|
256
|
+
if (options.body) {
|
|
257
|
+
Object.entries(options.body).forEach(([key, value]) => {
|
|
258
|
+
parts[key] = String(value);
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
options.files?.forEach(file => {
|
|
263
|
+
parts[file.fieldName] = fs.createReadStream(path.resolve(file.filePath));
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
return parts;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private async safeParseBody<T>(
|
|
270
|
+
response: APIResponse
|
|
271
|
+
): Promise<T | null> {
|
|
272
|
+
const contentType = response.headers()['content-type'] || '';
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
if (contentType.includes('application/json')) {
|
|
276
|
+
return (await response.json()) as T;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (contentType.includes('text')) {
|
|
280
|
+
return (await response.text()) as unknown as T;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// binary / empty
|
|
284
|
+
return null;
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
|
|
2
|
+
import { APIRequestContext } from '@playwright/test';
|
|
3
|
+
/**
|
|
4
|
+
* Interface for authentication strategies that can be applied to an APIRequestContext.
|
|
5
|
+
*/
|
|
6
|
+
export interface AuthStrategy {
|
|
7
|
+
/**
|
|
8
|
+
* Applies the authentication (e.g., setting headers or cookies) to the given context.
|
|
9
|
+
*/
|
|
10
|
+
apply(context: APIRequestContext): Promise<void>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
|
|
2
|
+
import { request } from '@playwright/test';
|
|
3
|
+
import { AuthStrategy } from './AuthStrategy';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Authentication strategy using session cookies.
|
|
7
|
+
* Logs in via a provided URL and saves the session cookies.
|
|
8
|
+
*/
|
|
9
|
+
export class CookieAuth implements AuthStrategy {
|
|
10
|
+
private cookies: any[] = [];
|
|
11
|
+
|
|
12
|
+
constructor(
|
|
13
|
+
private loginUrl: string,
|
|
14
|
+
private credentials: Record<string, string>
|
|
15
|
+
) { }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Internal method to perform login and capture cookies.
|
|
19
|
+
*/
|
|
20
|
+
private async login() {
|
|
21
|
+
const ctx = await request.newContext();
|
|
22
|
+
await ctx.post(this.loginUrl, { data: this.credentials });
|
|
23
|
+
const storage = await ctx.storageState();
|
|
24
|
+
this.cookies = storage.cookies;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Applies the captured cookies to the request context.
|
|
29
|
+
*/
|
|
30
|
+
async apply(context: any) {
|
|
31
|
+
if (!this.cookies.length) {
|
|
32
|
+
await this.login();
|
|
33
|
+
}
|
|
34
|
+
await context.addCookies(this.cookies);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
|
|
2
|
+
import { request } from '@playwright/test';
|
|
3
|
+
import { AuthStrategy } from './AuthStrategy';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Authentication strategy using OAuth2 Client Credentials flow.
|
|
7
|
+
*/
|
|
8
|
+
export class OAuth2Auth implements AuthStrategy {
|
|
9
|
+
private token?: string;
|
|
10
|
+
private expiry = 0;
|
|
11
|
+
|
|
12
|
+
constructor(
|
|
13
|
+
private tokenUrl: string,
|
|
14
|
+
private clientId: string,
|
|
15
|
+
private clientSecret: string
|
|
16
|
+
) { }
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Fetches a new access token using client credentials.
|
|
20
|
+
*/
|
|
21
|
+
private async fetchToken() {
|
|
22
|
+
const ctx = await request.newContext();
|
|
23
|
+
const res = await ctx.post(this.tokenUrl, {
|
|
24
|
+
form: {
|
|
25
|
+
grant_type: 'client_credentials',
|
|
26
|
+
client_id: this.clientId,
|
|
27
|
+
client_secret: this.clientSecret
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
const body = await res.json();
|
|
31
|
+
this.token = body.access_token;
|
|
32
|
+
this.expiry = Date.now() + body.expires_in * 1000;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Applies the OAuth2 Bearer token to the request context headers.
|
|
37
|
+
*/
|
|
38
|
+
async apply(context: any) {
|
|
39
|
+
if (!this.token || Date.now() > this.expiry) {
|
|
40
|
+
await this.fetchToken();
|
|
41
|
+
}
|
|
42
|
+
await context.setExtraHTTPHeaders({
|
|
43
|
+
Authorization: `Bearer ${this.token}`
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Interface representing the framework configuration structure.
|
|
6
|
+
*/
|
|
7
|
+
export interface FrameworkConfig {
|
|
8
|
+
/** Global default for AI self-healing. */
|
|
9
|
+
healingEnabled: boolean;
|
|
10
|
+
/** Environment name (dev, staging, prod). */
|
|
11
|
+
environment?: string;
|
|
12
|
+
/** Base URL for the application. */
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
/** API base URL. */
|
|
15
|
+
apiUrl?: string;
|
|
16
|
+
/** Default timeout in milliseconds. */
|
|
17
|
+
timeout?: number;
|
|
18
|
+
/** Number of retries for failed tests. */
|
|
19
|
+
retries?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Singleton manager for framework-level configuration.
|
|
24
|
+
* Reads settings from framework.config.json at the project root.
|
|
25
|
+
*/
|
|
26
|
+
export class ConfigManager {
|
|
27
|
+
private static config: FrameworkConfig | null = null;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Retrieves the current configuration. Loads it from disk if not already cached.
|
|
31
|
+
*/
|
|
32
|
+
public static getConfig(): FrameworkConfig {
|
|
33
|
+
if (!this.config) {
|
|
34
|
+
this.loadConfig();
|
|
35
|
+
}
|
|
36
|
+
return this.config!;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Loads the configuration from the framework.config.json file.
|
|
41
|
+
* Supports environment-specific configs via ENV variable: framework.config.{env}.json
|
|
42
|
+
* Fallbacks to defaults if the file is missing or invalid.
|
|
43
|
+
*/
|
|
44
|
+
private static loadConfig(): void {
|
|
45
|
+
const env = process.env.TEST_ENV || process.env.NODE_ENV || 'dev';
|
|
46
|
+
const envConfigPath = path.resolve(process.cwd(), `framework.config.${env}.json`);
|
|
47
|
+
const defaultConfigPath = path.resolve(process.cwd(), 'framework.config.json');
|
|
48
|
+
const defaultConfig: FrameworkConfig = { healingEnabled: true };
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
let configPath = defaultConfigPath;
|
|
52
|
+
|
|
53
|
+
// Prefer environment-specific config if exists
|
|
54
|
+
if (fs.existsSync(envConfigPath)) {
|
|
55
|
+
configPath = envConfigPath;
|
|
56
|
+
console.log(`✅ Loaded config for environment: ${env}`);
|
|
57
|
+
} else if (fs.existsSync(defaultConfigPath)) {
|
|
58
|
+
console.log(`✅ Loaded default config (no ${env}-specific config found)`);
|
|
59
|
+
} else {
|
|
60
|
+
console.warn(`⚠️ No configuration file found. Using defaults.`);
|
|
61
|
+
this.config = defaultConfig;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const fileContent = fs.readFileSync(configPath, 'utf8');
|
|
66
|
+
this.config = { ...defaultConfig, ...JSON.parse(fileContent) };
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.error(`❌ Failed to load framework configuration: ${error}`);
|
|
69
|
+
this.config = defaultConfig;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Handles localization and internationalization by reading translation files.
|
|
7
|
+
*/
|
|
8
|
+
export class Localization {
|
|
9
|
+
private static cache: Record<string, any> = {};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Translates a key using the loaded messages for the given locale.
|
|
13
|
+
* @param key The translation key.
|
|
14
|
+
* @param locale The locale to use (default 'en').
|
|
15
|
+
* @returns The translated message or the key if not found.
|
|
16
|
+
*/
|
|
17
|
+
static get(key: string, locale = 'en'): string {
|
|
18
|
+
const data = this.load(locale);
|
|
19
|
+
return data[key] ?? key;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Internal method to load a translation JSON file.
|
|
24
|
+
* @param locale The locale to load.
|
|
25
|
+
* @returns The parsed translation data.
|
|
26
|
+
*/
|
|
27
|
+
private static load(locale: string) {
|
|
28
|
+
if (!this.cache[locale]) {
|
|
29
|
+
const file = path.join(__dirname, `${locale}.json`);
|
|
30
|
+
this.cache[locale] = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
31
|
+
}
|
|
32
|
+
return this.cache[locale];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Page, Locator } from '@playwright/test';
|
|
2
|
+
import { ActionOrchestrator } from '../selfhealing/ActionOrchestrator';
|
|
3
|
+
import { ElementWrapper } from '../wrappers/ElementWrapper';
|
|
4
|
+
import { ConfigManager } from '../config/ConfigManager';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Base class for all Page Objects.
|
|
8
|
+
* Provides the 'element()' factory for creating self-healing elements.
|
|
9
|
+
*/
|
|
10
|
+
export abstract class BasePage {
|
|
11
|
+
protected orchestrator: ActionOrchestrator;
|
|
12
|
+
protected pageName: string;
|
|
13
|
+
|
|
14
|
+
constructor(protected readonly page: Page, options: { pageName?: string; orchestrator?: ActionOrchestrator } = {}) {
|
|
15
|
+
this.pageName = options.pageName || 'UnknownPage';
|
|
16
|
+
|
|
17
|
+
// Support Dependency Injection via constructor (for Fixtures)
|
|
18
|
+
// Fallback to internal creation (for Manual usage)
|
|
19
|
+
this.orchestrator = options.orchestrator || new ActionOrchestrator(page);
|
|
20
|
+
|
|
21
|
+
// Ensure the orchestrator knows the current page context
|
|
22
|
+
this.orchestrator.setPageContext(this.pageName);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Factory method to create a resilient ElementWrapper.
|
|
27
|
+
* Elements created via this method are automatically self-healing by default (controlled via framework.config.json).
|
|
28
|
+
*/
|
|
29
|
+
protected element(selector: string | Locator, healingEnabled: boolean = ConfigManager.getConfig().healingEnabled): ElementWrapper {
|
|
30
|
+
const loc = typeof selector === 'string' ? this.page.locator(selector) : selector;
|
|
31
|
+
return new ElementWrapper(loc, this.orchestrator, healingEnabled);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Navigates to a URL.
|
|
36
|
+
*/
|
|
37
|
+
async navigate(url: string) {
|
|
38
|
+
await this.page.goto(url);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Returns current page title.
|
|
43
|
+
*/
|
|
44
|
+
async title(): Promise<string> {
|
|
45
|
+
return await this.page.title();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
|
|
2
|
+
import { allure } from 'allure-playwright';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Utility for Allure reporting integration.
|
|
6
|
+
* Provides methods for attaching data and wrapping steps.
|
|
7
|
+
*/
|
|
8
|
+
export class AllureUtil {
|
|
9
|
+
/**
|
|
10
|
+
* Attaches JSON data to the Allure report.
|
|
11
|
+
*/
|
|
12
|
+
static attachJson(name: string, value: any) {
|
|
13
|
+
void allure.attachment(name, JSON.stringify(value, null, 2), 'application/json');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Attaches plain text to the Allure report.
|
|
17
|
+
*/
|
|
18
|
+
static attachText(name: string, value: string) {
|
|
19
|
+
void allure.attachment(name, value, 'text/plain');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Attaches image buffer to the Allure report.
|
|
23
|
+
*/
|
|
24
|
+
static attachImage(name: string, buffer: Buffer) {
|
|
25
|
+
void allure.attachment(name, buffer, 'image/png');
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Wraps a function in an Allure step.
|
|
29
|
+
* @param name Name of the step.
|
|
30
|
+
* @param fn Function to execute.
|
|
31
|
+
*/
|
|
32
|
+
static step<T>(name: string, fn: () => Promise<T>): Promise<T> {
|
|
33
|
+
return allure.step(name, fn as () => Promise<void>) as unknown as Promise<T>;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Page, Locator } from '@playwright/test';
|
|
2
|
+
import { LocatorHealing } from './LocatorHealing';
|
|
3
|
+
import { ElementProfileStore } from './ElementProfileStore';
|
|
4
|
+
import { ErrorUtils } from '../utils/ErrorUtils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Orchestrates UI actions with advanced AI-powered self-healing.
|
|
8
|
+
* Centralizing this logic ensures SRP and eliminates code duplication.
|
|
9
|
+
*/
|
|
10
|
+
export class ActionOrchestrator {
|
|
11
|
+
private healing: LocatorHealing;
|
|
12
|
+
private profileStore: ElementProfileStore;
|
|
13
|
+
private pageName: string;
|
|
14
|
+
|
|
15
|
+
constructor(
|
|
16
|
+
private page: Page,
|
|
17
|
+
options: {
|
|
18
|
+
pageName?: string;
|
|
19
|
+
healing?: LocatorHealing;
|
|
20
|
+
profileStore?: ElementProfileStore;
|
|
21
|
+
} = {}
|
|
22
|
+
) {
|
|
23
|
+
this.pageName = options.pageName || 'DefaultPage';
|
|
24
|
+
this.healing = options.healing || new LocatorHealing(page);
|
|
25
|
+
this.profileStore = options.profileStore || new ElementProfileStore();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Updates the contextual page name for logging and AI profiling.
|
|
30
|
+
*/
|
|
31
|
+
setPageContext(pageName: string): void {
|
|
32
|
+
this.pageName = pageName;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Core orchestration logic for self-healing actions.
|
|
37
|
+
* Exposed for consumption by ElementWrapper.
|
|
38
|
+
*/
|
|
39
|
+
async runWithHealing(
|
|
40
|
+
locator: Locator,
|
|
41
|
+
intent: string,
|
|
42
|
+
action: (loc: Locator) => Promise<any>,
|
|
43
|
+
healingEnabled: boolean = true
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const originalSelector = locator.toString();
|
|
46
|
+
let originalError: Error | null = null;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await action(locator);
|
|
50
|
+
// ✅ Success: Capture element profile for future healing
|
|
51
|
+
await this.profileStore.captureProfile(locator, this.pageName, intent);
|
|
52
|
+
return;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
originalError = ErrorUtils.toError(error);
|
|
55
|
+
const message = ErrorUtils.getErrorMessage(error);
|
|
56
|
+
console.log(`❌ Action failed (${intent}): ${message}`);
|
|
57
|
+
|
|
58
|
+
if (!healingEnabled) {
|
|
59
|
+
console.log(`🚫 Self-healing is disabled for this action. Throwing original error.`);
|
|
60
|
+
throw originalError;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Step 1: Stability Wait
|
|
65
|
+
if ((await locator.count()) === 0) {
|
|
66
|
+
console.log('⏳ Waiting for element to appear...');
|
|
67
|
+
try {
|
|
68
|
+
await locator.waitFor({ state: 'attached', timeout: 3000 });
|
|
69
|
+
await action(locator);
|
|
70
|
+
console.log('✅ Element recovered via waiting');
|
|
71
|
+
return;
|
|
72
|
+
} catch { /* proceed to healing */ }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Step 2: Check failure history
|
|
76
|
+
if (this.profileStore.hasHealingFailed(this.pageName, intent)) {
|
|
77
|
+
console.log('❌ Healing previously failed for this element. Skipping AI.');
|
|
78
|
+
throw originalError;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Step 3: Cached Healed Selector
|
|
82
|
+
const cached = this.profileStore.getHealedSelector(this.pageName, intent);
|
|
83
|
+
if (cached) {
|
|
84
|
+
console.log(`💾 Trying cached healed selector: ${cached}`);
|
|
85
|
+
try {
|
|
86
|
+
const healedLoc = this.healing.evaluateLocatorPublic(cached);
|
|
87
|
+
if ((await healedLoc.count()) === 1) {
|
|
88
|
+
await action(healedLoc);
|
|
89
|
+
console.log('✅ Recovered via cached selector');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
} catch { /* proceed to AI */ }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Step 4: AI Healing
|
|
96
|
+
const profile = this.profileStore.getProfile(this.pageName, intent);
|
|
97
|
+
const healedSelector = await this.healing.healWithAI(
|
|
98
|
+
originalSelector,
|
|
99
|
+
intent,
|
|
100
|
+
originalError,
|
|
101
|
+
profile
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
if (healedSelector) {
|
|
105
|
+
const healedLoc = this.healing.evaluateLocatorPublic(healedSelector);
|
|
106
|
+
await action(healedLoc);
|
|
107
|
+
|
|
108
|
+
this.profileStore.updateHealedSelector(this.pageName, intent, healedSelector);
|
|
109
|
+
await this.profileStore.captureProfile(healedLoc, this.pageName, intent);
|
|
110
|
+
console.log('✅ Recovered via AI Healing');
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
this.profileStore.markHealingFailed(this.pageName, intent, this.healing.lastFailureType || 'UNKNOWN');
|
|
115
|
+
throw originalError;
|
|
116
|
+
}
|
|
117
|
+
}
|