From 94d951131239891359c3fb685a79ec26f8f72165 Mon Sep 17 00:00:00 2001 From: pjay Date: Sat, 6 Jun 2026 13:59:08 +0530 Subject: [PATCH] test: full test-suite refactor sweep (#61) * chore(test): start test-suite refactor sweep * test(ltl): pin exact multi-obligation residual AST * test(ltl): table-test finalize Kleene connective combinations * test(ltl): pin reduce over pending inner for bound, Or, Not * test(ltl): marshal bounded Always steps/duration/deadline * test(verifier): cover LTL combinator verdict transitions and within unit panic * test(verifier): table-test DecodeAction kinds and lastAction field exposure * test(verifier): assert WithPlatform(ios) reaches the picker host and key pool * test(verifier): widen weighted-selection assertion to a 5x skew margin * test(verifier): un-skip ax-find round trip with a committed tree fixture * test(runner): pin isWDADrop to sidecar reconnect-failed message origin * test(runner): assert PressKey/Wait trace encoding records kind-specific fields * test(runner): cover RenderSummary unsupported-verbs surfacing branch * test(trace): set Hierarchy in round-trip and lock lossy Tree contract Also add a -race concurrent WriteStep test that asserts N well-formed JSONL lines, catching torn lines if the writer mutex is dropped. * test(trace): round-trip witnesses/changes/metrics/exceptions, pin step-0 witness * test(trace): document ViolationsAreGreppable grep contract and lock-free WriteScreenshot * test(hierarchy): cover invalid-JSON and malformed-bounds parser paths * test(trace): guard writer mutex via WriteStep/Close race on w.file * test(replay): drop unfailable assets and devproxy assertions * test(replay): cache reuses on equal mtime, reparses after append * test(replay): violation marker falls back to detection step when attributed missing * test(replay): corrupt meta/trace dirs return 500 with error body * test(replay): SSE client receives runs.changed after a broadcast * test(replay): Run coalesces creates, ignores write/chmod, closes subs on cancel * fix(sidecar): synchronize health fixture writes and exercise healthError * test(sidecar): cover swipe/longpress/doubletap/erase/presskey/metrics/logs translations * test(sidecar): cover DoubleTapSelector composition and mid-gesture cancel * test(sidecar): assert gRPC error status surfaces from action RPC * fix(chrome): route action methods through runCtx so caller cancellation aborts CDP * fix(chrome): route hierarchy/screenshot/waitidle/metrics through runCtx * refactor(ios): extract pure simctl JSON parsers * refactor(ios): add command-runner seams for EnsureSimulator * test(ios): table-test simctl parsers and EnsureSimulator seams * test(sidecarassets): cover placeholder build path * test(sidecarassets): assert reuse via sentinel bytes not mtime * test(bundler): cover properties-only spec registration * refactor(testrun): extract prepareBundleInputs from Execute * test(testrun): cover prepareBundleInputs aliases and missing-runtime error * test(testrun): table-test resolveRuntimeSibling search edges * test(testrun): exact-output tests for progressHandler line format * fix(cmd): point bundle-check aliases at pkg/spec/src * test(cmd): smoke-test bundle-check resolves spec aliases * test(cmd): table-test hier-check parse and FindAll on fixture * test(cmd): unit-test buildBrowseURL deep-link vs root * test(cmd): drop flaky TestRun_Doctor that launched real Chromium * test(cmd): pin pipeline error to bundle resolution on web platform * test(replay-ui): add bun test script * ci(replay-ui): run bun test via make web-test target * ci(replay-ui): point bun cache key at replay-ui/bun.lock * test(replay-ui): exercise real URL encoding and non-ok throw in getJson * refactor(replay-ui): extract snapshot flatten/getAtPath into lib module * test(replay-ui): pin snapshot flatten/getAtPath path round-trip * refactor(replay-ui): extract action selector/format into lib module * test(replay-ui): pin action selector parse and row formatting * refactor(replay-ui): share one statusFor between panels * refactor(replay-ui): extract run-history derivation into lib module * test(replay-ui): pin shared statusFor precedence and ordering * test(replay-ui): pin run-history derivation alignment * refactor(replay-ui): export clampIndex for testing * refactor(replay-ui): extract keyboard-nav dispatch into pure module * refactor(replay-ui): extract metrics formatters into lib module * test(replay-ui): pin clampIndex step boundaries * test(replay-ui): pin keyboard-nav ownership and key routing * test(replay-ui): pin metrics formatters and path gap handling * refactor(sidecar): expose device-output parsers as internal for testing * test(sidecar): table-test device-output parsers against malformed input * test(sidecar): cover logcat parsing year inference and line skipping * test(sidecar): pin pressKey keycode mapping and unknown-key rejection * test(sidecar): metrics bundleId falls back to launched app and honors override * test(sidecar): loosen deadline upper bound to tolerate slow CI scheduling * test(web-runtime): export selector builders for unit tests * test(web-runtime): guard sanitize cycle, function, and depth limits * test(web-runtime): table-test selector builder quoting and escaping * test(sidecar): collapse scalar-forwarding RPC tests into a table * test(replay-ui): dedup step/summary fixtures into shared module * test(ios): collapse pickSimulator point-tests into a table --- .github/workflows/ci.yml | 2 +- Makefile | 7 +- cmd/internal-tools/bundle-check/main.go | 21 +- cmd/internal-tools/bundle-check/main_test.go | 57 ++++ .../bundle-check/testdata/spec.ts | 12 + cmd/internal-tools/hier-check/main_test.go | 44 +++ .../hier-check/testdata/dump.json | 17 + cmd/sanderling/main_test.go | 29 +- cmd/sanderling/replay_test.go | 20 ++ internal/bundler/bundler_test.go | 23 ++ internal/driver/chrome/driver.go | 68 ++-- internal/driver/chrome/driver_test.go | 42 +++ internal/driver/sidecar/client_test.go | 319 +++++++++++++++++- internal/hierarchy/hierarchy_test.go | 66 ++++ internal/ios/ios.go | 49 ++- internal/ios/ios_test.go | 262 ++++++++++---- internal/ltl/evaluator_test.go | 42 +++ internal/ltl/finalize_test.go | 43 +++ internal/ltl/formula_test.go | 36 +- internal/replay/runs_test.go | 73 ++++ internal/replay/server_test.go | 103 +++++- internal/replay/watcher_test.go | 119 +++++++ internal/runner/runner_test.go | 86 ++++- internal/sidecarassets/embed_stub_test.go | 27 ++ .../sidecarassets/embed_withsidecar_test.go | 13 +- internal/testrun/progress_test.go | 53 +++ internal/testrun/testrun.go | 67 +++- internal/testrun/testrun_test.go | 123 ++++++- internal/trace/writer.go | 3 + internal/trace/writer_test.go | 129 +++++++ internal/verifier/ax_integration_test.go | 8 +- internal/verifier/combinator_test.go | 118 +++++++ internal/verifier/marshal_test.go | 149 ++++++++ internal/verifier/testdata/ax_find_tree.json | 15 + internal/verifier/verifier_test.go | 50 ++- pkg/spec/src/web-runtime.ts | 3 + pkg/spec/test/web-runtime.test.ts | 156 +++++++++ replay-ui/package.json | 3 +- replay-ui/src/__tests__/action-format.test.ts | 74 ++++ replay-ui/src/__tests__/api.test.ts | 35 +- replay-ui/src/__tests__/fixtures.ts | 15 + replay-ui/src/__tests__/keyboard-nav.test.ts | 103 ++++++ .../src/__tests__/metrics-format.test.ts | 63 ++++ .../src/__tests__/property-status.test.ts | 42 +++ replay-ui/src/__tests__/run-history.test.ts | 81 +++++ replay-ui/src/__tests__/snapshot-diff.test.ts | 67 ++++ replay-ui/src/__tests__/useStep.test.ts | 24 ++ replay-ui/src/api.ts | 2 +- replay-ui/src/hooks/useKeyboardNav.ts | 79 +---- replay-ui/src/hooks/useStep.ts | 2 +- replay-ui/src/lib/action-format.ts | 92 +++++ replay-ui/src/lib/keyboard-nav.ts | 76 +++++ replay-ui/src/lib/metrics-format.ts | 43 +++ replay-ui/src/lib/property-status.ts | 29 ++ replay-ui/src/lib/run-history.ts | 78 +++++ replay-ui/src/lib/snapshot-diff.ts | 101 ++++++ replay-ui/src/panels/ActionList.tsx | 90 +---- replay-ui/src/panels/MetricsChart.tsx | 50 +-- replay-ui/src/panels/SnapshotTable.tsx | 98 +----- replay-ui/src/panels/ViolationsPanel.tsx | 24 +- replay-ui/src/routes/RunDetail.tsx | 78 +---- .../dev/sanderling/sidecar/DriverBackend.kt | 14 +- .../sidecar/DeviceOutputParserTest.kt | 148 ++++++++ .../sanderling/sidecar/DriverServiceTest.kt | 35 ++ .../dev/sanderling/sidecar/InputTextTest.kt | 23 ++ .../sanderling/sidecar/StabilityPollTest.kt | 2 +- 66 files changed, 3419 insertions(+), 606 deletions(-) create mode 100644 cmd/internal-tools/bundle-check/main_test.go create mode 100644 cmd/internal-tools/bundle-check/testdata/spec.ts create mode 100644 cmd/internal-tools/hier-check/main_test.go create mode 100644 cmd/internal-tools/hier-check/testdata/dump.json create mode 100644 internal/sidecarassets/embed_stub_test.go create mode 100644 internal/testrun/progress_test.go create mode 100644 internal/verifier/combinator_test.go create mode 100644 internal/verifier/marshal_test.go create mode 100644 internal/verifier/testdata/ax_find_tree.json create mode 100644 replay-ui/src/__tests__/action-format.test.ts create mode 100644 replay-ui/src/__tests__/fixtures.ts create mode 100644 replay-ui/src/__tests__/keyboard-nav.test.ts create mode 100644 replay-ui/src/__tests__/metrics-format.test.ts create mode 100644 replay-ui/src/__tests__/property-status.test.ts create mode 100644 replay-ui/src/__tests__/run-history.test.ts create mode 100644 replay-ui/src/__tests__/snapshot-diff.test.ts create mode 100644 replay-ui/src/__tests__/useStep.test.ts create mode 100644 replay-ui/src/lib/action-format.ts create mode 100644 replay-ui/src/lib/keyboard-nav.ts create mode 100644 replay-ui/src/lib/metrics-format.ts create mode 100644 replay-ui/src/lib/property-status.ts create mode 100644 replay-ui/src/lib/run-history.ts create mode 100644 replay-ui/src/lib/snapshot-diff.ts create mode 100644 sidecar/src/test/kotlin/dev/sanderling/sidecar/DeviceOutputParserTest.kt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a6dfc5..9373c61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: uses: actions/cache@v4 with: path: ~/.bun/install/cache - key: bun-${{ runner.os }}-${{ hashFiles('web/bun.lock') }} + key: bun-${{ runner.os }}-${{ hashFiles('replay-ui/bun.lock') }} restore-keys: | bun-${{ runner.os }}- diff --git a/Makefile b/Makefile index 57a4d7d..df0bee4 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ DOCS_TEMPLATE := docs/_template/page.html REPLAY_DIST := internal/replay/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 replay-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-test web-typecheck web-build web-dev replay-dev docs clean release-cli release-npm-dry bootstrap: $(GO) mod download @@ -69,11 +69,14 @@ $(SIDECAR_EMBED): $(SIDECAR_JAR) mkdir -p $(dir $@) cp $< $@ -test: test-go test-spec-api web-typecheck +test: test-go test-spec-api web-typecheck web-test test-go: $(GO) test $(GO_PACKAGES) +web-test: + cd replay-ui && bun install --frozen-lockfile && bun test + # Drives small web fixtures and the chrome driver through real headless Chrome. # Kept out of `test` because it needs a Chrome binary on PATH. test-browser: diff --git a/cmd/internal-tools/bundle-check/main.go b/cmd/internal-tools/bundle-check/main.go index be9fb43..5c25c72 100644 --- a/cmd/internal-tools/bundle-check/main.go +++ b/cmd/internal-tools/bundle-check/main.go @@ -9,6 +9,17 @@ import ( "github.com/priyanshujain/sanderling/internal/bundler" ) +func bundleSpec(specSrc, entryFile string) (bundler.Result, error) { + return bundler.Bundle(bundler.Options{ + EntryFile: entryFile, + Aliases: map[string]string{ + "@sanderling/spec": filepath.Join(specSrc, "index.ts"), + "@sanderling/spec/defaults": filepath.Join(specSrc, "defaults/index.ts"), + "@sanderling/spec/defaults/properties": filepath.Join(specSrc, "defaults/properties.ts"), + }, + }) +} + func main() { if len(os.Args) < 2 { fmt.Fprintln(os.Stderr, "usage: bundle-check ") @@ -26,16 +37,8 @@ func main() { fmt.Fprintf(os.Stderr, "getwd: %v\n", err) os.Exit(1) } - specApiPath := filepath.Join(repoRoot, "pkg/spec-api/src/index.ts") - defaultPropertiesPath := filepath.Join(repoRoot, "pkg/spec-api/src/defaults/properties.ts") - result, err := bundler.Bundle(bundler.Options{ - EntryFile: entryFile, - Aliases: map[string]string{ - "@sanderling/spec": specApiPath, - "@sanderling/spec/defaults/properties": defaultPropertiesPath, - }, - }) + result, err := bundleSpec(filepath.Join(repoRoot, "pkg/spec/src"), entryFile) if err != nil { fmt.Fprintf(os.Stderr, "bundle: %v\n", err) os.Exit(1) diff --git a/cmd/internal-tools/bundle-check/main_test.go b/cmd/internal-tools/bundle-check/main_test.go new file mode 100644 index 0000000..b963556 --- /dev/null +++ b/cmd/internal-tools/bundle-check/main_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// repoSpecSrc walks up from the test's working directory to the repo root and +// returns pkg/spec/src. The bundle-check aliases once pointed at the +// non-existent pkg/spec-api/src, so esbuild resolved nothing and the tool +// silently emitted an empty bundle. +func repoSpecSrc(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + candidate := filepath.Join(dir, "pkg/spec/src") + if _, err := os.Stat(filepath.Join(candidate, "index.ts")); err == nil { + return candidate + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("pkg/spec/src/index.ts not found above test dir") + } + dir = parent + } +} + +func TestBundleSpec_ResolvesSpecAliases(t *testing.T) { + specSrc := repoSpecSrc(t) + entry, err := filepath.Abs(filepath.Join("testdata", "spec.ts")) + if err != nil { + t.Fatal(err) + } + + result, err := bundleSpec(specSrc, entry) + if err != nil { + t.Fatalf("bundle: %v", err) + } + if len(result.JavaScript) == 0 { + t.Fatal("empty bundle: aliases resolved to nothing") + } + if result.SHA256 == "" { + t.Fatal("missing bundle hash") + } + + second, err := bundleSpec(specSrc, entry) + if err != nil { + t.Fatal(err) + } + if result.SHA256 != second.SHA256 { + t.Errorf("unstable bundle hash: %s vs %s", result.SHA256, second.SHA256) + } +} diff --git a/cmd/internal-tools/bundle-check/testdata/spec.ts b/cmd/internal-tools/bundle-check/testdata/spec.ts new file mode 100644 index 0000000..6332cb2 --- /dev/null +++ b/cmd/internal-tools/bundle-check/testdata/spec.ts @@ -0,0 +1,12 @@ +import { Tap, actions } from "@sanderling/spec"; +import { noUncaughtExceptions } from "@sanderling/spec/defaults/properties"; +import * as defaults from "@sanderling/spec/defaults"; + +const tapPrimary = actions((state) => { + const button = state.ax.find("desc:primary"); + return button ? [Tap({ on: button })] : []; +}); + +export const properties = { noUncaughtExceptions }; +export const actionsRoot = tapPrimary; +export const defaultsKeys = Object.keys(defaults); diff --git a/cmd/internal-tools/hier-check/main_test.go b/cmd/internal-tools/hier-check/main_test.go new file mode 100644 index 0000000..8fa37cc --- /dev/null +++ b/cmd/internal-tools/hier-check/main_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/priyanshujain/sanderling/internal/hierarchy" +) + +func TestHierCheck_ParseAndFind(t *testing.T) { + content, err := os.ReadFile(filepath.Join("testdata", "dump.json")) + if err != nil { + t.Fatal(err) + } + tree, err := hierarchy.Parse(string(content)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got := len(tree.Elements); got != 4 { + t.Fatalf("element count: got %d, want 4", got) + } + + cases := []struct { + selector string + want int + }{ + {"desc:row", 2}, + {"id:title", 1}, + {"text:Bob", 1}, + {"id:missing", 0}, + } + for _, c := range cases { + if got := len(tree.FindAll(c.selector)); got != c.want { + t.Errorf("%s: got %d matches, want %d", c.selector, got, c.want) + } + } +} + +func TestHierCheck_MalformedReturnsError(t *testing.T) { + if _, err := hierarchy.Parse(`{"attributes": {`); err == nil { + t.Fatal("expected error for malformed hierarchy, got nil") + } +} diff --git a/cmd/internal-tools/hier-check/testdata/dump.json b/cmd/internal-tools/hier-check/testdata/dump.json new file mode 100644 index 0000000..3faf9b5 --- /dev/null +++ b/cmd/internal-tools/hier-check/testdata/dump.json @@ -0,0 +1,17 @@ +{ + "attributes": {"class": "android.widget.LinearLayout", "package": "app", "bounds": "[0,0,1080,2340]"}, + "children": [ + { + "attributes": {"resource-id": "app:id/title", "text": "Hello", "bounds": "[10,20,200,60]"}, + "children": [] + }, + { + "attributes": {"resource-id": "app:id/row", "text": "Alice", "content-desc": "row", "bounds": "[0,100,1080,200]"}, + "children": [] + }, + { + "attributes": {"resource-id": "app:id/row", "text": "Bob", "content-desc": "row", "bounds": "[0,200,1080,300]"}, + "children": [] + } + ] +} diff --git a/cmd/sanderling/main_test.go b/cmd/sanderling/main_test.go index e09af34..2cb99a5 100644 --- a/cmd/sanderling/main_test.go +++ b/cmd/sanderling/main_test.go @@ -152,17 +152,6 @@ func TestRun_UnknownCommand(t *testing.T) { } } -func TestRun_Doctor(t *testing.T) { - var stdout bytes.Buffer - // Doctor may pass or fail depending on host environment; we just want to - // confirm it runs and emits per-check lines. - _ = run([]string{"sanderling", "doctor"}, &stdout, io.Discard) - output := stdout.String() - if !strings.Contains(output, "OK") && !strings.Contains(output, "FAIL") { - t.Errorf("doctor output missing OK/FAIL lines: %q", output) - } -} - func TestParseTestArgs_AcceptsIosPlatform(t *testing.T) { options, err := parseTestArgs([]string{ "--spec", "s.ts", @@ -193,24 +182,20 @@ func TestParseTestArgs_IosDeviceFlag(t *testing.T) { } func TestRun_TestSubcommand_PipelineErrors(t *testing.T) { - // Without a real spec, a real device, or a bootable AVD the pipeline - // must surface a specific error rather than panicking — proves the flag - // wiring reaches the runner. + // Web skips host-dependent device boot and bundles first, so a missing spec + // deterministically surfaces a bundle-resolution error. This proves the + // flag wiring reaches the bundler rather than panicking on the way. err := run([]string{ "sanderling", "test", "--spec", "definitely-missing-spec.ts", - "--bundle-id", "com.example", - "--avd", "definitely-missing-avd", + "--bundle-id", "http://localhost:3000", + "--platform", "web", }, io.Discard, io.Discard) if err == nil { t.Fatal("expected error, got nil") } message := err.Error() - ok := strings.Contains(message, "bundle") || - strings.Contains(message, "device") || - strings.Contains(message, "AVD") || - strings.Contains(message, "emulator") - if !ok { - t.Errorf("expected pipeline error (bundle/device/AVD/emulator), got %v", err) + if !strings.HasPrefix(message, "bundle spec:") || !strings.Contains(message, "definitely-missing-spec.ts") { + t.Errorf("expected bundle-resolution error for the missing spec, got %v", err) } } diff --git a/cmd/sanderling/replay_test.go b/cmd/sanderling/replay_test.go index ab01d68..1bcdd40 100644 --- a/cmd/sanderling/replay_test.go +++ b/cmd/sanderling/replay_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "io" + "net" "strings" "testing" ) @@ -34,6 +35,25 @@ func TestParseReplayArgs_RejectsTooManyPositional(t *testing.T) { } } +func TestBuildBrowseURL(t *testing.T) { + address := &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 8080} + cases := []struct { + name string + deepLinkID string + want string + }{ + {"root", "", "http://127.0.0.1:8080/"}, + {"deep link", "20240101-120000", "http://127.0.0.1:8080/runs/20240101-120000"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := buildBrowseURL(address, c.deepLinkID); got != c.want { + t.Errorf("buildBrowseURL(%q) = %q, want %q", c.deepLinkID, got, c.want) + } + }) + } +} + func TestRun_HelpListsReplayCommand(t *testing.T) { var stdout bytes.Buffer if err := run([]string{"sanderling"}, &stdout, io.Discard); err != nil { diff --git a/internal/bundler/bundler_test.go b/internal/bundler/bundler_test.go index 41e9cea..56221f9 100644 --- a/internal/bundler/bundler_test.go +++ b/internal/bundler/bundler_test.go @@ -199,6 +199,29 @@ func TestBundle_RegistersWithoutSetupExport(t *testing.T) { } } +func TestBundle_RegistersPropertiesOnlySpec(t *testing.T) { + directory := t.TempDir() + runtimePath := filepath.Join(directory, "runtime.ts") + specPath := filepath.Join(directory, "spec.ts") + if err := os.WriteFile(runtimePath, []byte(`export {};`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(specPath, []byte(`export const properties = "PROPS_MARKER";`), 0o600); err != nil { + t.Fatal(err) + } + result, err := Bundle(Options{EntryFile: specPath, RuntimeFile: runtimePath}) + if err != nil { + t.Fatalf("properties-only spec should bundle: %v", err) + } + body := string(result.JavaScript) + if !strings.Contains(body, "PROPS_MARKER") { + t.Errorf("properties export not registered:\n%s", body) + } + if !strings.Contains(body, "globalThis.properties") { + t.Errorf("trailer should assign globalThis.properties:\n%s", body) + } +} + func TestBundle_ReportsSyntaxErrors(t *testing.T) { entry := writeFixture(t, "broken.ts", `const x = ;`) _, err := Bundle(Options{EntryFile: entry}) diff --git a/internal/driver/chrome/driver.go b/internal/driver/chrome/driver.go index 829a8a2..793e104 100644 --- a/internal/driver/chrome/driver.go +++ b/internal/driver/chrome/driver.go @@ -131,13 +131,17 @@ func (d *Driver) Terminate(_ context.Context) error { return nil } -func (d *Driver) Tap(_ context.Context, x, y int) error { - return chromedp.Run(d.tabCtx, +func (d *Driver) Tap(ctx context.Context, x, y int) error { + runCtx, cancel := d.runCtx(ctx) + defer cancel() + return chromedp.Run(runCtx, chromedp.MouseClickXY(float64(x), float64(y)), ) } -func (d *Driver) TapSelector(_ context.Context, selector string) error { +func (d *Driver) TapSelector(ctx context.Context, selector string) error { + runCtx, cancel := d.runCtx(ctx) + defer cancel() target, isXPath, err := TranslateStringSelector(selector) if err != nil { // Fall back to passing the string straight through; chromedp will @@ -145,9 +149,9 @@ func (d *Driver) TapSelector(_ context.Context, selector string) error { target = selector } if isXPath { - return chromedp.Run(d.tabCtx, chromedp.Click(target, chromedp.NodeVisible, chromedp.BySearch)) + return chromedp.Run(runCtx, chromedp.Click(target, chromedp.NodeVisible, chromedp.BySearch)) } - return chromedp.Run(d.tabCtx, chromedp.Click(target, chromedp.NodeVisible)) + return chromedp.Run(runCtx, chromedp.Click(target, chromedp.NodeVisible)) } // doubleTapGap is the inter-tap delay for DoubleTap: short enough to land both @@ -177,8 +181,10 @@ func webDoubleTap(ctx context.Context, tap func() error) error { return tap() } -func (d *Driver) InputText(_ context.Context, text string) error { - return chromedp.Run(d.tabCtx, +func (d *Driver) InputText(callerCtx context.Context, text string) error { + runCtx, cancel := d.runCtx(callerCtx) + defer cancel() + return chromedp.Run(runCtx, chromedp.ActionFunc(func(ctx context.Context) error { // Select any existing content so InsertText replaces rather than appends. if err := chromedp.Evaluate(` @@ -201,8 +207,10 @@ func (d *Driver) ReplacesTextOnInput() bool { // EraseText clears the focused field. InputText above already replaces via // select-all, so the character count is not needed to bound the deletion. -func (d *Driver) EraseText(_ context.Context, _ int) error { - return chromedp.Run(d.tabCtx, +func (d *Driver) EraseText(callerCtx context.Context, _ int) error { + runCtx, cancel := d.runCtx(callerCtx) + defer cancel() + return chromedp.Run(runCtx, chromedp.ActionFunc(func(ctx context.Context) error { if err := chromedp.Evaluate(` (function() { @@ -216,7 +224,9 @@ func (d *Driver) EraseText(_ context.Context, _ int) error { ) } -func (d *Driver) Swipe(_ context.Context, fromX, fromY, toX, toY int, duration time.Duration) error { +func (d *Driver) Swipe(ctx context.Context, fromX, fromY, toX, toY int, duration time.Duration) error { + runCtx, cancel := d.runCtx(ctx) + defer cancel() millis := max(duration.Milliseconds(), 50) script := fmt.Sprintf(` (function() { @@ -238,18 +248,22 @@ func (d *Driver) Swipe(_ context.Context, fromX, fromY, toX, toY int, duration t fromX, fromY, toX, toY, ) - return chromedp.Run(d.tabCtx, chromedp.Evaluate(script, nil)) + return chromedp.Run(runCtx, chromedp.Evaluate(script, nil)) } -func (d *Driver) PressKey(_ context.Context, key string) error { +func (d *Driver) PressKey(ctx context.Context, key string) error { k, ok := keyMap[key] if !ok { return fmt.Errorf("unsupported key: %q", key) } - return chromedp.Run(d.tabCtx, chromedp.KeyEvent(k)) + runCtx, cancel := d.runCtx(ctx) + defer cancel() + return chromedp.Run(runCtx, chromedp.KeyEvent(k)) } -func (d *Driver) LongPress(_ context.Context, x, y int) error { +func (d *Driver) LongPress(ctx context.Context, x, y int) error { + runCtx, cancel := d.runCtx(ctx) + defer cancel() script := fmt.Sprintf(` (function() { const el = document.elementFromPoint(%d, %d); @@ -263,7 +277,7 @@ func (d *Driver) LongPress(_ context.Context, x, y int) error { x, y, x, y, ) - return chromedp.Run(d.tabCtx, chromedp.Evaluate(script, nil)) + return chromedp.Run(runCtx, chromedp.Evaluate(script, nil)) } // keyMap covers the keys web specs may emit (enter/tab/escape/arrows). @@ -279,7 +293,9 @@ var keyMap = map[string]string{ "right": kb.ArrowRight, } -func (d *Driver) Hierarchy(_ context.Context) (string, error) { +func (d *Driver) Hierarchy(ctx context.Context) (string, error) { + runCtx, cancel := d.runCtx(ctx) + defer cancel() script := ` (function() { const route = window.location.hash.replace(/^#/, '').split('?')[0] || '/'; @@ -325,7 +341,7 @@ func (d *Driver) Hierarchy(_ context.Context) (string, error) { })()` var result any - if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &result)); err != nil { + if err := chromedp.Run(runCtx, chromedp.Evaluate(script, &result)); err != nil { return "", fmt.Errorf("hierarchy: %w", err) } bytes, err := json.Marshal(result) @@ -335,9 +351,11 @@ func (d *Driver) Hierarchy(_ context.Context) (string, error) { return string(bytes), nil } -func (d *Driver) Screenshot(_ context.Context) (driver.Image, error) { +func (d *Driver) Screenshot(ctx context.Context) (driver.Image, error) { + runCtx, cancel := d.runCtx(ctx) + defer cancel() var buf []byte - if err := chromedp.Run(d.tabCtx, chromedp.CaptureScreenshot(&buf)); err != nil { + if err := chromedp.Run(runCtx, chromedp.CaptureScreenshot(&buf)); err != nil { return driver.Image{}, fmt.Errorf("screenshot: %w", err) } w, h := pngDimensions(buf) @@ -376,8 +394,10 @@ func (d *Driver) RecentLogs(_ context.Context, since time.Time, minLevel string) return result, nil } -func (d *Driver) WaitForIdle(_ context.Context, _ time.Duration) error { - return chromedp.Run(d.tabCtx, chromedp.WaitReady("body", chromedp.ByQuery)) +func (d *Driver) WaitForIdle(ctx context.Context, _ time.Duration) error { + runCtx, cancel := d.runCtx(ctx) + defer cancel() + return chromedp.Run(runCtx, chromedp.WaitReady("body", chromedp.ByQuery)) } func (d *Driver) Health(_ context.Context) (driver.Health, error) { @@ -389,14 +409,16 @@ func (d *Driver) Health(_ context.Context) (driver.Health, error) { } } -func (d *Driver) Metrics(_ context.Context, _ string) (driver.Metrics, error) { +func (d *Driver) Metrics(ctx context.Context, _ string) (driver.Metrics, error) { + runCtx, cancel := d.runCtx(ctx) + defer cancel() var result map[string]any script := ` (function() { const mem = performance.memory || {}; return {heap: mem.usedJSHeapSize || 0, totalMem: mem.totalJSHeapSize || 0}; })()` - if err := chromedp.Run(d.tabCtx, chromedp.Evaluate(script, &result)); err != nil { + if err := chromedp.Run(runCtx, chromedp.Evaluate(script, &result)); err != nil { return driver.Metrics{}, nil } heap, _ := result["heap"].(float64) diff --git a/internal/driver/chrome/driver_test.go b/internal/driver/chrome/driver_test.go index 399f663..166d8d8 100644 --- a/internal/driver/chrome/driver_test.go +++ b/internal/driver/chrome/driver_test.go @@ -5,10 +5,52 @@ package chrome import ( "context" "encoding/json" + "errors" "testing" "time" ) +// TestActionMethods_HonorCallerCancellation confirms the DeviceDriver action +// methods route through runCtx so a cancelled caller context aborts the CDP +// round-trip instead of blocking on d.tabCtx. Without this a hung browser would +// ignore step deadlines and Ctrl-C. +func TestActionMethods_HonorCallerCancellation(t *testing.T) { + d := New() + defer d.Terminate(context.Background()) + launchCtx, launchCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer launchCancel() + if err := d.Launch(launchCtx, "data:text/html,", false, nil); err != nil { + t.Fatalf("Launch: %v", err) + } + + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + + actions := map[string]func() error{ + "Tap": func() error { return d.Tap(cancelled, 1, 1) }, + "Swipe": func() error { return d.Swipe(cancelled, 1, 1, 2, 2, 50*time.Millisecond) }, + "LongPress": func() error { return d.LongPress(cancelled, 1, 1) }, + "PressKey": func() error { return d.PressKey(cancelled, "enter") }, + "InputText": func() error { return d.InputText(cancelled, "x") }, + "EraseText": func() error { return d.EraseText(cancelled, 1) }, + "TapSelector": func() error { return d.TapSelector(cancelled, "#go") }, + } + for name, action := range actions { + t.Run(name, func(t *testing.T) { + done := make(chan error, 1) + go func() { done <- action() }() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("want context.Canceled, got %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("action ignored cancelled caller ctx and blocked") + } + }) + } +} + // TestHierarchy_EditableFlag confirms the injected hierarchy script marks text // inputs, textareas, and contenteditable elements editable while leaving // buttons and non-text inputs alone. diff --git a/internal/driver/sidecar/client_test.go b/internal/driver/sidecar/client_test.go index d64d868..46182ac 100644 --- a/internal/driver/sidecar/client_test.go +++ b/internal/driver/sidecar/client_test.go @@ -9,6 +9,8 @@ import ( "time" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" driverpb "github.com/priyanshujain/sanderling/proto/driverpb" ) @@ -33,7 +35,18 @@ type fakeServer struct { imageWidth int32 imageHeight int32 + longPresses []int32 + doubleTaps []int32 + swipes []*driverpb.SwipeRequest + erases []int32 + pressedKeys []string + metricsBundles []string + logsRequests []*driverpb.RecentLogsRequest + logEntries []*driverpb.LogEntry + metrics *driverpb.MetricsResponse + healthError error + tapError error } func (s *fakeServer) Health(_ context.Context, _ *driverpb.Empty) (*driverpb.HealthStatus, error) { @@ -68,6 +81,9 @@ func (s *fakeServer) Terminate(_ context.Context, _ *driverpb.Empty) (*driverpb. func (s *fakeServer) Tap(_ context.Context, point *driverpb.Point) (*driverpb.Empty, error) { s.mutex.Lock() defer s.mutex.Unlock() + if s.tapError != nil { + return nil, s.tapError + } s.taps = append(s.taps, point.GetX(), point.GetY()) return &driverpb.Empty{}, nil } @@ -93,6 +109,58 @@ func (s *fakeServer) WaitForIdle(_ context.Context, duration *driverpb.Duration) return &driverpb.Empty{}, nil } +func (s *fakeServer) LongPress(_ context.Context, point *driverpb.Point) (*driverpb.Empty, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + s.longPresses = append(s.longPresses, point.GetX(), point.GetY()) + return &driverpb.Empty{}, nil +} + +func (s *fakeServer) DoubleTap(_ context.Context, point *driverpb.Point) (*driverpb.Empty, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + s.doubleTaps = append(s.doubleTaps, point.GetX(), point.GetY()) + return &driverpb.Empty{}, nil +} + +func (s *fakeServer) Swipe(_ context.Context, request *driverpb.SwipeRequest) (*driverpb.Empty, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + s.swipes = append(s.swipes, request) + return &driverpb.Empty{}, nil +} + +func (s *fakeServer) EraseText(_ context.Context, request *driverpb.EraseTextRequest) (*driverpb.Empty, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + s.erases = append(s.erases, request.GetCharacterCount()) + return &driverpb.Empty{}, nil +} + +func (s *fakeServer) PressKey(_ context.Context, request *driverpb.PressKeyRequest) (*driverpb.Empty, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + s.pressedKeys = append(s.pressedKeys, request.GetKey()) + return &driverpb.Empty{}, nil +} + +func (s *fakeServer) RecentLogs(_ context.Context, request *driverpb.RecentLogsRequest) (*driverpb.LogEntries, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + s.logsRequests = append(s.logsRequests, request) + return &driverpb.LogEntries{Entries: s.logEntries}, nil +} + +func (s *fakeServer) Metrics(_ context.Context, request *driverpb.MetricsRequest) (*driverpb.MetricsResponse, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + s.metricsBundles = append(s.metricsBundles, request.GetBundleId()) + if s.metrics != nil { + return s.metrics, nil + } + return &driverpb.MetricsResponse{}, nil +} + func (s *fakeServer) Hierarchy(_ context.Context, _ *driverpb.Empty) (*driverpb.HierarchyJSON, error) { s.mutex.Lock() defer s.mutex.Unlock() @@ -180,7 +248,9 @@ func TestClient_WaitForHealth_PollsUntilReady(t *testing.T) { func TestClient_WaitForHealth_HonorsContext(t *testing.T) { state := newHarness(t) + state.fake.mutex.Lock() state.fake.healthReady = false + state.fake.mutex.Unlock() client, err := Dial(state.address) if err != nil { t.Fatal(err) @@ -195,6 +265,22 @@ func TestClient_WaitForHealth_HonorsContext(t *testing.T) { } } +func TestClient_Health_SurfacesRPCError(t *testing.T) { + state := newHarness(t) + state.fake.mutex.Lock() + state.fake.healthError = status.Error(codes.Unavailable, "boom") + state.fake.mutex.Unlock() + client, err := Dial(state.address) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + if _, err := client.Health(context.Background()); err == nil { + t.Fatal("expected Health to surface the RPC error, got nil") + } +} + func TestClient_LaunchAndTerminate(t *testing.T) { state := newHarness(t) client, _ := Dial(state.address) @@ -234,16 +320,47 @@ func TestClient_TapAndTapSelector(t *testing.T) { } } -func TestClient_InputText(t *testing.T) { - state := newHarness(t) - client, _ := Dial(state.address) - defer client.Close() - - if err := client.InputText(context.Background(), "hello world"); err != nil { - t.Fatal(err) +// TestClient_ScalarForwardingRPCs pins that each single-payload RPC forwards +// its argument unchanged to the captured request: a dropped/mistyped text, +// idle duration, erase count, or logical key would change device behavior with +// no other field to disambiguate it. +func TestClient_ScalarForwardingRPCs(t *testing.T) { + tests := []struct { + name string + call func(c *Client) error + check func(t *testing.T, s *fakeServer) + }{ + {"InputText", func(c *Client) error { return c.InputText(context.Background(), "hello world") }, func(t *testing.T, s *fakeServer) { + if len(s.inputs) != 1 || s.inputs[0] != "hello world" { + t.Errorf("inputs wrong: %v", s.inputs) + } + }}, + {"WaitForIdle", func(c *Client) error { return c.WaitForIdle(context.Background(), 250*time.Millisecond) }, func(t *testing.T, s *fakeServer) { + if len(s.idleMillis) != 1 || s.idleMillis[0] != 250 { + t.Errorf("idleMillis wrong: %v", s.idleMillis) + } + }}, + {"EraseText", func(c *Client) error { return c.EraseText(context.Background(), 7) }, func(t *testing.T, s *fakeServer) { + if len(s.erases) != 1 || s.erases[0] != 7 { + t.Errorf("erase count wrong: %v", s.erases) + } + }}, + {"PressKey", func(c *Client) error { return c.PressKey(context.Background(), "back") }, func(t *testing.T, s *fakeServer) { + if len(s.pressedKeys) != 1 || s.pressedKeys[0] != "back" { + t.Errorf("pressed key wrong: %v", s.pressedKeys) + } + }}, } - if len(state.fake.inputs) != 1 || state.fake.inputs[0] != "hello world" { - t.Errorf("inputs wrong: %v", state.fake.inputs) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + state := newHarness(t) + client, _ := Dial(state.address) + defer client.Close() + if err := tt.call(client); err != nil { + t.Fatal(err) + } + tt.check(t, state.fake) + }) } } @@ -286,15 +403,191 @@ func TestClient_SnapshotPairsHierarchyAndScreenshot(t *testing.T) { } } -func TestClient_WaitForIdleForwardsMillis(t *testing.T) { +// TestClient_RPCErrorSurfaces is the representative check that a gRPC error +// status from the sidecar propagates out of an action RPC instead of being +// swallowed into a nil error. Per-method coverage of the sidecar-side status +// mapping lives in the sidecar server tests. +func TestClient_RPCErrorSurfaces(t *testing.T) { + state := newHarness(t) + state.fake.mutex.Lock() + state.fake.tapError = status.Error(codes.Internal, "device offline") + state.fake.mutex.Unlock() + client, _ := Dial(state.address) + defer client.Close() + + err := client.Tap(context.Background(), 1, 2) + if err == nil { + t.Fatal("expected Tap to surface the gRPC error, got nil") + } + if status.Code(err) != codes.Internal { + t.Errorf("expected INTERNAL code, got %v", status.Code(err)) + } +} + +// TestClient_DoubleTapSelectorFiresTwice confirms the selector fallback +// composes exactly two taps; a broken composition would single-tap and the +// double-tap gesture would silently degrade. +func TestClient_DoubleTapSelectorFiresTwice(t *testing.T) { state := newHarness(t) client, _ := Dial(state.address) defer client.Close() - if err := client.WaitForIdle(context.Background(), 250*time.Millisecond); err != nil { + if err := client.DoubleTapSelector(context.Background(), "id:home"); err != nil { t.Fatal(err) } - if len(state.fake.idleMillis) != 1 || state.fake.idleMillis[0] != 250 { - t.Errorf("idleMillis wrong: %v", state.fake.idleMillis) + state.fake.mutex.Lock() + defer state.fake.mutex.Unlock() + if len(state.fake.tapSelectors) != 2 || state.fake.tapSelectors[0] != "id:home" || state.fake.tapSelectors[1] != "id:home" { + t.Errorf("expected two taps on id:home, got %v", state.fake.tapSelectors) + } +} + +// TestClient_DoubleTapSelectorCancelBetweenTaps confirms a context cancelled +// during the inter-tap gap fires exactly one tap and returns ctx.Err() - it +// must neither double-fire nor swallow the cancellation. +func TestClient_DoubleTapSelectorCancelBetweenTaps(t *testing.T) { + state := newHarness(t) + client, _ := Dial(state.address) + defer client.Close() + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + for { + state.fake.mutex.Lock() + n := len(state.fake.tapSelectors) + state.fake.mutex.Unlock() + if n >= 1 { + cancel() + return + } + } + }() + + err := client.DoubleTapSelector(ctx, "id:home") + if err == nil || !strings.Contains(err.Error(), "context") { + t.Fatalf("expected context error, got %v", err) + } + state.fake.mutex.Lock() + defer state.fake.mutex.Unlock() + if len(state.fake.tapSelectors) != 1 { + t.Errorf("expected exactly one tap before cancellation, got %d", len(state.fake.tapSelectors)) + } +} + +// TestClient_SwipeForwardsEndpointsInOrder pins down that From keeps the start +// point and To the end point. A from/to transposition would send the swipe in +// the reverse direction on-device while every other assertion still passed. +func TestClient_SwipeForwardsEndpointsInOrder(t *testing.T) { + state := newHarness(t) + client, _ := Dial(state.address) + defer client.Close() + + if err := client.Swipe(context.Background(), 10, 20, 300, 400, 150*time.Millisecond); err != nil { + t.Fatal(err) + } + if len(state.fake.swipes) != 1 { + t.Fatalf("expected 1 swipe, got %d", len(state.fake.swipes)) + } + got := state.fake.swipes[0] + if got.GetFrom().GetX() != 10 || got.GetFrom().GetY() != 20 { + t.Errorf("from wrong: %+v", got.GetFrom()) + } + if got.GetTo().GetX() != 300 || got.GetTo().GetY() != 400 { + t.Errorf("to wrong: %+v", got.GetTo()) + } + if got.GetDurationMillis() != 150 { + t.Errorf("duration wrong: %d", got.GetDurationMillis()) + } +} + +// TestClient_PointRPCsForwardCoordinates guards against x/y swaps in the point +// RPCs that have no other field to disambiguate which axis is which. +func TestClient_PointRPCsForwardCoordinates(t *testing.T) { + tests := []struct { + name string + call func(c *Client) error + got func(s *fakeServer) []int32 + }{ + {"LongPress", func(c *Client) error { return c.LongPress(context.Background(), 12, 34) }, func(s *fakeServer) []int32 { return s.longPresses }}, + {"DoubleTap", func(c *Client) error { return c.DoubleTap(context.Background(), 12, 34) }, func(s *fakeServer) []int32 { return s.doubleTaps }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + state := newHarness(t) + client, _ := Dial(state.address) + defer client.Close() + if err := tt.call(client); err != nil { + t.Fatal(err) + } + got := tt.got(state.fake) + if len(got) != 2 || got[0] != 12 || got[1] != 34 { + t.Errorf("coordinates wrong: %v", got) + } + }) + } +} + +// TestClient_MetricsMapsResponseFields catches CPU/heap/total being read off +// the wrong proto field, which would mislabel a memory regression as CPU. +func TestClient_MetricsMapsResponseFields(t *testing.T) { + state := newHarness(t) + state.fake.mutex.Lock() + state.fake.metrics = &driverpb.MetricsResponse{CpuPercent: 12.5, HeapBytes: 100, TotalMemoryBytes: 200} + state.fake.mutex.Unlock() + client, _ := Dial(state.address) + defer client.Close() + + got, err := client.Metrics(context.Background(), "com.example") + if err != nil { + t.Fatal(err) + } + if got.CPUPercent != 12.5 || got.HeapBytes != 100 || got.TotalMemoryBytes != 200 { + t.Errorf("metrics mapping wrong: %+v", got) + } + if len(state.fake.metricsBundles) != 1 || state.fake.metricsBundles[0] != "com.example" { + t.Errorf("bundle id not forwarded: %v", state.fake.metricsBundles) + } +} + +// TestClient_RecentLogsSinceBranches pins both arms of the zero-time guard: a +// zero time must send sinceMillis=0 (don't accidentally floor to epoch via +// UnixMilli of a zero Time, which is a huge negative number), a real time must +// forward its unix-millis. The response decode is checked alongside. +func TestClient_RecentLogsSinceBranches(t *testing.T) { + realTime := time.UnixMilli(1_700_000_000_000) + tests := []struct { + name string + since time.Time + wantMilli int64 + }{ + {"zero time forwards 0", time.Time{}, 0}, + {"real time forwards unix millis", realTime, realTime.UnixMilli()}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + state := newHarness(t) + state.fake.mutex.Lock() + state.fake.logEntries = []*driverpb.LogEntry{{UnixMillis: 5, Level: "E", Tag: "t", Message: "boom"}} + state.fake.mutex.Unlock() + client, _ := Dial(state.address) + defer client.Close() + + logs, err := client.RecentLogs(context.Background(), tt.since, "E") + if err != nil { + t.Fatal(err) + } + state.fake.mutex.Lock() + defer state.fake.mutex.Unlock() + req := state.fake.logsRequests[len(state.fake.logsRequests)-1] + if req.GetSinceUnixMillis() != tt.wantMilli { + t.Errorf("sinceUnixMillis = %d, want %d", req.GetSinceUnixMillis(), tt.wantMilli) + } + if req.GetLevelAtLeast() != "E" { + t.Errorf("levelAtLeast = %q, want E", req.GetLevelAtLeast()) + } + if len(logs) != 1 || logs[0].Level != "E" || logs[0].Message != "boom" || logs[0].UnixMillis != 5 || logs[0].Tag != "t" { + t.Errorf("decoded logs wrong: %+v", logs) + } + }) } } diff --git a/internal/hierarchy/hierarchy_test.go b/internal/hierarchy/hierarchy_test.go index 6eff3b7..97b3286 100644 --- a/internal/hierarchy/hierarchy_test.go +++ b/internal/hierarchy/hierarchy_test.go @@ -940,3 +940,69 @@ func TestIOSFlatStructuralChildStillPreferred(t *testing.T) { t.Fatalf("expected the structural child SubmitLabel, got id=%q", node.ResourceID) } } + +// Bug class: malformed device output must surface as an error, not a nil tree +// the callers dereference. A blank string is the only documented benign input. +func TestParseInvalidJSON(t *testing.T) { + cases := []struct { + name string + input string + wantErr bool + }{ + {"truncated object", `{"attributes":`, true}, + {"trailing garbage", `{"attributes":{}} oops`, true}, + {"not an object", `[1,2,3]`, true}, + {"bare garbage", `not json at all`, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := Parse(tc.input) + if (err != nil) != tc.wantErr { + t.Fatalf("Parse(%q) err = %v, wantErr %v", tc.input, err, tc.wantErr) + } + }) + } +} + +// Bug class: a node carrying a bounds value the device emitted in an +// unrecognized shape must degrade to a zero rectangle without losing the rest +// of the element. parseBounds' error is intentionally swallowed in +// elementFromNode, so a regression that aborts the whole parse, or that +// corrupts neighboring fields, would be caught here. +func TestParseMalformedBoundsKeepsElementIntact(t *testing.T) { + cases := []string{ + "[1,2,3]", // too few coordinates + "garbage", // not a bounds string at all + "[a,b,c,d]", // non-numeric + "", // empty + } + for _, b := range cases { + t.Run(b, func(t *testing.T) { + input := `{"attributes":{"resource-id":"app:id/x","text":"keep","bounds":"` + b + `"},"children":[]}` + tree, err := Parse(input) + if err != nil { + t.Fatalf("Parse: %v", err) + } + el := tree.Find("id:x") + if el == nil { + t.Fatal("element dropped when bounds was malformed") + } + if el.Text != "keep" { + t.Errorf("neighboring field corrupted: text=%q", el.Text) + } + if el.Bounds != (Bounds{}) { + t.Errorf("malformed bounds must yield zero rectangle, got %+v", el.Bounds) + } + }) + } +} + +// Bug class: parseBounds must reject inputs that are neither [L,T,R,B] nor +// [x1,y1][x2,y2] rather than returning a partially-filled rectangle. +func TestParseBoundsRejectsBadInput(t *testing.T) { + for _, in := range []string{"[1,2,3]", "[1,2,3,4,5]", "1,2,3,4", "[]", "garbage"} { + if _, err := parseBounds(in); err == nil { + t.Errorf("parseBounds(%q) = nil error, want error", in) + } + } +} diff --git a/internal/ios/ios.go b/internal/ios/ios.go index f91ed13..b1af83c 100644 --- a/internal/ios/ios.go +++ b/internal/ios/ios.go @@ -22,8 +22,17 @@ type simctlDeviceList struct { Devices map[string][]simDevice `json:"devices"` } +// Command-runner seams: overridable in tests so EnsureSimulator can be driven +// with canned device lists without invoking xcrun. +var ( + listBooted = bootedSimulator + listAvailable = availableSimulators + boot = bootSimulator + waitForBoot = waitForSimulatorBoot +) + func EnsureSimulator(ctx context.Context, deviceName string, stdout io.Writer) error { - booted, err := bootedSimulator(ctx) + booted, err := listBooted(ctx) if err != nil { return fmt.Errorf("list booted simulators: %w", err) } @@ -32,7 +41,7 @@ func EnsureSimulator(ctx context.Context, deviceName string, stdout io.Writer) e return nil } - available, err := availableSimulators(ctx) + available, err := listAvailable(ctx) if err != nil { return fmt.Errorf("list available simulators: %w", err) } @@ -43,11 +52,11 @@ func EnsureSimulator(ctx context.Context, deviceName string, stdout io.Writer) e } fmt.Fprintf(stdout, "booting simulator %q (%s)...\n", target.Name, target.UDID) - if err := bootSimulator(ctx, target.UDID); err != nil { + if err := boot(ctx, target.UDID); err != nil { return fmt.Errorf("boot simulator %q: %w", target.UDID, err) } - if err := waitForSimulatorBoot(ctx, target.UDID, 60*time.Second); err != nil { + if err := waitForBoot(ctx, target.UDID, 60*time.Second); err != nil { return fmt.Errorf("wait for simulator boot: %w", err) } @@ -69,18 +78,7 @@ func bootedSimulator(ctx context.Context) (*simDevice, error) { if err != nil { return nil, err } - var list simctlDeviceList - if err := json.Unmarshal(out, &list); err != nil { - return nil, err - } - for _, devices := range list.Devices { - for _, d := range devices { - if d.State == "Booted" { - return &d, nil - } - } - } - return nil, nil + return parseBootedDevice(out) } func availableSimulators(ctx context.Context) ([]simDevice, error) { @@ -88,6 +86,25 @@ func availableSimulators(ctx context.Context) ([]simDevice, error) { if err != nil { return nil, err } + return parseAvailableDevices(out) +} + +func parseBootedDevice(out []byte) (*simDevice, error) { + var list simctlDeviceList + if err := json.Unmarshal(out, &list); err != nil { + return nil, err + } + for _, devices := range list.Devices { + for i := range devices { + if devices[i].State == "Booted" { + return &devices[i], nil + } + } + } + return nil, nil +} + +func parseAvailableDevices(out []byte) ([]simDevice, error) { var list simctlDeviceList if err := json.Unmarshal(out, &list); err != nil { return nil, err diff --git a/internal/ios/ios_test.go b/internal/ios/ios_test.go index d957fe6..d4b1f5b 100644 --- a/internal/ios/ios_test.go +++ b/internal/ios/ios_test.go @@ -1,85 +1,145 @@ package ios import ( + "bytes" "context" + "errors" + "strings" "testing" + "time" ) -func TestPickSimulator_ByName(t *testing.T) { - available := []simDevice{ - {UDID: "aaa", Name: "iPad Pro", IsAvailable: true}, - {UDID: "bbb", Name: "iPhone 15", IsAvailable: true}, +const simctlJSON = `{ + "devices": { + "com.apple.CoreSimulator.SimRuntime.iOS-17-0": [ + {"udid": "ipad-udid", "state": "Shutdown", "name": "iPad Pro", "isAvailable": true}, + {"udid": "iphone15-udid", "state": "Booted", "name": "iPhone 15", "isAvailable": true}, + {"udid": "broken-udid", "state": "Shutdown", "name": "iPhone 14", "isAvailable": false} + ], + "com.apple.CoreSimulator.SimRuntime.iOS-16-4": [ + {"udid": "watch-udid", "state": "Shutdown", "name": "Apple Watch", "isAvailable": true} + ] + } +}` + +func TestParseBootedDevice(t *testing.T) { + cases := []struct { + name string + input string + wantUDID string + wantNil bool + }{ + {"picks booted entry", simctlJSON, "iphone15-udid", false}, + {"none booted", `{"devices":{"r":[{"udid":"a","state":"Shutdown","name":"x","isAvailable":true}]}}`, "", true}, + {"empty list", `{"devices":{}}`, "", true}, } - got, err := pickSimulator("iPhone 15", available) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := parseBootedDevice([]byte(tc.input)) + if err != nil { + t.Fatal(err) + } + if tc.wantNil { + if got != nil { + t.Fatalf("want nil, got %+v", got) + } + return + } + if got == nil || got.UDID != tc.wantUDID { + t.Fatalf("got %+v, want UDID %q", got, tc.wantUDID) + } + }) + } +} + +func TestParseBootedDevice_InvalidJSON(t *testing.T) { + if _, err := parseBootedDevice([]byte("not json")); err == nil { + t.Fatal("expected error on malformed JSON") + } +} + +func TestParseAvailableDevices_DropsUnavailable(t *testing.T) { + got, err := parseAvailableDevices([]byte(simctlJSON)) if err != nil { t.Fatal(err) } - if got.UDID != "bbb" { - t.Errorf("got %q, want bbb", got.UDID) + for _, d := range got { + if !d.IsAvailable { + t.Fatalf("unavailable device leaked through: %+v", d) + } + if d.UDID == "broken-udid" { + t.Fatalf("isAvailable=false device must not be selectable") + } + } + if len(got) != 3 { + t.Fatalf("got %d available across runtimes, want 3", len(got)) } } -func TestPickSimulator_ByUDID(t *testing.T) { - available := []simDevice{ - {UDID: "aaa", Name: "iPad Pro", IsAvailable: true}, - {UDID: "bbb", Name: "iPhone 14", IsAvailable: true}, +func TestPickSimulator(t *testing.T) { + cases := []struct { + name string + query string + available []simDevice + wantUDID string // "" with wantErr means error expected + wantErr bool + }{ + { + name: "by name", + query: "iPhone 15", + available: []simDevice{{UDID: "aaa", Name: "iPad Pro", IsAvailable: true}, {UDID: "bbb", Name: "iPhone 15", IsAvailable: true}}, + wantUDID: "bbb", + }, + { + name: "by udid", + query: "aaa", + available: []simDevice{{UDID: "aaa", Name: "iPad Pro", IsAvailable: true}, {UDID: "bbb", Name: "iPhone 14", IsAvailable: true}}, + wantUDID: "aaa", + }, + { + name: "unknown name errors", + query: "Pixel 7", + available: []simDevice{{UDID: "aaa", Name: "iPad Pro", IsAvailable: true}}, + wantErr: true, + }, + { + name: "empty query prefers iPhone", + query: "", + available: []simDevice{{UDID: "aaa", Name: "iPad mini", IsAvailable: true}, {UDID: "bbb", Name: "iPhone 16", IsAvailable: true}, {UDID: "ccc", Name: "Apple Watch", IsAvailable: true}}, + wantUDID: "bbb", + }, + { + name: "empty query falls back to first", + query: "", + available: []simDevice{{UDID: "aaa", Name: "iPad Air", IsAvailable: true}, {UDID: "bbb", Name: "Apple TV", IsAvailable: true}}, + wantUDID: "aaa", + }, + { + name: "empty list errors", + query: "", + wantErr: true, + }, } - got, err := pickSimulator("aaa", available) - if err != nil { - t.Fatal(err) - } - if got.Name != "iPad Pro" { - t.Errorf("got %q, want iPad Pro", got.Name) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := pickSimulator(tc.query, tc.available) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error for query %q", tc.query) + } + return + } + if err != nil { + t.Fatal(err) + } + if got.UDID != tc.wantUDID { + t.Errorf("got %q, want %q", got.UDID, tc.wantUDID) + } + }) } } -func TestPickSimulator_UnknownName(t *testing.T) { - available := []simDevice{ - {UDID: "aaa", Name: "iPad Pro", IsAvailable: true}, - } - _, err := pickSimulator("Pixel 7", available) - if err == nil { - t.Fatal("expected error for unknown simulator name") - } -} - -func TestPickSimulator_EmptyName_PrefersIPhone(t *testing.T) { - available := []simDevice{ - {UDID: "aaa", Name: "iPad mini", IsAvailable: true}, - {UDID: "bbb", Name: "iPhone 16", IsAvailable: true}, - {UDID: "ccc", Name: "Apple Watch", IsAvailable: true}, - } - got, err := pickSimulator("", available) - if err != nil { - t.Fatal(err) - } - if got.UDID != "bbb" { - t.Errorf("got %q, want bbb (iPhone)", got.UDID) - } -} - -func TestPickSimulator_EmptyName_FallsBackToFirst(t *testing.T) { - available := []simDevice{ - {UDID: "aaa", Name: "iPad Air", IsAvailable: true}, - {UDID: "bbb", Name: "Apple TV", IsAvailable: true}, - } - got, err := pickSimulator("", available) - if err != nil { - t.Fatal(err) - } - if got.UDID != "aaa" { - t.Errorf("got %q, want aaa (first available)", got.UDID) - } -} - -func TestPickSimulator_EmptyList(t *testing.T) { - _, err := pickSimulator("", nil) - if err == nil { - t.Fatal("expected error for empty simulator list") - } -} - -func TestBootedUDID_CanceledContext(t *testing.T) { +func TestBootedUDID_CanceledContextReturnsEmpty(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() udid := BootedUDID(ctx) @@ -87,3 +147,77 @@ func TestBootedUDID_CanceledContext(t *testing.T) { t.Errorf("expected empty UDID on canceled context, got %q", udid) } } + +func swapSeams(t *testing.T) { + t.Helper() + origBoot, origWait := boot, waitForBoot + t.Cleanup(func() { boot, waitForBoot = origBoot, origWait }) +} + +func TestEnsureSimulator_UsesBootedSimulator(t *testing.T) { + swapSeams(t) + origListBooted, origListAvailable := listBooted, listAvailable + t.Cleanup(func() { listBooted, listAvailable = origListBooted, origListAvailable }) + + listBooted = func(context.Context) (*simDevice, error) { + return &simDevice{UDID: "iphone15-udid", Name: "iPhone 15"}, nil + } + availableCalled := false + listAvailable = func(context.Context) ([]simDevice, error) { + availableCalled = true + return nil, nil + } + boot = func(context.Context, string) error { + t.Fatal("must not boot when a simulator is already booted") + return nil + } + + var out bytes.Buffer + if err := EnsureSimulator(context.Background(), "", &out); err != nil { + t.Fatal(err) + } + if availableCalled { + t.Error("should short-circuit without listing available simulators") + } + if !bytes.Contains(out.Bytes(), []byte("iphone15-udid")) { + t.Errorf("expected booted UDID in output, got %q", out.String()) + } +} + +func TestEnsureSimulator_BootsPickedSimulator(t *testing.T) { + swapSeams(t) + origListBooted, origListAvailable := listBooted, listAvailable + t.Cleanup(func() { listBooted, listAvailable = origListBooted, origListAvailable }) + + listBooted = func(context.Context) (*simDevice, error) { return nil, nil } + listAvailable = func(context.Context) ([]simDevice, error) { + return []simDevice{ + {UDID: "ipad-udid", Name: "iPad Pro", IsAvailable: true}, + {UDID: "iphone15-udid", Name: "iPhone 15", IsAvailable: true}, + }, nil + } + var bootedUDID string + boot = func(_ context.Context, udid string) error { bootedUDID = udid; return nil } + waitForBoot = func(context.Context, string, time.Duration) error { return nil } + + if err := EnsureSimulator(context.Background(), "", new(bytes.Buffer)); err != nil { + t.Fatal(err) + } + if bootedUDID != "iphone15-udid" { + t.Errorf("booted %q, want iphone15-udid (iPhone preference)", bootedUDID) + } +} + +func TestEnsureSimulator_BootedListError(t *testing.T) { + origListBooted := listBooted + t.Cleanup(func() { listBooted = origListBooted }) + listBooted = func(context.Context) (*simDevice, error) { return nil, errors.New("xcrun blew up") } + + err := EnsureSimulator(context.Background(), "", new(bytes.Buffer)) + if err == nil { + t.Fatal("expected error to propagate") + } + if !strings.Contains(err.Error(), "xcrun blew up") { + t.Errorf("error should wrap cause, got %v", err) + } +} diff --git a/internal/ltl/evaluator_test.go b/internal/ltl/evaluator_test.go index 6d0628e..a17d6c0 100644 --- a/internal/ltl/evaluator_test.go +++ b/internal/ltl/evaluator_test.go @@ -104,6 +104,48 @@ func TestDescribe(t *testing.T) { } } +// TestEventuallyWithinSteps_NextInnerHitsBoundFirstStep pins the boundary: a +// 1-step Eventually whose inner is a Next defers the inner to step 2, but the +// window closes at step 1, so the obligation is unmet and violates. Bug class: +// off-by-one at the step bound treating the deferred inner as still in-window. +func TestEventuallyWithinSteps_NextInnerHitsBoundFirstStep(t *testing.T) { + evaluator := NewEvaluator(EventuallyWithinSteps(Next(ThunkNamed("p", func() (bool, error) { return true, nil })), 1)) + if got := evaluator.ObserveAt(time.Unix(0, 0)); got != VerdictViolated { + t.Errorf("EventuallyWithinSteps(Next(p), 1) step 1: got %v, want violated", got) + } +} + +// TestOr_ViolatedDisjunctDoesNotViolateWhileOtherPending guards the Or-reduce +// path where one disjunct fails (Pure(false)) while the other is still pending +// (Next(p)). The disjunction must stay pending on the failing step, never +// violate. Bug class: Or-reduction dropping the still-pending branch and +// latching violated on a single failed disjunct. +func TestOr_ViolatedDisjunctDoesNotViolateWhileOtherPending(t *testing.T) { + evaluator := NewEvaluator(Always(Or(Pure(false), Next(ThunkNamed("p", func() (bool, error) { return true, nil }))))) + if got := evaluator.ObserveAt(time.Unix(0, 0)); got != VerdictPending { + t.Errorf("step 1: got %v, want pending (Pure(false) disjunct must not violate)", got) + } + if got := evaluator.ObserveAt(time.Unix(1, 0)); got != VerdictPending { + t.Errorf("step 2: got %v, want pending", got) + } +} + +// TestNot_OverPendingStaysPendingThenResolves pins Not over a pending inner: it +// must carry a Not-wrapped residual rather than collapse to a definite verdict +// at the step the inner is still deferred. Bug class: negation of a pending +// verdict resolving early to holds/violated. +func TestNot_OverPendingStaysPendingThenResolves(t *testing.T) { + // Next(p) is deferred at step 1, so Not(Next(p)) is pending, not definite. + // At step 2 the inner Next holds, so Not violates. + evaluator := NewEvaluator(Always(Not(Next(ThunkNamed("p", func() (bool, error) { return true, nil }))))) + if got := evaluator.ObserveAt(time.Unix(0, 0)); got != VerdictPending { + t.Errorf("step 1: got %v, want pending", got) + } + if got := evaluator.ObserveAt(time.Unix(1, 0)); got != VerdictViolated { + t.Errorf("step 2: got %v, want violated (Not over a held inner)", got) + } +} + func TestObserve_PanicsOnUnknownFormulaType(t *testing.T) { type unsupportedFormula struct{ Formula } defer func() { diff --git a/internal/ltl/finalize_test.go b/internal/ltl/finalize_test.go index 11dd577..a0c0fd9 100644 --- a/internal/ltl/finalize_test.go +++ b/internal/ltl/finalize_test.go @@ -146,6 +146,49 @@ func TestViolationLatchIsMonotonic(t *testing.T) { } } +// TestFinalize_KleeneConnectives locks the soundness guarantee in finalize's +// doc comment: an indefinite (pending) operand must never let a connective +// manufacture a definite verdict. Bug class: a pending side collapsing to +// holds/violated at run end, making sanderling lie about pass/fail. +func TestFinalize_KleeneConnectives(t *testing.T) { + pure := func(v bool) Formula { return PureFormula{Value: v} } + pendingThunk := ThunkFormula{Name: "t", Func: func() (bool, error) { return true, nil }} + eventuallyViolated := EventuallyFormula{Inner: PureFormula{Value: false}} + nextPending := NextFormula{Inner: PureFormula{Value: true}} + alwaysHolds := AlwaysFormula{Inner: PureFormula{Value: true}} + + cases := []struct { + name string + formula Formula + want residualStatus + }{ + {"and-pending-violated", AndFormula{Left: pendingThunk, Right: eventuallyViolated}, statusViolated}, + {"and-violated-pending", AndFormula{Left: nextPending, Right: eventuallyViolated}, statusViolated}, + {"and-pending-holds", AndFormula{Left: pendingThunk, Right: alwaysHolds}, statusPending}, + {"and-holds-holds", AndFormula{Left: pure(true), Right: alwaysHolds}, statusHolds}, + + {"or-pending-violated", OrFormula{Left: pendingThunk, Right: eventuallyViolated}, statusPending}, + {"or-pending-holds", OrFormula{Left: pendingThunk, Right: alwaysHolds}, statusHolds}, + {"or-violated-violated", OrFormula{Left: pure(false), Right: eventuallyViolated}, statusViolated}, + + {"not-pending", NotFormula{Inner: pendingThunk}, statusPending}, + {"not-violated", NotFormula{Inner: eventuallyViolated}, statusHolds}, + {"not-holds", NotFormula{Inner: alwaysHolds}, statusViolated}, + + {"implies-pending-violated", ImpliesFormula{Antecedent: pendingThunk, Consequent: eventuallyViolated}, statusPending}, + {"implies-holds-violated", ImpliesFormula{Antecedent: alwaysHolds, Consequent: eventuallyViolated}, statusViolated}, + {"implies-violated-pending", ImpliesFormula{Antecedent: eventuallyViolated, Consequent: nextPending}, statusHolds}, + + {"now-violated", NowFormula{Inner: eventuallyViolated}, statusViolated}, + {"now-pending", NowFormula{Inner: nextPending}, statusPending}, + } + for _, tc := range cases { + if got := finalize(tc.formula); got != tc.want { + t.Errorf("%s: finalize = %v, want %v", tc.name, got, tc.want) + } + } +} + func TestCollapse_IdenticalObligationsMerge(t *testing.T) { merged := collapse([]obligation{ {formula: Next(Pure(true)), origin: 1}, diff --git a/internal/ltl/formula_test.go b/internal/ltl/formula_test.go index 7716de5..ffb700e 100644 --- a/internal/ltl/formula_test.go +++ b/internal/ltl/formula_test.go @@ -220,6 +220,30 @@ func TestMarshalJSON_EventuallyMillisecondsAndDeadline(t *testing.T) { } } +// TestMarshalJSON_AlwaysStepsMillisecondsDeadline mirrors the Eventually +// marshal test for bounded Always: the within node must carry the right unit +// and amount for each bound flavor. Bug class: a bounded Always serializing the +// wrong bound (unit/amount) into the trace AST the replay UI consumes. +func TestMarshalJSON_AlwaysStepsMillisecondsDeadline(t *testing.T) { + steps := AlwaysFormula{Inner: Pure(true), StepBound: 4, HasStepBound: true} + body, _ := json.Marshal(steps) + if !strings.Contains(string(body), `"unit":"steps"`) || !strings.Contains(string(body), `"amount":4`) { + t.Errorf("always steps within wrong: %s", body) + } + + duration := AlwaysFormula{Inner: Pure(true), Duration: 250 * time.Millisecond} + body, _ = json.Marshal(duration) + if !strings.Contains(string(body), `"unit":"milliseconds"`) || !strings.Contains(string(body), `"amount":250`) { + t.Errorf("always milliseconds within wrong: %s", body) + } + + deadline := AlwaysFormula{Inner: Pure(true), Deadline: time.UnixMilli(1700000000000), HasDeadline: true} + body, _ = json.Marshal(deadline) + if !strings.Contains(string(body), `"unit":"deadline"`) || !strings.Contains(string(body), `"amount":1700000000000`) { + t.Errorf("always deadline within wrong: %s", body) + } +} + func TestMarshalJSON_NextAndThunkAndError(t *testing.T) { body, _ := json.Marshal(Next(Pure(true))) if string(body) != `{"op":"next","arg":{"op":"true"}}` { @@ -248,13 +272,19 @@ func TestResidual_HoldsViolatedPending(t *testing.T) { t.Errorf("violated residual = %v, want false", got) } - pendingEval := NewEvaluator(Always(Next(Pure(true)))) + // A genuinely multi-obligation residual: both Next inners survive to the + // next step folded under And, in registration order. A residual that + // dropped or transposed an obligation would change this exact AST. + predP := ThunkNamed("p", func() (bool, error) { return true, nil }) + predQ := ThunkNamed("q", func() (bool, error) { return true, nil }) + pendingEval := NewEvaluator(Always(And(Next(predP), Next(predQ)))) pendingEval.Observe() body, err := json.Marshal(pendingEval.Residual()) if err != nil { t.Fatal(err) } - if !strings.Contains(string(body), `"op":"and"`) && !strings.Contains(string(body), `"op":"true"`) { - t.Errorf("pending residual unexpected: %s", body) + want := `{"op":"and","left":{"op":"predicate","name":"p"},"right":{"op":"predicate","name":"q"}}` + if string(body) != want { + t.Errorf("pending residual:\n got: %s\nwant: %s", body, want) } } diff --git a/internal/replay/runs_test.go b/internal/replay/runs_test.go index 2d3b39a..659c24e 100644 --- a/internal/replay/runs_test.go +++ b/internal/replay/runs_test.go @@ -198,6 +198,79 @@ func TestCacheOpen_ViolationWithoutWitnessStepKeepsDetectionStep(t *testing.T) { } } +func TestCacheOpen_ReusesUnchangedRunAndReparsesOnAppend(t *testing.T) { + root := t.TempDir() + startedAt := time.Now().UTC() + writeRun(t, root, "r1", trace.Meta{StartedAt: startedAt}, []trace.Step{ + {Index: 1, Timestamp: startedAt}, + }) + cache := NewCache(root) + + first, err := cache.Open("r1") + if err != nil { + t.Fatalf("Open: %v", err) + } + again, err := cache.Open("r1") + if err != nil { + t.Fatalf("Open again: %v", err) + } + if first != again { + t.Fatal("unchanged run should be served from cache, got a re-parse") + } + + tracePath := filepath.Join(root, "r1", "trace.jsonl") + file, err := os.OpenFile(tracePath, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + if err := json.NewEncoder(file).Encode(trace.Step{Index: 2, Timestamp: startedAt.Add(time.Second)}); err != nil { + t.Fatal(err) + } + file.Close() + bumped := first.traceMtime.Add(time.Second) + if err := os.Chtimes(tracePath, bumped, bumped); err != nil { + t.Fatal(err) + } + + updated, err := cache.Open("r1") + if err != nil { + t.Fatalf("Open after append: %v", err) + } + if updated == first { + t.Fatal("mtime bump should force a re-parse, got the stale cached run") + } + if len(updated.Steps) != 2 { + t.Errorf("re-parsed steps = %d, want 2", len(updated.Steps)) + } +} + +func TestCacheOpen_ViolationFallsBackToDetectionStepWhenAttributedMissing(t *testing.T) { + // The witness attributes the violation to step 99, which never appears in + // the trace. The marker must fall back to the detection step (2) so the + // violation still renders somewhere instead of vanishing. + root := t.TempDir() + startedAt := time.Now().UTC() + steps := []trace.Step{ + {Index: 1, Timestamp: startedAt}, + { + Index: 2, + Timestamp: startedAt.Add(time.Second), + Violations: []string{"prop1"}, + Witnesses: map[string]trace.Witness{"prop1": {Reason: "predicate false", Step: 99}}, + }, + } + writeRun(t, root, "r1", trace.Meta{StartedAt: startedAt, EndedAt: timePointer(startedAt.Add(2 * time.Second))}, steps) + + cache := NewCache(root) + run, err := cache.Open("r1") + if err != nil { + t.Fatalf("Open: %v", err) + } + if !run.Steps[1].HasViolations { + t.Error("step 2 (detection step) should keep the marker when the attributed step is absent") + } +} + func TestDecodeStepSummary_ActionLabelPerKind(t *testing.T) { cases := []struct { line string diff --git a/internal/replay/server_test.go b/internal/replay/server_test.go index d5e8647..3db4fad 100644 --- a/internal/replay/server_test.go +++ b/internal/replay/server_test.go @@ -7,7 +7,6 @@ import ( "io/fs" "net/http" "net/http/httptest" - "net/url" "os" "path/filepath" "strings" @@ -158,6 +157,47 @@ func TestHandleStep_ErrorCases(t *testing.T) { } } +func TestServer_CorruptRunDirReturns500WithError(t *testing.T) { + root := t.TempDir() + mustWriteFile(t, filepath.Join(root, "bad-meta", "meta.json"), "{not json") + mustWriteFile(t, filepath.Join(root, "bad-trace", "meta.json"), `{"started_at":"2026-04-17T18:00:00Z"}`) + mustWriteFile(t, filepath.Join(root, "bad-trace", "trace.jsonl"), "{not json\n") + + server, err := NewServer(ServerOptions{RunsDirectory: root, AssetsFS: testAssetsFS}) + if err != nil { + t.Fatal(err) + } + + cases := []string{ + "/api/runs/bad-meta", + "/api/runs/bad-trace", + "/api/runs/bad-trace/steps/1", + } + for _, path := range cases { + t.Run(path, func(t *testing.T) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, path, nil) + server.Handler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500, body=%s", recorder.Code, recorder.Body.String()) + } + if strings.TrimSpace(recorder.Body.String()) == "" { + t.Error("expected a non-empty error body") + } + }) + } +} + +func mustWriteFile(t *testing.T, path, body string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + func TestScreenshot_ServesWhitelistedPNG(t *testing.T) { server, _ := newFixtureServer(t) recorder := httptest.NewRecorder() @@ -223,6 +263,57 @@ func TestSSE_ReturnsWhenContextCanceled(t *testing.T) { } } +func TestSSE_DeliversRunsChangedAfterBroadcast(t *testing.T) { + server, _ := newFixtureServer(t) + httpServer := httptest.NewServer(server.Handler()) + defer httpServer.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, httpServer.URL+"/api/events", nil) + if err != nil { + t.Fatal(err) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + + events := make(chan string, 1) + go func() { + buffer := make([]byte, 256) + for { + n, err := response.Body.Read(buffer) + if n > 0 { + events <- string(buffer[:n]) + } + if err != nil { + return + } + } + }() + + deadline := time.Now().Add(2 * time.Second) + for { + server.Watcher().broadcast() + select { + case got := <-events: + if strings.Contains(got, ": ping") { + continue + } + if !strings.Contains(got, "event: runs.changed") { + t.Fatalf("event = %q, want runs.changed", got) + } + return + case <-time.After(50 * time.Millisecond): + if time.Now().After(deadline) { + t.Fatal("client never received runs.changed after broadcast") + } + } + } +} + func TestDevProxy_ForwardsRequestBodyUnchanged(t *testing.T) { received := make(chan string, 1) upstream := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) { @@ -275,8 +366,8 @@ func TestAssets_FallbackToIndexHTML(t *testing.T) { t.Fatalf("status = %d", recorder.Code) } body := recorder.Body.String() - if !strings.Contains(body, "
") && !strings.Contains(body, "
") { - t.Errorf("expected SPA shell with #app or #root, got %q", body) + if !strings.Contains(body, "
") { + t.Errorf("expected SPA shell with #root, got %q", body) } } @@ -312,12 +403,8 @@ func TestResolveRunsDirectory(t *testing.T) { } } -func TestDevProxy_ParsesTarget(t *testing.T) { +func TestDevProxy_RejectsInvalidTarget(t *testing.T) { if _, err := newDevProxy(":://bad-url"); err == nil { t.Error("expected parse error for invalid URL") } - parsed, err := url.Parse(DevTarget) - if err != nil || parsed.Host != "127.0.0.1:5173" { - t.Errorf("DevTarget parsed wrong: %v %q", err, parsed.Host) - } } diff --git a/internal/replay/watcher_test.go b/internal/replay/watcher_test.go index 237e195..18acf7e 100644 --- a/internal/replay/watcher_test.go +++ b/internal/replay/watcher_test.go @@ -1,9 +1,128 @@ package replay import ( + "context" + "os" + "path/filepath" "testing" + "time" ) +func TestWatcher_RunCoalescesCreateBurstIntoOneBroadcast(t *testing.T) { + directory := t.TempDir() + w := NewWatcher(directory) + w.debounce = 30 * time.Millisecond + events := w.Subscribe() + + ctx, cancel := context.WithCancel(context.Background()) + go func() { _ = w.Run(ctx) }() + defer cancel() + waitForWatch(t, w.debounce) + + for i := 0; i < 5; i++ { + mustCreate(t, filepath.Join(directory, "run-"+string(rune('a'+i)), "meta.json")) + } + + select { + case <-events: + case <-time.After(time.Second): + t.Fatal("burst of creates produced no broadcast") + } + if extra := drainWithin(events, 4*w.debounce); extra != 0 { + t.Errorf("burst yielded %d extra broadcasts, want a single coalesced one", extra) + } +} + +func TestWatcher_RunIgnoresWriteAndChmod(t *testing.T) { + directory := t.TempDir() + existing := filepath.Join(directory, "marker") + if err := os.WriteFile(existing, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + + w := NewWatcher(directory) + w.debounce = 30 * time.Millisecond + events := w.Subscribe() + + ctx, cancel := context.WithCancel(context.Background()) + go func() { _ = w.Run(ctx) }() + defer cancel() + waitForWatch(t, w.debounce) + + if err := os.WriteFile(existing, []byte("touched"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chmod(existing, 0o600); err != nil { + t.Fatal(err) + } + + if got := drainWithin(events, 6*w.debounce); got != 0 { + t.Errorf("write/chmod produced %d broadcasts, want 0", got) + } +} + +func TestWatcher_RunCancelClosesSubscribers(t *testing.T) { + w := NewWatcher(t.TempDir()) + w.debounce = 30 * time.Millisecond + events := w.Subscribe() + + ctx, cancel := context.WithCancel(context.Background()) + go func() { _ = w.Run(ctx) }() + waitForWatch(t, w.debounce) + cancel() + + select { + case _, ok := <-events: + if ok { + // drain any pending broadcast, then expect close + _, ok = <-events + } + if ok { + t.Fatal("subscriber channel should be closed after ctx cancel") + } + case <-time.After(time.Second): + t.Fatal("subscriber channel was not closed after ctx cancel") + } + + post := w.Subscribe() + select { + case _, ok := <-post: + if ok { + t.Error("Subscribe after shutdown should return a pre-closed channel") + } + case <-time.After(time.Second): + t.Error("Subscribe after shutdown blocked instead of returning a closed channel") + } +} + +func mustCreate(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } +} + +func waitForWatch(t *testing.T, debounce time.Duration) { + t.Helper() + time.Sleep(10 * debounce) +} + +func drainWithin(events <-chan struct{}, window time.Duration) int { + count := 0 + deadline := time.After(window) + for { + select { + case <-events: + count++ + case <-deadline: + return count + } + } +} + func TestWatcher_UnsubscribeRemovesChannel(t *testing.T) { w := NewWatcher(t.TempDir()) first := w.Subscribe() diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index c2977de..ad27702 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -160,6 +160,35 @@ func TestRunner_MaxStepsStopsAfterExactlyNSteps(t *testing.T) { } } +// TestRenderSummary_SurfacesUnsupportedVerbs exercises the real path that takes +// verbs the picker requested but the platform cannot dispatch and puts them in +// front of the operator. TestRunner_HappyPath only ever asserts the field stays +// empty (every builtin is supported, so its non-empty arm can never fire), so +// without this the "unsupported on %s: ..." branch could be deleted and every +// unsupported-verb regression would pass silently. +func TestRenderSummary_SurfacesUnsupportedVerbs(t *testing.T) { + summary := Summary{Steps: 3, UnsupportedVerbs: []string{"longPresses", "scrolls"}} + + var out bytes.Buffer + RenderSummary(&out, summary, "ios") + + if !strings.Contains(out.String(), "unsupported on ios: longPresses, scrolls") { + t.Errorf("expected unsupported verbs line for ios, got:\n%s", out.String()) + } +} + +// TestRenderSummary_OmitsUnsupportedLineWhenNone guards the inverse: a clean run +// must not print a stray "unsupported on" line, so a future refactor cannot +// start emitting an empty list and alarm the operator on every run. +func TestRenderSummary_OmitsUnsupportedLineWhenNone(t *testing.T) { + var out bytes.Buffer + RenderSummary(&out, Summary{Steps: 2}, "android") + + if strings.Contains(out.String(), "unsupported on") { + t.Errorf("did not expect an unsupported-verbs line, got:\n%s", out.String()) + } +} + func TestRunner_ViolationSurfacesInSummary(t *testing.T) { state := newHarnessWithSpec(t, violationSpec) @@ -552,6 +581,48 @@ func TestTraceActionFor_StaleCoordinatesDoNotOverrideTreeCenter(t *testing.T) { } } +// TestTraceActionFor_RecordsKindSpecificFields locks each action kind's trace +// encoding. PressKey must carry its Key and Wait its DurationMillis; if either +// branch of traceActionFor drops the field (or a field rename desyncs from the +// trace.Action struct) the replay UI silently renders a key-less PressKey or a +// zero-duration Wait. Swipe's endpoint encoding is covered separately via +// applyAction (TestApplyAction_ScrollWithPrecomputedEndpointsSwipes). +func TestTraceActionFor_RecordsKindSpecificFields(t *testing.T) { + cases := []struct { + name string + action verifier.Action + check func(*testing.T, *trace.Action) + }{ + { + "PressKey records key", + verifier.Action{Kind: verifier.ActionKindPressKey, Key: "back"}, + func(t *testing.T, a *trace.Action) { + if a.Key != "back" { + t.Errorf("Key = %q, want %q", a.Key, "back") + } + }, + }, + { + "Wait records duration", + verifier.Action{Kind: verifier.ActionKindWait, DurationMillis: 250}, + func(t *testing.T, a *trace.Action) { + if a.DurationMillis != 250 { + t.Errorf("DurationMillis = %d, want 250", a.DurationMillis) + } + }, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + traceAction := traceActionFor(testCase.action, nil) + if traceAction.Kind != string(testCase.action.Kind) { + t.Errorf("Kind = %q, want %q", traceAction.Kind, testCase.action.Kind) + } + testCase.check(t, traceAction) + }) + } +} + func TestRunner_LogsWaitForIdleDriverErrors(t *testing.T) { state := newHarness(t) state.mock.Failures[mockdriver.ActionWaitForIdle] = errors.New("sidecar lost gRPC stream") @@ -1399,7 +1470,18 @@ func TestRunner_InternalApplyErrorMarksTransitional(t *testing.T) { // UNAVAILABLE that happens to embed raw exception text (e.g. ConnectException // from the original failure) means the sidecar already recovered and the run // must continue. +// TestIsWDADrop_Classification pins isWDADrop to the exact phrase the sidecar +// throws at its reconnect site (sidecar DriverBackend.kt): +// +// throw IllegalStateException("WDA reconnect failed: $restartErr", cause) +// +// If that message is reworded on the Kotlin side without updating the Go +// matcher, a fatal, unrecoverable WDA drop is misclassified as transient and +// the run burns its budget retrying a dead channel instead of aborting. func TestIsWDADrop_Classification(t *testing.T) { + // sidecarReconnectFailedMessage mirrors the literal the sidecar emits; the + // matcher's contract is keyed on this exact prefix. + const sidecarReconnectFailedMessage = "WDA reconnect failed" cases := []struct { name string err error @@ -1411,8 +1493,8 @@ func TestIsWDADrop_Classification(t *testing.T) { false, }, { - "reconnect failure is a drop", - status.Error(codes.Internal, "java.lang.IllegalStateException: WDA reconnect failed: IOSDriverTimeoutException"), + "sidecar reconnect-failed message is a drop", + status.Error(codes.Internal, "java.lang.IllegalStateException: "+sidecarReconnectFailedMessage+": IOSDriverTimeoutException"), true, }, {"generic internal is not a drop", status.Error(codes.Internal, "boom"), false}, diff --git a/internal/sidecarassets/embed_stub_test.go b/internal/sidecarassets/embed_stub_test.go new file mode 100644 index 0000000..8d27408 --- /dev/null +++ b/internal/sidecarassets/embed_stub_test.go @@ -0,0 +1,27 @@ +//go:build !withsidecar + +package sidecarassets + +import ( + "strings" + "testing" +) + +func TestStubBuild_IsPlaceholder(t *testing.T) { + if !IsPlaceholder() { + t.Error("default build (no -tags withsidecar) must report a placeholder") + } + if EmbeddedSize() != 0 { + t.Errorf("placeholder build must embed no JAR, got %d bytes", EmbeddedSize()) + } +} + +func TestStubBuild_ExtractErrors(t *testing.T) { + _, err := Extract(t.TempDir()) + if err == nil { + t.Fatal("Extract must fail when no JAR is embedded") + } + if !strings.Contains(err.Error(), "withsidecar") { + t.Errorf("error should tell the user to rebuild with -tags withsidecar, got %v", err) + } +} diff --git a/internal/sidecarassets/embed_withsidecar_test.go b/internal/sidecarassets/embed_withsidecar_test.go index cd82946..3ae7400 100644 --- a/internal/sidecarassets/embed_withsidecar_test.go +++ b/internal/sidecarassets/embed_withsidecar_test.go @@ -3,6 +3,7 @@ package sidecarassets import ( + "bytes" "crypto/sha256" "encoding/hex" "os" @@ -54,21 +55,21 @@ func TestExtract_ReusesIdenticalFile(t *testing.T) { if err != nil { t.Fatal(err) } - originalStat, err := os.Stat(path) - if err != nil { + sentinel := []byte("SENTINEL-do-not-rewrite") + if err := os.WriteFile(path, sentinel, 0o644); err != nil { t.Fatal(err) } - originalModTime := originalStat.ModTime() if _, err := Extract(directory); err != nil { t.Fatal(err) } - secondStat, err := os.Stat(path) + + after, err := os.ReadFile(path) if err != nil { t.Fatal(err) } - if !secondStat.ModTime().Equal(originalModTime) { - t.Errorf("second extract should not have rewritten the file") + if !bytes.Equal(after, sentinel) { + t.Errorf("second extract rewrote the existing JAR; reuse branch corrupted on-disk file") } } diff --git a/internal/testrun/progress_test.go b/internal/testrun/progress_test.go new file mode 100644 index 0000000..cbbeca4 --- /dev/null +++ b/internal/testrun/progress_test.go @@ -0,0 +1,53 @@ +package testrun + +import ( + "bytes" + "context" + "log/slog" + "testing" + "time" +) + +func TestProgressHandler_LineFormat(t *testing.T) { + tests := []struct { + name string + level slog.Level + msg string + attrs []slog.Attr + want string + }{ + {"info has no level prefix", slog.LevelInfo, "running", nil, "running\n"}, + {"warn prefixes lowercased level", slog.LevelWarn, "slow", nil, "warn: slow\n"}, + {"error prefixes lowercased level", slog.LevelError, "boom", nil, "error: boom\n"}, + { + "string attr is quoted", + slog.LevelInfo, "step", []slog.Attr{slog.String("name", "tap home")}, + "step name=\"tap home\"\n", + }, + { + "non-string attr renders bare", + slog.LevelInfo, "step", []slog.Attr{slog.Int("seed", 42)}, + "step seed=42\n", + }, + { + "attrs render in order", + slog.LevelInfo, "step", + []slog.Attr{slog.Int("n", 1), slog.String("k", "v")}, + "step n=1 k=\"v\"\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + h := &progressHandler{writer: &buf, level: slog.LevelInfo} + record := slog.NewRecord(time.Time{}, tt.level, tt.msg, 0) + record.AddAttrs(tt.attrs...) + if err := h.Handle(context.Background(), record); err != nil { + t.Fatal(err) + } + if got := buf.String(); got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/testrun/testrun.go b/internal/testrun/testrun.go index 6d58441..ed22daf 100644 --- a/internal/testrun/testrun.go +++ b/internal/testrun/testrun.go @@ -46,27 +46,17 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error { return err } } - aliases := map[string]string{} - specAPIPath := resolveSpecAPIPath(options.Spec) - if specAPIPath != "" { - aliases["@sanderling/spec"] = specAPIPath - base := filepath.Dir(specAPIPath) - aliases["@sanderling/spec/defaults"] = filepath.Join(base, "defaults/index.ts") - aliases["@sanderling/spec/defaults/properties"] = filepath.Join(base, "defaults/properties.ts") - } - seed := resolveSeed(options.Seed) - defines := map[string]string{ - "SANDERLING_TEST_PHONE": os.Getenv("SANDERLING_TEST_PHONE"), - "SANDERLING_TEST_OTP": os.Getenv("SANDERLING_TEST_OTP"), - "SANDERLING_SEED": strconv.FormatInt(seed, 10), - } - gojaRuntimePath := resolveGojaRuntimePath(specAPIPath, options.Spec) - if gojaRuntimePath == "" { - return fmt.Errorf("goja-runtime.ts not found near %s; checkout pkg/spec or set @sanderling/spec alias", options.Spec) + prep, err := prepareBundleInputs(options) + if err != nil { + return err } + aliases := prep.aliases + seed := prep.seed + defines := prep.defines + specAPIPath := prep.specAPIPath bundle, err := bundler.Bundle(bundler.Options{ EntryFile: options.Spec, - RuntimeFile: gojaRuntimePath, + RuntimeFile: prep.gojaRuntimePath, Defines: defines, Aliases: aliases, }) @@ -171,6 +161,47 @@ func Execute(ctx context.Context, options Options, stdout io.Writer) error { return nil } +// bundleInputs holds the pre-driver assembly: alias map, seed, esbuild defines, +// and the resolved spec-API/goja-runtime paths the bundler consumes. +type bundleInputs struct { + aliases map[string]string + seed int64 + defines map[string]string + specAPIPath string + gojaRuntimePath string +} + +// prepareBundleInputs builds the alias map, defines, seed, and resolves the +// goja runtime path. It is the pure (no driver/JVM) front half of Execute, +// returning the documented error when the runtime entry cannot be located. +func prepareBundleInputs(options Options) (bundleInputs, error) { + aliases := map[string]string{} + specAPIPath := resolveSpecAPIPath(options.Spec) + if specAPIPath != "" { + aliases["@sanderling/spec"] = specAPIPath + base := filepath.Dir(specAPIPath) + aliases["@sanderling/spec/defaults"] = filepath.Join(base, "defaults/index.ts") + aliases["@sanderling/spec/defaults/properties"] = filepath.Join(base, "defaults/properties.ts") + } + seed := resolveSeed(options.Seed) + defines := map[string]string{ + "SANDERLING_TEST_PHONE": os.Getenv("SANDERLING_TEST_PHONE"), + "SANDERLING_TEST_OTP": os.Getenv("SANDERLING_TEST_OTP"), + "SANDERLING_SEED": strconv.FormatInt(seed, 10), + } + gojaRuntimePath := resolveGojaRuntimePath(specAPIPath, options.Spec) + if gojaRuntimePath == "" { + return bundleInputs{}, fmt.Errorf("goja-runtime.ts not found near %s; checkout pkg/spec or set @sanderling/spec alias", options.Spec) + } + return bundleInputs{ + aliases: aliases, + seed: seed, + defines: defines, + specAPIPath: specAPIPath, + gojaRuntimePath: gojaRuntimePath, + }, nil +} + // resolveSeed returns the configured seed, or a time-derived one when unset. // The same value seeds both the goja PRNG and the web bundle's SANDERLING_SEED // define, so a single run is reproducible across both runtimes. diff --git a/internal/testrun/testrun_test.go b/internal/testrun/testrun_test.go index 5cd63bf..6ab171c 100644 --- a/internal/testrun/testrun_test.go +++ b/internal/testrun/testrun_test.go @@ -3,7 +3,7 @@ package testrun import ( "os" "path/filepath" - "strconv" + "strings" "testing" ) @@ -19,12 +19,68 @@ func TestResolveSeed_DerivesWhenZero(t *testing.T) { } } -// TestSeedDefineFormatsAsDecimal guards the contract that Execute uses to put -// the seed into the web bundle: strconv.FormatInt base 10, which web-runtime -// folds to a 32-bit PRNG seed. -func TestSeedDefineFormatsAsDecimal(t *testing.T) { - if got := strconv.FormatInt(resolveSeed(8675309), 10); got != "8675309" { - t.Fatalf("got %q, want 8675309", got) +func TestPrepareBundleInputs_MissingGojaRuntime(t *testing.T) { + root := t.TempDir() + specPath := filepath.Join(root, "spec.ts") + if err := os.WriteFile(specPath, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) + if err := os.Chdir(root); err != nil { + t.Fatal(err) + } + + _, err = prepareBundleInputs(Options{Spec: specPath}) + if err == nil || !strings.Contains(err.Error(), "goja-runtime.ts not found") || + !strings.Contains(err.Error(), "checkout pkg/spec or set @sanderling/spec alias") { + t.Fatalf("got %v, want documented goja-runtime error", err) + } +} + +func TestPrepareBundleInputs_DerivesSpecAliases(t *testing.T) { + root := t.TempDir() + srcDir := filepath.Join(root, "pkg", "spec", "src") + if err := os.MkdirAll(srcDir, 0o755); err != nil { + t.Fatal(err) + } + apiPath := filepath.Join(srcDir, "index.ts") + gojaPath := filepath.Join(srcDir, "goja-runtime.ts") + for _, p := range []string{apiPath, gojaPath} { + if err := os.WriteFile(p, []byte("export {}"), 0o644); err != nil { + t.Fatal(err) + } + } + specPath := filepath.Join(root, "examples", "spec.ts") + if err := os.MkdirAll(filepath.Dir(specPath), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(specPath, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + + prep, err := prepareBundleInputs(Options{Spec: specPath}) + if err != nil { + t.Fatal(err) + } + want := map[string]string{ + "@sanderling/spec": apiPath, + "@sanderling/spec/defaults": filepath.Join(srcDir, "defaults/index.ts"), + "@sanderling/spec/defaults/properties": filepath.Join(srcDir, "defaults/properties.ts"), + } + for key, wantValue := range want { + if prep.aliases[key] != wantValue { + t.Errorf("alias %q = %q, want %q", key, prep.aliases[key], wantValue) + } + } + if len(prep.aliases) != len(want) { + t.Errorf("got %d aliases, want %d: %v", len(prep.aliases), len(want), prep.aliases) + } + if prep.gojaRuntimePath != gojaPath { + t.Errorf("gojaRuntimePath = %q, want %q", prep.gojaRuntimePath, gojaPath) } } @@ -51,6 +107,59 @@ func TestResolveSpecAPIPath_FindsUpwardSibling(t *testing.T) { } } +func TestResolveRuntimeSibling(t *testing.T) { + const filename = "goja-runtime.ts" + + siblingRoot := t.TempDir() + siblingAPI := filepath.Join(siblingRoot, "pkg", "spec", "src", "index.ts") + if err := os.MkdirAll(filepath.Dir(siblingAPI), 0o755); err != nil { + t.Fatal(err) + } + siblingFile := filepath.Join(filepath.Dir(siblingAPI), filename) + if err := os.WriteFile(siblingFile, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + + nmRoot := t.TempDir() + nmFile := filepath.Join(nmRoot, "node_modules", "@sanderling", "spec", "src", filename) + if err := os.MkdirAll(filepath.Dir(nmFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(nmFile, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + nmSpec := filepath.Join(nmRoot, "examples", "deep", "spec.ts") + if err := os.MkdirAll(filepath.Dir(nmSpec), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(nmSpec, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + + missingSpec := filepath.Join(t.TempDir(), "spec.ts") + if err := os.WriteFile(missingSpec, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + specAPIPath string + userSpec string + want string + }{ + {"sibling next to spec-API wins", siblingAPI, missingSpec, siblingFile}, + {"node_modules fallback upward", "", nmSpec, nmFile}, + {"neither reachable returns empty", "", missingSpec, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resolveRuntimeSibling(tt.specAPIPath, tt.userSpec, filename); got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} + func TestResolveSpecAPIPath_ReturnsEmptyWhenMissing(t *testing.T) { root := t.TempDir() specPath := filepath.Join(root, "spec.ts") diff --git a/internal/trace/writer.go b/internal/trace/writer.go index 7e29a53..970d8be 100644 --- a/internal/trace/writer.go +++ b/internal/trace/writer.go @@ -158,6 +158,9 @@ func (w *Writer) WriteStep(step Step) error { return w.encoder.Encode(step) } +// WriteScreenshot is lock-free: each call writes a distinct, uniquely-named +// file via os.WriteFile and touches no field of Writer, so concurrent calls +// never contend. func (w *Writer) WriteScreenshot(stepIndex int, png []byte) error { return w.writePNG(fmt.Sprintf("step-%05d.png", stepIndex), png) } diff --git a/internal/trace/writer_test.go b/internal/trace/writer_test.go index 24b846d..8b3f417 100644 --- a/internal/trace/writer_test.go +++ b/internal/trace/writer_test.go @@ -6,8 +6,11 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" + + "github.com/priyanshujain/sanderling/internal/hierarchy" ) func TestWriteMeta_RoundTrip(t *testing.T) { @@ -99,6 +102,12 @@ func TestWriteStep_HierarchyAndResidualsRoundTrip(t *testing.T) { writer, _ := NewWriter(directory) defer writer.Close() + tree, err := hierarchy.Parse(`{"attributes":{"resource-id":"root"},"children":[ + {"attributes":{"resource-id":"child","text":"hi"},"children":[]}]}`) + if err != nil { + t.Fatal(err) + } + step := Step{ Index: 1, Timestamp: time.Now().UTC(), @@ -108,6 +117,7 @@ func TestWriteStep_HierarchyAndResidualsRoundTrip(t *testing.T) { ResolvedBounds: &BoundsRecord{X: 10, Y: 20, Width: 100, Height: 50}, TapPoint: &PointRecord{X: 60, Y: 45}, }, + Hierarchy: tree, Residuals: map[string]json.RawMessage{ "prop1": json.RawMessage(`{"op":"true"}`), }, @@ -132,6 +142,24 @@ func TestWriteStep_HierarchyAndResidualsRoundTrip(t *testing.T) { if string(got.Residuals["prop1"]) != `{"op":"true"}` { t.Errorf("residuals round-trip wrong: %s", got.Residuals["prop1"]) } + + // Intentionally-lossy contract: Tree marshals only Elements (Root and + // Node.Children are json:"-"). The flat element list survives; tree + // structure does not. Lock both halves so a regression that drops the + // element list, or one that silently starts persisting structure the + // replay UI would then depend on, is caught. + if got.Hierarchy == nil { + t.Fatal("hierarchy dropped from trace") + } + if len(got.Hierarchy.Elements) != 2 { + t.Fatalf("hierarchy elements not preserved: got %d", len(got.Hierarchy.Elements)) + } + if got.Hierarchy.Elements[1].Text != "hi" { + t.Errorf("element field lost: %+v", got.Hierarchy.Elements[1]) + } + if got.Hierarchy.Root != nil { + t.Errorf("Root is json:\"-\" and must decode nil, got %+v", got.Hierarchy.Root) + } } func TestWriteStep_OmitsEmptyHierarchyAndResiduals(t *testing.T) { @@ -182,6 +210,66 @@ func TestWriteStep_AppendsOneJsonLine(t *testing.T) { } } +// Bug class: a property that first violates at step 0 carries Witness.Step==0, +// which omitempty drops from JSON. Decode must still yield Step 0 (the true +// origin) rather than confusing it with a later step. Also pins that the +// Witnesses/ExtractorChanges/Metrics/Exceptions sidecars survive the round-trip +// rather than silently vanishing on decode. +func TestWriteStep_DiagnosticsRoundTrip(t *testing.T) { + directory := t.TempDir() + writer, _ := NewWriter(directory) + defer writer.Close() + + step := Step{ + Index: 4, + Witnesses: map[string]Witness{ + "balanceNonNegative": { + Reason: "balance went negative", + IsError: true, + Step: 0, + Extractors: map[string]json.RawMessage{ + "balance": json.RawMessage(`-5`), + }, + }, + }, + ExtractorChanges: map[string]ExtractorChange{ + "balance": {Prev: json.RawMessage(`10`), Curr: json.RawMessage(`-5`)}, + }, + Metrics: &Metrics{CPUPercent: 12.5, HeapBytes: 4096}, + Exceptions: []Exception{{Class: "NullPointerException", Message: "boom"}}, + } + if err := writer.WriteStep(step); err != nil { + t.Fatal(err) + } + body, _ := os.ReadFile(filepath.Join(directory, "trace.jsonl")) + if strings.Contains(string(body), `"step":0`) { + t.Errorf("Witness.Step==0 should be omitted from JSON, got: %s", body) + } + var got Step + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("bad jsonl: %v\n%s", err, body) + } + w, ok := got.Witnesses["balanceNonNegative"] + if !ok { + t.Fatal("step-0 witness vanished on decode") + } + if w.Step != 0 || w.Reason != "balance went negative" || !w.IsError { + t.Errorf("witness round-trip wrong: %+v", w) + } + if string(w.Extractors["balance"]) != `-5` { + t.Errorf("witness extractors lost: %s", w.Extractors["balance"]) + } + if c := got.ExtractorChanges["balance"]; string(c.Prev) != `10` || string(c.Curr) != `-5` { + t.Errorf("extractor change round-trip wrong: %+v", c) + } + if got.Metrics == nil || got.Metrics.CPUPercent != 12.5 || got.Metrics.HeapBytes != 4096 { + t.Errorf("metrics round-trip wrong: %+v", got.Metrics) + } + if len(got.Exceptions) != 1 || got.Exceptions[0].Class != "NullPointerException" { + t.Errorf("exceptions round-trip wrong: %+v", got.Exceptions) + } +} + func TestWriteStep_MultipleStepsAppend(t *testing.T) { directory := t.TempDir() writer, err := NewWriter(directory) @@ -201,6 +289,47 @@ func TestWriteStep_MultipleStepsAppend(t *testing.T) { } } +// Bug class: dropping the writer mutex unsynchronizes the w.file field that +// WriteStep reads and Close nils out. Run under -race with WriteStep racing +// Close: a missing mutex is a reported data race on w.file, and any step that +// survives Close must still be a complete, parseable JSONL line. +func TestWriteStep_RacesCloseSafely(t *testing.T) { + directory := t.TempDir() + writer, err := NewWriter(directory) + if err != nil { + t.Fatal(err) + } + + const n = 50 + var wg sync.WaitGroup + for index := 0; index < n; index++ { + wg.Add(1) + go func(index int) { + defer wg.Done() + // Tolerate "writer is closed": the contract under test is that the + // w.file access is synchronized, not that every write lands. + _ = writer.WriteStep(Step{Index: index, Screen: "s"}) + }(index) + } + if err := writer.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + wg.Wait() + + for _, line := range readLines(t, filepath.Join(directory, "trace.jsonl")) { + var got Step + if err := json.Unmarshal([]byte(line), &got); err != nil { + t.Fatalf("torn JSONL line: %v\n%s", err, line) + } + } +} + +// Grep contract (intentional substring assertion, not a JSON round-trip): +// operators and CI scripts locate failing steps by grepping raw trace.jsonl for +// `"violations":[""]` without a JSON parser. Bug class: a serialization +// change (whitespace from indenting, renamed/reordered field, pointer slice) +// that keeps the Step parseable but breaks that exact on-disk byte shape would +// silently blind every grep-based tool. func TestWriteStep_ViolationsAreGreppable(t *testing.T) { directory := t.TempDir() writer, _ := NewWriter(directory) diff --git a/internal/verifier/ax_integration_test.go b/internal/verifier/ax_integration_test.go index e634657..97fd596 100644 --- a/internal/verifier/ax_integration_test.go +++ b/internal/verifier/ax_integration_test.go @@ -60,12 +60,12 @@ func TestStateAxObjectSelectorTestTagAlias(t *testing.T) { } // TestStateAxFindWorks verifies that a Parse+PushSnapshot+extract round trip -// actually lets the spec resolve selectors through state.ax.find. -// Reads /tmp/live-dump.json (sidecar TreeNode JSON format); skipped if absent. +// actually lets the spec resolve selectors through state.ax.find. Reads a +// committed sidecar TreeNode JSON fixture so the round trip always runs. func TestStateAxFindWorks(t *testing.T) { - jsonText, err := os.ReadFile("/tmp/live-dump.json") + jsonText, err := os.ReadFile("testdata/ax_find_tree.json") if err != nil { - t.Skip("live-dump.json not present") + t.Skip("ax_find_tree.json fixture unreadable") } tree, err := hierarchy.Parse(string(jsonText)) if err != nil { diff --git a/internal/verifier/combinator_test.go b/internal/verifier/combinator_test.go new file mode 100644 index 0000000..ad20501 --- /dev/null +++ b/internal/verifier/combinator_test.go @@ -0,0 +1,118 @@ +package verifier + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/priyanshujain/sanderling/internal/ltl" +) + +// TestCombinators_VerdictTransitions loads real specs through the goja runtime +// using the chainable LTL combinators (implies/or/and/not + now + within steps) +// and drives them across snapshots. Bug class: a user spec built from these +// combinators silently mis-evaluates (wrong verdict at the wrong step). +func TestCombinators_VerdictTransitions(t *testing.T) { + const heads = ` +globalThis.p = __sanderling__.extract(state => state.snapshots["p"] ?? false, "p"); +globalThis.q = __sanderling__.extract(state => state.snapshots["q"] ?? false, "q"); +` + type step struct { + p, q string + want ltl.Verdict + } + cases := []struct { + name string + body string + steps []step + }{ + { + name: "implies", + body: `globalThis.properties = { r: __sanderling__.always(__sanderling__.now(()=>p.current).implies(__sanderling__.now(()=>q.current))) };`, + steps: []step{ + {"false", "false", ltl.VerdictHolds}, // antecedent false -> vacuously holds + {"true", "true", ltl.VerdictHolds}, + {"true", "false", ltl.VerdictViolated}, // p true, q false + {"false", "false", ltl.VerdictViolated}, // sticky + }, + }, + { + name: "or", + body: `globalThis.properties = { r: __sanderling__.always(__sanderling__.now(()=>p.current).or(__sanderling__.now(()=>q.current))) };`, + steps: []step{ + {"true", "false", ltl.VerdictHolds}, + {"false", "true", ltl.VerdictHolds}, + {"false", "false", ltl.VerdictViolated}, + }, + }, + { + name: "and", + body: `globalThis.properties = { r: __sanderling__.always(__sanderling__.now(()=>p.current).and(__sanderling__.now(()=>q.current))) };`, + steps: []step{ + {"true", "true", ltl.VerdictHolds}, + {"true", "false", ltl.VerdictViolated}, // one conjunct false + }, + }, + { + name: "not", + body: `globalThis.properties = { r: __sanderling__.always(__sanderling__.now(()=>p.current).not()) };`, + steps: []step{ + {"false", "false", ltl.VerdictHolds}, + {"true", "false", ltl.VerdictViolated}, + }, + }, + { + name: "within_steps_deadline", + body: `globalThis.properties = { r: __sanderling__.always(__sanderling__.eventually(()=>p.current).within(2,'steps')) };`, + steps: []step{ + {"false", "false", ltl.VerdictPending}, // obligation open + {"false", "false", ltl.VerdictViolated}, // deadline blown, never fired + }, + }, + { + name: "within_steps_satisfied", + body: `globalThis.properties = { r: __sanderling__.always(__sanderling__.eventually(()=>p.current).within(2,'steps')) };`, + steps: []step{ + {"false", "false", ltl.VerdictPending}, + {"true", "false", ltl.VerdictHolds}, // fired before deadline + }, + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, heads+testCase.body) + for i, s := range testCase.steps { + if err := verifier.PushSnapshot(SnapshotInput{ + Snapshots: Snapshots{"p": json.RawMessage(s.p), "q": json.RawMessage(s.q)}, + StepIndex: i + 1, + }); err != nil { + t.Fatal(err) + } + if got := verifier.EvaluateProperties()["r"]; got != s.want { + t.Errorf("step %d (p=%s q=%s): got %v, want %v", i+1, s.p, s.q, got, s.want) + } + } + }) + } +} + +// TestWithin_InvalidUnitPanics verifies an unrecognized within() unit surfaces +// as a spec load error rather than silently constructing an unbounded +// eventually. Bug class: a typo'd unit ('ms'/'s') would otherwise build a +// formula that never enforces its deadline. +func TestWithin_InvalidUnitPanics(t *testing.T) { + for _, unit := range []string{"ms", "s", "minutes", ""} { + verifier := newVerifier(t) + src := `globalThis.properties = { r: __sanderling__.always(__sanderling__.eventually(()=>true).within(2,'` + unit + `')) };` + err := verifier.Load(src) + if err == nil { + t.Errorf("unit %q: expected load error, got nil", unit) + continue + } + if !strings.Contains(err.Error(), "within unit must be") { + t.Errorf("unit %q: error = %v, want within-unit diagnostic", unit, err) + } + } +} diff --git a/internal/verifier/marshal_test.go b/internal/verifier/marshal_test.go new file mode 100644 index 0000000..6013faa --- /dev/null +++ b/internal/verifier/marshal_test.go @@ -0,0 +1,149 @@ +package verifier + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/dop251/goja" +) + +// TestDecodeAction_AllKinds decodes the flat camelCase wire contract for every +// action kind from raw JSON. Bug class: an action field rename or a missing +// case silently mangles or drops actions on the decode side. +func TestDecodeAction_AllKinds(t *testing.T) { + cases := []struct { + name string + raw string + want Action + }{ + { + name: "Tap", + raw: `{"kind":"Tap","selector":"id:btn","x":10,"y":20}`, + want: Action{Kind: ActionKindTap, On: "id:btn", X: 10, Y: 20}, + }, + { + name: "DoubleTap", + raw: `{"kind":"DoubleTap","selector":"id:btn","x":1,"y":2}`, + want: Action{Kind: ActionKindDoubleTap, On: "id:btn", X: 1, Y: 2}, + }, + { + name: "LongPress", + raw: `{"kind":"LongPress","selector":"id:btn","x":3,"y":4}`, + want: Action{Kind: ActionKindLongPress, On: "id:btn", X: 3, Y: 4}, + }, + { + name: "InputText", + raw: `{"kind":"InputText","selector":"id:field","text":"hi","x":5,"y":6}`, + want: Action{Kind: ActionKindInputText, On: "id:field", Text: "hi", X: 5, Y: 6}, + }, + { + name: "Swipe", + raw: `{"kind":"Swipe","fromX":1,"fromY":2,"toX":3,"toY":4,"durationMillis":250}`, + want: Action{Kind: ActionKindSwipe, FromX: 1, FromY: 2, ToX: 3, ToY: 4, DurationMillis: 250}, + }, + { + name: "Scroll", + raw: `{"kind":"Scroll","direction":"down","fromX":1,"fromY":2,"toX":3,"toY":4,"durationMillis":100}`, + want: Action{Kind: ActionKindScroll, Direction: "down", FromX: 1, FromY: 2, ToX: 3, ToY: 4, DurationMillis: 100}, + }, + { + name: "PressKey", + raw: `{"kind":"PressKey","key":"back"}`, + want: Action{Kind: ActionKindPressKey, Key: "back"}, + }, + { + name: "Wait", + raw: `{"kind":"Wait","durationMillis":500}`, + want: Action{Kind: ActionKindWait, DurationMillis: 500}, + }, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + got, err := DecodeAction(json.RawMessage(testCase.raw)) + if err != nil { + t.Fatalf("DecodeAction(%s): %v", testCase.raw, err) + } + if got != testCase.want { + t.Errorf("DecodeAction(%s) = %+v, want %+v", testCase.raw, got, testCase.want) + } + }) + } +} + +// TestDecodeAction_NullAndUnknown verifies a null/empty payload reports +// ErrNoAction and an unrecognized kind returns an error. Bug class: an unknown +// kind silently decoding to a zero Action (a no-op the runner dispatches). +func TestDecodeAction_NullAndUnknown(t *testing.T) { + for _, raw := range []string{"null", ""} { + if _, err := DecodeAction(json.RawMessage(raw)); !errors.Is(err, ErrNoAction) { + t.Errorf("DecodeAction(%q): err = %v, want ErrNoAction", raw, err) + } + } + if _, err := DecodeAction(json.RawMessage(`{"kind":"Teleport"}`)); err == nil { + t.Error("DecodeAction(unknown kind): err = nil, want error") + } +} + +// TestLastActionObject_ExposesKindSpecificFields pushes a LastAction of each +// kind and asserts the JS-side lastAction object the spec reads carries the +// kind plus that kind's fields (on/from/direction/key). Bug class: specs +// gating on lastAction see wrong or missing fields. +func TestLastActionObject_ExposesKindSpecificFields(t *testing.T) { + verifier := newVerifier(t) + mustLoad(t, verifier, ` + globalThis.last = __sanderling__.extract(state => state.lastAction); + `) + + read := func(t *testing.T, action *Action) *goja.Object { + t.Helper() + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}, LastAction: action}); err != nil { + t.Fatal(err) + } + current := verifier.runtime.GlobalObject().Get("last").ToObject(verifier.runtime).Get("current") + if goja.IsNull(current) || goja.IsUndefined(current) { + t.Fatal("lastAction current is null/undefined") + } + return current.ToObject(verifier.runtime) + } + + t.Run("Tap", func(t *testing.T) { + obj := read(t, &Action{Kind: ActionKindTap, On: "id:btn"}) + if obj.Get("kind").String() != "Tap" { + t.Errorf("kind = %q, want Tap", obj.Get("kind")) + } + if obj.Get("on").String() != "id:btn" { + t.Errorf("on = %q, want id:btn", obj.Get("on")) + } + }) + + t.Run("Swipe", func(t *testing.T) { + obj := read(t, &Action{Kind: ActionKindSwipe, FromX: 1, FromY: 2, ToX: 3, ToY: 4}) + from := obj.Get("from").ToObject(verifier.runtime) + if from.Get("x").ToInteger() != 1 || from.Get("y").ToInteger() != 2 { + t.Errorf("from = (%v,%v), want (1,2)", from.Get("x"), from.Get("y")) + } + to := obj.Get("to").ToObject(verifier.runtime) + if to.Get("x").ToInteger() != 3 || to.Get("y").ToInteger() != 4 { + t.Errorf("to = (%v,%v), want (3,4)", to.Get("x"), to.Get("y")) + } + }) + + t.Run("Scroll", func(t *testing.T) { + obj := read(t, &Action{Kind: ActionKindScroll, Direction: "down", FromX: 5, FromY: 6}) + if obj.Get("direction").String() != "down" { + t.Errorf("direction = %q, want down", obj.Get("direction")) + } + from := obj.Get("from").ToObject(verifier.runtime) + if from.Get("x").ToInteger() != 5 || from.Get("y").ToInteger() != 6 { + t.Errorf("from = (%v,%v), want (5,6)", from.Get("x"), from.Get("y")) + } + }) + + t.Run("PressKey", func(t *testing.T) { + obj := read(t, &Action{Kind: ActionKindPressKey, Key: "back"}) + if obj.Get("key").String() != "back" { + t.Errorf("key = %q, want back", obj.Get("key")) + } + }) +} diff --git a/internal/verifier/testdata/ax_find_tree.json b/internal/verifier/testdata/ax_find_tree.json new file mode 100644 index 0000000..2dd0ef2 --- /dev/null +++ b/internal/verifier/testdata/ax_find_tree.json @@ -0,0 +1,15 @@ +{ + "attributes": {"resource-id": "com.example:id/root", "bounds": "[0,0,1080,2340]"}, + "children": [ + { + "attributes": {"resource-id": "com.example:id/header", "bounds": "[0,0,1080,200]"}, + "children": [] + }, + { + "attributes": {"resource-id": "com.example:id/select_language", "text": "Select language", "bounds": "[40,400,1040,520]"}, + "clickable": true, + "enabled": true, + "children": [] + } + ] +} diff --git a/internal/verifier/verifier_test.go b/internal/verifier/verifier_test.go index 8defe5a..2a1c82e 100644 --- a/internal/verifier/verifier_test.go +++ b/internal/verifier/verifier_test.go @@ -339,8 +339,11 @@ func TestNextAction_WeightedSelectsByWeight(t *testing.T) { awayCount++ } } - if awayCount <= homeCount { - t.Errorf("expected away-skewed distribution, got home=%d away=%d", homeCount, awayCount) + // Weights are 99:1, so away must dominate by a wide margin. Requiring a 5x + // skew (rather than a bare >) keeps the assertion robust to harmless picker + // reshuffles while still failing if the weight is ignored or inverted. + if awayCount <= 5*homeCount { + t.Errorf("expected away to outweigh home by >5x (weights 99:1), got home=%d away=%d", homeCount, awayCount) } } @@ -1296,3 +1299,46 @@ func TestUnsupportedVerbs_CollectedDedupedInOrder(t *testing.T) { t.Errorf("UnsupportedVerbs = %v, want %v", got, want) } } + +// TestWithPlatform_IOSReachesPicker asserts WithPlatform("ios") is plumbed all +// the way to the host binding the shared picker reads (host.platform()), and +// that a platform-gated builtin (pressKeys) still resolves on iOS, drawing from +// the native key pool. Bug class: the platform option is dropped before the +// picker, so iOS silently runs the android (default) verb matrix / key pool. +func TestWithPlatform_IOSReachesPicker(t *testing.T) { + verifier := newVerifier(t, WithPlatform("ios")) + + platform, ok := goja.AssertFunction( + verifier.runtime.GlobalObject().Get("__sanderlingHost__"). + ToObject(verifier.runtime).Get("platform"), + ) + if !ok { + t.Fatal("platform host binding missing") + } + value, err := platform(goja.Undefined()) + if err != nil { + t.Fatal(err) + } + if value.String() != "ios" { + t.Errorf("host.platform() = %q, want ios", value.String()) + } + + loadActionSpec(t, verifier, ` + import { pressKeys } from "@sanderling/spec"; + globalThis.actions = pressKeys; + `) + if err := verifier.PushSnapshot(SnapshotInput{Snapshots: Snapshots{}}); err != nil { + t.Fatal(err) + } + action, err := verifier.NextAction() + if err != nil { + t.Fatal(err) + } + if action.Kind != ActionKindPressKey { + t.Fatalf("kind = %v, want PressKey", action.Kind) + } + // iOS draws from NATIVE_PRESS_KEYS (corpus.ts), which contains only "back". + if action.Key != "back" { + t.Errorf("key = %q, want back (native press-key pool)", action.Key) + } +} diff --git a/pkg/spec/src/web-runtime.ts b/pkg/spec/src/web-runtime.ts index a6edb79..7f74004 100644 --- a/pkg/spec/src/web-runtime.ts +++ b/pkg/spec/src/web-runtime.ts @@ -600,6 +600,9 @@ export const __testing__ = { runtime, extractors, evaluateExtractors, + selectorFromString, + selectorFromObject, + xpathStringLiteral, }; export {}; diff --git a/pkg/spec/test/web-runtime.test.ts b/pkg/spec/test/web-runtime.test.ts index 2ee1e3b..d01fbee 100644 --- a/pkg/spec/test/web-runtime.test.ts +++ b/pkg/spec/test/web-runtime.test.ts @@ -9,6 +9,44 @@ import { test } from "node:test"; // A 64-bit seed that loses precision as a JS Number must survive as a BigInt. process.env.SANDERLING_SEED = "9007199254740993"; +// cssEscape delegates to the browser's CSS.escape, absent in node. Install the +// WHATWG CSSOM escape algorithm so selector-builder tests exercise the real +// escaping production relies on, not a stub. +if (!(globalThis as { CSS?: unknown }).CSS) { + (globalThis as { CSS?: { escape(v: string): string } }).CSS = { + escape(value: string): string { + let out = ""; + for (let i = 0; i < value.length; i++) { + const c = value.charCodeAt(i); + if (c === 0) { + out += "�"; + } else if ( + (c >= 0x1 && c <= 0x1f) || + c === 0x7f || + (i === 0 && c >= 0x30 && c <= 0x39) || + (i === 1 && c >= 0x30 && c <= 0x39 && value.charCodeAt(0) === 0x2d) + ) { + out += "\\" + c.toString(16) + " "; + } else if (i === 0 && c === 0x2d && value.length === 1) { + out += "\\" + value[i]; + } else if ( + c >= 0x80 || + c === 0x2d || + c === 0x5f || + (c >= 0x30 && c <= 0x39) || + (c >= 0x41 && c <= 0x5a) || + (c >= 0x61 && c <= 0x7a) + ) { + out += value[i]; + } else { + out += "\\" + value[i]; + } + } + return out; + }, + }; +} + const { __testing__ } = await import("../src/web-runtime.ts"); const { host } = __testing__; @@ -183,3 +221,121 @@ test("an uncaught cross-extractor read aborts evaluateExtractors", () => { /inside another extractor is not allowed/, ); }); + +// sanitize runs over every extractor's return value before it leaves the +// runtime. A user extractor that returns a page object reachable from +// document/window can be self-referential, carry functions, or nest deeply; +// without cycle, function, and depth guards extraction overflows the stack or +// emits non-serializable values. These exercise sanitize via the real path. +function sanitizeViaExtract(value: unknown): unknown { + __testing__.extractors.length = 0; + __testing__.runtime.extract(() => value); + let out: Record = {}; + withState(() => { + out = __testing__.evaluateExtractors(); + }); + return out[0]; +} + +test("sanitize breaks a self-referential cycle instead of overflowing", () => { + const cyclic: Record = { name: "root" }; + cyclic.self = cyclic; + const result = sanitizeViaExtract(cyclic) as Record; + assert.equal(result.name, "root"); + assert.equal(result.self, null); +}); + +test("sanitize drops function-valued properties", () => { + const result = sanitizeViaExtract({ keep: 1, fn: () => 7 }) as Record; + assert.deepEqual(result, { keep: 1 }); +}); + +test("sanitize drops a top-level function to undefined", () => { + assert.equal(sanitizeViaExtract(() => 7), undefined); +}); + +test("sanitize bounds recursion past its depth limit", () => { + let deep: Record = { leaf: true }; + for (let i = 0; i < 40; i++) deep = { next: deep }; + // Walk to the depth cap; beyond it sanitize must yield null, not recurse on. + let node: unknown = sanitizeViaExtract(deep); + for (let i = 0; i < 32 && node && typeof node === "object"; i++) { + node = (node as Record).next; + } + assert.equal(node, null); +}); + +test("sanitize preserves arrays and nested plain values", () => { + const result = sanitizeViaExtract({ items: [1, "two", { ok: true }] }); + assert.deepEqual(result, { items: [1, "two", { ok: true }] }); +}); + +// xpathStringLiteral builds XPath 1.0 string literals by hand (no escape +// syntax in XPath 1.0). A value carrying a quote that isn't wrapped or +// concat()-composed produces a malformed expression, so document.evaluate +// throws or, worse, matches the wrong node by truncating at the quote. +const { xpathStringLiteral } = __testing__; + +test("xpathStringLiteral table: quote handling stays well-formed", () => { + const cases: Array<[string, string]> = [ + ["plain", '"plain"'], + ['has"double', `'has"double'`], + ["has'single", `"has'single"`], + [`both"and'`, `concat("both", '"', "and'")`], + [`"`, `'"'`], + ]; + for (const [input, want] of cases) { + assert.equal(xpathStringLiteral(input), want, input); + } +}); + +// selectorFromString routes a "kind:value" prefix; text becomes an XPath +// equality, everything else a CSS attribute selector. A value containing a +// colon must not be re-split, and a quote in a text value must reach the +// well-formed XPath literal rather than corrupting the predicate. +const { selectorFromString, selectorFromObject } = __testing__; + +test("selectorFromString routes text to a normalize-space XPath", () => { + assert.deepEqual(selectorFromString("text:Hello"), { + xpath: `//*[normalize-space(text())="Hello"]`, + }); +}); + +test("selectorFromString keeps colons in the value intact", () => { + // Only the first colon splits kind from value; the rest is the value. + assert.deepEqual(selectorFromString("text:a:b:c"), { + xpath: `//*[normalize-space(text())="a:b:c"]`, + }); +}); + +test("selectorFromString text value with both quote kinds uses concat", () => { + assert.deepEqual(selectorFromString(`text:say "hi" o'clock`), { + xpath: `//*[normalize-space(text())=concat("say ", '"', "hi", '"', " o'clock")]`, + }); +}); + +test("selectorFromObject escapes attribute values to prevent injection", () => { + // A quote in an id value, left unescaped, would close the attribute selector + // early and match a different element. + assert.deepEqual(selectorFromObject({ id: 'a"]' }), { + css: `[id="a\\"\\]"]`, + }); +}); + +test("selectorFromObject maps known keys to their canonical attribute", () => { + assert.deepEqual(selectorFromObject({ testID: "submit" }), { + css: `[data-testid="submit"]`, + }); +}); + +test("selectorFromObject falls back to a literal attribute for unknown keys", () => { + assert.deepEqual(selectorFromObject({ "data-foo": "bar" }), { + css: `[data-foo="bar"]`, + }); +}); + +test("selectorFromObject text-only selector becomes an XPath", () => { + assert.deepEqual(selectorFromObject({ text: "Go" }), { + xpath: `//*[normalize-space(text())="Go"]`, + }); +}); diff --git a/replay-ui/package.json b/replay-ui/package.json index b09867a..9f3b134 100644 --- a/replay-ui/package.json +++ b/replay-ui/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "bun test" }, "dependencies": { "react": "^18.3.1", diff --git a/replay-ui/src/__tests__/action-format.test.ts b/replay-ui/src/__tests__/action-format.test.ts new file mode 100644 index 0000000..fc65fa6 --- /dev/null +++ b/replay-ui/src/__tests__/action-format.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from "bun:test"; +import { + formatActionRow, + formatElapsed, + parseSelector, + tagFromSelector, +} from "../lib/action-format"; +import { summary } from "./fixtures"; + +// Bug class: selector parsing mislabels every action row — splitting on the +// wrong colon, treating a value-with-colon as the kind, or dropping the prefix +// ellipsis would render the wrong target tag for every step. +describe("parseSelector", () => { + const cases: { input: string; out: ReturnType }[] = [ + { input: "id:login", out: { kind: "id", value: "login" } }, + { input: "text:Sign In", out: { kind: "text", value: "Sign In" } }, + { input: "textPrefix:Hello", out: { kind: "textPrefix", value: "Hello" } }, + { input: "id:com.app:id/btn", out: { kind: "id", value: "com.app:id/btn" } }, + { input: "bogus:x", out: null }, + { input: ":leading", out: null }, + { input: "no-colon", out: null }, + ]; + for (const { input, out } of cases) { + it(`parses ${input}`, () => { + expect(parseSelector(input)).toEqual(out); + }); + } +}); + +describe("tagFromSelector", () => { + it("appends ellipsis only for prefix selectors", () => { + expect(tagFromSelector("textPrefix:Hel")).toBe("Hel..."); + expect(tagFromSelector("text:Hello")).toBe("Hello"); + expect(tagFromSelector("plain")).toBe("plain"); + }); +}); + +describe("formatActionRow", () => { + it("observes when no action kind, with and without screen", () => { + expect(formatActionRow(summary({}))).toEqual({ + verb: "Observe", + target: "", + targetIsTag: false, + }); + expect(formatActionRow(summary({ screen: "Home" }))).toEqual({ + verb: "Observe", + target: "@ Home", + targetIsTag: false, + }); + }); + + it("treats a selector label as a tag and a coordinate label as literal", () => { + expect( + formatActionRow(summary({ action_kind: "Tap", action_label: "id:btn" })), + ).toEqual({ verb: "Click", target: "btn", targetIsTag: true }); + expect( + formatActionRow(summary({ action_kind: "Tap", action_label: "(10, 20)" })), + ).toEqual({ verb: "Click", target: "(10, 20)", targetIsTag: false }); + }); + + it("maps known verbs and falls back to the raw kind", () => { + expect(formatActionRow(summary({ action_kind: "InputText", action_label: "hi" })).verb).toBe("Type"); + expect(formatActionRow(summary({ action_kind: "Swipe" })).verb).toBe("Swipe"); + expect(formatActionRow(summary({ action_kind: "Custom" })).verb).toBe("Custom"); + }); +}); + +describe("formatElapsed", () => { + it("formats mm:ss.mmm and clamps negatives to zero", () => { + expect(formatElapsed(0)).toBe("00:00.000"); + expect(formatElapsed(65_432)).toBe("01:05.432"); + expect(formatElapsed(-50)).toBe("00:00.000"); + }); +}); diff --git a/replay-ui/src/__tests__/api.test.ts b/replay-ui/src/__tests__/api.test.ts index 80235a6..d209811 100644 --- a/replay-ui/src/__tests__/api.test.ts +++ b/replay-ui/src/__tests__/api.test.ts @@ -1,10 +1,37 @@ import { describe, it, expect } from "bun:test"; -import { screenshotUrl } from "../api"; +import { getJson, screenshotUrl } from "../api"; describe("screenshotUrl", () => { - it("encodes runId and name", () => { - expect(screenshotUrl("run-1", "step-00001.png")).toBe( - "/api/runs/run-1/screenshots/step-00001.png", + it("percent-encodes runId and name with reserved characters", () => { + expect(screenshotUrl("run #1/a", "step 00001.png")).toBe( + "/api/runs/run%20%231%2Fa/screenshots/step%2000001.png", ); }); }); + +describe("getJson", () => { + it("returns the decoded body on a 200 response", async () => { + const server = Bun.serve({ + port: 0, + fetch: () => Response.json({ ok: true }), + }); + try { + const body = await getJson<{ ok: boolean }>(server.url.href); + expect(body).toEqual({ ok: true }); + } finally { + server.stop(true); + } + }); + + it("throws on a non-ok response instead of returning the error body", async () => { + const server = Bun.serve({ + port: 0, + fetch: () => new Response("boom", { status: 500 }), + }); + try { + await expect(getJson(server.url.href)).rejects.toThrow("500"); + } finally { + server.stop(true); + } + }); +}); diff --git a/replay-ui/src/__tests__/fixtures.ts b/replay-ui/src/__tests__/fixtures.ts new file mode 100644 index 0000000..1b5e49a --- /dev/null +++ b/replay-ui/src/__tests__/fixtures.ts @@ -0,0 +1,15 @@ +import type { Step, StepSummary } from "../types"; + +export function step(over: Partial): Step { + return { step: 0, timestamp: "1970-01-01T00:00:00.000Z", ...over }; +} + +export function summary(over: Partial): StepSummary { + return { + index: 0, + timestamp: "1970-01-01T00:00:00.000Z", + has_violations: false, + has_exceptions: false, + ...over, + }; +} diff --git a/replay-ui/src/__tests__/keyboard-nav.test.ts b/replay-ui/src/__tests__/keyboard-nav.test.ts new file mode 100644 index 0000000..5e4194c --- /dev/null +++ b/replay-ui/src/__tests__/keyboard-nav.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "bun:test"; +import { + dispatchKey, + targetOwnsArrowKeys, + type KeyboardNavOptions, +} from "../lib/keyboard-nav"; + +function el( + over: Partial<{ + tagName: string; + isContentEditable: boolean; + role: string | null; + parent: HTMLElement | null; + }> = {}, +): HTMLElement { + const role = over.role ?? null; + return { + tagName: over.tagName ?? "DIV", + isContentEditable: over.isContentEditable ?? false, + getAttribute: (name: string) => (name === "role" ? role : null), + parentElement: over.parent ?? null, + } as unknown as HTMLElement; +} + +function spyOptions() { + const calls: string[] = []; + const make = (name: keyof KeyboardNavOptions) => () => { + calls.push(name); + }; + const options: KeyboardNavOptions = { + onPrev: make("onPrev"), + onNext: make("onNext"), + onJumpStart: make("onJumpStart"), + onJumpEnd: make("onJumpEnd"), + onJumpPrev10: make("onJumpPrev10"), + onJumpNext10: make("onJumpNext10"), + onJumpNextViolation: make("onJumpNextViolation"), + }; + return { calls, options }; +} + +describe("targetOwnsArrowKeys", () => { + it("walks ancestors and matches arrow-owning roles", () => { + expect(targetOwnsArrowKeys(el({ role: "option" }))).toBe(true); + const child = el({ role: null, parent: el({ role: "listbox" }) }); + expect(targetOwnsArrowKeys(child)).toBe(true); + expect(targetOwnsArrowKeys(el({ role: "banner" }))).toBe(false); + expect(targetOwnsArrowKeys(null)).toBe(false); + }); +}); + +// Bug class: navigation keys firing while the user types in a form field would +// scrub the timeline out from under them; and arrow keys must yield to a +// listbox/tab that owns them so its own roving focus still works. +describe("dispatchKey ownership", () => { + it("ignores keys originating in editable targets", () => { + const { calls, options } = spyOptions(); + expect(dispatchKey({ key: "j", target: el({ tagName: "INPUT" }) }, options)).toBe(false); + expect(dispatchKey({ key: "g", target: el({ isContentEditable: true }) }, options)).toBe(false); + expect(calls).toEqual([]); + }); + + it("yields arrow keys to an owning ancestor but still handles letters", () => { + const { calls, options } = spyOptions(); + const inListbox = el({ parent: el({ role: "listbox" }) }); + expect(dispatchKey({ key: "ArrowRight", target: inListbox }, options)).toBe(false); + expect(dispatchKey({ key: "j", target: inListbox }, options)).toBe(true); + expect(calls).toEqual(["onNext"]); + }); + + it("ignores keys combined with a modifier", () => { + const { calls, options } = spyOptions(); + expect(dispatchKey({ key: "j", metaKey: true }, options)).toBe(false); + expect(dispatchKey({ key: "j", ctrlKey: true }, options)).toBe(false); + expect(calls).toEqual([]); + }); +}); + +describe("dispatchKey routing", () => { + const cases: [string, boolean, keyof KeyboardNavOptions][] = [ + ["ArrowLeft", false, "onPrev"], + ["k", false, "onPrev"], + ["ArrowLeft", true, "onJumpPrev10"], + ["ArrowRight", false, "onNext"], + ["j", true, "onJumpNext10"], + ["g", false, "onJumpStart"], + ["G", false, "onJumpEnd"], + [".", false, "onJumpNextViolation"], + ]; + for (const [key, shiftKey, expected] of cases) { + it(`${shiftKey ? "shift+" : ""}${key} -> ${expected}`, () => { + const { calls, options } = spyOptions(); + expect(dispatchKey({ key, shiftKey }, options)).toBe(true); + expect(calls).toEqual([expected]); + }); + } + + it("leaves unmapped keys untouched", () => { + const { calls, options } = spyOptions(); + expect(dispatchKey({ key: "x" }, options)).toBe(false); + expect(calls).toEqual([]); + }); +}); diff --git a/replay-ui/src/__tests__/metrics-format.test.ts b/replay-ui/src/__tests__/metrics-format.test.ts new file mode 100644 index 0000000..8c36dae --- /dev/null +++ b/replay-ui/src/__tests__/metrics-format.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "bun:test"; +import { + buildPath, + formatHeap, + formatTime, + fractionFor, +} from "../lib/metrics-format"; + +describe("formatHeap", () => { + const cases: [number, string][] = [ + [0, "0B"], + [-1, "0B"], + [2048, "2K"], + [1024 * 1024, "1M"], + [5 * 1024 * 1024, "5M"], + [2 * 1024 * 1024 * 1024, "2.0G"], + ]; + for (const [bytes, expected] of cases) { + it(`${bytes} -> ${expected}`, () => { + expect(formatHeap(bytes)).toBe(expected); + }); + } +}); + +describe("formatTime", () => { + it("formats mm:ss and clamps negatives", () => { + expect(formatTime(0)).toBe("00:00"); + expect(formatTime(65_000)).toBe("01:05"); + expect(formatTime(-10)).toBe("00:00"); + }); +}); + +describe("fractionFor", () => { + it("centers a lone point and spreads the rest across 0..1", () => { + expect(fractionFor(0, 1)).toBe(0.5); + expect(fractionFor(0, 5)).toBe(0); + expect(fractionFor(4, 5)).toBe(1); + expect(fractionFor(2, 5)).toBe(0.5); + }); +}); + +// Bug class: a missing sample must lift the pen (M) so the chart does not draw +// a straight line bridging the gap, which would imply data that was never +// measured. +describe("buildPath gap handling", () => { + it("starts a new subpath after each undefined value", () => { + const samples = [{ v: 0 }, { v: 100 }, { v: undefined }, { v: 50 }]; + const path = buildPath(samples, (s) => s.v, 100); + const commands = path.match(/[ML]/g); + expect(commands).toEqual(["M", "L", "M"]); + expect(path.startsWith("M0.0000,1.0000")).toBe(true); + }); + + it("emits empty string when every sample is missing", () => { + const samples = [{ v: undefined }, { v: undefined }]; + expect(buildPath(samples, (s) => s.v, 100)).toBe(""); + }); + + it("clamps values above the ceiling to the top of the lane", () => { + const path = buildPath([{ v: 200 }], (s) => s.v, 100); + expect(path).toBe("M0.5000,0.0000"); + }); +}); diff --git a/replay-ui/src/__tests__/property-status.test.ts b/replay-ui/src/__tests__/property-status.test.ts new file mode 100644 index 0000000..bf52641 --- /dev/null +++ b/replay-ui/src/__tests__/property-status.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from "bun:test"; +import { + STATUS_ORDER, + statusFor, + statusForStep, +} from "../lib/property-status"; +import { step } from "./fixtures"; + +// Bug class: RunDetail and ViolationsPanel once carried two copies of this +// status logic; if they drift, the same property shows a different verdict in +// the timeline vs the violations list. Both panels now share statusFor, so its +// precedence and the violated-first ordering must stay pinned. +describe("statusFor", () => { + it("ranks violated over holds and defaults missing residuals to pending", () => { + const violations = new Set(["v"]); + expect(statusFor("v", violations, { v: { op: "true" } })).toBe("violated"); + expect(statusFor("h", violations, { h: { op: "true" } })).toBe("holds"); + expect(statusFor("p", violations, { p: { op: "false" } })).toBe("pending"); + expect(statusFor("x", violations, undefined)).toBe("pending"); + }); +}); + +describe("statusForStep", () => { + it("matches statusFor for the same step and is pending for a null step", () => { + const s = step({ + violations: ["v"], + residuals: { v: { op: "true" }, h: { op: "true" } }, + }); + expect(statusForStep("v", s)).toBe("violated"); + expect(statusForStep("h", s)).toBe("holds"); + expect(statusForStep("v", null)).toBe("pending"); + }); +}); + +describe("STATUS_ORDER", () => { + it("sorts violated before pending before holds", () => { + const sorted = ["holds", "violated", "pending"].sort( + (a, b) => STATUS_ORDER[a as never] - STATUS_ORDER[b as never], + ); + expect(sorted).toEqual(["violated", "pending", "holds"]); + }); +}); diff --git a/replay-ui/src/__tests__/run-history.test.ts b/replay-ui/src/__tests__/run-history.test.ts new file mode 100644 index 0000000..e9bc1d3 --- /dev/null +++ b/replay-ui/src/__tests__/run-history.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "bun:test"; +import { + buildRunHistory, + collectPropertyNames, + sortLanes, + statusForProperty, +} from "../lib/run-history"; +import type { PropertyLane } from "../panels/Timeline"; +import type { Run } from "../types"; +import { step, summary } from "./fixtures"; + +function lane(name: string, statuses: PropertyLane["statuses"]): PropertyLane { + return { name, statuses }; +} + +describe("collectPropertyNames", () => { + it("dedups and sorts names across steps, skipping null and residual-less steps", () => { + const names = collectPropertyNames([ + null, + step({ residuals: { b: { op: "true" }, a: { op: "false" } } }), + step({}), + step({ residuals: { a: { op: "true" }, c: { op: "true" } } }), + ]); + expect(names).toEqual(["a", "b", "c"]); + }); +}); + +// Bug class: getting status precedence wrong (e.g. checking residual before +// the violation set, or not defaulting null/absent to pending) would paint a +// violated property lane green. +describe("statusForProperty", () => { + it("ranks violated over a holding residual and defaults to pending", () => { + const s = step({ violations: ["p"], residuals: { p: { op: "true" } } }); + expect(statusForProperty("p", s)).toBe("violated"); + expect(statusForProperty("q", step({ residuals: { q: { op: "true" } } }))).toBe("holds"); + expect(statusForProperty("q", step({ residuals: { q: { op: "false" } } }))).toBe("pending"); + expect(statusForProperty("p", null)).toBe("pending"); + }); +}); + +// Bug class: a lane that ever violated must sort first; trailing-pending lanes +// rank ahead of fully-holding ones, else the timeline buries active failures. +describe("sortLanes", () => { + it("orders violated, then trailing-pending, then holds, ties by name", () => { + const ordered = sortLanes([ + lane("holds-b", ["holds", "holds"]), + lane("pending-a", ["holds", "pending"]), + lane("violated-z", ["holds", "violated", "holds"]), + lane("holds-a", ["holds", "holds"]), + ]).map((l) => l.name); + expect(ordered).toEqual(["violated-z", "pending-a", "holds-a", "holds-b"]); + }); +}); + +describe("buildRunHistory", () => { + it("aligns lane statuses, metrics samples, and first-violation index by position", () => { + const run = { + id: "run-1", + steps: [ + summary({ index: 0 }), + summary({ index: 1, has_violations: true }), + summary({ index: 2, has_exceptions: true }), + ], + } as unknown as Run; + const responses = [ + step({ step: 0, residuals: { p: { op: "true" } } }), + step({ step: 1, violations: ["p"], residuals: { p: { op: "true" } } }), + null, + ]; + + const history = buildRunHistory(run, responses); + + expect(history.names).toEqual(["p"]); + expect(history.lanes[0].statuses).toEqual(["holds", "violated", "pending"]); + expect(history.firstViolationStep).toBe(1); + expect(history.firstExceptionStep).toBe(2); + expect(history.violationStepIndices).toEqual([1]); + expect(history.exceptionStepIndices).toEqual([2]); + expect(history.metricsSamples.map((m) => m.stepIndex)).toEqual([0, 1, 2]); + }); +}); diff --git a/replay-ui/src/__tests__/snapshot-diff.test.ts b/replay-ui/src/__tests__/snapshot-diff.test.ts new file mode 100644 index 0000000..12f8e70 --- /dev/null +++ b/replay-ui/src/__tests__/snapshot-diff.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from "bun:test"; +import { + canonicalize, + flatten, + getAtPath, + stableStringify, +} from "../lib/snapshot-diff"; + +// Bug class: a path off-by-one in the getAtPath regex parse resolves the wrong +// node, so every diff between two snapshots is mis-reported. flatten emits the +// paths the diff later feeds back through getAtPath, so every emitted path must +// resolve to the same value flatten recorded. +describe("flatten/getAtPath round-trip", () => { + const cases: { name: string; input: Record }[] = [ + { name: "nested objects", input: { a: { b: { c: 1 } } } }, + { + name: "array longer than inline limit indexes each element", + input: { items: [10, 20, 30, 40] }, + }, + { + name: "objects inside an expanded array", + input: { rows: [{ id: 1 }, { id: 2 }, { id: 3 }] }, + }, + { + name: "keys with dots are matched verbatim before regex split", + input: { "a.b": 7 }, + }, + { + name: "mixed nesting", + input: { ui: { tabs: ["x", "y", "z", "w"], open: true }, n: 0 }, + }, + ]; + + for (const { name, input } of cases) { + it(name, () => { + for (const row of flatten(input)) { + expect(stableStringify(getAtPath(input, row.path))).toBe( + stableStringify(row.value), + ); + } + }); + } + + it("indexes array elements by their real position, not off by one", () => { + const input = { items: ["a", "b", "c", "d"] }; + const rows = flatten(input); + expect(getAtPath(input, "items[0]")).toBe("a"); + expect(getAtPath(input, "items[3]")).toBe("d"); + expect(rows.map((r) => r.path)).toEqual([ + "items[0]", + "items[1]", + "items[2]", + "items[3]", + ]); + }); +}); + +describe("canonicalize", () => { + it("orders object keys so reordered snapshots compare equal", () => { + expect(stableStringify({ b: 1, a: 2 })).toBe(stableStringify({ a: 2, b: 1 })); + expect(canonicalize({ b: 1, a: 2 })).toEqual({ a: 2, b: 1 }); + }); + + it("preserves array order", () => { + expect(stableStringify([3, 1, 2])).not.toBe(stableStringify([1, 2, 3])); + }); +}); diff --git a/replay-ui/src/__tests__/useStep.test.ts b/replay-ui/src/__tests__/useStep.test.ts new file mode 100644 index 0000000..8fec0ea --- /dev/null +++ b/replay-ui/src/__tests__/useStep.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "bun:test"; +import { clampIndex } from "../hooks/useStep"; + +// Bug class: a wrong boundary lets the URL address step 0 or a step past the +// end, so the viewer requests a non-existent step and renders nothing. Steps +// are 1-based and capped at maxIndex. +describe("clampIndex", () => { + const cases: [number, number | undefined, number][] = [ + [0, 5, 1], + [-3, 5, 1], + [1, 5, 1], + [3, 5, 3], + [5, 5, 5], + [6, 5, 5], + [100, 5, 5], + [3, undefined, 3], + [0, undefined, 1], + ]; + for (const [index, max, expected] of cases) { + it(`clamps (${index}, ${max}) -> ${expected}`, () => { + expect(clampIndex(index, max)).toBe(expected); + }); + } +}); diff --git a/replay-ui/src/api.ts b/replay-ui/src/api.ts index ba1e97a..77af9a9 100644 --- a/replay-ui/src/api.ts +++ b/replay-ui/src/api.ts @@ -1,6 +1,6 @@ import type { Run, RunSummary, Step } from "./types"; -async function getJson(path: string): Promise { +export async function getJson(path: string): Promise { const response = await fetch(path, { headers: { Accept: "application/json" } }); if (!response.ok) { throw new Error(`request failed: ${response.status} ${response.statusText} (${path})`); diff --git a/replay-ui/src/hooks/useKeyboardNav.ts b/replay-ui/src/hooks/useKeyboardNav.ts index d7a2017..2db6c0a 100644 --- a/replay-ui/src/hooks/useKeyboardNav.ts +++ b/replay-ui/src/hooks/useKeyboardNav.ts @@ -1,73 +1,24 @@ import { useEffect } from "react"; +import { dispatchKey, type KeyboardNavOptions } from "../lib/keyboard-nav"; -export interface UseKeyboardNavOptions { - onPrev: () => void; - onNext: () => void; - onJumpStart: () => void; - onJumpEnd: () => void; - onJumpPrev10: () => void; - onJumpNext10: () => void; - onJumpNextViolation: () => void; -} - -const NAVIGATION_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]); -const ARROW_OWNING_ROLES = new Set(["tab", "tablist", "option", "listbox", "menuitem", "menu"]); - -function targetOwnsArrowKeys(target: HTMLElement | null): boolean { - let node: HTMLElement | null = target; - while (node) { - const role = node.getAttribute?.("role"); - if (role && ARROW_OWNING_ROLES.has(role)) return true; - node = node.parentElement; - } - return false; -} +export type UseKeyboardNavOptions = KeyboardNavOptions; export function useKeyboardNav(options: UseKeyboardNavOptions) { useEffect(() => { const handler = (event: KeyboardEvent) => { - const target = event.target as HTMLElement | null; - if (target && (NAVIGATION_TAGS.has(target.tagName) || target.isContentEditable)) { - return; - } - const isArrow = event.key.startsWith("Arrow"); - if (isArrow && targetOwnsArrowKeys(target)) { - return; - } - if (event.metaKey || event.ctrlKey || event.altKey) { - return; - } - switch (event.key) { - case "ArrowLeft": - case "k": - if (event.shiftKey) { - options.onJumpPrev10(); - } else { - options.onPrev(); - } - event.preventDefault(); - return; - case "ArrowRight": - case "j": - if (event.shiftKey) { - options.onJumpNext10(); - } else { - options.onNext(); - } - event.preventDefault(); - return; - case "g": - options.onJumpStart(); - event.preventDefault(); - return; - case "G": - options.onJumpEnd(); - event.preventDefault(); - return; - case ".": - options.onJumpNextViolation(); - event.preventDefault(); - return; + const handled = dispatchKey( + { + key: event.key, + shiftKey: event.shiftKey, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + target: event.target as HTMLElement | null, + }, + options, + ); + if (handled) { + event.preventDefault(); } }; window.addEventListener("keydown", handler); diff --git a/replay-ui/src/hooks/useStep.ts b/replay-ui/src/hooks/useStep.ts index a9e3ecf..2ee1121 100644 --- a/replay-ui/src/hooks/useStep.ts +++ b/replay-ui/src/hooks/useStep.ts @@ -32,7 +32,7 @@ export function useStep(maxIndex: number | undefined): UseStepResult { return { runId, stepIndex: clamped, goTo }; } -function clampIndex(index: number, maxIndex: number | undefined): number { +export function clampIndex(index: number, maxIndex: number | undefined): number { if (index < 1) { return 1; } diff --git a/replay-ui/src/lib/action-format.ts b/replay-ui/src/lib/action-format.ts new file mode 100644 index 0000000..d21831f --- /dev/null +++ b/replay-ui/src/lib/action-format.ts @@ -0,0 +1,92 @@ +import type { StepSummary } from "../types"; + +export interface FormattedRow { + verb: string; + target: string; + targetIsTag: boolean; +} + +const SELECTOR_PREFIXES = [ + "id", + "text", + "textPrefix", + "desc", + "descPrefix", + "class", + "classPrefix", + "package", +]; + +export function parseSelector( + selector: string, +): { kind: string; value: string } | null { + const colonIndex = selector.indexOf(":"); + if (colonIndex <= 0) { + return null; + } + const kind = selector.slice(0, colonIndex); + const value = selector.slice(colonIndex + 1); + if (!SELECTOR_PREFIXES.includes(kind)) { + return null; + } + return { kind, value }; +} + +export function tagFromSelector(selector: string): string { + const parsed = parseSelector(selector); + if (!parsed) { + return selector; + } + if (parsed.kind.endsWith("Prefix")) { + return `${parsed.value}...`; + } + return parsed.value; +} + +export function formatActionRow(step: StepSummary): FormattedRow { + const kind = step.action_kind; + const label = step.action_label ?? ""; + + if (!kind) { + if (step.screen) { + return { verb: "Observe", target: `@ ${step.screen}`, targetIsTag: false }; + } + return { verb: "Observe", target: "", targetIsTag: false }; + } + + switch (kind) { + case "Tap": { + if (!label) { + return { verb: "Click", target: "", targetIsTag: false }; + } + if (label.startsWith("(") && label.endsWith(")")) { + return { verb: "Click", target: label, targetIsTag: false }; + } + if (parseSelector(label)) { + return { verb: "Click", target: tagFromSelector(label), targetIsTag: true }; + } + return { verb: "Click", target: label, targetIsTag: false }; + } + case "InputText": + return { verb: "Type", target: label, targetIsTag: false }; + case "Swipe": + return { verb: "Swipe", target: label, targetIsTag: true }; + case "PressKey": + return { verb: "Press", target: label, targetIsTag: true }; + case "Wait": + return { verb: "Wait", target: label, targetIsTag: true }; + default: + return { verb: kind, target: label, targetIsTag: false }; + } +} + +export function formatElapsed(millis: number): string { + const safe = Math.max(0, Math.floor(millis)); + const totalSeconds = Math.floor(safe / 1000); + const mm = Math.floor(totalSeconds / 60); + const ss = totalSeconds % 60; + const ms = safe % 1000; + const pad2 = (n: number) => String(n).padStart(2, "0"); + const pad3 = (n: number) => String(n).padStart(3, "0"); + return `${pad2(mm)}:${pad2(ss)}.${pad3(ms)}`; +} diff --git a/replay-ui/src/lib/keyboard-nav.ts b/replay-ui/src/lib/keyboard-nav.ts new file mode 100644 index 0000000..bf21bc5 --- /dev/null +++ b/replay-ui/src/lib/keyboard-nav.ts @@ -0,0 +1,76 @@ +export interface KeyboardNavOptions { + onPrev: () => void; + onNext: () => void; + onJumpStart: () => void; + onJumpEnd: () => void; + onJumpPrev10: () => void; + onJumpNext10: () => void; + onJumpNextViolation: () => void; +} + +export interface KeyEvent { + key: string; + shiftKey?: boolean; + metaKey?: boolean; + ctrlKey?: boolean; + altKey?: boolean; + target?: HTMLElement | null; +} + +const NAVIGATION_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]); +const ARROW_OWNING_ROLES = new Set([ + "tab", + "tablist", + "option", + "listbox", + "menuitem", + "menu", +]); + +export function targetOwnsArrowKeys(target: HTMLElement | null): boolean { + let node: HTMLElement | null = target; + while (node) { + const role = node.getAttribute?.("role"); + if (role && ARROW_OWNING_ROLES.has(role)) return true; + node = node.parentElement; + } + return false; +} + +// Returns true when the key was consumed (caller should preventDefault). +export function dispatchKey( + event: KeyEvent, + options: KeyboardNavOptions, +): boolean { + const target = event.target ?? null; + if (target && (NAVIGATION_TAGS.has(target.tagName) || target.isContentEditable)) { + return false; + } + if (event.key.startsWith("Arrow") && targetOwnsArrowKeys(target)) { + return false; + } + if (event.metaKey || event.ctrlKey || event.altKey) { + return false; + } + switch (event.key) { + case "ArrowLeft": + case "k": + (event.shiftKey ? options.onJumpPrev10 : options.onPrev)(); + return true; + case "ArrowRight": + case "j": + (event.shiftKey ? options.onJumpNext10 : options.onNext)(); + return true; + case "g": + options.onJumpStart(); + return true; + case "G": + options.onJumpEnd(); + return true; + case ".": + options.onJumpNextViolation(); + return true; + default: + return false; + } +} diff --git a/replay-ui/src/lib/metrics-format.ts b/replay-ui/src/lib/metrics-format.ts new file mode 100644 index 0000000..cf3b344 --- /dev/null +++ b/replay-ui/src/lib/metrics-format.ts @@ -0,0 +1,43 @@ +const MB = 1024 * 1024; + +export function formatHeap(bytes: number): string { + if (bytes <= 0) return "0B"; + if (bytes < MB) return `${Math.round(bytes / 1024)}K`; + if (bytes < 1024 * MB) return `${Math.round(bytes / MB)}M`; + return `${(bytes / (1024 * MB)).toFixed(1)}G`; +} + +export function formatTime(millis: number): string { + const safe = Math.max(0, Math.floor(millis)); + const seconds = Math.floor(safe / 1000); + const mm = String(Math.floor(seconds / 60)).padStart(2, "0"); + const ss = String(seconds % 60).padStart(2, "0"); + return `${mm}:${ss}`; +} + +export function fractionFor(index: number, count: number): number { + if (count <= 1) return 0.5; + return index / (count - 1); +} + +export function buildPath( + samples: T[], + getValue: (sample: T) => number | undefined, + ceiling: number, +): string { + const segments: string[] = []; + let pendingCommand = "M"; + samples.forEach((sample, index) => { + const value = getValue(sample); + if (value === undefined) { + pendingCommand = "M"; + return; + } + const x = fractionFor(index, samples.length); + const ratio = ceiling === 0 ? 0 : Math.min(value / ceiling, 1); + const y = 1 - ratio; + segments.push(`${pendingCommand}${x.toFixed(4)},${y.toFixed(4)}`); + pendingCommand = "L"; + }); + return segments.join(" "); +} diff --git a/replay-ui/src/lib/property-status.ts b/replay-ui/src/lib/property-status.ts new file mode 100644 index 0000000..adc398c --- /dev/null +++ b/replay-ui/src/lib/property-status.ts @@ -0,0 +1,29 @@ +import type { ResidualNode, Step } from "../types"; + +export type Status = "violated" | "pending" | "holds"; + +export const STATUS_ORDER: Record = { + violated: 0, + pending: 1, + holds: 2, +}; + +export function statusFor( + name: string, + violations: Set, + residuals?: Record, +): Status { + if (violations.has(name)) { + return "violated"; + } + const residual = residuals?.[name]; + if (residual && residual.op === "true") { + return "holds"; + } + return "pending"; +} + +export function statusForStep(name: string, step: Step | null): Status { + if (!step) return "pending"; + return statusFor(name, new Set(step.violations ?? []), step.residuals); +} diff --git a/replay-ui/src/lib/run-history.ts b/replay-ui/src/lib/run-history.ts new file mode 100644 index 0000000..5103100 --- /dev/null +++ b/replay-ui/src/lib/run-history.ts @@ -0,0 +1,78 @@ +import type { Run, Step } from "../types"; +import type { LaneStatus, PropertyLane } from "../panels/Timeline"; +import type { MetricsSample } from "../panels/MetricsChart"; +import { statusForStep } from "./property-status"; + +export interface RunHistory { + names: string[]; + lanes: PropertyLane[]; + firstViolationStep?: number; + firstExceptionStep?: number; + exceptionStepIndices: number[]; + violationStepIndices: number[]; + metricsSamples: MetricsSample[]; + steps: (Step | null)[]; +} + +export function collectPropertyNames(steps: (Step | null)[]): string[] { + const names = new Set(); + for (const step of steps) { + if (!step?.residuals) continue; + for (const name of Object.keys(step.residuals)) { + names.add(name); + } + } + return [...names].sort(); +} + +export function statusForProperty(name: string, step: Step | null): LaneStatus { + return statusForStep(name, step); +} + +export function sortLanes(lanes: PropertyLane[]): PropertyLane[] { + const rank = (lane: PropertyLane): number => { + const last = lane.statuses[lane.statuses.length - 1]; + if (lane.statuses.includes("violated")) return 0; + if (last === "pending") return 1; + return 2; + }; + return [...lanes].sort((a, b) => { + const delta = rank(a) - rank(b); + if (delta !== 0) return delta; + return a.name.localeCompare(b.name); + }); +} + +export function buildRunHistory( + run: Run, + responses: (Step | null)[], +): RunHistory { + const propertyNames = collectPropertyNames(responses); + const lanes: PropertyLane[] = propertyNames.map((name) => ({ + name, + statuses: responses.map((step) => statusForProperty(name, step)), + })); + const firstViolationStep = run.steps.find((entry) => entry.has_violations)?.index; + const firstExceptionStep = run.steps.find((entry) => entry.has_exceptions)?.index; + const exceptionStepIndices = run.steps + .filter((entry) => entry.has_exceptions) + .map((entry) => entry.index); + const violationStepIndices = run.steps + .filter((entry) => entry.has_violations) + .map((entry) => entry.index); + const metricsSamples: MetricsSample[] = run.steps.map((entry, position) => ({ + stepIndex: entry.index, + timestamp: entry.timestamp, + metrics: responses[position]?.metrics, + })); + return { + names: propertyNames, + lanes: sortLanes(lanes), + firstViolationStep, + firstExceptionStep, + exceptionStepIndices, + violationStepIndices, + metricsSamples, + steps: responses, + }; +} diff --git a/replay-ui/src/lib/snapshot-diff.ts b/replay-ui/src/lib/snapshot-diff.ts new file mode 100644 index 0000000..cb75914 --- /dev/null +++ b/replay-ui/src/lib/snapshot-diff.ts @@ -0,0 +1,101 @@ +export interface Row { + path: string; + value: unknown; +} + +export const INLINE_ARRAY_LIMIT = 2; + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} + +export function flatten(input: Record): Row[] { + const rows: Row[] = []; + const walk = (value: unknown, path: string) => { + if (isPlainObject(value)) { + const keys = Object.keys(value).sort(); + if (keys.length === 0) { + rows.push({ path, value: {} }); + return; + } + for (const key of keys) { + const nextPath = path === "" ? key : `${path}.${key}`; + walk(value[key], nextPath); + } + return; + } + if (Array.isArray(value)) { + if (value.length <= INLINE_ARRAY_LIMIT) { + rows.push({ path, value }); + return; + } + for (let i = 0; i < value.length; i++) { + walk(value[i], `${path}[${i}]`); + } + return; + } + rows.push({ path, value }); + }; + for (const key of Object.keys(input).sort()) { + walk(input[key], key); + } + return rows.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); +} + +export function getAtPath( + source: Record | undefined, + path: string, +): unknown { + if (!source) { + return undefined; + } + if (Object.prototype.hasOwnProperty.call(source, path)) { + return source[path]; + } + const segments = path + .split(/\.|\[(\d+)\]/) + .filter((segment) => segment !== undefined && segment !== ""); + let current: unknown = source; + for (const segment of segments) { + if (current === null || current === undefined) { + return undefined; + } + if (Array.isArray(current)) { + const index = Number(segment); + if (Number.isNaN(index)) { + return undefined; + } + current = current[index]; + continue; + } + if (typeof current === "object") { + current = (current as Record)[segment]; + continue; + } + return undefined; + } + return current; +} + +export function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize); + } + if (isPlainObject(value)) { + const out: Record = {}; + for (const key of Object.keys(value).sort()) { + out[key] = canonicalize(value[key]); + } + return out; + } + return value; +} + +export function stableStringify(value: unknown): string { + return JSON.stringify(canonicalize(value)); +} diff --git a/replay-ui/src/panels/ActionList.tsx b/replay-ui/src/panels/ActionList.tsx index 66d2e14..ffa030f 100644 --- a/replay-ui/src/panels/ActionList.tsx +++ b/replay-ui/src/panels/ActionList.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from "react"; import type { KeyboardEvent } from "react"; import type { Step, StepSummary } from "../types"; +import { formatActionRow, formatElapsed } from "../lib/action-format"; import "./ActionList.css"; export interface ActionListProps { @@ -11,95 +12,6 @@ export interface ActionListProps { selectedStep?: Step; } -interface FormattedRow { - verb: string; - target: string; - targetIsTag: boolean; -} - -const SELECTOR_PREFIXES = [ - "id", - "text", - "textPrefix", - "desc", - "descPrefix", - "class", - "classPrefix", - "package", -]; - -function parseSelector(selector: string): { kind: string; value: string } | null { - const colonIndex = selector.indexOf(":"); - if (colonIndex <= 0) { - return null; - } - const kind = selector.slice(0, colonIndex); - const value = selector.slice(colonIndex + 1); - if (!SELECTOR_PREFIXES.includes(kind)) { - return null; - } - return { kind, value }; -} - -function tagFromSelector(selector: string): string { - const parsed = parseSelector(selector); - if (!parsed) { - return selector; - } - if (parsed.kind.endsWith("Prefix")) { - return `${parsed.value}...`; - } - return parsed.value; -} - -export function formatActionRow(step: StepSummary): FormattedRow { - const kind = step.action_kind; - const label = step.action_label ?? ""; - - if (!kind) { - if (step.screen) { - return { verb: "Observe", target: `@ ${step.screen}`, targetIsTag: false }; - } - return { verb: "Observe", target: "", targetIsTag: false }; - } - - switch (kind) { - case "Tap": { - if (!label) { - return { verb: "Click", target: "", targetIsTag: false }; - } - if (label.startsWith("(") && label.endsWith(")")) { - return { verb: "Click", target: label, targetIsTag: false }; - } - if (parseSelector(label)) { - return { verb: "Click", target: tagFromSelector(label), targetIsTag: true }; - } - return { verb: "Click", target: label, targetIsTag: false }; - } - case "InputText": - return { verb: "Type", target: label, targetIsTag: false }; - case "Swipe": - return { verb: "Swipe", target: label, targetIsTag: true }; - case "PressKey": - return { verb: "Press", target: label, targetIsTag: true }; - case "Wait": - return { verb: "Wait", target: label, targetIsTag: true }; - default: - return { verb: kind, target: label, targetIsTag: false }; - } -} - -export function formatElapsed(millis: number): string { - const safe = Math.max(0, Math.floor(millis)); - const totalSeconds = Math.floor(safe / 1000); - const mm = Math.floor(totalSeconds / 60); - const ss = totalSeconds % 60; - const ms = safe % 1000; - const pad2 = (n: number) => String(n).padStart(2, "0"); - const pad3 = (n: number) => String(n).padStart(3, "0"); - return `${pad2(mm)}:${pad2(ss)}.${pad3(ms)}`; -} - function renderTarget(target: string, isTag: boolean) { if (!target) { return null; diff --git a/replay-ui/src/panels/MetricsChart.tsx b/replay-ui/src/panels/MetricsChart.tsx index 730ebe8..ffa0d3e 100644 --- a/replay-ui/src/panels/MetricsChart.tsx +++ b/replay-ui/src/panels/MetricsChart.tsx @@ -1,5 +1,11 @@ import { useState } from "react"; import type { Metrics } from "../types"; +import { + buildPath, + formatHeap, + formatTime, + fractionFor, +} from "../lib/metrics-format"; import "./MetricsChart.css"; export interface MetricsSample { @@ -15,50 +21,6 @@ export interface MetricsChartProps { runStartMillis?: number; } -const MB = 1024 * 1024; - -function formatHeap(bytes: number): string { - if (bytes <= 0) return "0B"; - if (bytes < MB) return `${Math.round(bytes / 1024)}K`; - if (bytes < 1024 * MB) return `${Math.round(bytes / MB)}M`; - return `${(bytes / (1024 * MB)).toFixed(1)}G`; -} - -function formatTime(millis: number): string { - const safe = Math.max(0, Math.floor(millis)); - const seconds = Math.floor(safe / 1000); - const mm = String(Math.floor(seconds / 60)).padStart(2, "0"); - const ss = String(seconds % 60).padStart(2, "0"); - return `${mm}:${ss}`; -} - -function fractionFor(index: number, count: number): number { - if (count <= 1) return 0.5; - return index / (count - 1); -} - -function buildPath( - samples: MetricsSample[], - getValue: (sample: MetricsSample) => number | undefined, - ceiling: number, -): string { - const segments: string[] = []; - let pendingCommand = "M"; - samples.forEach((sample, index) => { - const value = getValue(sample); - if (value === undefined) { - pendingCommand = "M"; - return; - } - const x = fractionFor(index, samples.length); - const ratio = ceiling === 0 ? 0 : Math.min(value / ceiling, 1); - const y = 1 - ratio; - segments.push(`${pendingCommand}${x.toFixed(4)},${y.toFixed(4)}`); - pendingCommand = "L"; - }); - return segments.join(" "); -} - interface LaneDot { key: number | string; x: number; diff --git a/replay-ui/src/panels/SnapshotTable.tsx b/replay-ui/src/panels/SnapshotTable.tsx index 7848316..54e96eb 100644 --- a/replay-ui/src/panels/SnapshotTable.tsx +++ b/replay-ui/src/panels/SnapshotTable.tsx @@ -1,3 +1,4 @@ +import { flatten, getAtPath, stableStringify } from "../lib/snapshot-diff"; import "./SnapshotTable.css"; export interface SnapshotTableProps { @@ -5,103 +6,6 @@ export interface SnapshotTableProps { previousSnapshots?: Record; } -interface Row { - path: string; - value: unknown; -} - -const INLINE_ARRAY_LIMIT = 2; - -function isPlainObject(value: unknown): value is Record { - return ( - typeof value === "object" && - value !== null && - !Array.isArray(value) && - Object.getPrototypeOf(value) === Object.prototype - ); -} - -function flatten(input: Record): Row[] { - const rows: Row[] = []; - const walk = (value: unknown, path: string) => { - if (isPlainObject(value)) { - const keys = Object.keys(value).sort(); - if (keys.length === 0) { - rows.push({ path, value: {} }); - return; - } - for (const key of keys) { - const nextPath = path === "" ? key : `${path}.${key}`; - walk(value[key], nextPath); - } - return; - } - if (Array.isArray(value)) { - if (value.length <= INLINE_ARRAY_LIMIT) { - rows.push({ path, value }); - return; - } - for (let i = 0; i < value.length; i++) { - walk(value[i], `${path}[${i}]`); - } - return; - } - rows.push({ path, value }); - }; - for (const key of Object.keys(input).sort()) { - walk(input[key], key); - } - return rows.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); -} - -function getAtPath(source: Record | undefined, path: string): unknown { - if (!source) { - return undefined; - } - if (Object.prototype.hasOwnProperty.call(source, path)) { - return source[path]; - } - const segments = path.split(/\.|\[(\d+)\]/).filter((segment) => segment !== undefined && segment !== ""); - let current: unknown = source; - for (const segment of segments) { - if (current === null || current === undefined) { - return undefined; - } - if (Array.isArray(current)) { - const index = Number(segment); - if (Number.isNaN(index)) { - return undefined; - } - current = current[index]; - continue; - } - if (typeof current === "object") { - current = (current as Record)[segment]; - continue; - } - return undefined; - } - return current; -} - -function canonicalize(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(canonicalize); - } - if (isPlainObject(value)) { - const out: Record = {}; - for (const key of Object.keys(value).sort()) { - out[key] = canonicalize(value[key]); - } - return out; - } - return value; -} - -function stableStringify(value: unknown): string { - return JSON.stringify(canonicalize(value)); -} - function formatValue(value: unknown): string { if (value === null) { return "null"; diff --git a/replay-ui/src/panels/ViolationsPanel.tsx b/replay-ui/src/panels/ViolationsPanel.tsx index 8266ffc..1c6a73e 100644 --- a/replay-ui/src/panels/ViolationsPanel.tsx +++ b/replay-ui/src/panels/ViolationsPanel.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import type { ResidualNode, Witness } from "../types"; +import { STATUS_ORDER, statusFor } from "../lib/property-status"; import ResidualNodeView from "../components/ResidualNode"; import "./ViolationsPanel.css"; @@ -15,29 +16,6 @@ export interface ViolationsPanelProps { violationsOnly?: boolean; } -type Status = "violated" | "pending" | "holds"; - -const STATUS_ORDER: Record = { - violated: 0, - pending: 1, - holds: 2, -}; - -function statusFor( - name: string, - violations: Set, - residuals?: Record, -): Status { - if (violations.has(name)) { - return "violated"; - } - const residual = residuals?.[name]; - if (residual && residual.op === "true") { - return "holds"; - } - return "pending"; -} - function formatValue(value: unknown): string { const encoded = JSON.stringify(value); return encoded === undefined ? String(value) : encoded; diff --git a/replay-ui/src/routes/RunDetail.tsx b/replay-ui/src/routes/RunDetail.tsx index bade4e5..7529cee 100644 --- a/replay-ui/src/routes/RunDetail.tsx +++ b/replay-ui/src/routes/RunDetail.tsx @@ -1,36 +1,26 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Link } from "react-router-dom"; import { getRun, getStep, screenshotUrl } from "../api"; -import type { Run, Step } from "../types"; +import type { Run } from "../types"; import ActionList from "../panels/ActionList"; import HierarchyPanel from "../panels/HierarchyPanel"; import Screenshot from "../panels/Screenshot"; import SnapshotTable from "../panels/SnapshotTable"; import ViolationsPanel from "../panels/ViolationsPanel"; import ExceptionsPanel from "../panels/ExceptionsPanel"; -import type { LaneStatus, PropertyLane } from "../panels/Timeline"; -import MetricsChart, { type MetricsSample } from "../panels/MetricsChart"; +import MetricsChart from "../panels/MetricsChart"; import Tabs, { type TabDefinition } from "../components/Tabs"; import { useStep } from "../hooks/useStep"; import { useKeyboardNav } from "../hooks/useKeyboardNav"; import { useTheme } from "../hooks/useTheme"; import { deviceSpaceOf } from "../lib/device-space"; +import { buildRunHistory, type RunHistory } from "../lib/run-history"; function basename(specPath: string): string { const index = specPath.lastIndexOf("/"); return index >= 0 ? specPath.slice(index + 1) : specPath; } -interface RunHistory { - names: string[]; - lanes: PropertyLane[]; - firstViolationStep?: number; - firstExceptionStep?: number; - exceptionStepIndices: number[]; - violationStepIndices: number[]; - metricsSamples: MetricsSample[]; - steps: (Step | null)[]; -} export default function RunDetail() { const [run, setRun] = useState(null); @@ -371,65 +361,5 @@ async function loadHistory(run: Run): Promise { const responses = await Promise.all( run.steps.map((entry) => getStep(run.id, entry.index).catch(() => null)), ); - const propertyNames = collectPropertyNames(responses); - const lanes: PropertyLane[] = propertyNames.map((name) => ({ - name, - statuses: responses.map((step) => statusForProperty(name, step)), - })); - const firstViolationStep = run.steps.find((entry) => entry.has_violations)?.index; - const firstExceptionStep = run.steps.find((entry) => entry.has_exceptions)?.index; - const exceptionStepIndices = run.steps - .filter((entry) => entry.has_exceptions) - .map((entry) => entry.index); - const violationStepIndices = run.steps - .filter((entry) => entry.has_violations) - .map((entry) => entry.index); - const metricsSamples: MetricsSample[] = run.steps.map((entry, position) => ({ - stepIndex: entry.index, - timestamp: entry.timestamp, - metrics: responses[position]?.metrics, - })); - return { - names: propertyNames, - lanes: sortLanes(lanes), - firstViolationStep, - firstExceptionStep, - exceptionStepIndices, - violationStepIndices, - metricsSamples, - steps: responses, - }; -} - -function collectPropertyNames(steps: (Step | null)[]): string[] { - const names = new Set(); - for (const step of steps) { - if (!step?.residuals) continue; - for (const name of Object.keys(step.residuals)) { - names.add(name); - } - } - return [...names].sort(); -} - -function statusForProperty(name: string, step: Step | null): LaneStatus { - if (!step) return "pending"; - if (step.violations?.includes(name)) return "violated"; - const residual = step.residuals?.[name]; - if (residual && residual.op === "true") return "holds"; - return "pending"; -} - -function sortLanes(lanes: PropertyLane[]): PropertyLane[] { - const rank = (lane: PropertyLane): number => { - const last = lane.statuses[lane.statuses.length - 1]; - if (lane.statuses.includes("violated")) return 0; - if (last === "pending") return 1; - return 2; - }; - return [...lanes].sort((a, b) => { - const delta = rank(a) - rank(b); - if (delta !== 0) return delta; - return a.name.localeCompare(b.name); - }); + return buildRunHistory(run, responses); } diff --git a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt index e75ed56..70cda0d 100644 --- a/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt +++ b/sidecar/src/main/kotlin/dev/sanderling/sidecar/DriverBackend.kt @@ -281,7 +281,7 @@ private fun sampleCpuTwice(serial: String?, pid: Int): Double { return (deltaCpuNanos / 50_000_000.0) * 100.0 } -private fun parseCpuTicks(statLine: String): Long? { +internal fun parseCpuTicks(statLine: String): Long? { val afterComm = statLine.substringAfterLast(')').trim() val fields = afterComm.split(Regex("\\s+")) if (fields.size < 13) return null @@ -304,7 +304,7 @@ private fun sampleProcessMemory(serial: String?, pid: Int): Pair { return Pair(rssKb * 1024L, vmSizeKb * 1024L) } -private fun parseKb(line: String): Long? { +internal fun parseKb(line: String): Long? { val parts = line.split(Regex("\\s+")) if (parts.size < 2) return null return parts[1].toLongOrNull() @@ -638,7 +638,7 @@ private fun buildDadb(serial: String?): dadb.Dadb { } } -private fun findBoundsBySelector(root: maestro.TreeNode, selector: String): IntArray? { +internal fun findBoundsBySelector(root: maestro.TreeNode, selector: String): IntArray? { val colon = selector.indexOf(':') if (colon < 0) return null val kind = selector.substring(0, colon) @@ -646,7 +646,7 @@ private fun findBoundsBySelector(root: maestro.TreeNode, selector: String): IntA return findBoundsInTree(root, kind, value) } -private fun findBoundsInTree(node: maestro.TreeNode, kind: String, value: String): IntArray? { +internal fun findBoundsInTree(node: maestro.TreeNode, kind: String, value: String): IntArray? { val attrs = node.attributes val matches = when (kind) { "id" -> attrs["resource-id"]?.let { it == value || it.endsWith(":id/$value") } == true @@ -664,19 +664,19 @@ private fun findBoundsInTree(node: maestro.TreeNode, kind: String, value: String return null } -private fun parseBounds(s: String): IntArray? { +internal fun parseBounds(s: String): IntArray? { val pattern = Regex("^\\[(-?\\d+),(-?\\d+),(-?\\d+),(-?\\d+)\\]$") val m = pattern.matchEntire(s) ?: return null return IntArray(4) { m.groupValues[it + 1].toInt() } } -private fun pngWidth(bytes: ByteArray): Int { +internal fun pngWidth(bytes: ByteArray): Int { if (bytes.size < 24) return 0 return (bytes[16].toInt() and 0xFF shl 24) or (bytes[17].toInt() and 0xFF shl 16) or (bytes[18].toInt() and 0xFF shl 8) or (bytes[19].toInt() and 0xFF) } -private fun pngHeight(bytes: ByteArray): Int { +internal fun pngHeight(bytes: ByteArray): Int { if (bytes.size < 24) return 0 return (bytes[20].toInt() and 0xFF shl 24) or (bytes[21].toInt() and 0xFF shl 16) or (bytes[22].toInt() and 0xFF shl 8) or (bytes[23].toInt() and 0xFF) diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DeviceOutputParserTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DeviceOutputParserTest.kt new file mode 100644 index 0000000..afbccbd --- /dev/null +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DeviceOutputParserTest.kt @@ -0,0 +1,148 @@ +package dev.sanderling.sidecar + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DeviceOutputParserTest { + + // Logcat's threadtime format carries no year; the parser infers the current + // one. A line missing a field (or in another format) must be skipped, not + // misparsed into a bogus entry. + @Test fun parseLogcatExtractsLevelTagMessageAndInfersYear() { + val output = """ + 06-06 12:34:56.789 1000 1000 E AndroidRuntime: FATAL EXCEPTION: main + not a logcat line at all + 06-06 12:34:57.000 1000 1000 I ActivityManager: Start proc + """.trimIndent() + + val lines = StubDriverBackend.parseLogcatOutput(output) + assertEquals(2, lines.size) + assertEquals("E", lines[0].level) + assertEquals("AndroidRuntime", lines[0].tag) + assertEquals("FATAL EXCEPTION: main", lines[0].message) + + val year = java.util.Calendar.getInstance().get(java.util.Calendar.YEAR) + val cal = java.util.Calendar.getInstance().apply { timeInMillis = lines[0].unixMillis } + assertEquals(year, cal.get(java.util.Calendar.YEAR)) + assertEquals(56, cal.get(java.util.Calendar.SECOND)) + assertEquals(789, cal.get(java.util.Calendar.MILLISECOND)) + } + + @Test fun parseLogcatEmptyOutputYieldsNoLines() { + assertTrue(StubDriverBackend.parseLogcatOutput("").isEmpty()) + assertTrue(StubDriverBackend.parseLogcatOutput(" \n ").isEmpty()) + } + + // /proc/pid/stat: the comm field is parenthesized and may itself contain + // spaces and a ')'. Splitting before substringAfterLast(')') would shift + // every field index and read the wrong utime/stime ticks. + @Test fun parseCpuTicksSumsUtimeAndStimeAfterComm() { + val cases = listOf( + statLine("(app)", utime = 100, stime = 23) to 123L, + statLine("(com.foo (bar))", utime = 7, stime = 8) to 15L, + statLine("(weird )name)", utime = 1, stime = 2) to 3L, + ) + for ((line, expected) in cases) { + assertEquals(expected, parseCpuTicks(line), line) + } + } + + @Test fun parseCpuTicksReturnsNullOnTruncatedOrNonNumericStat() { + assertNull(parseCpuTicks("1234 (app) S 1 2 3")) + assertNull(parseCpuTicks("1234 (app) S " + (1..12).joinToString(" ") { "x" })) + assertNull(parseCpuTicks("")) + } + + // VmRSS/VmSize lines are "Key:\t kB"; the kB unit token must not be + // read as the value, and a missing value must not crash the sampler. + @Test fun parseKbReadsSecondFieldOrNull() { + assertEquals(2048L, parseKb("VmRSS:\t 2048 kB")) + assertEquals(900100L, parseKb("VmSize: 900100 kB")) + assertNull(parseKb("VmRSS:")) + assertNull(parseKb("VmRSS: notanumber kB")) + } + + @Test fun pngWidthAndHeightReadIhdrDimensions() { + val png = ihdr(width = 1080, height = 2340) + assertEquals(1080, pngWidth(png)) + assertEquals(2340, pngHeight(png)) + } + + // A short/empty screencap (the device returned nothing) must report 0 + // rather than indexing past the buffer. + @Test fun pngWidthAndHeightReturnZeroOnTruncatedInput() { + assertEquals(0, pngWidth(ByteArray(23))) + assertEquals(0, pngHeight(ByteArray(23))) + assertEquals(0, pngWidth(ByteArray(0))) + } + + @Test fun parseBoundsAcceptsWellFormedAndRejectsMalformed() { + assertEquals(listOf(0, 0, 1080, 2340), parseBounds("[0,0,1080,2340]")?.toList()) + assertEquals(listOf(-5, -10, 20, 30), parseBounds("[-5,-10,20,30]")?.toList()) + assertNull(parseBounds("[0,0,1080]")) + assertNull(parseBounds("0,0,1,1")) + assertNull(parseBounds("[0, 0, 1, 1]")) + assertNull(parseBounds("")) + } + + @Test fun findBoundsBySelectorMatchesIdSuffixForm() { + val tree = node( + "resource-id" to "com.example:id/loginButton", + "bounds" to "[10,20,110,80]", + ) + assertEquals(listOf(10, 20, 110, 80), findBoundsBySelector(tree, "id:loginButton")?.toList()) + assertEquals( + listOf(10, 20, 110, 80), + findBoundsBySelector(tree, "id:com.example:id/loginButton")?.toList(), + ) + } + + @Test fun findBoundsBySelectorMatchesTextAndDescPrefixDeepInTree() { + val tree = node( + "resource-id" to "root", + children = listOf( + node("text" to "Sign in", "bounds" to "[1,2,3,4]"), + node("content-desc" to "AccountCardRow-7", "bounds" to "[5,6,7,8]"), + ), + ) + assertEquals(listOf(1, 2, 3, 4), findBoundsBySelector(tree, "text:Sign in")?.toList()) + assertEquals(listOf(5, 6, 7, 8), findBoundsBySelector(tree, "descPrefix:AccountCard")?.toList()) + } + + @Test fun findBoundsBySelectorReturnsNullForBadSelectorOrNoMatch() { + val tree = node("resource-id" to "com.example:id/x", "bounds" to "[0,0,1,1]") + assertNull(findBoundsBySelector(tree, "id")) + assertNull(findBoundsBySelector(tree, "id:missing")) + } + + @Test fun findBoundsBySelectorReturnsNullWhenMatchHasMalformedBounds() { + val tree = node("resource-id" to "com.example:id/x", "bounds" to "not-bounds") + assertNull(findBoundsBySelector(tree, "id:x")) + } + + private fun statLine(comm: String, utime: Int, stime: Int): String { + // After comm, parseCpuTicks reads index 11 (utime) and 12 (stime), so + // the state field plus ten placeholders must precede them. + val before = "1234 $comm S " + (1..10).joinToString(" ") + return "$before $utime $stime 0 0 0 0" + } + + private fun ihdr(width: Int, height: Int): ByteArray { + val b = ByteArray(33) + for (i in 0 until 8) b[8 + i] = 0 + b[12] = 'I'.code.toByte(); b[13] = 'H'.code.toByte() + b[14] = 'D'.code.toByte(); b[15] = 'R'.code.toByte() + b[16] = (width ushr 24).toByte(); b[17] = (width ushr 16).toByte() + b[18] = (width ushr 8).toByte(); b[19] = width.toByte() + b[20] = (height ushr 24).toByte(); b[21] = (height ushr 16).toByte() + b[22] = (height ushr 8).toByte(); b[23] = height.toByte() + return b + } + + private fun node( + vararg attrs: Pair, + children: List = emptyList(), + ): maestro.TreeNode = maestro.TreeNode(attributes = attrs.toMap().toMutableMap(), children = children) +} diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt index 77f148f..65d046b 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/DriverServiceTest.kt @@ -5,6 +5,7 @@ import dev.sanderling.driver.v1.Duration import dev.sanderling.driver.v1.Empty import dev.sanderling.driver.v1.EraseTextRequest import dev.sanderling.driver.v1.LaunchRequest +import dev.sanderling.driver.v1.MetricsRequest import dev.sanderling.driver.v1.Point import dev.sanderling.driver.v1.PressKeyRequest import dev.sanderling.driver.v1.RecentLogsRequest @@ -338,6 +339,40 @@ class DriverServiceTest { assertEquals("boom", response.getEntries(0).message) } + // With no explicit bundle in the request, metrics must sample the app the + // service launched; otherwise CPU/memory are read from the wrong process. + @Test fun metricsFallsBackToLaunchedBundleWhenRequestOmitsIt() { + var sampled: String? = null + val backend = object : DriverBackend by StubDriverBackend("android") { + override fun metrics(bundleId: String): MetricsSample { + sampled = bundleId + return MetricsSample(0.0, 0L, 0L) + } + } + val client = newClient(backend) + + client.launch(LaunchRequest.newBuilder().setBundleId("com.launched").build()) + client.metrics(MetricsRequest.getDefaultInstance()) + + assertEquals("com.launched", sampled) + } + + @Test fun metricsRequestBundleOverridesLaunchedBundle() { + var sampled: String? = null + val backend = object : DriverBackend by StubDriverBackend("android") { + override fun metrics(bundleId: String): MetricsSample { + sampled = bundleId + return MetricsSample(0.0, 0L, 0L) + } + } + val client = newClient(backend) + + client.launch(LaunchRequest.newBuilder().setBundleId("com.launched").build()) + client.metrics(MetricsRequest.newBuilder().setBundleId("com.other").build()) + + assertEquals("com.other", sampled) + } + @Test fun healthReportsPlatformAndVersion() { val backend = StubDriverBackend("android") val client = newClient(backend) diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt index 55f928c..36eb3c9 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/InputTextTest.kt @@ -2,10 +2,33 @@ package dev.sanderling.sidecar import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue class InputTextTest { + // A logical key name must map to the right Android keycode; a typo'd table + // entry would silently dispatch the wrong key (e.g. 'back' issuing HOME). + @Test fun pressKeyDispatchesMappedKeycode() { + val cases = mapOf( + "back" to "KEYCODE_BACK", + "enter" to "KEYCODE_ENTER", + "up" to "KEYCODE_DPAD_UP", + ) + for ((key, keycode) in cases) { + val commands = mutableListOf>() + StubDriverBackend("android") { commands.add(it) }.pressKey(key) + assertEquals(listOf(listOf("shell", "input", "keyevent", keycode)), commands, key) + } + } + + @Test fun pressKeyRejectsUnknownKeyInsteadOfSilentlyDoingNothing() { + val commands = mutableListOf>() + val backend = StubDriverBackend("android") { commands.add(it) } + assertFailsWith { backend.pressKey("zorp") } + assertTrue(commands.isEmpty()) + } + @Test fun inputTextTypesAtCursorWithoutClearing() { val commands = mutableListOf>() val backend = StubDriverBackend("android") { commands.add(it) } diff --git a/sidecar/src/test/kotlin/dev/sanderling/sidecar/StabilityPollTest.kt b/sidecar/src/test/kotlin/dev/sanderling/sidecar/StabilityPollTest.kt index 7e38534..de9937e 100644 --- a/sidecar/src/test/kotlin/dev/sanderling/sidecar/StabilityPollTest.kt +++ b/sidecar/src/test/kotlin/dev/sanderling/sidecar/StabilityPollTest.kt @@ -65,7 +65,7 @@ class StabilityPollTest { "frame-$calls" } val elapsed = System.currentTimeMillis() - start - assertTrue(elapsed in budget..(budget + 200L), "expected to hit cap, elapsed=$elapsed") + assertTrue(elapsed in budget..(budget + 1000L), "expected to hit cap, elapsed=$elapsed") } @Test fun zeroBudgetReturnsImmediately() {