Skip to content

Instantly share code, notes, and snippets.

@ewestern
Created February 20, 2026 19:16
Show Gist options
  • Select an option

  • Save ewestern/48e48d6a32e98b175a2d260825020682 to your computer and use it in GitHub Desktop.

Select an option

Save ewestern/48e48d6a32e98b175a2d260825020682 to your computer and use it in GitHub Desktop.
## 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