> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tuturuuu.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Local End-to-End Environment

> Run the isolated Supabase stack, web, Playwright and mobile simulator/emulator end-to-end tests on a dedicated build Mac.

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.

> [!WARNING]
>
> 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).

| Component | Location |
| - | - |
| Xcode 26.5 + iOS 26.5 runtime | `/Applications/Xcode.app` |
| Flutter 3.47.7 | `$T/flutter-3.47.7-stable-arm64/flutter` |
| JDK 17 (Temurin) | `$T/jdk` |
| Android SDK (platforms 36 + 37, NDK 28.2, API 36 arm64 image) | `$T/android-sdk` |
| AVDs | `$T/android-avd` |
| E2E state (Supabase workdir, env files, logs, screenshots) | `$T/e2e/<project>` |

`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

| Resource | Value | Override |
| - | - | - |
| Supabase project id | `tuturuuu-e2e` | `TUTURUUU_E2E_PROJECT_ID` |
| Supabase ports | `8000`-`8007` (API `8001`, DB `8002`, Studio `8003`, mail `8004`) | `TUTURUUU_E2E_SUPABASE_BASE_PORT` |
| Web dev server | `http://127.0.0.1:7803` | `TUTURUUU_E2E_WEB_PORT` |
| Satellites | Calendar on `http://localhost:7806` | `TUTURUUU_E2E_SATELLITES` (`calendar`, `none`) |
| iOS Simulator | `tuturuuu-ios-iphone17pro` | `TUTURUUU_E2E_IOS_SIM` |
| Android Emulator | `tuturuuu-android-36-pixel9pro` on `emulator-5580` | `TUTURUUU_E2E_AVD`, `TUTURUUU_E2E_AVD_PORT` |

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

```bash theme={null}
ssh <build-mac>
. <ci-lanes>/tuturuuu-tools/mobile-env.sh
cd <ci-lanes>/tuturuuu-e2e          # any checkout under <ci-lanes>/tuturuuu-*
bun install                         # once per checkout
E2E=scripts/thanh-e2e/thanh-e2e.mjs

node $E2E doctor
ttr resources run -- node $E2E up          # start + migrations + seed
ttr resources run -- node $E2E web         # build types/supabase, start web + satellites
node $E2E devices                          # boot simulator + emulator headless
ttr resources run -- node $E2E web-e2e --install -- e2e/auth.noauth.spec.ts
ttr resources run -- node $E2E mobile-e2e ios
ttr resources run -- node $E2E mobile-e2e android
ttr resources run -- node $E2E mobile-e2e android --mode=profile
node $E2E status
node $E2E down            # stop web + Supabase; --web-only, --purge
```

`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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.