Created
February 20, 2026 19:16
-
-
Save ewestern/48e48d6a32e98b175a2d260825020682 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| ## Browser Monitor DSL Design | |
| ### Design Principles | |
| 1. **One new node type** (`BROWSER`) alongside existing `HTTP_REQUEST`, `WAIT`, `ASSERTION` | |
| 2. **Declarative steps** that serialize to JSON (just like HTTP nodes) — no opaque functions | |
| 3. **Reuse everything**: `$variable`, `$secret`, `$template`, `Assert()`, state proxy, notifications, frequency — all work unchanged | |
| 4. **A browser node = a session** (persistent context across steps, like how a request node = one HTTP round-trip) | |
| ### What it looks like | |
| ```typescript | |
| // __griffin__/login-flow.ts | |
| import { | |
| createMonitorBuilder, | |
| Frequency, Assert, variable, secret, template, notify, | |
| // New exports: | |
| BrowserAction, navigate, click, fill, | |
| waitForSelector, extractText, screenshot, | |
| } from "@griffin-app/griffin-core"; | |
| const monitor = createMonitorBuilder({ | |
| name: "login-flow", | |
| frequency: Frequency.every(5).minutes(), | |
| notifications: [notify.onFailure().toSlack("#alerts")], | |
| }) | |
| .browser( | |
| "login", | |
| BrowserAction({ | |
| steps: [ | |
| navigate(variable("APP_URL")), | |
| fill("[data-testid='email']", "test@example.com"), | |
| fill("[data-testid='password']", secret("TEST_PASSWORD")), | |
| click("[data-testid='submit']"), | |
| waitForSelector("[data-testid='dashboard']"), | |
| screenshot(), | |
| ], | |
| }), | |
| ) | |
| .assert((state) => [ | |
| Assert(state["login"].page.url).contains("/dashboard"), | |
| Assert(state["login"].page.title).equals("Dashboard"), | |
| Assert(state["login"].duration).lessThan(5000), | |
| ]) | |
| .build(); | |
| export default monitor; | |
| ``` | |
| ### The step primitives (minimal set) | |
| | Helper | Serialized action | Purpose | | |
| |---|---|---| | |
| | `navigate(url)` | `{ action: "navigate", url }` | Go to URL | | |
| | `click(selector)` | `{ action: "click", selector }` | Click element | | |
| | `fill(selector, value)` | `{ action: "fill", selector, value }` | Type into input | | |
| | `waitForSelector(selector)` | `{ action: "wait_for_selector", selector }` | Wait for element | | |
| | `extractText(name, selector)` | `{ action: "extract_text", name, selector }` | Capture text for assertions | | |
| | `screenshot()` | `{ action: "screenshot" }` | Capture screenshot artifact | | |
| Each helper is just a function returning a plain object — like `HttpRequest()` today. String arguments accept the same `StringSchema` union (`$literal`, `$variable`, `$secret`, `$template`). | |
| ### Extracted values & state proxy | |
| The browser state proxy exposes: | |
| ```typescript | |
| state["login"].page.url // final page URL | |
| state["login"].page.title // final page title | |
| state["login"].duration // total ms | |
| state["login"].console.errors // array of console error strings | |
| state["login"].extracts["name"] // text captured by extractText("name", sel) | |
| ``` | |
| This means `extractText` is the browser equivalent of an HTTP response body — it's how you pull data out for assertions: | |
| ```typescript | |
| .browser("check_profile", BrowserAction({ | |
| steps: [ | |
| navigate(template`${variable("APP_URL")}/profile`), | |
| waitForSelector(".username"), | |
| extractText("username", ".username"), | |
| extractText("plan", ".billing-plan"), | |
| ], | |
| })) | |
| .assert((state) => [ | |
| Assert(state["check_profile"].extracts["username"]).equals("testuser"), | |
| Assert(state["check_profile"].extracts["plan"]).not.equals("expired"), | |
| Assert(state["check_profile"].console.errors).isEmpty(), | |
| ]) | |
| ``` | |
| ### Interop with HTTP nodes | |
| Browser and HTTP nodes live in the same graph, so you can mix them freely: | |
| ```typescript | |
| // Create resource via API, verify it shows up in the browser | |
| createMonitorBuilder({ name: "create-and-verify", frequency: Frequency.every(10).minutes() }) | |
| .request("create_item", HttpRequest({ | |
| method: POST, | |
| base: variable("API_URL"), | |
| path: "/items", | |
| response_format: Json, | |
| headers: { Authorization: template`Bearer ${secret("API_KEY")}` }, | |
| body: { name: "test-item" }, | |
| })) | |
| .assert((state) => [Assert(state["create_item"].status).equals(201)]) | |
| .browser("verify_in_ui", BrowserAction({ | |
| steps: [ | |
| navigate(template`${variable("APP_URL")}/items`), | |
| waitForSelector(".item-list"), | |
| extractText("item_name", ".item-list .item:first-child .name"), | |
| ], | |
| })) | |
| .assert((state) => [ | |
| Assert(state["verify_in_ui"].extracts["item_name"]).equals("test-item"), | |
| ]) | |
| .build(); | |
| ``` | |
| ### Serialized JSON (schema level) | |
| A browser node serializes identically to other nodes — just a different `type` discriminant: | |
| ```json | |
| { | |
| "id": "login", | |
| "type": "BROWSER", | |
| "viewport": { "width": 1280, "height": 720 }, | |
| "steps": [ | |
| { "action": "navigate", "url": { "$variable": { "key": "APP_URL" } } }, | |
| { "action": "fill", "selector": "[data-testid='email']", | |
| "value": { "$literal": "test@example.com" } }, | |
| { "action": "fill", "selector": "[data-testid='password']", | |
| "value": { "$secret": { "ref": "TEST_PASSWORD" } } }, | |
| { "action": "click", "selector": "[data-testid='submit']" }, | |
| { "action": "wait_for_selector", "selector": "[data-testid='dashboard']" }, | |
| { "action": "screenshot" } | |
| ] | |
| } | |
| ``` | |
| ### What stays unchanged | |
| - `createMonitorBuilder` / `createGraphBuilder` — just add `.browser()` method | |
| - `Frequency`, `notify`, `secret`, `variable`, `template` — zero changes | |
| - `Assert()` — zero changes (the state proxy just gets new shapes) | |
| - Schema `version: "1.0"` — `NodeDSL` union gets a new member | |
| - Discovery, variable resolution, CLI — all unchanged | |
| - Edges — browser nodes connect the same way as any other node | |
| ### What's new | |
| | Layer | Addition | | |
| |---|---| | |
| | `griffin-core` | `BrowserAction()` factory, 6 step helpers, `BROWSER` node schema, state proxy browser shape | | |
| | `griffin-executor` | Browser node handler (launches Playwright, runs steps, captures state) | | |
| | Hub/infra | Executor environments need Playwright installed | | |
| ### Options left intentionally out (for now) | |
| - **Viewport** — defaults to 1280x720, optional override on `BrowserAction({ viewport })` | |
| - **Auth/cookies** — handle via `fill` + `click` on login forms, or add `setCookie()` step later | |
| - **Multi-page/tab** — not needed for v1 | |
| - **Network interception** — future concern | |
| - **Custom timeouts per step** — could add `{ timeout: 10000 }` options bag to any step later | |
| --- | |
| The total DSL surface added is one factory (`BrowserAction`) and ~6 step helpers. Everything else — the builder, assertions, secrets, variables, notifications, graph model — stays exactly the same. The key insight is that `extractText` is to browser nodes what response bodies are to HTTP nodes: it's the bridge into the assertion system. |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment