Skip to content

Commit 11f110f

Browse files
JPeer264claude
andcommitted
test(cloudflare): Add E2E test that deploys a real Worker and sends to Sentry
Local wrangler cannot reproduce what the platform does around waitUntil, isolate reuse and Durable Object hibernation. The new optional E2E app builds with the Sentry Vite plugin, deploys one Worker per ref in CI, verifies errors and request spans in Sentry through the CLI helpers, and deletes PR Workers through a new workflow when the PR closes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent e333c07 commit 11f110f

17 files changed

Lines changed: 355 additions & 0 deletions

File tree

‎.github/workflows/build.yml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,6 +1149,9 @@ jobs:
11491149
REACT_APP_E2E_TEST_DSN: ${{ secrets.E2E_TEST_DSN }}
11501150
E2E_TEST_SENTRY_ORG_SLUG: 'sentry-javascript-sdks'
11511151
E2E_TEST_SENTRY_PROJECT: 'sentry-javascript-e2e-tests'
1152+
# Used by test apps that deploy a real Cloudflare Worker, e.g. cloudflare-workers-send-to-sentry
1153+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
1154+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
11521155
strategy:
11531156
fail-fast: false
11541157
matrix: ${{ fromJson(needs.job_build.outputs.e2e-matrix-optional) }}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: 'Automation: Cleanup E2E workers'
2+
on:
3+
pull_request:
4+
types:
5+
- closed
6+
7+
jobs:
8+
cleanup:
9+
# The optional E2E job deploys only for PRs from this repository, so forks never have a worker to delete.
10+
if: github.event.pull_request.head.repo.full_name == github.repository
11+
runs-on: ubuntu-latest
12+
permissions: {}
13+
timeout-minutes: 5
14+
env:
15+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
16+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
17+
strategy:
18+
matrix:
19+
# Name prefix of every E2E app that deploys a real worker, see the app's global-setup.mjs
20+
worker-prefix:
21+
- e2e-send-to-sentry
22+
steps:
23+
- name: Set up Node
24+
uses: actions/setup-node@v7
25+
with:
26+
node-version: 24
27+
28+
- name: Delete worker
29+
run: |
30+
WORKER="${{ matrix.worker-prefix }}-pr-${{ github.event.pull_request.number }}"
31+
32+
if ! output=$(npx --yes wrangler@4 delete --name "$WORKER" --force 2>&1); then
33+
echo "$output"
34+
# 10007 means the worker does not exist, i.e. the PR never ran the optional E2E job.
35+
echo "$output" | grep -q 'code: 10007' || exit 1
36+
fi

‎dev-packages/e2e-tests/.env.example‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,9 @@ E2E_TEST_SENTRY_ORG_SLUG=
1111

1212
# A Sentry project slug
1313
E2E_TEST_SENTRY_PROJECT=
14+
15+
# Cloudflare credentials for E2E tests that deploy a real Worker (e.g. cloudflare-workers-send-to-sentry).
16+
# The API token needs "Workers Scripts: Edit" on the account; "Workers KV Storage: Read" additionally silences a
17+
# warning when a worker is deleted. Leave it empty to use a `wrangler login` session instead.
18+
CLOUDFLARE_API_TOKEN=
19+
CLOUDFLARE_ACCOUNT_ID=

‎dev-packages/e2e-tests/README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ current state.
1818
- Copy `.env.example` to `.env`
1919
- OPTIONAL: Fill in auth information in `.env` for an example Sentry project - you only need this to run E2E tests that
2020
send data to Sentry.
21+
- OPTIONAL: Fill in the Cloudflare credentials in `.env` - you only need this to run E2E tests that deploy a real
22+
Cloudflare Worker (e.g. `cloudflare-workers-send-to-sentry`). A local run deploys a throwaway worker and deletes it
23+
again afterwards; set `E2E_KEEP_WORKER=1` to keep it for debugging. CI keeps one worker per branch or PR instead, and
24+
PR workers are deleted by the `cleanup-e2e-workers` workflow when the PR closes.
2125
- Run `yarn build:tarball` in the root of the repository (needs to be rerun after every update in /packages for the
2226
changes to have effect on the tests).
2327

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
dist
2+
.wrangler
3+
node_modules
4+
test-results
5+
pnpm-lock.yaml
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { execFileSync } from 'node:child_process';
2+
import { dirname } from 'node:path';
3+
import { fileURLToPath } from 'node:url';
4+
5+
const __dirname = dirname(fileURLToPath(import.meta.url));
6+
7+
function wrangler(args) {
8+
const output = execFileSync('pnpm', ['exec', 'wrangler', ...args], {
9+
cwd: __dirname,
10+
encoding: 'utf8',
11+
stdio: ['ignore', 'pipe', 'inherit'],
12+
});
13+
console.log(output);
14+
return output;
15+
}
16+
17+
/** Deploys the worker under `name` and returns its workers.dev URL. */
18+
export function deployWorker(name, dsn) {
19+
const output = wrangler(['deploy', '--name', name, '--var', `E2E_TEST_DSN:${dsn}`]);
20+
const url = output.match(/https:\/\/\S+\.workers\.dev/)?.[0];
21+
22+
if (!url) {
23+
throw new Error(`Could not find the workers.dev URL in the wrangler deploy output for ${name}.`);
24+
}
25+
26+
return url;
27+
}
28+
29+
export function deleteWorker(name) {
30+
wrangler(['delete', '--name', name, '--force']);
31+
}
32+
33+
/**
34+
* CI keeps its Workers: one per ref, overwritten by the next run of the same ref and deleted by the
35+
* cleanup workflow once a PR closes. Local runs delete theirs unless `E2E_KEEP_WORKER` is set.
36+
*/
37+
export function keepsWorker() {
38+
return Boolean(process.env.GITHUB_ACTIONS || process.env.E2E_KEEP_WORKER);
39+
}
40+
41+
/** A freshly created workers.dev route can take a moment to become reachable. */
42+
export async function waitForWorker(url) {
43+
const deadline = Date.now() + 60_000;
44+
45+
while (Date.now() < deadline) {
46+
try {
47+
// The SDK does not trace HEAD requests, so the probe leaves no spans behind in Sentry.
48+
const response = await fetch(url, { method: 'HEAD' });
49+
50+
if (response.ok) {
51+
return;
52+
}
53+
} catch {
54+
// DNS for the new subdomain may not have propagated yet.
55+
}
56+
57+
await new Promise(resolve => setTimeout(resolve, 2_000));
58+
}
59+
60+
throw new Error(`Worker at ${url} did not become reachable within 60s.`);
61+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { randomBytes } from 'node:crypto';
2+
import { deleteWorker, deployWorker, keepsWorker, waitForWorker } from './deployed-worker.mjs';
3+
4+
const WORKER_PREFIX = 'e2e-send-to-sentry';
5+
6+
/**
7+
* In CI the name follows the ref, so `develop`, `master` and every PR get a stable Worker that the
8+
* next run of the same ref overwrites. Pull request refs look like `123/merge` and merge queue refs
9+
* like `gh-readonly-queue/<base>/pr-123-<sha>`; both map to the PR's Worker.
10+
*/
11+
export function getWorkerName() {
12+
if (!process.env.GITHUB_ACTIONS) {
13+
return `${WORKER_PREFIX}-local-${randomBytes(3).toString('hex')}`;
14+
}
15+
16+
const { GITHUB_EVENT_NAME, GITHUB_REF_NAME = '' } = process.env;
17+
const prNumber =
18+
GITHUB_EVENT_NAME === 'pull_request' ? GITHUB_REF_NAME.split('/')[0] : /\/pr-(\d+)-/.exec(GITHUB_REF_NAME)?.[1];
19+
const ref = prNumber ? `pr-${prNumber}` : GITHUB_REF_NAME;
20+
// Worker names allow lowercase alphanumerics and dashes only, up to 63 characters.
21+
const slug = ref.toLowerCase().replace(/[^a-z0-9]+/g, '-');
22+
23+
return `${WORKER_PREFIX}-${slug}`.slice(0, 63).replace(/-+$/, '');
24+
}
25+
26+
export default async function globalSetup() {
27+
const { CLOUDFLARE_ACCOUNT_ID, E2E_TEST_DSN } = process.env;
28+
29+
// Wrangler authenticates with `CLOUDFLARE_API_TOKEN` (CI) or a `wrangler login` session (local),
30+
// but it cannot pick an account on its own outside of a terminal.
31+
if (!CLOUDFLARE_ACCOUNT_ID) {
32+
throw new Error('CLOUDFLARE_ACCOUNT_ID must be set to deploy the test worker.');
33+
}
34+
35+
const workerName = getWorkerName();
36+
const workerUrl = deployWorker(workerName, E2E_TEST_DSN);
37+
process.env.E2E_TEST_WORKER_NAME = workerName;
38+
39+
try {
40+
await waitForWorker(workerUrl);
41+
} catch (error) {
42+
if (!keepsWorker()) {
43+
try {
44+
deleteWorker(workerName);
45+
} catch (deleteError) {
46+
// The unreachable worker is the failure to report, not the cleanup.
47+
console.error(`Failed to delete worker ${workerName}:`, deleteError);
48+
}
49+
}
50+
throw error;
51+
}
52+
53+
process.env.E2E_TEST_WORKER_URL = workerUrl;
54+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { deleteWorker, keepsWorker } from './deployed-worker.mjs';
2+
3+
export default function globalTeardown() {
4+
const workerName = process.env.E2E_TEST_WORKER_NAME;
5+
6+
if (!workerName) {
7+
return;
8+
}
9+
10+
if (keepsWorker()) {
11+
console.log(`Keeping worker ${workerName} at ${process.env.E2E_TEST_WORKER_URL}`);
12+
return;
13+
}
14+
15+
deleteWorker(workerName);
16+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"name": "cloudflare-workers-send-to-sentry",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"build": "vite build",
8+
"typecheck": "tsc --noEmit",
9+
"test": "playwright test",
10+
"clean": "npx rimraf node_modules pnpm-lock.yaml",
11+
"test:build": "pnpm install && pnpm build",
12+
"test:assert": "pnpm typecheck && pnpm test"
13+
},
14+
"dependencies": {
15+
"@sentry/cloudflare": "file:../../packed/sentry-cloudflare-packed.tgz"
16+
},
17+
"devDependencies": {
18+
"@cloudflare/vite-plugin": "^1.47.0",
19+
"@cloudflare/workers-types": "^5.20260727.1",
20+
"@playwright/test": "~1.56.0",
21+
"@sentry-internal/test-utils": "link:../../../test-utils",
22+
"@types/node": "^26.1.2",
23+
"sentry": "~0.44.1",
24+
"typescript": "~6.0.3",
25+
"vite": "^8.1.5",
26+
"wrangler": "^4.114.0"
27+
},
28+
"volta": {
29+
"node": "24.15.0",
30+
"extends": "../../package.json"
31+
},
32+
"sentryTest": {
33+
"optional": true
34+
}
35+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { defineConfig } from '@playwright/test';
2+
3+
export default defineConfig({
4+
testDir: './tests',
5+
// The worker is deployed once for the whole run and deleted again afterwards.
6+
globalSetup: './global-setup.mjs',
7+
globalTeardown: './global-teardown.mjs',
8+
/* Spans take ~2min to become queryable via the trace endpoint. */
9+
timeout: 210_000,
10+
fullyParallel: true,
11+
forbidOnly: !!process.env.CI,
12+
retries: 0,
13+
// Every test spends most of its time polling Sentry, so run them all at once.
14+
workers: '100%',
15+
reporter: process.env.CI ? [['list'], ['junit', { outputFile: 'results.junit.xml' }]] : 'list',
16+
});

0 commit comments

Comments
 (0)