Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 9 additions & 171 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,9 @@ name: E2E Tests
on:
workflow_call:
secrets:
PLAYWRIGHT_SERVICE_URL:
required: false
PLAYWRIGHT_SERVICE_ACCESS_TOKEN:
required: false
ENTERPRISE_LICENSE_KEY:
required: true
# A secret added here must also be named in pr.yml's e2e-test job: the caller does not
# `inherit`, so an unnamed optional secret arrives empty and a required one fails the call.
workflow_dispatch:
inputs:
force_mode:
description: "Playwright execution mode (auto = use the service when its secrets are set)"
type: choice
options:
- auto
- service
- local
default: auto

permissions:
contents: read
Expand All @@ -30,16 +15,10 @@ jobs:
build:
name: Run E2E Tests
runs-on: ubuntu-latest
# Chosen so the step timeouts below always fire FIRST, because a step timeout names
# the culprit while a job timeout just says "exceeded the maximum execution time" —
# the opaque symptom this workflow is being changed to eliminate.
#
# Measured fixed overhead outside the two capped steps is 122s (from the local-mode
# run 31169882088: 1009s total - 247s build - 640s tests), so the worst case where
# both step caps are hit is 122 + 720 + 900 = 1742s ~= 29m. 30 clears that; 25 did
# not, and on the fork path would have killed the job before either step timeout.
# Still half the previous 60, which let a stalled `playwright install` sit on the
# merge queue for the better part of an hour.
permissions:
contents: read
actions: read
id-token: write
timeout-minutes: 30
services:
postgres:
Expand Down Expand Up @@ -76,46 +55,6 @@ jobs:
persist-credentials: false
- uses: ./.github/actions/dangerous-git-checkout

# Resolved before anything expensive runs, because it decides whether local browser
# binaries are needed at all. In service mode the browsers execute on Azure, so the
# apt-backed `playwright install` further down is pure waste — and that apt step has
# stalled for 40+ minutes on the Ubuntu mirror, wedging the merge queue behind a job
# whose build had long since finished.
- name: Determine Playwright execution mode
shell: bash
env:
# `inputs` is empty when this workflow is reached via workflow_call, so the
# fallback keeps the automatic secret-based detection for PR/merge_group runs.
FORCE_MODE: ${{ inputs.force_mode || 'auto' }}
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
run: |
set -euo pipefail

if [[ "${FORCE_MODE}" == "local" ]]; then
echo "PW_MODE=local" >> "$GITHUB_ENV"
echo "Playwright mode forced to 'local' via workflow_dispatch input."
exit 0
fi

if [[ "${FORCE_MODE}" == "service" ]]; then
if [[ -z "${PLAYWRIGHT_SERVICE_URL}" || -z "${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}" ]]; then
echo "::error::force_mode=service but PLAYWRIGHT_SERVICE_URL / PLAYWRIGHT_SERVICE_ACCESS_TOKEN are not both set."
exit 1
fi
echo "PW_MODE=service" >> "$GITHUB_ENV"
echo "Playwright mode forced to 'service' via workflow_dispatch input."
exit 0
fi

if [[ -n "${PLAYWRIGHT_SERVICE_URL}" && -n "${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}" ]]; then
echo "PW_MODE=service" >> "$GITHUB_ENV"
echo "Playwright mode: service (both service secrets present)."
else
echo "PW_MODE=local" >> "$GITHUB_ENV"
echo "Playwright mode: local (service secrets absent — fork PR or unconfigured repo)."
fi

- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0

Expand Down Expand Up @@ -197,101 +136,12 @@ jobs:
# regardless; a real reduction needs a Turbo cache backend, not local
# actions/cache.

# Cache the downloaded browser binaries so re-runs skip the ~100MB
# Chromium download. `--with-deps` below still runs the (uncacheable) apt
# step, but a cache hit avoids the browser fetch itself.
# Local mode only — service-mode runs never download a browser to cache.
# NOTE: Actions caches are branch-scoped and every merge_group run happens on a
# throwaway `gh-readonly-queue/...` branch, so this misses ~always there. It still
# earns its keep on repeat runs of the same PR branch.
- name: Cache Playwright browsers
if: env.PW_MODE == 'local'
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-

- name: Build App (with Playwright browser install in parallel when needed)
- name: Build App
timeout-minutes: 12
env:
# Turborepo hashes its own process environment, not the `.env` file this value
# is otherwise written to. E2E_TESTING changes the emitted CSP
# (apps/web/next.config.mjs), so it has to be visible here — otherwise any
# shared or remote Turbo cache would happily replay the non-E2E build for this
# job. No behavioural change today: Next already reads the same value via dotenv.
E2E_TESTING: "1"
run: |
set -euo pipefail

PW_INSTALL_PID=""

emit_install_log() {
echo "--- playwright install output ---"
cat /tmp/playwright-install.log 2>/dev/null || echo "(no output captured)"
echo "--- end playwright install output ---"
}

# If `pnpm build` fails, `set -e` leaves the step before the reaping block at the
# bottom — stranding the installer and discarding its log, which is exactly the
# diagnostic this step exists to preserve. The trap reaps the child and emits the
# log on every exit path; the happy path clears PW_INSTALL_PID once it has done
# both itself, so this never runs twice.
on_exit() {
local status=$?
if [[ -n "${PW_INSTALL_PID}" ]]; then
kill "${PW_INSTALL_PID}" 2>/dev/null || true
wait "${PW_INSTALL_PID}" 2>/dev/null || true
emit_install_log
fi
return "${status}"
}
trap on_exit EXIT

if [[ "${PW_MODE:-}" == "local" ]]; then
# Install Playwright browsers + OS deps concurrently with the Next.js build.
# Both are largely I/O-bound, so overlapping them shaves the browser-install
# time (~1-2 min) off the critical path.
#
# Only `chromium` is enabled in playwright.config.ts (firefox/webkit are
# commented out), and `ffmpeg` is a separate download that the config's
# `video: "retain-on-failure"` needs — naming neither would pull Firefox and
# WebKit for nothing, naming only chromium would silently break failure videos.
#
# Bounded with `timeout` and logged to a file that is always echoed below:
# the `--with-deps` apt step has hung for 40+ minutes on the Ubuntu mirror,
# and because it is backgrounded that looked like a hung build with no output.
# 420s is ~5x the observed healthy install; it runs concurrently with the
# ~247s build, so it only becomes this step's critical path when it misbehaves.
(timeout 420 pnpm exec playwright install chromium ffmpeg --with-deps \
> /tmp/playwright-install.log 2>&1) &
PW_INSTALL_PID=$!
else
echo "PW_MODE=${PW_MODE:-unset}: browsers run on the Playwright service, skipping local install."
fi

pnpm build --filter=@formbricks/web...

if [[ -n "${PW_INSTALL_PID}" ]]; then
# `wait` yields the background subshell's exit status. Capture it via `|| rc=$?`
# rather than a temp file: under `set -e` the subshell dies the moment the
# install fails, so anything appended after the install inside it never runs.
pw_rc=0
wait "${PW_INSTALL_PID}" || pw_rc=$?
PW_INSTALL_PID="" # reaped — keep the EXIT trap from re-reporting it

emit_install_log

if [[ "${pw_rc}" == "124" ]]; then
echo "::error::playwright install exceeded its 420s budget (apt mirror stall?). See the install output above."
exit 1
fi
if [[ "${pw_rc}" != "0" ]]; then
echo "::error::playwright install failed with exit code ${pw_rc}. See the install output above."
exit 1
fi
fi
run: pnpm build
shell: bash

- name: Apply Prisma Migrations
run: |
Expand Down Expand Up @@ -386,23 +236,11 @@ jobs:
exit 1
shell: bash

- name: Run E2E Tests (Playwright Service)
if: env.PW_MODE == 'service'
- name: Run E2E Tests (Endform)
timeout-minutes: 15
env:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
CI: true
run: |
pnpm test-e2e:azure

- name: Run E2E Tests (Local)
if: env.PW_MODE == 'local'
timeout-minutes: 15
env:
CI: true
run: |
pnpm test:e2e
run: npx endform@latest test --organization-id 2G1ZCj7X

- name: Verify AuthZed CI outbox worker
if: always() && steps.authzed-worker-start.outcome == 'success'
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
name: PR Update

# No `id-token: write` here: nothing in this gate uses OIDC (the Azure login that needed it left
# e2e.yml in #6949), and a workflow-level grant reaches every job, most of which run PR-head code.
# Endform uses OIDC only in e2e-test; keep that permission scoped to its caller job.
permissions:
contents: read
pull-requests: read
Expand Down Expand Up @@ -51,10 +50,11 @@ jobs:
e2e-test:
name: Run E2E Tests
uses: ./.github/workflows/e2e.yml
# The three e2e.yml declares, named rather than inherited — see api-v3-contract-tests below.
permissions:
contents: read
actions: read
id-token: write
secrets:
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
ENTERPRISE_LICENSE_KEY: ${{ secrets.ENTERPRISE_LICENSE_KEY }}

validate-authzed-schema:
Expand Down
15 changes: 15 additions & 0 deletions endform.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { defineEndformConfig } from "endform";

export default defineEndformConfig({
// The app and S3 service are started by CI outside Playwright's webServer.
proxyNetworkHosts: ["<loopback>"],
// Test fixtures seed the same CI-local Postgres database through Prisma.
proxyNetworkPorts: [Number(process.env.POSTGRES_PORT || 5432)],
environmentVariables: ["^DATABASE_URL$"],
additionalFiles: [
"apps/web/public/logo-transparent.png",
"apps/web/public/favicon/android-chrome-192x192.png",
],
// Keep application pressure at the established native runner's four workers.
concurrentTestLimits: [{ scope: "within-suite-run", limit: 4 }],
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"@redocly/cli": "1.34.17",
"@trivago/prettier-plugin-sort-imports": "catalog:",
"dotenv": "catalog:",
"endform": "0.81.1",
"eslint": "9.39.5",
"husky": "9.1.7",
"js-yaml": "catalog:",
Expand Down
9 changes: 5 additions & 4 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { defineConfig, devices } from "@playwright/test";
import { config } from "dotenv";

/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
require("dotenv").config({ path: ".env" });
config({ path: ".env" });

/**
* See https://playwright.dev/docs/test-configuration.
Expand All @@ -29,10 +30,10 @@ export default defineConfig({
/* Shared settings for all the workspaces below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: "http://localhost:3000",
baseURL: process.env.BASE_URL || "http://localhost:3000",

/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
/* Retain traces for failed attempts, including failures before a retry. */
trace: "retain-on-failure",
permissions: ["clipboard-read", "clipboard-write"],
screenshot: "only-on-failure", // Capture screenshots only on test failure
video: "retain-on-failure", // Optionally record video on failure
Expand Down
64 changes: 64 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading