Skip to main content
A dedicated build Mac (reached over SSH) hosts a repeatable local end-to-end environment: an isolated Supabase project with migrations and seed data, the apps/web dev server, Playwright, and the mobile app on a dedicated iOS Simulator and Android Emulator. Use it to validate changes against real simulator/emulator runs instead of host-only widget tests. The entry point is scripts/thanh-e2e/thanh-e2e.mjs. It works on any macOS host with the same toolchain; the build Mac’s defaults are baked in and overridable by environment variables.
Use the build Mac only for this lane. Queue heavy steps (up, seed, web, web-e2e, mobile-e2e) with ttr resources run -- from a checkout under <ci-lanes>/tuturuuu-*, and never stop other projects’ or lanes’ containers, emulators or queue owners.
Android’s dummy Firebase input is created exclusively: an existing file or symlink is preserved, including a dangling symlink. PID state is opened without following symlinks and classified and read through the same descriptor; malformed PID values are ignored, while unsafe file types fail explicitly. Run the focused filesystem regressions with node --test scripts/thanh-e2e/*.test.mjs; no simulator or database setup is required.

Toolchain

Everything Tuturuuu-specific lives in user space under <ci-lanes>/tuturuuu-tools ($T). Source $T/mobile-env.sh first; it sets DEVELOPER_DIR, JAVA_HOME, ANDROID_HOME/ANDROID_AVD_HOME, PUB_CACHE, GRADLE_USER_HOME, an isolated XDG_CONFIG_HOME for Flutter settings, and PATH (Flutter 3.47.7, Temurin 17, Android SDK tools, Bun, Docker). xcode-select on the build Mac still points at the Command Line Tools. Dart native-asset build hooks run with a filtered environment that keeps PATH but drops DEVELOPER_DIR, so $T/bin/xcrun pins Xcode. Without it, iOS builds fail in objective_c/hook/build.dart with Bad state: No element. Remove the shim after an administrator runs sudo xcode-select -s /Applications/Xcode.app/Contents/Developer.

Isolation

The ports stay canonical on purpose: the web local-E2E auth and captcha bypass (apps/web/src/lib/auth/local-e2e.ts) and the Playwright safety guard (apps/web/e2e/helpers/environment.ts) only trust Supabase :8001 and web :7803 on loopback. With other ports, mobile password sign-in fails with Turnstile is not configured. Isolation comes from the project id instead: the script refuses the default tuturuuu id and up fails fast when any port in the block is already listening, so it never adopts another lane’s stack. Only one stack on the canonical ports can run on a host at a time; stop it with down when you finish. The Supabase config is staged from tracked apps/database/supabase files and rewritten with the isolated helpers from apps/database/scripts/run-supabase-isolated.js. Docker over SSH cannot use the macOS keychain (credsStore: desktop). The script points DOCKER_CONFIG at a credential-free directory in the state dir and DOCKER_HOST at the Docker Desktop socket, so public image pulls work anonymously and the keychain is never touched.

Run it

seed re-applies migrations and seed data. env rewrites $T/e2e/<project>/env/web.env and mobile.json from supabase status. web.log and other logs live in the state dir’s logs/.

Test accounts

The seed creates local@tuturuuu.com (root admin) and user1-user4@tuturuuu.com with the public local-only seed password from apps/database/supabase/seed.sql. The mobile flow defaults to local@tuturuuu.com; override with --dart-define=E2E_EMAIL=... only for other seeded accounts.

Satellites

Web proxies some owner APIs to satellite apps; Calendar’s event API is proxied to the Calendar app, so without it Agenda shows ApiException(500): Request failed and web logs Failed to proxy https://calendar.tuturuuu.localhost/... ECONNREFUSED. web therefore also starts each satellite in TUTURUUU_E2E_SATELLITES (default calendar) on its default port with the same local Supabase env and WEB_APP_URL/INTERNAL_WEB_API_ORIGIN pointing back at web; web gets CALENDAR_APP_URL=http://localhost:7806. down stops web and satellites. web also builds @tuturuuu/internal-api, which Calendar imports from dist/. Satellites run on their Next.js runtime (next dev), not Vite+ dev:app. Vite+ dev runs Calendar inside workerd, which loads linked workspace packages unbundled; @tuturuuu/internal-api builds as CommonJS (NodeNext without "type": "module"), so startup fails with ReferenceError: exports is not defined, or Failed to resolve entry for package "@tuturuuu/internal-api" before it is built. The Next runtime serves the same route handlers.

Meet and the realtime Worker

The script does not start Meet. For Meet verification (coding room, shared document, backup checkpoints) run, from the same checkout and with the web.env values exported:
  • the realtime Worker: node_modules/.bin/wrangler dev --local --config apps/meet-realtime/wrangler.jsonc --ip 127.0.0.1 --port 8786 --persist-to <state dir> --var MEET_REALTIME_TOKEN_SECRET:<secret> --var PLATFORM_API_BASE_URL:http://localhost:7803;
  • web with the same MEET_REALTIME_TOKEN_SECRET (the document checkpoint route verifies the Worker’s ticket with it; node $E2E web inherits it from the shell), plus PROGRAMMING_REALTIME_URL=ws://127.0.0.1:8786/collaboration and CLOUDFLARE_CHANNELS_URL=ws://127.0.0.1:8786/channels;
  • Meet with apps/meet/node_modules/.bin/next dev -p 7807 --turbopack and WEB_APP_URL/INTERNAL_WEB_API_ORIGIN=http://localhost:7803, NEXT_PUBLIC_APP_URL=http://localhost:7807 and the same secret and realtime URLs.
Meet must run on 7807: web validates satellite return URLs against the registered local origins in packages/utils/src/internal-domains.ts, and the login page refuses an unregistered port. Start these servers as plain background processes you own, never inside ttr resources run (a dev server would hold a shared validation slot indefinitely); use admission only for finite captures. Sign in once on web through dev-session with a fresh @tuturuuu.com address (it creates a confirmed account): the seeded accounts carry version-0 UUIDs that Meet’s realtime token schema rejects, so they cannot join a call. The Supabase cookie is shared across localhost ports, and a confirmed @tuturuuu.com identity can open a meeting coding room without a playground benefit. The [wsId]/meetings page panics under next dev --turbopack (node:async_hooks external), so create the meeting through POST /api/v1/workspaces/<id>/meetings and open /r/<meeting id> directly. A local Worker fetch sends no User-Agent, and the API proxy treats other bearer tokens as Supabase sessions; the Worker therefore sends explicit service headers and the proxy exempts verified realtime service tickets on the checkpoint routes. Code execution still needs a runner, so Run reports a failure locally; the layout, collaboration and Drive checkpoints are exercised end to end.

Native Worker imports

Workerd rejects random generation during module initialization. Meet and Parley’s shared chat initializes its unguessable mention marker on first rendering use, then reuses it for that module instance. Keep the marker lazy; a fixed marker would let authored links impersonate generated mentions. The import and lifetime regressions are in packages/meet-core/src/features/call/lib/assistant-identity-import.test.ts. An authenticated native preview returning 500 is not a valid performance sample.

Mobile wiring

mobile.json is passed with --dart-define-from-file and routes NEXT_PUBLIC_SUPABASE_URL and every *_API_BASE_URL owner to loopback 127.0.0.1. Android permits cleartext only to 127.0.0.1/localhost, and the app rewrites localhost to 10.0.2.2, so the script uses 127.0.0.1 and runs adb reverse for the web and Supabase ports. All satellite API owners point at the local web server, which owns the live API handlers. apps/mobile/integration_test/e2e_flows_test.dart signs in with the seeded account, opens Agenda, opens Settings → Preferences, changes the personal timezone (default Pacific/Niue, override with --dart-define=E2E_TIMEZONE=...; pick a zone near the top of the picker, whose list is lazy and lives in a draggable sheet) and returns to Agenda. It fails if an ErrorWidget renders at any step. At each checkpoint it prints an E2E_SHOT <name> marker and holds the frame; the runner streams flutter drive output and captures the device with xcrun simctl io ... screenshot or adb exec-out screencap, so screenshots work in debug, profile and release builds. Shots and flutter-drive.log land in $T/e2e/<project>/shots/<platform>-<mode>-<timestamp>/. (On Android, IntegrationTestWidgetsFlutterBinding.takeScreenshot after convertFlutterSurfaceToImage captured only the first frame.) Before each run the script prepares the gitignored Firebase inputs the same way mobile CI does: scripts/mobile-deployment/firebase-service-config.py copies the tracked development plist on iOS, and Android gets the synthetic google-services.json CI writes. iOS builds run pod install, which can leave ios/Podfile.lock and project.pbxproj modified in the E2E checkout; do not commit those side effects. flutter test -d <device> test/... does not run on the device: files outside integration_test/ run on the host VM. Widget tests that resize tester.view also fail under the live binding, so on-device coverage belongs in integration_test/.

Release-mode gray screen

Release builds replace the debug red error box with a gray ErrorWidget. iOS simulators run debug builds only (Flutter refuses --profile and --release), so reproduce release-only rendering on the Android emulator with mobile-e2e android --mode=profile or --mode=release. The same ErrorWidget assertion reports the failing screen, and the screenshots show it.

Web Playwright

web and web-e2e set BASE_URL=http://127.0.0.1:7803, the local Supabase keys and TUTURUUU_LOCAL_E2E_AUTH_BYPASS=1, the same local-only opt-in CI web E2E uses. local-e2e.ts honors it only for the loopback :7803/:8001 origins; without it the /api/auth/dev-session rate-limit reset returns 403 and most auth specs fail in beforeEach. --install installs the Playwright Chromium build first. Pass spec paths or Playwright flags after --.

Gotchas

  • Browsers must open web at http://localhost:7803, not 127.0.0.1: Next dev serves its dev resources (HMR, hydration) only to localhost, so a 127.0.0.1 page stays unhydrated and the login form remains disabled. The script uses localhost for BASE_URL/NEXT_PUBLIC_WEB_APP_URL and keeps 127.0.0.1 for mobile, which Android cleartext requires.
  • bootstrap() installs the app’s FlutterError.onError. The integration test restores the test binding’s handler after app.main(); otherwise a framework error surfaces only as A test overrode FlutterError.onError ... and the real failure is hidden.
  • Mobile login ignores “Continue with email” while OTP availability resolves (a short grace period); the test retries the tap until the password stage renders.
  • Every wait in the flow captures timeout-<label>.png and prints the visible text on timeout, so a failing run shows the stuck screen.
  • Simulator and emulator keyboards are real: the test dismisses the keyboard before tapping, and re-enters the email before each Continue attempt because the login page can rebuild once startup checks settle. On the Android profile build the typed email still did not stick; after 20 seconds the test signs in through AuthCubit.signInWithPassword (the call the Sign in button makes) and logs E2E_NOTE, so profile mode still covers Agenda and Settings.
  • mobile-e2e runs flutter drive in its own process group with a 20-minute timeout and requires the All tests passed line; a killed drive can exit 0.
  • The web auth spam guard blocks requests without a native or browser user agent; probe mobile APIs with a Dart/3.x (dart:io) user agent, not bare curl.
  • A killed flutter run/flutter drive can leave adb ... logcat or simctl ... log stream children holding stdout open, which pins a ttr resources slot. Kill only your own leftover streamer.
  • permission_handler_android compiles against Android SDK 37; keep platforms;android-37.0 installed.
  • Plain ./gradlew tasks need -Ptarget=lib/main_development.dart because the app has no lib/main.dart, and flavored task names such as :app:testDevelopmentDebugUnitTest.

Measuring Vite+ satellites signed in

Production Meet and Mail redirect every signed-out request to the web login, so first-load metrics for their real surfaces need a signed-in session. The same isolated stack serves that: build the satellite on the build Mac against the local Supabase values from env/web.env (vp build with NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY, NEXT_PUBLIC_APP_URL=http://localhost:<port> and NEXT_PUBLIC_WEB_APP_URL=http://localhost:7803 exported, because the client bundle inlines them), then vp preview --host 127.0.0.1 --port <port> runs the emitted Worker in workerd. Three details make the handshake work:
  • Void copies only the secrets.required entries of void.config.ts into dist/server/.dev.vars. Meet therefore has no TUTURUUU_APP_COORDINATION_SECRET after a build; append it (and the loopback WEB_APP_URL/INTERNAL_WEB_API_ORIGIN) before starting the preview, or the Worker cannot verify the app-session cookie web issues.
  • Supabase cookies are only trusted on *.tuturuuu.com and *.tuturuuu.localhost hosts. On plain localhost the web session cookie is host-only and reaches every port, and /verify-token then clears it with response.cookies.set, which rebuilds the Set-Cookie header and drops the app-session cookies that were appended a moment earlier. Sign in on web, capture the /verify-token?token=… URL web redirects to, clear the sb-* cookies from the browser context, and only then open that URL on the satellite origin.
  • Keep the Playwright run on ttr resources run -- and point it at the playwright package under <ci-lanes>/tuturuuu-e2e/node_modules/.bun; the Chromium build in ~/Library/Caches/ms-playwright is shared.
The satellite then serves /personal/… with a real session, and a CDP-based Playwright script can record TTFB, FCP, LCP, CLS and transferred JavaScript for cold and warm loads of the exact local build.