artes 1.7.24 → 1.7.26

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": "artes",
3
- "version": "1.7.24",
3
+ "version": "1.7.26",
4
4
  "description": "The simplest way to automate UI and API tests using Cucumber-style steps.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -1,230 +1,265 @@
1
- const { context } = require("../../hooks/context");
2
- const path = require("path");
3
-
4
- let elements = {};
5
-
6
- function addElements(newElements) {
7
- elements = { ...elements, ...newElements };
8
- }
9
-
10
- // async function locatorExistenceChecker(locator){
11
- // const locatorCount = await locator.count();
12
- // console.log(locator, locatorCount)
13
- // return locatorCount ==0 ? false : true;
14
- // }
15
-
16
- function selectorSeparator(element) {
17
- if (typeof element !== "string") return element;
18
-
19
- const selector = element?.split("=");
20
- const validTypes = [
21
- "xpath",
22
- "name",
23
- "placeholder",
24
- "text",
25
- "label",
26
- "role",
27
- "alt",
28
- "title",
29
- "testid",
30
- ];
31
-
32
- if (selector && validTypes.includes(selector[0]?.trim())) {
33
- return [
34
- selector[0].trim(),
35
- selector[1] !== undefined ? selector[1].trim() : "",
36
- ];
37
- } else {
38
- return selector.join("=");
39
- }
40
- }
41
-
42
- function getSelector(element) {
43
- element = resolveVariable(element);
44
-
45
- const selector =
46
- elements?.[element]?.selector || elements?.[element] || element;
47
- return resolveVariable(selectorSeparator(selector));
48
- }
49
-
50
- function getElement(element) {
51
- if (!context.page) {
52
- throw new Error("Page context is not initialized.");
53
- }
54
-
55
- const selector = getSelector(element);
56
- const waitTime = elements[element]?.waitTime * 1000 || 0;
57
-
58
- let locator;
59
- switch (selector[0]) {
60
- case "xpath":
61
- locator = context.page.locator(`xpath=${selector[1]}`, { exact: true });
62
- break;
63
- case "name":
64
- locator = context.page.locator(`[name="${selector[1]}"]`, {
65
- exact: true,
66
- });
67
- break;
68
- case "placeholder":
69
- locator = context.page.getByPlaceholder(selector[1], { exact: true });
70
- break;
71
- case "text":
72
- locator = context.page.getByText(selector[1], { exact: true });
73
- break;
74
- case "label":
75
- locator = context.page.getByLabel(selector[1], { exact: true });
76
- break;
77
- case "role":
78
- locator = context.page.getByRole(selector[1], { exact: true });
79
- break;
80
- case "alt":
81
- locator = context.page.getByAltText(selector[1], { exact: true });
82
- break;
83
- case "title":
84
- locator = context.page.getByTitle(selector[1], { exact: true });
85
- break;
86
- case "testid":
87
- locator = context.page.getByTestId(selector[1], { exact: true });
88
- break;
89
- default:
90
- locator = context.page.locator(selector, { exact: true });
91
- break;
92
- }
93
-
94
- return locator;
95
- }
96
-
97
- function normalizeCrossplatformPath(inputPath) {
98
- return path.normalize(inputPath.replace(/\\/g, "/"));
99
- }
100
-
101
- function pathToCamelCase(path) {
102
- const cleaned = path.replace(/\[(\d+)\]/g, "_$1");
103
- const parts = cleaned.split(".");
104
- return parts
105
- .map((part, index) => {
106
- if (index === 0) return part;
107
- return part.charAt(0).toUpperCase() + part.slice(1);
108
- })
109
- .join("");
110
- }
111
-
112
- function extractVarsFromResponse(responseBody, vars, customVarNames) {
113
- function getValueByPath(obj, path) {
114
- if (typeof obj === "string") return obj;
115
-
116
- const keys = path.split(".").flatMap((key) => {
117
- const arrayMatch = key.match(/^([^\[]+)\[(\d+)\]$/);
118
- if (arrayMatch) {
119
- return [arrayMatch[1], parseInt(arrayMatch[2])];
120
- }
121
- return [key];
122
- });
123
-
124
- let current = obj;
125
- for (const key of keys) {
126
- if (current == null) return undefined;
127
-
128
- if (typeof key === "number") {
129
- if (!Array.isArray(current)) return undefined;
130
- current = current[key];
131
- } else if (typeof current === "object" && key in current) {
132
- current = current[key];
133
- } else {
134
- return undefined;
135
- }
136
- }
137
-
138
- return current;
139
- }
140
-
141
-
142
- const varPaths = vars.split(",").map((v) => v.trim());
143
- let customNames = [];
144
-
145
- if (!customVarNames) {
146
- customNames = varPaths.map(pathToCamelCase);
147
- } else if (typeof customVarNames === "string") {
148
- customNames = customVarNames.split(",").map((n) => n.trim());
149
- } else if (Array.isArray(customVarNames)) {
150
- customNames = customVarNames;
151
- } else {
152
- throw new Error("customVarNames must be a string or an array");
153
- }
154
-
155
- if (customNames.length !== varPaths.length) {
156
- customNames = varPaths.map(pathToCamelCase);
157
- }
158
-
159
- varPaths.forEach((path, index) => {
160
- const value = getValueByPath(responseBody, path);
161
- if (value !== undefined) {
162
- saveVar(value, customNames[index], path);
163
- }
164
- });
165
- }
166
-
167
- function saveVar(value, customName, path) {
168
- if (!customName) {
169
- const flatKey = path
170
- .split(".")
171
- .map((part, i) =>
172
- i === 0 ? part : part[0].toUpperCase() + part.slice(1),
173
- )
174
- .join("");
175
-
176
- context.vars[flatKey] = value;
177
- } else {
178
- context.vars[customName] = value;
179
- }
180
- }
181
-
182
- function resolveVariable(template) {
183
- if (typeof template === "string") {
184
- return template.replace(/{{\s*(\w+)\s*}}/g, (_, varName) => {
185
- let value = context.vars[varName];
186
-
187
- if (value !== undefined) {
188
- if (typeof value !== "string") {
189
- try {
190
- value = JSON.stringify(value);
191
- } catch {
192
- value = String(value);
193
- }
194
- }
195
-
196
- return value
197
- .replace(/\n/g, "\\n")
198
- .replace(/\r/g, "\\r")
199
- .replace(/\t/g, "\\t");
200
- }
201
-
202
- return `{{${varName}}}`;
203
- });
204
- }
205
-
206
- if (Array.isArray(template)) {
207
- return template.map((item) => resolveVariable(item));
208
- }
209
-
210
- if (template && typeof template === "object") {
211
- const result = {};
212
- for (const key in template) {
213
- result[key] = resolveVariable(template[key]);
214
- }
215
- return result;
216
- }
217
-
218
- return template;
219
- }
220
-
221
- module.exports = {
222
- getElement,
223
- addElements,
224
- getSelector,
225
- extractVarsFromResponse,
226
- pathToCamelCase,
227
- normalizeCrossplatformPath,
228
- saveVar,
229
- resolveVariable,
230
- };
1
+ const { context } = require("../../hooks/context");
2
+ const path = require("path");
3
+
4
+ let elements = {};
5
+
6
+ function addElements(newElements) {
7
+ elements = { ...elements, ...newElements };
8
+ }
9
+
10
+ // async function locatorExistenceChecker(locator){
11
+ // const locatorCount = await locator.count();
12
+ // console.log(locator, locatorCount)
13
+ // return locatorCount ==0 ? false : true;
14
+ // }
15
+
16
+ function isPlaywrightCallSyntax(str) {
17
+ return typeof str === "string" && /^[a-zA-Z]+\(.*\)\s*;?\s*$/s.test(str.trim());
18
+ }
19
+
20
+ function parsePlaywrightCall(str) {
21
+ const match = str.trim().match(/^([a-zA-Z]+)\((.*)\)\s*;?\s*$/s);
22
+ if (!match) return null;
23
+
24
+ const [, method, argsStr] = match;
25
+
26
+ // Safely evaluate the argument list as a JS array literal
27
+ // e.g. "'button', { name: 'Decline' }" -> ['button', { name: 'Decline' }]
28
+ let args;
29
+ try {
30
+ args = Function(`"use strict"; return [${argsStr}];`)();
31
+ } catch (err) {
32
+ throw new Error(`Failed to parse locator arguments in "${str}": ${err.message}`);
33
+ }
34
+
35
+ return { method, args };
36
+ }
37
+
38
+ function selectorSeparator(element) {
39
+ if (typeof element !== "string") return element;
40
+
41
+ const selector = element?.split("=");
42
+ const validTypes = [
43
+ "xpath",
44
+ "name",
45
+ "placeholder",
46
+ "text",
47
+ "label",
48
+ "role",
49
+ "alt",
50
+ "title",
51
+ "testid",
52
+ ];
53
+
54
+ if (selector && validTypes.includes(selector[0]?.trim())) {
55
+ return [
56
+ selector[0].trim(),
57
+ selector[1] !== undefined ? selector[1].trim() : "",
58
+ ];
59
+ } else {
60
+ return selector.join("=");
61
+ }
62
+ }
63
+
64
+ function getSelector(element) {
65
+ element = resolveVariable(element);
66
+
67
+ const selector =
68
+ elements?.[element]?.selector || elements?.[element] || element;
69
+ return resolveVariable(selectorSeparator(selector));
70
+ }
71
+
72
+ function getElement(element) {
73
+ if (!context.page) {
74
+ throw new Error("Page context is not initialized.");
75
+ }
76
+
77
+ const rawValue = resolveVariable(
78
+ elements?.[element]?.selector || elements?.[element] || element
79
+ );
80
+
81
+ // NEW: handle raw Playwright method-call syntax, e.g. getByRole('button', { name: 'Decline' })
82
+ if (isPlaywrightCallSyntax(rawValue)) {
83
+ const parsed = parsePlaywrightCall(rawValue);
84
+ if (!parsed || typeof context.page[parsed.method] !== "function") {
85
+ throw new Error(`Unsupported or invalid Playwright locator call: "${rawValue}"`);
86
+ }
87
+ return context.page[parsed.method](...parsed.args);
88
+ }
89
+
90
+ const selector = getSelector(element);
91
+ const waitTime = elements[element]?.waitTime * 1000 || 0;
92
+
93
+ let locator;
94
+ switch (selector[0]) {
95
+ case "xpath":
96
+ locator = context.page.locator(`xpath=${selector[1]}`, { exact: true });
97
+ break;
98
+ case "name":
99
+ locator = context.page.locator(`[name="${selector[1]}"]`, {
100
+ exact: true,
101
+ });
102
+ break;
103
+ case "placeholder":
104
+ locator = context.page.getByPlaceholder(selector[1], { exact: true });
105
+ break;
106
+ case "text":
107
+ locator = context.page.getByText(selector[1], { exact: true });
108
+ break;
109
+ case "label":
110
+ locator = context.page.getByLabel(selector[1], { exact: true });
111
+ break;
112
+ case "role":
113
+ locator = context.page.getByRole(selector[1], { exact: true });
114
+ break;
115
+ case "alt":
116
+ locator = context.page.getByAltText(selector[1], { exact: true });
117
+ break;
118
+ case "title":
119
+ locator = context.page.getByTitle(selector[1], { exact: true });
120
+ break;
121
+ case "testid":
122
+ locator = context.page.getByTestId(selector[1], { exact: true });
123
+ break;
124
+ default:
125
+ locator = context.page.locator(selector, { exact: true });
126
+ break;
127
+ }
128
+
129
+ return locator;
130
+ }
131
+
132
+ function normalizeCrossplatformPath(inputPath) {
133
+ return path.normalize(inputPath.replace(/\\/g, "/"));
134
+ }
135
+
136
+ function pathToCamelCase(path) {
137
+ const cleaned = path.replace(/\[(\d+)\]/g, "_$1");
138
+ const parts = cleaned.split(".");
139
+ return parts
140
+ .map((part, index) => {
141
+ if (index === 0) return part;
142
+ return part.charAt(0).toUpperCase() + part.slice(1);
143
+ })
144
+ .join("");
145
+ }
146
+
147
+ function extractVarsFromResponse(responseBody, vars, customVarNames) {
148
+ function getValueByPath(obj, path) {
149
+ if (typeof obj === "string") return obj;
150
+
151
+ const keys = path.split(".").flatMap((key) => {
152
+ const arrayMatch = key.match(/^([^\[]+)\[(\d+)\]$/);
153
+ if (arrayMatch) {
154
+ return [arrayMatch[1], parseInt(arrayMatch[2])];
155
+ }
156
+ return [key];
157
+ });
158
+
159
+ let current = obj;
160
+ for (const key of keys) {
161
+ if (current == null) return undefined;
162
+
163
+ if (typeof key === "number") {
164
+ if (!Array.isArray(current)) return undefined;
165
+ current = current[key];
166
+ } else if (typeof current === "object" && key in current) {
167
+ current = current[key];
168
+ } else {
169
+ return undefined;
170
+ }
171
+ }
172
+
173
+ return current;
174
+ }
175
+
176
+
177
+ const varPaths = vars.split(",").map((v) => v.trim());
178
+ let customNames = [];
179
+
180
+ if (!customVarNames) {
181
+ customNames = varPaths.map(pathToCamelCase);
182
+ } else if (typeof customVarNames === "string") {
183
+ customNames = customVarNames.split(",").map((n) => n.trim());
184
+ } else if (Array.isArray(customVarNames)) {
185
+ customNames = customVarNames;
186
+ } else {
187
+ throw new Error("customVarNames must be a string or an array");
188
+ }
189
+
190
+ if (customNames.length !== varPaths.length) {
191
+ customNames = varPaths.map(pathToCamelCase);
192
+ }
193
+
194
+ varPaths.forEach((path, index) => {
195
+ const value = getValueByPath(responseBody, path);
196
+ if (value !== undefined) {
197
+ saveVar(value, customNames[index], path);
198
+ }
199
+ });
200
+ }
201
+
202
+ function saveVar(value, customName, path) {
203
+ if (!customName) {
204
+ const flatKey = path
205
+ .split(".")
206
+ .map((part, i) =>
207
+ i === 0 ? part : part[0].toUpperCase() + part.slice(1),
208
+ )
209
+ .join("");
210
+
211
+ context.vars[flatKey] = value;
212
+ } else {
213
+ context.vars[customName] = value;
214
+ }
215
+ }
216
+
217
+ function resolveVariable(template) {
218
+ if (typeof template === "string") {
219
+ return template.replace(/{{\s*(\w+)\s*}}/g, (_, varName) => {
220
+ let value = context.vars[varName];
221
+
222
+ if (value !== undefined) {
223
+ if (typeof value !== "string") {
224
+ try {
225
+ value = JSON.stringify(value);
226
+ } catch {
227
+ value = String(value);
228
+ }
229
+ }
230
+
231
+ return value
232
+ .replace(/\n/g, "\\n")
233
+ .replace(/\r/g, "\\r")
234
+ .replace(/\t/g, "\\t");
235
+ }
236
+
237
+ return `{{${varName}}}`;
238
+ });
239
+ }
240
+
241
+ if (Array.isArray(template)) {
242
+ return template.map((item) => resolveVariable(item));
243
+ }
244
+
245
+ if (template && typeof template === "object") {
246
+ const result = {};
247
+ for (const key in template) {
248
+ result[key] = resolveVariable(template[key]);
249
+ }
250
+ return result;
251
+ }
252
+
253
+ return template;
254
+ }
255
+
256
+ module.exports = {
257
+ getElement,
258
+ addElements,
259
+ getSelector,
260
+ extractVarsFromResponse,
261
+ pathToCamelCase,
262
+ normalizeCrossplatformPath,
263
+ saveVar,
264
+ resolveVariable,
265
+ };
@@ -1364,3 +1364,27 @@ Then(
1364
1364
  },
1365
1365
  );
1366
1366
 
1367
+ Then(
1368
+ "User expects that response has {string} field that contains {string} value",
1369
+ async (field, value) => {
1370
+ extractVarsFromResponse(context.response["Response Body"], field);
1371
+ const key = pathToCamelCase(field);
1372
+ expect(String(context.vars[key])).toContain(resolveVariable(value));
1373
+ },
1374
+ );
1375
+
1376
+
1377
+ Then('User expects that response has {string} header with {string} value', async (field, value) => {
1378
+ extractVarsFromResponse(context.response["Response Headers"], field);
1379
+ const key = pathToCamelCase(field);
1380
+ expect(String(context.vars[key])).toBe(resolveVariable(value));
1381
+ })
1382
+
1383
+ Then(
1384
+ "User expects that response does not have {string} field with {string} value",
1385
+ async (field, value) => {
1386
+ extractVarsFromResponse(context.response["Response Body"], field);
1387
+ const key = pathToCamelCase(field);
1388
+ expect(String(context.vars[key])).not.toBe(resolveVariable(value));
1389
+ },
1390
+ );
@@ -1,76 +1,208 @@
1
- const { When } = require("../helper/imports/commons");
2
- const { frame } = require("../helper/stepFunctions/exporter");
3
-
4
- // User takes a screenshot of a specific selector
5
- When("User takes a screenshot of {string}", async function (selector) {
6
- await frame.screenshot(selector);
7
- });
8
-
9
- // User gets the content frame of a specific selector
10
- When("User gets the content frame of {string}", async function (selector) {
11
- await frame.contentFrame(selector);
12
- });
13
-
14
- // User gets the frame locator of a specific selector
15
- When("User gets the frame locator of {string}", async function (selector) {
16
- await frame.frameLocator(selector);
17
- });
18
-
19
- // User gets the nth element of a specific selector
20
- When(
21
- "User gets the {int} th element of {string}",
22
- async function (index, selector) {
23
- await frame.nth(selector, index);
24
- },
25
- );
26
-
27
- // User gets the first element of a specific selector
28
- When("User gets the first element of {string}", async function (selector) {
29
- await frame.first(selector);
30
- });
31
-
32
- // User gets the last element of a specific selector
33
- When("User gets the last element of {string}", async function (selector) {
34
- await frame.last(selector);
35
- });
36
-
37
- // User filters elements of a specific selector
38
- When(
39
- "User filters elements of {string} with filter {string}",
40
- async function (selector, filter) {
41
- await frame.filter(selector, filter);
42
- },
43
- );
44
-
45
- // User counts the number of elements of a specific selector
46
- When("User counts the elements of {string}", async function (selector) {
47
- await frame.count(selector);
48
- });
49
-
50
- // User gets an element by its alt text
51
- When("User gets the element with alt text {string}", async function (text) {
52
- await frame.getByAltText(text);
53
- });
54
-
55
- // User gets an element by its label
56
- When("User gets the element with label {string}", async function (label) {
57
- await frame.getByLabel(label);
58
- });
59
-
60
- // User gets an element by its placeholder
61
- When(
62
- "User gets the element with placeholder {string}",
63
- async function (placeholder) {
64
- await frame.getByPlaceholder(placeholder);
65
- },
66
- );
67
-
68
- // User gets an element by its role
69
- When("User gets the element with role {string}", async function (role) {
70
- await frame.getByRole(role);
71
- });
72
-
73
- // User gets an element by its testId
74
- When("User gets the element with testId {string}", async function (testId) {
75
- await frame.getByTestId(testId);
76
- });
1
+ const { When } = require("../helper/imports/commons");
2
+ const { frame } = require("../helper/stepFunctions/exporter");
3
+ const { saveVar, element} = require("artes");
4
+
5
+ // User takes a screenshot of a specific selector
6
+ When("User takes a screenshot of {string}", async function (selector) {
7
+ await frame.screenshot(selector);
8
+ });
9
+
10
+ // User gets the content frame of a specific selector
11
+ When("User gets the content frame of {string}", async function (selector) {
12
+ await frame.contentFrame(selector);
13
+ });
14
+
15
+ // User gets the frame locator of a specific selector
16
+ When("User gets the frame locator of {string}", async function (selector) {
17
+ await frame.frameLocator(selector);
18
+ });
19
+
20
+ // User gets the nth element of a specific selector
21
+ When(
22
+ "User gets the {int} th element of {string}",
23
+ async function (index, selector) {
24
+ await frame.nth(selector, index);
25
+ },
26
+ );
27
+
28
+ // User gets the first element of a specific selector
29
+ When("User gets the first element of {string}", async function (selector) {
30
+ await frame.first(selector);
31
+ });
32
+
33
+ // User gets the last element of a specific selector
34
+ When("User gets the last element of {string}", async function (selector) {
35
+ await frame.last(selector);
36
+ });
37
+
38
+ // User filters elements of a specific selector
39
+ When(
40
+ "User filters elements of {string} with filter {string}",
41
+ async function (selector, filter) {
42
+ await frame.filter(selector, filter);
43
+ },
44
+ );
45
+
46
+ // User counts the number of elements of a specific selector
47
+ When("User counts the elements of {string}", async function (selector) {
48
+ await frame.count(selector);
49
+ });
50
+
51
+ // User gets an element by its alt text
52
+ When("User gets the element with alt text {string}", async function (text) {
53
+ await frame.getByAltText(text);
54
+ });
55
+
56
+ // User gets an element by its label
57
+ When("User gets the element with label {string}", async function (label) {
58
+ await frame.getByLabel(label);
59
+ });
60
+
61
+ // User gets an element by its placeholder
62
+ When(
63
+ "User gets the element with placeholder {string}",
64
+ async function (placeholder) {
65
+ await frame.getByPlaceholder(placeholder);
66
+ },
67
+ );
68
+
69
+ // User gets an element by its role
70
+ When("User gets the element with role {string}", async function (role) {
71
+ await frame.getByRole(role);
72
+ });
73
+
74
+ // User gets an element by its testId
75
+ When("User gets the element with testId {string}", async function (testId) {
76
+ await frame.getByTestId(testId);
77
+ });
78
+
79
+
80
+
81
+
82
+ When("User saves the value of {string} as {string}", async function (selector, varName) {
83
+
84
+ async function extractElementValue(locator) {
85
+ await locator.waitFor({ state: "visible" });
86
+
87
+ const tagName = await locator.evaluate((el) => el.tagName.toLowerCase());
88
+ const normalize = (str) => str?.replace(/\s+/g, " ").trim();
89
+
90
+ if (["input", "textarea"].includes(tagName)) {
91
+ return normalize(await locator.inputValue());
92
+ }
93
+
94
+ if (tagName === "select") {
95
+ return normalize(
96
+ await locator.evaluate((el) => el.options[el.selectedIndex]?.text)
97
+ );
98
+ }
99
+
100
+ if (tagName === "img") {
101
+ return normalize(await locator.getAttribute("alt"));
102
+ }
103
+
104
+ // Try several strategies in order, use the first non-empty result
105
+ const strategies = [
106
+ () => locator.innerText(),
107
+ () => locator.textContent(),
108
+ () => locator.getAttribute("value"),
109
+ () => locator.getAttribute("aria-label"),
110
+ () => locator.getAttribute("title"),
111
+ () => locator.getAttribute("placeholder"),
112
+ () => locator.getAttribute("alt"),
113
+ ];
114
+
115
+ for (const strategy of strategies) {
116
+ try {
117
+ const result = normalize(await strategy());
118
+ if (result) return result;
119
+ } catch {
120
+ // strategy not applicable to this element, try next
121
+ continue;
122
+ }
123
+ }
124
+
125
+ return "";
126
+ }
127
+
128
+ const locator = element(selector);
129
+ const value = await extractElementValue(locator);
130
+ console.log( typeof locator)
131
+
132
+ saveVar(value, varName);
133
+
134
+ });
135
+
136
+
137
+ When(
138
+ "User saves the value of {int} th of {string} as {string}",
139
+ async function (ordinal, selector, varName) {
140
+
141
+
142
+ async function extractElementValue(locator) {
143
+ await locator.waitFor({ state: "visible" });
144
+
145
+ const tagName = await locator.evaluate((el) => el.tagName.toLowerCase());
146
+ const normalize = (str) => str?.replace(/\s+/g, " ").trim();
147
+
148
+ if (["input", "textarea"].includes(tagName)) {
149
+ return normalize(await locator.inputValue());
150
+ }
151
+
152
+ if (tagName === "select") {
153
+ return normalize(
154
+ await locator.evaluate((el) => el.options[el.selectedIndex]?.text)
155
+ );
156
+ }
157
+
158
+ if (tagName === "img") {
159
+ return normalize(await locator.getAttribute("alt"));
160
+ }
161
+
162
+ const strategies = [
163
+ () => locator.innerText(),
164
+ () => locator.textContent(),
165
+ () => locator.getAttribute("value"),
166
+ () => locator.getAttribute("aria-label"),
167
+ () => locator.getAttribute("title"),
168
+ () => locator.getAttribute("placeholder"),
169
+ () => locator.getAttribute("alt"),
170
+ ];
171
+
172
+ for (const strategy of strategies) {
173
+ try {
174
+ const result = normalize(await strategy());
175
+ if (result) return result;
176
+ } catch {
177
+ continue;
178
+ }
179
+ }
180
+
181
+ return "";
182
+ }
183
+
184
+ function ordinalToIndex(ordinal) {
185
+ const num = parseInt(ordinal, 10);
186
+ if (isNaN(num) || num < 1) {
187
+ throw new Error(`Invalid ordinal "${ordinal}" — must be like "1st", "2nd", "3rd", "4th"...`);
188
+ }
189
+ return num - 1;
190
+ }
191
+
192
+
193
+ const index = ordinalToIndex(ordinal);
194
+ const baseLocator = element(selector);
195
+
196
+ const count = await baseLocator.count();
197
+ if (index >= count) {
198
+ throw new Error(
199
+ `Requested ${ordinal} match for "${selector}", but only ${count} element(s) found.`
200
+ );
201
+ }
202
+
203
+ const locator = baseLocator.nth(index);
204
+ const value = await extractElementValue(locator);
205
+
206
+ saveVar(value, varName);
207
+ }
208
+ );