diff --git a/QUICKSTART.md b/QUICKSTART.md index 2d801e4..37c1884 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -223,9 +223,10 @@ browsii session new fresh --port 9222 # wipe state and start fresh # Recordings capture every action for replay browsii record start myflow --port 9222 -# ... perform actions ... +# ... perform actions (expect calls are recorded as checkpoints) ... browsii record stop --port 9222 -browsii record replay myflow --speed 2.0 --port 9222 # 0=instant, 1=realtime +browsii record replay myflow --port 9222 # instant by default +browsii record export myflow --port 9222 # Playwright spec browsii record list --port 9222 # Isolated browser contexts (incognito) @@ -558,6 +559,8 @@ To update the fixture, delete the HAR and re-record against the live site with a **Actions carry receipts.** click/press/navigate append what the action caused: navigation, requests (up to 5 samples), dialogs, console-error count. `expect` then independently asserts outcomes — the two compose into a verifiable act→check loop. +**Replay is fingerprint-healed, not selector-bound.** Recordings store each target element's fingerprint (tag, role, text, name, href) plus its position among identical siblings. On replay, a selector that no longer matches its element is healed by relocating the fingerprint; the substitution is reported (`healed: step 2 #p2-add → …`). A semantic change — the element is gone or relabelled — fails at that step with the original identity. Record with `--capture-har` and replay runs fully offline against the recorded traffic (`--live` opts out); `--session ` restores a saved login first. `record export` writes a Playwright spec with role-based locators that keeps the same healing properties. + **Capture is destructive.** Calling `network capture stop` / `console capture stop` returns and clears the buffer. A second call returns an empty array. **`session save`** persists cookies and localStorage — not the actual tab URLs. Use it to checkpoint auth state between runs. diff --git a/cmd/browsii/record.go b/cmd/browsii/record.go index dc5a565..7737f7a 100644 --- a/cmd/browsii/record.go +++ b/cmd/browsii/record.go @@ -1,8 +1,10 @@ package main import ( + "encoding/json" "fmt" "log" + "os" "path/filepath" "strings" @@ -11,7 +13,12 @@ import ( "github.com/cdg-me/browsii/internal/client" ) -var replaySpeed float64 +var ( + replaySpeed float64 + replayLive bool + replaySession string + recordCaptureHar bool +) func resolveRecordingName(name string) string { if strings.HasSuffix(name, ".json") || strings.Contains(name, string(filepath.Separator)) { @@ -32,17 +39,30 @@ func init() { startCmd := &cobra.Command{ Use: "start ", Short: "Start recording browser actions", - Args: cobra.ExactArgs(1), + Long: `Starts recording browser actions. + +Every click, hover, and type is stored with the target element's +fingerprint, and every expect call becomes a checkpoint that replay +enforces. Use --capture-har to also record network traffic, which lets +replays run offline.`, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { name := resolveRecordingName(args[0]) - payload := map[string]string{"name": name} + payload := map[string]any{"name": name} + if recordCaptureHar { + payload["captureHar"] = true + } _, err := client.SendCommand(port, "record/start", payload) if err != nil { log.Fatalf("Record start failed: %v", err) } fmt.Printf("Recording started: %s\n", args[0]) + if recordCaptureHar { + fmt.Println("Network capture active; replay will run offline against the recorded HAR.") + } }, } + startCmd.Flags().BoolVar(&recordCaptureHar, "capture-har", false, "Record network traffic to a HAR file alongside the recording") stopCmd := &cobra.Command{ Use: "stop", @@ -53,28 +73,59 @@ func init() { if err != nil { log.Fatalf("Record stop failed: %v", err) } - fmt.Printf("Recording saved: %s\n", string(resp)) + var saved struct { + Name string `json:"name"` + Events int `json:"events"` + HAR string `json:"har"` + } + if err := json.Unmarshal(resp, &saved); err != nil { + log.Fatalf("Record stop failed: unexpected response: %v", err) + } + if saved.HAR != "" { + fmt.Printf("Recording saved: %s (%d events, HAR: %s)\n", saved.Name, saved.Events, saved.HAR) + } else { + fmt.Printf("Recording saved: %s (%d events)\n", saved.Name, saved.Events) + } }, } replayCmd := &cobra.Command{ Use: "replay ", Short: "Replay a recorded session", - Args: cobra.ExactArgs(1), + Long: `Replays a recorded session. + +Element targets are matched by their recorded fingerprint: when the +selector no longer resolves to the same element, the element is relocated +and the substitution is reported under "healed". Recorded expects are +enforced as checkpoints. + +When the recording has a HAR file, replay serves all recorded responses +locally and needs no network. Use --live to hit the real network instead, +--session to restore a saved session (cookies, tabs) first.`, + Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { name := resolveRecordingName(args[0]) - payload := map[string]interface{}{ + payload := map[string]any{ "name": name, "speed": replaySpeed, } - _, err := client.SendCommand(port, "record/replay", payload) + if replayLive { + payload["live"] = true + } + if replaySession != "" { + payload["session"] = replaySession + } + resp, err := client.SendCommand(port, "record/replay", payload) if err != nil { - log.Fatalf("Record replay failed: %v", err) + // The daemon returns the report JSON with a 417 on failure. + printReplayFailure(err) } - fmt.Printf("Replay of %q complete\n", args[0]) + printReplayReport(resp) }, } - replayCmd.Flags().Float64Var(&replaySpeed, "speed", 1.0, "Replay speed (0=instant, 1=real-time, 2=2x)") + replayCmd.Flags().Float64Var(&replaySpeed, "speed", 0, "Replay speed (0=instant, 1=recorded timing, 2=twice as fast)") + replayCmd.Flags().BoolVar(&replayLive, "live", false, "Hit the real network; ignore any recorded HAR") + replayCmd.Flags().StringVar(&replaySession, "session", "", "Restore this saved session before replaying") listCmd := &cobra.Command{ Use: "list", @@ -104,11 +155,99 @@ func init() { }, } + exportCmd := &cobra.Command{ + Use: "export ", + Short: "Write a Playwright TypeScript spec for the recording", + Long: `Writes a Playwright spec (test) that reproduces the recording: +fingerprinted elements become role-based locators, expects become +assertions, and a recorded HAR becomes routeFromHAR so the test runs +offline. Run it with: npx playwright test .`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + name := resolveRecordingName(args[0]) + payload := map[string]string{"name": name} + if exportOut != "" { + payload["out"] = exportOut + } + resp, err := client.SendCommand(port, "record/export", payload) + if err != nil { + log.Fatalf("Record export failed: %v", err) + } + var out struct { + Path string `json:"path"` + } + if err := json.Unmarshal(resp, &out); err != nil || out.Path == "" { + log.Fatalf("Record export failed: unexpected response") + } + fmt.Printf("Wrote %s\n", out.Path) + }, + } + exportCmd.Flags().StringVar(&exportOut, "out", "", "Output path (default: alongside the recording)") + recordCmd.AddCommand(startCmd) recordCmd.AddCommand(stopCmd) recordCmd.AddCommand(replayCmd) recordCmd.AddCommand(listCmd) recordCmd.AddCommand(deleteCmd) + recordCmd.AddCommand(exportCmd) rootCmd.AddCommand(recordCmd) } + +var exportOut string + +type replayReportCLI struct { + Name string `json:"name"` + Steps int `json:"steps"` + Checkpoints struct { + Total int `json:"total"` + Passed int `json:"passed"` + } `json:"checkpoints"` + Healed []struct { + Step int `json:"step"` + From string `json:"from"` + To string `json:"to"` + } `json:"healed"` + DurationMs int64 `json:"durationMs"` + FailedStep int `json:"failedStep"` + Error string `json:"error"` +} + +func printReplayReport(resp []byte) { + var report replayReportCLI + if err := json.Unmarshal(resp, &report); err != nil { + fmt.Println("Replay complete") + return + } + if report.Error != "" { + fmt.Printf("Replay failed at step %d of %d: %s\n", report.FailedStep, report.Steps, report.Error) + fmt.Printf("checkpoints: %d/%d passed before failure\n", report.Checkpoints.Passed, report.Checkpoints.Total) + os.Exit(1) + } + fmt.Printf("Replayed %d steps, %d/%d checkpoints passed in %dms\n", + report.Steps, report.Checkpoints.Passed, report.Checkpoints.Total, report.DurationMs) + for _, h := range report.Healed { + fmt.Printf(" healed: step %d %s → %s\n", h.Step, h.From, h.To) + } +} + +// printReplayFailure extracts the daemon report from the error string and +// renders the failed replay for the operator. +func printReplayFailure(err error) { + msg := err.Error() + const marker = "daemon returned error: " + idx := strings.Index(msg, marker) + if idx < 0 { + log.Fatalf("Replay failed: %v", err) + } + var report replayReportCLI + if json.Unmarshal([]byte(msg[idx+len(marker):]), &report) != nil || report.Error == "" { + log.Fatalf("Replay failed: %v", err) + } + fmt.Fprintf(os.Stderr, "Replay failed at step %d of %d: %s\n", report.FailedStep, report.Steps, report.Error) + fmt.Fprintf(os.Stderr, "checkpoints: %d/%d passed before failure\n", report.Checkpoints.Passed, report.Checkpoints.Total) + for _, h := range report.Healed { + fmt.Fprintf(os.Stderr, " healed: step %d %s → %s\n", h.Step, h.From, h.To) + } + os.Exit(1) +} diff --git a/examples/recording-presets/preset_shop_demo.json b/examples/recording-presets/preset_shop_demo.json new file mode 100644 index 0000000..3b6f8eb --- /dev/null +++ b/examples/recording-presets/preset_shop_demo.json @@ -0,0 +1,24 @@ +{ + "name": "preset_shop_demo", + "url": "http://127.0.0.1:8931/shop", + "events": [ + { + "t": 120, + "action": "navigate", + "params": { "url": "http://127.0.0.1:8931/shop" } + }, + { + "t": 940, + "action": "click", + "params": { "selector": "#p2-add" }, + "fp": { "tag": "button", "role": "button", "text": "Add to cart", "name": "", "href": "", "type": "" }, + "fpIndex": 1 + }, + { + "t": 1500, + "action": "expect", + "params": { "text": "Total: $30" }, + "timeoutMs": 5000 + } + ] +} diff --git a/internal/daemon/elements.go b/internal/daemon/elements.go index acd6822..461a3a1 100644 --- a/internal/daemon/elements.go +++ b/internal/daemon/elements.go @@ -2,6 +2,7 @@ package daemon import ( "encoding/json" + "fmt" "net/http" "sort" "strconv" @@ -211,14 +212,24 @@ var elementsJS = `() => {` + elementsHelpersJS + ` return JSON.stringify(out); }` -// liveElementJS returns the identity fields of the element currently matching -// the given selector, or null when the selector matches nothing. Shares the -// helper functions with the enumeration so fingerprints are comparable. +// liveElementJS returns [identity, index] for the element currently matching +// the selector, or null when it matches nothing. Index is the element's +// position among all elements with the same identity (0 when unique) — +// needed to disambiguate repeated elements such as identically-labelled +// buttons in a product list. var liveElementJS = `(sel) => {` + elementsHelpersJS + ` const el = document.querySelector(sel); if (!el) return null; const tag = el.tagName.toLowerCase(); - return JSON.stringify(identityOf(el, tag)); + const id = identityOf(el, tag); + const key = JSON.stringify(id); + let idx = 0; + for (const other of document.querySelectorAll('a, button, input, select, textarea, summary, label, [role], [tabindex], [onclick]')) { + if (other === el) break; + const otag = other.tagName.toLowerCase(); + if (JSON.stringify(identityOf(other, otag)) === key) idx++; + } + return JSON.stringify([id, idx]); }` // enumerateElements runs the in-page enumeration and refreshes the ref store @@ -273,18 +284,39 @@ func fingerprintOf(e elementInfo) string { // liveFingerprint evaluates the page and returns the identity string of the // element currently matching selector ("" when the selector matches nothing). func liveFingerprint(page *rod.Page, selector string) (string, error) { + id, _, err := liveFingerprintEx(page, selector) + if err != nil || id == nil { + return "", err + } + return fingerprintParts(id.Tag, id.Role, id.Text, id.Name, id.Href, id.Type), nil +} + +// liveFingerprintEx returns the identity and same-identity index of the +// element matching selector. id is nil when the selector matches nothing. +func liveFingerprintEx(page *rod.Page, selector string) (*elementIdentity, int, error) { res, err := page.Eval(liveElementJS, selector) if err != nil { - return "", err + return nil, 0, err } if res == nil || res.Value.Val() == nil { - return "", nil // selector matches nothing + return nil, 0, nil + } + var pair []json.RawMessage + if err := json.Unmarshal([]byte(res.Value.Str()), &pair); err != nil { + return nil, 0, err + } + if len(pair) != 2 { + return nil, 0, fmt.Errorf("unexpected live element payload") } var id elementIdentity - if err := json.Unmarshal([]byte(res.Value.Str()), &id); err != nil { - return "", err + if err := json.Unmarshal(pair[0], &id); err != nil { + return nil, 0, err } - return fingerprintParts(id.Tag, id.Role, id.Text, id.Name, id.Href, id.Type), nil + var idx int + if err := json.Unmarshal(pair[1], &idx); err != nil { + return nil, 0, err + } + return &id, idx, nil } // lookupRefInStore returns the element recorded at ref in the page's ref @@ -402,6 +434,28 @@ func (s *Server) candidatesFor(page *rod.Page, failedSelector string) []elementC return findCandidates(elems, failedSelector, 5) } +// findByFingerprint enumerates the page and returns the selector of the +// fpIndex-th element whose fingerprint matches want. Repeated elements with +// identical fingerprints (e.g. identically-labelled buttons) are +// disambiguated by fpIndex in document order. +func (s *Server) findByFingerprint(page *rod.Page, want string, fpIndex int) (string, bool) { + elems, err := s.enumerateElements(page) + if err != nil { + return "", false + } + n := 0 + for _, e := range elems { + if fingerprintOf(e) != want { + continue + } + if n == fpIndex { + return e.Selector, true + } + n++ + } + return "", false +} + // handleElements lists the interactive elements of the active page. // POST /elements {"all": bool, "filter": "substring"} func (s *Server) handleElements(w http.ResponseWriter, r *http.Request) { diff --git a/internal/daemon/export.go b/internal/daemon/export.go new file mode 100644 index 0000000..08d777b --- /dev/null +++ b/internal/daemon/export.go @@ -0,0 +1,181 @@ +package daemon + +import ( + "fmt" + "path/filepath" + "strings" +) + +// buildPlaywrightSpec renders a recording as a Playwright TypeScript spec. +// Element actions with a fingerprint become role-based locators; expects +// become assertions; a recorded HAR becomes routeFromHAR. Actions with no +// Playwright equivalent are emitted as comments so nothing is silently +// dropped. +func buildPlaywrightSpec(url, har string, events []RecordedEvent) string { + var b strings.Builder + + b.WriteString("import { test, expect } from '@playwright/test';\n\n") + + name := "recorded flow" + if har != "" { + name = strings.TrimSuffix(filepath.Base(har), ".har") + } + fmt.Fprintf(&b, "test(%q, async ({ page }) => {\n", name) + + if har != "" { + fmt.Fprintf(&b, " await page.routeFromHAR(%q, { url: \"*\" });\n", har) + } + if url != "" { + fmt.Fprintf(&b, " await page.goto(%q);\n", url) + } + if url != "" { + for _, ev := range events { + if ev.Action == "navigate" && paramString(ev.Params, "url") == url { + ev.Params["__emitted__"] = true + } else { + break + } + } + } + + for _, ev := range events { + if paramBool(ev.Params, "__emitted__") { + continue + } + switch ev.Action { + case "expect": + writeExpectSpec(&b, ev) + case "navigate": + fmt.Fprintf(&b, " await page.goto(%q);\n", paramString(ev.Params, "url")) + case "click": + fmt.Fprintf(&b, " await %s.click();\n", specLocatorWithComment(&b, ev)) + case "hover": + fmt.Fprintf(&b, " await %s.hover();\n", specLocatorWithComment(&b, ev)) + case "type": + fmt.Fprintf(&b, " await %s.fill(%q);\n", specLocatorWithComment(&b, ev), paramString(ev.Params, "text")) + case "press": + fmt.Fprintf(&b, " await page.keyboard.press(%q);\n", paramString(ev.Params, "key")) + case "scroll": + dir := paramString(ev.Params, "direction") + pixels := 300 + if p, ok := ev.Params["pixels"].(float64); ok && p > 0 { + pixels = int(p) + } + switch dir { + case "down": + fmt.Fprintf(&b, " await page.mouse.wheel(0, %d);\n", pixels) + case "up": + fmt.Fprintf(&b, " await page.mouse.wheel(0, -%d);\n", pixels) + case "top": + b.WriteString(" await page.evaluate(() => window.scrollTo(0, 0));\n") + case "bottom": + b.WriteString(" await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));\n") + } + case "reload": + b.WriteString(" await page.reload();\n") + case "back": + b.WriteString(" await page.goBack();\n") + case "forward": + b.WriteString(" await page.goForward();\n") + case "js": + fmt.Fprintf(&b, " await page.evaluate(%q);\n", paramString(ev.Params, "script")) + default: + fmt.Fprintf(&b, " // skipped: %s %v\n", ev.Action, ev.Params) + } + } + + b.WriteString("});\n") + return b.String() +} + +// specLocator renders the locator expression for an element action. With a +// fingerprint it uses getByRole plus accessible name, disambiguating +// repeated elements with .nth(fpIndex); the recorded CSS selector is kept +// as a comment line above for debugging. +func specLocator(ev RecordedEvent) string { + selector := paramString(ev.Params, "selector") + if ev.FP == nil { + return fmt.Sprintf("page.locator(%q)", selector) + } + name := ev.FP.Name + if name == "" { + name = ev.FP.Text + } + if name == "" { + return fmt.Sprintf("page.locator(%q)", selector) + } + expr := fmt.Sprintf("page.getByRole(%q, { name: %q })", ev.FP.Role, name) + if ev.FPIndex > 0 { + expr += fmt.Sprintf(".nth(%d)", ev.FPIndex-1) + } + return expr +} + +// specLocatorWithComment pairs specLocator with its recorded selector as a +// leading comment line. +func specLocatorWithComment(b *strings.Builder, ev RecordedEvent) string { + if ev.FP == nil { + return specLocator(ev) + } + selector := paramString(ev.Params, "selector") + fmt.Fprintf(b, " // recorded selector: %s\n", selector) + return specLocator(ev) +} + +// writeExpectSpec renders a recorded expect event as a Playwright assertion. +func writeExpectSpec(b *strings.Builder, ev RecordedEvent) { + timeout := "" + if ev.TimeoutMs > 0 { + timeout = fmt.Sprintf("{ timeout: %d }", ev.TimeoutMs) + } + switch { + case paramString(ev.Params, "text") != "": + fmt.Fprintf(b, " await expect(page.getByText(%q)).toBeVisible(%s);\n", paramString(ev.Params, "text"), timeout) + case paramString(ev.Params, "textGone") != "": + fmt.Fprintf(b, " await expect(page.getByText(%q)).toBeHidden(%s);\n", paramString(ev.Params, "textGone"), timeout) + case paramString(ev.Params, "urlPattern") != "": + pattern := paramString(ev.Params, "urlPattern") + regex := globToSpecRegex(pattern) + fmt.Fprintf(b, " await expect(page).toHaveURL(/%s/)%s;\n", regex, timeoutSuffix(timeout)) + case paramString(ev.Params, "value") != "": + valueTimeout := "" + if timeout != "" { + valueTimeout = ", " + timeout + } + fmt.Fprintf(b, " await expect(page.locator(%q)).toHaveValue(%q%s);\n", paramString(ev.Params, "selector"), paramString(ev.Params, "value"), valueTimeout) + case paramString(ev.Params, "selector") != "": + if paramBool(ev.Params, "hidden") { + fmt.Fprintf(b, " await expect(page.locator(%q)).toBeHidden(%s);\n", paramString(ev.Params, "selector"), timeout) + } else { + fmt.Fprintf(b, " await expect(page.locator(%q)).toBeVisible(%s);\n", paramString(ev.Params, "selector"), timeout) + } + default: + fmt.Fprintf(b, " // skipped expect: %v\n", ev.Params) + } +} + +// timeoutSuffix renders the options argument for assertions that take it as +// a second parameter. +func timeoutSuffix(timeout string) string { + if timeout == "" { + return "" + } + return ", " + timeout +} + +// globToSpecRegex converts a browsii glob (* wildcards) to a regex source +// string suitable for embedding in a toHaveURL literal. +func globToSpecRegex(pattern string) string { + var b strings.Builder + for _, r := range pattern { + switch r { + case '*': + b.WriteString(".*") + case '/': + b.WriteString("\\/") + default: + b.WriteRune(r) + } + } + return b.String() +} diff --git a/internal/daemon/export_test.go b/internal/daemon/export_test.go new file mode 100644 index 0000000..1d14446 --- /dev/null +++ b/internal/daemon/export_test.go @@ -0,0 +1,112 @@ +package daemon + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestRecordedEventRoundTrip(t *testing.T) { + events := []RecordedEvent{ + {T: 100, Action: "navigate", Params: map[string]interface{}{"url": "http://x/"}}, + {T: 200, Action: "click", Params: map[string]interface{}{"selector": "#a"}, + FP: &elementIdentity{Tag: "button", Role: "button", Text: "Add to cart"}, FPIndex: 1}, + {T: 300, Action: "expect", Params: map[string]interface{}{"text": "Total: $30"}, TimeoutMs: 5000}, + } + + data, err := json.Marshal(events) + if err != nil { + t.Fatal(err) + } + var back []RecordedEvent + if err := json.Unmarshal(data, &back); err != nil { + t.Fatal(err) + } + + if len(back) != 3 { + t.Fatalf("got %d events", len(back)) + } + if back[0].Action != "navigate" || back[0].T != 100 { + t.Errorf("navigate mismatch: %+v", back[0]) + } + if back[1].FP == nil || back[1].FP.Text != "Add to cart" || back[1].FPIndex != 1 { + t.Errorf("fingerprint lost: %+v", back[1]) + } + if back[2].Action != "expect" || back[2].TimeoutMs != 5000 { + t.Errorf("expect mismatch: %+v", back[2]) + } + + encoded := string(data) + if !strings.Contains(encoded, `"fp"`) || !strings.Contains(encoded, `"fpIndex":1`) { + t.Errorf("json field names wrong: %s", encoded) + } + if strings.Contains(encoded, `"fpIndex":0`) { + t.Errorf("zero fpIndex must be omitted: %s", encoded) + } +} + +func TestBuildPlaywrightSpec(t *testing.T) { + events := []RecordedEvent{ + {T: 1, Action: "navigate", Params: map[string]interface{}{"url": "http://shop/"}}, + {T: 2, Action: "click", Params: map[string]interface{}{"selector": "#p2-add"}, + FP: &elementIdentity{Tag: "button", Role: "button", Text: "Add to cart"}}, + {T: 3, Action: "type", Params: map[string]interface{}{"selector": "#email", "text": "a@b.c"}, + FP: &elementIdentity{Tag: "input", Role: "textbox", Name: "Email"}}, + {T: 4, Action: "expect", Params: map[string]interface{}{"text": "Total: $30"}, TimeoutMs: 4000}, + {T: 5, Action: "expect", Params: map[string]interface{}{"urlPattern": "*/orders/*"}}, + {T: 6, Action: "expect", Params: map[string]interface{}{"selector": ".spin", "hidden": true}}, + {T: 7, Action: "expect", Params: map[string]interface{}{"selector": "#email", "value": "a@b.c"}}, + {T: 8, Action: "screenshot", Params: map[string]interface{}{"filename": "x.png"}}, + } + + spec := buildPlaywrightSpec("http://shop/", "/tmp/shop.har", events) + + want := []string{ + `import { test, expect } from '@playwright/test';`, + `await page.routeFromHAR("/tmp/shop.har"`, + `await page.goto("http://shop/")`, + `page.getByRole("button", { name: "Add to cart" })`, + `// recorded selector: #p2-add`, + `.fill("a@b.c")`, + `getByText("Total: $30")).toBeVisible({ timeout: 4000 });`, + `toHaveURL(/.*\/orders\/.*/)`, + `toBeHidden()`, + `toHaveValue("a@b.c")`, + `// skipped: screenshot`, + } + for _, w := range want { + if !strings.Contains(spec, w) { + t.Errorf("spec missing %q\n%s", w, spec) + } + } + + if strings.Contains(spec, "test('recorded flow'") == false && !strings.Contains(spec, `test("shop"`) { + t.Errorf("test name not derived from har: %s", spec) + } +} + +func TestBuildPlaywrightSpec_NoHarNoFP(t *testing.T) { + events := []RecordedEvent{ + {T: 1, Action: "click", Params: map[string]interface{}{"selector": "#plain"}}, + } + spec := buildPlaywrightSpec("", "", events) + if !strings.Contains(spec, `page.locator("#plain").click()`) { + t.Errorf("plain selector must map to locator: %s", spec) + } + if strings.Contains(spec, "routeFromHAR") { + t.Errorf("no har must not emit routeFromHAR: %s", spec) + } +} + +func TestGlobToSpecRegex(t *testing.T) { + cases := map[string]string{ + "*/orders/*": `.*\/orders\/.*`, + "/a/b": `\/a\/b`, + "*": `.*`, + } + for in, want := range cases { + if got := globToSpecRegex(in); got != want { + t.Errorf("globToSpecRegex(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/daemon/routes_expect.go b/internal/daemon/routes_expect.go index 77ac12f..b19fdc6 100644 --- a/internal/daemon/routes_expect.go +++ b/internal/daemon/routes_expect.go @@ -137,6 +137,8 @@ func (s *Server) handleExpect(w http.ResponseWriter, r *http.Request) { return } + s.recordExpect(&req, timeout) + start := time.Now() describe := expectDescribe(&req) @@ -170,6 +172,49 @@ func (s *Server) handleExpect(w http.ResponseWriter, r *http.Request) { } } +// recordExpect appends the expect call to the active recording so replays +// enforce it as a checkpoint. Refs are not stable across sessions, so only +// selector-based conditions are recorded verbatim; a ref condition is +// recorded with its resolved selector. +func (s *Server) recordExpect(req *expectRequest, timeout time.Duration) { + if !s.recording { + return + } + params := map[string]interface{}{} + if req.Text != "" { + params["text"] = req.Text + } + if req.TextGone != "" { + params["textGone"] = req.TextGone + } + if req.URLPattern != "" { + params["urlPattern"] = req.URLPattern + } + if req.Selector != "" { + params["selector"] = req.Selector + } + if req.Value != "" { + params["value"] = req.Value + } + if req.Request != "" { + params["request"] = req.Request + } + if req.Hidden { + params["hidden"] = true + } + if req.NoConsoleError { + params["noConsoleErrors"] = true + } + s.recMu.Lock() + s.recordEvents = append(s.recordEvents, RecordedEvent{ + T: time.Since(s.recordStart).Milliseconds(), + Action: "expect", + Params: params, + TimeoutMs: int(timeout.Milliseconds()), + }) + s.recMu.Unlock() +} + // expectCondition returns the polling closure for the request's primary // condition (validation already happened in handleExpect). detail returned // by the closure updates the failure description. sinceSeq anchors diff --git a/internal/daemon/routes_interaction.go b/internal/daemon/routes_interaction.go index 9e9e959..b2e370e 100644 --- a/internal/daemon/routes_interaction.go +++ b/internal/daemon/routes_interaction.go @@ -179,7 +179,7 @@ func (s *Server) handleHover(w http.ResponseWriter, r *http.Request) { http.Error(w, "hover failed: "+err.Error(), http.StatusInternalServerError) return } - s.recordAction("hover", map[string]interface{}{"selector": selector}) + s.recordInteraction("hover", map[string]interface{}{"selector": selector}, page, selector) w.WriteHeader(http.StatusOK) } @@ -216,7 +216,7 @@ func (s *Server) handleClick(w http.ResponseWriter, r *http.Request) { http.Error(w, "click failed: "+err.Error(), http.StatusInternalServerError) return } - s.recordAction("click", map[string]interface{}{"selector": selector}) + s.recordInteraction("click", map[string]interface{}{"selector": selector}, page, selector) // Receipt: what did the click actually cause? s.writeEvidence(w, page, urlBefore, sinceSeq, req.NoEvidence) @@ -267,6 +267,6 @@ func (s *Server) handleType(w http.ResponseWriter, r *http.Request) { // Now insert text as global keystrokes to whatever is focused page.MustInsertText(req.Text) - s.recordAction("type", map[string]interface{}{"selector": selector, "text": req.Text}) + s.recordInteraction("type", map[string]interface{}{"selector": selector, "text": req.Text}, page, selector) w.WriteHeader(http.StatusOK) } diff --git a/internal/daemon/routes_network.go b/internal/daemon/routes_network.go index a60b50c..79d39d0 100644 --- a/internal/daemon/routes_network.go +++ b/internal/daemon/routes_network.go @@ -60,10 +60,8 @@ func (s *Server) handleNetworkCaptureStart(w http.ResponseWriter, r *http.Reques s.networkDomain.acquirePages(pages) - s.recordAction("network_capture_start", map[string]interface{}{ - "tab": req.Tab, "output": req.Output, - "include": req.Include, "format": req.Format, - }) + // Capture control is not recorded: replays manage their own network + // state via the recorded HAR. w.WriteHeader(http.StatusOK) } @@ -94,8 +92,6 @@ func (s *Server) handleNetworkCaptureStop(w http.ResponseWriter, r *http.Request reqs = []*capturedRequest{} } - s.recordAction("network_capture_stop", nil) - out, err := formatNetworkEntries(reqs, format) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) diff --git a/internal/daemon/routes_record.go b/internal/daemon/routes_record.go index d8ef442..f4b993c 100644 --- a/internal/daemon/routes_record.go +++ b/internal/daemon/routes_record.go @@ -1,10 +1,11 @@ package daemon import ( + "context" + "encoding/base64" "encoding/json" "fmt" "io" - "log" "net/http" "os" "path/filepath" @@ -21,15 +22,26 @@ func (s *Server) registerRecordRoutes(mux *http.ServeMux) { mux.HandleFunc("/record/replay", s.handleRecordReplay) mux.HandleFunc("/record/list", s.handleRecordList) mux.HandleFunc("/record/delete", s.handleRecordDelete) + mux.HandleFunc("/record/export", s.handleRecordExport) } -// /record/start endpoint — begins recording actions +func recordingPath(name string) string { + if filepath.IsAbs(name) { + return name + } + homeDir, _ := os.UserHomeDir() + return filepath.Join(homeDir, ".browsii", "recordings", name+".json") +} + +// /record/start — begins recording actions. +// captureHar also records network traffic to a HAR file alongside the +// recording, written when recording stops. func (s *Server) handleRecordStart(w http.ResponseWriter, r *http.Request) { var req struct { - Name string `json:"name"` + Name string `json:"name"` + CaptureHar bool `json:"captureHar"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + if !decodeBodyRequired(w, r, &req) { return } @@ -38,292 +50,544 @@ func (s *Server) handleRecordStart(w http.ResponseWriter, r *http.Request) { s.recordName = req.Name s.recordStart = time.Now() s.recordEvents = nil + s.recordHar = req.CaptureHar s.recMu.Unlock() + if req.CaptureHar { + harPath := strings.TrimSuffix(recordingPath(req.Name), ".json") + ".har" + if err := s.startHarCapture(harPath); err != nil { + s.recMu.Lock() + s.recording = false + s.recMu.Unlock() + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + recordURL := "" + if p := s.activePage(); p != nil { + if href, err := s.pageURL(p); err == nil { + recordURL = href + } + } + s.recMu.Lock() + s.recordURL = recordURL + s.recMu.Unlock() + + if req.CaptureHar { + harPath := strings.TrimSuffix(recordingPath(req.Name), ".json") + ".har" + if err := s.startHarCapture(harPath); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + w.WriteHeader(http.StatusOK) } -// /record/stop endpoint — stops recording and saves to disk +// /record/stop — stops recording and saves to disk. func (s *Server) handleRecordStop(w http.ResponseWriter, r *http.Request) { s.recMu.Lock() s.recording = false name := s.recordName events := s.recordEvents + captureHar := s.recordHar + recordURL := s.recordURL + s.recordHar = false s.recMu.Unlock() + var harPath string + if captureHar { + harPath = strings.TrimSuffix(recordingPath(name), ".json") + ".har" + if err := s.stopHarCapture(harPath); err != nil { + http.Error(w, "har capture: "+err.Error(), http.StatusInternalServerError) + return + } + } + recording := map[string]interface{}{ "name": name, + "url": recordURL, "events": events, } + if harPath != "" { + recording["har"] = harPath + } - var recFile string - if filepath.IsAbs(name) { - recFile = name - if err := os.MkdirAll(filepath.Dir(recFile), 0755); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - } else { - homeDir, _ := os.UserHomeDir() - recDir := filepath.Join(homeDir, ".browsii", "recordings") - if err := os.MkdirAll(recDir, 0755); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - recFile = filepath.Join(recDir, name+".json") + recFile := recordingPath(name) + if err := os.MkdirAll(filepath.Dir(recFile), 0755); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return } - data, _ := json.MarshalIndent(recording, "", " ") + data, err := json.MarshalIndent(recording, "", " ") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } if err := os.WriteFile(recFile, data, 0644); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") - buf, err := json.Marshal(map[string]interface{}{ + _ = json.NewEncoder(w).Encode(map[string]interface{}{ "name": name, "events": len(events), + "har": harPath, + }) +} + +// startHarCapture begins a network capture that includes response bodies, +// written as HAR to path on stop. +func (s *Server) startHarCapture(path string) error { + _, err := sendLocal(func() (*http.Response, error) { + return postLocal(fmt.Sprintf("http://127.0.0.1:%d/network/capture/start", s.port), + `{"include":["request-headers","request-body","response-headers","response-body","response-timing","response-size","request-timestamp"],"format":"har","output":"`+path+`"}`) }) + return err +} + +// stopHarCapture ends the capture and verifies the HAR file exists. Capture +// commands are not recorded as events. +func (s *Server) stopHarCapture(path string) error { + s.recMu.Lock() + s.recording = false + s.recMu.Unlock() + + if _, err := sendLocal(func() (*http.Response, error) { + return postLocal(fmt.Sprintf("http://127.0.0.1:%d/network/capture/stop", s.port), "") + }); err != nil { + return err + } + if _, err := os.Stat(path); err != nil { + return fmt.Errorf("no har written to %s", path) + } + return nil +} + +// postLocal issues a POST with a short timeout against this daemon. +func postLocal(url, body string) (*http.Response, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + var rdr io.Reader + if body != "" { + rdr = strings.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, rdr) if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return + return nil, err + } + req.Header.Set("Content-Type", "application/json") + return http.DefaultClient.Do(req) +} + +// sendLocal executes an HTTP call against this daemon's own API. Used by +// record flows that compose existing endpoints. +func sendLocal(do func() (*http.Response, error)) ([]byte, error) { + resp, err := do() + if err != nil { + return nil, err + } + defer resp.Body.Close() //nolint:errcheck + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("%s", strings.TrimSpace(string(body))) } - w.Write(buf) //nolint:errcheck + return body, nil +} + +// replayReport summarizes a replay run. +type replayReport struct { + Name string `json:"name"` + Steps int `json:"steps"` + Checkpoints replayCheckpoints `json:"checkpoints"` + Healed []replayHealNote `json:"healed,omitempty"` + DurationMs int64 `json:"durationMs"` + FailedStep int `json:"failedStep,omitempty"` + Error string `json:"error,omitempty"` } -// /record/replay endpoint — replays a recorded session +type replayCheckpoints struct { + Total int `json:"total"` + Passed int `json:"passed"` +} + +type replayHealNote struct { + Step int `json:"step"` + From string `json:"from"` + To string `json:"to"` +} + +// /record/replay — replays a recorded session. +// +// name recording name or absolute path +// speed 0 = instant (default), 1 = recorded timing, 2 = half timing +// live skip the recorded HAR snapshot; requests hit the network +// session resume this saved session before replaying func (s *Server) handleRecordReplay(w http.ResponseWriter, r *http.Request) { var req struct { - Name string `json:"name"` - Speed float64 `json:"speed"` // 0 = instant, 1 = real-time, 2 = 2x + Name string `json:"name"` + Speed float64 `json:"speed"` + Live bool `json:"live"` + Session string `json:"session"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + if !decodeBodyRequired(w, r, &req) { return } - var recFile string - if filepath.IsAbs(req.Name) { - recFile = req.Name - } else { - homeDir, _ := os.UserHomeDir() - recFile = filepath.Join(homeDir, ".browsii", "recordings", req.Name+".json") - } - - data, err := os.ReadFile(recFile) + data, err := os.ReadFile(recordingPath(req.Name)) if err != nil { http.Error(w, fmt.Sprintf("recording %q not found", req.Name), http.StatusNotFound) return } var recording struct { + URL string `json:"url"` + HAR string `json:"har"` Events []RecordedEvent `json:"events"` } if err := json.Unmarshal(data, &recording); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + http.Error(w, "invalid recording: "+err.Error(), http.StatusBadRequest) return } - // Disable recording during replay to avoid re-recording - wasRecording := s.recording - s.recording = false + report := replayReport{Name: req.Name, Steps: len(recording.Events)} + start := time.Now() + defer func() { + report.DurationMs = time.Since(start).Milliseconds() + s.writeReplayReport(w, &report) + }() + + if req.Session != "" { + if err := s.resumeSession(req.Session); err != nil { + report.Error = "session " + req.Session + ": " + err.Error() + return + } + } - for i, event := range recording.Events { - // Catch panics per-action to avoid crashing the whole replay - func() { - defer func() { - if r := recover(); r != nil { - log.Printf("Replay of action %q panicked: %v", event.Action, r) + snapshotLoaded := false + if !req.Live { + if recording.HAR != "" { + if _, err := os.Stat(recording.HAR); err == nil { + if err := s.loadSnapshot(recording.HAR); err != nil { + report.Error = "har " + recording.HAR + ": " + err.Error() + return } - }() + snapshotLoaded = true + } + } + } else { + // A snapshot left over from a previous replay would silently defeat + // --live; clear it. + s.clearSnapshot() + } + + wasRecording := s.recording + s.recording = false + defer func() { s.recording = wasRecording }() - // Apply timing delay - if req.Speed > 0 && i > 0 { - delay := event.T - recording.Events[i-1].T + for i := range recording.Events { + ev := recording.Events[i] + if req.Speed > 0 && i > 0 { + delay := ev.T - recording.Events[i-1].T + if delay > 0 { time.Sleep(time.Duration(float64(delay)/req.Speed) * time.Millisecond) } + } + + page := s.activePage() + if page == nil { + report.FailedStep = i + 1 + report.Error = "no active page" + return + } - // Dispatch the action directly - page := s.activePage() - if page == nil { + if ev.Action == "expect" { + report.Checkpoints.Total++ + ok, msg := s.replayExpect(page, ev) + if !ok { + report.FailedStep = i + 1 + report.Error = msg return } + report.Checkpoints.Passed++ + continue + } - switch event.Action { - case "tab_new": - if url, ok := event.Params["url"].(string); ok { - s.activePg = s.browser.MustPage(url).MustWaitLoad() - s.trackPage(s.activePg) - } - case "tab_close": - s.untrackPage(page) - page.MustClose() - s.activePg = nil - case "tab_switch": - if idxF, ok := event.Params["index"].(float64); ok { - pages := s.orderedPages() - idx := int(idxF) - if idx >= 0 && idx < len(pages) { - s.activePg = pages[idx] - s.activePg.MustActivate() - } - } - case "mouse_move": - if x, ok := event.Params["x"].(float64); ok { - if y, ok := event.Params["y"].(float64); ok { - page.Mouse.MustMoveTo(x, y) - } - } - case "mouse_drag": - x1, _ := event.Params["x1"].(float64) - y1, _ := event.Params["y1"].(float64) - x2, _ := event.Params["x2"].(float64) - y2, _ := event.Params["y2"].(float64) - stepsF, ok := event.Params["steps"].(float64) - steps := 10 - if ok && stepsF > 0 { - steps = int(stepsF) - } - page.Mouse.MustMoveTo(x1, y1) - page.Mouse.MustDown("left") - for i := 1; i <= steps; i++ { - t := float64(i) / float64(steps) - page.Mouse.MustMoveTo(x1+t*(x2-x1), y1+t*(y2-y1)) - } - page.Mouse.MustUp("left") - case "mouse_rightclick": - if sel, ok := event.Params["selector"].(string); ok { - el := page.MustElement(sel) - el.MustScrollIntoView() - box := el.MustShape().Box() - page.Mouse.MustMoveTo(box.X+box.Width/2, box.Y+box.Height/2) - page.Mouse.MustClick("right") - } - case "mouse_doubleclick": - if sel, ok := event.Params["selector"].(string); ok { - js := `(sel) => { document.querySelector(sel).dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true})); }` - page.MustEval(js, sel) - } - case "upload": - sel, ok1 := event.Params["selector"].(string) - filesI, ok2 := event.Params["files"].([]interface{}) - if ok1 && ok2 { - var files []string - for _, f := range filesI { - if fs, ok := f.(string); ok { - files = append(files, fs) - } - } - page.MustElement(sel).MustSetFiles(files...) - } - case "screenshot": - filename, _ := event.Params["filename"].(string) - el, _ := event.Params["element"].(string) - fullPage, _ := event.Params["fullPage"].(bool) - if el != "" { - data, _ := page.MustElement(el).Screenshot(proto.PageCaptureScreenshotFormatPng, 0) - os.WriteFile(filename, data, 0644) //nolint:errcheck - } else if fullPage { - page.MustScreenshotFullPage(filename) - } else { - page.MustScreenshot(filename) - } - case "pdf": - if filename, ok := event.Params["filename"].(string); ok { - pdfData, _ := page.PDF(&proto.PagePrintToPDF{}) - data, _ := io.ReadAll(pdfData) - os.WriteFile(filename, data, 0644) //nolint:errcheck - } - case "js": - if script, ok := event.Params["script"].(string); ok { - page.MustEval(script) - } - case "network_throttle": - lat, _ := event.Params["latency"].(float64) - dl, _ := event.Params["download"].(float64) - up, _ := event.Params["upload"].(float64) - proto.NetworkEmulateNetworkConditions{Offline: false, Latency: lat, DownloadThroughput: dl, UploadThroughput: up}.Call(page) //nolint:errcheck - case "network_mock": - pat, _ := event.Params["pattern"].(string) - body, _ := event.Params["body"].(string) - ct, _ := event.Params["contentType"].(string) - sc, _ := event.Params["statusCode"].(float64) - if sc == 0 { - sc = 200 - } - router := page.HijackRequests() - router.MustAdd(pat, func(ctx *rod.Hijack) { - ctx.Response.SetBody(body) - if ct != "" { - ctx.Response.SetHeader("Content-Type", ct) - } - ctx.Response.Payload().ResponseCode = int(sc) - }) - go router.Run() - case "navigate": - if url, ok := event.Params["url"].(string); ok { - page.MustNavigate(url).MustWaitLoad() - } - case "click": - if sel, ok := event.Params["selector"].(string); ok { - page.MustElement(sel).MustClick() - } - case "type": - sel, _ := event.Params["selector"].(string) - text, _ := event.Params["text"].(string) - if sel != "" { - page.MustElement(sel) - js := fmt.Sprintf(`() => { - const el = document.querySelector('%s'); - if (el) { el.value = ''; el.focus(); } - }`, sel) - page.MustEval(js) - page.MustInsertText(text) - } - case "scroll": - dir, _ := event.Params["direction"].(string) - pixels := 300 - if p, ok := event.Params["pixels"].(float64); ok && p > 0 { - pixels = int(p) - } - switch dir { - case "down": - page.MustEval(fmt.Sprintf("() => window.scrollBy(0, %d)", pixels)) - case "up": - page.MustEval(fmt.Sprintf("() => window.scrollBy(0, -%d)", pixels)) - case "top": - page.MustEval("() => window.scrollTo(0, 0)") - case "bottom": - page.MustEval("() => window.scrollTo(0, document.body.scrollHeight)") - } - case "hover": - if sel, ok := event.Params["selector"].(string); ok { - page.MustElement(sel).MustHover() - } - case "press": - if key, ok := event.Params["key"].(string); ok { - keys := parseKeyCombo(key) - ka := page.KeyActions() - for _, k := range keys { - ka = ka.Press(k) - } - ka.MustDo() + err := s.replayActionSafely(page, ev, &report, i+1) + if err != nil { + report.FailedStep = i + 1 + report.Error = err.Error() + return + } + } + + if snapshotLoaded { + s.clearSnapshot() + } +} + +// replayActionSafely runs replayAction, converting panics into errors so a +// failing step is reported instead of aborting the response. +func (s *Server) replayActionSafely(page *rod.Page, ev RecordedEvent, report *replayReport, step int) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("%v", r) + } + }() + return s.replayAction(page, ev, report, step) +} + +// replayExpect runs a recorded expect event through the same wait loop the +// /expect endpoint uses. +func (s *Server) replayExpect(page *rod.Page, ev RecordedEvent) (bool, string) { + req := expectRequest{ + Text: paramString(ev.Params, "text"), + TextGone: paramString(ev.Params, "textGone"), + URLPattern: paramString(ev.Params, "urlPattern"), + Selector: paramString(ev.Params, "selector"), + Hidden: paramBool(ev.Params, "hidden"), + Ref: 0, + Value: paramString(ev.Params, "value"), + Request: paramString(ev.Params, "request"), + NoConsoleError: paramBool(ev.Params, "noConsoleErrors"), + TimeoutMs: ev.TimeoutMs, + } + cond, aerr := s.expectCondition(page, &req, s.currentEventSeq()) + if aerr != nil { + return false, aerr.Message + } + timeout := expectDefaultTimeout + if req.TimeoutMs > 0 { + timeout = time.Duration(req.TimeoutMs) * time.Millisecond + } + start := time.Now() + for { + ok, _ := cond() + if ok { + return true, "" + } + if time.Since(start) >= timeout { + return false, fmt.Sprintf("checkpoint failed: %s — timed out after %dms", + expectDescribe(&req), timeout.Milliseconds()) + } + time.Sleep(expectPollInterval) + } +} + +func paramString(p map[string]interface{}, key string) string { + v, _ := p[key].(string) + return v +} + +func paramBool(p map[string]interface{}, key string) bool { + v, _ := p[key].(bool) + return v +} + +// replayAction executes one non-expect event. Element actions resolve their +// selector through the recorded fingerprint: when the selector no longer +// matches, the element is relocated by fingerprint and occurrence index, and +// the substitution is noted in the report. +func (s *Server) replayAction(page *rod.Page, ev RecordedEvent, report *replayReport, step int) error { + selector := paramString(ev.Params, "selector") + + switch ev.Action { + case "click", "hover", "type": + if selector != "" { + resolved, err := s.resolveRecordedTarget(page, ev, selector, report, step) + if err != nil { + return err + } + selector = resolved + } + } + + switch ev.Action { + case "navigate": + page.MustNavigate(paramString(ev.Params, "url")).MustWaitLoad() + case "click": + el, aerr := s.findElement(page, selector) + if aerr != nil { + return fmt.Errorf("%s", aerr.Message) + } + if err := el.Click(proto.InputMouseButtonLeft, 1); err != nil { + return err + } + case "type": + if _, aerr := s.findElement(page, selector); aerr != nil { + return fmt.Errorf("%s", aerr.Message) + } + _, _ = page.Eval(`(sel) => { + const el = document.querySelector(sel); + if (el) { el.value = ''; el.focus(); } + }`, selector) + page.MustInsertText(paramString(ev.Params, "text")) + case "hover": + el, aerr := s.findElement(page, selector) + if aerr != nil { + return fmt.Errorf("%s", aerr.Message) + } + if err := el.Hover(); err != nil { + return err + } + case "press": + ka := page.KeyActions() + for _, k := range parseKeyCombo(paramString(ev.Params, "key")) { + ka = ka.Press(k) + } + ka.MustDo() + case "reload": + page.MustReload().MustWaitLoad() + case "back": + page.MustNavigateBack().MustWaitLoad() + case "forward": + page.MustNavigateForward().MustWaitLoad() + case "scroll": + dir := paramString(ev.Params, "direction") + pixels := 300 + if p, ok := ev.Params["pixels"].(float64); ok && p > 0 { + pixels = int(p) + } + switch dir { + case "down": + page.MustEval(fmt.Sprintf("() => window.scrollBy(0, %d)", pixels)) + case "up": + page.MustEval(fmt.Sprintf("() => window.scrollBy(0, -%d)", pixels)) + case "top": + page.MustEval("() => window.scrollTo(0, 0)") + case "bottom": + page.MustEval("() => window.scrollTo(0, document.body.scrollHeight)") + } + case "js": + page.MustEval(wrapScript(paramString(ev.Params, "script"))) + case "tab_new": + s.activePg = s.browser.MustPage(paramString(ev.Params, "url")).MustWaitLoad() + s.trackPage(s.activePg) + case "tab_close": + s.untrackPage(page) + page.MustClose() + s.activePg = nil + case "tab_switch": + if idxF, ok := ev.Params["index"].(float64); ok { + pages := s.orderedPages() + idx := int(idxF) + if idx >= 0 && idx < len(pages) { + s.activePg = pages[idx] + s.activePg.MustActivate() + } + } + case "mouse_move": + if x, ok := ev.Params["x"].(float64); ok { + if y, ok := ev.Params["y"].(float64); ok { + page.Mouse.MustMoveTo(x, y) + } + } + case "mouse_rightclick": + el := page.MustElement(selector) + el.MustScrollIntoView() + box := el.MustShape().Box() + page.Mouse.MustMoveTo(box.X+box.Width/2, box.Y+box.Height/2) + page.Mouse.MustClick("right") + case "mouse_doubleclick": + page.MustEval(`(sel) => { + document.querySelector(sel).dispatchEvent(new MouseEvent('dblclick', {bubbles: true, cancelable: true})); + }`, selector) + case "upload": + var files []string + if list, ok := ev.Params["files"].([]interface{}); ok { + for _, f := range list { + if fs, ok := f.(string); ok { + files = append(files, fs) } - case "reload": - page.MustReload().MustWaitLoad() - case "back": - page.MustNavigateBack().MustWaitLoad() - case "forward": - page.MustNavigateForward().MustWaitLoad() } - }() + } + page.MustElement(selector).MustSetFiles(files...) + case "screenshot": + filename := paramString(ev.Params, "filename") + el := paramString(ev.Params, "element") + if el != "" { + data, err := page.MustElement(el).Screenshot(proto.PageCaptureScreenshotFormatPng, 0) + if err == nil { + _ = os.WriteFile(filename, data, 0644) + } + } else if paramBool(ev.Params, "fullPage") { + page.MustScreenshotFullPage(filename) + } else { + page.MustScreenshot(filename) + } + case "pdf": + if filename := paramString(ev.Params, "filename"); filename != "" { + pdfData, _ := page.PDF(&proto.PagePrintToPDF{}) + data, _ := io.ReadAll(pdfData) + _ = os.WriteFile(filename, data, 0644) + } + case "network_throttle": + lat, _ := ev.Params["latency"].(float64) + dl, _ := ev.Params["download"].(float64) + up, _ := ev.Params["upload"].(float64) + _ = proto.NetworkEmulateNetworkConditions{Offline: false, Latency: lat, DownloadThroughput: dl, UploadThroughput: up}.Call(page) + case "network_mock": + pat := paramString(ev.Params, "pattern") + router := page.HijackRequests() + router.MustAdd(pat, func(ctx *rod.Hijack) { + ctx.Response.SetBody(paramString(ev.Params, "body")) + if ct := paramString(ev.Params, "contentType"); ct != "" { + ctx.Response.SetHeader("Content-Type", ct) + } + sc, _ := ev.Params["statusCode"].(float64) + if sc == 0 { + sc = 200 + } + ctx.Response.Payload().ResponseCode = int(sc) + }) + go router.Run() + default: + return fmt.Errorf("unsupported action %q", ev.Action) + } + return nil +} + +// resolveRecordedTarget verifies the recorded selector against the recorded +// fingerprint. On mismatch the element is relocated by fingerprint plus +// occurrence index; the substitution is recorded in the report. Relocation +// failure returns an error naming the original element. +func (s *Server) resolveRecordedTarget(page *rod.Page, ev RecordedEvent, selector string, report *replayReport, step int) (string, error) { + if ev.FP == nil { + return selector, nil + } + want := fingerprintParts(ev.FP.Tag, ev.FP.Role, ev.FP.Text, ev.FP.Name, ev.FP.Href, ev.FP.Type) + if live, err := liveFingerprint(page, selector); err == nil && live == want { + return selector, nil + } + if to, ok := s.findByFingerprint(page, want, ev.FPIndex); ok { + report.Healed = append(report.Healed, replayHealNote{Step: step, From: selector, To: to}) + return to, nil } + label := ev.FP.Text + if label == "" { + label = ev.FP.Name + } + return "", fmt.Errorf("element no longer matches: was %s %q → %s", ev.FP.Role, label, selector) +} - s.recording = wasRecording - w.WriteHeader(http.StatusOK) +// writeReplayReport writes the replay outcome. Failures use 417 to match +// expect semantics; success is 200 with the full report. +func (s *Server) writeReplayReport(w http.ResponseWriter, report *replayReport) { + status := http.StatusOK + if report.Error != "" { + status = http.StatusExpectationFailed + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(report) } -// /record/list endpoint — returns available recordings +// /record/list — returns available recordings. func (s *Server) handleRecordList(w http.ResponseWriter, r *http.Request) { homeDir, _ := os.UserHomeDir() recDir := filepath.Join(homeDir, ".browsii", "recordings") @@ -353,36 +617,157 @@ func (s *Server) handleRecordList(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - buf, err := json.Marshal(recordings) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.Write(buf) //nolint:errcheck + _ = json.NewEncoder(w).Encode(recordings) } -// /record/delete endpoint — removes a recording +// /record/delete — removes a recording and its HAR if present. func (s *Server) handleRecordDelete(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + if !decodeBodyRequired(w, r, &req) { return } - var recFile string - if filepath.IsAbs(req.Name) { - recFile = req.Name - } else { - homeDir, _ := os.UserHomeDir() - recFile = filepath.Join(homeDir, ".browsii", "recordings", req.Name+".json") - } - + recFile := recordingPath(req.Name) if err := os.Remove(recFile); err != nil { http.Error(w, fmt.Sprintf("recording %q not found", req.Name), http.StatusNotFound) return } + harPath := strings.TrimSuffix(recFile, ".json") + ".har" + if _, err := os.Stat(harPath); err == nil { + _ = os.Remove(harPath) + } w.WriteHeader(http.StatusOK) } + +// /record/export — writes a Playwright TypeScript spec for the recording. +func (s *Server) handleRecordExport(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Out string `json:"out"` + } + if !decodeBodyRequired(w, r, &req) { + return + } + + data, err := os.ReadFile(recordingPath(req.Name)) + if err != nil { + http.Error(w, fmt.Sprintf("recording %q not found", req.Name), http.StatusNotFound) + return + } + + var recording struct { + URL string `json:"url"` + HAR string `json:"har"` + Events []RecordedEvent `json:"events"` + } + if err := json.Unmarshal(data, &recording); err != nil { + http.Error(w, "invalid recording: "+err.Error(), http.StatusBadRequest) + return + } + + spec := buildPlaywrightSpec(recording.URL, recording.HAR, recording.Events) + + out := req.Out + if out == "" { + out = strings.TrimSuffix(recordingPath(req.Name), ".json") + ".spec.ts" + } + if err := os.WriteFile(out, []byte(spec), 0644); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"path": out}) +} + +// resumeSession restores a saved session by name, without the HTTP layer. +func (s *Server) resumeSession(name string) error { + homeDir, _ := os.UserHomeDir() + sessFile := filepath.Join(homeDir, ".browsii", "sessions", name+".json") + data, err := os.ReadFile(sessFile) + if err != nil { + return fmt.Errorf("not found") + } + + var session struct { + ActiveTab int `json:"activeTab"` + Tabs []struct { + URL string `json:"url"` + ScrollX int `json:"scrollX"` + ScrollY int `json:"scrollY"` + } `json:"tabs"` + } + if err := json.Unmarshal(data, &session); err != nil { + return err + } + + oldPages, _ := s.browser.Pages() + s.pageOrder = nil + s.listenedPages = make(map[proto.TargetTargetID]struct{}) + s.consoleListenedPages = make(map[proto.TargetTargetID]struct{}) + for i, tab := range session.Tabs { + page := s.browser.MustPage(tab.URL).MustWaitLoad() + s.trackPage(page) + if tab.ScrollX != 0 || tab.ScrollY != 0 { + page.MustEval(fmt.Sprintf("() => window.scrollTo(%d, %d)", tab.ScrollX, tab.ScrollY)) + } + if i == session.ActiveTab { + s.activePg = page + page.MustActivate() + } + } + for _, p := range oldPages { + p.MustClose() + } + return nil +} + +// clearSnapshot stops any active snapshot router without touching the HTTP +// layer. +func (s *Server) clearSnapshot() { + s.mu.Lock() + prev := s.snapshotRouter + s.snapshotRouter = nil + s.mu.Unlock() + if prev != nil { + prev.Stop() //nolint:errcheck + } +} + +// loadSnapshot installs the HAR snapshot router for offline replay. +func (s *Server) loadSnapshot(harPath string) error { + data, err := os.ReadFile(harPath) + if err != nil { + return err + } + var har harSnapshot + if err := json.Unmarshal(data, &har); err != nil { + return err + } + urlMap := make(map[string]snapshotEntry, len(har.Log.Entries)) + for _, e := range har.Log.Entries { + if e.Request.URL == "" { + continue + } + var body []byte + if e.Response.Content.Encoding == "base64" { + body, _ = base64.StdEncoding.DecodeString(e.Response.Content.Text) + } else { + body = []byte(e.Response.Content.Text) + } + status := e.Response.Status + if status == 0 { + status = 200 + } + urlMap[e.Request.URL] = snapshotEntry{ + status: status, + contentType: e.Response.Content.MimeType, + body: body, + } + } + s.replaceSnapshotRouter(urlMap) + return nil +} diff --git a/internal/daemon/routes_session.go b/internal/daemon/routes_session.go index d2bcbe3..cf26d09 100644 --- a/internal/daemon/routes_session.go +++ b/internal/daemon/routes_session.go @@ -49,10 +49,20 @@ func (s *Server) handleSessionSave(w http.ResponseWriter, r *http.Request) { } } + cookies := []proto.NetworkCookie{} + if ap := s.activePage(); ap != nil { + if all, err := ap.Cookies(nil); err == nil { + for _, c := range all { + cookies = append(cookies, *c) + } + } + } + session := map[string]interface{}{ "name": req.Name, "activeTab": activeTab, "tabs": tabs, + "cookies": cookies, } // Write to ~/.browsii/sessions/.json @@ -125,12 +135,32 @@ func (s *Server) handleSessionResume(w http.ResponseWriter, r *http.Request) { ScrollX int `json:"scrollX"` ScrollY int `json:"scrollY"` } `json:"tabs"` + Cookies []proto.NetworkCookie `json:"cookies"` } if err := json.Unmarshal(data, &session); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + if len(session.Cookies) > 0 { + params := make([]*proto.NetworkCookieParam, 0, len(session.Cookies)) + for _, c := range session.Cookies { + params = append(params, &proto.NetworkCookieParam{ + Name: c.Name, + Value: c.Value, + Domain: c.Domain, + Path: c.Path, + Expires: c.Expires, + HTTPOnly: c.HTTPOnly, + Secure: c.Secure, + SameSite: c.SameSite, + }) + } + if ap := s.activePage(); ap != nil { + _ = ap.SetCookies(params) + } + } + // Restore tabs first, then close old pages oldPages, _ := s.browser.Pages() oldIDs := make(map[string]bool) diff --git a/internal/daemon/server.go b/internal/daemon/server.go index c436cac..955159c 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -38,11 +38,18 @@ type contextState struct { page *rod.Page } -// RecordedEvent represents a single captured action. +// RecordedEvent is a single recorded action. type RecordedEvent struct { T int64 `json:"t"` Action string `json:"action"` Params map[string]interface{} `json:"params,omitempty"` + // FP describes the target element as it was at record time; replay + // uses it to relocate the element when the selector no longer matches. + FP *elementIdentity `json:"fp,omitempty"` + // FPIndex is the zero-based position of the element among all elements + // with the same FP, for pages with repeated identical elements. + FPIndex int `json:"fpIndex,omitempty"` + TimeoutMs int `json:"timeoutMs,omitempty"` } // injectJSEntry is a registered pre-load JS script. Script always holds the @@ -89,8 +96,10 @@ type Server struct { // Recording state recording bool recordName string + recordURL string recordStart time.Time recordEvents []RecordedEvent + recordHar bool recMu sync.Mutex // Stable insertion-order tab list. browser.Pages() returns tabs in @@ -205,6 +214,27 @@ func (s *Server) recordAction(action string, params map[string]interface{}) { }) } +// recordInteraction records an element interaction with its FP, if the +// selector currently resolves. +func (s *Server) recordInteraction(action string, params map[string]interface{}, page *rod.Page, selector string) { + if !s.recording { + return + } + ev := RecordedEvent{ + T: time.Since(s.recordStart).Milliseconds(), + Action: action, + Params: params, + } + if id, idx, err := liveFingerprintEx(page, selector); err == nil && id != nil { + fp := *id + ev.FP = &fp + ev.FPIndex = idx + } + s.recMu.Lock() + s.recordEvents = append(s.recordEvents, ev) + s.recMu.Unlock() +} + // NewServer creates a new Daemon configuration. func NewServer(port int, mode string) *Server { s := &Server{ diff --git a/internal/daemon/server_test.go b/internal/daemon/server_test.go index 90f2a82..1fe3399 100644 --- a/internal/daemon/server_test.go +++ b/internal/daemon/server_test.go @@ -11,18 +11,24 @@ import ( "time" ) +// TestSSEBroadcastAndBackpressure verifies the SSE contract: a slow client +// never blocks the daemon, overflowing events are dropped (oldest first) +// with a visible overflow_warning, and the stream recovers to deliver later +// events once the client drains. +// +// The consumer is held until the burst completes so the overflow cascade is +// deterministic regardless of scheduling; every read is deadline-guarded so +// a regression fails the test instead of hanging it. func TestSSEBroadcastAndBackpressure(t *testing.T) { - // 1. Setup a test Server with a small capacity channel for testing s := NewServer(0, "headless") - // 2. Start a test HTTP server + releaseConsumer := make(chan struct{}) + mux := http.NewServeMux() mux.HandleFunc("/events/stream", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/event-stream") - // Use a tiny buffer of 2 to easily trigger overflow clientChan := make(chan StreamEvent, 2) - s.sseMu.Lock() s.sseClients[clientChan] = struct{}{} s.sseMu.Unlock() @@ -38,14 +44,19 @@ func TestSSEBroadcastAndBackpressure(t *testing.T) { w.WriteHeader(http.StatusOK) flusher.Flush() - ctx := r.Context() + // Park until the test has fired the burst; registration already + // happened, so broadcasts overflow the buffer deterministically. + select { + case <-r.Context().Done(): + return + case <-releaseConsumer: + } for { select { - case <-ctx.Done(): + case <-r.Context().Done(): return case event := <-clientChan: - time.Sleep(50 * time.Millisecond) // Simulate a slow client to allow the buffer to fill data, _ := json.Marshal(event) w.Write([]byte("data: " + string(data) + "\n\n")) //nolint:errcheck flusher.Flush() @@ -56,10 +67,8 @@ func TestSSEBroadcastAndBackpressure(t *testing.T) { ts := httptest.NewServer(mux) defer ts.Close() - // 3. Connect a background client bound to a cancelable context ctx, cancel := context.WithCancel(context.Background()) defer cancel() - req, _ := http.NewRequestWithContext(ctx, "GET", ts.URL+"/events/stream", nil) clientResp, err := http.DefaultClient.Do(req) if err != nil { @@ -67,32 +76,59 @@ func TestSSEBroadcastAndBackpressure(t *testing.T) { } defer clientResp.Body.Close() //nolint:errcheck - // 4. Force an overflow by sending 4 events into a capacity 2 channel + lines := make(chan string, 16) + go func() { + scanner := bufio.NewScanner(clientResp.Body) + for scanner.Scan() { + if text := scanner.Text(); strings.HasPrefix(text, "data: ") { + lines <- text + } + } + close(lines) + }() + + // collect fails the test on timeout instead of blocking forever. + collect := func(n int) []string { + t.Helper() + deadline := time.After(5 * time.Second) + var got []string + for len(got) < n { + select { + case line, ok := <-lines: + if !ok { + t.Fatalf("stream closed after %d lines, wanted %d: %v", len(got), n, got) + } + got = append(got, line) + case <-deadline: + t.Fatalf("timed out collecting %d lines, got %v", n, got) + } + } + return got + } + + // Burst while the consumer is parked: buffer fills, e1/e2 are dropped + // oldest-first, warnings are enqueued. Final buffer: [warn, warn]. s.broadcastEvent(StreamEvent{Type: EventNetworkRequest, Payload: "event 1"}) s.broadcastEvent(StreamEvent{Type: EventNetworkRequest, Payload: "event 2"}) - s.broadcastEvent(StreamEvent{Type: EventNetworkRequest, Payload: "event 3"}) // Triggers overflow + s.broadcastEvent(StreamEvent{Type: EventNetworkRequest, Payload: "event 3"}) s.broadcastEvent(StreamEvent{Type: EventNetworkRequest, Payload: "event 4"}) - // 5. Read events from the stream (which is the capacity of the channel plus the initial instant read) - scanner := bufio.NewScanner(clientResp.Body) - var lines []string + close(releaseConsumer) - for len(lines) < 3 && scanner.Scan() { - text := scanner.Text() - if strings.HasPrefix(text, "data: ") { - lines = append(lines, text) - } + // The consumer delivers the two warnings; receiving both proves the + // buffer is empty and the handler is parked in its select. + overflow := collect(2) + if !strings.Contains(overflow[0], "overflow_warning") || !strings.Contains(overflow[1], "overflow_warning") { + t.Fatalf("expected two overflow warnings first, got: %v", overflow) } - output := strings.Join(lines, "\n") - - // Verification - // The buffer should contain an overflow warning because the client was slow/blocked. - if !strings.Contains(output, "overflow_warning") { - t.Errorf("Expected overflow warning in output, got: %s", output) + // Recovery: with the buffer drained, the next event is delivered. + s.broadcastEvent(StreamEvent{Type: EventNetworkRequest, Payload: "event 5"}) + recovered := collect(1) + if !strings.Contains(recovered[0], "event 5") { + t.Errorf("expected event 5 delivered after drain, got: %v", recovered) } - - if !strings.Contains(output, "event 3") && !strings.Contains(output, "event 4") { - t.Errorf("Expected newer events to be preserved, got: %s", output) + if strings.Contains(strings.Join(append(overflow, recovered...), "\n"), "event 1") { + t.Errorf("dropped events must not be delivered, got: %v %v", overflow, recovered) } } diff --git a/tests/api_replay_test.go b/tests/api_replay_test.go new file mode 100644 index 0000000..b96b43f --- /dev/null +++ b/tests/api_replay_test.go @@ -0,0 +1,389 @@ +package tests + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordFlow runs the canonical shop flow under a recording: click product +// two's Add to cart by element ref, then verify the total with expect. +// Recording is stopped and the saved JSON is returned. +func recordFlow(t *testing.T, bin string, port int, serverURL string, name string) map[string]any { + t.Helper() + runCLI(t, bin, port, "record", "start", name) + runCLI(t, bin, port, "navigate", serverURL) + + out := runCLI(t, bin, port, "elements", "--json") + var list struct { + Elements []struct { + Ref int `json:"ref"` + Role string `json:"role"` + Text string `json:"text"` + Selector string `json:"selector"` + } `json:"elements"` + } + require.NoError(t, json.Unmarshal([]byte(out), &list)) + + gadgetRef := 0 + for _, e := range list.Elements { + if e.Selector == "#p2-add" { + gadgetRef = e.Ref + break + } + } + require.NotZero(t, gadgetRef, "#p2-add not found in elements output") + + runCLI(t, bin, port, "click", strconv.Itoa(gadgetRef)) + runCLI(t, bin, port, "expect", "--text", "Total: $30") + runCLI(t, bin, port, "record", "stop") + + home, _ := os.UserHomeDir() + data, err := os.ReadFile(filepath.Join(home, ".browsii", "recordings", name+".json")) + require.NoError(t, err) + var rec map[string]any + require.NoError(t, json.Unmarshal(data, &rec)) + return rec +} + +func replayCLI(t *testing.T, bin string, port int, name string, extra ...string) (string, error) { + t.Helper() + args := append([]string{"record", "replay", name}, extra...) + return runCLIExpectFail(t, bin, port, args...) +} + +func TestReplay_RecordAndReplayDeterministic(t *testing.T) { + server := setupShopServer() + defer server.Close() + + port := nextPort() + bin, cleanup := startDaemon(t, port) + defer cleanup() + + rec := recordFlow(t, bin, port, server.URL, "shop-basic") + events := rec["events"].([]any) + assert.GreaterOrEqual(t, len(events), 3, "navigate + click + expect minimum") + + // The click event carries a fingerprint, and the expect was recorded. + hasFP, hasExpect := false, false + for _, e := range events { + ev := e.(map[string]any) + if ev["action"] == "click" && ev["fp"] != nil { + hasFP = true + } + if ev["action"] == "expect" { + hasExpect = true + } + } + assert.True(t, hasFP, "click events must record fingerprints") + assert.True(t, hasExpect, "expect calls must be recorded as events") + + // Fresh page, then replay twice. + for i := 0; i < 2; i++ { + runCLI(t, bin, port, "session", "new", "fresh") + out, err := replayCLI(t, bin, port, "shop-basic") + require.NoError(t, err, "replay %d failed: %s", i+1, out) + assert.Contains(t, out, "Replayed") + assert.Contains(t, out, "checkpoints passed") + + jsOut := runCLI(t, bin, port, "js", "() => window.cartCount") + assert.Contains(t, jsOut, "1", "replay must perform the recorded click") + } +} + +func TestReplay_FastByDefault(t *testing.T) { + server := setupShopServer() + defer server.Close() + + port := nextPort() + bin, cleanup := startDaemon(t, port) + defer cleanup() + + recordFlow(t, bin, port, server.URL, "shop-fast") + + runCLI(t, bin, port, "session", "new", "fresh") + start := time.Now() + out, err := replayCLI(t, bin, port, "shop-fast") + require.NoError(t, err, out) + require.Less(t, time.Since(start), 10*time.Second, "instant replay must not wait recorded gaps") +} + +func TestReplay_HealsThroughUIDrift(t *testing.T) { + server := setupShopServer() + defer server.Close() + + port := nextPort() + bin, cleanup := startDaemon(t, port) + defer cleanup() + + recordFlow(t, bin, port, server.URL+"?variant=canonical", "shop-drift") + retargetRecording(t, "shop-drift", server.URL+"?variant=drifted") + + runCLI(t, bin, port, "session", "new", "fresh") + out, err := replayCLI(t, bin, port, "shop-drift", "--live") + require.NoError(t, err, "replay must pass against drifted UI: %s", out) + assert.Contains(t, out, "healed: step", "drifted selector must be reported as healed") + + // The click hit a product: drifted order is Gizmo, Widget, Gadget, all + // fingerprint-identical, so cartCount proves the click landed somewhere + // real rather than nothing happening. + jsOut := runCLI(t, bin, port, "js", "() => window.cartCount") + assert.Contains(t, jsOut, "1") +} + +// retargetRecording rewrites the recorded navigate URL so replay targets +// toURL instead of the one the flow was recorded on. +func retargetRecording(t *testing.T, name, toURL string) { + t.Helper() + home, _ := os.UserHomeDir() + recPath := filepath.Join(home, ".browsii", "recordings", name+".json") + data, err := os.ReadFile(recPath) + require.NoError(t, err) + var rec struct { + Name string `json:"name"` + URL string `json:"url"` + Events []map[string]any `json:"events"` + } + require.NoError(t, json.Unmarshal(data, &rec)) + rec.URL = toURL + for _, ev := range rec.Events { + if ev["action"] == "navigate" { + params := ev["params"].(map[string]any) + params["url"] = toURL + } + } + fixed, err := json.Marshal(rec) + require.NoError(t, err) + require.NoError(t, os.WriteFile(recPath, fixed, 0644)) +} + +func TestReplay_JsAndTypeRegressions(t *testing.T) { + server := setupShopServer() + defer server.Close() + + port := nextPort() + bin, cleanup := startDaemon(t, port) + defer cleanup() + + runCLI(t, bin, port, "record", "start", "shop-js") + runCLI(t, bin, port, "navigate", server.URL) + runCLI(t, bin, port, "js", "document.title") + runCLI(t, bin, port, "record", "stop") + + runCLI(t, bin, port, "session", "new", "fresh") + out, err := replayCLI(t, bin, port, "shop-js") + require.NoError(t, err, out) +} + +func TestReplay_SemanticChangeFailsAtTheRightStep(t *testing.T) { + server := setupShopServer() + defer server.Close() + + port := nextPort() + bin, cleanup := startDaemon(t, port) + defer cleanup() + + // Record a flow that ends by clicking checkout. + runCLI(t, bin, port, "record", "start", "shop-broken") + runCLI(t, bin, port, "navigate", server.URL+"?variant=canonical") + runCLI(t, bin, port, "click", "#p1-add") + runCLI(t, bin, port, "click", "#checkout") + runCLI(t, bin, port, "record", "stop") + + retargetRecording(t, "shop-broken", server.URL+"?variant=broken") + + runCLI(t, bin, port, "session", "new", "fresh") + out, err := replayCLI(t, bin, port, "shop-broken", "--live") + require.Error(t, err, "replay against broken UI must fail") + assert.Contains(t, out, "no longer matches") + assert.Contains(t, out, `"Checkout"`) + assert.Contains(t, out, "step 3") + + // Earlier steps executed. + jsOut := runCLI(t, bin, port, "js", "() => window.cartCount") + assert.Contains(t, jsOut, "1") +} + +func TestReplay_CheckpointCatchesWrongOutcome(t *testing.T) { + server := setupShopServer() + defer server.Close() + + port := nextPort() + bin, cleanup := startDaemon(t, port) + defer cleanup() + + recordFlow(t, bin, port, server.URL+"?variant=canonical", "shop-checkpoint") + + // Corrupt the recorded checkpoint: expect a total the server never emits. + home, _ := os.UserHomeDir() + recPath := filepath.Join(home, ".browsii", "recordings", "shop-checkpoint.json") + data, err := os.ReadFile(recPath) + require.NoError(t, err) + var rec struct { + Name string `json:"name"` + URL string `json:"url"` + Events []map[string]any `json:"events"` + } + require.NoError(t, json.Unmarshal(data, &rec)) + for _, ev := range rec.Events { + if ev["action"] == "expect" { + params := ev["params"].(map[string]any) + params["text"] = "Total: $999" + } + } + fixed, err := json.Marshal(rec) + require.NoError(t, err) + require.NoError(t, os.WriteFile(recPath, fixed, 0644)) + + runCLI(t, bin, port, "session", "new", "fresh") + out, err := replayCLI(t, bin, port, "shop-checkpoint", "--live") + require.Error(t, err, "wrong outcome must fail the replay") + assert.Contains(t, out, "checkpoint failed") + assert.Contains(t, out, "Total: $999") + + // The action itself succeeded — only the checkpoint failed. + jsOut := runCLI(t, bin, port, "js", "() => window.cartCount") + assert.Contains(t, jsOut, "1") +} + +func TestReplay_OfflineWithHAR(t *testing.T) { + server := setupShopServer() + defer server.Close() + + port := nextPort() + bin, cleanup := startDaemon(t, port) + defer cleanup() + + name := "shop-har" + runCLI(t, bin, port, "record", "start", name, "--capture-har") + runCLI(t, bin, port, "navigate", server.URL) + runCLI(t, bin, port, "click", "#p1-add") + runCLI(t, bin, port, "expect", "--text", "Total: $30") + runCLI(t, bin, port, "record", "stop") + + home, _ := os.UserHomeDir() + recData, err := os.ReadFile(filepath.Join(home, ".browsii", "recordings", name+".json")) + require.NoError(t, err) + var rec struct { + HAR string `json:"har"` + } + require.NoError(t, json.Unmarshal(recData, &rec)) + require.NotEmpty(t, rec.HAR, "recording must reference its HAR") + _, err = os.Stat(rec.HAR) + require.NoError(t, err, "HAR file must exist") + + // Kill the server, then replay: everything must be served from the HAR. + server.Close() + runCLI(t, bin, port, "session", "new", "fresh") + out, err := replayCLI(t, bin, port, name) + require.NoError(t, err, "replay must succeed offline: %s", out) + assert.Contains(t, out, "checkpoints passed") + + // --live against a dead server fails with a clear error. + runCLI(t, bin, port, "session", "new", "fresh") + out, err = replayCLI(t, bin, port, name, "--live") + require.Error(t, err, "--live must hit the network") + assert.True(t, strings.Contains(out, "failed") || strings.Contains(out, "error"), out) +} + +func TestReplay_SessionPinning(t *testing.T) { + mux := setupMemberServer() + server := mux + defer server.Close() + + port := nextPort() + bin, cleanup := startDaemon(t, port) + defer cleanup() + + // Save the logged-in session. + runCLI(t, bin, port, "navigate", server.URL+"/login") + runCLI(t, bin, port, "session", "save", "member") + + // Record the member flow. + runCLI(t, bin, port, "record", "start", "shop-member") + runCLI(t, bin, port, "navigate", server.URL+"/prices") + runCLI(t, bin, port, "expect", "--text", "member price") + runCLI(t, bin, port, "record", "stop") + + // Fresh daemon state loses the cookie; --session restores it. + runCLI(t, bin, port, "session", "new", "fresh") + out, err := replayCLI(t, bin, port, "shop-member", "--session", "member", "--live") + require.NoError(t, err, "replay with session must pass: %s", out) + assert.Contains(t, out, "checkpoints passed") +} + +// setupMemberServer gates content behind a cookie. +func setupMemberServer() *httptest.Server { + mux := http.NewServeMux() + mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Set-Cookie", "member=1; Path=/") + fmt.Fprint(w, `logged in`) //nolint:errcheck + }) + mux.HandleFunc("/prices", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if c, err := r.Cookie("member"); err == nil && c.Value == "1" { + fmt.Fprint(w, `

member price: $8

`) //nolint:errcheck + return + } + fmt.Fprint(w, `

guest price: $10

`) //nolint:errcheck + }) + return httptest.NewServer(mux) +} + +func TestReplay_ReportShape(t *testing.T) { + server := setupShopServer() + defer server.Close() + + port := nextPort() + bin, cleanup := startDaemon(t, port) + defer cleanup() + + recordFlow(t, bin, port, server.URL, "shop-report") + + runCLI(t, bin, port, "session", "new", "fresh") + out, err := replayCLI(t, bin, port, "shop-report") + require.NoError(t, err) + + // The human line mirrors the JSON report fields (steps, checkpoints, + // duration); the full shape is asserted in the daemon unit tests. + assert.Contains(t, out, "Replayed") + assert.Contains(t, out, "1/1 checkpoints passed") +} + +func TestExport_WritesPlayableSpec(t *testing.T) { + server := setupShopServer() + defer server.Close() + + port := nextPort() + bin, cleanup := startDaemon(t, port) + defer cleanup() + + rec := recordFlow(t, bin, port, server.URL, "shop-export") + _ = rec + + out := runCLI(t, bin, port, "record", "export", "shop-export") + assert.Contains(t, out, "Wrote") + + home, _ := os.UserHomeDir() + specPath := filepath.Join(home, ".browsii", "recordings", "shop-export.spec.ts") + data, err := os.ReadFile(specPath) + require.NoError(t, err) + spec := string(data) + + assert.Contains(t, spec, `import { test, expect } from '@playwright/test'`) + assert.Contains(t, spec, `page.goto(`) + assert.Contains(t, spec, `getByRole("button", { name: "Add to cart" })`) + assert.Contains(t, spec, "#p2-add") + assert.Contains(t, spec, `getByText("Total: $30")`) +} diff --git a/tests/shop_fixture_test.go b/tests/shop_fixture_test.go new file mode 100644 index 0000000..68d95b9 --- /dev/null +++ b/tests/shop_fixture_test.go @@ -0,0 +1,89 @@ +package tests + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" +) + +// setupShopServer serves a shop page in four variants for replay tests: +// +// canonical — stable layout +// drifted — same elements with different ids, classes, nesting, and order +// broken — the checkout button replaced by a differently-labelled one +// wrong-total — the API returns a different total +// +// The drifted variant keeps every element's tag, role, text, name, and href +// identical so replay must relocate elements by fingerprint rather than +// selector. +func setupShopServer() *httptest.Server { + mux := http.NewServeMux() + + mux.HandleFunc("/api/total", func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.Referer(), "variant=wrong-total") { + fmt.Fprint(w, `{"total":"$300"}`) //nolint:errcheck + return + } + fmt.Fprint(w, `{"total":"$30"}`) //nolint:errcheck + }) + + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + variant := r.URL.Query().Get("variant") + + var b strings.Builder + b.WriteString(`Shop`) + + type product struct{ id, name, price string } + products := []product{ + {"p1", "Widget", "$10"}, + {"p2", "Gadget", "$20"}, + {"p3", "Gizmo", "$30"}, + } + + if variant == "drifted" { + b.WriteString(`
`) + for _, p := range []product{products[2], products[0], products[1]} { + fmt.Fprintf(&b, `
%s%s
`, + p.id, p.name, p.price) + } + b.WriteString(`
`) + } else { + b.WriteString(`
`) + for _, p := range products { + fmt.Fprintf(&b, `
%s%s
`, + p.id, p.name, p.price, p.id) + } + b.WriteString(`
`) + } + + if variant == "broken" { + b.WriteString(``) + } + + b.WriteString(``) + + fmt.Fprint(w, b.String()) //nolint:errcheck + }) + return httptest.NewServer(mux) +}