mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 11:07:10 +00:00
replay ui fix (#56)
* refactor: rename inspect to replay across the codebase Renames inspect-ui/ to replay-ui/, internal/inspect/ to internal/replay/, the CLI subcommand from `sanderling inspect` to `sanderling replay`, and updates all references in docs, Makefile, README, and Go comments. * feat(replay-ui): show spec filename with full path on hover RunList and RunDetail now render the basename of spec_path (e.g. login.spec.ts) with the full path available as a title tooltip.
This commit is contained in:
71 files changed
+103
-91
No files matched your search
+9
-7
@@ -38,15 +38,15 @@ pkg/spec/dist/
|
|||||||
# goreleaser local output
|
# goreleaser local output
|
||||||
/dist/
|
/dist/
|
||||||
|
|
||||||
# inspect web bundle output; built by `make web-build` before go build.
|
# replay 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.
|
# Only .gitkeep is tracked so //go:embed all:dist compiles on a fresh checkout.
|
||||||
/internal/inspect/dist/*
|
/internal/replay/dist/*
|
||||||
!/internal/inspect/dist/.gitkeep
|
!/internal/replay/dist/.gitkeep
|
||||||
|
|
||||||
# inspect web frontend
|
# replay web frontend
|
||||||
inspect-ui/node_modules/
|
replay-ui/node_modules/
|
||||||
inspect-ui/dist/
|
replay-ui/dist/
|
||||||
inspect-ui/.vite/
|
replay-ui/.vite/
|
||||||
|
|
||||||
# folio-web frontend
|
# folio-web frontend
|
||||||
examples/folio-web/node_modules/
|
examples/folio-web/node_modules/
|
||||||
@@ -59,3 +59,5 @@ examples/folio-web/.vite/
|
|||||||
|
|
||||||
# Personal research notes
|
# Personal research notes
|
||||||
/research/
|
/research/
|
||||||
|
|
||||||
|
/talk/
|
||||||
@@ -19,10 +19,10 @@ PAGE_OUT := $(patsubst docs/%.md,build/site/%/index.html,$(PAGE_SRC))
|
|||||||
DOCS_OUT := $(INDEX_OUT) $(PAGE_OUT)
|
DOCS_OUT := $(INDEX_OUT) $(PAGE_OUT)
|
||||||
DOCS_TEMPLATE := docs/_template/page.html
|
DOCS_TEMPLATE := docs/_template/page.html
|
||||||
|
|
||||||
INSPECT_DIST := internal/inspect/dist
|
REPLAY_DIST := internal/replay/dist
|
||||||
WEB_DIST := inspect-ui/dist
|
WEB_DIST := replay-ui/dist
|
||||||
|
|
||||||
.PHONY: bootstrap proto sidecar sanderling install test test-go test-browser test-kotlin test-spec-api web-typecheck web-build web-dev inspect-dev docs clean release-cli release-npm-dry
|
.PHONY: bootstrap proto sidecar sanderling install test test-go test-browser test-kotlin test-spec-api web-typecheck web-build web-dev replay-dev docs clean release-cli release-npm-dry
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
$(GO) mod download
|
$(GO) mod download
|
||||||
@@ -48,19 +48,19 @@ install: $(SIDECAR_EMBED) web-build
|
|||||||
@dest="$$($(GO) env GOBIN)"; [ -n "$$dest" ] || dest="$$($(GO) env GOPATH)/bin"; echo "installed sanderling to $$dest"
|
@dest="$$($(GO) env GOBIN)"; [ -n "$$dest" ] || dest="$$($(GO) env GOPATH)/bin"; echo "installed sanderling to $$dest"
|
||||||
|
|
||||||
web-build:
|
web-build:
|
||||||
cd inspect-ui && bun install --frozen-lockfile && bun run build
|
cd replay-ui && bun install --frozen-lockfile && bun run build
|
||||||
mkdir -p $(INSPECT_DIST)
|
mkdir -p $(REPLAY_DIST)
|
||||||
rm -rf $(INSPECT_DIST)/assets $(INSPECT_DIST)/fonts
|
rm -rf $(REPLAY_DIST)/assets $(REPLAY_DIST)/fonts
|
||||||
cp -R $(WEB_DIST)/. $(INSPECT_DIST)/
|
cp -R $(WEB_DIST)/. $(REPLAY_DIST)/
|
||||||
|
|
||||||
web-dev:
|
web-dev:
|
||||||
cd inspect-ui && bun run dev
|
cd replay-ui && bun run dev
|
||||||
|
|
||||||
inspect-dev: $(SIDECAR_EMBED)
|
replay-dev: $(SIDECAR_EMBED)
|
||||||
$(GO) run -tags withsidecar ./cmd/sanderling inspect --dev
|
$(GO) run -tags withsidecar ./cmd/sanderling replay --dev
|
||||||
|
|
||||||
web-typecheck:
|
web-typecheck:
|
||||||
cd inspect-ui && bun install --frozen-lockfile && bun run typecheck
|
cd replay-ui && bun install --frozen-lockfile && bun run typecheck
|
||||||
|
|
||||||
$(SIDECAR_JAR): $(SIDECAR_SRC)
|
$(SIDECAR_JAR): $(SIDECAR_SRC)
|
||||||
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:shadowJar
|
ANDROID_HOME=$(ANDROID_HOME) $(GRADLE) :sidecar:shadowJar
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ Autonomous property-based testing for mobile and web apps.
|
|||||||
|
|
||||||
- [Getting started](https://priyanshujain.github.io/sanderling/manual/getting-started.html)
|
- [Getting started](https://priyanshujain.github.io/sanderling/manual/getting-started.html)
|
||||||
- [Writing specs](https://priyanshujain.github.io/sanderling/manual/writing-specs.html)
|
- [Writing specs](https://priyanshujain.github.io/sanderling/manual/writing-specs.html)
|
||||||
- [`sanderling inspect` UI](https://priyanshujain.github.io/sanderling/manual/inspect.html)
|
- [`sanderling replay` UI](https://priyanshujain.github.io/sanderling/manual/replay.html)
|
||||||
- Examples: [folio](https://github.com/priyanshujain/sanderling/tree/master/examples/folio) (KMP, Android/iOS/web), [folio-web](https://github.com/priyanshujain/sanderling/tree/master/examples/folio-web) (React + Vite)
|
- Examples: [folio](https://github.com/priyanshujain/sanderling/tree/master/examples/folio) (KMP, Android/iOS/web), [folio-web](https://github.com/priyanshujain/sanderling/tree/master/examples/folio-web) (React + Vite)
|
||||||
- [Architecture](https://priyanshujain.github.io/sanderling/development/architecture.html)
|
- [Architecture](https://priyanshujain.github.io/sanderling/development/architecture.html)
|
||||||
|
|
||||||
After a `sanderling test` run, browse traces locally with `sanderling inspect`. It opens a web UI for stepping through actions, screenshots, snapshots, residual formulas, and exceptions.
|
After a `sanderling test` run, browse traces locally with `sanderling replay`. It opens a web UI for stepping through actions, screenshots, snapshots, residual formulas, and exceptions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Usage:
|
|||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
test Run a spec against an app for a fixed duration.
|
test Run a spec against an app for a fixed duration.
|
||||||
inspect Serve a local web UI for browsing runs/.
|
replay Serve a local web UI for browsing runs/.
|
||||||
doctor Check that the host environment is ready to run sanderling.
|
doctor Check that the host environment is ready to run sanderling.
|
||||||
version Print the sanderling version.
|
version Print the sanderling version.
|
||||||
|
|
||||||
@@ -98,12 +98,12 @@ func run(args []string, stdout, stderr io.Writer) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return runTest(options, stdout)
|
return runTest(options, stdout)
|
||||||
case "inspect":
|
case "replay":
|
||||||
options, err := parseInspectArgs(args[2:], stderr)
|
options, err := parseReplayArgs(args[2:], stderr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return runInspect(options, stdout)
|
return runReplay(options, stdout)
|
||||||
case "doctor":
|
case "doctor":
|
||||||
return runDoctor(args[2:], stdout, stderr)
|
return runDoctor(args[2:], stdout, stderr)
|
||||||
case "version", "-v", "--version":
|
case "version", "-v", "--version":
|
||||||
|
|||||||
@@ -14,29 +14,29 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/priyanshujain/sanderling/internal/inspect"
|
"github.com/priyanshujain/sanderling/internal/replay"
|
||||||
)
|
)
|
||||||
|
|
||||||
type inspectOptions struct {
|
type replayOptions struct {
|
||||||
port int
|
port int
|
||||||
noOpen bool
|
noOpen bool
|
||||||
dev bool
|
dev bool
|
||||||
directory string
|
directory string
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseInspectArgs(args []string, stderr io.Writer) (inspectOptions, error) {
|
func parseReplayArgs(args []string, stderr io.Writer) (replayOptions, error) {
|
||||||
flagSet := flag.NewFlagSet("inspect", flag.ContinueOnError)
|
flagSet := flag.NewFlagSet("replay", flag.ContinueOnError)
|
||||||
flagSet.SetOutput(stderr)
|
flagSet.SetOutput(stderr)
|
||||||
var options inspectOptions
|
var options replayOptions
|
||||||
flagSet.IntVar(&options.port, "port", 0, "TCP port to listen on (0 = ephemeral)")
|
flagSet.IntVar(&options.port, "port", 0, "TCP port to listen on (0 = ephemeral)")
|
||||||
flagSet.BoolVar(&options.noOpen, "no-open", false, "do not open the default browser on startup")
|
flagSet.BoolVar(&options.noOpen, "no-open", false, "do not open the default browser on startup")
|
||||||
flagSet.BoolVar(&options.dev, "dev", false, "reverse-proxy non-API requests to "+inspect.DevTarget)
|
flagSet.BoolVar(&options.dev, "dev", false, "reverse-proxy non-API requests to "+replay.DevTarget)
|
||||||
if err := flagSet.Parse(args); err != nil {
|
if err := flagSet.Parse(args); err != nil {
|
||||||
return inspectOptions{}, err
|
return replayOptions{}, err
|
||||||
}
|
}
|
||||||
rest := flagSet.Args()
|
rest := flagSet.Args()
|
||||||
if len(rest) > 1 {
|
if len(rest) > 1 {
|
||||||
return inspectOptions{}, errors.New("inspect takes at most one positional argument (run or runs directory)")
|
return replayOptions{}, errors.New("replay takes at most one positional argument (run or runs directory)")
|
||||||
}
|
}
|
||||||
if len(rest) == 1 {
|
if len(rest) == 1 {
|
||||||
options.directory = rest[0]
|
options.directory = rest[0]
|
||||||
@@ -44,16 +44,16 @@ func parseInspectArgs(args []string, stderr io.Writer) (inspectOptions, error) {
|
|||||||
return options, nil
|
return options, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func runInspect(options inspectOptions, stdout io.Writer) error {
|
func runReplay(options replayOptions, stdout io.Writer) error {
|
||||||
runsDirectory, deepLinkID, err := inspect.ResolveRunsDirectory(options.directory)
|
runsDirectory, deepLinkID, err := replay.ResolveRunsDirectory(options.directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
devTarget := ""
|
devTarget := ""
|
||||||
if options.dev {
|
if options.dev {
|
||||||
devTarget = inspect.DevTarget
|
devTarget = replay.DevTarget
|
||||||
}
|
}
|
||||||
server, err := inspect.NewServer(inspect.ServerOptions{
|
server, err := replay.NewServer(replay.ServerOptions{
|
||||||
RunsDirectory: runsDirectory,
|
RunsDirectory: runsDirectory,
|
||||||
DevTarget: devTarget,
|
DevTarget: devTarget,
|
||||||
})
|
})
|
||||||
@@ -67,7 +67,7 @@ func runInspect(options inspectOptions, stdout io.Writer) error {
|
|||||||
}
|
}
|
||||||
address := listener.Addr().(*net.TCPAddr)
|
address := listener.Addr().(*net.TCPAddr)
|
||||||
browseURL := buildBrowseURL(address, deepLinkID)
|
browseURL := buildBrowseURL(address, deepLinkID)
|
||||||
fmt.Fprintf(stdout, "sanderling inspect listening on %s (runs=%s)\n", browseURL, runsDirectory)
|
fmt.Fprintf(stdout, "sanderling replay listening on %s (runs=%s)\n", browseURL, runsDirectory)
|
||||||
|
|
||||||
context, cancel := context.WithCancel(context.Background())
|
context, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseInspectArgs_Defaults(t *testing.T) {
|
func TestParseReplayArgs_Defaults(t *testing.T) {
|
||||||
options, err := parseInspectArgs(nil, io.Discard)
|
options, err := parseReplayArgs(nil, io.Discard)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -17,8 +17,8 @@ func TestParseInspectArgs_Defaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseInspectArgs_AllFlags(t *testing.T) {
|
func TestParseReplayArgs_AllFlags(t *testing.T) {
|
||||||
options, err := parseInspectArgs([]string{"--port", "9090", "--no-open", "--dev", "/tmp/runs"}, io.Discard)
|
options, err := parseReplayArgs([]string{"--port", "9090", "--no-open", "--dev", "/tmp/runs"}, io.Discard)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -27,19 +27,19 @@ func TestParseInspectArgs_AllFlags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseInspectArgs_RejectsTooManyPositional(t *testing.T) {
|
func TestParseReplayArgs_RejectsTooManyPositional(t *testing.T) {
|
||||||
_, err := parseInspectArgs([]string{"a", "b"}, io.Discard)
|
_, err := parseReplayArgs([]string{"a", "b"}, io.Discard)
|
||||||
if err == nil || !strings.Contains(err.Error(), "at most one") {
|
if err == nil || !strings.Contains(err.Error(), "at most one") {
|
||||||
t.Fatalf("expected too-many-args error, got %v", err)
|
t.Fatalf("expected too-many-args error, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_HelpListsInspectCommand(t *testing.T) {
|
func TestRun_HelpListsReplayCommand(t *testing.T) {
|
||||||
var stdout bytes.Buffer
|
var stdout bytes.Buffer
|
||||||
if err := run([]string{"sanderling"}, &stdout, io.Discard); err != nil {
|
if err := run([]string{"sanderling"}, &stdout, io.Discard); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(stdout.String(), "inspect") {
|
if !strings.Contains(stdout.String(), "replay") {
|
||||||
t.Errorf("usage missing inspect command: %q", stdout.String())
|
t.Errorf("usage missing replay command: %q", stdout.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File renamed without changes.
Vendored
+1
-1
@@ -36,7 +36,7 @@
|
|||||||
<li><a href="__ROOT__manual/writing-specs/">Writing specs</a></li>
|
<li><a href="__ROOT__manual/writing-specs/">Writing specs</a></li>
|
||||||
<li><a href="__ROOT__manual/spec-language/">Spec language reference</a></li>
|
<li><a href="__ROOT__manual/spec-language/">Spec language reference</a></li>
|
||||||
<li><a href="__ROOT__manual/runs/">Runs</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/replay/">Replay</a></li>
|
||||||
<li><a href="__ROOT__manual/cli/">CLI reference</a></li>
|
<li><a href="__ROOT__manual/cli/">CLI reference</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Development</h3>
|
<h3>Development</h3>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ flowchart TB
|
|||||||
DC["Device / Emulator"]
|
DC["Device / Emulator"]
|
||||||
CH["Chrome (CDP)"]
|
CH["Chrome (CDP)"]
|
||||||
RD[("runs/")]
|
RD[("runs/")]
|
||||||
IN["sanderling inspect\nHTTP + SSE"]
|
IN["sanderling replay\nHTTP + SSE"]
|
||||||
UI["Web UI (React)"]
|
UI["Web UI (React)"]
|
||||||
|
|
||||||
D -->|gRPC| SC
|
D -->|gRPC| SC
|
||||||
@@ -45,9 +45,9 @@ flowchart TB
|
|||||||
|
|
||||||
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.
|
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
|
## Replay 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 any driver; it only consumes the trace artifacts.
|
`sanderling replay` 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
|
## Per-step cycle
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ Architectural and organizational decisions worth recording. Each entry states th
|
|||||||
|
|
||||||
## Directory and Package Organization
|
## Directory and Package Organization
|
||||||
|
|
||||||
### `web/` renamed to `inspect-ui/`
|
### `web/` renamed to `replay-ui/`
|
||||||
|
|
||||||
The directory containing the React/TypeScript frontend is `inspect-ui/`, not `web/`. The name `web/` was ambiguous (the project also has a web/Chrome driver target). `inspect-ui/` makes the purpose explicit: this is the UI for the `sanderling inspect` command.
|
The directory containing the React/TypeScript frontend is `replay-ui/`, not `web/`. The name `web/` was ambiguous (the project also has a web/Chrome driver target). `replay-ui/` makes the purpose explicit: this is the UI for the `sanderling replay` command.
|
||||||
|
|
||||||
### Keep `internal/`
|
### Keep `internal/`
|
||||||
|
|
||||||
@@ -22,9 +22,9 @@ Go's `internal/` directory restriction prevents any code outside this module fro
|
|||||||
|
|
||||||
The `driver.go` file defines the `DeviceDriver` interface. Concrete implementations live in subdirectories: `sidecar/` (gRPC to the native sidecar), `chrome/` (CDP), `mock/` (tests). This pattern keeps the runner and verifier decoupled from any specific platform.
|
The `driver.go` file defines the `DeviceDriver` interface. Concrete implementations live in subdirectories: `sidecar/` (gRPC to the native sidecar), `chrome/` (CDP), `mock/` (tests). This pattern keeps the runner and verifier decoupled from any specific platform.
|
||||||
|
|
||||||
### `internal/verifier/marshal.go` moves to `internal/inspect/`
|
### `internal/verifier/marshal.go` moves to `internal/replay/`
|
||||||
|
|
||||||
`marshal.go` serializes LTL formulas to JSON for the inspect UI. That is an inspect concern, not a verifier concern. Verifier should not know inspect exists.
|
`marshal.go` serializes LTL formulas to JSON for the replay UI. That is a replay concern, not a verifier concern. Verifier should not know replay exists.
|
||||||
|
|
||||||
### `internal/verifier/bindings.go` splits into `types.go` + `bindings.go`
|
### `internal/verifier/bindings.go` splits into `types.go` + `bindings.go`
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ Android device enumeration, AVD selection, and emulator boot logic moves to `int
|
|||||||
|
|
||||||
Driver setup, agent connection, verifier init, trace setup, and runner orchestration extract to `internal/testrun/`. `cmd/sanderling/` wires CLI flags to `testrun` calls and nothing more.
|
Driver setup, agent connection, verifier init, trace setup, and runner orchestration extract to `internal/testrun/`. `cmd/sanderling/` wires CLI flags to `testrun` calls and nothing more.
|
||||||
|
|
||||||
### `internal/inspect/runs.go` splits into multiple files
|
### `internal/replay/runs.go` splits into multiple files
|
||||||
|
|
||||||
429 LOC with mixed concerns (cache, file I/O, JSON decoding, summary types) splits into at least `runs_cache.go` and `runs_decode.go` within the same package.
|
429 LOC with mixed concerns (cache, file I/O, JSON decoding, summary types) splits into at least `runs_cache.go` and `runs_decode.go` within the same package.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ Alpha: Scope of v0.1.0 is tracked in [issue #4](https://github.com/priyanshujain
|
|||||||
- [Getting started](./manual/getting-started/)
|
- [Getting started](./manual/getting-started/)
|
||||||
- [Writing specs](./manual/writing-specs/)
|
- [Writing specs](./manual/writing-specs/)
|
||||||
- [Runs](./manual/runs/)
|
- [Runs](./manual/runs/)
|
||||||
- [Inspect](./manual/inspect/)
|
- [Replay](./manual/replay/)
|
||||||
- [CLI reference](./manual/cli/)
|
- [CLI reference](./manual/cli/)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+2
-2
@@ -25,7 +25,7 @@ Run a spec against an app for a fixed duration.
|
|||||||
| `--output` | `./runs` | Output directory for traces. |
|
| `--output` | `./runs` | Output directory for traces. |
|
||||||
| `--clear-data` | `false` | Clear app data before launching so the run starts from a fresh install. |
|
| `--clear-data` | `false` | Clear app data before launching so the run starts from a fresh install. |
|
||||||
|
|
||||||
## `sanderling inspect [run-or-runs-dir]`
|
## `sanderling replay [run-or-runs-dir]`
|
||||||
|
|
||||||
Serve a local web UI for browsing traces. The positional argument is optional and may point at either a runs directory (the parent of many runs) or a single run directory (auto-detected by the presence of `meta.json`). Defaults to `./runs`.
|
Serve a local web UI for browsing traces. The positional argument is optional and may point at either a runs directory (the parent of many runs) or a single run directory (auto-detected by the presence of `meta.json`). Defaults to `./runs`.
|
||||||
|
|
||||||
@@ -35,7 +35,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. |
|
| `--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`. |
|
| `--dev` | `false` | Reverse-proxy non-API requests to the Vite dev server on `127.0.0.1:5173`. |
|
||||||
|
|
||||||
See [the inspect UI page](./inspect/) for the panel reference and keyboard shortcuts.
|
See [the replay UI page](./replay/) for the panel reference and keyboard shortcuts.
|
||||||
|
|
||||||
## `sanderling doctor`
|
## `sanderling doctor`
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,6 @@ runs/2026-04-18T12-34-56/
|
|||||||
└── meta.json
|
└── meta.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Browse it with `sanderling inspect` (see [inspect](./inspect/)), or read `trace.jsonl` step by step.
|
Browse it with `sanderling replay` (see [replay](./replay/)), or read `trace.jsonl` step by step.
|
||||||
|
|
||||||
Next: [writing specs](./writing-specs/).
|
Next: [writing specs](./writing-specs/).
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
---
|
---
|
||||||
title: sanderling inspect
|
title: sanderling replay
|
||||||
---
|
---
|
||||||
|
|
||||||
# sanderling inspect
|
# sanderling replay
|
||||||
|
|
||||||
Local web UI for exploring runs produced by `sanderling test`. Reads `runs/<id>/meta.json` and `runs/<id>/trace.jsonl` from disk.
|
Local web UI for exploring runs produced by `sanderling test`. Reads `runs/<id>/meta.json` and `runs/<id>/trace.jsonl` from disk.
|
||||||
|
|
||||||
```
|
```
|
||||||
sanderling inspect [run-or-runs-dir] [--port N] [--no-open] [--dev]
|
sanderling replay [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`.
|
The positional argument can be a runs directory or a single run directory (auto-detected by `meta.json`). Defaults to `./runs`.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Panels
|
## Panels
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ Arrow keys inside a tablist or listbox yield to those widgets. Use `j`/`k` when
|
|||||||
|
|
||||||
`/runs/:id/steps/:n` links to a specific step. Use it in issues or PRs when pointing at a violation.
|
`/runs/:id/steps/:n` links to a specific step. Use it in issues or PRs when pointing at a violation.
|
||||||
|
|
||||||
The run index auto-refreshes over SSE as new runs land, so `sanderling inspect` and `sanderling test` can run side by side.
|
The run index auto-refreshes over SSE as new runs land, so `sanderling replay` and `sanderling test` can run side by side.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ Two-process loop while iterating on the UI:
|
|||||||
|
|
||||||
```
|
```
|
||||||
make web-dev # bun + vite on http://127.0.0.1:5173
|
make web-dev # bun + vite on http://127.0.0.1:5173
|
||||||
make inspect-dev # sanderling inspect --dev, proxies non-API to 5173
|
make replay-dev # sanderling replay --dev, proxies non-API to 5173
|
||||||
```
|
```
|
||||||
|
|
||||||
Single binary with the bundle embedded:
|
Single binary with the bundle embedded:
|
||||||
@@ -265,7 +265,7 @@ func violated() reduceResult { return reduceResult{status: statusViolated} }
|
|||||||
|
|
||||||
// violatedWith reports a violation that originates at the given sub-formula
|
// violatedWith reports a violation that originates at the given sub-formula
|
||||||
// with the given reason. The reason distinguishes a thrown predicate from a
|
// with the given reason. The reason distinguishes a thrown predicate from a
|
||||||
// plain false so callers (and the inspect UI) can render the cause.
|
// plain false so callers (and the replay UI) can render the cause.
|
||||||
func violatedWith(formula Formula, reason string) reduceResult {
|
func violatedWith(formula Formula, reason string) reduceResult {
|
||||||
return reduceResult{
|
return reduceResult{
|
||||||
status: statusViolated,
|
status: statusViolated,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type PredicateLabel interface {
|
|||||||
// ErrorFormula represents a thunk that threw during evaluation. The verifier
|
// ErrorFormula represents a thunk that threw during evaluation. The verifier
|
||||||
// substitutes one of these into the residual when MarshalJSON would otherwise
|
// substitutes one of these into the residual when MarshalJSON would otherwise
|
||||||
// have to encode an opaque thunk that already errored. It exists so that the
|
// have to encode an opaque thunk that already errored. It exists so that the
|
||||||
// inspect UI can render "predicate threw" inline.
|
// replay UI can render "predicate threw" inline.
|
||||||
type ErrorFormula struct {
|
type ErrorFormula struct {
|
||||||
Message string
|
Message string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package inspect serves the embedded web UI for browsing recorded runs.
|
// Package replay serves the embedded web UI for browsing recorded runs.
|
||||||
package inspect
|
package replay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
@@ -11,11 +11,11 @@ var distFS embed.FS
|
|||||||
|
|
||||||
// Assets returns the embedded SPA bundle rooted at the dist directory.
|
// Assets returns the embedded SPA bundle rooted at the dist directory.
|
||||||
// In Stage 2 this contains a stub index.html. Stage 4 wires the real
|
// In Stage 2 this contains a stub index.html. Stage 4 wires the real
|
||||||
// bundle in via Makefile (copy inspect-ui/dist -> internal/inspect/dist).
|
// bundle in via Makefile (copy replay-ui/dist -> internal/replay/dist).
|
||||||
func Assets() fs.FS {
|
func Assets() fs.FS {
|
||||||
sub, err := fs.Sub(distFS, "dist")
|
sub, err := fs.Sub(distFS, "dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic("inspect: dist embed missing: " + err.Error())
|
panic("replay: dist embed missing: " + err.Error())
|
||||||
}
|
}
|
||||||
return sub
|
return sub
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package inspect
|
package replay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
File renamed without changes.
@@ -1,4 +1,4 @@
|
|||||||
package inspect
|
package replay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package inspect
|
package replay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package inspect
|
package replay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package inspect
|
package replay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package inspect
|
package replay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -22,7 +22,7 @@ type ServerOptions struct {
|
|||||||
AssetsFS fs.FS
|
AssetsFS fs.FS
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server holds the HTTP handlers for `sanderling inspect`.
|
// Server holds the HTTP handlers for `sanderling replay`.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
options ServerOptions
|
options ServerOptions
|
||||||
cache *Cache
|
cache *Cache
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package inspect
|
package replay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package inspect
|
package replay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package inspect
|
package replay
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
@@ -172,7 +172,7 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
// one would poison the verifier's previous/current extractor
|
// one would poison the verifier's previous/current extractor
|
||||||
// advance, so the next clean step would compare against this
|
// advance, so the next clean step would compare against this
|
||||||
// transient state and emit false-positive violations. We still
|
// transient state and emit false-positive violations. We still
|
||||||
// record the step (hierarchy + screenshot) for inspect-side
|
// record the step (hierarchy + screenshot) for replay-side
|
||||||
// debugging, but skip the verifier entirely and pick the next
|
// debugging, but skip the verifier entirely and pick the next
|
||||||
// action against the unchanged prior state to keep the loop
|
// action against the unchanged prior state to keep the loop
|
||||||
// progressing.
|
// progressing.
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ type Witness struct {
|
|||||||
|
|
||||||
// ExtractorChange records the prev/curr JSON values of an extractor whose
|
// ExtractorChange records the prev/curr JSON values of an extractor whose
|
||||||
// observation differed between two consecutive steps. Surfaced under
|
// observation differed between two consecutive steps. Surfaced under
|
||||||
// violation rows in the inspect UI as a "what changed at this step"
|
// violation rows in the replay UI as a "what changed at this step"
|
||||||
// breadcrumb.
|
// breadcrumb.
|
||||||
type ExtractorChange struct {
|
type ExtractorChange struct {
|
||||||
Prev json.RawMessage `json:"prev"`
|
Prev json.RawMessage `json:"prev"`
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ type Exception struct {
|
|||||||
|
|
||||||
// ExtractorChange records a single extractor's value transition across one
|
// ExtractorChange records a single extractor's value transition across one
|
||||||
// step. Used to surface "what changed at this step" breadcrumbs at violation
|
// step. Used to surface "what changed at this step" breadcrumbs at violation
|
||||||
// markers in the inspect UI.
|
// markers in the replay UI.
|
||||||
type ExtractorChange struct {
|
type ExtractorChange struct {
|
||||||
Prev []byte
|
Prev []byte
|
||||||
Curr []byte
|
Curr []byte
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ func encodeExtractorValue(value goja.Value) []byte {
|
|||||||
|
|
||||||
// ChangedExtractors returns the named extractors whose value changed between
|
// ChangedExtractors returns the named extractors whose value changed between
|
||||||
// the prior PushSnapshot and the current one. The map is keyed by extractor
|
// the prior PushSnapshot and the current one. The map is keyed by extractor
|
||||||
// name; unnamed extractors (extractor_N fallback) are included so the inspect
|
// name; unnamed extractors (extractor_N fallback) are included so the replay
|
||||||
// UI can still display them under a numeric label. The very first snapshot
|
// UI can still display them under a numeric label. The very first snapshot
|
||||||
// emits every non-null extractor as a change (Prev=null, Curr=current) since
|
// emits every non-null extractor as a change (Prev=null, Curr=current) since
|
||||||
// the runner can otherwise misread "no diff yet" as "nothing initialized".
|
// the runner can otherwise misread "no diff yet" as "nothing initialized".
|
||||||
@@ -525,7 +525,7 @@ func (v *Verifier) NewlyViolatedProperties() []string {
|
|||||||
// Residuals returns the residual formula for each registered property after
|
// Residuals returns the residual formula for each registered property after
|
||||||
// the most recent EvaluateProperties call. Properties whose violation was
|
// the most recent EvaluateProperties call. Properties whose violation was
|
||||||
// caused by a thrown predicate surface as ErrorFormula, sourced from the
|
// caused by a thrown predicate surface as ErrorFormula, sourced from the
|
||||||
// captured witness, so the inspect UI can render "predicate threw" inline.
|
// captured witness, so the replay UI can render "predicate threw" inline.
|
||||||
func (v *Verifier) Residuals() map[string]ltl.Formula {
|
func (v *Verifier) Residuals() map[string]ltl.Formula {
|
||||||
residuals := map[string]ltl.Formula{}
|
residuals := map[string]ltl.Formula{}
|
||||||
for name, evaluator := range v.evaluators {
|
for name, evaluator := range v.evaluators {
|
||||||
|
|||||||
File renamed without changes.
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>sanderling inspect</title>
|
<title>sanderling replay</title>
|
||||||
<link rel="stylesheet" href="/src/styles/typography.css" />
|
<link rel="stylesheet" href="/src/styles/typography.css" />
|
||||||
<link rel="stylesheet" href="/src/styles/tokens.css" />
|
<link rel="stylesheet" href="/src/styles/tokens.css" />
|
||||||
<link rel="stylesheet" href="/src/styles/app.css" />
|
<link rel="stylesheet" href="/src/styles/app.css" />
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@sanderling/inspect-web",
|
"name": "@sanderling/replay-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
export type ThemeMode = "light" | "dark";
|
export type ThemeMode = "light" | "dark";
|
||||||
const STORAGE_KEY = "sanderling-inspect-theme";
|
const STORAGE_KEY = "sanderling-replay-theme";
|
||||||
|
|
||||||
function detectInitial(): ThemeMode {
|
function detectInitial(): ThemeMode {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -15,6 +15,11 @@ import { useStep } from "../hooks/useStep";
|
|||||||
import { useKeyboardNav } from "../hooks/useKeyboardNav";
|
import { useKeyboardNav } from "../hooks/useKeyboardNav";
|
||||||
import { useTheme } from "../hooks/useTheme";
|
import { useTheme } from "../hooks/useTheme";
|
||||||
|
|
||||||
|
function basename(specPath: string): string {
|
||||||
|
const index = specPath.lastIndexOf("/");
|
||||||
|
return index >= 0 ? specPath.slice(index + 1) : specPath;
|
||||||
|
}
|
||||||
|
|
||||||
interface RunHistory {
|
interface RunHistory {
|
||||||
names: string[];
|
names: string[];
|
||||||
lanes: PropertyLane[];
|
lanes: PropertyLane[];
|
||||||
@@ -242,7 +247,7 @@ export default function RunDetail() {
|
|||||||
<Link to="/">runs</Link>
|
<Link to="/">runs</Link>
|
||||||
<span>{run.id}</span>
|
<span>{run.id}</span>
|
||||||
<span>
|
<span>
|
||||||
<strong>{run.spec_path}</strong> seed={run.seed}
|
<strong title={run.spec_path}>{basename(run.spec_path)}</strong> seed={run.seed}
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
step {stepIndex} / {stepCount ?? 0}
|
step {stepIndex} / {stepCount ?? 0}
|
||||||
@@ -28,6 +28,11 @@ function formatStartedAt(value: string): string {
|
|||||||
return date.toLocaleString();
|
return date.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function basename(specPath: string): string {
|
||||||
|
const index = specPath.lastIndexOf("/");
|
||||||
|
return index >= 0 ? specPath.slice(index + 1) : specPath;
|
||||||
|
}
|
||||||
|
|
||||||
export default function RunList() {
|
export default function RunList() {
|
||||||
const [runs, setRuns] = useState<RunSummary[] | null>(null);
|
const [runs, setRuns] = useState<RunSummary[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -76,7 +81,7 @@ export default function RunList() {
|
|||||||
<td>
|
<td>
|
||||||
<Link to={`/runs/${run.id}`}>{formatStartedAt(run.started_at)}</Link>
|
<Link to={`/runs/${run.id}`}>{formatStartedAt(run.started_at)}</Link>
|
||||||
</td>
|
</td>
|
||||||
<td>{run.spec_path}</td>
|
<td title={run.spec_path}>{basename(run.spec_path)}</td>
|
||||||
<td>{run.seed}</td>
|
<td>{run.seed}</td>
|
||||||
<td>{run.platform}</td>
|
<td>{run.platform}</td>
|
||||||
<td>{formatDuration(run.duration_millis)}</td>
|
<td>{formatDuration(run.duration_millis)}</td>
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Reference in new issue
Block a user