@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.
@@ -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
+ };