Skip to main content
The Thanh Mac Studio (ssh skr-studio) 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; Thanh defaults are baked in and overridable by environment variables.
Use Thanh only for this lane. Queue heavy steps (up, seed, web, web-e2e, mobile-e2e) with ttr resources run -- from a checkout under /Users/thanh/ci-lanes/tuturuuu-*, and never stop Upskii or other 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 /Users/thanh/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 Thanh 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.

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.