CDP acceptance
Boot an app, drive it over the Chrome DevTools Protocol, and assert on network / console / heap observations using the `cdp-acceptance` run.
Fires on Every PR push — wire it any of these ways:
- Action recommended A ci.yml step dispatches the run — when it must interleave with other CI jobs or gate the PR.
- Webhook pattern The GitHub App webhook fires the run directly — no .github/workflows file, no GHA minutes. (needs app boot + target from config)
- Use case
- Boot an app, drive it over CDP, assert on observations
- FlareDispatch shape
- 1 typed run + dispatch
- Plain GHA shape
- 1 workflow · 1 job · 10+ steps with detach/wait/cleanup
Source
The recommended FlareDispatch shape is shown first. Toggle to Without FlareDispatch to see the full GitHub Actions workflow a team would maintain to do the same job without it.
Action mode — triggered by a GitHub Actions workflow that calls the shipped run.
# Recipe: CDP acceptance tests on Cloudflare
#
# Use case: acceptance tests that boot the app under test and drive it via
# the Chrome DevTools Protocol — asserting on network calls, console errors,
# document counts, heap deltas. Offload to the shipped `cdp-acceptance` run,
# which boots the app in a container and attaches Browser Rendering over CDP.
#
# Mode: Action mode, await — a follow-up deploy gate needs the result inline.
# See specs/04-gha-integration.md#await-sub-mode.
# Run: cdp-acceptance — see specs/02-runs.md#4-cdp-acceptance.
name: acceptance
on:
pull_request:
paths: ["apps/**"]
jobs:
cdp-acceptance:
runs-on: ubuntu-latest
steps:
- uses: openhackersclub/flare-dispatch-action@v1
with:
run: cdp-acceptance
endpoint: ${{ vars.FLAREDISPATCH_ENDPOINT }}
hmac-secret: ${{ secrets.FLAREDISPATCH_HMAC }}
mode: await
timeout: 30m # must be >= the cdp-acceptance run's maxDurationSec (1800s)
inputs: |
{
"repo": "${{ github.repository }}",
"sha": "${{ github.sha }}",
"appBootCommand": "pnpm dev",
"appPort": 4173,
"testCommand": "pnpm test:acceptance"
}
# In await mode the action mirrors the run's conclusion onto this GHA step,
# so a subsequent deploy job can `needs:` it. // Recipe: CDP acceptance tests — the `cdp-acceptance` Run
//
// The typed Run that ./ci.yml dispatches. Boots the app under test in a
// detached container, attaches Browser Rendering over the Chrome DevTools
// Protocol, runs the acceptance suite, and uploads screenshots + a trace as
// artifacts (see ./README.md — those can be attached to the PR).
//
// This recipe rides on two primitives — `workspace` (acquire + clone +
// cached install) and `bootApp` (detached run + wait-for-port) — so the file
// carries only the CDP-specific logic. See specs/03-dsl.md § Primitives.
//
// This recipe is the minimal illustration of the run shape. The shipped run is
// `runs/cdp-acceptance.ts` — it additionally injects credentials from the
// config store via the `loadSecrets` primitive (see its header). Recipe scope:
// specs/02-runs.md § 4. DSL: specs/03-dsl.md.
import { Effect, Schema } from "effect";
import { defineRun, step, sandbox, browser, artifact, io } from "@flare-dispatch/core";
import { workspace, bootApp } from "@flare-dispatch/core/primitives";
const Input = Schema.Struct({
repo: Schema.String,
sha: Schema.String,
appBootCommand: Schema.String, // e.g. "pnpm dev"
appPort: Schema.Number, // e.g. 4173
testCommand: Schema.String, // e.g. "pnpm test:acceptance"
});
const Output = Schema.Struct({
exitCode: Schema.Number,
reportUri: Schema.String, // HTML report
screenshotsUri: Schema.String, // screenshots + trace — attachable to the PR
});
export const cdpAcceptance = defineRun({
name: "cdp-acceptance",
version: "1.0.0",
inputs: Input,
outputs: Output,
limits: { maxDurationSec: 1800, requiresBrowser: true },
run: (input) =>
Effect.gen(function* () {
// Acquire a container, clone the target SHA, and install dependencies
// from the R2-backed cache — the whole checkout dance is one primitive.
const { container, dir } = yield* step("checkout", () =>
workspace({ repo: input.repo, sha: input.sha, install: true }),
);
// Boot the app in a detached container and block until its port opens.
yield* step("boot-app", () =>
bootApp({
container,
dir,
command: input.appBootCommand,
port: input.appPort,
timeoutSec: 120,
}),
);
// Expose the app port as a public preview URL — the browser runs in
// Cloudflare's cloud and cannot reach the container's `localhost`, so the
// suite navigates to this reachable URL (handed over as CDP_TARGET_URL).
const exposed = yield* step("expose-app", () =>
sandbox.exposePort({ container, port: input.appPort }),
);
// Attach Browser Rendering over CDP and run the acceptance suite. The
// suite drives the app and writes screenshots/traces under ./artifacts.
// Persist only the `wsEndpoint` string: a `CDPSession` carries a `close`
// Effect, which a CF Workflow `step` checkpoint cannot structured-clone
// (`DataCloneError`). See runs/cdp-acceptance.ts attach-cdp note.
const cdpWsUrl = yield* step("attach-cdp", () =>
browser
.newCDPSession({ targetUrl: exposed.url })
.pipe(Effect.map((session) => session.wsEndpoint)),
);
const exec = yield* step("run-tests", () =>
sandbox.exec({
cwd: dir,
container,
env: { CDP_WS_URL: cdpWsUrl, CDP_TARGET_URL: exposed.url },
command: input.testCommand,
}),
);
// Upload the report and the screenshots/trace bundle. Both come back as
// signed R2 URLs in the check-run summary; a developer can drop the
// screenshots or the demo recording straight into the PR.
const reportUri = yield* step("upload-report", () =>
artifact.upload({
name: "acceptance-report",
path: `${dir}/playwright-report/`,
container,
signedUrlTTL: "30 days",
}),
);
const screenshotsUri = yield* step("upload-screenshots", () =>
artifact.upload({
name: "screenshots",
path: `${dir}/artifacts/`,
container,
signedUrlTTL: "30 days",
}),
);
yield* io.log("info", `cdp-acceptance exited ${exec.exitCode}`);
return { exitCode: exec.exitCode, reportUri, screenshotsUri };
}),
});
A faithful, runnable GHA workflow — what a team actually
maintains to do this job without FlareDispatch.
# Recipe: CDP acceptance tests — BASELINE (without FlareDispatch)
#
# This is what you'd maintain on plain GitHub Actions to boot the app under
# test, drive it via the Chrome DevTools Protocol, and capture screenshots /
# trace artifacts that reviewers can drop into a PR. Shown here ONLY as a
# comparison — see ./cdp-acceptance.run.ts for the FlareDispatch version
# (~55 lines, two primitives: `workspace` + `bootApp`).
#
# Notable costs of the GHA-only path:
# - You have to background the app boot yourself (`pnpm dev &`) and then
# wait for the port with a hand-rolled retry loop. There's no `wait-on`
# analogue baked into the runner; `wait-on` itself works but is a brittle
# npm dependency to keep current.
# - The CDP endpoint your tests connect to is local (`ws://localhost:9222`)
# because GHA runners can't reach into Cloudflare Browser Rendering — so
# you launch Chromium on the runner itself, which means you can't run
# more than one acceptance suite concurrently per machine.
# - Screenshots and the Playwright trace are uploaded as `actions/upload-artifact`
# and surfaced only via the GHA Artifacts UI — there is no signed URL you
# can paste into the PR. Drag-and-drop into the PR description requires
# downloading the artifact bundle, unzipping, then re-uploading the file
# to GitHub. FlareDispatch hands the reviewer a signed R2 URL directly.
# - A follow-up deploy gate must `needs:` this entire job, and your test
# command must `exit 1` cleanly OR the job result lies.
name: cdp-acceptance-baseline
on:
pull_request:
paths: ["apps/**"]
jobs:
cdp-acceptance:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
APP_PORT: 4173
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Cache Playwright browsers
id: pw-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install Playwright + Chromium
if: steps.pw-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium
# Boot the app detached. nohup + & so the step can continue; logs go to
# a file so failures are debuggable.
- name: Boot app
run: |
mkdir -p ./artifacts
# CONFIGURE: your dev/start command.
nohup pnpm dev > ./artifacts/app.log 2>&1 &
echo $! > ./.app.pid
# Hand-rolled wait-for-port. The retry loop has to live in this YAML,
# because GHA has no first-class "block until TCP open" step.
- name: Wait for app to be ready
run: |
for i in $(seq 1 60); do
if curl -fsS -o /dev/null "http://localhost:${APP_PORT}/"; then
echo "App is up"
exit 0
fi
sleep 2
done
echo "::error::App never came up on port ${APP_PORT}"
cat ./artifacts/app.log || true
exit 1
# Launch a long-running Chromium with the DevTools port exposed, so the
# acceptance suite can connect over CDP. We have to background this too.
- name: Launch Chromium with CDP port
run: |
nohup pnpm exec chromium \
--remote-debugging-port=9222 \
--headless=new \
--no-sandbox \
"http://localhost:${APP_PORT}/" \
> ./artifacts/chromium.log 2>&1 &
echo $! > ./.chromium.pid
sleep 3
- name: Run acceptance suite over CDP
env:
CDP_WS_URL: ws://localhost:9222
# CONFIGURE: your acceptance test command. It must `exit 1` on
# failure for branch protection to mean anything.
run: pnpm test:acceptance
- name: Stop background processes
if: always()
run: |
kill "$(cat ./.app.pid 2>/dev/null || echo 0)" 2>/dev/null || true
kill "$(cat ./.chromium.pid 2>/dev/null || echo 0)" 2>/dev/null || true
- name: Upload HTML report
if: always()
uses: actions/upload-artifact@v4
with:
name: acceptance-report
path: playwright-report
retention-days: 30
- name: Upload screenshots + trace
if: always()
uses: actions/upload-artifact@v4
with:
name: screenshots
path: |
./artifacts
./test-results
retention-days: 30