@skyramp/skyramp 2025.8.11 → 2025.12.19
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/package.json +3 -2
- package/scripts/download-binary.js +0 -5
- package/src/classes/MockV2.d.ts +93 -0
- package/src/classes/MockV2.js +62 -0
- package/src/classes/ResponseV2.d.ts +2 -1
- package/src/classes/ResponseV2.js +5 -1
- package/src/classes/SkyrampClient.d.ts +123 -36
- package/src/classes/SkyrampClient.js +93 -4
- package/src/classes/SmartPlaywright.d.ts +5 -0
- package/src/classes/SmartPlaywright.js +992 -0
- package/src/function.d.ts +20 -0
- package/src/index.d.ts +21 -19
- package/src/index.js +7 -1
- package/src/utils.d.ts +82 -0
- package/src/utils.js +27 -0
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
const { expect: playwrightExpect } = require('@playwright/test');
|
|
2
|
+
const lib = require('../lib');
|
|
3
|
+
const koffi = require('koffi');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const responseType = koffi.struct({
|
|
8
|
+
response: 'char*',
|
|
9
|
+
error: 'char*',
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const { checkForUpdate } = require('../utils');
|
|
13
|
+
const improvePlaywrightSelectorWrapper = lib.func('improvePlywrightSelectorWrapper', responseType, ['string']);
|
|
14
|
+
const hasJavascriptWrapper = lib.func('hasJavascript', 'bool', ['string']);
|
|
15
|
+
|
|
16
|
+
const defaultWaitForTimeout = 1500
|
|
17
|
+
|
|
18
|
+
async function improvePlaywrightSelectorWithLlm(originalSelector, errorMessage, domContext, pageTitle, pageURL) {
|
|
19
|
+
const requestData = {
|
|
20
|
+
"original_selector": originalSelector,
|
|
21
|
+
"error_message": errorMessage,
|
|
22
|
+
"dom_context": domContext,
|
|
23
|
+
"page_title": pageTitle,
|
|
24
|
+
"page_url": pageURL,
|
|
25
|
+
"language": "javascript"
|
|
26
|
+
};
|
|
27
|
+
const request = JSON.stringify(requestData);
|
|
28
|
+
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
improvePlaywrightSelectorWrapper.async(request, (err, res) => {
|
|
31
|
+
if (err) {
|
|
32
|
+
reject(err);
|
|
33
|
+
} else if (res) {
|
|
34
|
+
if (res.error != null) {
|
|
35
|
+
reject(res.error);
|
|
36
|
+
} else if (res.response != null) {
|
|
37
|
+
const response = JSON.parse(res.response);
|
|
38
|
+
resolve(response["suggestions"] ?? null);
|
|
39
|
+
} else {
|
|
40
|
+
reject(new Error('failed'));
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
reject(new Error('failed'));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function debug(...args) {
|
|
50
|
+
if (process.env.SKYRAMP_DEBUG == "true") {
|
|
51
|
+
console.log(...args);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Detect if we're running inside a Docker container.
|
|
57
|
+
*
|
|
58
|
+
* Checks the SKYRAMP_IN_DOCKER environment variable
|
|
59
|
+
*
|
|
60
|
+
* @returns {boolean} True if running in Docker, false otherwise
|
|
61
|
+
*/
|
|
62
|
+
function isRunningInDocker() {
|
|
63
|
+
const envValue = (process.env.SKYRAMP_IN_DOCKER || '').toLowerCase();
|
|
64
|
+
return envValue === 'true' || envValue === '1' || envValue === 'yes';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Transform localhost URLs to host.docker.internal when running in Docker.
|
|
69
|
+
*
|
|
70
|
+
* This helper function automatically detects if we're running inside a Docker
|
|
71
|
+
* container and transforms localhost URLs to use host.docker.internal, which
|
|
72
|
+
* allows containers to access services running on the host machine.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} url - The URL to potentially transform
|
|
75
|
+
* @returns {string} The transformed URL if in Docker and URL contains localhost, otherwise the original URL
|
|
76
|
+
*/
|
|
77
|
+
function transformUrlForDocker(url) {
|
|
78
|
+
if (!url) {
|
|
79
|
+
return url;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Auto-detect if we're running in Docker
|
|
83
|
+
if (!isRunningInDocker()) {
|
|
84
|
+
return url;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Use proper URL parsing to only replace the hostname component
|
|
88
|
+
try {
|
|
89
|
+
const urlObj = new URL(url);
|
|
90
|
+
const hostname = urlObj.hostname;
|
|
91
|
+
|
|
92
|
+
// Only transform if hostname is exactly 'localhost' or '127.0.0.1'
|
|
93
|
+
if (hostname === 'localhost' || hostname === '127.0.0.1') {
|
|
94
|
+
urlObj.hostname = 'host.docker.internal';
|
|
95
|
+
const transformed = urlObj.toString();
|
|
96
|
+
debug(`Transformed URL: ${url} -> ${transformed}`);
|
|
97
|
+
return transformed;
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
debug(`Failed to parse URL: ${url}, returning original`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return url;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function shouldAttemptImprovement(errorMessage, errorName) {
|
|
107
|
+
const improvementKeywords = [
|
|
108
|
+
"timeout",
|
|
109
|
+
"not found",
|
|
110
|
+
"no element",
|
|
111
|
+
"not visible",
|
|
112
|
+
"not attached",
|
|
113
|
+
"selector resolved to hidden",
|
|
114
|
+
"element is not enabled",
|
|
115
|
+
"strict mode violation"
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
if (errorName != "") {
|
|
119
|
+
const playwrightErrorTypes = [
|
|
120
|
+
"TimeoutError",
|
|
121
|
+
"Error",
|
|
122
|
+
"LocatorAssertionError"
|
|
123
|
+
];
|
|
124
|
+
if (playwrightErrorTypes.some(e => e == errorName)) {
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let errorLower = errorMessage.toLowerCase();
|
|
130
|
+
return improvementKeywords.some(keyword => errorLower.includes(keyword));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// for debugging only
|
|
134
|
+
/*
|
|
135
|
+
const sampleSuggestions = [
|
|
136
|
+
{
|
|
137
|
+
selector: "page.getByText('K', { exact: true})",
|
|
138
|
+
confidence: 0.9,
|
|
139
|
+
reasoning: "The 'L' appears to be within a user profile button that shows 'K' as the user initial, using semantic role for better reliability"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
selector: "page.locator('#onyx-user-dropdown')",
|
|
143
|
+
confidence: 0.85,
|
|
144
|
+
reasoning: 'Targets the specific user dropdown container with unique ID that contains the user initial'
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
selector: "page.locator('.bg-background-900').filter({ hasText: 'K' })",
|
|
148
|
+
confidence: 0.8,
|
|
149
|
+
reasoning: 'Targets the specific styled element containing the user initial with background class'
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
selector: "page.locator('div').filter({ hasText: /^K$/ }).nth(0)",
|
|
153
|
+
confidence: 0.75,
|
|
154
|
+
reasoning: "Uses regex to find exact 'K' text match and takes first occurrence"
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
selector: "page.getByText('kolja').locator('..').getByText('K', { exact: true })",
|
|
158
|
+
confidence: 0.7,
|
|
159
|
+
reasoning: "Navigates to parent of username text and finds the exact 'K' text within that context"
|
|
160
|
+
}
|
|
161
|
+
]
|
|
162
|
+
*/
|
|
163
|
+
|
|
164
|
+
function parseErrorStack(ori) {
|
|
165
|
+
const stackLines = ori.split('\n');
|
|
166
|
+
let prev = ""
|
|
167
|
+
for (let idx = stackLines.length - 1; idx >= 0 ; idx --) {
|
|
168
|
+
// this could be improved
|
|
169
|
+
if (stackLines[idx].includes("@skyramp/skyramp")) {
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
prev = stackLines[idx];
|
|
173
|
+
}
|
|
174
|
+
return prev
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function retryWithLLM(skyrampLocator, error) {
|
|
178
|
+
// when API_KEY is not given, we return right away
|
|
179
|
+
if (!process.env.API_KEY) {
|
|
180
|
+
throw error
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let errorMessage = error.toString();
|
|
184
|
+
let errorType = typeof error;
|
|
185
|
+
|
|
186
|
+
if (!(skyrampLocator instanceof SkyrampPlaywrightLocator)) {
|
|
187
|
+
throw error
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let locatorStr = skyrampLocator._locator.toString();
|
|
191
|
+
|
|
192
|
+
if (!shouldAttemptImprovement(errorMessage, errorType)) {
|
|
193
|
+
debug("cannot improve", error.name);
|
|
194
|
+
throw error
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let pageTitle = await skyrampLocator.page().title();
|
|
198
|
+
let pageUrl = skyrampLocator.page().url();
|
|
199
|
+
|
|
200
|
+
let newMsg = `${errorMessage} (while using selector: ${locatorStr})`;
|
|
201
|
+
|
|
202
|
+
let pageContent = await skyrampLocator.page().content();
|
|
203
|
+
|
|
204
|
+
// for debugging only
|
|
205
|
+
// let suggestions = sampleSuggestions;
|
|
206
|
+
|
|
207
|
+
var suggestions;
|
|
208
|
+
try {
|
|
209
|
+
suggestions = await improvePlaywrightSelectorWithLlm(
|
|
210
|
+
locatorStr,
|
|
211
|
+
newMsg,
|
|
212
|
+
pageContent,
|
|
213
|
+
pageTitle,
|
|
214
|
+
pageUrl
|
|
215
|
+
);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
debug("failed to consult with llm", error)
|
|
218
|
+
throw error
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (suggestions == null || suggestions.length == 0) {
|
|
222
|
+
debug("No LLM suggestions available, failing with original error");
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
suggestions.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0))
|
|
227
|
+
debug(suggestions)
|
|
228
|
+
|
|
229
|
+
for (let suggestion of suggestions) {
|
|
230
|
+
try {
|
|
231
|
+
const newLocator = skyrampLocator.createLocatorFromString(suggestion.selector);
|
|
232
|
+
if (newLocator == null) {
|
|
233
|
+
continue
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const locatorCount = await newLocator.count();
|
|
237
|
+
|
|
238
|
+
debug(`trying new Locator ${newLocator} instead of ${skyrampLocator._locator}, count = ${locatorCount}`);
|
|
239
|
+
|
|
240
|
+
if (locatorCount == 1) {
|
|
241
|
+
const func = newLocator[skyrampLocator.execFname];
|
|
242
|
+
return func.call(newLocator, skyrampLocator.execParam, skyrampLocator.execArgs).then(result => {
|
|
243
|
+
console.log(`✅ SUCCESS! Used selector: ${newLocator} instead of ${skyrampLocator._locator}`);
|
|
244
|
+
console.log(` ${parseErrorStack(error.stack)}`);
|
|
245
|
+
|
|
246
|
+
skyrampLocator.llmLocator = newLocator;
|
|
247
|
+
skyrampLocator.locatorCount = 1;
|
|
248
|
+
skyrampLocator._skyrampPage.addLLMChoices(skyrampLocator._locator, newLocator, error.stack);
|
|
249
|
+
return result;
|
|
250
|
+
}).catch(error => {
|
|
251
|
+
// if it fails, move to the next one
|
|
252
|
+
debug(`retrying with LLM failed at ${skyrampLocator._locator} replaced by {newLocator}`, error.name);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
continue
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
error.message = `Failed to find a good alternative for ${skyrampLocator._locator} with LLM.\n` +
|
|
261
|
+
`Please add "data-testid" attribute for a more stable locator\n` + error.message;
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
class SkyrampPlaywrightLocator {
|
|
266
|
+
constructor(skyrampPage, locator, prevLocator, args, options) {
|
|
267
|
+
this._skyrampPage = skyrampPage
|
|
268
|
+
this._locator = locator
|
|
269
|
+
this._previousLocator = prevLocator
|
|
270
|
+
this._args = args
|
|
271
|
+
this._options = options
|
|
272
|
+
return new Proxy(this, {
|
|
273
|
+
get(wrapper, prop, receiver) {
|
|
274
|
+
// First, check if the property exists on the wrapper.
|
|
275
|
+
if (Reflect.has(wrapper, prop)) {
|
|
276
|
+
return Reflect.get(wrapper, prop, receiver);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Otherwise, forward the call to the original object (`target`).
|
|
280
|
+
const value = Reflect.get(wrapper._locator, prop, wrapper._locator);
|
|
281
|
+
|
|
282
|
+
// If the property is a function, bind it to the original object.
|
|
283
|
+
// This ensures the correct `this` context is used when the method is called.
|
|
284
|
+
if (typeof value === 'function') {
|
|
285
|
+
return value.bind(wrapper._locator);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async execute() {
|
|
294
|
+
debug(` execute ${ this._locator}.${this.execFname} ${this.execParam ?? ''} ${this.execArgs ?? ''}`)
|
|
295
|
+
const func = this._locator[this.execFname];
|
|
296
|
+
if (this.execParam !== null && this.execParam !== undefined) {
|
|
297
|
+
return func.call(this._locator, this.execParam, ...this.execArgs);
|
|
298
|
+
} else {
|
|
299
|
+
return func.call(this._locator, ...this.execArgs);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
generateLLMErrors() {
|
|
304
|
+
let ret = "List of dynamic selectors that could be relevant to error:\n"
|
|
305
|
+
|
|
306
|
+
let choices = this._skyrampPage.getLLMChoices();
|
|
307
|
+
if (choices ==undefined || choices.length == 0) {
|
|
308
|
+
return ""
|
|
309
|
+
}
|
|
310
|
+
for (let i = 0 ; i < choices.length; i ++) {
|
|
311
|
+
const choice = choices[i];
|
|
312
|
+
ret += `${ i+1 }. original locator: ${choice.original}\n`
|
|
313
|
+
ret += ` selected locator: ${choice.new}\n`
|
|
314
|
+
ret += ` ${choice.at}\n`
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return ret;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
wrapError(msg, error) {
|
|
321
|
+
let newMsg = msg;
|
|
322
|
+
if (this._skyrampPage.hasLLMChoices()) {
|
|
323
|
+
newMsg += this.generateLLMErrors();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Ensure error is an Error object, not a string
|
|
327
|
+
if (typeof error === 'string') {
|
|
328
|
+
error = new Error(error);
|
|
329
|
+
} else if (!(error instanceof Error)) {
|
|
330
|
+
error = new Error(String(error));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
error.message = error.message + "\n" + newMsg
|
|
334
|
+
|
|
335
|
+
return error
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
hydrationErrorMsg = "Potentially a hydration issue. Please add enough waitForTimeout()"
|
|
339
|
+
|
|
340
|
+
newPrevHydrationErrorMsg() {
|
|
341
|
+
return `Cannot find locator ${this._locator} and likely a hydration issue on ${this._previousLocator._locator}.\n` +
|
|
342
|
+
`Please add enough waitForTimeout() on ${this._previoousLocator._locator}`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
newMultiLocatorErrorMsg() {
|
|
346
|
+
return `${this._locator} found ${this.locatorCount} locators. Please add "data-testid" attribute for a more stable locator`
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async _retryWithLLM(error, msg1) {
|
|
350
|
+
// if API_KEY is not defined, throw an error here without trying
|
|
351
|
+
if (!process.env.API_KEY) {
|
|
352
|
+
error.message = msg1 + error.message;
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
debug(` try to get suggessions from LLM for ${this._locator}`);
|
|
357
|
+
return retryWithLLM(this, error).then(result => {
|
|
358
|
+
this.LLMselector = true;
|
|
359
|
+
return result;
|
|
360
|
+
}).catch(newError => {
|
|
361
|
+
throw this.wrapError("", newError)
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async SmartRetryWithFallback(fname, param, ...args) {
|
|
366
|
+
this.execFname = fname;
|
|
367
|
+
this.execParam = param;
|
|
368
|
+
this.execArgs = args;
|
|
369
|
+
|
|
370
|
+
let locatorCount = await this._locator.count();
|
|
371
|
+
let currentUrl = this._skyrampPage._page.url();
|
|
372
|
+
debug(`handling ${ this._locator }.${ fname }, count = ${ locatorCount }, ${currentUrl}`);
|
|
373
|
+
this.locatorCount = locatorCount
|
|
374
|
+
|
|
375
|
+
//debug(`current url = ${currentUrl}`);
|
|
376
|
+
|
|
377
|
+
// if locator exists, DOM is available
|
|
378
|
+
if (locatorCount == 1) {
|
|
379
|
+
debug(` single locator for ${this._locator} identified, ${currentUrl}`);
|
|
380
|
+
// we try action, this will most likely succeed
|
|
381
|
+
// if it fails, there could be potentially a hydration issue we can retry after a little wait time
|
|
382
|
+
try {
|
|
383
|
+
return await this.execute().then(result => {
|
|
384
|
+
return this._skyrampPage.checkNavigation(currentUrl, result);
|
|
385
|
+
});
|
|
386
|
+
} catch (error) {
|
|
387
|
+
debug(` first attempt of ${this._locator} failed, ${error.name}`);
|
|
388
|
+
if (error.name == "TimeoutError") {
|
|
389
|
+
// this is likely a hydration issue
|
|
390
|
+
// we wait a bit and retry
|
|
391
|
+
debug(` locator ${this._locator} exists, but execution failed, wait a bit and try again`);
|
|
392
|
+
await this.wait(defaultWaitForTimeout);
|
|
393
|
+
|
|
394
|
+
// Is this really necessary?
|
|
395
|
+
await this.execute().then(result => {
|
|
396
|
+
return this._skyrampPage.checkNavigation(currentUrl, result);
|
|
397
|
+
}).catch(() => {
|
|
398
|
+
debug(` failed second time and execute previous locator ${this._previousLocator._locator} again`);
|
|
399
|
+
this._previousLocator.execute();
|
|
400
|
+
}).catch(() => {
|
|
401
|
+
debug(` failed to execute previous locator ${this._previousLocator._locator} again, continue`);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
return this.execute().catch(newError => {
|
|
405
|
+
debug(` third attempt on ${this._locator} failed ${newError.name}`);
|
|
406
|
+
if (newError.name == "TimeoutError") {
|
|
407
|
+
// this hadn't happened yet. we need to validate if this is indeed hydration case
|
|
408
|
+
return this._retryWithLLM(newError, this.hydrationErrorMsg)
|
|
409
|
+
}
|
|
410
|
+
if (newError.message.includes("strict mode violation")) {
|
|
411
|
+
return this._retryWithLLM(newError, this.newMultiLocatorErrorMsg());
|
|
412
|
+
}
|
|
413
|
+
throw error;
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (error.message.includes("strict mode violation")) {
|
|
417
|
+
return this._retryWithLLM(error, this.newMultiLocatorErrorMsg());
|
|
418
|
+
}
|
|
419
|
+
if (error.message.includes("Unknown key")) {
|
|
420
|
+
let msg = error.message.split("\n")[0];
|
|
421
|
+
console.log(`${msg}, continue without execution`);
|
|
422
|
+
return undefined
|
|
423
|
+
}
|
|
424
|
+
// we do not handle the rest of the error cases, but simply forward error
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
} else if (locatorCount > 0) {
|
|
428
|
+
debug(` multiple ${locatorCount} locators for ${this._locator} identified, ${currentUrl}`);
|
|
429
|
+
// wait a bit and retry before consulting with LLM
|
|
430
|
+
await this.wait(defaultWaitForTimeout);
|
|
431
|
+
|
|
432
|
+
locatorCount = await this._locator.count();
|
|
433
|
+
this.locatorCount = locatorCount
|
|
434
|
+
|
|
435
|
+
if (locatorCount > 5) {
|
|
436
|
+
// we fail even without trying
|
|
437
|
+
throw new Error(`${locatorCount} locators detected for ${this._locator}. Please add "data-testid" attribute for a more stable locator`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// this will likely fail, but we try to get error message generated by playwright
|
|
441
|
+
return this.execute().then(result => {
|
|
442
|
+
return this._skyrampPage.checkNavigation(currentUrl, result);
|
|
443
|
+
}).catch(error => {
|
|
444
|
+
return this._retryWithLLM(error, this.newMultiLocatorErrorMsg());
|
|
445
|
+
});
|
|
446
|
+
} else {
|
|
447
|
+
// if locator does not exist, we need to consider two cases
|
|
448
|
+
// one is if any actions that required hydration did not work
|
|
449
|
+
// second, if locator id is not correct
|
|
450
|
+
// check if last step has potential hydration
|
|
451
|
+
if (this._previousLocator && this._previousLocator.locatorCount == 0) {
|
|
452
|
+
debug(` previous action ${this._previousLocator._locator} is potentially associated with hydration, ${currentUrl}`);
|
|
453
|
+
// wait for a short time to finish hydration
|
|
454
|
+
await this.wait(defaultWaitForTimeout);
|
|
455
|
+
const previousCount = await this._previousLocator.count();
|
|
456
|
+
debug(` re-execute the previous one ${this._previousLocator._locator}, new locator count = ${previousCount}, ${currentUrl}`);
|
|
457
|
+
// re-execute the previous locator
|
|
458
|
+
await this._previousLocator.execute().catch(() => {
|
|
459
|
+
// log the failure but continues to the current one
|
|
460
|
+
debug(` failed to execute previous locator ${this._previousLocator._locator} again, continue`);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
// then execute the current one
|
|
465
|
+
return await this.execute().then(result => {
|
|
466
|
+
return this._skyrampPage.checkNavigation(currentUrl, result);
|
|
467
|
+
});
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (error.name == "TimeoutError") {
|
|
470
|
+
debug(` ${this._locator} failed at first try. attempting again with some timeout`);
|
|
471
|
+
// wait for some time and re execute
|
|
472
|
+
await this.wait(defaultWaitForTimeout);
|
|
473
|
+
return this.execute().then(result => {
|
|
474
|
+
return this._skyrampPage.checkNavigation(currentUrl, result);
|
|
475
|
+
}).catch(newError => {
|
|
476
|
+
return this._retryWithLLM(newError, this.newPrevHydrationErrorMsg());
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
if (error.message.includes("strict mode violation")) {
|
|
480
|
+
debug(` a rare case when multiple locators are detected on ${this._locator}`);
|
|
481
|
+
return this._retryWithLLM(error, this.newMultiLocatorErrorMsg());
|
|
482
|
+
}
|
|
483
|
+
throw error;
|
|
484
|
+
}
|
|
485
|
+
} else {
|
|
486
|
+
if (this._previousLocator && this._previousLocator.LLMselector) {
|
|
487
|
+
debug(` ${this._locator} locator count is zero, but previous locator was selected by LLM`);
|
|
488
|
+
} else {
|
|
489
|
+
debug(` ${this._locator} locator count is zero, but previous locator seems not related to hydration`);
|
|
490
|
+
}
|
|
491
|
+
// previous action may not be associated with hydration
|
|
492
|
+
// then we just try current locator. could be a locator with a wrong id
|
|
493
|
+
// wait for a short time just in case
|
|
494
|
+
await this.wait(defaultWaitForTimeout);
|
|
495
|
+
|
|
496
|
+
this.locatorCount = await this._locator.count();
|
|
497
|
+
debug(` after waiting locator ${this._locator} count = ${this.locatorCount}, ${currentUrl}`);
|
|
498
|
+
|
|
499
|
+
try {
|
|
500
|
+
return await this.execute().then(result => {
|
|
501
|
+
return this._skyrampPage.checkNavigation(currentUrl, result);
|
|
502
|
+
});
|
|
503
|
+
} catch (error) {
|
|
504
|
+
if (error.name == "TimeoutError") {
|
|
505
|
+
debug(`${this._locator} failed at first try. attempting again with some timeout`);
|
|
506
|
+
await this.wait(defaultWaitForTimeout);
|
|
507
|
+
return this.execute().then(result=> {
|
|
508
|
+
return this._skyrampPage.checkNavigation(currentUrl, result);
|
|
509
|
+
}).catch(newError => {
|
|
510
|
+
if (newError.name == "TimeoutError") {
|
|
511
|
+
return this._retryWithLLM(newError, this.hydrationErrorMsg);
|
|
512
|
+
}
|
|
513
|
+
if (newError.message.includes("strict mode violation")) {
|
|
514
|
+
return this._retryWithLLM(newError, this.newMultiLocatorErrorMsg());
|
|
515
|
+
}
|
|
516
|
+
throw newError;
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
// if multiple locator found
|
|
520
|
+
if (this.locatorCount > 1 || error.message.includes("strict mode violation")) {
|
|
521
|
+
return this._retryWithLLM(error, this.newMultiLocatorErrorMsg());
|
|
522
|
+
}
|
|
523
|
+
throw error;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
parseFunctionChain(chainStr) {
|
|
530
|
+
// Remove any object prefix like "page."
|
|
531
|
+
chainStr = chainStr.replace(/^[a-zA-Z_][a-zA-Z0-9_]*\./, '');
|
|
532
|
+
|
|
533
|
+
// Match function calls: funcName(args)
|
|
534
|
+
const regex = /([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([^)]*)\)/g;
|
|
535
|
+
let result = [];
|
|
536
|
+
let match;
|
|
537
|
+
while ((match = regex.exec(chainStr)) !== null) {
|
|
538
|
+
const funcName = match[1];
|
|
539
|
+
const argsStr = match[2].trim();
|
|
540
|
+
let args = [];
|
|
541
|
+
if (argsStr.length > 0) {
|
|
542
|
+
// Split arguments by comma, but keep objects and strings together
|
|
543
|
+
// This is a simple approach, for more complex cases use a parser
|
|
544
|
+
let argMatches = [];
|
|
545
|
+
let depth = 0, current = '', inString = false, stringChar = '';
|
|
546
|
+
for (let i = 0; i < argsStr.length; i++) {
|
|
547
|
+
const c = argsStr[i];
|
|
548
|
+
if ((c === "'" || c === '"') && !inString) {
|
|
549
|
+
inString = true;
|
|
550
|
+
stringChar = c;
|
|
551
|
+
current += c;
|
|
552
|
+
} else if (inString && c === stringChar) {
|
|
553
|
+
inString = false;
|
|
554
|
+
current += c;
|
|
555
|
+
} else if (!inString && (c === '{' || c === '[')) {
|
|
556
|
+
depth++;
|
|
557
|
+
current += c;
|
|
558
|
+
} else if (!inString && (c === '}' || c === ']')) {
|
|
559
|
+
depth--;
|
|
560
|
+
current += c;
|
|
561
|
+
} else if (!inString && c === ',' && depth === 0) {
|
|
562
|
+
argMatches.push(current.trim());
|
|
563
|
+
current = '';
|
|
564
|
+
} else {
|
|
565
|
+
current += c;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (current.trim().length > 0) {
|
|
569
|
+
argMatches.push(current.trim());
|
|
570
|
+
}
|
|
571
|
+
args = argMatches;
|
|
572
|
+
}
|
|
573
|
+
result.push({ function: funcName, arguments: args });
|
|
574
|
+
}
|
|
575
|
+
return result;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
createLocatorFromString(suggested) {
|
|
579
|
+
suggested = suggested.trim();
|
|
580
|
+
if (suggested.startsWith("page.")) {
|
|
581
|
+
suggested = suggested.substring(5);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
let chains = this.parseFunctionChain(suggested);
|
|
585
|
+
|
|
586
|
+
let cur = this.page();
|
|
587
|
+
// find locator per function chain
|
|
588
|
+
for (let i = 0; i < chains.length; i ++) {
|
|
589
|
+
const chain = chains[i];
|
|
590
|
+
let f = cur[chain.function];
|
|
591
|
+
let args = [];
|
|
592
|
+
for (const a of chain.arguments) {
|
|
593
|
+
let arg = Function('"use strict";return (' + a + ')')();
|
|
594
|
+
args.push(arg);
|
|
595
|
+
}
|
|
596
|
+
cur = f.call(cur, ...args);
|
|
597
|
+
}
|
|
598
|
+
return cur;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async click(...args) {
|
|
602
|
+
return this.SmartRetryWithFallback("click", null, ...args);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async fill(text, ...args) {
|
|
606
|
+
return this.SmartRetryWithFallback("fill", text, ...args);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
async type(text, ...args) {
|
|
610
|
+
return this.SmartRetryWithFallback("type", text, ...args);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async press(key, ...args) {
|
|
614
|
+
return this.SmartRetryWithFallback("press", key, ...args);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
async check(...args) {
|
|
618
|
+
return this.SmartRetryWithFallback("check", null, ...args);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async uncheck(...args) {
|
|
622
|
+
return this.SmartRetryWithFallback("uncheck", null, ...args);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async selectOption(value, ...args) {
|
|
626
|
+
return this.SmartRetryWithFallback("selectOption", value, ...args);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
async hover(...args) {
|
|
630
|
+
return this.SmartRetryWithFallback("hover", null, ...args);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async textContent(...args) {
|
|
634
|
+
return this.SmartRetryWithFallback("textContent", null, ...args);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async isVisible(...args) {
|
|
638
|
+
return this.SmartRetryWithFallback("isVisible", null, ...args);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
filter(options) {
|
|
642
|
+
const originalLocator = this._locator.filter(options);
|
|
643
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, null, options);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
locator(selector, options) {
|
|
647
|
+
const originalLocator = this._locator.locator(selector, options);
|
|
648
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, selector, options);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
getByRole(role, options) {
|
|
652
|
+
const originalLocator = this._locator.getByRole(role, options);
|
|
653
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, role, options);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
getByText(text, options) {
|
|
657
|
+
const originalLocator = this._locator.getByText(text, options);
|
|
658
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, text, options);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
getByLabel(label, options) {
|
|
662
|
+
const originalLocator = this._locator.getByLabel(label, options);
|
|
663
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, label, options);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
getByTestId(testId, options) {
|
|
667
|
+
const originalLocator = this._locator.getByTestId(testId);
|
|
668
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, testId, options);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
getByTitle(title, options) {
|
|
672
|
+
const originalLocator = this._locator.getByTitle(title, options);
|
|
673
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, title, options);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
getByPlaceholder(placeholder, options) {
|
|
677
|
+
const originalLocator = this._locator.getByPlaceholder(placeholder, options);
|
|
678
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, placeholder, options);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
getByAltText(alt, options) {
|
|
682
|
+
const originalLocator = this._locator.getByAltText(alt, options);
|
|
683
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, alt, options);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
unwrap() {
|
|
687
|
+
return this._locator
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
nth(index) {
|
|
691
|
+
// get nth element - return a new SkyrampPlaywrightLocator
|
|
692
|
+
let new_locator = this._locator.nth(index)
|
|
693
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(new_locator, index, null);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
first() {
|
|
697
|
+
// get first element - return a new SkyrampPlaywrightLocator
|
|
698
|
+
let new_locator = this._locator.first()
|
|
699
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(new_locator, null, null);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
last() {
|
|
703
|
+
// get last element - return a new SkyrampPlaywrightLocator
|
|
704
|
+
let new_locator = this._locator.last()
|
|
705
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(new_locator, null, null);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
page() {
|
|
709
|
+
return this._skyrampPage._page;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
async wait(t) {
|
|
713
|
+
debug(` wait for ${t}`);
|
|
714
|
+
return this._skyrampPage._page.waitForTimeout(t);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
class SkyrampPlaywrightPage {
|
|
719
|
+
constructor(page, testInfo) {
|
|
720
|
+
checkForUpdate("npm")
|
|
721
|
+
|
|
722
|
+
this._page = page;
|
|
723
|
+
this._testInfo = testInfo; // Store testInfo for screenshot auto-baseline
|
|
724
|
+
return new Proxy(this, {
|
|
725
|
+
// The `get` trap is the key to forwarding.
|
|
726
|
+
// This will foraward any methods not implemented in this struct
|
|
727
|
+
// to be handled by the original class (i.e., playwright page object)
|
|
728
|
+
get(wrapper, prop, receiver) {
|
|
729
|
+
// First, check if the property exists on the wrapper.
|
|
730
|
+
if (Reflect.has(wrapper, prop)) {
|
|
731
|
+
return Reflect.get(wrapper, prop, receiver);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Otherwise, forward the call to the original object (`target`).
|
|
735
|
+
const value = Reflect.get(wrapper._page, prop, wrapper._page);
|
|
736
|
+
|
|
737
|
+
// If the property is a function, bind it to the original object.
|
|
738
|
+
// This ensures the correct `this` context is used when the method is called.
|
|
739
|
+
if (typeof value === 'function') {
|
|
740
|
+
return value.bind(wrapper._page);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return value;
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
unwrap() {
|
|
749
|
+
return this._page;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
pushLocator(locator) {
|
|
753
|
+
if (this.locators == undefined ) {
|
|
754
|
+
this.locators = [];
|
|
755
|
+
}
|
|
756
|
+
this.locators.push(locator);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
getLastLocator() {
|
|
760
|
+
if (this.locators == undefined) {
|
|
761
|
+
return null
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if (this.locators.length == 0) {
|
|
765
|
+
return null
|
|
766
|
+
}
|
|
767
|
+
return this.locators.at(-1)
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
newSkyrampPlaywrightLocator(originalLocator, param, options) {
|
|
771
|
+
let prevLocator = this.getLastLocator();
|
|
772
|
+
/*
|
|
773
|
+
if (prevLocator != null) {
|
|
774
|
+
debug(`handling ${originalLocator} prev ${prevLocator._locator}`);
|
|
775
|
+
} else {
|
|
776
|
+
debug(`handling ${originalLocator}`);
|
|
777
|
+
}
|
|
778
|
+
*/
|
|
779
|
+
|
|
780
|
+
let newLocator = new SkyrampPlaywrightLocator(this, originalLocator, prevLocator, [param], options);
|
|
781
|
+
this.pushLocator(newLocator)
|
|
782
|
+
return newLocator
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
locator(selector, options) {
|
|
786
|
+
const originalLocator = this._page.locator(selector, options);
|
|
787
|
+
return this.newSkyrampPlaywrightLocator(originalLocator, selector, options);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
getByRole(role, options) {
|
|
791
|
+
const originalLocator = this._page.getByRole(role, options);
|
|
792
|
+
return this.newSkyrampPlaywrightLocator(originalLocator, role, options);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
getByText(text, options) {
|
|
796
|
+
const originalLocator = this._page.getByText(text, options);
|
|
797
|
+
return this.newSkyrampPlaywrightLocator(originalLocator, text, options);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
getByLabel(label, options) {
|
|
801
|
+
const originalLocator = this._page.getByLabel(label, options);
|
|
802
|
+
return this.newSkyrampPlaywrightLocator(originalLocator, label, options);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
getByTestId(testId, options) {
|
|
806
|
+
const originalLocator = this._page.getByTestId(testId);
|
|
807
|
+
return this.newSkyrampPlaywrightLocator(originalLocator, testId, options);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
getByTitle(title, options) {
|
|
811
|
+
const originalLocator = this._page.getByTitle(title, options);
|
|
812
|
+
return this.newSkyrampPlaywrightLocator(originalLocator, title, options);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
getByPlaceholder(placeholder, options) {
|
|
816
|
+
const originalLocator = this._page.getByPlaceholder(placeholder, options);
|
|
817
|
+
return this.newSkyrampPlaywrightLocator(originalLocator, placeholder, options);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
getByAltText(alt, options) {
|
|
821
|
+
const originalLocator = this._page.getByAltText(alt, options);
|
|
822
|
+
return this.newSkyrampPlaywrightLocator(originalLocator, alt, options);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
async goto(url, options) {
|
|
826
|
+
const transformedUrl = transformUrlForDocker(url);
|
|
827
|
+
const result = await this._page.goto(transformedUrl, options);
|
|
828
|
+
const content = await this._page.content();
|
|
829
|
+
if (hasJavascriptWrapper(content)) {
|
|
830
|
+
debug(`javascript download detected when visiting ${this._page.url()}`);
|
|
831
|
+
debug(` wait for sometime for potential hydration`);
|
|
832
|
+
await this._page.waitForTimeout(defaultWaitForTimeout);
|
|
833
|
+
} else {
|
|
834
|
+
debug(`javascript not detected when visiting ${this._page.url()}`);
|
|
835
|
+
}
|
|
836
|
+
return result;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
addLLMChoices(originalLocator, newLocator, stack) {
|
|
840
|
+
if (this.llmChoices == undefined) {
|
|
841
|
+
this.llmChoices = []
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
this.llmChoices.push({
|
|
845
|
+
"original": originalLocator.toString(),
|
|
846
|
+
"new": newLocator.toString(),
|
|
847
|
+
"at": parseErrorStack(stack),
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
getLLMChoices() {
|
|
852
|
+
return this.llmChoices
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
hasLLMChoices() {
|
|
856
|
+
if (this.llmChoices == undefined || this.llmChoices.length == 0) {
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
return true;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
async checkNavigation(original, result) {
|
|
863
|
+
const newURL = this._page.url()
|
|
864
|
+
if (newURL != original) {
|
|
865
|
+
debug(`page navigation to ${newURL} detected, wait a bit`);
|
|
866
|
+
await this._page.waitForTimeout(defaultWaitForTimeout);
|
|
867
|
+
}
|
|
868
|
+
return result;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function newSkyrampPlaywrightPage(page, testInfo) {
|
|
873
|
+
return new SkyrampPlaywrightPage(page, testInfo);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Wrapper class for Playwright's expect that provides auto-baseline generation for screenshots.
|
|
878
|
+
* Check if snapshot exists, create if missing, then compare. Enabling single-run screenshots
|
|
879
|
+
*/
|
|
880
|
+
class SkyrampPageAssertions {
|
|
881
|
+
constructor(playwrightExpectation, actualObject, testInfo, pageOrLocator) {
|
|
882
|
+
this._playwrightExpectation = playwrightExpectation;
|
|
883
|
+
this._actualObject = actualObject;
|
|
884
|
+
this._testInfo = testInfo;
|
|
885
|
+
this._pageOrLocator = pageOrLocator || actualObject;
|
|
886
|
+
this._autoBaseline = process.env.SKYRAMP_AUTO_BASELINE !== 'false';
|
|
887
|
+
|
|
888
|
+
// Proxy to forward all methods except toHaveScreenshot
|
|
889
|
+
return new Proxy(this, {
|
|
890
|
+
get(target, prop) {
|
|
891
|
+
if (prop in target) {
|
|
892
|
+
return target[prop];
|
|
893
|
+
}
|
|
894
|
+
const playwrightValue = target._playwrightExpectation[prop];
|
|
895
|
+
if (typeof playwrightValue === 'function') {
|
|
896
|
+
return playwrightValue.bind(target._playwrightExpectation);
|
|
897
|
+
}
|
|
898
|
+
return playwrightValue;
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Auto-generates baseline screenshots if missing
|
|
905
|
+
*/
|
|
906
|
+
async toHaveScreenshot(nameOrOptions, options) {
|
|
907
|
+
// wait for some time before taking snapshot
|
|
908
|
+
await this._pageOrLocator.waitForTimeout(2000);
|
|
909
|
+
|
|
910
|
+
// If auto-baseline disabled or no testInfo, use standard Playwright behavior
|
|
911
|
+
if (!this._autoBaseline || !this._testInfo) {
|
|
912
|
+
return await this._playwrightExpectation.toHaveScreenshot(nameOrOptions, options);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Only handle string names (auto-generated names not supported)
|
|
916
|
+
if (typeof nameOrOptions !== 'string') {
|
|
917
|
+
return await this._playwrightExpectation.toHaveScreenshot(nameOrOptions, options);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Use Playwright's official API to get snapshot path
|
|
921
|
+
// playwright.config.js
|
|
922
|
+
//export default {
|
|
923
|
+
// 1. Configure snapshot directory
|
|
924
|
+
// snapshotDir: './my-snapshots', // Default: next to test file, filename-snapshots
|
|
925
|
+
// 2. Configure snapshot path template
|
|
926
|
+
// snapshotPathTemplate: '{testDir}/__screenshots__/{testFilePath}/{arg}{ext}',
|
|
927
|
+
// 3. Configure base snapshot directory
|
|
928
|
+
// testDir: './tests',
|
|
929
|
+
//}
|
|
930
|
+
const snapshotPath = this._testInfo.snapshotPath(nameOrOptions);
|
|
931
|
+
|
|
932
|
+
if (!fs.existsSync(snapshotPath)) {
|
|
933
|
+
const snapshotDir = path.dirname(snapshotPath);
|
|
934
|
+
if (!fs.existsSync(snapshotDir)) {
|
|
935
|
+
fs.mkdirSync(snapshotDir, { recursive: true });
|
|
936
|
+
}
|
|
937
|
+
// Apply Playwright's toHaveScreenshot defaults for consistency
|
|
938
|
+
// User can override these defaults via options
|
|
939
|
+
await this._actualObject.screenshot({
|
|
940
|
+
animations: 'disabled',
|
|
941
|
+
caret: 'hide',
|
|
942
|
+
scale: 'css',
|
|
943
|
+
...options,
|
|
944
|
+
path: snapshotPath // Always use our computed path
|
|
945
|
+
});
|
|
946
|
+
debug(`Generated baseline: ${snapshotPath}`);
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
// Baseline exists (or just created): assert normally
|
|
950
|
+
return await this._playwrightExpectation.toHaveScreenshot(nameOrOptions, options);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Custom expect function with auto-baseline screenshot support.
|
|
956
|
+
* Automatically extracts testInfo from wrapped page.
|
|
957
|
+
*
|
|
958
|
+
* Usage:
|
|
959
|
+
* // testInfo is automatically extracted from the wrapped page
|
|
960
|
+
* await expect(page).toHaveScreenshot('page.png');
|
|
961
|
+
* await expect(page.locator('h1')).toHaveScreenshot('heading.png');
|
|
962
|
+
*/
|
|
963
|
+
function expect(obj, testInfo) {
|
|
964
|
+
// Unwrap smart locators and pages, extract testInfo if not provided
|
|
965
|
+
let actualObject = obj;
|
|
966
|
+
let extractedTestInfo = testInfo;
|
|
967
|
+
|
|
968
|
+
if (obj instanceof SkyrampPlaywrightLocator) {
|
|
969
|
+
actualObject = obj.unwrap();
|
|
970
|
+
// Get testInfo from the locator's parent page
|
|
971
|
+
if (!extractedTestInfo && obj._skyrampPage && obj._skyrampPage._testInfo) {
|
|
972
|
+
extractedTestInfo = obj._skyrampPage._testInfo;
|
|
973
|
+
}
|
|
974
|
+
const playwrightExpectation = playwrightExpect(actualObject);
|
|
975
|
+
return new SkyrampPageAssertions(playwrightExpectation, actualObject, extractedTestInfo, actualObject.page());
|
|
976
|
+
}
|
|
977
|
+
if (obj instanceof SkyrampPlaywrightPage) {
|
|
978
|
+
actualObject = obj.unwrap();
|
|
979
|
+
// Get testInfo from the wrapped page
|
|
980
|
+
if (!extractedTestInfo && obj._testInfo) {
|
|
981
|
+
extractedTestInfo = obj._testInfo;
|
|
982
|
+
}
|
|
983
|
+
const playwrightExpectation = playwrightExpect(actualObject);
|
|
984
|
+
return new SkyrampPageAssertions(playwrightExpectation, actualObject, extractedTestInfo);
|
|
985
|
+
}
|
|
986
|
+
return playwrightExpect(obj);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
module.exports = {
|
|
990
|
+
newSkyrampPlaywrightPage,
|
|
991
|
+
expect,
|
|
992
|
+
};
|