Skip to content

Commit caa925a

Browse files
authored
ci: run the mobile e2e suite on a production build, one job per browser (#8948)
* ci: run the mobile e2e suite on the shared production build, one job per browser The build job now packages .next (zstd, cache excluded) and uploads it for a day. A new e2e-mobile job, one per browser (chromium, webkit), extracts it and serves it with `next start`, so the suite no longer waits on dev compiles or runs both browsers back to back. The mobile group leaves the e2e matrix. On runs that execute e2e, the build bakes NEXT_PUBLIC_APP_URL=http://localhost:3000, which is what production images bake, so the served build is the one that ships. Content-only pull requests keep the hosted value, under which next build prerenders every post. http-e2e.sh gains a `next start` mode used only by mobile: it installs only the selected browser, drops the dev heap override, and rebuilds the HTTP status log from RouteHandler's JSON lines at LOG_LEVEL=INFO. The ci gate treats e2e-mobile like e2e: a skip passes only when `full` is exactly false. * ci: build mobile's artifact in its own job and keep the hosted build untouched The `build` job is back to staging byte for byte: its www.sim.ai build is CI's only render of every post, and it must stay on every run. A new `e2e-build` job, gated on `full` with its own Turbo cache key, builds with NEXT_PUBLIC_APP_URL=http://localhost:3000 (the Dockerfile default, so the same public-env mode production images use; images run the standalone server) and packages and uploads it for e2e-mobile, now kept 3 days and without .next/trace. e2e-mobile fails fast when the downloaded build prerendered posts, which only a hosted build does; the URL is no marker, since next.config's redirects name www.sim.ai in every build. The ci gate treats e2e-build like e2e. * ci: key the non-hosted guard on prerendered post pages, not every /blog/ route * ci: expect the socket-token rate limit in the mobile e2e suite Each document load mints one realtime token from a route capped at 30/min per IP. On the production build the suite loads well over 30 documents a minute from loopback, so the route's designed 429 is recorded apart from httpErrors (only with Retry-After), and each browser must still mint at least once.
1 parent 90ed227 commit caa925a

6 files changed

Lines changed: 250 additions & 25 deletions

File tree

‎.github/scripts/ci-changes.sh‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
# skip list) to `true`; a change under .github/, an empty diff, or any failure to work out the
66
# diff sets all of them.
77
#
8-
# full integration and e2e: any file not allowlisted below
8+
# full integration, e2e, e2e-build, e2e-mobile: any file not allowlisted below
99
# test the Vitest shards: content, docs app, and desktop changes, plus `full`
1010
# build the app's `next build`: content changes, plus `full`
1111
# desktop_live the live Electron suite: any file outside its own skip list

‎.github/scripts/ci-gate.sh‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
#
66
# A skip passes only for a gated job whose `changes` flag is exactly `false`:
77
#
8-
# integration, e2e full
9-
# test test
10-
# build build
11-
# desktop-live desktop_live
8+
# integration, e2e, e2e-build, e2e-mobile full
9+
# test test
10+
# build build
11+
# desktop-live desktop_live
1212
#
1313
# Every other skip fails: an ungated job (lint, `changes` itself), a gated job whose flag is
1414
# `true`, missing, or anything else (a failed or skipped `changes` leaves no outputs). A failure
@@ -24,7 +24,8 @@ if ! jq -e 'type == "object"' <<< "$needs" > /dev/null 2>&1; then
2424
fi
2525

2626
failed="$(jq -r '
27-
{"integration": "full", "e2e": "full", "test": "test", "build": "build", "desktop-live": "desktop_live"} as $gate
27+
{"integration": "full", "e2e": "full", "e2e-build": "full", "e2e-mobile": "full", "test": "test",
28+
"build": "build", "desktop-live": "desktop_live"} as $gate
2829
| (.changes.outputs // {}) as $flags
2930
| to_entries[]
3031
| select(.value.result != "success")

‎.github/scripts/http-e2e.sh‎

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
#!/usr/bin/env bash
2-
# Runs one end-to-end suite group over real HTTP, each against its own `next dev` app.
2+
# Runs one end-to-end suite group over real HTTP, each against its own app: `next dev`, except
3+
# mobile, which serves the production build the `e2e-build` job uploads with `next start` (the
4+
# job extracts it into apps/sim/.next first).
35
#
46
# Usage: http-e2e.sh <scim|cli|stop-after|desktop-inbox|mobile> (run from apps/sim)
57
#
68
# The job provides DATABASE_URL, BETTER_AUTH_SECRET and ENCRYPTION_KEY; each group sets the rest of
79
# its app's environment here. Reports and server logs land in $RUNNER_TEMP/e2e.
810
#
9-
# The first request cold-compiles the app under Turbopack, which takes 42-150s on CI runners, so
10-
# the readiness deadline only has to catch a hung boot: an exited server fails immediately, and
11-
# either way the server log tail lands in the job log.
11+
# Under `next dev` the first request cold-compiles the app under Turbopack, which takes 42-150s on
12+
# CI runners, so the readiness deadline only has to catch a hung boot: an exited server fails
13+
# immediately, and either way the server log tail lands in the job log.
1214
#
1315
# Each app starts from an empty Turbopack dev cache: a cache written under other NEXT_PUBLIC_*
1416
# values, by a server that `next dev` SIGKILLs 100ms after SIGTERM, can panic Turbopack or wedge a
@@ -30,13 +32,22 @@ realtime_pid=''
3032
realtime_tag=''
3133
realtime_log=''
3234
browsers_pid=''
35+
# `dev` or `start`; a group sets it before start_app.
36+
app_server=dev
3337

3438
finish() {
3539
local status=$?
3640
if [ -n "$server_pid" ]; then
3741
bash "$GITHUB_WORKSPACE/.github/scripts/stop-session.sh" "$server_pid" "$app_tag" || status=1
3842
wait "$server_pid" 2>/dev/null || true
39-
if [ -n "$status_log" ]; then
43+
if [ -n "$status_log" ] && [ "$app_server" = start ]; then
44+
# `next start` logs no requests; RouteHandler's closing line for each API request carries
45+
# its status (an unhandled error's line is a 500 without one) and duration.
46+
jq -Rr 'fromjson? | objects | select(.module == "RouteHandler" and .duration != null
47+
and ((.path // "") | startswith("/api/")))
48+
| "\(.method) \(.path) \(.status // 500) in \(.duration)ms"' \
49+
"$server_log" > "$status_log"
50+
elif [ -n "$status_log" ]; then
4051
awk '/^ (GET|POST|PUT|PATCH|DELETE|HEAD) \/api\// { print }' "$server_log" > "$status_log"
4152
fi
4253
if [ "$status" -ne 0 ]; then
@@ -69,9 +80,20 @@ start_app() {
6980
export NEXT_PUBLIC_APP_URL="http://127.0.0.1:$port"
7081
export BETTER_AUTH_URL="$NEXT_PUBLIC_APP_URL"
7182
export DISABLE_TELEMETRY=true NEXT_TELEMETRY_DISABLED=1
72-
rm -rf .next/dev
73-
E2E_APP="$app_tag" setsid node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 \
74-
--port "$port" > "$server_log" 2>&1 &
83+
if [ "$app_server" = start ]; then
84+
if [ ! -f .next/BUILD_ID ]; then
85+
echo "::error::No production build in apps/sim/.next for next start."
86+
exit 1
87+
fi
88+
# Production logs only errors by default; INFO writes the RouteHandler lines the status log
89+
# reads.
90+
E2E_APP="$app_tag" LOG_LEVEL=INFO setsid node ../../node_modules/next/dist/bin/next start \
91+
--hostname 127.0.0.1 --port "$port" > "$server_log" 2>&1 &
92+
else
93+
rm -rf .next/dev
94+
E2E_APP="$app_tag" setsid node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 \
95+
--port "$port" > "$server_log" 2>&1 &
96+
fi
7597
server_pid=$!
7698
wait_ready "$server_pid" "$NEXT_PUBLIC_APP_URL/api/health" "$label app"
7799
}
@@ -189,14 +211,16 @@ case "$group" in
189211
bun run test:desktop-inbox:e2e
190212
;;
191213

214+
# MOBILE_E2E_BROWSER (chromium or webkit) narrows the job to one browser; unset runs both.
192215
mobile)
216+
app_server=start
193217
export NEXT_PUBLIC_FORCE_HOSTED=false
194218
export INTERNAL_API_SECRET=mobile-http-ci-local-secret-at-least-32-characters
195-
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--max-old-space-size=12288"
196219
export NEXT_PUBLIC_SOCKET_URL=http://127.0.0.1:3025 SOCKET_SERVER_URL=http://127.0.0.1:3025
220+
read -ra browsers <<< "${MOBILE_E2E_BROWSER:-chromium webkit}"
197221
# The browsers install while the app boots, in their own process group so finish() can stop them.
198222
browsers_log="$report_dir/mobile-playwright-install.log"
199-
setsid --wait bunx --no-install playwright install --with-deps chromium webkit > "$browsers_log" 2>&1 &
223+
setsid --wait bunx --no-install playwright install --with-deps "${browsers[@]}" > "$browsers_log" 2>&1 &
200224
browsers_pid=$!
201225
start_app mobile 3024 'mobile browser' record-http-status
202226
start_realtime mobile 3025

‎.github/workflows/checks.yml‎

Lines changed: 149 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -172,14 +172,14 @@ jobs:
172172
# The suites exercise HTTP behavior rather than a provisioning path, so they run once, against
173173
# the production (migrate) path. Each group's app environment lives in http-e2e.sh.
174174
#
175-
# SCIM's hosted suites and the mobile browser matrix use 8 vCPU; the other
176-
# self-hosted suites fit on 4.
175+
# SCIM's hosted suites use 8 vCPU; the other self-hosted suites fit on 4. The mobile browser
176+
# suite runs in e2e-mobile below.
177177
e2e:
178178
name: e2e (${{ matrix.group }})
179179
needs: changes
180180
if: needs.changes.outputs.full == 'true'
181181
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.runner || 'ubuntu-latest' }}
182-
timeout-minutes: ${{ matrix.group == 'mobile' && 40 || 20 }}
182+
timeout-minutes: 20
183183
strategy:
184184
fail-fast: false
185185
matrix:
@@ -192,8 +192,6 @@ jobs:
192192
runner: blacksmith-4vcpu-ubuntu-2404
193193
- group: desktop-inbox
194194
runner: blacksmith-4vcpu-ubuntu-2404
195-
- group: mobile
196-
runner: blacksmith-8vcpu-ubuntu-2404
197195
services:
198196
postgres:
199197
image: pgvector/pgvector:pg17
@@ -252,6 +250,86 @@ jobs:
252250
if-no-files-found: ignore
253251
retention-days: 7
254252

253+
# The mobile browser suite against the production build e2e-build uploads, served by
254+
# `next start` (http-e2e.sh), one browser per job: Chromium and WebKit no longer run back to
255+
# back, and no route waits on a dev compile. Gated like `e2e`, on a build that already ran.
256+
#
257+
# 8 vCPU: `next start`, Postgres, the realtime server and one Playwright browser would fit a 4
258+
# vCPU runner's 16 GB, but the suite is timing-sensitive, and CPU contention between the browser
259+
# and the server is what turns a slow tap into a flake.
260+
e2e-mobile:
261+
name: e2e (mobile, ${{ matrix.browser }})
262+
needs: [changes, e2e-build]
263+
if: needs.changes.outputs.full == 'true'
264+
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }}
265+
timeout-minutes: 25
266+
strategy:
267+
fail-fast: false
268+
matrix:
269+
browser: [chromium, webkit]
270+
services:
271+
postgres:
272+
image: pgvector/pgvector:pg17
273+
env:
274+
POSTGRES_USER: postgres
275+
POSTGRES_PASSWORD: postgres
276+
POSTGRES_DB: sim_test
277+
ports:
278+
- 5432:5432
279+
options: >-
280+
--health-cmd "pg_isready -U postgres -d sim_test"
281+
--health-interval 5s
282+
--health-timeout 5s
283+
--health-retries 10
284+
env:
285+
DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_test
286+
BETTER_AUTH_SECRET: http-e2e-ci-secret-at-least-32-characters
287+
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000'
288+
289+
steps:
290+
- *checkout-mirror
291+
- *checkout-plain
292+
293+
- name: Setup workspace
294+
uses: ./.github/actions/setup
295+
with:
296+
provider: ${{ vars.CI_PROVIDER }}
297+
298+
- name: Provision the database through migrations
299+
working-directory: packages/db
300+
run: bun run db:migrate
301+
302+
- name: Download the production build
303+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
304+
with:
305+
name: next-build
306+
path: ${{ runner.temp }}
307+
308+
# A hosted build (NEXT_PUBLIC_APP_URL on sim.ai) prerenders every post; the build production
309+
# images use prerenders none. The URL itself is no marker: next.config's redirects name
310+
# www.sim.ai in every build.
311+
- name: Extract the production build
312+
run: |
313+
tar -I zstd -xf "$RUNNER_TEMP/next-build.tar.zst" -C apps/sim
314+
jq -e '[.routes[].srcRoute] | any(. == "/blog/[slug]" or . == "/library/[slug]") | not' \
315+
apps/sim/.next/prerender-manifest.json > /dev/null ||
316+
{ echo "::error::The build prerendered posts (a hosted build) or has no prerender manifest."; exit 1; }
317+
318+
- name: Run end-to-end suites
319+
working-directory: apps/sim
320+
env:
321+
MOBILE_E2E_BROWSER: ${{ matrix.browser }}
322+
run: bash ../../.github/scripts/http-e2e.sh mobile
323+
324+
- name: Upload end-to-end reports and server logs
325+
if: failure()
326+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
327+
with:
328+
name: http-e2e-mobile-${{ matrix.browser }}
329+
path: ${{ runner.temp }}/e2e/
330+
if-no-files-found: ignore
331+
retention-days: 7
332+
255333
# Desktop tools in the real Electron app against a local app, on its own runner: the
256334
# Electron app, the dev app and its realtime server together outgrow the e2e runner.
257335
desktop-live:
@@ -724,6 +802,71 @@ jobs:
724802
TURBO_CACHE_DIR: .turbo
725803
run: bunx turbo run build --filter=@sim/app
726804

805+
# The production build e2e-mobile serves with `next start`, built in the public-env mode
806+
# production images use: NEXT_PUBLIC_APP_URL is docker/app.Dockerfile's localhost default, so
807+
# every route is dynamic and NEXT_PUBLIC_* are read at start. (Images run the standalone
808+
# DOCKER_BUILD server; this is the plain `next build` output.) The `build` job above keeps the
809+
# hosted value, under which `next build` prerenders every post. Gated like `e2e`; its own Turbo
810+
# cache key, so the two builds never evict each other's entries. 16 vCPU for the same reason as
811+
# `build`: a cold build peaks at 51 GB.
812+
e2e-build:
813+
name: build (e2e)
814+
needs: changes
815+
if: needs.changes.outputs.full == 'true'
816+
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-16vcpu-ubuntu-2404' || 'linux-x64-8-core' }}
817+
timeout-minutes: 25
818+
819+
steps:
820+
- *checkout-mirror
821+
- *checkout-plain
822+
823+
- name: Setup workspace
824+
uses: ./.github/actions/setup
825+
with:
826+
provider: ${{ vars.CI_PROVIDER }}
827+
turbo-cache-key: turbo-cache-build-e2e
828+
829+
- name: Check runner memory headroom
830+
run: |
831+
TOTAL_GB=$(awk '/MemTotal/ {printf "%d", $2/1048576}' /proc/meminfo)
832+
echo "Runner memory: ${TOTAL_GB} GB"
833+
if [ "$TOTAL_GB" -lt 40 ]; then
834+
echo "::warning::Runner has ${TOTAL_GB} GB. A cold-cache build peaks ~51 GB, so this run may be OOM-killed (reported only as 'the runner has received a shutdown signal'). Warm/partial builds should still fit."
835+
fi
836+
837+
- name: Build application
838+
env:
839+
NODE_OPTIONS: '--no-warnings --max-old-space-size=8192'
840+
NEXT_PUBLIC_APP_URL: 'http://localhost:3000'
841+
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/simstudio'
842+
STRIPE_SECRET_KEY: 'dummy_key_for_ci_only'
843+
STRIPE_WEBHOOK_SECRET: 'dummy_secret_for_ci_only'
844+
RESEND_API_KEY: 'dummy_key_for_ci_only'
845+
AWS_REGION: 'us-west-2'
846+
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' # dummy key for CI only
847+
TURBO_CACHE_DIR: .turbo
848+
run: bunx turbo run build --filter=@sim/app
849+
850+
# Everything `next start` reads; node_modules and the source tree come from e2e-mobile's own
851+
# checkout and setup, at the same workspace path and lockfile.
852+
- name: Package the build for e2e-mobile
853+
run: >-
854+
tar -I 'zstd -T0' -cf "$RUNNER_TEMP/next-build.tar.zst" -C apps/sim
855+
--exclude .next/cache --exclude .next/trace .next
856+
857+
# Already zstd-compressed, so stored without zip compression. `overwrite` lets a re-run of
858+
# this job replace the earlier attempt's build; three days covers re-running a failed shard
859+
# after a weekend.
860+
- name: Upload the build for e2e-mobile
861+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
862+
with:
863+
name: next-build
864+
path: ${{ runner.temp }}/next-build.tar.zst
865+
if-no-files-found: error
866+
compression-level: 0
867+
overwrite: true
868+
retention-days: 3
869+
727870
# One status for every check above: the single check to require on a branch ruleset, so adding,
728871
# sharding or renaming a job never means editing the ruleset. .github/scripts/ci-gate.sh decides:
729872
# a skip passes only for a gated job whose `changes` flag is exactly `false`; any other skip (a
@@ -733,7 +876,7 @@ jobs:
733876
# on a cancelled run would report a cancelled head commit as passing.
734877
ci:
735878
name: ci
736-
needs: [changes, integration, e2e, desktop-live, lint, test, build]
879+
needs: [changes, integration, e2e, e2e-mobile, desktop-live, lint, test, build, e2e-build]
737880
if: ${{ always() }}
738881
runs-on: *runner-2vcpu
739882
timeout-minutes: 5

‎apps/sim/scripts/test-mobile-e2e.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ const startedAt = new Date().toISOString()
6464
const checks: CheckResult[] = []
6565
const screenshots: string[] = []
6666
const httpErrors: { browser: string; status: number; path: string }[] = []
67+
/**
68+
* Every document load mounts the workspace socket provider, which mints one realtime token from a
69+
* route capped at 30 per minute per client IP. The suite loads well over 30 documents a minute, all
70+
* from loopback, so that route's own rate-limit response is expected here; it is recorded apart
71+
* from `httpErrors` and must still carry `Retry-After`, and each browser must mint at least once.
72+
*/
73+
const SOCKET_TOKEN_PATH = '/api/auth/socket-token'
74+
const socketTokenMints = new Map<string, { minted: number; rateLimited: number }>()
6775
const requestFailures: {
6876
browser: string
6977
viewport: string
@@ -507,8 +515,17 @@ async function exerciseViewport(
507515
}
508516
if (pageErrors.length < 20) pageErrors.push(error.message)
509517
})
518+
const mints = socketTokenMints.get(browserName) ?? { minted: 0, rateLimited: 0 }
519+
socketTokenMints.set(browserName, mints)
510520
page.on('response', (response) => {
511521
const url = new URL(response.url())
522+
if (url.origin === baseUrl.origin && url.pathname === SOCKET_TOKEN_PATH) {
523+
if (response.ok()) mints.minted += 1
524+
if (response.status() === 429 && response.headers()['retry-after']) {
525+
mints.rateLimited += 1
526+
return
527+
}
528+
}
512529
if (url.origin === baseUrl.origin && response.status() >= 400 && httpErrors.length < 100) {
513530
httpErrors.push({ browser: browserName, status: response.status(), path: url.pathname })
514531
}
@@ -1194,6 +1211,11 @@ try {
11941211
await check('same-origin HTTP responses succeed', undefined, async () => {
11951212
assert.deepEqual(httpErrors, [])
11961213
})
1214+
await check('each browser mints a realtime token', undefined, async () => {
1215+
for (const [browserName, mints] of socketTokenMints) {
1216+
assert(mints.minted > 0, `${browserName} never minted a realtime token`)
1217+
}
1218+
})
11971219
await mkdir(dirname(reportPath), { recursive: true })
11981220
await writeFile(
11991221
reportPath,
@@ -1205,6 +1227,7 @@ try {
12051227
checks,
12061228
screenshots,
12071229
httpErrors,
1230+
socketTokenMints: Object.fromEntries(socketTokenMints),
12081231
requestFailures,
12091232
navigationDiagnostics,
12101233
viewportMeasurements,

0 commit comments

Comments
 (0)