Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f31bd74d0d | ||
|
|
161c28ac42 | ||
|
|
1f7ee4c525 |
Generated
+74
@@ -9,6 +9,7 @@ dependencies = [
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -107,6 +108,79 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm",
|
||||
"windows_aarch64_msvc",
|
||||
"windows_i686_gnu",
|
||||
"windows_i686_gnullvm",
|
||||
"windows_i686_msvc",
|
||||
"windows_x86_64_gnu",
|
||||
"windows_x86_64_gnullvm",
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.12"
|
||||
|
||||
@@ -12,6 +12,9 @@ serde_json = "1.0"
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.52", features = ["Win32_System_Threading", "Win32_Foundation"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
|
||||
@@ -6,14 +6,93 @@ mod output;
|
||||
|
||||
use serde_json::json;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::process::exit;
|
||||
|
||||
#[cfg(unix)]
|
||||
use libc;
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
#[cfg(windows)]
|
||||
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION};
|
||||
|
||||
use commands::{gen_id, parse_command, ParseError};
|
||||
use connection::{ensure_daemon, send_command};
|
||||
use flags::{clean_args, parse_flags};
|
||||
use install::run_install;
|
||||
use output::{print_help, print_response};
|
||||
|
||||
fn run_session(args: &[String], session: &str, json_mode: bool) {
|
||||
let subcommand = args.get(1).map(|s| s.as_str());
|
||||
|
||||
match subcommand {
|
||||
Some("list") => {
|
||||
let tmp = env::temp_dir();
|
||||
let mut sessions: Vec<String> = Vec::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&tmp) {
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
// Look for socket files (Unix) or pid files
|
||||
if name.starts_with("agent-browser-") && name.ends_with(".pid") {
|
||||
let session_name = name
|
||||
.strip_prefix("agent-browser-")
|
||||
.and_then(|s| s.strip_suffix(".pid"))
|
||||
.unwrap_or("");
|
||||
if !session_name.is_empty() {
|
||||
// Check if session is actually running
|
||||
let pid_path = tmp.join(&name);
|
||||
if let Ok(pid_str) = fs::read_to_string(&pid_path) {
|
||||
if let Ok(pid) = pid_str.trim().parse::<u32>() {
|
||||
#[cfg(unix)]
|
||||
let running = unsafe { libc::kill(pid as i32, 0) == 0 };
|
||||
#[cfg(windows)]
|
||||
let running = unsafe {
|
||||
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
|
||||
if handle != 0 {
|
||||
CloseHandle(handle);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if running {
|
||||
sessions.push(session_name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if json_mode {
|
||||
println!(
|
||||
r#"{{"success":true,"data":{{"sessions":{}}}}}"#,
|
||||
serde_json::to_string(&sessions).unwrap_or_default()
|
||||
);
|
||||
} else if sessions.is_empty() {
|
||||
println!("No active sessions");
|
||||
} else {
|
||||
println!("Active sessions:");
|
||||
for s in &sessions {
|
||||
let marker = if s == session { "→" } else { " " };
|
||||
println!("{} {}", marker, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
None | Some(_) => {
|
||||
// Just show current session
|
||||
if json_mode {
|
||||
println!(r#"{{"success":true,"data":{{"session":"{}"}}}}"#, session);
|
||||
} else {
|
||||
println!("{}", session);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().skip(1).collect();
|
||||
let flags = parse_flags(&args);
|
||||
@@ -31,6 +110,12 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle session separately (doesn't need daemon)
|
||||
if clean.get(0).map(|s| s.as_str()) == Some("session") {
|
||||
run_session(&clean, &flags.session, flags.json);
|
||||
return;
|
||||
}
|
||||
|
||||
let cmd = match parse_command(&clean, &flags) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
|
||||
@@ -215,6 +215,10 @@ Debug:
|
||||
errors [--clear] View page errors
|
||||
highlight <sel> Highlight element
|
||||
|
||||
Sessions:
|
||||
session Show current session name
|
||||
session list List active sessions
|
||||
|
||||
Setup:
|
||||
install Install browser binaries
|
||||
install --with-deps Also install system dependencies (Linux)
|
||||
|
||||
+19
-100
@@ -111,47 +111,6 @@ interface SnapshotData {
|
||||
refs?: Record<string, { role: string; name?: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Playwright errors to AI-friendly messages
|
||||
*/
|
||||
function toAIFriendlyError(error: unknown, selector: string): Error {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Handle strict mode violation (multiple elements match)
|
||||
if (message.includes('strict mode violation')) {
|
||||
// Extract count if available
|
||||
const countMatch = message.match(/resolved to (\d+) elements/);
|
||||
const count = countMatch ? countMatch[1] : 'multiple';
|
||||
|
||||
return new Error(
|
||||
`Selector "${selector}" matched ${count} elements. ` +
|
||||
`Run 'snapshot' to get updated refs, or use a more specific CSS selector.`
|
||||
);
|
||||
}
|
||||
|
||||
// Handle element not found
|
||||
if (
|
||||
message.includes('waiting for') &&
|
||||
(message.includes('to be visible') || message.includes('Timeout'))
|
||||
) {
|
||||
return new Error(
|
||||
`Element "${selector}" not found or not visible. ` +
|
||||
`Run 'snapshot' to see current page elements.`
|
||||
);
|
||||
}
|
||||
|
||||
// Handle element not interactable
|
||||
if (message.includes('intercepts pointer events') || message.includes('not visible')) {
|
||||
return new Error(
|
||||
`Element "${selector}" is not interactable (may be hidden or covered). ` +
|
||||
`Try scrolling it into view or check if a modal/overlay is blocking it.`
|
||||
);
|
||||
}
|
||||
|
||||
// Return original error for unknown cases
|
||||
return error instanceof Error ? error : new Error(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command and return a response
|
||||
*/
|
||||
@@ -425,15 +384,11 @@ async function handleClick(command: ClickCommand, browser: BrowserManager): Prom
|
||||
// Support both refs (@e1) and regular selectors
|
||||
const locator = browser.getLocator(command.selector);
|
||||
|
||||
try {
|
||||
await locator.click({
|
||||
button: command.button,
|
||||
clickCount: command.clickCount,
|
||||
delay: command.delay,
|
||||
});
|
||||
} catch (error) {
|
||||
throw toAIFriendlyError(error, command.selector);
|
||||
}
|
||||
await locator.click({
|
||||
button: command.button,
|
||||
clickCount: command.clickCount,
|
||||
delay: command.delay,
|
||||
});
|
||||
|
||||
return successResponse(command.id, { clicked: true });
|
||||
}
|
||||
@@ -441,18 +396,14 @@ async function handleClick(command: ClickCommand, browser: BrowserManager): Prom
|
||||
async function handleType(command: TypeCommand, browser: BrowserManager): Promise<Response> {
|
||||
const locator = browser.getLocator(command.selector);
|
||||
|
||||
try {
|
||||
if (command.clear) {
|
||||
await locator.fill('');
|
||||
}
|
||||
|
||||
await locator.pressSequentially(command.text, {
|
||||
delay: command.delay,
|
||||
});
|
||||
} catch (error) {
|
||||
throw toAIFriendlyError(error, command.selector);
|
||||
if (command.clear) {
|
||||
await locator.fill('');
|
||||
}
|
||||
|
||||
await locator.pressSequentially(command.text, {
|
||||
delay: command.delay,
|
||||
});
|
||||
|
||||
return successResponse(command.id, { typed: true });
|
||||
}
|
||||
|
||||
@@ -605,22 +556,14 @@ async function handleSelect(command: SelectCommand, browser: BrowserManager): Pr
|
||||
const locator = browser.getLocator(command.selector);
|
||||
const values = Array.isArray(command.values) ? command.values : [command.values];
|
||||
|
||||
try {
|
||||
await locator.selectOption(values);
|
||||
} catch (error) {
|
||||
throw toAIFriendlyError(error, command.selector);
|
||||
}
|
||||
await locator.selectOption(values);
|
||||
|
||||
return successResponse(command.id, { selected: values });
|
||||
}
|
||||
|
||||
async function handleHover(command: HoverCommand, browser: BrowserManager): Promise<Response> {
|
||||
const locator = browser.getLocator(command.selector);
|
||||
try {
|
||||
await locator.hover();
|
||||
} catch (error) {
|
||||
throw toAIFriendlyError(error, command.selector);
|
||||
}
|
||||
await locator.hover();
|
||||
|
||||
return successResponse(command.id, { hovered: true });
|
||||
}
|
||||
@@ -700,42 +643,26 @@ async function handleWindowNew(
|
||||
|
||||
async function handleFill(command: FillCommand, browser: BrowserManager): Promise<Response> {
|
||||
const locator = browser.getLocator(command.selector);
|
||||
try {
|
||||
await locator.fill(command.value);
|
||||
} catch (error) {
|
||||
throw toAIFriendlyError(error, command.selector);
|
||||
}
|
||||
await locator.fill(command.value);
|
||||
return successResponse(command.id, { filled: true });
|
||||
}
|
||||
|
||||
async function handleCheck(command: CheckCommand, browser: BrowserManager): Promise<Response> {
|
||||
const locator = browser.getLocator(command.selector);
|
||||
try {
|
||||
await locator.check();
|
||||
} catch (error) {
|
||||
throw toAIFriendlyError(error, command.selector);
|
||||
}
|
||||
await locator.check();
|
||||
return successResponse(command.id, { checked: true });
|
||||
}
|
||||
|
||||
async function handleUncheck(command: UncheckCommand, browser: BrowserManager): Promise<Response> {
|
||||
const locator = browser.getLocator(command.selector);
|
||||
try {
|
||||
await locator.uncheck();
|
||||
} catch (error) {
|
||||
throw toAIFriendlyError(error, command.selector);
|
||||
}
|
||||
await locator.uncheck();
|
||||
return successResponse(command.id, { unchecked: true });
|
||||
}
|
||||
|
||||
async function handleUpload(command: UploadCommand, browser: BrowserManager): Promise<Response> {
|
||||
const locator = browser.getLocator(command.selector);
|
||||
const files = Array.isArray(command.files) ? command.files : [command.files];
|
||||
try {
|
||||
await locator.setInputFiles(files);
|
||||
} catch (error) {
|
||||
throw toAIFriendlyError(error, command.selector);
|
||||
}
|
||||
await locator.setInputFiles(files);
|
||||
return successResponse(command.id, { uploaded: files });
|
||||
}
|
||||
|
||||
@@ -744,21 +671,13 @@ async function handleDoubleClick(
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
const locator = browser.getLocator(command.selector);
|
||||
try {
|
||||
await locator.dblclick();
|
||||
} catch (error) {
|
||||
throw toAIFriendlyError(error, command.selector);
|
||||
}
|
||||
await locator.dblclick();
|
||||
return successResponse(command.id, { clicked: true });
|
||||
}
|
||||
|
||||
async function handleFocus(command: FocusCommand, browser: BrowserManager): Promise<Response> {
|
||||
const locator = browser.getLocator(command.selector);
|
||||
try {
|
||||
await locator.focus();
|
||||
} catch (error) {
|
||||
throw toAIFriendlyError(error, command.selector);
|
||||
}
|
||||
await locator.focus();
|
||||
return successResponse(command.id, { focused: true });
|
||||
}
|
||||
|
||||
|
||||
+3
-11
@@ -95,20 +95,12 @@ export class BrowserManager {
|
||||
|
||||
const page = this.getPage();
|
||||
|
||||
// Build locator with exact: true to avoid substring matches
|
||||
let locator: Locator;
|
||||
// Parse the selector and create locator
|
||||
if (refData.name) {
|
||||
locator = page.getByRole(refData.role as any, { name: refData.name, exact: true });
|
||||
return page.getByRole(refData.role as any, { name: refData.name });
|
||||
} else {
|
||||
locator = page.getByRole(refData.role as any);
|
||||
return page.getByRole(refData.role as any);
|
||||
}
|
||||
|
||||
// If an nth index is stored (for disambiguation), use it
|
||||
if (refData.nth !== undefined) {
|
||||
locator = locator.nth(refData.nth);
|
||||
}
|
||||
|
||||
return locator;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-91
@@ -24,8 +24,6 @@ export interface RefMap {
|
||||
selector: string;
|
||||
role: string;
|
||||
name?: string;
|
||||
/** Index for disambiguation when multiple elements have same role+name */
|
||||
nth?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,7 +129,7 @@ const STRUCTURAL_ROLES = new Set([
|
||||
function buildSelector(role: string, name?: string): string {
|
||||
if (name) {
|
||||
const escapedName = name.replace(/"/g, '\\"');
|
||||
return `getByRole('${role}', { name: "${escapedName}", exact: true })`;
|
||||
return `getByRole('${role}', { name: "${escapedName}" })`;
|
||||
}
|
||||
return `getByRole('${role}')`;
|
||||
}
|
||||
@@ -163,60 +161,12 @@ export async function getEnhancedSnapshot(
|
||||
return { tree: enhancedTree, refs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Track role+name combinations to detect duplicates
|
||||
*/
|
||||
interface RoleNameTracker {
|
||||
counts: Map<string, number>;
|
||||
/** Maps role+name key to array of ref IDs that use it */
|
||||
refsByKey: Map<string, string[]>;
|
||||
getKey(role: string, name?: string): string;
|
||||
getNextIndex(role: string, name?: string): number;
|
||||
trackRef(role: string, name: string | undefined, ref: string): void;
|
||||
/** Get all role+name keys that have duplicates */
|
||||
getDuplicateKeys(): Set<string>;
|
||||
}
|
||||
|
||||
function createRoleNameTracker(): RoleNameTracker {
|
||||
const counts = new Map<string, number>();
|
||||
const refsByKey = new Map<string, string[]>();
|
||||
return {
|
||||
counts,
|
||||
refsByKey,
|
||||
getKey(role: string, name?: string): string {
|
||||
return `${role}:${name ?? ''}`;
|
||||
},
|
||||
getNextIndex(role: string, name?: string): number {
|
||||
const key = this.getKey(role, name);
|
||||
const current = counts.get(key) ?? 0;
|
||||
counts.set(key, current + 1);
|
||||
return current;
|
||||
},
|
||||
trackRef(role: string, name: string | undefined, ref: string): void {
|
||||
const key = this.getKey(role, name);
|
||||
const refs = refsByKey.get(key) ?? [];
|
||||
refs.push(ref);
|
||||
refsByKey.set(key, refs);
|
||||
},
|
||||
getDuplicateKeys(): Set<string> {
|
||||
const duplicates = new Set<string>();
|
||||
for (const [key, refs] of refsByKey) {
|
||||
if (refs.length > 1) {
|
||||
duplicates.add(key);
|
||||
}
|
||||
}
|
||||
return duplicates;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Process ARIA snapshot: add refs and apply filters
|
||||
*/
|
||||
function processAriaTree(ariaTree: string, refs: RefMap, options: SnapshotOptions): string {
|
||||
const lines = ariaTree.split('\n');
|
||||
const result: string[] = [];
|
||||
const tracker = createRoleNameTracker();
|
||||
|
||||
// For interactive-only mode, we collect just interactive elements
|
||||
if (options.interactive) {
|
||||
@@ -229,43 +179,31 @@ function processAriaTree(ariaTree: string, refs: RefMap, options: SnapshotOption
|
||||
|
||||
if (INTERACTIVE_ROLES.has(roleLower)) {
|
||||
const ref = nextRef();
|
||||
const nth = tracker.getNextIndex(roleLower, name);
|
||||
tracker.trackRef(roleLower, name, ref);
|
||||
refs[ref] = {
|
||||
selector: buildSelector(roleLower, name),
|
||||
role: roleLower,
|
||||
name,
|
||||
nth, // Always store nth, we'll use it for duplicates
|
||||
};
|
||||
|
||||
let enhanced = `- ${role}`;
|
||||
if (name) enhanced += ` "${name}"`;
|
||||
enhanced += ` [ref=${ref}]`;
|
||||
// Only show nth in output if it's > 0 (for readability)
|
||||
if (nth > 0) enhanced += ` [nth=${nth}]`;
|
||||
if (suffix && suffix.includes('[')) enhanced += suffix;
|
||||
|
||||
result.push(enhanced);
|
||||
}
|
||||
}
|
||||
|
||||
// Post-process: remove nth from refs that don't have duplicates
|
||||
removeNthFromNonDuplicates(refs, tracker);
|
||||
|
||||
return result.join('\n') || '(no interactive elements)';
|
||||
}
|
||||
|
||||
// Normal processing with depth/compact filters
|
||||
for (const line of lines) {
|
||||
const processed = processLine(line, refs, options, tracker);
|
||||
const processed = processLine(line, refs, options);
|
||||
if (processed !== null) {
|
||||
result.push(processed);
|
||||
}
|
||||
}
|
||||
|
||||
// Post-process: remove nth from refs that don't have duplicates
|
||||
removeNthFromNonDuplicates(refs, tracker);
|
||||
|
||||
// If compact mode, remove empty structural elements
|
||||
if (options.compact) {
|
||||
return compactTree(result.join('\n'));
|
||||
@@ -274,22 +212,6 @@ function processAriaTree(ariaTree: string, refs: RefMap, options: SnapshotOption
|
||||
return result.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove nth from refs that ended up not having duplicates
|
||||
* This keeps single-element locators simple (no unnecessary .nth(0))
|
||||
*/
|
||||
function removeNthFromNonDuplicates(refs: RefMap, tracker: RoleNameTracker): void {
|
||||
const duplicateKeys = tracker.getDuplicateKeys();
|
||||
|
||||
for (const [ref, data] of Object.entries(refs)) {
|
||||
const key = tracker.getKey(data.role, data.name);
|
||||
if (!duplicateKeys.has(key)) {
|
||||
// Not a duplicate, remove nth to keep locator simple
|
||||
delete refs[ref].nth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get indentation level (number of spaces / 2)
|
||||
*/
|
||||
@@ -301,12 +223,7 @@ function getIndentLevel(line: string): number {
|
||||
/**
|
||||
* Process a single line: add ref if needed, filter if requested
|
||||
*/
|
||||
function processLine(
|
||||
line: string,
|
||||
refs: RefMap,
|
||||
options: SnapshotOptions,
|
||||
tracker: RoleNameTracker
|
||||
): string | null {
|
||||
function processLine(line: string, refs: RefMap, options: SnapshotOptions): string | null {
|
||||
const depth = getIndentLevel(line);
|
||||
|
||||
// Check max depth
|
||||
@@ -356,22 +273,17 @@ function processLine(
|
||||
|
||||
if (shouldHaveRef) {
|
||||
const ref = nextRef();
|
||||
const nth = tracker.getNextIndex(roleLower, name);
|
||||
tracker.trackRef(roleLower, name, ref);
|
||||
|
||||
refs[ref] = {
|
||||
selector: buildSelector(roleLower, name),
|
||||
role: roleLower,
|
||||
name,
|
||||
nth, // Always store nth, we'll clean up non-duplicates later
|
||||
};
|
||||
|
||||
// Build enhanced line with ref
|
||||
let enhanced = `${prefix}${role}`;
|
||||
if (name) enhanced += ` "${name}"`;
|
||||
enhanced += ` [ref=${ref}]`;
|
||||
// Only show nth in output if it's > 0 (for readability)
|
||||
if (nth > 0) enhanced += ` [nth=${nth}]`;
|
||||
if (suffix) enhanced += suffix;
|
||||
|
||||
return enhanced;
|
||||
|
||||
Reference in New Issue
Block a user