refactor: code organization cleanup (#35)

* chore: fix gitignore + decisions doc after web->inspect-ui rename

Update web/ references to inspect-ui/ in .gitignore and Makefile. Add
decisions.md tracking architectural decisions from code-org discussion.

* refactor: rename pkg/spec-api to pkg/spec

Aligns the directory name with the npm package name @sanderling/spec.
Updates Makefile, package.json directory field, and resolveSpecAPIPath.

* refactor(verifier): split bindings.go into types.go + bindings.go

Move shared public types (Action, ActionKind, LogEntry, Exception) to
types.go. bindings.go retains internal JS runtime wiring only.

* refactor(inspect): split runs.go into runs.go, runs_cache.go, runs_decode.go

runs.go: types (RunSummary, StepSummary, RunDetail, Run) and Scan.
runs_cache.go: Cache type, Open/Step/Detail methods, parseRun, scanSteps.
runs_decode.go: readMeta, tallyTrace, decodeStepSummary, validRunID.

* refactor: move android_env.go to internal/android/

Extracts Android device/AVD/adb logic into internal/android package.
Exports EnsureDevice, AdbReverse, AdbReverseRemove, EnvWithAndroidPlatformTools, AdbBinary.
Moves tests to internal/android/android_test.go. cmd/sanderling becomes a thin caller.

* refactor: extract test pipeline to internal/testrun/

runTestPipeline logic moves to testrun.Execute. buildDriver, resolveSpecAPIPath,
pickFreePort, and the progress logger move to internal/testrun/. cmd/sanderling/test_run.go
becomes a thin adapter. Tests follow their code.

* ci: update workflow paths after pkg/spec-api -> pkg/spec rename
This commit is contained in:
pj authored and GitHub committed 2026-04-22 20:35:34 +07:00
1 parent 46abb28ed6
commit eed99e58aa
32 files changed
+870 -745

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
import type {
AccessibilityElement,
Action,
ActionGenerator,
InputTextAction,
Key,
Point,
PressKeyAction,
Sampler,
SwipeAction,
TapAction,
WaitAction,
WeightedEntry,
} from "./types.ts";
export function actions(generator: () => Action[]): ActionGenerator {
return globalThis.__sanderling__.actions(generator);
}
export function weighted(...entries: WeightedEntry[]): ActionGenerator {
return globalThis.__sanderling__.weighted(...entries);
}
export function from<T>(items: readonly T[]): Sampler<T> {
return globalThis.__sanderling__.from(items);
}
export function Tap(parameters: { on: string | AccessibilityElement }): TapAction {
return globalThis.__sanderling__.tap(parameters);
}
export function InputText(parameters: {
into: string | AccessibilityElement;
text: string;
}): InputTextAction {
return globalThis.__sanderling__.inputText(parameters);
}
export function Swipe(parameters: {
from: Point | AccessibilityElement;
to: Point | AccessibilityElement;
durationMillis?: number;
}): SwipeAction {
return globalThis.__sanderling__.swipe(parameters);
}
export function PressKey(parameters: { key: Key }): PressKeyAction {
return globalThis.__sanderling__.pressKey(parameters);
}
export function Wait(parameters: { durationMillis: number }): WaitAction {
return globalThis.__sanderling__.wait(parameters);
}
function builtinGenerator(name: "taps" | "swipes" | "waitOnce" | "pressKeys"): ActionGenerator {
return new Proxy({} as ActionGenerator, {
get(_target, property) {
const runtime = globalThis.__sanderling__[name] as unknown as Record<
string | symbol,
unknown
>;
return runtime[property];
},
});
}
export const taps: ActionGenerator = builtinGenerator("taps");
export const swipes: ActionGenerator = builtinGenerator("swipes");
export const waitOnce: ActionGenerator = builtinGenerator("waitOnce");
export const pressKey: ActionGenerator = builtinGenerator("pressKeys");
+21
View File
@@ -0,0 +1,21 @@
import { always } from "../ltl.ts";
import { extract } from "../extract.ts";
import type { Formula } from "../types.ts";
const exceptionCount = extract<number>((state) => state.exceptions.length);
// Fails when the SDK captured an uncaught throwable or a Sanderling.reportError
// call surfaced one during the run.
export const noUncaughtExceptions: Formula = always(
() => exceptionCount.current === 0,
);
const errorLogCount = extract<number>(
(state) => state.logs.reduce((count, log) => count + (log.level === "E" ? 1 : 0), 0),
);
// Fails when the runner's logcat fetch observed any error-level lines since
// the previous step.
export const noLogcatErrors: Formula = always(
() => errorLogCount.current === 0,
);
+5
View File
@@ -0,0 +1,5 @@
import type { Extracted, State } from "./types.ts";
export function extract<T>(getter: (state: State) => T): Extracted<T> {
return globalThis.__sanderling__.extract(getter);
}
+40
View File
@@ -0,0 +1,40 @@
export type {
AccessibilityElement,
AccessibilityTree,
Action,
ActionGenerator,
EventuallyFormula,
ExceptionRecord,
Extracted,
Formula,
InputTextAction,
Key,
LogEntry,
Point,
PressKeyAction,
Sampler,
Snapshots,
State,
SwipeAction,
TapAction,
SanderlingRuntime,
WaitAction,
WeightedEntry,
} from "./types.ts";
export { extract } from "./extract.ts";
export { always, eventually, next, now } from "./ltl.ts";
export {
InputText,
PressKey,
Swipe,
Tap,
Wait,
actions,
from,
pressKey,
swipes,
taps,
waitOnce,
weighted,
} from "./actions.ts";
+20
View File
@@ -0,0 +1,20 @@
import type { EventuallyFormula, Formula } from "./types.ts";
export function always(predicateOrFormula: (() => boolean) | Formula): Formula {
return globalThis.__sanderling__.always(predicateOrFormula);
}
export function now(predicate: () => boolean): Formula {
return globalThis.__sanderling__.now(predicate);
}
export function next(predicate: () => boolean): Formula {
return globalThis.__sanderling__.next(predicate);
}
// An unbounded `eventually` never forces a violation within a finite run —
// prefer `.within(n, unit)` when you want the verifier to fail a property
// that stalls.
export function eventually(predicate: () => boolean): EventuallyFormula {
return globalThis.__sanderling__.eventually(predicate);
}
+133
View File
@@ -0,0 +1,133 @@
export type Snapshots = Record<string, unknown>;
export interface AccessibilityElement {
id?: string;
text?: string;
bounds?: { left: number; top: number; right: number; bottom: number };
x?: number;
y?: number;
}
export interface AccessibilityTree {
find(selector: string): AccessibilityElement | undefined;
findAll(selector: string): AccessibilityElement[];
}
export interface LogEntry {
unixMillis: number;
level: string;
tag: string;
message: string;
}
export interface ExceptionRecord {
class: string;
message: string;
stackTrace: string;
unixMillis?: number;
}
export interface State {
snapshots: Snapshots;
ax: AccessibilityTree;
lastAction: Action | null;
time: number;
logs: readonly LogEntry[];
exceptions: readonly ExceptionRecord[];
}
export interface Extracted<T> {
readonly current: T;
readonly previous: T | undefined;
}
export interface Point {
x: number;
y: number;
}
export type TapAction = { kind: "Tap"; on: string | AccessibilityElement };
export type InputTextAction = {
kind: "InputText";
into: string | AccessibilityElement;
text: string;
};
export type SwipeAction = {
kind: "Swipe";
from: Point | AccessibilityElement;
to: Point | AccessibilityElement;
durationMillis?: number;
};
export type PressKeyAction = { kind: "PressKey"; key: Key };
export type WaitAction = { kind: "Wait"; durationMillis: number };
export type Action =
| TapAction
| InputTextAction
| SwipeAction
| PressKeyAction
| WaitAction;
export type Key =
| "back"
| "home"
| "enter"
| "tab"
| "up"
| "down"
| "left"
| "right";
export interface ActionGenerator {
readonly __sanderlingActionGenerator: true;
generate(): Action[];
}
export interface Formula {
readonly __sanderlingFormula: true;
implies(other: Formula): Formula;
or(other: Formula): Formula;
and(other: Formula): Formula;
not(): Formula;
}
export interface EventuallyFormula extends Formula {
within(amount: number, unit: "milliseconds" | "seconds" | "steps"): Formula;
}
export interface Sampler<T> {
generate(): T;
}
export interface SanderlingRuntime {
extract: <T>(getter: (state: State) => T) => Extracted<T>;
always: (predicateOrFormula: (() => boolean) | Formula) => Formula;
now: (predicate: () => boolean) => Formula;
next: (predicate: () => boolean) => Formula;
eventually: (predicate: () => boolean) => EventuallyFormula;
actions: (generator: () => Action[]) => ActionGenerator;
weighted: (...entries: WeightedEntry[]) => ActionGenerator;
from: <T>(items: readonly T[]) => Sampler<T>;
tap: (parameters: { on: string | AccessibilityElement }) => TapAction;
inputText: (parameters: {
into: string | AccessibilityElement;
text: string;
}) => InputTextAction;
swipe: (parameters: {
from: Point | AccessibilityElement;
to: Point | AccessibilityElement;
durationMillis?: number;
}) => SwipeAction;
pressKey: (parameters: { key: Key }) => PressKeyAction;
wait: (parameters: { durationMillis: number }) => WaitAction;
taps: ActionGenerator;
swipes: ActionGenerator;
waitOnce: ActionGenerator;
pressKeys: ActionGenerator;
}
export type WeightedEntry = readonly [number, ActionGenerator];
declare global {
// eslint-disable-next-line no-var
var __sanderling__: SanderlingRuntime;
}