@skyramp/skyramp 1.2.20 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyramp/skyramp",
3
- "version": "1.2.20",
3
+ "version": "1.2.22",
4
4
  "description": "module for leveraging skyramp cli functionality",
5
5
  "scripts": {
6
6
  "lint": "eslint 'src/**/*.js' 'src/**/*.ts' --fix",
@@ -17,6 +17,7 @@
17
17
  "src/classes/*.ts"
18
18
  ],
19
19
  "main": "src/index.js",
20
+ "types": "src/index.d.ts",
20
21
  "author": "",
21
22
  "license": "MIT",
22
23
  "dependencies": {
@@ -30,4 +31,4 @@
30
31
  "@typescript-eslint/parser": "^6.14.0",
31
32
  "eslint": "^8.55.0"
32
33
  }
33
- }
34
+ }
@@ -1,16 +1,16 @@
1
- import { ResponseValue } from '..';
2
- import { Endpoint } from './Endpoint';
3
- import { Scenario } from './Scenario';
4
- import {TrafficConfig} from './TrafficConfig';
5
- import { Protocol } from './Protocol';
6
- import { AsyncScenario } from './AsyncScenario';
7
- import { LoadTestConfig } from './LoadTestConfig';
8
- import { AsyncTestStatus } from './AsyncTestStatus';
9
- import { MultipartParam } from './MultipartParam';
10
- import { MockV2 } from './MockV2';
1
+ import { ResponseValue } from "..";
2
+ import { Endpoint } from "./Endpoint";
3
+ import { Scenario } from "./Scenario";
4
+ import { TrafficConfig } from "./TrafficConfig";
5
+ import { Protocol } from "./Protocol";
6
+ import { AsyncScenario } from "./AsyncScenario";
7
+ import { LoadTestConfig } from "./LoadTestConfig";
8
+ import AsyncTestStatus = require("./AsyncTestStatus");
9
+ import { MultipartParam } from "./MultipartParam";
10
+ import { MockV2 } from "./MockV2";
11
11
 
12
12
  export enum Language {
13
- PYTHON = 'python'
13
+ PYTHON = "python",
14
14
  }
15
15
 
16
16
  interface testerStartV1Options {
@@ -21,8 +21,8 @@ interface testerStartV1Options {
21
21
  address?: string;
22
22
  scenario: Scenario | [Scenario];
23
23
  testName: string;
24
- globalHeaders?: {[headerName: string]: string};
25
- globalVars?: {[variableName: string]: string};
24
+ globalHeaders?: { [headerName: string]: string };
25
+ globalVars?: { [variableName: string]: string };
26
26
  generateTestReport?: boolean;
27
27
  isDockerenv?: boolean;
28
28
  }
@@ -40,12 +40,20 @@ interface SendRequestV2Options {
40
40
  path: string;
41
41
  method: string;
42
42
  body?: string;
43
- headers?: {[headerName: string]: string};
44
- cookies?: {[cookieName: string]: string};
45
- dataOverride?: {[dataName: string]: string | number | boolean | object | null};
46
- pathParams?: {[pathName: string]: string | number | boolean | object | null};
47
- queryParams?: {[queryName: string]: string | number | boolean | object | null};
48
- formParams?: {[formParamName: string]: string | number | boolean | object | null};
43
+ headers?: { [headerName: string]: string };
44
+ cookies?: { [cookieName: string]: string };
45
+ dataOverride?: {
46
+ [dataName: string]: string | number | boolean | object | null;
47
+ };
48
+ pathParams?: {
49
+ [pathName: string]: string | number | boolean | object | null;
50
+ };
51
+ queryParams?: {
52
+ [queryName: string]: string | number | boolean | object | null;
53
+ };
54
+ formParams?: {
55
+ [formParamName: string]: string | number | boolean | object | null;
56
+ };
49
57
  multipartParams?: Array<MultipartParam>;
50
58
  expectedCode?: string;
51
59
  description?: string;
@@ -136,36 +144,97 @@ interface SendScenarioOptions {
136
144
  }
137
145
 
138
146
  export declare class SkyrampClient {
139
- constructor(kubeconfigPath?: string, clusterName?: string, context?: string, userToken?: string);
147
+ constructor(
148
+ kubeconfigPath?: string,
149
+ clusterName?: string,
150
+ context?: string,
151
+ userToken?: string
152
+ );
140
153
  constructor(options: SkyrampClientOptions);
141
154
  login(): Promise<string>;
142
155
  logout(): Promise<string>;
143
156
  applyLocal(): Promise<void>;
144
- addKubeconfig(context: string, clusterName: string, kubeconfigPath: string): Promise<void>;
157
+ addKubeconfig(
158
+ context: string,
159
+ clusterName: string,
160
+ kubeconfigPath: string
161
+ ): Promise<void>;
145
162
  removeLocal(): Promise<void>;
146
163
  removeCluster(clusterName?: string): Promise<void>;
147
- mockerApply(namespace: string, kubePath: string, kubeContext: string, clusterName: string, address: string, endpoint: Endpoint): Promise<void>;
148
- deploySkyrampWorker(namespace?: string, workerImage?: string, localImage?: boolean, kubePath?: string, kubeContext?: string, clusterName?: string): Promise<void>
149
- deleteSkyrampWorker(namespace?: string, kubePath:string, kubeContext?: string, clusterName?: string): Promise<void>
150
- runDockerSkyrampWorker(workerImage?:string, workerTag?:string, hostPost?:int, targetNetworkName?:string): Promise<void>
151
- removeDockerSkyrampWorker() : Promise<void>
164
+ mockerApply(
165
+ namespace: string,
166
+ kubePath: string,
167
+ kubeContext: string,
168
+ clusterName: string,
169
+ address: string,
170
+ endpoint: Endpoint
171
+ ): Promise<void>;
172
+ deploySkyrampWorker(
173
+ namespace?: string,
174
+ workerImage?: string,
175
+ localImage?: boolean,
176
+ kubePath?: string,
177
+ kubeContext?: string,
178
+ clusterName?: string
179
+ ): Promise<void>;
180
+ deleteSkyrampWorker(
181
+ namespace?: string,
182
+ kubePath?: string,
183
+ kubeContext?: string,
184
+ clusterName?: string
185
+ ): Promise<void>;
186
+ runDockerSkyrampWorker(
187
+ workerImage?: string,
188
+ workerTag?: string,
189
+ hostPost?: number,
190
+ targetNetworkName?: string
191
+ ): Promise<void>;
192
+ removeDockerSkyrampWorker(): Promise<void>;
152
193
  mockerApplyV1(options: MockerApplyV1Options): Promise<void>;
153
- mockerApplyV1(namespace: string, kubePath: string, kubeContext: string, clusterName: string, address: string, response: ResponseValue | ResponseValue[], trafficConfig: TrafficConfig): Promise<void>;
154
- mockerApplyFromFile(namespace: string, kubePath: string, kubeContext: string, clusterName: string, address: string, filePath: string): Promise<void>;
155
- applyMockDescription(namespace: string, address: string, mockYamlContent: string): Promise<void>;
194
+ mockerApplyV1(
195
+ namespace: string,
196
+ kubePath: string,
197
+ kubeContext: string,
198
+ clusterName: string,
199
+ address: string,
200
+ response: ResponseValue | ResponseValue[],
201
+ trafficConfig: TrafficConfig
202
+ ): Promise<void>;
203
+ mockerApplyFromFile(
204
+ namespace: string,
205
+ kubePath: string,
206
+ kubeContext: string,
207
+ clusterName: string,
208
+ address: string,
209
+ filePath: string
210
+ ): Promise<void>;
211
+ applyMockDescription(
212
+ namespace: string,
213
+ address: string,
214
+ mockYamlContent: string
215
+ ): Promise<void>;
156
216
  applyMock(mock: MockV2 | MockV2[]): Promise<void>;
157
- testerStart(namespace: string, kubePath: string, kubeContext: string, clusterName: string, address: string, scenario: Scenario): Promise<void>;
158
- testerStartV1(namespace: string,
217
+ testerStart(
218
+ namespace: string,
219
+ kubePath: string,
220
+ kubeContext: string,
221
+ clusterName: string,
222
+ address: string,
223
+ scenario: Scenario
224
+ ): Promise<void>;
225
+ testerStartV1(
226
+ namespace: string,
159
227
  kubePath: string,
160
228
  kubeContext: string,
161
229
  clusterName: string,
162
230
  address: string,
163
231
  scenario: Scenario,
164
232
  testName: string,
165
- globalHeaders: {[headerName: string]: string},
233
+ globalHeaders: { [headerName: string]: string },
166
234
  generateTestReport: boolean,
167
- isDockerenv: boolean): Promise<void>;
168
-
235
+ isDockerenv: boolean
236
+ ): Promise<void>;
237
+
169
238
  testerStartV1(options: testerStartV1Options): Promise<void>;
170
239
 
171
240
  testerGenerate(
@@ -179,9 +248,10 @@ export declare class SkyrampClient {
179
248
  port: int,
180
249
  generateRobot: boolean,
181
250
  functionalScenario: boolean,
182
- negativeScenario: boolean): Promise<string[]>;
251
+ negativeScenario: boolean
252
+ ): Promise<string[]>;
183
253
 
184
- sendRequest(options: SendRequestV2Options): Promise<void>;
254
+ sendRequest(options: SendRequestV2Options): Promise<ResponseV2>;
185
255
  deployDashboard(network: string): Promise<void>;
186
256
 
187
257
  generateRestTest(options: GenerateRestTestOptions): Promise<string>;
@@ -193,5 +263,8 @@ export declare class SkyrampClient {
193
263
  * @param scenario The scenario object or array of scenarios to run
194
264
  * @param options Additional options for the load test
195
265
  */
196
- sendScenario(scenario: AsyncScenario | AsyncScenario[], options?: SendScenarioOptions): Promise<AsyncTestStatus>;
266
+ sendScenario(
267
+ scenario: AsyncScenario | AsyncScenario[],
268
+ options?: SendScenarioOptions
269
+ ): Promise<AsyncTestStatus>;
197
270
  }
@@ -1,2 +1,5 @@
1
1
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
2
2
  export function newSkyrampPlaywrightPage(page: any): any;
3
+
4
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
5
+ export function expect(obj: any): any;
@@ -1,4 +1,4 @@
1
- /*
1
+ const { expect: playwrightExpect } = require('@playwright/test');
2
2
  const lib = require('../lib');
3
3
  const koffi = require('koffi');
4
4
 
@@ -8,6 +8,9 @@ const responseType = koffi.struct({
8
8
  });
9
9
 
10
10
  const improvePlaywrightSelectorWrapper = lib.func('improvePlywrightSelectorWrapper', responseType, ['string']);
11
+ const hasJavascriptWrapper = lib.func('hasJavascript', 'bool', ['string']);
12
+
13
+ const defaultWaitForTimeout = 1500
11
14
 
12
15
  async function improvePlaywrightSelectorWithLlm(originalSelector, errorMessage, domContext, pageTitle, pageURL) {
13
16
  const requestData = {
@@ -15,7 +18,8 @@ async function improvePlaywrightSelectorWithLlm(originalSelector, errorMessage,
15
18
  "error_message": errorMessage,
16
19
  "dom_context": domContext,
17
20
  "page_title": pageTitle,
18
- "page_url": pageURL
21
+ "page_url": pageURL,
22
+ "language": "javascript"
19
23
  };
20
24
  const request = JSON.stringify(requestData);
21
25
 
@@ -38,7 +42,6 @@ async function improvePlaywrightSelectorWithLlm(originalSelector, errorMessage,
38
42
  });
39
43
  });
40
44
  }
41
- */
42
45
 
43
46
  function debug(...args) {
44
47
  if (process.env.SKYRAMP_DEBUG == "true") {
@@ -46,6 +49,165 @@ function debug(...args) {
46
49
  }
47
50
  }
48
51
 
52
+ function shouldAttemptImprovement(errorMessage, errorName) {
53
+ const improvementKeywords = [
54
+ "timeout",
55
+ "not found",
56
+ "no element",
57
+ "not visible",
58
+ "not attached",
59
+ "selector resolved to hidden",
60
+ "element is not enabled",
61
+ "strict mode violation"
62
+ ];
63
+
64
+ if (errorName != "") {
65
+ const playwrightErrorTypes = [
66
+ "TimeoutError",
67
+ "Error",
68
+ "LocatorAssertionError"
69
+ ];
70
+ if (playwrightErrorTypes.some(e => e == errorName)) {
71
+ return true;
72
+ }
73
+ }
74
+
75
+ let errorLower = errorMessage.toLowerCase();
76
+ return improvementKeywords.some(keyword => errorLower.includes(keyword));
77
+ }
78
+
79
+ // for debugging only
80
+ /*
81
+ const sampleSuggestions = [
82
+ {
83
+ selector: "page.getByText('K', { exact: true})",
84
+ confidence: 0.9,
85
+ reasoning: "The 'L' appears to be within a user profile button that shows 'K' as the user initial, using semantic role for better reliability"
86
+ },
87
+ {
88
+ selector: "page.locator('#onyx-user-dropdown')",
89
+ confidence: 0.85,
90
+ reasoning: 'Targets the specific user dropdown container with unique ID that contains the user initial'
91
+ },
92
+ {
93
+ selector: "page.locator('.bg-background-900').filter({ hasText: 'K' })",
94
+ confidence: 0.8,
95
+ reasoning: 'Targets the specific styled element containing the user initial with background class'
96
+ },
97
+ {
98
+ selector: "page.locator('div').filter({ hasText: /^K$/ }).nth(0)",
99
+ confidence: 0.75,
100
+ reasoning: "Uses regex to find exact 'K' text match and takes first occurrence"
101
+ },
102
+ {
103
+ selector: "page.getByText('kolja').locator('..').getByText('K', { exact: true })",
104
+ confidence: 0.7,
105
+ reasoning: "Navigates to parent of username text and finds the exact 'K' text within that context"
106
+ }
107
+ ]
108
+ */
109
+
110
+ function parseErrorStack(ori) {
111
+ const stackLines = ori.split('\n');
112
+ let prev = ""
113
+ for (let idx = stackLines.length - 1; idx >= 0 ; idx --) {
114
+ // this could be improved
115
+ if (stackLines[idx].includes("@skyramp/skyramp")) {
116
+ break;
117
+ }
118
+ prev = stackLines[idx];
119
+ }
120
+ return prev
121
+ }
122
+
123
+ async function retryWithLLM(skyrampLocator, error) {
124
+ // when API_KEY is not given, we return right away
125
+ if (!process.env.API_KEY) {
126
+ throw error
127
+ }
128
+
129
+ let errorMessage = error.toString();
130
+ let errorType = typeof error;
131
+
132
+ if (!(skyrampLocator instanceof SkyrampPlaywrightLocator)) {
133
+ throw error
134
+ }
135
+
136
+ let locatorStr = skyrampLocator._locator.toString();
137
+
138
+ if (!shouldAttemptImprovement(errorMessage, errorType)) {
139
+ debug("cannot improve", error.name);
140
+ throw error
141
+ }
142
+
143
+ let pageTitle = await skyrampLocator.page().title();
144
+ let pageUrl = skyrampLocator.page().url();
145
+
146
+ debug("retry with LLM", skyrampLocator._locator);
147
+
148
+ let newMsg = `${errorMessage} (while using selector: ${locatorStr})`;
149
+
150
+ let pageContent = await skyrampLocator.page().content();
151
+
152
+ // for debugging only
153
+ // let suggestions = sampleSuggestions;
154
+
155
+ var suggestions;
156
+ try {
157
+ suggestions = await improvePlaywrightSelectorWithLlm(
158
+ locatorStr,
159
+ newMsg,
160
+ pageContent,
161
+ pageTitle,
162
+ pageUrl
163
+ );
164
+ } catch (error) {
165
+ debug("failed to consult with llm", error)
166
+ throw error
167
+ }
168
+
169
+ if (suggestions == null || suggestions.length == 0) {
170
+ debug("No LLM suggestoins available, failing with original error");
171
+ throw error;
172
+ }
173
+
174
+ suggestions.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0))
175
+ debug(suggestions)
176
+
177
+ for (let suggestion of suggestions) {
178
+ debug(`try ${suggestion.selector} instead of ${skyrampLocator._locator}`);
179
+ try {
180
+ const newLocator = skyrampLocator.createLocatorFromString(suggestion.selector);
181
+ if (newLocator == null) {
182
+ continue
183
+ }
184
+
185
+ const locatorCount = await newLocator.count();
186
+
187
+ debug(` trying new Locator ${newLocator}, count = ${locatorCount}`);
188
+
189
+ if (locatorCount == 1) {
190
+ const func = newLocator[skyrampLocator.execFname];
191
+ return func.call(newLocator, skyrampLocator.execParam, skyrampLocator.execArgs).then(result => {
192
+ console.log(`✅ SUCCESS! Used selector: ${newLocator} instead of ${skyrampLocator._locator}`);
193
+ console.log(` ${parseErrorStack(error.stack)}`);
194
+
195
+ skyrampLocator.llmLocator = newLocator;
196
+ skyrampLocator.locatorCount = 1;
197
+ skyrampLocator._skyrampPage.addLLMChoices(skyrampLocator._locator, newLocator, error.stack);
198
+ return result;
199
+ }).catch(error => {
200
+ throw new Error(`retrying with LLM failed at ${skyrampLocator._locator} replacing ${newLocator}`, error);
201
+ });
202
+ }
203
+ } catch {
204
+ continue
205
+ }
206
+ }
207
+
208
+ throw new Error(`failed to find a working locator from LLM's suggestions`, error);
209
+ }
210
+
49
211
  class SkyrampPlaywrightLocator {
50
212
  constructor(skyrampPage, locator, prevLocator, args, options, hydration) {
51
213
  this._skyrampPage = skyrampPage
@@ -83,49 +245,29 @@ class SkyrampPlaywrightLocator {
83
245
  return this._previousLocator && this._previousLocator.isHydration()
84
246
  }
85
247
 
86
- shouldAttemptImprovement(errorMessage, errorType) {
87
- const improvementKeywords = [
88
- "timeout",
89
- "not found",
90
- "no element",
91
- "not visible",
92
- "not attached",
93
- "selector resolved to hidden",
94
- "element is not enabled",
95
- ];
96
-
97
- if (errorType != null) {
98
- const playwrightErrorTypes = [
99
- "TimeoutError",
100
- "Error",
101
- "LocatorAssertionError"
102
- ];
103
- if (playwrightErrorTypes.some(errType => errorType.name.endsWith(errType))) {
104
- return true;
105
- }
106
- }
107
-
108
- let errorLower = errorMessage.toLowerCase();
109
- return improvementKeywords.some(keyword => errorLower.includes(keyword));
110
- }
111
-
112
- isSelectorMethod(methodName) {
113
- // Check if a method uses selectors that can be improved.
114
- const selectorMethods = [
115
- 'click', 'fill', 'type', 'press', 'check', 'uncheck', 'select_option',
116
- 'hover', 'focus', 'blur', 'scroll_into_view_if_needed', 'screenshot',
117
- 'text_content', 'inner_text', 'inner_html', 'get_attribute', 'is_visible',
118
- 'is_enabled', 'is_checked', 'is_disabled', 'is_editable', 'is_hidden'
119
- ]
120
- return selectorMethods.includes(methodName)
121
- }
122
-
123
248
  async execute() {
124
- debug(`execute ${ this._locator} with ${this.execParam} ${this.execArgs}`)
249
+ debug(`execute ${ this._locator}.${this.execFname} ${this.execParam ?? ''} ${this.execArgs ?? ''}`)
125
250
  const func = this._locator[this.execFname];
126
251
  return func.call(this._locator, this.execParam, this.execArgs);
127
252
  }
128
253
 
254
+ generateLLMErrors() {
255
+ let ret = "List of dynamic selectors that could be relevant to error:\n"
256
+
257
+ let choices = this._skyrampPage.getLLMChoices();
258
+ if (choices ==undefined || choices.length == 0) {
259
+ return ""
260
+ }
261
+ for (let i = 0 ; i < choices.length; i ++) {
262
+ const choice = choices[i];
263
+ ret += `${ i+1 }. original locator: ${choice.original}\n`
264
+ ret += ` selected locator: ${choice.new}\n`
265
+ ret += ` ${choice.at}\n`
266
+ }
267
+
268
+ return ret;
269
+ }
270
+
129
271
  // this is the function that does smart selector retry
130
272
  async SmartRetryWithFallback(fname, param, ...args) {
131
273
  this.execFname = fname;
@@ -133,9 +275,12 @@ class SkyrampPlaywrightLocator {
133
275
  this.execArgs = args;
134
276
 
135
277
  let locatorCount = await this._locator.count();
136
- debug(`handling ${ fname } of ${ this._locator }, count = ${ locatorCount }`)
278
+ debug(`handling ${ this._locator }.${ fname }, count = ${ locatorCount }`)
137
279
  this.locatorCount = locatorCount
138
280
 
281
+ let currentUrl = this._skyrampPage._page.url();
282
+ debug(`current url = ${currentUrl}`);
283
+
139
284
  // if locator exists, DOM is available
140
285
  if (locatorCount == 1) {
141
286
  // we try action, this will most likely succeed
@@ -143,50 +288,219 @@ class SkyrampPlaywrightLocator {
143
288
  return this.execute()
144
289
  .catch(error => {
145
290
  if (error.name == "TimeoutError") {
146
- return this.execute()
147
- .catch(error => {
148
- throw new Error("Potential hydration. Please add enough waitForTimeout() or use hydration flag", error);
149
- });
291
+ debug(`locator ${this._locator} exists, but execution failed, wait a bit and retry`)
292
+ this.wait(defaultWaitForTimeout)
293
+
294
+ return this.execute().catch(newError => {
295
+ debug(`second attempt on ${this._locator} failed`)
296
+ debug(newError)
297
+ if (this._skyrampPage.hasLLMChoices()) {
298
+ newError.message += this.generateLLMErrors();
299
+ throw newError;
300
+ } else {
301
+ throw new Error("Potentially a hydration issue. Please add enough waitForTimeout()", error);
302
+ }
303
+ });
150
304
  }
151
305
  throw error;
152
306
  });
153
307
  } else if (locatorCount > 0) {
154
308
  // TODO
155
- debug("multiple locators identified")
156
- return this.execute();
309
+ debug(`multiple ${locatorCount} locators identified`)
310
+
311
+ if (locatorCount > 5) {
312
+ throw new Error(`${locatorCount} locators detected for ${this._locator}. Please add "data-testid" attribute for a more stable locator`);
313
+ }
314
+
315
+ return this.execute().then(result => {
316
+ return result;
317
+ }).catch(error => {
318
+ if (!process.env.API_KEY) {
319
+ throw new Error(`${locatorCount} locators detected for ${this._locator}. Please add "data-testid" attribute for a more stable locator`, error)
320
+ }
321
+
322
+ console.log(`${locatorCount} locators found for ${this._locator}`)
323
+ // this is the palce where we consult with LLM
324
+ return retryWithLLM(this, error).then(result => {
325
+ this.multiLocatorLLM = true;
326
+ return result;
327
+ }).catch(newError => {
328
+ if (this._skyrampPage.hasLLMChoices()) {
329
+ newError.message += this.generateLLMErrors();
330
+ throw newError;
331
+ }
332
+ throw new Error(`failed to find a good alternative for ${this._locator}. Please add "data-testid" attribute for a more stable locator`, newError);
333
+ });
334
+ });
157
335
  } else {
158
336
  // if locator does not exist, we need to consider two cases
159
337
  // one is if any actions that required hydration did not work
160
338
  // second, if locator id is not correct
161
- try {
162
- // check if last step has potential hydration
163
- if (this._previousLocator && (this.isPrevHydration() || this._previousLocator.locatorCount == 0)) {
164
- debug(`previous action ${this._previousLocator} is potentially associated with hydration`);
165
- // wait for a short time to finish hydration
166
- await this._skyrampPage._page.waitForTimeout(1500);
167
- // then re-execute the previous locator
168
- await this._previousLocator.execute();
339
+ // check if last step has potential hydration
340
+ if (this._previousLocator && this._previousLocator.locatorCount == 0) {
341
+ debug(`previous action ${this._previousLocator._locator} is potentially associated with hydration`);
342
+ // wait for a short time to finish hydration
343
+ await this.wait(defaultWaitForTimeout);
344
+
345
+ const previousCount = await this._previousLocator.count();
346
+ debug(` re-execute the previous one ${this._previousLocator._locator}, previous Locator count = ${previousCount}`);
347
+
348
+ // re-execute the previous locator
349
+ await this._previousLocator.execute().catch(() => {
350
+ // log the failure but continues to the current one
351
+ debug(`failed to execute previous locator ${this._previousLocator._locator} again`);
352
+ });
353
+
354
+ try {
169
355
  // then execute the current one
170
- return this.execute();
171
- } else {
172
- // previous action may not be associated with hydration
173
- // but we still try
174
- return this.execute();
356
+ return await this.execute();
357
+ } catch (error) {
358
+ if (error.name == "TimeoutError") {
359
+ debug(`${this._locator} failed at first try. attempting again with some timeout`);
360
+ // wait for some time and re execute
361
+ await this.wait(defaultWaitForTimeout);
362
+
363
+ return this.execute().catch(newError => {
364
+ if (!process.env.API_KEY) {
365
+ throw new Error(`Cannot find locator ${this._locator} and likely a hydration issue on ${this._previousLocator._locator}. Please add enough waitForTimeout()`, newError);
366
+ }
367
+ // retry with llm
368
+ return retryWithLLM(this, newError).then(result => {
369
+ this.singleLocatorLLM = true;
370
+ return result;
371
+ }).catch(newError2 => {
372
+ if (this._skyrampPage.hasLLMChoices()) {
373
+ newError2.message += this.generateLLMErrors();
374
+ throw newError2;
375
+ }
376
+ throw new Error(`Failed to find a good alternative of ${this._locator}. Please add "data-testid" attribute for a more stable locator`, newError2);
377
+ });
378
+ });
379
+ }
380
+ throw error;
175
381
  }
176
- } catch (error) {
177
- if (error.name == "TimeoutError") {
178
- return this.execute()
179
- .catch(error => {
180
- throw new Error("Potential hydration. Please add enough waitForTimeout() or use hydration flag", error);
181
- });
382
+ } else {
383
+ if (this._previousLocator && this._previousLocator.multiLocatorLLM) {
384
+ debug(`${this._locator} locator count is zero, but previous locator was selected by LLM from many candidates`);
385
+ } else if (this._previousLocator && this._previousLocator.singleLocatorLLM) {
386
+ debug(`${this._locator} locator count is zero, but previous locator was selected by LLM from zero candidate`);
182
387
  } else {
183
- // TODO
388
+ debug(`${this._locator} locator count is zero, but previous locator seems not related to hydration`);
389
+ }
390
+ // previous action may not be associated with hydration
391
+ // then we just try current locator. could be a locator with a wrong id
392
+ // wait for a short time just in case
393
+ await this.wait(defaultWaitForTimeout);
394
+
395
+ this.locatorCount = await this._locator.count();
396
+ debug(` after waiting locator count = ${this.locatorCount}, url = ${this._skyrampPage._page.url()}`);
397
+
398
+ try {
399
+ return await this.execute();
400
+ } catch (error) {
401
+ if (error.name == "TimeoutError") {
402
+ debug(`${this._locator} failed at first try. attempting again with some timeout`);
403
+ await this.wait(defaultWaitForTimeout);
404
+
405
+ return this.execute().catch(newError => {
406
+ if (newError.name == "TimeoutError") {
407
+ if (!process.env.API_KEY) {
408
+ throw new Error(`Cannot find locator ${this._locator} and most likely not a hydration issue. Please add "data-testid" attribute for a more stable locator`, newError);
409
+ }
410
+ // retry with llm
411
+ return retryWithLLM(this, newError).then(result => {
412
+ this.singleLocatorLLM = true;
413
+ return result;
414
+ }).catch(newError2 => {
415
+ if (this._skyrampPage.hasLLMChoices()) {
416
+ newError2.message += this.generateLLMErrors();
417
+ throw newError2;
418
+ }
419
+ throw new Error(`Failed to find a good alternative of ${this._locator}. Please add "data-testid" attribute for a more stable locator`, newError2);
420
+ });
421
+ } else {
422
+ throw newError;
423
+ }
424
+ });
425
+ }
184
426
  throw error;
185
427
  }
186
428
  }
187
429
  }
188
430
  }
189
431
 
432
+ parseFunctionChain(chainStr) {
433
+ // Remove any object prefix like "page."
434
+ chainStr = chainStr.replace(/^[a-zA-Z_][a-zA-Z0-9_]*\./, '');
435
+
436
+ // Match function calls: funcName(args)
437
+ const regex = /([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([^)]*)\)/g;
438
+ let result = [];
439
+ let match;
440
+ while ((match = regex.exec(chainStr)) !== null) {
441
+ const funcName = match[1];
442
+ const argsStr = match[2].trim();
443
+ let args = [];
444
+ if (argsStr.length > 0) {
445
+ // Split arguments by comma, but keep objects and strings together
446
+ // This is a simple approach, for more complex cases use a parser
447
+ let argMatches = [];
448
+ let depth = 0, current = '', inString = false, stringChar = '';
449
+ for (let i = 0; i < argsStr.length; i++) {
450
+ const c = argsStr[i];
451
+ if ((c === "'" || c === '"') && !inString) {
452
+ inString = true;
453
+ stringChar = c;
454
+ current += c;
455
+ } else if (inString && c === stringChar) {
456
+ inString = false;
457
+ current += c;
458
+ } else if (!inString && (c === '{' || c === '[')) {
459
+ depth++;
460
+ current += c;
461
+ } else if (!inString && (c === '}' || c === ']')) {
462
+ depth--;
463
+ current += c;
464
+ } else if (!inString && c === ',' && depth === 0) {
465
+ argMatches.push(current.trim());
466
+ current = '';
467
+ } else {
468
+ current += c;
469
+ }
470
+ }
471
+ if (current.trim().length > 0) {
472
+ argMatches.push(current.trim());
473
+ }
474
+ args = argMatches;
475
+ }
476
+ result.push({ function: funcName, arguments: args });
477
+ }
478
+ return result;
479
+ }
480
+
481
+ createLocatorFromString(suggested) {
482
+ suggested = suggested.trim();
483
+ if (suggested.startsWith("page.")) {
484
+ suggested = suggested.substring(5);
485
+ }
486
+
487
+ let chains = this.parseFunctionChain(suggested);
488
+
489
+ let cur = this.page();
490
+ // find locator per function chain
491
+ for (let i = 0; i < chains.length; i ++) {
492
+ const chain = chains[i];
493
+ let f = cur[chain.function];
494
+ let args = [];
495
+ for (const a of chain.arguments) {
496
+ let arg = Function('"use strict";return (' + a + ')')();
497
+ args.push(arg);
498
+ }
499
+ cur = f.call(cur, ...args);
500
+ }
501
+ return cur;
502
+ }
503
+
190
504
  async click(...args) {
191
505
  return this.SmartRetryWithFallback("click", null, ...args);
192
506
  }
@@ -227,6 +541,55 @@ class SkyrampPlaywrightLocator {
227
541
  return this.SmartRetryWithFallback("isVisible", null, ...args);
228
542
  }
229
543
 
544
+ filter(options) {
545
+ const originalLocator = this._locator.filter(options);
546
+ return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, null, options);
547
+ }
548
+
549
+ locator(selector, options) {
550
+ const originalLocator = this._locator.locator(selector, options);
551
+ return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, selector, options);
552
+ }
553
+
554
+ getByRole(role, options) {
555
+ const originalLocator = this._locator.getByRole(role, options);
556
+ return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, role, options);
557
+ }
558
+
559
+ getByText(text, options) {
560
+ const originalLocator = this._locator.getByText(text, options);
561
+ return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, text, options);
562
+ }
563
+
564
+ getByLabel(label, options) {
565
+ const originalLocator = this._locator.getByLabel(label, options);
566
+ return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, label, options);
567
+ }
568
+
569
+ getByTestId(testId, options) {
570
+ const originalLocator = this._locator.getByTestId(testId);
571
+ return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, testId, options);
572
+ }
573
+
574
+ getByTitle(title, options) {
575
+ const originalLocator = this._locator.getByTitle(title, options);
576
+ return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, title, options);
577
+ }
578
+
579
+ getByPlaceholder(placeholder, options) {
580
+ const originalLocator = this._locator.getByPlaceholder(placeholder, options);
581
+ return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, placeholder, options);
582
+ }
583
+
584
+ getByAltText(alt, options) {
585
+ const originalLocator = this._locator.getByAltText(alt, options);
586
+ return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, alt, options);
587
+ }
588
+
589
+ unwrap() {
590
+ return this._locator
591
+ }
592
+
230
593
  nth(index) {
231
594
  // get nth element - return a new SkyrampPlaywrightLocator
232
595
  let new_locator = this._locator.nth(index)
@@ -244,107 +607,173 @@ class SkyrampPlaywrightLocator {
244
607
  let new_locator = this._locator.last()
245
608
  return this._skyrampPage.newSkyrampPlaywrightLocator(new_locator, null, null);
246
609
  }
610
+
611
+ page() {
612
+ return this._skyrampPage._page;
613
+ }
614
+
615
+ async wait(t) {
616
+ debug(`wait for ${t}`);
617
+ return this._skyrampPage._page.waitForTimeout(t);
618
+ }
247
619
  }
248
620
 
249
621
  class SkyrampPlaywrightPage {
250
- constructor(page) {
251
- this._page = page;
252
- return new Proxy(this, {
253
- // The `get` trap is the key to forwarding.
254
- // This will foraward any methods not implemented in this struct
255
- // to be handled by the original class (i.e., playwright page object)
256
- get(wrapper, prop, receiver) {
257
- // First, check if the property exists on the wrapper.
258
- if (Reflect.has(wrapper, prop)) {
259
- return Reflect.get(wrapper, prop, receiver);
622
+ constructor(page) {
623
+ this._page = page;
624
+ this._curUrl = "";
625
+ return new Proxy(this, {
626
+ // The `get` trap is the key to forwarding.
627
+ // This will foraward any methods not implemented in this struct
628
+ // to be handled by the original class (i.e., playwright page object)
629
+ get(wrapper, prop, receiver) {
630
+ // First, check if the property exists on the wrapper.
631
+ if (Reflect.has(wrapper, prop)) {
632
+ return Reflect.get(wrapper, prop, receiver);
633
+ }
634
+
635
+ // Otherwise, forward the call to the original object (`target`).
636
+ const value = Reflect.get(wrapper._page, prop, wrapper._page);
637
+
638
+ // If the property is a function, bind it to the original object.
639
+ // This ensures the correct `this` context is used when the method is called.
640
+ if (typeof value === 'function') {
641
+ return value.bind(wrapper._page);
642
+ }
643
+
644
+ return value;
645
+ }
646
+ });
647
+ }
648
+
649
+ getCurUrl() {
650
+ return this._curUrl;
651
+ }
652
+
653
+ pushLocator(locator) {
654
+ if (this.locators == undefined ) {
655
+ this.locators = [];
260
656
  }
657
+ this.locators.push(locator);
658
+ }
261
659
 
262
- // Otherwise, forward the call to the original object (`target`).
263
- const value = Reflect.get(wrapper._page, prop, wrapper._page);
660
+ getLastLocator() {
661
+ if (this.locators == undefined) {
662
+ return null
663
+ }
264
664
 
265
- // If the property is a function, bind it to the original object.
266
- // This ensures the correct `this` context is used when the method is called.
267
- if (typeof value === 'function') {
268
- return value.bind(wrapper._page);
665
+ if (this.locators.length == 0) {
666
+ return null
269
667
  }
668
+ return this.locators.at(-1)
669
+ }
270
670
 
271
- return value;
272
- }
273
- });
274
- }
671
+ newSkyrampPlaywrightLocator(originalLocator, param, options) {
672
+ let prevLocator = this.getLastLocator();
673
+ const hydration = options && (options.hydration || false);
674
+ /*
675
+ if (prevLocator != null) {
676
+ debug(`handling ${originalLocator} prev ${prevLocator._locator}`);
677
+ } else {
678
+ debug(`handling ${originalLocator}`);
679
+ }
680
+ */
275
681
 
276
- pushLocator(locator) {
277
- if (this.locators == undefined ) {
278
- this.locators = [];
279
- }
280
- this.locators.push(locator);
281
- }
682
+ let newLocator = new SkyrampPlaywrightLocator(this, originalLocator, prevLocator, [param], options, hydration);
683
+ this.pushLocator(newLocator)
684
+ return newLocator
685
+ }
282
686
 
283
- getLastLocator() {
284
- if (this.locators == undefined) {
285
- return null
286
- }
687
+ locator(selector, options) {
688
+ const originalLocator = this._page.locator(selector, options);
689
+ return this.newSkyrampPlaywrightLocator(originalLocator, selector, options);
690
+ }
287
691
 
288
- if (this.locators.length == 0) {
289
- return null
290
- }
291
- return this.locators.at(-1)
292
- }
692
+ getByRole(role, options) {
693
+ const originalLocator = this._page.getByRole(role, options);
694
+ return this.newSkyrampPlaywrightLocator(originalLocator, role, options);
695
+ }
293
696
 
294
- newSkyrampPlaywrightLocator(originalLocator, param, options) {
295
- let prevLocator = this.getLastLocator()
296
- const hydration = options && (options.hydration || false)
297
- debug("intercepting", originalLocator)
298
- let newLocator = new SkyrampPlaywrightLocator(this, originalLocator, prevLocator, [param], options, hydration);
299
- this.pushLocator(newLocator)
300
- return newLocator
301
- }
697
+ getByText(text, options) {
698
+ const originalLocator = this._page.getByText(text, options);
699
+ return this.newSkyrampPlaywrightLocator(originalLocator, text, options);
700
+ }
302
701
 
303
- locator(selector, options) {
304
- const originalLocator = this._page.locator(selector, options);
305
- return this.newSkyrampPlaywrightLocator(originalLocator, selector, options)
306
- }
702
+ getByLabel(label, options) {
703
+ const originalLocator = this._page.getByLabel(label, options);
704
+ return this.newSkyrampPlaywrightLocator(originalLocator, label, options);
705
+ }
307
706
 
308
- getByRole(role, options) {
309
- const originalLocator = this._page.getByRole(role, options);
310
- return this.newSkyrampPlaywrightLocator(originalLocator, role, options)
311
- }
707
+ getByTestId(testId, options) {
708
+ const originalLocator = this._page.getByTestId(testId);
709
+ return this.newSkyrampPlaywrightLocator(originalLocator, testId, options);
710
+ }
312
711
 
313
- getByText(text, options) {
314
- const originalLocator = this._page.getByText(text, options);
315
- return this.newSkyrampPlaywrightLocator(originalLocator, text, options)
316
- }
712
+ getByTitle(title, options) {
713
+ const originalLocator = this._page.getByTitle(title, options);
714
+ return this.newSkyrampPlaywrightLocator(originalLocator, title, options);
715
+ }
317
716
 
318
- getByLabel(label, options) {
319
- const originalLocator = this._page.getByLabel(label, options);
320
- return this.newSkyrampPlaywrightLocator(originalLocator, label, options)
321
- }
717
+ getByPlaceholder(placeholder, options) {
718
+ const originalLocator = this._page.getByPlaceholder(placeholder, options);
719
+ return this.newSkyrampPlaywrightLocator(originalLocator, placeholder, options);
720
+ }
322
721
 
323
- getByTestId(testId, options) {
324
- const originalLocator = this._page.getByTestId(testId);
325
- return this.newSkyrampPlaywrightLocator(originalLocator, testId, options)
326
- }
722
+ getByAltText(alt, options) {
723
+ const originalLocator = this._page.getByAltText(alt, options);
724
+ return this.newSkyrampPlaywrightLocator(originalLocator, alt, options);
725
+ }
327
726
 
328
- getByTitle(title, options) {
329
- const originalLocator = this._page.getByTitle(title, options);
330
- return this.newSkyrampPlaywrightLocator(originalLocator, title, options)
331
- }
727
+ async goto(url, options) {
728
+ const result = await this._page.goto(url, options);
729
+ const content = await this._page.content();
730
+ if (hasJavascriptWrapper(content)) {
731
+ debug(`javascript download detected when visiting ${this._page.url()}`);
732
+ debug(` wait for sometime for potential hydration`);
733
+ await this._page.waitForTimeout(defaultWaitForTimeout);
734
+ } else {
735
+ debug(`javascript not detected when visiting ${this._page.url()}`);
736
+ }
737
+ this._curUrl = this._page.url();
738
+ return result;
739
+ }
332
740
 
333
- getByPlaceholder(placeholder, options) {
334
- const originalLocator = this._page.getByPlaceholder(placeholder, options);
335
- return this.newSkyrampPlaywrightLocator(originalLocator, placeholder, options)
336
- }
741
+ addLLMChoices(originalLocator, newLocator, stack) {
742
+ if (this.llmChoices == undefined) {
743
+ this.llmChoices = []
744
+ }
337
745
 
338
- getByAltText(alt, options) {
339
- const originalLocator = this._page.getByAltText(alt, options);
340
- return this.newSkyrampPlaywrightLocator(originalLocator, alt, options)
341
- }
746
+ this.llmChoices.push({
747
+ "original": originalLocator.toString(),
748
+ "new": newLocator.toString(),
749
+ "at": parseErrorStack(stack),
750
+ });
751
+ }
752
+
753
+ getLLMChoices() {
754
+ return this.llmChoices
755
+ }
756
+
757
+ hasLLMChoices() {
758
+ if (this.llmChoices == undefined || this.llmChoices.length == 0) {
759
+ return false;
760
+ }
761
+ return true;
762
+ }
342
763
  }
343
764
 
344
765
  function newSkyrampPlaywrightPage(page) {
345
766
  return new SkyrampPlaywrightPage(page);
346
767
  }
347
768
 
769
+ function expect(obj) {
770
+ if (obj instanceof SkyrampPlaywrightLocator) {
771
+ return playwrightExpect(obj.unwrap());
772
+ }
773
+ return playwrightExpect(obj);
774
+ }
775
+
348
776
  module.exports = {
349
777
  newSkyrampPlaywrightPage,
778
+ expect,
350
779
  };
@@ -0,0 +1,20 @@
1
+ import { ResponseV2 } from "./classes/ResponseV2";
2
+
3
+ /**
4
+ * Checks if the response's status code matches the expected status code (with support for wildcards).
5
+ *
6
+ * The expected status can include 'x' as a wildcard for a single digit.
7
+ * Examples:
8
+ * - "200" matches exactly 200
9
+ * - "20x" matches any status code in the range 200 to 209
10
+ * - "2xx" matches any status code in the range 200 to 299
11
+ *
12
+ * @param response - The response object containing a `status_code` attribute.
13
+ * @param expectedStatus - The expected status code as a string with optional 'x' wildcards.
14
+ * @returns True if the status code matches, false otherwise.
15
+ * @throws Error if the response object lacks a 'status_code' attribute.
16
+ */
17
+ export function checkStatusCode(
18
+ response: ResponseV2,
19
+ expectedStatus: string
20
+ ): boolean;
package/src/index.d.ts CHANGED
@@ -1,21 +1,21 @@
1
- export * from './classes/Endpoint';
2
- export * from './classes/GrpcEndpoint';
3
- export * from './classes/RestEndpoint';
4
- export * from './classes/Scenario';
5
- export * from './classes/SkyrampClient';
6
- export * from './classes/RequestValue';
7
- export * from './classes/ResponseValue';
8
- export * from './classes/RestParam';
9
- export * from './classes/MultipartParam';
10
- export * from './classes/TrafficConfig';
11
- export * from './classes/DelayConfig';
12
- export * from './classes/Protocol';
13
- export * from './classes/RequestV2';
14
- export * from './classes/ResponseV2';
15
- export * from './classes/MockV2';
16
- export * from './classes/AsyncScenario';
17
- export * from './classes/LoadTestConfig';
18
- export * from './classes/AsyncTestStatus';
19
- export * from './utils';
20
- export * from './function';
21
- export * from './classes/SmartPlaywright';
1
+ export * from "./classes/Endpoint";
2
+ export * from "./classes/GrpcEndpoint";
3
+ export * from "./classes/RestEndpoint";
4
+ export * from "./classes/Scenario";
5
+ export * from "./classes/SkyrampClient";
6
+ export * from "./classes/RequestValue";
7
+ export * from "./classes/ResponseValue";
8
+ export * from "./classes/RestParam";
9
+ export * from "./classes/MultipartParam";
10
+ export * from "./classes/TrafficConfig";
11
+ export * from "./classes/DelayConfig";
12
+ export * from "./classes/Protocol";
13
+ export * from "./classes/RequestV2";
14
+ export * from "./classes/ResponseV2";
15
+ export * from "./classes/MockV2";
16
+ export * from "./classes/AsyncScenario";
17
+ export * from "./classes/LoadTestConfig";
18
+ export * from "./classes/AsyncTestStatus";
19
+ export * from "./utils";
20
+ export * from "./function";
21
+ export * from "./classes/SmartPlaywright";
package/src/index.js CHANGED
@@ -19,7 +19,7 @@ const AsyncTestStatus = require('./classes/AsyncTestStatus');
19
19
  const MockV2 = require('./classes/MockV2');
20
20
  const { getValue, checkSchema, iterate } = require('./utils');
21
21
  const { checkStatusCode } = require('./function');
22
- const { newSkyrampPlaywrightPage } = require('./classes/SmartPlaywright');
22
+ const { newSkyrampPlaywrightPage, expect } = require('./classes/SmartPlaywright');
23
23
 
24
24
  module.exports = {
25
25
  SkyrampClient,
@@ -47,4 +47,5 @@ module.exports = {
47
47
  checkSchema,
48
48
  iterate,
49
49
  newSkyrampPlaywrightPage,
50
+ expect,
50
51
  }
package/src/utils.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Checks if the given value is a JSON string.
3
+ * @param value - The value to check
4
+ * @returns True if the value is a JSON string, false otherwise
5
+ */
6
+ export function isJSONString(value: string): boolean;
7
+
8
+ /**
9
+ * Safely converts a value to a JSON string.
10
+ * @param value - The value to stringify
11
+ * @returns The JSON string representation
12
+ */
13
+ export function safeStringify(value: string): string;
14
+
15
+ /**
16
+ * Creates a test description from a scenario.
17
+ * @param scenario - The scenario object
18
+ * @returns The test description
19
+ */
20
+ export function createTestDescriptionFromScenario(scenario: object): object;
21
+
22
+ /**
23
+ * Gets YAML bytes from a configuration.
24
+ * @param config - The configuration object
25
+ * @returns The YAML bytes
26
+ */
27
+ export function getYamlBytes(config: object): string;
28
+
29
+ /**
30
+ * Reads data from a file.
31
+ * @param filePath - The path to the file
32
+ * @returns The file data
33
+ */
34
+ export function readDataFromFile(
35
+ filename: string
36
+ ): [string, false] | [string, true];
37
+
38
+ /**
39
+ * Extracts a value from JSON using JSONPath syntax.
40
+ * @param jsonInput - The JSON object or string to extract from
41
+ * @param path - The JSONPath expression (e.g., "$.value", "$.data[0].name")
42
+ * @returns The extracted value or null if not found
43
+ */
44
+ export function getValue(
45
+ jsonInput: object,
46
+ path: string
47
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
48
+ ): any;
49
+
50
+ /**
51
+ * Validates JSON against a schema.
52
+ * @param jsonInput - The JSON to validate
53
+ * @param schema - The schema to validate against
54
+ * @returns The validation result
55
+ */
56
+ export function checkSchema(jsonInput: object, schema: object): boolean;
57
+
58
+ /**
59
+ * Iterates over JSON data structure.
60
+ * @param jsonInput - The JSON data to iterate
61
+ * @returns The iteration result
62
+ */
63
+ export function iterate(jsonInput: object): object;
64
+
65
+ /**
66
+ * The Skyramp YAML version constant.
67
+ */
68
+ export const SKYRAMP_YAML_VERSION: string;