| 1 | //go:build browser |
| 2 | |
| 3 | // The browser drive: the whole app in a real Chromium, wired exactly as |
| 4 | // cmd/calendar/main.go wires it — loud on any console error, failed |
| 5 | // request, 4xx/5xx, or template value that leaked to the screen |
| 6 | // unrendered. Not part of the plain suite: |
| 7 | // |
| 8 | // go test -tags browser ./... |
| 9 | // |
| 10 | // It needs a Chromium: on PATH, via RASTRILLO_CHROME, or in a |
| 11 | // Playwright cache. A skip is not a pass — with no browser this fails, |
| 12 | // unless RASTRILLO_BROWSER_OPTIONAL is set, which makes the skip a |
| 13 | // deliberate visible choice rather than an accident. |
| 14 | // |
| 15 | // This gate exists because every other test in this repo lives inside |
| 16 | // one layer and agrees with itself: the Go suite asserts on strings the |
| 17 | // Go code produced. Sheets' rule is that at least one gate must cross |
| 18 | // from a computed value to painted output, and this is that crossing — |
| 19 | // a server-side packer emitting inline percentages is exactly the kind |
| 20 | // of thing that can be arithmetically perfect and visually wrong. |
| 21 | package calendartest |
| 22 | |
| 23 | import ( |
| 24 | "io" |
| 25 | "log/slog" |
| 26 | "net/http" |
| 27 | "path/filepath" |
| 28 | "strings" |
| 29 | "testing" |
| 30 | |
| 31 | "amadan.net/rastrillo/rastrillo/harness" |
| 32 | "github.com/chromedp/chromedp" |
| 33 | |
| 34 | calendar "amadan.net/oficina/calendar/internal/calendar" |
| 35 | ) |
| 36 | |
| 37 | // TestBrowserWalk drives the real screens in a real browser: sign in, |
| 38 | // create an event through the form, and look at every view. |
| 39 | func TestBrowserWalk(t *testing.T) { |
| 40 | // The rig builds the app inside its own closure, because only it |
| 41 | // knows the localhost origin it will serve on — and the origin is |
| 42 | // load-bearing: it is the CSRF yardstick and the base of the |
| 43 | // emailed sign-in link, so a guessed one breaks both. The handle |
| 44 | // is captured on the way past so the test can read the mail |
| 45 | // recorder afterwards. |
| 46 | var app *calendar.App |
| 47 | rig := harness.New(t, func(origin string) http.Handler { |
| 48 | a, err := calendar.New(calendar.Config{ |
| 49 | DBPath: filepath.Join(t.TempDir(), "calendar.db"), |
| 50 | Env: "development", |
| 51 | Origin: origin, |
| 52 | Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| 53 | }) |
| 54 | if err != nil { |
| 55 | t.Fatalf("calendar.New: %v", err) |
| 56 | } |
| 57 | t.Cleanup(func() { a.Close() }) |
| 58 | app = a |
| 59 | return a.Handler() |
| 60 | }) |
| 61 | |
| 62 | // The sign-in page, signed out. |
| 63 | rig.Run(chromedp.Navigate(rig.Origin + "/signin")) |
| 64 | rig.Screen("body", "signin") |
| 65 | |
| 66 | // Sign in through the actual form, then follow the emailed link — |
| 67 | // the same flow a person completes, rather than a forged cookie. |
| 68 | rig.Run( |
| 69 | chromedp.SendKeys(`input[name="address"]`, "someone@example.com", chromedp.ByQuery), |
| 70 | chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), |
| 71 | chromedp.WaitVisible("body", chromedp.ByQuery), |
| 72 | ) |
| 73 | // The link opens the confirm door; its one Sign in button signs in. |
| 74 | link := signInLinkFrom(t, app, rig.Origin) |
| 75 | rig.Run( |
| 76 | chromedp.Navigate(link), |
| 77 | chromedp.WaitVisible(`input[name="token"] ~ div button[type="submit"]`, chromedp.ByQuery), |
| 78 | chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), |
| 79 | chromedp.WaitVisible("body", chromedp.ByQuery), |
| 80 | ) |
| 81 | rig.Screen("body", "signed in") |
| 82 | |
| 83 | // Create an event through the form. |
| 84 | rig.Run( |
| 85 | chromedp.Navigate(rig.Origin+"/events/new?start=2026-09-02T09:30&end=2026-09-02T10:00"), |
| 86 | chromedp.WaitVisible(`input[name="title"]`, chromedp.ByQuery), |
| 87 | chromedp.SendKeys(`input[name="title"]`, "Standup", chromedp.ByQuery), |
| 88 | chromedp.Click(`button[type="submit"]`, chromedp.ByQuery), |
| 89 | chromedp.WaitVisible("body", chromedp.ByQuery), |
| 90 | ) |
| 91 | rig.Screen("body", "the event's own page") |
| 92 | |
| 93 | // Every view, painted. |
| 94 | for _, page := range []struct{ path, note string }{ |
| 95 | {"/day/2026-09-02", "day"}, |
| 96 | {"/week/2026-09-02", "week"}, |
| 97 | {"/month/2026-09", "month"}, |
| 98 | {"/agenda?from=2026-09-02", "agenda"}, |
| 99 | {"/calendars", "calendars"}, |
| 100 | {"/settings", "settings"}, |
| 101 | {"/import", "import"}, |
| 102 | } { |
| 103 | rig.Run( |
| 104 | chromedp.Navigate(rig.Origin+page.path), |
| 105 | chromedp.WaitVisible("body", chromedp.ByQuery), |
| 106 | ) |
| 107 | rig.Screen("body", page.note) |
| 108 | } |
| 109 | |
| 110 | // The crossing that matters: the server packed this chip and gave |
| 111 | // it a height in percent. Ask the BROWSER what it actually |
| 112 | // measured. A packer that emitted 0%, or a stylesheet that never |
| 113 | // loaded, produces a chip with no height — arithmetically fine and |
| 114 | // invisible on screen. |
| 115 | var height float64 |
| 116 | rig.Run( |
| 117 | chromedp.Navigate(rig.Origin+"/day/2026-09-02"), |
| 118 | chromedp.WaitVisible("[cal-placed]", chromedp.ByQuery), |
| 119 | chromedp.Evaluate(`document.querySelector('[cal-placed]').getBoundingClientRect().height`, &height), |
| 120 | ) |
| 121 | if height < 8 { |
| 122 | t.Errorf("the packed event painted %vpx tall — the server's percentages are not reaching the screen", height) |
| 123 | } |
| 124 | |
| 125 | // And the slot links are real, clickable targets rather than |
| 126 | // zero-size boxes. |
| 127 | var slotHeight float64 |
| 128 | rig.Run(chromedp.Evaluate( |
| 129 | `document.querySelector('[cal-slot]').getBoundingClientRect().height`, &slotHeight)) |
| 130 | if slotHeight < 8 { |
| 131 | t.Errorf("a half-hour slot link painted %vpx tall; it cannot be clicked", slotHeight) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | func signInLinkFrom(t *testing.T, app *calendar.App, origin string) string { |
| 136 | t.Helper() |
| 137 | body, err := app.LastMailForTest() |
| 138 | if err != nil { |
| 139 | t.Fatalf("LastMailForTest: %v", err) |
| 140 | } |
| 141 | i := strings.Index(body, origin+"/auth/verify?token=") |
| 142 | if i < 0 { |
| 143 | t.Fatalf("no sign-in link for %s in:\n%s", origin, body) |
| 144 | } |
| 145 | rest := body[i:] |
| 146 | if j := strings.IndexAny(rest, " \r\n\t"); j >= 0 { |
| 147 | rest = rest[:j] |
| 148 | } |
| 149 | return rest |
| 150 | } |
| 151 | |