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.
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 createslocal@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 showsApiException(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 grayErrorWidget. 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, not127.0.0.1: Next dev serves its dev resources (HMR, hydration) only tolocalhost, so a127.0.0.1page stays unhydrated and the login form remains disabled. The script useslocalhostforBASE_URL/NEXT_PUBLIC_WEB_APP_URLand keeps127.0.0.1for mobile, which Android cleartext requires. -
bootstrap()installs the app’sFlutterError.onError. The integration test restores the test binding’s handler afterapp.main(); otherwise a framework error surfaces only asA 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>.pngand 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 logsE2E_NOTE, so profile mode still covers Agenda and Settings. -
mobile-e2erunsflutter drivein its own process group with a 20-minute timeout and requires theAll tests passedline; 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 barecurl. -
A killed
flutter run/flutter drivecan leaveadb ... logcatorsimctl ... log streamchildren holding stdout open, which pins attr resourcesslot. Kill only your own leftover streamer. -
permission_handler_androidcompiles against Android SDK 37; keepplatforms;android-37.0installed. -
Plain
./gradlewtasks need-Ptarget=lib/main_development.dartbecause the app has nolib/main.dart, and flavored task names such as:app:testDevelopmentDebugUnitTest.