tools/ui/ — scripts + Playwright config that let Claude Code (and
humans) drive the Flutter WASM build in a real browser. The point is
to avoid screenshot round-trips: every interactive widget in clide
ships a Semantics wrapper anyway (a11y requirement), so the automation
layer just queries the `flt-semantics[aria-label]` DOM.
* build.sh — `flutter build web --wasm` from app/
* serve.sh — `python3 -m http.server 4280` in the background.
Before binding, kills any stale listener on the port (orphans
from earlier failed runs no longer accumulate).
* stop.sh — port-based kill; escalates to SIGKILL after 300ms.
The pidfile is now advisory — port ownership is the source of
truth.
* driver.ts — `ClideDriver` class with `byLabel`, `click`, `type`,
`readText`, `screenshot`, `dumpSemanticsTree`, and
`waitUntilReady` that auto-clicks the `flt-semantics-placeholder`
so the semantic tree is populated before queries.
* tests/smoke.spec.ts — first driver test; asserts welcome +
disconnected labels surface in the Semantics DOM.
Co-Authored-By: Claude <noreply@anthropic.com>
110 lines
3.7 KiB
TypeScript
110 lines
3.7 KiB
TypeScript
import { expect } from '@playwright/test';
|
|
import type { Page, Locator } from '@playwright/test';
|
|
|
|
/**
|
|
* Playwright helpers that drive the Flutter WASM build by querying
|
|
* `flt-semantics` DOM elements instead of pixel coordinates.
|
|
*
|
|
* Requires the app to have `SemanticsBinding.ensureSemantics()` on
|
|
* boot (main.dart already does this). Every interactive widget in
|
|
* clide emits a `Semantics(label:, hint:, button:)` wrapper, which
|
|
* surfaces as a `flt-semantics[aria-label="…"]` element.
|
|
*/
|
|
export class ClideDriver {
|
|
readonly page: Page;
|
|
|
|
constructor(page: Page) {
|
|
this.page = page;
|
|
}
|
|
|
|
/** Navigate to the app root and wait until Flutter has finished first-frame. */
|
|
async goto(path: string = '/'): Promise<void> {
|
|
await this.page.goto(path);
|
|
await this.waitUntilReady();
|
|
}
|
|
|
|
/**
|
|
* Wait until the Flutter app is past first frame and click the
|
|
* accessibility placeholder so the semantics tree is populated.
|
|
* Flutter web ships semantics disabled by default; the placeholder
|
|
* at the very top-left of the page is the official way to turn them
|
|
* on from outside the app.
|
|
*/
|
|
async waitUntilReady(): Promise<void> {
|
|
await this.page.waitForSelector('flt-glass-pane', {
|
|
timeout: 30_000,
|
|
state: 'attached',
|
|
});
|
|
const placeholder = this.page.locator('flt-semantics-placeholder');
|
|
if ((await placeholder.count()) > 0) {
|
|
await placeholder.click({ force: true });
|
|
}
|
|
await this.page.waitForSelector('flt-semantics[aria-label]', {
|
|
timeout: 30_000,
|
|
state: 'attached',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns a locator for a Semantics node whose `aria-label` contains
|
|
* [label]. Flutter web merges sibling labels into one aria-label
|
|
* (newline-separated), so exact match wouldn't work. Substring match
|
|
* is usually unique — narrow with `.filter()` if not.
|
|
*/
|
|
byLabel(label: string): Locator {
|
|
const safe = label.replace(/"/g, '\\"');
|
|
return this.page.locator(`flt-semantics[aria-label*="${safe}"]`);
|
|
}
|
|
|
|
/** Click an element by its semantic label. Asserts it exists + is enabled. */
|
|
async click(label: string): Promise<void> {
|
|
const el = this.byLabel(label);
|
|
await el.waitFor({ state: 'attached', timeout: 5_000 });
|
|
await el.click();
|
|
}
|
|
|
|
/** Type into the element with the given label. */
|
|
async type(label: string, text: string): Promise<void> {
|
|
const el = this.byLabel(label);
|
|
await el.waitFor({ state: 'attached', timeout: 5_000 });
|
|
await el.fill(text);
|
|
}
|
|
|
|
/** Read the visible label of an element (useful for state transitions). */
|
|
async readText(label: string): Promise<string> {
|
|
const el = this.byLabel(label);
|
|
await el.waitFor({ state: 'attached', timeout: 5_000 });
|
|
return (await el.textContent()) ?? '';
|
|
}
|
|
|
|
/** Save a full-page PNG to `path`. */
|
|
async screenshot(path: string): Promise<void> {
|
|
await this.page.screenshot({ path, fullPage: true });
|
|
}
|
|
|
|
/**
|
|
* Dump the entire Flutter semantic tree as structured JSON. Useful
|
|
* for test-failure diagnosis ("why didn't my label match?") and for
|
|
* Claude's own debugging flow.
|
|
*/
|
|
async dumpSemanticsTree(): Promise<unknown> {
|
|
return this.page.evaluate(() => {
|
|
function walk(el: Element): unknown {
|
|
const children = Array.from(el.children)
|
|
.filter((c) => c.tagName.toLowerCase().startsWith('flt-semantics'))
|
|
.map(walk);
|
|
return {
|
|
tag: el.tagName.toLowerCase(),
|
|
label: el.getAttribute('aria-label'),
|
|
role: el.getAttribute('role'),
|
|
hint: el.getAttribute('aria-describedby'),
|
|
selected: el.getAttribute('aria-selected'),
|
|
children,
|
|
};
|
|
}
|
|
const hosts = Array.from(document.querySelectorAll('flt-semantics-host'));
|
|
return hosts.map(walk);
|
|
});
|
|
}
|
|
}
|