@capagents/forgeqe 1.1.4 → 1.1.6
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/HOW_TO_USE.md +64 -17
- package/README.md +2 -1
- package/browserstack.example.yaml +14 -2
- package/docs/user-guide.html +78 -23
- package/forgeqe.example.yaml +18 -5
- package/package.json +2 -1
- package/prompts/README.md +1 -0
- package/prompts/brain.md +8 -1
- package/prompts/defect-rca.md +43 -0
- package/prompts/test-agent.md +13 -2
- package/prompts/test-reviewer.md +8 -3
- package/prompts/test-rules.md +10 -4
- package/py/forgeqe_engine/browser.py +1 -1
- package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
- package/skills/playwright-typescript/SKILL.md +2 -1
- package/src/brain/index.ts +1 -1
- package/src/capabilities/registry.ts +34 -6
- package/src/cases/bugs.ts +209 -22
- package/src/cases/defectHistory.ts +193 -0
- package/src/cases/defects.ts +255 -0
- package/src/cases/github.ts +162 -0
- package/src/cases/goal.ts +1 -1
- package/src/cases/index.ts +23 -4
- package/src/cases/predict.ts +372 -0
- package/src/cases/rca.ts +258 -0
- package/src/cases/readiness.ts +38 -1
- package/src/cases/repro.ts +202 -0
- package/src/cases/types.ts +8 -1
- package/src/cli/help.ts +8 -2
- package/src/cli/index.ts +36 -7
- package/src/code/graph/graph.ts +518 -0
- package/src/code/graph/locators.ts +398 -0
- package/src/code/graph/parse.ts +439 -0
- package/src/code/graph/tools.ts +113 -0
- package/src/code/handoff.ts +93 -18
- package/src/code/loop.ts +30 -5
- package/src/code/profile.ts +14 -3
- package/src/code/replay.ts +1 -1
- package/src/code/review.ts +22 -0
- package/src/code/runtime.ts +8 -1
- package/src/config.ts +142 -18
- package/src/mcp/attachtools.ts +223 -0
- package/src/mcp/commands.ts +1 -1
- package/src/mcp/defecttools.ts +106 -0
- package/src/mcp/devicetools.ts +125 -0
- package/src/mcp/external.ts +42 -1
- package/src/mcp/server.ts +3 -1
- package/src/mcp/tools.ts +95 -10
- package/src/monitor/run.ts +1 -1
- package/src/report/data.ts +14 -0
- package/src/report/html.ts +15 -3
- package/src/report/template.html +44 -43
- package/src/run.ts +4 -2
- package/src/suite/report.ts +44 -11
- package/src/suite/run.ts +163 -16
- package/src/tui/App.tsx +153 -94
- package/src/tui/components.tsx +27 -23
- package/src/tui/inspector.tsx +140 -83
- package/src/tui/landing.ts +3 -4
- package/src/tui/refine-panel.tsx +102 -52
- package/src/tui/shortcuts.tsx +2 -0
- package/src/tui/transcript.tsx +32 -2
- package/src/types.ts +3 -1
- package/src/usage.ts +1 -1
- package/testmu.example.yaml +11 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locators as one vocabulary, whatever wrote them: Playwright in any language (getByRole, GetByRole, get_by_role,
|
|
3
|
+
* locator), Selenium (By.id, By.Id, By.ID, find_element(By.ID, …)), Cypress (cy.get, cy.contains), WebdriverIO ($, $$,
|
|
4
|
+
* aria/ and = selectors), and Testing Library queries. Code, the handoff, and page memory all reduce to these, so the
|
|
5
|
+
* same element is recognised however each one names it.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type LocatorKind = "role" | "label" | "placeholder" | "text" | "testid" | "alt" | "title" | "id" | "name" | "css" | "xpath"
|
|
9
|
+
|
|
10
|
+
/** semantic: what a user perceives (role, label, text, test id). stable: an id or attribute. brittle: XPath, positions, long CSS paths. */
|
|
11
|
+
export type LocatorQuality = "semantic" | "stable" | "brittle"
|
|
12
|
+
|
|
13
|
+
export type LocatorRef = {
|
|
14
|
+
kind: LocatorKind
|
|
15
|
+
/** The literal value; for role, the accessible name. "" when it comes from a parameter or variable. */
|
|
16
|
+
value: string
|
|
17
|
+
/** role: the ARIA role, lower case; "*" for a WebdriverIO aria/ selector, which matches any role. */
|
|
18
|
+
role?: string
|
|
19
|
+
/** The value is a regular expression (NameRegex, /…/, re.compile). */
|
|
20
|
+
pattern?: boolean
|
|
21
|
+
/** The value, or part of it, comes from a parameter: it finds a family of elements (one per argument). */
|
|
22
|
+
param?: boolean
|
|
23
|
+
/** testid from an attribute selector: the attribute it names (data-test), which getByTestId only finds when configured. */
|
|
24
|
+
attr?: string
|
|
25
|
+
quality: LocatorQuality
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SEMANTIC = new Set<LocatorKind>(["role", "label", "placeholder", "text", "testid", "alt", "title"])
|
|
29
|
+
const TEST_ATTR = /^(data-(?:test|testid|test-id|qa|cy|e2e|automation-id|automationid)|data-test-[\w-]+)$/i
|
|
30
|
+
|
|
31
|
+
/** A string literal's contents in any of the languages ('…', "…", `…`, @"…", $"…", r"…", f"…"); null when it is not one. */
|
|
32
|
+
function literal(src: string): { value: string; param: boolean } | null {
|
|
33
|
+
const s = src.trim()
|
|
34
|
+
const m = /^(?:[@$]{1,2}|[rRfFbBuU]{1,2})?(["'`])([\s\S]*)\1$/.exec(s)
|
|
35
|
+
if (!m) return null
|
|
36
|
+
const body = m[2]
|
|
37
|
+
// Interpolations: ${x} (TS), {x} (C# $"", Python f""). Their text is a placeholder, not part of the value.
|
|
38
|
+
const interpolated = (m[1] === "`" && /\$\{[^}]+\}/.test(body)) || (/^[$fF]/.test(s) && /\{[^}]+\}/.test(body))
|
|
39
|
+
const value = interpolated ? body.replace(/\$?\{[^}]+\}/g, "\u0000") : body
|
|
40
|
+
return { value: value.replace(/\\(["'`\\])/g, "$1"), param: interpolated }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Top-level arguments of a call's argument text, split on commas outside brackets and strings. */
|
|
44
|
+
export function splitArgs(args: string): string[] {
|
|
45
|
+
const out: string[] = []
|
|
46
|
+
let depth = 0
|
|
47
|
+
let quote = ""
|
|
48
|
+
let cur = ""
|
|
49
|
+
for (let i = 0; i < args.length; i++) {
|
|
50
|
+
const c = args[i]
|
|
51
|
+
if (quote) {
|
|
52
|
+
cur += c
|
|
53
|
+
if (c === "\\") cur += args[++i] ?? ""
|
|
54
|
+
else if (c === quote) quote = ""
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
if (c === '"' || c === "'" || c === "`") quote = c
|
|
58
|
+
else if ("([{".includes(c)) depth++
|
|
59
|
+
else if (")]}".includes(c)) depth--
|
|
60
|
+
else if (c === "," && depth === 0) {
|
|
61
|
+
out.push(cur.trim())
|
|
62
|
+
cur = ""
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
cur += c
|
|
66
|
+
}
|
|
67
|
+
if (cur.trim()) out.push(cur.trim())
|
|
68
|
+
return out
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A regular expression literal or constructor: /x/i, new Regex(@"x"), re.compile(r"x"), new RegExp("x"). */
|
|
72
|
+
function regexOf(src: string): string | null {
|
|
73
|
+
const s = src.trim()
|
|
74
|
+
const lit = /^\/((?:\\.|[^/])+)\/[a-z]*$/.exec(s)
|
|
75
|
+
if (lit) return lit[1]
|
|
76
|
+
const ctor = /^(?:new\s+(?:Regex|RegExp)|re\.compile|Pattern\.compile)\s*\(\s*([\s\S]+?)\s*(?:,[\s\S]*)?\)$/.exec(s)
|
|
77
|
+
if (ctor) return literal(splitArgs(ctor[1])[0] || "")?.value ?? null
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A text-like argument: a literal, a regular expression, or a parameter. */
|
|
82
|
+
function textArg(src: string | undefined): { value: string; pattern?: boolean; param?: boolean } | null {
|
|
83
|
+
if (!src) return null
|
|
84
|
+
const lit = literal(src)
|
|
85
|
+
if (lit) return lit.param ? { value: lit.value, param: true } : { value: lit.value }
|
|
86
|
+
const re = regexOf(src)
|
|
87
|
+
if (re !== null) return { value: re, pattern: true }
|
|
88
|
+
// A plain identifier or member access: the value comes from a parameter or a variable.
|
|
89
|
+
if (/^[A-Za-z_][\w.]*$/.test(src.trim())) return { value: "", param: true }
|
|
90
|
+
return null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The expression that starts at `from`, up to a comma or closing bracket outside strings and brackets. */
|
|
94
|
+
function expressionAt(s: string, from: number): string {
|
|
95
|
+
let depth = 0
|
|
96
|
+
let quote = ""
|
|
97
|
+
let i = from
|
|
98
|
+
for (; i < s.length; i++) {
|
|
99
|
+
const c = s[i]
|
|
100
|
+
if (quote) {
|
|
101
|
+
if (c === "\\" && quote !== "@") i++
|
|
102
|
+
else if (c === quote) quote = ""
|
|
103
|
+
continue
|
|
104
|
+
}
|
|
105
|
+
if (c === '"' || c === "'" || c === "`") quote = c
|
|
106
|
+
else if (c === "/" && i === from) {
|
|
107
|
+
// A regular expression literal: skip to its closing slash.
|
|
108
|
+
const end = /^\/(?:\\.|[^/])+\/[a-z]*/.exec(s.slice(i))
|
|
109
|
+
if (end) i += end[0].length - 1
|
|
110
|
+
} else if ("([{".includes(c)) depth++
|
|
111
|
+
else if (")]}".includes(c)) {
|
|
112
|
+
if (depth === 0) break
|
|
113
|
+
depth--
|
|
114
|
+
} else if (c === "," && depth === 0) break
|
|
115
|
+
}
|
|
116
|
+
return s.slice(from, i).trim()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The accessible name option of a role call: { name: 'x' }, new() { Name = "x" }, NameRegex = new Regex(…), name="x". */
|
|
120
|
+
function nameOption(args: string[]): { value: string; pattern?: boolean; param?: boolean } | null {
|
|
121
|
+
for (const a of args.slice(1)) {
|
|
122
|
+
// { name: … }, new() { Name = … }, name=… (Python), new GetByRoleOptions().setName(…) (Java).
|
|
123
|
+
const key = /\b(name|Name|NameString|NameRegex)\s*[:=]\s*|\.(setName)\(\s*/.exec(a)
|
|
124
|
+
if (!key) continue
|
|
125
|
+
const value = expressionAt(a, (key.index ?? 0) + key[0].length)
|
|
126
|
+
const arg = textArg(value)
|
|
127
|
+
if (arg) return key[1] === "NameRegex" && !arg.pattern ? { value: "", param: true } : arg
|
|
128
|
+
return { value: "", param: true }
|
|
129
|
+
}
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** AriaRole.Button, 'button', "BUTTON", AriaRole.BUTTON → button. */
|
|
134
|
+
function roleOf(src: string | undefined): string {
|
|
135
|
+
if (!src) return ""
|
|
136
|
+
const lit = literal(src)
|
|
137
|
+
if (lit) return lit.param ? "" : lit.value.toLowerCase()
|
|
138
|
+
const m = /(?:AriaRole|Role)\.(\w+)$/.exec(src.trim())
|
|
139
|
+
return m ? m[1].toLowerCase() : ""
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function cssQuality(css: string): LocatorQuality {
|
|
143
|
+
if (/:nth-(?:child|of-type)|:eq\(|:first-child|:last-child/.test(css)) return "brittle"
|
|
144
|
+
const parts = css.trim().split(/\s*[\s>+~]\s*/).filter(Boolean)
|
|
145
|
+
return parts.length >= 3 ? "brittle" : "stable"
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** A CSS selector reduced to what it says: an id, a test id, a name, a label, or plain CSS. */
|
|
149
|
+
export function fromCss(raw: string): LocatorRef {
|
|
150
|
+
const css = raw.trim()
|
|
151
|
+
const id = /^#([\w-]+)$/.exec(css) || /^\[id\s*=\s*['"]?([^'"\]]+)['"]?\]$/.exec(css)
|
|
152
|
+
if (id) return { kind: "id", value: id[1], quality: "stable" }
|
|
153
|
+
const attr = /^(\w+)?\[([\w-]+)\s*=\s*['"]?([^'"\]]+)['"]?\]$/.exec(css)
|
|
154
|
+
if (attr && TEST_ATTR.test(attr[2])) return { kind: "testid", value: attr[3], attr: attr[2], quality: "semantic" }
|
|
155
|
+
if (attr && attr[2] === "name") return { kind: "name", value: attr[3], quality: "stable" }
|
|
156
|
+
if (attr && attr[2] === "aria-label") return { kind: "label", value: attr[3], quality: "semantic" }
|
|
157
|
+
if (attr && attr[2] === "placeholder") return { kind: "placeholder", value: attr[3], quality: "semantic" }
|
|
158
|
+
return { kind: "css", value: css, quality: cssQuality(css) }
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A Playwright selector string: css, xpath=, //…, text=, id=, data-testid=, role=…[name=…]. */
|
|
162
|
+
function fromSelector(raw: string, param: boolean): LocatorRef {
|
|
163
|
+
const s = raw.trim()
|
|
164
|
+
const ref = ((): LocatorRef => {
|
|
165
|
+
if (/^xpath=/.test(s)) return { kind: "xpath", value: s.slice(6), quality: "brittle" }
|
|
166
|
+
if (/^\.?\/\/|^\(\/\//.test(s)) return { kind: "xpath", value: s, quality: "brittle" }
|
|
167
|
+
if (/^text=/.test(s)) return { kind: "text", value: s.slice(5).replace(/^["']|["']$/g, ""), quality: "semantic" }
|
|
168
|
+
if (/^id=/.test(s)) return { kind: "id", value: s.slice(3), quality: "stable" }
|
|
169
|
+
const tid = /^(data-[\w-]+)=(.+)$/.exec(s)
|
|
170
|
+
if (tid && TEST_ATTR.test(tid[1])) return { kind: "testid", value: tid[2].replace(/^["']|["']$/g, ""), quality: "semantic" }
|
|
171
|
+
const role = /^role=(\w+)(?:\[name=["']?([^"'\]]+)["']?\])?$/.exec(s)
|
|
172
|
+
if (role) return { kind: "role", role: role[1].toLowerCase(), value: role[2] || "", quality: "semantic" }
|
|
173
|
+
return fromCss(s.replace(/^css=/, ""))
|
|
174
|
+
})()
|
|
175
|
+
return param ? { ...ref, param: true } : ref
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A WebdriverIO selector: aria/Name, =Link text, *=partial, #id, CSS, XPath. */
|
|
179
|
+
function fromWdio(raw: string, param: boolean): LocatorRef {
|
|
180
|
+
const s = raw.trim()
|
|
181
|
+
const ref = ((): LocatorRef => {
|
|
182
|
+
if (s.startsWith("aria/")) return { kind: "role", role: "*", value: s.slice(5), quality: "semantic" }
|
|
183
|
+
if (/^\*?=/.test(s)) return { kind: "text", value: s.replace(/^\*?=/, ""), quality: "semantic" }
|
|
184
|
+
if (/^<\w+\s*\/>$/.test(s)) return { kind: "css", value: s, quality: "stable" }
|
|
185
|
+
return fromSelector(s, false)
|
|
186
|
+
})()
|
|
187
|
+
return param ? { ...ref, param: true } : ref
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Selenium's By kinds, in any language's spelling: id, Id, ID, cssSelector, CssSelector, CSS_SELECTOR, … */
|
|
191
|
+
function fromBy(kind: string, raw: string, param: boolean): LocatorRef | null {
|
|
192
|
+
const k = kind.toLowerCase().replace(/_/g, "")
|
|
193
|
+
const ref = ((): LocatorRef | null => {
|
|
194
|
+
if (k === "id") return { kind: "id", value: raw, quality: "stable" }
|
|
195
|
+
if (k === "name") return { kind: "name", value: raw, quality: "stable" }
|
|
196
|
+
if (k === "cssselector" || k === "css") return fromCss(raw)
|
|
197
|
+
if (k === "xpath") return { kind: "xpath", value: raw, quality: "brittle" }
|
|
198
|
+
if (k === "linktext" || k === "partiallinktext") return { kind: "text", value: raw, quality: "semantic" }
|
|
199
|
+
if (k === "classname") return { kind: "css", value: `.${raw}`, quality: "stable" }
|
|
200
|
+
if (k === "tagname") return { kind: "css", value: raw, quality: "stable" }
|
|
201
|
+
return null
|
|
202
|
+
})()
|
|
203
|
+
return ref && param ? { ...ref, param: true } : ref
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Selenium's By kinds as a ref: locatorFromBy("cssSelector", "#a"), locatorFromBy("ID", "user-name"). */
|
|
207
|
+
export function locatorFromBy(kind: string, value: string): LocatorRef | null {
|
|
208
|
+
return fromBy(kind, value, false)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A string that reads as a selector, kept in a constant or field to pass to locator() later: "#login",
|
|
213
|
+
* "[data-test='username']", ".cart_item", "input[name=q]", "//div[2]", "xpath=…", "text=…". Not URLs or prose.
|
|
214
|
+
*/
|
|
215
|
+
export function selectorLike(s: string): boolean {
|
|
216
|
+
const t = s.trim()
|
|
217
|
+
if (!t || t.length > 200 || /:\/\//.test(t) || /\s{2,}/.test(t)) return false
|
|
218
|
+
if (/^(xpath=|css=|text=|id=|data-[\w-]+=|\/\/|\(\/\/|\.\/\/)/.test(t)) return true
|
|
219
|
+
if (/\.(json|js|ts|html?|png|jpe?g|csv|xml|ya?ml|txt|md|cs|java|py)$/i.test(t)) return false
|
|
220
|
+
// CSS: every part between combinators is a simple selector, and at least one has an id, class, attribute, or pseudo.
|
|
221
|
+
const parts = t.split(/\s*[>+~]\s*|\s+/).filter(Boolean)
|
|
222
|
+
const simple = /^([a-z][\w-]*|\*)?((#|\.)[\w-]+|\[[^\]]+\]|:{1,2}[\w-]+(\([^)]*\))?)*$/
|
|
223
|
+
return parts.every((p) => simple.test(p)) && parts.some((p) => /[#.[:]/.test(p))
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** A selector string's locator (Playwright selector syntax, which includes CSS and XPath). */
|
|
227
|
+
export function fromSelectorString(raw: string): LocatorRef {
|
|
228
|
+
return fromSelector(raw, false)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const BY_QUERY = /^(?:get|find|query)(?:all)?by(role|text|labeltext|label|placeholdertext|placeholder|testid|alttext|title|displayvalue)$/
|
|
232
|
+
|
|
233
|
+
/** Callee names that can build a locator, lower case without underscores. */
|
|
234
|
+
export const LOCATOR_CALLEES = new Set([
|
|
235
|
+
"locator",
|
|
236
|
+
"$",
|
|
237
|
+
"$$",
|
|
238
|
+
"get",
|
|
239
|
+
"contains",
|
|
240
|
+
"findelement",
|
|
241
|
+
"findelements",
|
|
242
|
+
"queryselector",
|
|
243
|
+
"queryselectorall",
|
|
244
|
+
"waitforselector",
|
|
245
|
+
])
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The locator a call builds, from its callee, the receiver it is called on, and its argument text (without the
|
|
249
|
+
* parentheses); null when the call builds none.
|
|
250
|
+
*/
|
|
251
|
+
export function locatorFromCall(callee: string, receiver: string, argText: string): LocatorRef | null {
|
|
252
|
+
const name = callee.replace(/_/g, "").toLowerCase()
|
|
253
|
+
const args = splitArgs(argText)
|
|
254
|
+
// Selenium By.id("x"), By.Id("x"), By.cssSelector("x").
|
|
255
|
+
if (/^By$/i.test(receiver.split(".").pop() || "")) {
|
|
256
|
+
const arg = textArg(args[0])
|
|
257
|
+
return arg && !arg.pattern ? fromBy(name, arg.value, Boolean(arg.param)) : null
|
|
258
|
+
}
|
|
259
|
+
// Python Selenium: find_element(By.ID, "x").
|
|
260
|
+
if ((name === "findelement" || name === "findelements") && /^By\.\w+$/.test(args[0] || "")) {
|
|
261
|
+
const arg = textArg(args[1])
|
|
262
|
+
return arg && !arg.pattern ? fromBy(args[0].slice(3), arg.value, Boolean(arg.param)) : null
|
|
263
|
+
}
|
|
264
|
+
const q = BY_QUERY.exec(name)
|
|
265
|
+
if (q) {
|
|
266
|
+
const kind = q[1]
|
|
267
|
+
if (kind === "role") {
|
|
268
|
+
const role = roleOf(args[0])
|
|
269
|
+
if (!role && !/^[A-Za-z_][\w.]*$/.test((args[0] || "").trim())) return null
|
|
270
|
+
const opt = nameOption(args)
|
|
271
|
+
return { kind: "role", role: role || "*", value: opt?.value || "", ...(opt?.pattern ? { pattern: true } : {}), ...(opt?.param || !role ? { param: true } : {}), quality: "semantic" }
|
|
272
|
+
}
|
|
273
|
+
const arg = textArg(args[0])
|
|
274
|
+
if (!arg) return null
|
|
275
|
+
const map: Record<string, LocatorKind> = { text: "text", labeltext: "label", label: "label", placeholdertext: "placeholder", placeholder: "placeholder", testid: "testid", alttext: "alt", title: "title", displayvalue: "text" }
|
|
276
|
+
return { kind: map[kind], value: arg.value, ...(arg.pattern ? { pattern: true } : {}), ...(arg.param ? { param: true } : {}), quality: "semantic" }
|
|
277
|
+
}
|
|
278
|
+
if (!LOCATOR_CALLEES.has(name)) return null
|
|
279
|
+
const arg = textArg(args[0])
|
|
280
|
+
if (!arg || arg.pattern) return null
|
|
281
|
+
if (name === "contains") return receiver === "cy" || receiver.endsWith(".cy") ? { kind: "text", value: arg.value, ...(arg.param ? { param: true } : {}), quality: "semantic" } : null
|
|
282
|
+
if (name === "get" && receiver !== "cy") return null
|
|
283
|
+
if (arg.param && !arg.value.replace(/\u0000/g, "")) return null
|
|
284
|
+
if (name === "$" || name === "$$") return fromWdio(arg.value, Boolean(arg.param))
|
|
285
|
+
return fromSelector(arg.value, Boolean(arg.param))
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The locator a Playwright expression like the handoff's builds: page.getByRole("button", { name: "Go" }), page.locator("#q"). */
|
|
289
|
+
export function locatorFromExpression(expr: string): LocatorRef | null {
|
|
290
|
+
// The last locator-building call in a chain wins: page.locator("#a").getByRole(…) is the role inside #a.
|
|
291
|
+
const calls = [...expr.matchAll(/(?:([\w$.]+)\.)?([\w$]+)\s*\(/g)]
|
|
292
|
+
for (let i = calls.length - 1; i >= 0; i--) {
|
|
293
|
+
const c = calls[i]
|
|
294
|
+
const open = (c.index ?? 0) + c[0].length
|
|
295
|
+
let depth = 1
|
|
296
|
+
let j = open
|
|
297
|
+
let quote = ""
|
|
298
|
+
for (; j < expr.length && depth; j++) {
|
|
299
|
+
const ch = expr[j]
|
|
300
|
+
if (quote) {
|
|
301
|
+
if (ch === "\\") j++
|
|
302
|
+
else if (ch === quote) quote = ""
|
|
303
|
+
} else if (ch === '"' || ch === "'" || ch === "`") quote = ch
|
|
304
|
+
else if (ch === "(") depth++
|
|
305
|
+
else if (ch === ")") depth--
|
|
306
|
+
}
|
|
307
|
+
const ref = locatorFromCall(c[2], c[1] || "", expr.slice(open, j - 1))
|
|
308
|
+
if (ref) return ref
|
|
309
|
+
}
|
|
310
|
+
return null
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Page memory's locator types (test_id, id, role "button|Name", label, …) as LocatorRefs. */
|
|
314
|
+
export function fromMemory(type: string | undefined, value: string | undefined): LocatorRef | null {
|
|
315
|
+
if (!type || !value) return null
|
|
316
|
+
switch (type) {
|
|
317
|
+
case "role": {
|
|
318
|
+
const [role, ...name] = value.split("|")
|
|
319
|
+
return { kind: "role", role: role.toLowerCase(), value: name.join("|"), quality: "semantic" }
|
|
320
|
+
}
|
|
321
|
+
case "test_id":
|
|
322
|
+
return { kind: "testid", value, quality: "semantic" }
|
|
323
|
+
case "id":
|
|
324
|
+
case "name":
|
|
325
|
+
case "label":
|
|
326
|
+
case "placeholder":
|
|
327
|
+
case "text":
|
|
328
|
+
case "alt":
|
|
329
|
+
case "title":
|
|
330
|
+
return { kind: type, value, quality: SEMANTIC.has(type) ? "semantic" : "stable" }
|
|
331
|
+
case "css":
|
|
332
|
+
return fromCss(value)
|
|
333
|
+
case "xpath":
|
|
334
|
+
return { kind: "xpath", value, quality: "brittle" }
|
|
335
|
+
default:
|
|
336
|
+
return null
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const norm = (s: string) => s.replace(/[\s ]+/g, " ").trim().toLowerCase()
|
|
341
|
+
|
|
342
|
+
function patternMatches(pattern: string, value: string): boolean {
|
|
343
|
+
try {
|
|
344
|
+
return new RegExp(pattern.replace(/^\(\?i\)/, ""), "i").test(value)
|
|
345
|
+
} catch {
|
|
346
|
+
return false
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** A template value with a parameter hole (\u0000) against a concrete value. */
|
|
351
|
+
function templateMatches(template: string, value: string): boolean {
|
|
352
|
+
const parts = template.split("\u0000").map((p) => p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
|
353
|
+
return new RegExp(`^${parts.join(".+")}$`, "i").test(value.trim())
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* How a locator in code finds a concrete locator of an element: "exact" (the same value), "pattern" (its regular
|
|
358
|
+
* expression or template matches), "param" (the same role or kind, with the value left to an argument), or null.
|
|
359
|
+
*/
|
|
360
|
+
export function matchLocator(code: LocatorRef, element: LocatorRef): "exact" | "pattern" | "param" | null {
|
|
361
|
+
if (code.kind !== element.kind) {
|
|
362
|
+
// text= and getByText also find a role's accessible name when it is the element's text.
|
|
363
|
+
if (!(code.kind === "text" && element.kind === "role")) return null
|
|
364
|
+
}
|
|
365
|
+
if (code.kind === "role" && element.kind === "role" && code.role !== "*" && element.role !== "*" && code.role !== element.role) return null
|
|
366
|
+
const value = element.value
|
|
367
|
+
if (code.pattern) return value && patternMatches(code.value, value) ? "pattern" : null
|
|
368
|
+
if (code.param) {
|
|
369
|
+
if (code.value.includes("\u0000")) return value && templateMatches(code.value, value) ? "pattern" : null
|
|
370
|
+
// A role with its name from an argument still names a role; any other kind with no literal says too little.
|
|
371
|
+
return code.kind === "role" && code.role !== "*" ? "param" : null
|
|
372
|
+
}
|
|
373
|
+
if (code.kind === "css" && code.value !== value) return compoundWithin(code.value, value) ? "exact" : null
|
|
374
|
+
if (code.kind === "css" || code.kind === "xpath" || code.kind === "id" || code.kind === "testid" || code.kind === "name") return code.value === value ? "exact" : null
|
|
375
|
+
return norm(code.value) === norm(value) ? "exact" : null
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** tag.class selectors: ".cart_link" is within "a.cart_link.big" (its tag agrees and its classes are among the other's). */
|
|
379
|
+
function compoundWithin(code: string, element: string): boolean {
|
|
380
|
+
const parse = (css: string) => {
|
|
381
|
+
const m = /^([a-z][\w-]*)?((?:\.[\w-]+)+)$/i.exec(css.trim())
|
|
382
|
+
return m ? { tag: (m[1] || "").toLowerCase(), classes: m[2].slice(1).split(".") } : null
|
|
383
|
+
}
|
|
384
|
+
const c = parse(code)
|
|
385
|
+
const e = parse(element)
|
|
386
|
+
if (!c || !e) return false
|
|
387
|
+
return (!c.tag || c.tag === e.tag) && c.classes.every((x) => e.classes.includes(x))
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** One line for a locator: role button "Sign in", id #login-button, xpath //tr[…]. */
|
|
391
|
+
export function describeLocator(l: LocatorRef): string {
|
|
392
|
+
const v = l.pattern ? `/${l.value}/` : l.param && !l.value ? "(from a parameter)" : JSON.stringify(l.value.replace(/\u0000/g, "{…}"))
|
|
393
|
+
if (l.kind === "role") return `role ${l.role === "*" ? "any" : l.role}${l.value || l.param || l.pattern ? ` ${v}` : ""}`
|
|
394
|
+
if (l.kind === "id") return `id #${l.value}`
|
|
395
|
+
// An attribute selector stays one: getByTestId finds it only when the repository sets that attribute as its test id.
|
|
396
|
+
if (l.kind === "testid" && l.attr) return `testid [${l.attr}=${v}]`
|
|
397
|
+
return `${l.kind} ${v}`
|
|
398
|
+
}
|