mirror of https://github.com/garrytan/gstack.git
feat: browser ref staleness detection via async count() validation
resolveRef() now checks element count to detect stale refs after page mutations (e.g. SPA navigation). RefEntry stores role+name metadata for better diagnostics. 3 new snapshot tests for staleness detection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bb46ca6b21
commit
84f0ee62df
|
|
@ -18,6 +18,12 @@
|
||||||
import { chromium, type Browser, type BrowserContext, type Page, type Locator } from 'playwright';
|
import { chromium, type Browser, type BrowserContext, type Page, type Locator } from 'playwright';
|
||||||
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
|
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
|
||||||
|
|
||||||
|
export interface RefEntry {
|
||||||
|
locator: Locator;
|
||||||
|
role: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class BrowserManager {
|
export class BrowserManager {
|
||||||
private browser: Browser | null = null;
|
private browser: Browser | null = null;
|
||||||
private context: BrowserContext | null = null;
|
private context: BrowserContext | null = null;
|
||||||
|
|
@ -31,7 +37,7 @@ export class BrowserManager {
|
||||||
public serverPort: number = 0;
|
public serverPort: number = 0;
|
||||||
|
|
||||||
// ─── Ref Map (snapshot → @e1, @e2, @c1, @c2, ...) ────────
|
// ─── Ref Map (snapshot → @e1, @e2, @c1, @c2, ...) ────────
|
||||||
private refMap: Map<string, Locator> = new Map();
|
private refMap: Map<string, RefEntry> = new Map();
|
||||||
|
|
||||||
// ─── Snapshot Diffing ─────────────────────────────────────
|
// ─── Snapshot Diffing ─────────────────────────────────────
|
||||||
// NOT cleared on navigation — it's a text baseline for diffing
|
// NOT cleared on navigation — it's a text baseline for diffing
|
||||||
|
|
@ -169,7 +175,7 @@ export class BrowserManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Ref Map ──────────────────────────────────────────────
|
// ─── Ref Map ──────────────────────────────────────────────
|
||||||
setRefMap(refs: Map<string, Locator>) {
|
setRefMap(refs: Map<string, RefEntry>) {
|
||||||
this.refMap = refs;
|
this.refMap = refs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,16 +187,23 @@ export class BrowserManager {
|
||||||
* Resolve a selector that may be a @ref (e.g., "@e3", "@c1") or a CSS selector.
|
* Resolve a selector that may be a @ref (e.g., "@e3", "@c1") or a CSS selector.
|
||||||
* Returns { locator } for refs or { selector } for CSS selectors.
|
* Returns { locator } for refs or { selector } for CSS selectors.
|
||||||
*/
|
*/
|
||||||
resolveRef(selector: string): { locator: Locator } | { selector: string } {
|
async resolveRef(selector: string): Promise<{ locator: Locator } | { selector: string }> {
|
||||||
if (selector.startsWith('@e') || selector.startsWith('@c')) {
|
if (selector.startsWith('@e') || selector.startsWith('@c')) {
|
||||||
const ref = selector.slice(1); // "e3" or "c1"
|
const ref = selector.slice(1); // "e3" or "c1"
|
||||||
const locator = this.refMap.get(ref);
|
const entry = this.refMap.get(ref);
|
||||||
if (!locator) {
|
if (!entry) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Ref ${selector} not found. Page may have changed — run 'snapshot' to get fresh refs.`
|
`Ref ${selector} not found. Run 'snapshot' to get fresh refs.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { locator };
|
const count = await entry.locator.count();
|
||||||
|
if (count === 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Ref ${selector} (${entry.role} "${entry.name}") is stale — element no longer exists. ` +
|
||||||
|
`Run 'snapshot' for fresh refs.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { locator: entry.locator };
|
||||||
}
|
}
|
||||||
return { selector };
|
return { selector };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ export async function handleMetaCommand(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetSelector) {
|
if (targetSelector) {
|
||||||
const resolved = bm.resolveRef(targetSelector);
|
const resolved = await bm.resolveRef(targetSelector);
|
||||||
const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
|
const locator = 'locator' in resolved ? resolved.locator : page.locator(resolved.selector);
|
||||||
await locator.screenshot({ path: outputPath, timeout: 5000 });
|
await locator.screenshot({ path: outputPath, timeout: 5000 });
|
||||||
return `Screenshot saved (element): ${outputPath}`;
|
return `Screenshot saved (element): ${outputPath}`;
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ export async function handleReadCommand(
|
||||||
case 'html': {
|
case 'html': {
|
||||||
const selector = args[0];
|
const selector = args[0];
|
||||||
if (selector) {
|
if (selector) {
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = await bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
return await resolved.locator.innerHTML({ timeout: 5000 });
|
return await resolved.locator.innerHTML({ timeout: 5000 });
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +135,7 @@ export async function handleReadCommand(
|
||||||
case 'css': {
|
case 'css': {
|
||||||
const [selector, property] = args;
|
const [selector, property] = args;
|
||||||
if (!selector || !property) throw new Error('Usage: browse css <selector> <property>');
|
if (!selector || !property) throw new Error('Usage: browse css <selector> <property>');
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = await bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
const value = await resolved.locator.evaluate(
|
const value = await resolved.locator.evaluate(
|
||||||
(el, prop) => getComputedStyle(el).getPropertyValue(prop),
|
(el, prop) => getComputedStyle(el).getPropertyValue(prop),
|
||||||
|
|
@ -157,7 +157,7 @@ export async function handleReadCommand(
|
||||||
case 'attrs': {
|
case 'attrs': {
|
||||||
const selector = args[0];
|
const selector = args[0];
|
||||||
if (!selector) throw new Error('Usage: browse attrs <selector>');
|
if (!selector) throw new Error('Usage: browse attrs <selector>');
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = await bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
const attrs = await resolved.locator.evaluate((el) => {
|
const attrs = await resolved.locator.evaluate((el) => {
|
||||||
const result: Record<string, string> = {};
|
const result: Record<string, string> = {};
|
||||||
|
|
@ -221,7 +221,7 @@ export async function handleReadCommand(
|
||||||
const selector = args[1];
|
const selector = args[1];
|
||||||
if (!property || !selector) throw new Error('Usage: browse is <property> <selector>\nProperties: visible, hidden, enabled, disabled, checked, editable, focused');
|
if (!property || !selector) throw new Error('Usage: browse is <property> <selector>\nProperties: visible, hidden, enabled, disabled, checked, editable, focused');
|
||||||
|
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = await bm.resolveRef(selector);
|
||||||
let locator;
|
let locator;
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
locator = resolved.locator;
|
locator = resolved.locator;
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Page, Locator } from 'playwright';
|
import type { Page, Locator } from 'playwright';
|
||||||
import type { BrowserManager } from './browser-manager';
|
import type { BrowserManager, RefEntry } from './browser-manager';
|
||||||
import * as Diff from 'diff';
|
import * as Diff from 'diff';
|
||||||
|
|
||||||
// Roles considered "interactive" for the -i flag
|
// Roles considered "interactive" for the -i flag
|
||||||
|
|
@ -154,7 +154,7 @@ export async function handleSnapshot(
|
||||||
|
|
||||||
// Parse the ariaSnapshot output
|
// Parse the ariaSnapshot output
|
||||||
const lines = ariaText.split('\n');
|
const lines = ariaText.split('\n');
|
||||||
const refMap = new Map<string, Locator>();
|
const refMap = new Map<string, RefEntry>();
|
||||||
const output: string[] = [];
|
const output: string[] = [];
|
||||||
let refCounter = 1;
|
let refCounter = 1;
|
||||||
|
|
||||||
|
|
@ -218,7 +218,7 @@ export async function handleSnapshot(
|
||||||
locator = locator.nth(seenIndex);
|
locator = locator.nth(seenIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
refMap.set(ref, locator);
|
refMap.set(ref, { locator, role: node.role, name: node.name || '' });
|
||||||
|
|
||||||
// Format output line
|
// Format output line
|
||||||
let outputLine = `${indent}@${ref} [${node.role}]`;
|
let outputLine = `${indent}@${ref} [${node.role}]`;
|
||||||
|
|
@ -287,7 +287,7 @@ export async function handleSnapshot(
|
||||||
for (const elem of cursorElements) {
|
for (const elem of cursorElements) {
|
||||||
const ref = `c${cRefCounter++}`;
|
const ref = `c${cRefCounter++}`;
|
||||||
const locator = page.locator(elem.selector);
|
const locator = page.locator(elem.selector);
|
||||||
refMap.set(ref, locator);
|
refMap.set(ref, { locator, role: 'cursor-interactive', name: elem.text });
|
||||||
output.push(`@${ref} [${elem.reason}] "${elem.text}"`);
|
output.push(`@${ref} [${elem.reason}] "${elem.text}"`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -318,9 +318,9 @@ export async function handleSnapshot(
|
||||||
try {
|
try {
|
||||||
// Inject overlay divs at each ref's bounding box
|
// Inject overlay divs at each ref's bounding box
|
||||||
const boxes: Array<{ ref: string; box: { x: number; y: number; width: number; height: number } }> = [];
|
const boxes: Array<{ ref: string; box: { x: number; y: number; width: number; height: number } }> = [];
|
||||||
for (const [ref, locator] of refMap) {
|
for (const [ref, entry] of refMap) {
|
||||||
try {
|
try {
|
||||||
const box = await locator.boundingBox({ timeout: 1000 });
|
const box = await entry.locator.boundingBox({ timeout: 1000 });
|
||||||
if (box) {
|
if (box) {
|
||||||
boxes.push({ ref: `@${ref}`, box });
|
boxes.push({ ref: `@${ref}`, box });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export async function handleWriteCommand(
|
||||||
case 'click': {
|
case 'click': {
|
||||||
const selector = args[0];
|
const selector = args[0];
|
||||||
if (!selector) throw new Error('Usage: browse click <selector>');
|
if (!selector) throw new Error('Usage: browse click <selector>');
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = await bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
await resolved.locator.click({ timeout: 5000 });
|
await resolved.locator.click({ timeout: 5000 });
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -59,7 +59,7 @@ export async function handleWriteCommand(
|
||||||
const [selector, ...valueParts] = args;
|
const [selector, ...valueParts] = args;
|
||||||
const value = valueParts.join(' ');
|
const value = valueParts.join(' ');
|
||||||
if (!selector || !value) throw new Error('Usage: browse fill <selector> <value>');
|
if (!selector || !value) throw new Error('Usage: browse fill <selector> <value>');
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = await bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
await resolved.locator.fill(value, { timeout: 5000 });
|
await resolved.locator.fill(value, { timeout: 5000 });
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -72,7 +72,7 @@ export async function handleWriteCommand(
|
||||||
const [selector, ...valueParts] = args;
|
const [selector, ...valueParts] = args;
|
||||||
const value = valueParts.join(' ');
|
const value = valueParts.join(' ');
|
||||||
if (!selector || !value) throw new Error('Usage: browse select <selector> <value>');
|
if (!selector || !value) throw new Error('Usage: browse select <selector> <value>');
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = await bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
await resolved.locator.selectOption(value, { timeout: 5000 });
|
await resolved.locator.selectOption(value, { timeout: 5000 });
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -84,7 +84,7 @@ export async function handleWriteCommand(
|
||||||
case 'hover': {
|
case 'hover': {
|
||||||
const selector = args[0];
|
const selector = args[0];
|
||||||
if (!selector) throw new Error('Usage: browse hover <selector>');
|
if (!selector) throw new Error('Usage: browse hover <selector>');
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = await bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
await resolved.locator.hover({ timeout: 5000 });
|
await resolved.locator.hover({ timeout: 5000 });
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -110,7 +110,7 @@ export async function handleWriteCommand(
|
||||||
case 'scroll': {
|
case 'scroll': {
|
||||||
const selector = args[0];
|
const selector = args[0];
|
||||||
if (selector) {
|
if (selector) {
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = await bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
|
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -139,7 +139,7 @@ export async function handleWriteCommand(
|
||||||
return 'DOM content loaded';
|
return 'DOM content loaded';
|
||||||
}
|
}
|
||||||
const timeout = args[1] ? parseInt(args[1], 10) : 15000;
|
const timeout = args[1] ? parseInt(args[1], 10) : 15000;
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = await bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
await resolved.locator.waitFor({ state: 'visible', timeout });
|
await resolved.locator.waitFor({ state: 'visible', timeout });
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -204,7 +204,7 @@ export async function handleWriteCommand(
|
||||||
if (!fs.existsSync(fp)) throw new Error(`File not found: ${fp}`);
|
if (!fs.existsSync(fp)) throw new Error(`File not found: ${fp}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolved = bm.resolveRef(selector);
|
const resolved = await bm.resolveRef(selector);
|
||||||
if ('locator' in resolved) {
|
if ('locator' in resolved) {
|
||||||
await resolved.locator.setInputFiles(filePaths);
|
await resolved.locator.setInputFiles(filePaths);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,55 @@ describe('Ref invalidation', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ─── Ref Staleness Detection ────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Ref staleness detection', () => {
|
||||||
|
test('ref metadata stores role and name', async () => {
|
||||||
|
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||||
|
await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
|
||||||
|
// Refs should exist with metadata
|
||||||
|
expect(bm.getRefCount()).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stale ref after DOM removal gives descriptive error', async () => {
|
||||||
|
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||||
|
const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
|
||||||
|
// Find a button ref
|
||||||
|
const buttonLine = snap.split('\n').find(l => l.includes('[button]') && l.includes('"Submit"'));
|
||||||
|
expect(buttonLine).toBeDefined();
|
||||||
|
const refMatch = buttonLine!.match(/@(e\d+)/);
|
||||||
|
expect(refMatch).toBeDefined();
|
||||||
|
const ref = `@${refMatch![1]}`;
|
||||||
|
|
||||||
|
// Remove the button from DOM (simulates SPA re-render)
|
||||||
|
await handleReadCommand('js', ['document.querySelector("button[type=submit]").remove()'], bm);
|
||||||
|
|
||||||
|
// Try to click — should get descriptive staleness error
|
||||||
|
try {
|
||||||
|
await handleWriteCommand('click', [ref], bm);
|
||||||
|
expect(true).toBe(false); // Should not reach here
|
||||||
|
} catch (err: any) {
|
||||||
|
expect(err.message).toContain('stale');
|
||||||
|
expect(err.message).toContain('button');
|
||||||
|
expect(err.message).toContain('Submit');
|
||||||
|
expect(err.message).toContain('snapshot');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('valid ref still resolves normally after staleness check', async () => {
|
||||||
|
await handleWriteCommand('goto', [baseUrl + '/snapshot.html'], bm);
|
||||||
|
const snap = await handleMetaCommand('snapshot', ['-i'], bm, shutdown);
|
||||||
|
const linkLine = snap.split('\n').find(l => l.includes('[link]'));
|
||||||
|
expect(linkLine).toBeDefined();
|
||||||
|
const refMatch = linkLine!.match(/@(e\d+)/);
|
||||||
|
const ref = `@${refMatch![1]}`;
|
||||||
|
// Should work normally — element still exists
|
||||||
|
const result = await handleWriteCommand('hover', [ref], bm);
|
||||||
|
expect(result).toContain('Hovered');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Snapshot Diffing ──────────────────────────────────────────
|
// ─── Snapshot Diffing ──────────────────────────────────────────
|
||||||
|
|
||||||
describe('Snapshot diff', () => {
|
describe('Snapshot diff', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue