mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
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:
commit
d987526e47
73 files changed
+6639
-457
No files matched your search
Whitespace-only changes.
@@ -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],
|
||||
);
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user