Files
zeekay dc4ce1a036 Agents see a page as a snapshot with refs and act on the refs
An agent drove a tab by guessing CSS selectors against a raw CDP AX tree
on Chrome and a depth-5 tag walk on Firefox, and learned a click missed
only when nothing happened. Now `hanzo.snapshot` prints the rendered
accessibility tree with a ref on everything worth a handle
(`- button "Submit" [ref=e2]`), and `hanzo.act` clicks, fills, types,
presses, selects, checks, hovers and reads by that ref.

A ref names one element while it stays in the document, and the same
element keeps its ref across snapshots. A ref whose element left, or
from a document that navigated away, is refused with "run snapshot
again" rather than resolved to whatever sits there now; refs continue
past the tab's last one, so an old page's ref never names a new page's
element. A click first proves where it lands: an element under a
consent banner or modal fails before any event fires, naming the cover.

`hanzo.read` returns the page as markdown (outline, section filter),
`hanzo.wait` polls for a ref, selector or text, `hanzo.navigate` returns
after the load, and `hanzo.annotate` boxes and numbers every on-screen
ref on a screenshot, label N being ref @eN.

The page half is page.js, injected into the tab's isolated world on
first use and called there by shared/drive.ts, so Chrome, Firefox and
Safari run the same bytes without chrome.debugger. Both dispatchers hand
the six methods to drive() before their own switch; extension pages
reach the same dispatcher with runtime message `drive`, which the e2e
suite uses and web pages are refused.

page.js is 26.5KB and gets its own budget.

Claude-Session: https://claude.ai/code/session_01Dc8DXc3fEQCRHUa6XWK4Ku
2026-09-29 12:56:20 -07:00

154 lines
8.2 KiB
TypeScript

import http from 'http';
import type { AddressInfo } from 'net';
import type { Page } from '@playwright/test';
import { test, expect } from './fixtures';
/**
* The agent loop, end to end, in Chromium with the built extension:
* snapshot → act by ref → read → annotate, through the same dispatcher the
* zapd router calls (the extension page reaches it with runtime message
* `drive`). A consent modal covers the page first, so the covered-click
* refusal is exercised against real layout, not a mock.
*/
const FIXTURE = `<!doctype html>
<html><head><title>Ref fixture</title><style>
body { margin: 0; font: 16px sans-serif }
#consent { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 10 }
#consent div { background: #fff; margin: 120px auto; width: 300px; padding: 16px }
main { padding: 16px }
iframe { width: 300px; height: 80px; border: 0 }
</style></head><body>
<div id="consent"><div>We use cookies. <button id="accept">Accept all</button></div></div>
<main>
<h1>Newsletter</h1>
<p id="status">Not subscribed</p>
<form id="f"><label for="email">Email</label> <input id="email" type="email"> <button type="submit">Subscribe</button></form>
<label><input type="checkbox" id="terms"> I agree</label>
<select id="freq" aria-label="Frequency"><option value="d">Daily</option><option value="w">Weekly</option></select>
<x-card></x-card>
<iframe id="frame" title="Inner" srcdoc="<button onclick=&quot;document.body.dataset.hit=1&quot;>Frame button</button>"></iframe>
<h2>Archive</h2><p>Past issues live here.</p>
</main>
<script>
document.getElementById('accept').onclick = () => document.getElementById('consent').remove();
document.getElementById('f').onsubmit = (e) => {
e.preventDefault();
document.getElementById('status').textContent = 'Subscribed ' + document.getElementById('email').value;
};
customElements.define('x-card', class extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = '<button>Shadow action</button>';
root.querySelector('button').onclick = () => { this.dataset.hit = '1'; };
}
});
</script>
</body></html>`;
let server: http.Server;
let origin: string;
test.beforeAll(async () => {
server = http.createServer((_req, res) => {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(FIXTURE);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
test.afterAll(async () => {
await new Promise((resolve) => server.close(resolve));
});
function refOf(tree: string, pattern: RegExp): string {
const line = tree.split('\n').find((l) => pattern.test(l));
const m = line && /\[ref=(e\d+)\]/.exec(line);
if (!m) throw new Error(`no ref for ${pattern} in:\n${tree}`);
return `@${m[1]}`;
}
/** Send one engine method to `tabId` from an extension page, as the popup or sidebar would. */
function driver(ext: Page, tabId: number) {
return (method: string, params: Record<string, unknown> = {}) =>
ext.evaluate(
([m, p]) => chrome.runtime.sendMessage({ action: 'drive', method: m, params: p }),
[method, { tabId, ...params }] as const,
) as Promise<{ success: boolean; result?: any; error?: string }>;
}
test('snapshot → click by ref → read, with a covering modal and stale refs', async ({ context, extensionId }) => {
const target = await context.newPage();
await target.goto(`${origin}/`);
const ext = await context.newPage();
await ext.goto(`chrome-extension://${extensionId}/popup.html`);
const tabId = await ext.evaluate(async (o) => (await chrome.tabs.query({})).find((t) => t.url?.startsWith(o))!.id!, origin);
const drive = driver(ext, tabId);
const snap = await drive('hanzo.snapshot', { interactive: true });
expect(snap.success, snap.error).toBe(true);
const tree: string = snap.result.tree;
expect(tree).toMatch(/^- button "Accept all" \[ref=e\d+\]$/m);
expect(tree).toMatch(/^- textbox "Email" \[ref=e\d+\]$/m);
expect(tree).toMatch(/^- checkbox "I agree" \[ref=e\d+\]$/m);
expect(tree).toMatch(/^- combobox "Frequency" \[ref=e\d+\]: Daily$/m);
expect(tree).toMatch(/^- button "Shadow action" \[ref=e\d+\]$/m);
expect(tree).toMatch(/^- button "Frame button" \[ref=e\d+\]$/m);
// The modal covers the form: refused early, naming the cover.
const subscribe = refOf(tree, /button "Subscribe"/);
const covered = await drive('hanzo.act', { op: 'click', selector: subscribe });
expect(covered.success).toBe(false);
expect(covered.error).toMatch(/\(button "Subscribe"\) is covered by <div#consent> "We use cookies\. Accept all" at \(\d+, \d+\)\. .*run snapshot again/);
await expect(target.locator('#status')).toHaveText('Not subscribed');
const accept = refOf(tree, /"Accept all"/);
expect((await drive('hanzo.act', { op: 'click', selector: accept })).success).toBe(true);
await expect(target.locator('#consent')).toHaveCount(0);
// The refs of elements that survived still work; the removed button's does not.
expect((await drive('hanzo.act', { op: 'fill', selector: refOf(tree, /textbox "Email"/), text: '[email protected]' })).success).toBe(true);
const clicked = await drive('hanzo.act', { op: 'click', selector: subscribe });
expect(clicked.success, clicked.error).toBe(true);
await expect(target.locator('#status')).toHaveText('Subscribed [email protected]');
const stale = await drive('hanzo.act', { op: 'click', selector: accept });
expect(stale.error).toMatch(/Stale ref @e\d+: its element is no longer in the page\. Run snapshot again/);
expect((await drive('hanzo.act', { op: 'check', selector: refOf(tree, /checkbox "I agree"/) })).result).toEqual({ checked: true, changed: true });
await expect(target.locator('#terms')).toBeChecked();
expect((await drive('hanzo.act', { op: 'select', selector: refOf(tree, /"Frequency"/), value: 'Weekly' })).result)
.toEqual({ selected: { value: 'w', label: 'Weekly' } });
await expect(target.locator('#freq')).toHaveValue('w');
expect((await drive('hanzo.act', { op: 'click', selector: refOf(tree, /"Shadow action"/) })).success).toBe(true);
await expect(target.locator('x-card')).toHaveAttribute('data-hit', '1');
expect((await drive('hanzo.act', { op: 'click', selector: refOf(tree, /"Frame button"/) })).success).toBe(true);
await expect(target.frameLocator('#frame').locator('body')).toHaveAttribute('data-hit', '1');
expect((await drive('hanzo.act', { op: 'text', selector: '#status' })).result).toEqual({ text: 'Subscribed [email protected]' });
const read = await drive('hanzo.read');
expect(read.success, read.error).toBe(true);
expect(read.result.title).toBe('Ref fixture');
expect(read.result.markdown).toContain('# Newsletter\n\nSubscribed [email protected]');
expect((await drive('hanzo.read', { outline: true })).result.markdown).toBe('# Newsletter\n## Archive');
expect((await drive('hanzo.read', { filter: 'past issues' })).result.markdown).toBe('## Archive\n\nPast issues live here.');
const shot = await drive('hanzo.annotate');
expect(shot.success, shot.error).toBe(true);
expect(Buffer.from(shot.result.data, 'base64').subarray(1, 4).toString()).toBe('PNG');
expect(shot.result.legend).toContainEqual(`[${subscribe.slice(2)}] ${subscribe} button "Subscribe"`);
// A new document starts a new ref table: old refs are refused, new ones continue past them.
const nav = await drive('hanzo.navigate', { url: `${origin}/again` });
expect(nav.result).toMatchObject({ url: `${origin}/again`, title: 'Ref fixture', loaded: true });
expect((await drive('hanzo.act', { op: 'click', selector: subscribe })).error).toMatch(/Unknown ref .*Run snapshot again/);
const fresh = (await drive('hanzo.snapshot', { interactive: true })).result.tree;
expect(Number(refOf(fresh, /"Accept all"/).slice(2))).toBeGreaterThan(Number(subscribe.slice(2)));
// wait: for text that appears, then a timeout that says what it waited for.
await target.evaluate(() => setTimeout(() => { document.body.insertAdjacentHTML('beforeend', '<p>Welcome back</p>'); }, 300));
expect((await drive('hanzo.wait', { text: 'Welcome back', timeout: 5000 })).result).toMatchObject({ met: true });
expect((await drive('hanzo.wait', { selector: '#never', timeout: 300 })).error).toMatch(/Timed out after 300ms waiting for #never to be visible/);
});