import type { ElementContext } from "@ltt/shared";
const TEST_ATTRS = ["data-testid", "data-test", "data-qa", "data-cy"];
/**
* Builds a CSS selector that (when possible) matches exactly one element.
* Preference: test attributes → #id → aria-label → nth-of-type path (depth ≤ 6).
*/
export function buildUniqueSelector(el: Element): { selector: string; unique: boolean } {
const candidates: string[] = [];
for (const attr of TEST_ATTRS) {
const value = el.getAttribute(attr);
if (value) candidates.push(`[data-testid="${cssEscape(value)}"]`.replace("data-testid", attr));
}
if (el.id) candidates.push(`#${cssEscape(el.id)}`);
const aria = el.getAttribute("aria-label");
if (aria) candidates.push(`${el.tagName.toLowerCase()}[aria-label="${cssEscape(aria)}"]`);
for (const candidate of candidates) {
if (countMatches(candidate) === 1) return { selector: candidate, unique: true };
}
// nth-of-type path, capped at depth 6
const parts: string[] = [];
let node: Element | null = el;
let depth = 0;
while (node && node.nodeType === 1 && depth < 6) {
const tag = node.tagName.toLowerCase();
if (tag === "html" || tag === "body") {
parts.unshift(tag);
break;
}
let index = 1;
let sibling = node.previousElementSibling;
while (sibling) {
if (sibling.tagName === node!.tagName) index++;
sibling = sibling.previousElementSibling;
}
const siblingsSameTag = node.parentElement
? Array.from(node.parentElement.children).filter((c) => c.tagName === node!.tagName).length
: 1;
parts.unshift(siblingsSameTag > 1 ? `${tag}:nth-of-type(${index})` : tag);
// early exit if the path so far is unique
const candidate = parts.join(" > ");
if (parts.length >= 2 && countMatches(candidate) === 1) {
return { selector: candidate, unique: true };
}
node = node.parentElement;
depth++;
}
const selector = parts.join(" > ");
return { selector, unique: countMatches(selector) === 1 };
}
function countMatches(selector: string): number {
try {
return document.querySelectorAll(selector).length;
} catch {
return 0;
}
}
function cssEscape(value: string): string {
if (typeof CSS !== "undefined" && CSS.escape) return CSS.escape(value);
return value.replace(/["\\\]]/g, "\\$&");
}
/** Extracts structured context about an element for the report payload. */
export function buildElementContext(el: Element): ElementContext {
const rect = el.getBoundingClientRect();
const { selector, unique } = buildUniqueSelector(el);
const testAttributes: Record<string, string> = {};
for (const attr of TEST_ATTRS) {
const value = el.getAttribute(attr);
if (value) testAttributes[attr] = value;
}
const classes = Array.from(el.classList).slice(0, 10);
const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
return {
selector,
unique_selector: unique ? selector : null,
tag: el.tagName.toLowerCase(),
id: el.id || null,
classes,
text_snippet: text.slice(0, 120) || null,
aria_label: el.getAttribute("aria-label"),
test_attributes: Object.keys(testAttributes).length ? testAttributes : null,
rect: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
screenshot_rel: null, // filled by the caller once the screenshot size is known
};
}
/**
* Fills screenshot_rel: element rect as fractions of the visible viewport,
* which matches captureVisibleTab output regardless of devicePixelRatio.
*/
export function withScreenshotRel(
context: ElementContext,
viewportWidth: number,
viewportHeight: number
): ElementContext {
const rect = context.rect;
if (!rect || !viewportWidth || !viewportHeight) return context;
return {
...context,
screenshot_rel: {
x: rect.x / viewportWidth,
y: rect.y / viewportHeight,
w: rect.w / viewportWidth,
h: rect.h / viewportHeight,
},
};
}
export interface BrowserEnvironment {
user_agent: string;
browser: string;
browser_version: string | null;
os: string | null;
viewport: { w: number; h: number };
dpr: number;
language: string;
captured_at: string;
}
export function collectEnvironment(): BrowserEnvironment {
const ua = navigator.userAgent;
let browser = "Unknown";
let version: string | null = null;
const firefox = /Firefox\/([\d.]+)/.exec(ua);
const chrome = /Chrome\/([\d.]+)/.exec(ua);
const safari = /Version\/([\d.]+).*Safari/.exec(ua);
if (firefox) {
browser = "Firefox";
version = firefox[1];
} else if (chrome) {
browser = "Chrome";
version = chrome[1];
} else if (safari) {
browser = "Safari";
version = safari[1];
}
let os: string | null = null;
if (/Windows/.test(ua)) os = "Windows";
else if (/Mac OS X/.test(ua)) os = "macOS";
else if (/Linux/.test(ua)) os = "Linux";
else if (/Android/.test(ua)) os = "Android";
else if (/iPhone|iPad/.test(ua)) os = "iOS";
return {
user_agent: ua,
browser,
browser_version: version,
os,
viewport: { w: window.innerWidth, h: window.innerHeight },
dpr: window.devicePixelRatio,
language: navigator.language,
captured_at: new Date().toISOString(),
};
}