@capagents/forgeqe 1.1.3 → 1.1.5

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.
Files changed (60) hide show
  1. package/HOW_TO_USE.md +46 -4
  2. package/README.md +7 -5
  3. package/browserstack.example.yaml +15 -3
  4. package/docs/user-guide.html +62 -10
  5. package/forgeqe.example.yaml +25 -18
  6. package/package.json +2 -2
  7. package/prompts/README.md +1 -0
  8. package/prompts/brain.md +7 -1
  9. package/prompts/defect-rca.md +43 -0
  10. package/prompts/test-agent.md +12 -2
  11. package/prompts/test-reviewer.md +5 -2
  12. package/prompts/test-rules.md +8 -4
  13. package/py/forgeqe_engine/browser.py +79 -12
  14. package/py/forgeqe_engine/service.py +12 -0
  15. package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
  16. package/skills/playwright-typescript/SKILL.md +2 -1
  17. package/src/bi.ts +382 -302
  18. package/src/brain/index.ts +1 -1
  19. package/src/capabilities/guide.ts +3 -3
  20. package/src/capabilities/registry.ts +34 -6
  21. package/src/cases/bugs.ts +209 -22
  22. package/src/cases/defectHistory.ts +193 -0
  23. package/src/cases/defects.ts +255 -0
  24. package/src/cases/github.ts +162 -0
  25. package/src/cases/goal.ts +1 -1
  26. package/src/cases/index.ts +23 -4
  27. package/src/cases/predict.ts +372 -0
  28. package/src/cases/rca.ts +258 -0
  29. package/src/cases/readiness.ts +38 -1
  30. package/src/cases/repro.ts +202 -0
  31. package/src/cases/types.ts +8 -1
  32. package/src/cli/help.ts +16 -7
  33. package/src/cli/index.ts +56 -15
  34. package/src/code/graph/graph.ts +511 -0
  35. package/src/code/graph/locators.ts +398 -0
  36. package/src/code/graph/parse.ts +439 -0
  37. package/src/code/graph/tools.ts +113 -0
  38. package/src/code/handoff.ts +93 -18
  39. package/src/code/loop.ts +30 -5
  40. package/src/code/profile.ts +14 -3
  41. package/src/code/replay.ts +1 -1
  42. package/src/code/review.ts +22 -0
  43. package/src/config.ts +160 -56
  44. package/src/mcp/attachtools.ts +223 -0
  45. package/src/mcp/commands.ts +1 -1
  46. package/src/mcp/defecttools.ts +106 -0
  47. package/src/mcp/devicetools.ts +125 -0
  48. package/src/mcp/external.ts +42 -1
  49. package/src/mcp/tools.ts +92 -9
  50. package/src/monitor/run.ts +1 -1
  51. package/src/report/data.ts +14 -0
  52. package/src/report/html.ts +15 -3
  53. package/src/report/template.html +44 -43
  54. package/src/run.ts +9 -2
  55. package/src/suite/report.ts +44 -11
  56. package/src/suite/run.ts +163 -16
  57. package/src/tui/App.tsx +9 -3
  58. package/src/usage.ts +1 -1
  59. package/testmu.example.yaml +14 -4
  60. package/src/cache.ts +0 -92
@@ -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
+ }