Merge origin/master into llm-recording-and-analysis

Brings in #73, which landed web fact-parity work overlapping this branch:
selector-tagged ax handles, aria-disabled in `enabled`, shadow-DOM traversal
and a `scrollable`/`editable` dump, plus a third folio property and two new
testTags on HomeScreen.

Six files conflicted. The option-carrying ones took the union of both sides'
fields, so `--label-source` and `--exit-on-violation` both reach the pipeline.
In web-runtime.ts both sides changed how an element is described: master gave
`elementHandle` a selector to tag handles with, this branch gave it raw
attribute names and a field hint. Both survive, and `enabled` now answers
through master's isEnabled while `editable` stays.

Claude-Session: https://claude.ai/code/session_01A5KmftdEJ49A9z5mF5ESrX
This commit is contained in:
pj committed 2026-08-15 13:28:11 +05:30
commit d987526e47
73 files changed
+6639 -457

No files matched your search

View File
Whitespace-only changes.
+180
View File
@@ -0,0 +1,180 @@
// Sanderling fuzzing sanderling's own replay UI.
//
// Six of the seven properties here are cross-panel agreements: two panels that
// derive the same fact by different paths have to say the same thing. That
// holds for any trace, so this spec never needs recalibrating when the fixture
// run changes. The seventh is the stock noUncaughtExceptions.
//
// The hooks it drives (data-testid, data-step, ...) were added to the UI for
// this spec. Needing them is the lesson: a UI with no stable handles is a UI
// nothing can assert on, and that is as true for a person writing a test as it
// is for a fuzzer.
import { type AccessibilityElement, type Key, PressKey, Tap, actions, always, extract, from, next, weighted } from "@sanderling/spec";
import { defaultActions, noUncaughtExceptions } from "@sanderling/spec/defaults";
function numberOf(value: string | undefined): number | null {
if (value === undefined || value === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function dataOf(element: AccessibilityElement | undefined, key: string): string | undefined {
const attrs = (element as unknown as { attrs?: Record<string, string> } | undefined)?.attrs;
return attrs ? attrs[key] : undefined;
}
// The toolbar's own claim about which step is on screen, and how many there are.
const toolbar = extract("toolbar", (s) => {
const indicator = s.ax.find({ "data-testid": "step-indicator" });
if (!indicator) return null;
return {
step: numberOf(dataOf(indicator, "step")),
stepCount: numberOf(dataOf(indicator, "stepCount")),
};
});
// The step list's claim: which rows exist, which one is selected, which ones
// carry a violation marker.
const stepRows = extract("stepRows", (s) =>
s.ax.findAll({ "data-testid": "step-row" }).map((row) => ({
step: numberOf(dataOf(row, "step")),
active: dataOf(row, "active") === "true",
violating: dataOf(row, "violations") === "true",
})),
);
// What the "state before" panel is showing, read off the image URL it renders.
// Scoped to that panel by name rather than by DOM position: an earlier draft
// took the first screenshot on the page, and the fuzzer put the before panel on
// another tab, which left the after panel's image first and fired the property
// against a UI that was behaving correctly.
const beforeScreenshotStep = extract("beforeScreenshotStep", (s) => {
const image = s.ax.find([{ "data-testid": "state-before" }, { "data-testid": "screenshot" }]);
return image ? numberOf(dataOf(image, "step")) : null;
});
// Which tab is selected in each tab strip, as one comparable string.
const activeTabs = extract("activeTabs", (s) =>
s.ax
.findAll({ "data-testid": "tab" })
.filter((tab) => dataOf(tab, "active") === "true")
.map((tab) => dataOf(tab, "tabId") ?? "")
.join(","),
);
// Two counts of one fact: the tab badge counts the step's violation records,
// the panel counts the property rows it renders as violated.
const violationBadges = extract("violationBadges", (s) =>
s.ax.findAll({ "data-testid": "violations-badge" }).map((badge) => numberOf(badge.text)),
);
const violationPanelCounts = extract("violationPanelCounts", (s) =>
s.ax
.findAll({ "data-testid": "violations-panel" })
.map((panel) => numberOf(dataOf(panel, "violationCount"))),
);
// The step in the URL is user input: it survives a reload, a typo, and every
// tap that navigates. It must always land inside the run.
const selectedStepIsInRange = always(() => {
const current = toolbar.current;
if (!current || current.step === null || current.stepCount === null) return true;
return current.step >= 1 && current.step <= current.stepCount;
});
// Exactly one row is highlighted whenever the list is on screen. Zero means the
// toolbar is showing a step the list has no row for, which is what an
// off-by-one or a failed clamp looks like from the list's side.
const exactlyOneStepIsSelected = always(() => {
const rows = stepRows.current;
if (rows.length === 0) return true;
return rows.filter((row) => row.active).length === 1;
});
// The step count the toolbar prints and the number of rows the list renders are
// two readings of the same run.
const stepCountMatchesTheList = always(() => {
const current = toolbar.current;
const rows = stepRows.current;
if (!current || current.stepCount === null || rows.length === 0) return true;
return current.stepCount === rows.length;
});
// The screenshot panel builds its URL from the loaded run's step; the toolbar
// prints the step from the URL. They must name the same step.
const screenshotShowsTheSelectedStep = always(() => {
const current = toolbar.current;
const shown = beforeScreenshotStep.current;
if (!current || current.step === null || shown === null) return true;
return shown === current.step;
});
// Switching a tab is a view change, not a navigation: it must never move the
// run to another step.
const switchingTabsKeepsTheStep = always(
next(() => {
const previousTabs = activeTabs.previous;
const previousToolbar = toolbar.previous;
const currentToolbar = toolbar.current;
if (previousTabs === undefined || previousTabs === activeTabs.current) return true;
if (!previousToolbar || !currentToolbar) return true;
return previousToolbar.step === currentToolbar.step;
}),
);
// A badge that counts violations the panel cannot show a row for is a violation
// the operator can see a number for and never read.
const badgeCountMatchesThePanel = always(() => {
const badges = violationBadges.current;
const panels = violationPanelCounts.current;
if (badges.length === 0 || panels.length === 0) return true;
const badge = badges[0];
if (badge === null) return true;
return panels.every((count) => count === null || count === badge);
});
export const properties = {
noUncaughtExceptions,
selectedStepIsInRange,
exactlyOneStepIsSelected,
stepCountMatchesTheList,
screenshotShowsTheSelectedStep,
switchingTabsKeepsTheStep,
badgeCountMatchesThePanel,
};
// Step rows get their own weight for the same reason tabs do below: the default
// enumeration reaches them now that role="option" is in the tappable set, but
// one row out of the page's clickable elements is a thin chance, and both
// step-facing properties are vacuous on a run that never selects one.
const rowElements = extract("rowElements", (s) => s.ax.findAll({ "data-testid": "step-row" }));
const selectAStep = actions(() => {
const rows = rowElements.current;
return rows.length === 0 ? [] : [Tap({ on: from(rows).generate() })];
});
// Left/right are the UI's own prev/next step bindings, and the one path into
// the step navigation that does not go through a tap.
const arrowKeys = from<Key>(["left", "right"]);
const navigateByKeyboard = actions(() => [PressKey({ key: arrowKeys.generate() })]);
// Tabs get their own weight rather than being left to the undirected tap mix:
// with ~15 clickable elements on the page, an undirected run went 40 steps
// without switching a single tab, which left both tab-facing properties
// vacuously true. Weighting them is what makes those properties mean something.
const tabElements = extract("tabElements", (s) => s.ax.findAll({ "data-testid": "tab" }));
const switchATab = actions(() => {
const tabs = tabElements.current;
return tabs.length === 0 ? [] : [Tap({ on: from(tabs).generate() })];
});
// defaultActions carries the rest: the jump-to-violation button, the theme
// toggle, the link back to the run list, and the scrolling.
export const actionsRoot = weighted(
[30, selectAStep],
[20, navigateByKeyboard],
[25, switchATab],
[25, defaultActions],
);
+3
View File
@@ -65,6 +65,7 @@ export default function Tabs({ tabs, defaultTabId, ariaLabel }: TabsProps) {
<div className="tabs">
<div
className="tabs-header"
data-testid="tab-strip"
role="tablist"
aria-label={ariaLabel}
onKeyDown={handleKeyDown}
@@ -82,6 +83,8 @@ export default function Tabs({ tabs, defaultTabId, ariaLabel }: TabsProps) {
}
}}
type="button"
data-testid="tab"
data-tab-id={tab.id}
role="tab"
id={`${panelId}-tab-${tab.id}`}
className="tabs-tab"
+2
View File
@@ -170,6 +170,8 @@ export default function ActionList({
}
}}
className="action-list-item"
data-testid="step-row"
data-step={step.index}
role="option"
tabIndex={isActive ? 0 : -1}
aria-selected={isActive}
+8
View File
@@ -9,6 +9,12 @@ export interface ScreenshotProps {
deviceHeight?: number;
}
// stepOfSource reads the step out of the screenshot URL the panel is rendering,
// so a spec can compare what this panel shows against what the toolbar claims.
function stepOfSource(src: string): string | undefined {
return /step-0*(\d+)\.png/.exec(src)?.[1];
}
const DEFAULT_WIDTH = 1080;
const DEFAULT_HEIGHT = 1920;
@@ -58,6 +64,8 @@ export default function Screenshot({ src, action, deviceWidth, deviceHeight }: S
>
<img
className="screenshot-image"
data-testid="screenshot"
data-step={stepOfSource(src)}
src={src}
alt="device screenshot"
onLoad={(event) => {
+12 -2
View File
@@ -130,13 +130,18 @@ export default function ViolationsPanel({
}
return (
<section className="violations-panel">
<section
className="violations-panel"
data-testid="violations-panel"
data-violation-count={rows.filter((row) => row.status === "violated").length}
>
{violationsOnly ? null : (
<header className="violations-panel-header">
<h2 className="violations-panel-title">properties</h2>
<button
type="button"
className="violations-panel-jump"
data-testid="jump-to-first-violation"
onClick={onJumpToFirstViolation}
disabled={!hasFirstViolation}
>
@@ -149,7 +154,12 @@ export default function ViolationsPanel({
const residual = residuals?.[name];
const witness = status === "violated" ? witnesses?.[name] : undefined;
return (
<li key={name} className="violations-panel-row" data-status={status}>
<li
key={name}
className="violations-panel-row"
data-testid="property-row"
data-status={status}
>
<div className="violations-panel-row-head">
<span
className="violations-panel-badge"
+9 -5
View File
@@ -172,7 +172,7 @@ export default function RunDetail() {
label: "Violations",
badge:
stepViolations.length > 0 ? (
<span className="tabs-badge" data-kind="violation">
<span className="tabs-badge" data-testid="violations-badge" data-kind="violation">
{stepViolations.length}
</span>
) : undefined,
@@ -239,7 +239,7 @@ export default function RunDetail() {
label: "Violations",
badge:
stepViolations.length > 0 ? (
<span className="tabs-badge" data-kind="violation">
<span className="tabs-badge" data-testid="violations-badge" data-kind="violation">
{stepViolations.length}
</span>
) : undefined,
@@ -267,7 +267,11 @@ export default function RunDetail() {
<span>
<strong title={run.spec_path}>{basename(run.spec_path)}</strong> seed={run.seed}
</span>
<span>
<span
data-testid="step-indicator"
data-step={stepIndex}
data-step-count={stepCount ?? 0}
>
step {stepIndex} / {stepCount ?? 0}
</span>
</div>
@@ -319,14 +323,14 @@ export default function RunDetail() {
</div>
</aside>
<section className="detail-state-before detail-panel">
<section className="detail-state-before detail-panel" data-testid="state-before">
<h2>state before</h2>
<div className="detail-panel-body">
<Tabs tabs={beforeTabs} defaultTabId="screenshot" ariaLabel="state before" />
</div>
</section>
<section className="detail-state-after detail-panel">
<section className="detail-state-after detail-panel" data-testid="state-after">
<h2>state after</h2>
<div className="detail-panel-body">
<Tabs tabs={afterTabs} defaultTabId="screenshot" ariaLabel="state after" />
+1 -1
View File
@@ -8,7 +8,7 @@
--text-muted: #6b6b6b;
--accent-violation: #d32f2f;
--accent-positive: #2e7d32;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
:root[data-theme="dark"],
-24
View File
@@ -1,27 +1,3 @@
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/JetBrainsMono-Regular.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url("/fonts/JetBrainsMono-Medium.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/JetBrainsMono-Bold.woff2") format("woff2");
}
body {
font-family: var(--font-mono);
font-size: 13px;