mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
docs: web platform + clean URLs + dark/light mode (#37)
* chore(docs): replace d2 diagram pipeline with mermaid Remove docs/_diagrams/ and d2 build step from Makefile. The HTML template already initialises mermaid.js; diagrams are now inline code fences rendered client-side. * docs(architecture): add mermaid diagram + web/CDP platform docs Replace SVG img tag with inline mermaid flowchart showing both native (Maestro sidecar + in-app SDK) and web (Chrome CDP) paths. Update Processes, Transports table, and per-step cycle sections. * docs(design-principles): update principles 1-4 for web platform Principles 1, 2, 3, and 4 referenced Maestro and native-only concepts. Add web/CDP context and update driver-is-an-interface to name both sidecar and chrome implementations. * docs(manual): add web prerequisites and folio-web example Update --platform flag to list android, ios, web. Add web prerequisites section (Chrome, no SDK needed) and folio-web quick-start to getting-started. * chore(gitignore): untrack inspect dist/index.html build artifact index.html is regenerated by vite on every build with a new content hash, making it permanently dirty. Only .gitkeep is needed for //go:embed to compile on a fresh checkout. Also remove duplicate dist/* line and stale d2 diagram ignore entries. * feat(docs): click-to-zoom for mermaid diagrams * docs(architecture): change diagram layout from LR to TB * docs(getting-started): link npm and Maven Central package headers * update docs root * docs(spec): rewrite npm package README Update usage example to current API, drop stale version-compatibility and license sections. * build(docs): output pages as pagename/index.html for clean URLs Split DOCS_OUT into INDEX_OUT (index.md files stay as index.html) and PAGE_OUT (all other pages become pagename/index.html). The __ROOT__ depth computation already handles the extra directory level correctly. * chore(docs): update sidebar links to directory-style URLs * docs: update cross-links from .html to directory-style paths * ci(docs): remove d2 install step * feat(docs): dark/light mode toggle Add theme toggle button (top-right, fixed). Persists preference in localStorage; falls back to prefers-color-scheme. Flash-free via inline script in <head> that sets data-theme before first paint. * fix(docs): fix inspect image path broken by directory URL restructure * feat(docs): click-to-fullscreen for all article images * fix(inspect): allow AssetsFS override in ServerOptions; drop unused request param from serveIndex * fix(inspect): use in-memory FS in tests so TestAssets_FallbackToIndexHTML passes without web build
This commit is contained in:
20 files changed
+288
-180
No files matched your search
@@ -27,9 +27,6 @@ jobs:
|
||||
- name: Install pandoc
|
||||
run: sudo apt-get update && sudo apt-get install -y pandoc
|
||||
|
||||
- name: Install d2
|
||||
run: curl -fsSL https://d2lang.com/install.sh | sh -s -- --version v0.7.1
|
||||
|
||||
- name: Build site
|
||||
run: make docs
|
||||
|
||||
|
||||
+2
-9
@@ -37,11 +37,10 @@ pkg/spec/dist/
|
||||
# goreleaser local output
|
||||
/dist/
|
||||
|
||||
# inspect web bundle output (real bundle wired in Stage 4 via Makefile/CI).
|
||||
# Track only the stub index.html + .gitkeep so //go:embed succeeds.
|
||||
# inspect web bundle output; built by `make web-build` before go build.
|
||||
# Only .gitkeep is tracked so //go:embed all:dist compiles on a fresh checkout.
|
||||
/internal/inspect/dist/*
|
||||
!/internal/inspect/dist/.gitkeep
|
||||
!/internal/inspect/dist/index.html
|
||||
|
||||
# inspect web frontend
|
||||
inspect-ui/node_modules/
|
||||
@@ -53,15 +52,9 @@ examples/folio-web/node_modules/
|
||||
examples/folio-web/dist/
|
||||
examples/folio-web/.vite/
|
||||
|
||||
# d2 diagrams render into build/site/_assets/diagrams; keep sources only
|
||||
/docs/_diagrams/*.svg
|
||||
/docs/_diagrams/*.png
|
||||
|
||||
# coding agent files
|
||||
.claude/
|
||||
.claude/*
|
||||
|
||||
# Personal research notes
|
||||
/research/
|
||||
|
||||
internal/inspect/dist/*
|
||||
@@ -11,11 +11,13 @@ SIDECAR_EMBED := internal/sidecar/assets/sidecar-all.jar
|
||||
SDK_AAR := sdk/android/build/outputs/aar/sdk-android-release.aar
|
||||
SANDERLING_BIN := bin/sanderling
|
||||
|
||||
DOCS_SRC := $(shell find docs -type f -name '*.md' -not -path 'docs/_*')
|
||||
DOCS_OUT := $(patsubst docs/%.md,build/site/%.html,$(DOCS_SRC))
|
||||
DOCS_SRC := $(shell find docs -type f -name '*.md' -not -path 'docs/_*')
|
||||
INDEX_SRC := $(filter %index.md,$(DOCS_SRC))
|
||||
PAGE_SRC := $(filter-out %index.md,$(DOCS_SRC))
|
||||
INDEX_OUT := $(patsubst docs/%.md,build/site/%.html,$(INDEX_SRC))
|
||||
PAGE_OUT := $(patsubst docs/%.md,build/site/%/index.html,$(PAGE_SRC))
|
||||
DOCS_OUT := $(INDEX_OUT) $(PAGE_OUT)
|
||||
DOCS_TEMPLATE := docs/_template/page.html
|
||||
DIAGRAM_SRC := $(shell find docs/_diagrams -type f -name '*.d2' 2>/dev/null)
|
||||
DIAGRAM_OUT := $(patsubst docs/_diagrams/%.d2,build/site/_assets/diagrams/%.svg,$(DIAGRAM_SRC))
|
||||
|
||||
INSPECT_DIST := internal/inspect/dist
|
||||
WEB_DIST := inspect-ui/dist
|
||||
@@ -84,24 +86,27 @@ test-kotlin:
|
||||
test-spec-api:
|
||||
cd pkg/spec && npm test --silent
|
||||
|
||||
docs: $(DOCS_OUT) build/site/_assets $(DIAGRAM_OUT)
|
||||
@echo "built $(words $(DOCS_OUT)) pages, $(words $(DIAGRAM_OUT)) diagrams to build/site"
|
||||
docs: $(DOCS_OUT) build/site/_assets
|
||||
@echo "built $(words $(DOCS_OUT)) pages to build/site"
|
||||
|
||||
build/site/_assets: docs/_assets
|
||||
@mkdir -p build/site
|
||||
@rm -rf $@
|
||||
@cp -R $< $@
|
||||
|
||||
build/site/_assets/diagrams/%.svg: docs/_diagrams/%.d2
|
||||
@mkdir -p $(dir $@)
|
||||
@d2 --theme 301 --pad 20 $< $@
|
||||
|
||||
build/site/%.html: docs/%.md $(DOCS_TEMPLATE)
|
||||
define build_page
|
||||
@mkdir -p $(dir $@)
|
||||
@pandoc $< --from=gfm --to=html5 --standalone \
|
||||
--highlight-style=tango --template=$(DOCS_TEMPLATE) -o $@
|
||||
@rel=$$(echo $(patsubst build/site/%,%,$@) | awk -F/ '{for(i=1;i<NF;i++)printf "../"}'); \
|
||||
sed -i.bak "s|__ROOT__|$$rel|g" $@ && rm $@.bak
|
||||
endef
|
||||
|
||||
$(INDEX_OUT): build/site/%.html: docs/%.md $(DOCS_TEMPLATE)
|
||||
$(build_page)
|
||||
|
||||
$(PAGE_OUT): build/site/%/index.html: docs/%.md $(DOCS_TEMPLATE)
|
||||
$(build_page)
|
||||
|
||||
clean:
|
||||
$(GO) clean
|
||||
|
||||
@@ -21,6 +21,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
html[data-theme="light"] {
|
||||
--bg: #ffffff;
|
||||
--fg: #2a2a2a;
|
||||
--muted: #6a6a6a;
|
||||
--border: #e5e5e5;
|
||||
--accent: #0a66c2;
|
||||
--code-bg: #f6f6f6;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--bg: #0f0f10;
|
||||
--fg: #e5e5e5;
|
||||
--muted: #a0a0a0;
|
||||
--border: #2a2a2a;
|
||||
--accent: #4aa3ff;
|
||||
--code-bg: #1a1a1b;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { background: var(--bg); color: var(--fg); }
|
||||
@@ -114,6 +132,7 @@ main article img {
|
||||
display: block;
|
||||
margin: 1.25rem 0;
|
||||
border-radius: 4px;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
a { color: var(--accent); }
|
||||
@@ -184,8 +203,66 @@ footer a { color: var(--muted); }
|
||||
.mermaid {
|
||||
text-align: center;
|
||||
margin: 1.5rem 0;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.diagram-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
z-index: 9999;
|
||||
cursor: zoom-out;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.diagram-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.diagram-overlay svg {
|
||||
max-width: 95vw;
|
||||
max-height: 95vh;
|
||||
width: auto;
|
||||
height: auto;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 100;
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
padding: 0.35rem;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.theme-toggle:hover { border-color: var(--muted); }
|
||||
|
||||
.theme-toggle svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
html[data-theme="light"] .icon-sun { display: none; }
|
||||
html[data-theme="light"] .icon-moon { display: block; }
|
||||
html[data-theme="dark"] .icon-sun { display: block; }
|
||||
html[data-theme="dark"] .icon-moon { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.sidebar {
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
grid-rows: 2
|
||||
grid-gap: 40
|
||||
|
||||
test_run: {
|
||||
label: "Test run"
|
||||
grid-rows: 2
|
||||
grid-gap: 100
|
||||
|
||||
go_binary: sanderling (Go) {
|
||||
direction: right
|
||||
|
||||
bundler: Bundler\nesbuild
|
||||
verifier: Verifier\ngoja + LTL
|
||||
runner: Runner
|
||||
driver: Driver
|
||||
trace: Trace writer\nJSONL + PNG
|
||||
|
||||
bundler -> verifier
|
||||
verifier <-> runner
|
||||
runner -> driver
|
||||
runner -> trace
|
||||
}
|
||||
|
||||
platform: {
|
||||
label: ""
|
||||
direction: right
|
||||
style.stroke-width: 0
|
||||
style.fill: transparent
|
||||
|
||||
device: Emulator / device {
|
||||
sdk: sanderling-sdk\npause / state\ncoverage / logs
|
||||
}
|
||||
|
||||
sidecar: Maestro sidecar (JVM) {
|
||||
maestro: maestro-client
|
||||
}
|
||||
|
||||
sidecar.maestro -> device.sdk: UIAutomator
|
||||
}
|
||||
|
||||
go_binary.driver -> platform.sidecar.maestro: gRPC
|
||||
go_binary.runner -> platform.device.sdk: Unix socket
|
||||
}
|
||||
|
||||
viewer: {
|
||||
label: "Inspect"
|
||||
grid-columns: 4
|
||||
grid-gap: 40
|
||||
|
||||
left_pad: "" {
|
||||
style.stroke-width: 0
|
||||
style.fill: transparent
|
||||
style.opacity: 0
|
||||
}
|
||||
web: Web UI (React)
|
||||
inspect: sanderling inspect\nHTTP + SSE
|
||||
runs: runs/ {
|
||||
shape: cylinder
|
||||
}
|
||||
|
||||
runs -> inspect
|
||||
inspect -> web
|
||||
}
|
||||
|
||||
test_run.go_binary.trace -> viewer.runs
|
||||
Vendored
+56
-8
@@ -9,24 +9,39 @@
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap">
|
||||
<link rel="stylesheet" href="__ROOT___assets/style.css">
|
||||
<style>$highlighting-css$</style>
|
||||
<script>
|
||||
const t = localStorage.getItem('theme') || (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||
document.documentElement.dataset.theme = t;
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<button id="theme-toggle" class="theme-toggle" title="Toggle theme">
|
||||
<svg class="icon-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
<svg class="icon-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="layout">
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="__ROOT__index.html">sanderling</a>
|
||||
<a class="brand" href="__ROOT__">sanderling</a>
|
||||
<nav>
|
||||
<h3>Manual</h3>
|
||||
<ul>
|
||||
<li><a href="__ROOT__manual/getting-started.html">Getting started</a></li>
|
||||
<li><a href="__ROOT__manual/writing-specs.html">Writing specs</a></li>
|
||||
<li><a href="__ROOT__manual/runs.html">Runs</a></li>
|
||||
<li><a href="__ROOT__manual/inspect.html">Inspect</a></li>
|
||||
<li><a href="__ROOT__manual/cli.html">CLI reference</a></li>
|
||||
<li><a href="__ROOT__manual/getting-started/">Getting started</a></li>
|
||||
<li><a href="__ROOT__manual/writing-specs/">Writing specs</a></li>
|
||||
<li><a href="__ROOT__manual/runs/">Runs</a></li>
|
||||
<li><a href="__ROOT__manual/inspect/">Inspect</a></li>
|
||||
<li><a href="__ROOT__manual/cli/">CLI reference</a></li>
|
||||
</ul>
|
||||
<h3>Development</h3>
|
||||
<ul>
|
||||
<li><a href="__ROOT__development/design-principles.html">Design principles</a></li>
|
||||
<li><a href="__ROOT__development/architecture.html">Architecture</a></li>
|
||||
<li><a href="__ROOT__development/design-principles/">Design principles</a></li>
|
||||
<li><a href="__ROOT__development/architecture/">Architecture</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -51,6 +66,39 @@ for (const pre of document.querySelectorAll('pre.mermaid, pre > code.language-me
|
||||
node.replaceWith(div);
|
||||
}
|
||||
await mermaid.run();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'diagram-overlay';
|
||||
document.body.appendChild(overlay);
|
||||
overlay.addEventListener('click', () => overlay.classList.remove('open'));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') overlay.classList.remove('open');
|
||||
});
|
||||
for (const diagram of document.querySelectorAll('.mermaid')) {
|
||||
diagram.addEventListener('click', () => {
|
||||
const svg = diagram.querySelector('svg');
|
||||
if (!svg) return;
|
||||
overlay.innerHTML = '';
|
||||
overlay.appendChild(svg.cloneNode(true));
|
||||
overlay.classList.add('open');
|
||||
});
|
||||
}
|
||||
|
||||
for (const img of document.querySelectorAll('article img')) {
|
||||
img.addEventListener('click', () => {
|
||||
overlay.innerHTML = '';
|
||||
const clone = img.cloneNode();
|
||||
clone.style.cssText = 'max-width:95vw;max-height:95vh;width:auto;height:auto;border-radius:6px;';
|
||||
overlay.appendChild(clone);
|
||||
overlay.classList.add('open');
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('theme-toggle').addEventListener('click', () => {
|
||||
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.dataset.theme = next;
|
||||
localStorage.setItem('theme', next);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,30 +4,54 @@ title: Architecture
|
||||
|
||||
# Architecture
|
||||
|
||||
Three processes, two transports.
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph go["sanderling (Go)"]
|
||||
direction LR
|
||||
B["Bundler / esbuild"] --> V["Verifier / goja + LTL"]
|
||||
V <--> R["Runner"]
|
||||
R --> D["DeviceDriver"]
|
||||
R --> T["Trace writer\nJSONL + PNG"]
|
||||
end
|
||||
|
||||
<img src="../_assets/diagrams/architecture.svg" alt="sanderling architecture" />
|
||||
SC["Maestro sidecar (JVM)"]
|
||||
SDK["in-app SDK\n(Device / Emulator)"]
|
||||
CH["Chrome (CDP)"]
|
||||
RD[("runs/")]
|
||||
IN["sanderling inspect\nHTTP + SSE"]
|
||||
UI["Web UI (React)"]
|
||||
|
||||
D -->|gRPC| SC
|
||||
SC -->|UIAutomator / XCTest| SDK
|
||||
R -->|"Unix socket<br/>(pause / state / logs)"| SDK
|
||||
D -->|CDP| CH
|
||||
|
||||
T --> RD --> IN --> UI
|
||||
```
|
||||
|
||||
## Processes
|
||||
|
||||
**sanderling (Go).** The top-level binary. Bundles the spec with esbuild, evaluates it in goja, runs the main loop, dispatches actions through the driver, writes the trace.
|
||||
**sanderling (Go).** The top-level binary. Bundles the spec with esbuild, evaluates it in goja, runs the main loop, dispatches actions through the `DeviceDriver` interface, writes the trace.
|
||||
|
||||
**Maestro sidecar (JVM).** A Kotlin process that wraps `maestro-client` and exposes a gRPC surface matching the `driver.Driver` interface. Handles UI input, screenshots, the system accessibility tree, and OS-level alerts.
|
||||
**Maestro sidecar (JVM).** A Kotlin process that wraps `maestro-client` and exposes a gRPC surface matching the `DeviceDriver` interface. Handles UI input, screenshots, the system accessibility tree, and OS-level alerts. Native platforms only.
|
||||
|
||||
**In-app SDK.** A Kotlin (or Swift for iOS) library linked into the app under test. Exposes a Unix socket to the runner. Provides pause and resume, view-hierarchy dumps, coverage reads, log capture, and user-registered state extractors.
|
||||
**In-app SDK.** A Kotlin (or Swift for iOS) library linked into the app under test. Exposes a Unix socket to the runner. Provides pause and resume, view-hierarchy dumps, coverage reads, log capture, and user-registered state extractors. Native platforms only.
|
||||
|
||||
**Chrome (CDP).** For web targets, the Go binary drives Chrome directly over the Chrome DevTools Protocol. No sidecar or in-app SDK is involved.
|
||||
|
||||
## Transports
|
||||
|
||||
| Channel | Transport | Purpose |
|
||||
|---|---|---|
|
||||
| Go to Maestro sidecar | gRPC (localhost TCP) | UI input, screenshots, system alerts |
|
||||
| Go to in-app SDK | Unix domain socket | Pause / resume, hierarchy, coverage, logs, extractors |
|
||||
| Channel | Platform | Transport | Purpose |
|
||||
|---|---|---|---|
|
||||
| Go to Maestro sidecar | Native | gRPC (localhost TCP) | UI input, screenshots, system alerts |
|
||||
| Go to in-app SDK | Native | Unix domain socket | Pause / resume, hierarchy, coverage, logs, extractors |
|
||||
| Go to Chrome | Web | Chrome DevTools Protocol | UI input, screenshots, DOM hierarchy, console logs |
|
||||
|
||||
The split exists for one reason: only real UI events need the cost of crossing process and OS-API boundaries. Introspection is cheap, frequent, and lives on a fast local socket directly to the app.
|
||||
On native, the transport split exists because only real UI events need to cross process and OS-API boundaries. Introspection is cheap, frequent, and lives on a fast local socket directly to the app. On web, CDP handles both.
|
||||
|
||||
## Inspect UI
|
||||
|
||||
`sanderling inspect` is a separate mode of the same Go binary. It serves an embedded React bundle and reads `runs/` from disk, streaming file-watcher events over SSE so the UI updates as new steps land. It has no connection to the sidecar or the SDK; it only consumes the trace artifacts.
|
||||
`sanderling inspect` is a separate mode of the same Go binary. It serves an embedded React bundle and reads `runs/` from disk, streaming file-watcher events over SSE so the UI updates as new steps land. It has no connection to any driver; it only consumes the trace artifacts.
|
||||
|
||||
## Per-step cycle
|
||||
|
||||
@@ -37,14 +61,20 @@ The heart of the system is:
|
||||
pause ─► capture state ─► evaluate properties ─► pick action ─► resume ─► dispatch
|
||||
```
|
||||
|
||||
**Native (Android / iOS):**
|
||||
|
||||
1. The runner asks the driver to wait until the UI is idle.
|
||||
2. The runner sends `PAUSE` to the SDK over the agent socket. The SDK freezes the main runloop at a safe point.
|
||||
2. The runner sends `PAUSE` to the SDK over the Unix socket. The SDK freezes the main runloop at a safe point.
|
||||
3. The SDK sends back a `STATE` message: view hierarchy, coverage delta, logs since last step, exception list, snapshot values.
|
||||
4. The runner feeds state into goja. Extractors re-read; properties re-evaluate; the action generator returns a weighted tree.
|
||||
5. The runner writes the trace entry for this step.
|
||||
6. The runner picks an action by weight.
|
||||
7. The runner sends `RESUME` to the SDK, then dispatches the action through the driver (gRPC to sidecar, which talks to Maestro, which talks to UIAutomator or XCTest).
|
||||
7. The runner sends `RESUME` to the SDK, then dispatches the action through the driver (gRPC to sidecar → Maestro → UIAutomator or XCTest).
|
||||
8. Loop.
|
||||
|
||||
**Web (Chrome):**
|
||||
|
||||
Steps 2-3 use CDP to capture the DOM hierarchy and console logs directly; there is no SDK pause/resume. The rest of the cycle is identical.
|
||||
|
||||
The cycle runs hundreds of times per minute. Every step produces one row in `trace.jsonl` and one screenshot.
|
||||
|
||||
@@ -6,29 +6,33 @@ title: Design principles
|
||||
|
||||
## 1. The app owns introspection; the driver owns input
|
||||
|
||||
The in-app SDK knows the state: view hierarchy, coverage, logs, exceptions, custom extractors. Maestro causes the state to change through taps, swipes, typed text, and deep links. The Go runner decides what to do.
|
||||
On native platforms, the in-app SDK knows the state: view hierarchy, coverage, logs, exceptions, custom extractors. Maestro causes the state to change through taps, swipes, typed text, and deep links. The Go runner decides what to do.
|
||||
|
||||
Splitting these responsibilities is what makes the system work across iOS and Android with one spec surface. Neither Maestro nor the SDK alone is sufficient.
|
||||
|
||||
- Maestro can read a coarse accessibility tree, but not the real `UIView` or `View` hierarchy, not coverage, not in-process logs.
|
||||
- The SDK can see everything inside the app, but cannot dispatch UI events the way the OS would. Touch injection through Maestro goes through XCTest or UIAutomator, which the OS treats as real input.
|
||||
|
||||
On web, Chrome DevTools Protocol handles both input and introspection. No in-app SDK is needed.
|
||||
|
||||
## 2. One TypeScript surface across platforms
|
||||
|
||||
Spec authors write against `state.ax`, `state.logs`, `state.snapshots`, and so on, regardless of iOS or Android. Platform differences (back button semantics, system alerts, coverage format) are absorbed in the Go runner and the SDKs.
|
||||
Spec authors write against `state.ax`, `state.logs`, `state.snapshots`, and so on, regardless of iOS, Android, or web. Platform differences (back button semantics, system alerts, coverage format) are absorbed in the Go runner and the drivers.
|
||||
|
||||
Corollary: if a concept only exists on one platform, it does not belong in the spec API. It belongs behind a feature flag or an extractor.
|
||||
|
||||
## 3. The driver is an interface
|
||||
|
||||
Today `driver.Driver` has one production implementation (`maestro`) and a `mock` for tests. Tomorrow it might be Appium, direct XCTest, or UIAutomator. The runner never knows. This keeps the Maestro dependency contained. If we ever outgrow it, the blast radius is one package.
|
||||
`DeviceDriver` has two production implementations: `sidecar` (Maestro gRPC, for native) and `chrome` (CDP, for web), plus a `mock` for tests. The runner never knows which is wired in. Adding a new platform means adding a new implementation; nothing else changes.
|
||||
|
||||
## 4. Hot loops bypass Maestro
|
||||
## 4. Hot loops bypass Maestro (native)
|
||||
|
||||
Per-step introspection (hierarchy dump, coverage read, pause and resume) goes over a local Unix socket directly to the SDK. Only physical UI events go through Maestro's gRPC.
|
||||
On native, per-step introspection (hierarchy dump, coverage read, pause and resume) goes over a local Unix socket directly to the SDK. Only physical UI events go through Maestro's gRPC.
|
||||
|
||||
A 30-minute run is about 10,000 steps. Every step has at least one hierarchy dump and one coverage read. If those went through Maestro, the JVM sidecar would be the bottleneck. Instead the hot path is a 2 ms round-trip to an in-process Swift or Kotlin SDK.
|
||||
|
||||
On web, CDP is fast enough that a separate introspection channel is not needed.
|
||||
|
||||
## 5. Deterministic where it can be
|
||||
|
||||
A seeded PRNG drives action selection. Spec evaluation is pure given state and snapshots. The bundle hash and seed are recorded in `meta.json`.
|
||||
|
||||
@@ -4,8 +4,8 @@ title: Development
|
||||
|
||||
# Development
|
||||
|
||||
- [Design principles](./design-principles.html)
|
||||
- [Architecture](./architecture.html)
|
||||
- [Design principles](./design-principles/)
|
||||
- [Architecture](./architecture/)
|
||||
- v0.1.0 scope: [issue #4](https://github.com/priyanshujain/sanderling/issues/4)
|
||||
|
||||
## Building the docs site locally
|
||||
|
||||
+7
-12
@@ -4,21 +4,16 @@ title: Sanderling Manual
|
||||
|
||||
# Sanderling Manual
|
||||
|
||||
Autonomous property-based testing for mobile apps. Specs in TypeScript. Core in Go. Drives the app under test through Maestro and an in-app SDK.
|
||||
Autonomous property-based testing for mobile/web apps. Specs in TypeScript. Core in Go. Drives the app under test through UIAutomation/XCTest and an in-app SDK on Android/iOS and CDP on web.
|
||||
|
||||
Alpha: Android emulator only. Scope of v0.1.0 is tracked in [issue #4](https://github.com/priyanshujain/sanderling/issues/4).
|
||||
Alpha: Scope of v0.1.0 is tracked in [issue #4](https://github.com/priyanshujain/sanderling/issues/4).
|
||||
|
||||
|
||||
- [Getting started](./manual/getting-started.html)
|
||||
- [Writing specs](./manual/writing-specs.html)
|
||||
- [Runs](./manual/runs.html)
|
||||
- [Inspect](./manual/inspect.html)
|
||||
- [CLI reference](./manual/cli.html)
|
||||
|
||||
## Development
|
||||
|
||||
- [Design principles](./development/design-principles.html)
|
||||
- [Architecture](./development/architecture.html)
|
||||
- [Getting started](./manual/getting-started/)
|
||||
- [Writing specs](./manual/writing-specs/)
|
||||
- [Runs](./manual/runs/)
|
||||
- [Inspect](./manual/inspect/)
|
||||
- [CLI reference](./manual/cli/)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-2
@@ -17,7 +17,7 @@ Run a spec against an app for a fixed duration.
|
||||
| `--spec` | required | Path to the TypeScript spec. |
|
||||
| `--bundle-id` | required | Target app bundle ID (Android: applicationId). |
|
||||
| `--launcher-activity` | resolved | Optional `<pkg>/<activity>` to launch. Overrides default resolution. |
|
||||
| `--platform` | `android` | Target platform. Only `android` in the current alpha. |
|
||||
| `--platform` | `android` | Target platform: `android`, `ios`, or `web`. |
|
||||
| `--avd` | optional (android) | Android AVD name to boot if no device is connected. Required only when no device is connected and multiple AVDs exist. |
|
||||
| `--duration` | `5m` | Total test duration (`30s`, `5m`, `2h`, `1d`). |
|
||||
| `--seed` | `0` | PRNG seed. `0` uses a random seed and records it in `meta.json`. |
|
||||
@@ -33,7 +33,7 @@ Serve a local web UI for browsing traces. The positional argument is optional an
|
||||
| `--no-open` | `false` | Skip opening the default browser on startup. |
|
||||
| `--dev` | `false` | Reverse-proxy non-API requests to the Vite dev server on `127.0.0.1:5173`. |
|
||||
|
||||
See [the inspect UI page](inspect.md) for the panel reference and keyboard shortcuts.
|
||||
See [the inspect UI page](./inspect/) for the panel reference and keyboard shortcuts.
|
||||
|
||||
## `sanderling doctor`
|
||||
|
||||
|
||||
@@ -8,10 +8,17 @@ Install the CLI, link the SDK into your debug build, run a spec.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An Android emulator with API level 30 or newer.
|
||||
**Android / iOS:**
|
||||
|
||||
- An Android emulator with API level 30 or newer (or a connected device).
|
||||
- The app under test built as a debug variant with the sanderling Android SDK linked in.
|
||||
- `adb` on your PATH.
|
||||
|
||||
**Web:**
|
||||
|
||||
- Chrome installed. sanderling drives it via CDP; no other setup required.
|
||||
- No in-app SDK needed.
|
||||
|
||||
Run `sanderling doctor` to check the host environment.
|
||||
|
||||
## Install
|
||||
@@ -22,13 +29,13 @@ Run `sanderling doctor` to check the host environment.
|
||||
curl -fsSL https://raw.githubusercontent.com/priyanshujain/sanderling/master/install.sh | bash
|
||||
```
|
||||
|
||||
### Spec package (npm)
|
||||
### Spec package ([npm](https://www.npmjs.com/package/@sanderling/spec))
|
||||
|
||||
```sh
|
||||
npm install --save-dev @sanderling/spec
|
||||
```
|
||||
|
||||
### Android SDK (Maven Central)
|
||||
### Android SDK ([Maven Central](https://central.sonatype.com/artifact/io.github.priyanshujain.sanderling/sdk-android))
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
@@ -38,6 +45,8 @@ dependencies {
|
||||
|
||||
## Your first run
|
||||
|
||||
### Android
|
||||
|
||||
The repo ships a working sample at `examples/folio`, a Kotlin Multiplatform app with a TypeScript spec under `sanderling/spec.ts`. Install `just`, then from `examples/folio`:
|
||||
|
||||
```sh
|
||||
@@ -53,6 +62,18 @@ AVD=Pixel_7 just test
|
||||
|
||||
Persistent settings can live in a `.env` alongside the justfile (`AVD=Pixel_7`, `DURATION=5m`, and so on).
|
||||
|
||||
### Web
|
||||
|
||||
The repo also ships a web sample at `examples/folio-web`, a React/Vite app with the same domain logic. From `examples/folio-web`:
|
||||
|
||||
```sh
|
||||
just test # starts Chrome, runs the spec
|
||||
```
|
||||
|
||||
No emulator or SDK setup needed.
|
||||
|
||||
### Trace output
|
||||
|
||||
When the run ends, the trace lands in `sanderling/runs/<timestamp>/`:
|
||||
|
||||
```
|
||||
@@ -62,6 +83,6 @@ runs/2026-04-18T12-34-56/
|
||||
└── meta.json
|
||||
```
|
||||
|
||||
Browse it with `sanderling inspect` (see [inspect](./inspect.html)), or read `trace.jsonl` step by step.
|
||||
Browse it with `sanderling inspect` (see [inspect](./inspect/)), or read `trace.jsonl` step by step.
|
||||
|
||||
Next: [writing specs](./writing-specs.html).
|
||||
Next: [writing specs](./writing-specs/).
|
||||
@@ -4,7 +4,7 @@ title: Manual
|
||||
|
||||
# Manual
|
||||
|
||||
- [Getting started](./getting-started.html)
|
||||
- [Writing specs](./writing-specs.html)
|
||||
- [Runs](./runs.html)
|
||||
- [CLI reference](./cli.html)
|
||||
- [Getting started](./getting-started/)
|
||||
- [Writing specs](./writing-specs/)
|
||||
- [Runs](./runs/)
|
||||
- [CLI reference](./cli/)
|
||||
@@ -12,7 +12,7 @@ sanderling inspect [run-or-runs-dir] [--port N] [--no-open] [--dev]
|
||||
|
||||
The positional argument can be a runs directory or a single run directory (auto-detected by `meta.json`). Defaults to `./runs`.
|
||||
|
||||

|
||||

|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ Long-linear trajectories find bugs that restart-based testing structurally canno
|
||||
|
||||
## Setup cost amortizes
|
||||
|
||||
Preconditions (login, onboarding, consent dialogs) are written as weighted action generators gated on extractors. See [writing specs](./writing-specs.html#pattern-preconditions-login-onboarding). They fire only when applicable, so login happens once per run, not per step.
|
||||
Preconditions (login, onboarding, consent dialogs) are written as weighted action generators gated on extractors. See [writing specs](./writing-specs/#pattern-preconditions-login-onboarding). They fire only when applicable, so login happens once per run, not per step.
|
||||
|
||||
| Run length | Login cost | % of run |
|
||||
|---|---|---|
|
||||
|
||||
Vendored
-13
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>sanderling inspect</title>
|
||||
<script type="module" crossorigin src="/assets/index-DFpYQDqN.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BSJUf7yI.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -18,6 +18,8 @@ import (
|
||||
type ServerOptions struct {
|
||||
RunsDirectory string
|
||||
DevTarget string
|
||||
// AssetsFS overrides the default embedded dist FS. Intended for tests.
|
||||
AssetsFS fs.FS
|
||||
}
|
||||
|
||||
// Server holds the HTTP handlers for `sanderling inspect`.
|
||||
@@ -33,11 +35,15 @@ type Server struct {
|
||||
// server reverse-proxies non-API GETs to it; otherwise it serves embedded
|
||||
// assets from the dist FS.
|
||||
func NewServer(options ServerOptions) (*Server, error) {
|
||||
assetsFS := options.AssetsFS
|
||||
if assetsFS == nil {
|
||||
assetsFS = Assets()
|
||||
}
|
||||
server := &Server{
|
||||
options: options,
|
||||
cache: NewCache(options.RunsDirectory),
|
||||
watcher: NewWatcher(options.RunsDirectory),
|
||||
assets: spaHandler(Assets()),
|
||||
assets: spaHandler(assetsFS),
|
||||
}
|
||||
if options.DevTarget != "" {
|
||||
proxy, err := newDevProxy(options.DevTarget)
|
||||
@@ -207,12 +213,12 @@ func spaHandler(assets fs.FS) http.Handler {
|
||||
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
||||
clean := strings.TrimPrefix(path.Clean(request.URL.Path), "/")
|
||||
if clean == "" {
|
||||
serveIndex(responseWriter, request, assets)
|
||||
serveIndex(responseWriter, assets)
|
||||
return
|
||||
}
|
||||
file, err := assets.Open(clean)
|
||||
if err != nil {
|
||||
serveIndex(responseWriter, request, assets)
|
||||
serveIndex(responseWriter, assets)
|
||||
return
|
||||
}
|
||||
file.Close()
|
||||
@@ -220,7 +226,7 @@ func spaHandler(assets fs.FS) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func serveIndex(responseWriter http.ResponseWriter, request *http.Request, assets fs.FS) {
|
||||
func serveIndex(responseWriter http.ResponseWriter, assets fs.FS) {
|
||||
file, err := assets.Open("index.html")
|
||||
if err != nil {
|
||||
http.Error(responseWriter, "index.html missing from embedded assets", http.StatusInternalServerError)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
@@ -11,11 +12,18 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/priyanshujain/sanderling/internal/trace"
|
||||
)
|
||||
|
||||
var testAssetsFS fs.FS = fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{
|
||||
Data: []byte(`<!doctype html><html><body><div id="root"></div></body></html>`),
|
||||
},
|
||||
}
|
||||
|
||||
func newFixtureServer(t *testing.T) (*Server, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
@@ -48,7 +56,7 @@ func newFixtureServer(t *testing.T) (*Server, string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server, err := NewServer(ServerOptions{RunsDirectory: root})
|
||||
server, err := NewServer(ServerOptions{RunsDirectory: root, AssetsFS: testAssetsFS})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
+23
-21
@@ -1,8 +1,8 @@
|
||||
# @sanderling/spec
|
||||
|
||||
TypeScript spec API for [sanderling](https://github.com/priyanshujain/sanderling), a property-based UI fuzzer for mobile apps.
|
||||
TypeScript spec API for [sanderling](https://github.com/priyanshujain/sanderling), a property-based UI fuzzer for mobile and web apps.
|
||||
|
||||
Spec authors write specs in TypeScript that describe what an app should *always* do (safety invariants), generate weighted actions to exercise the app, and extract structured state from the accessibility tree. The `sanderling` CLI picks up the spec and drives the app under test.
|
||||
Spec authors write properties (what the app must always or eventually do), extractors (structured state from the UI), and action generators (what sanderling is allowed to do). The `sanderling` CLI evaluates the spec in a loop against a running app.
|
||||
|
||||
## Install
|
||||
|
||||
@@ -13,27 +13,29 @@ npm install --save-dev @sanderling/spec
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { extract, always, actions, Tap, weighted } from "@sanderling/spec";
|
||||
import { extract, always, eventually, now, actions, weighted, taps, swipes, InputText, Tap } from "@sanderling/spec";
|
||||
|
||||
export const spec = {
|
||||
extract: extract((tree) => ({
|
||||
onHomeScreen: tree.some((n) => n.text === "Home"),
|
||||
})),
|
||||
const loggedIn = extract((s) => !!s.ax.find("id:home-tab-bar"));
|
||||
const balance = extract<number>((s) => (s.snapshots.balance as number) ?? 0);
|
||||
|
||||
always: always(({ state }) => state.onHomeScreen || !state.startedOnHome),
|
||||
|
||||
actions: actions(({ tree }) =>
|
||||
weighted([
|
||||
[1, Tap(tree.first((n) => n.text === "Checkout"))],
|
||||
]),
|
||||
),
|
||||
export const properties = {
|
||||
balanceNeverNegative: always(() => balance.current >= 0),
|
||||
loginSucceeds: eventually(() => loggedIn.current).within(30, "seconds"),
|
||||
};
|
||||
|
||||
const doLogin = actions(() => {
|
||||
if (loggedIn.current) return [];
|
||||
const email = state.ax.find("id:email-field");
|
||||
const submit = state.ax.find("id:sign-in-button");
|
||||
if (!email || !submit) return [];
|
||||
return [InputText({ into: email, text: "[email protected]" }), Tap({ on: submit })];
|
||||
});
|
||||
|
||||
export const actions = weighted(
|
||||
[50, doLogin],
|
||||
[10, taps],
|
||||
[2, swipes],
|
||||
);
|
||||
```
|
||||
|
||||
## Version compatibility
|
||||
|
||||
`@sanderling/spec` is released in lockstep with the sanderling CLI. Pin the same major/minor version as your installed `sanderling` binary.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
Works identically across Android, iOS, and web targets.
|
||||
Executable
BIN
Binary file not shown.
Reference in new issue
Block a user