Repository navigation
Conversation
25e77e7 to
5b9f030
Compare
|
You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool. What Enabling Code Scanning Means:
For more information about GitHub Code Scanning, check out the documentation. |
|
Merged: engine hardening for the native scaffold. The legacy token is adopted only when the previous shell actually stored one; the account modal gained its Done item with a router-level test that the modal closes; PlistBuddy failures in the iOS privacy script surface through the script's own exit path and the aps-environment key follows the build configuration; the expo-crypto digest shim has its own test. Verified with root pnpm check, native jest on both platforms, and a full CI run. |
|
Merged: the deterministic checks. Biome runs with warnings as errors and a cognitive complexity gate at 15, every suppression carrying a written reason. size-limit budgets cover the web initial load, all web JS and CSS, and both Hermes bundles. The release lanes assert the APK and the exported IPA against download ceilings out of the built artifacts. CodeQL scans source and workflows and gates the mobile release. The footprint job measures base and head in one run and recreates its comment on every push. Every gate was shown to fail under a planted violation before it was kept. |
|
Merged: the sync contract. Read failures are typed rather than collapsed to one flag. The read pool owns rate limits, honouring Retry-After and publishing one observable pause that every screen reads; the query layer owns transport and 5xx retries, so each failure kind has exactly one bounded ladder. The strip's states, in precedence: offline, rate limited with a live countdown, retrying, unreachable with the cause named and a Retry action, pending. A mark is green for the 5 second undo window, the same number the snackbar uses, then advancing, checked and disabled with a quiet dot while the write is queued, until Trakt confirms and the next episode takes the row. The fake Trakt gained fault modes (429 with Retry-After, 5xx, delay, hold, drop) and a reset control, and the mock lane drives both defects end to end. Merge-scoped footprint against the previous tip: product +1173, tests +1789, web initial load +1.2 kB, Hermes bundles +47.8 kB iOS and +32.2 kB Android, complexity profile unchanged. |
|
Merged: the native design foundation. packages/native/src/ui holds the tokens (dynamic colour pairs on iOS, scheme-resolved on Android, gated token for token against the web stylesheet's two theme blocks), eleven type roles per platform bound to Dynamic Type styles and Material 3 tokens with a test anchored to Apple's Large sizes and the Material scale, the snackbar hosts for the root, the sheet and the account modal, the app-idle marker, the accessibility id vocabulary, and the first primitives (row, check control, section header, empty state, sync strip). The splash stays up until the stores and fonts settle. Screen readers get the longer snackbar window. |
|
Two fixed behaviours, recorded on the web app running against the repository's fake Trakt ( Marking an episodecue-marking-an-episode.mp4First take: the check on the queue row is tapped and turns green, and the snackbar offers Undo ("Midnight Cartography S2 E3 marked"). The row advances to S2 E4 in the same frame, on the clock rather than on a round trip. The write is held by the fake for 7 seconds, so the undo window ( The sync strip under faultscue-sync-strip-under-faults.mp4Healthy is silence: no strip. One 429 with Two things in the recordings are worth a look before the native screens copy them:
|
|
Merged: size and quality budgets anchored to published norms. A committed quality baseline ratchets the count of cognitive-complexity suppressions (21 today) and the worst measured complexity (71 today) downward only, with zero suppressions allowed under the native app, and the check refuses a baseline that outruns the measurement. TypeScript suppressions are rejected across every package's source, tests, app routes and modules. Android release builds now ship with R8 and resource shrinking; CI builds the App Bundle, estimates the Play download for an arm64 xxhdpi device with bundletool (17.4 MB today, gated at 20 MB), gates the bundle itself, and derives the universal APK for the permission gate. The old 200 MB universal-APK ceiling is gone. The footprint comment reports comment density per package. Two gates written for the deleted web app (Vite size budgets, Lighthouse against vite preview) were dropped before merge, since the web UI is retired with the shells. |
|
Merged: the Maestro launch harness and the fake Trakt's missing controls. The fake Trakt gains six selectable seed states behind its reset control and the fault modes the parity flows need (a one-shot 401, a refused refresh, a 429 mid fan-out, a held and a dropped write, a failing history page, a pre-seeded op-log), each proven observable from a client. A Maestro launch flow asserts the onboarding screen, the connect button, the device code and the arrival on Up Next, with a test that every id a flow names exists in the app's id vocabulary. CI's iOS job now uploads the simulator app and a new native-e2e job on macOS installs it, boots a simulator, starts the fake and runs the flow. Getting that job green found and fixed a real launch defect: an unsigned simulator binary carries no entitlements, so every Keychain call failed and the auth store never left loading; the build is signed for the simulator now, the packaging step asserts the entitlements section is present, a token store that cannot answer drops to onboarding with a message instead of hanging, and the boot gates render a one-point frame so a hierarchy dump names whichever gate is holding. |
|
One mark no longer refetches the whole library. Marking an episode used to invalidate the Up A read's A mark never names an episode that has not aired. The optimistic advance projects the next Requests are bounded, and the fake can let go. Every Trakt request now aborts after 15 Cloudflare's answers stop reading as your connection. A 429 or 403 issued in front of the API The exact-alarm claim is corrected, not just deleted. The sync strip wraps. At phone width "Can't reach Trakt. Showing your cached data." was cut |
|
Merged Android. The release bundle is measured with bundletool at the device configuration Play Console itself reports against, XXXHDPI ARMv8, which means screen density 640 rather than the 480 that was there before. A second run measures every SDK, ABI, density and language combination and takes the largest. Both are held under 20 MB. On the merged tip CI reports 16,904,619 bytes at Play's reference configuration and 17,657,277 bytes across every configuration. The old ceiling on the App Bundle's own bytes is gone: every Play limit is stated on the compressed download, so an upload ceiling had no anchor, and the all-dimensions run does the job it was given properly. The font nobody imported. Expo Router's Android tab icons pulled in Budgets that can only go down. Every
The Play download budget is a decision rather than an anchor: 20 MB sits between Expo's minimal-app floor and the comparables, and Google's published curve prices the gap at roughly a point of install conversion per 6 MB. It re-anchors against the Play Console peer group once there is a production release. Growth needs a reason. The footprint job compares the merge base and the head for both Hermes bundles and the Play estimate, and fails above 64,000 bytes of growth. The only way past it is a Assets are enumerated. Every asset in the built export is listed with its bytes in Attribution. The footprint comment carries an Expo Atlas table of the largest contributing packages per platform, and the Atlas file uploads as an artifact, so a failing delta names its cause instead of leaving you to guess.
Two things worth knowing from the review before the merge. iOS has no download gate yet. Xcode documents its thinning export option as applying to non App Store exports only, so the App Thinning Size Report the branch tried to assert on is never produced by the release lane's App Store export, and every iOS build would have failed on the missing file. That assertion is removed and the IPA smoke ceiling stays. A real number needs either an ad hoc provisioning profile in CI for a second export of the same archive, or App Store Connect's App File Sizes after the first upload. The generated Android tab icons were invisible. Material Symbols path data is authored in the SVG viewBox |
|
The core's cognitive-complexity debt is retired. Nine functions carried a suppression; all nine are
What was split into what:
Behaviour is unchanged: every test that covered these paths passed before each split and passes now. Comments: 415 comment lines removed, 71 added, net -344 across the eight files, taking core Item 22's other target, core comment density at or under 14 percent, is not met and stays open: this Verified before merge: root
|
|
Merged: a deterministic simulator build. The native end-to-end job had gone red on every branch: React Native decides at pod install, through a live request to Maven, whether to use its prebuilt core, and when that request fails it silently builds from source without embedding React.framework while the Expo module frameworks still link it, so the app died in the dynamic linker at launch. Expo's precompiled modules are now off so the two halves are always built the same way, and the packaging step walks every embedded framework's load commands and refuses an artifact whose transitive frameworks are not in the bundle. |
|
Merged: Up Next on the native app. The queue rows are built on the foundation primitives with the three mark states (unwatched, just marked for the undo window, advancing), swipe to mark at the 96 pt threshold with the threshold and success haptics, pull to refresh that ends immediately during a rate-limit pause, the sync strip in flow, the marquee, "On the way" capped at three rows, the History footer, the lapsed drawer, and the empty, loading, degraded and offline states, in light and dark and at the largest accessibility text size. Two build defects were found and fixed on the way (an Expo UI release that does not compile against the pinned SDK, now pinned level with a test; a snack message built from a DOM element inside the shared store, which crashes a native text tree), and five visual defects were found by operating the screen on the simulator and fixed with tests. The placeholder onboarding screen is now readable on both appearances. Clips and stills below. clip-1-mark-undo-swipe.mp4clip-2-refresh-and-strip.mp4 |
|
Merged The native app syncs on its own now. The core no longer needs browser globals. A 40-line module used to install The accessibility ids are one vocabulary again. Render counts are gated. Three surfaces (the queue row, the check control, the Up Next screen) are measured with Reassure on every CI run and pinned at 2 renders with zero allowed deviation, so an accidental extra state update fails the build instead of arriving as a stutter. Durations are printed but do not gate: one machine's timing is not a threshold. The tests that hold the core to its coverage floors live in the core. 26 test files and their support moved from Duplicated concepts collapsed. One Also: a browser-hidden response is its own failure kind rather than a 503 Trakt never sent; pagination without headers keeps going until a short page instead of stopping after page one; concurrent identical reads share one request; write retry pacing keeps a 100 ms margin under Trakt's limit; and 420, 423 and 426 are named permanent account failures. Numbers, from the footprint. Nothing regressed: worst cognitive complexity 41 with 11 functions over 15, unchanged. Web initial load 139.1 kB brotli against a 170 kB limit, all JavaScript and CSS 230.9 kB against 285 kB, iOS bundle 5.12 MB against 5250 kB, Android 5.15 MB against 5400 kB, Play download 17.30 MB against 20 MB. 1,001 vitest tests and 288 jest tests pass and every coverage floor holds. Still owed: a startup timing gate. The simulator was unreachable where this was built, so there is no launch measurement and therefore no defensible ceiling to ratchet. That is the last item from the performance work. |
|
Merged: the native release lane, and a settle wait in the launch flow. The mobile release workflow gains a line choice, expo by default and capacitor for rollback. The Expo line prebuilds both platforms in the workflow, then fastlane builds the workspace with the existing signing, asserts the built IPA's version and build number against the values the config wrote, checks TestFlight for a strictly greater build number within the marketing version, and uploads to the internal group; on Android it builds the release App Bundle with R8 and resource shrinking, derives the universal APK with bundletool for the permission gate and for Firebase App Distribution, and checks the latest Firebase release the same way. The Expo line starts at version 2.0.0 so it never collides with the Capacitor 1.x builds. The launch flow now waits explicitly for the device-code screen after the connect tap instead of relying on the driver's automatic settle, which raced the screen transition. |
|
Merged: the push entitlement removed, 235 lines deleted and 20 added. expo-notifications wrote an APNs push entitlement into the iOS build, and the distribution profile has no push capability, so the first iOS archive failed. The module is out of the native app until per-episode notifications are built (local notifications need no push entitlement), along with its plugin, its permission suppressions and the entitlement check in the privacy script. |
|
Merged: the Capacitor shells removed, 4,300 lines deleted and 147 added (net -4,153). Bundles: web initial load -3.3 kB (135.8 kB), web all js and css -5.2 kB (225.6 kB), Expo iOS bundle -43 B (5.03 MB), Expo Android bundle -23 B (5.06 MB), Play download -87 B (16.85 MB). Functions over cognitive complexity 15 unchanged at 11, worst 41, all in the web UI. The Capacitor iOS and Android shells, their five packages, the platform seams, the ios and android CI jobs and the Capacitor release lanes are gone; the Expo app is the only native codebase. The reader that migrates the old app's data on first launch stays until its sunset. |
|
Merged: the Android launch crash fixed, 118 lines added and 3 deleted. Bundles: web unchanged, Expo iOS bundle -99 B, Expo Android bundle +8 B, Play download -11 B. Build 1201 died at boot with "EXPO_PUBLIC_TRAKT_CLIENT_ID is not set". The release workflow set the variable on the prebuild step only, and Metro inlines it while bundling inside the Gradle and Xcode builds, which ran without it; both platforms shipped an empty client id. The Fastlane steps and the CI Android bundle now receive it, a workflow test requires it on every bundle-producing step, and a new android-e2e job installs the signed CI build on an API 35 emulator and fails on any fatal exception at launch. |
|
Merged: the web app removed, 30,797 lines deleted and 891 added (net -29,906). Bundles: Expo iOS bundle -1.8 kB (5.03 MB), Expo Android bundle -1.9 kB (5.06 MB), Play download -100 B (16.85 MB); the web size ceilings left with the web build. Functions over cognitive complexity 15: 0 (was 11, all in the web UI); worst function now 15. The DOM application, its unit tests, its Playwright suite and its Vite tooling are gone; the Expo app is the only UI over the shared core. Twelve network-behaviour checks that only the browser suite exercised (per-flow request budgets from the fake Trakt's journal, the fault modes, token refresh, the rate-limit pause) now run headless in the core's harness against the same fake Trakt, each proven by a mutation. The repository-level CI tests moved to a root test project. Coverage percentages rose in every category. |
|
Merged: the core's read layer reshaped, 1,662 lines deleted and 1,403 added (net -259; core source -787, native screens and tests +528). Bundles: Expo iOS bundle -10.1 kB, Expo Android bundle -10.0 kB, Play download -3.0 kB. Reads are query option factories under the core's |
|
Merged: the startup-time gate, 348 lines added and 12 deleted. Bundles: Expo iOS bundle +478 B, Expo Android bundle +567 B, Play download +155 B. The simulator lane now measures a returning user's launch: the app publishes the time from process start to its idle marker (React Native's own startup timing) in the accessibility tree, and a second-launch Maestro flow reads it and asserts it against a ceiling kept in a ratchet file that carries its measurement, run and date and can only be edited downward. The ceiling is 600 ms from a measured 466.5 ms plus the observed run-to-run band; a planted three-second block at boot failed the flow on a throwaway PR, and raising the ceiling fails the ratchet test. The runner-timed launch-to-idle duration is reported in the job summary. |
|
Merged: honest sizes, 487 lines added and 387 deleted (net +100), plus a harness fix. Sizes: Firebase tester APK 102.84 MB to 36.97 MB (arm64 only; the Play bundle keeps every architecture), Expo iOS bundle +107 B, Expo Android bundle -34 B, Play download estimate +16.2 kB (launcher icon and splash), iOS Release simulator app first reported at 92.80 MB. The footprint comment carries the tester APK and the iOS simulator app beside the Play estimate, each labelled with what it measures; the tester APK and the IPA have downward-only ceilings; the launcher icon, adaptive icon and splash are back; the release workflow runs only on manual dispatch and strict vX.Y.Z tags. The network harness tests advanced fake time from the fake server's journal, before the client had registered its retry timers, which failed under load; they now wait on the client's own state. |
|
Merged: the first Android run fixed, 421 lines added and 266 deleted (net +155), plus two harness commits. PR #24 now stands at +27,576 / -37,771 over main. Sizes: bundles and the tester APK unchanged beyond three small PNG placeholders the fake Trakt now serves. The marquee card rendered as an empty block on Android because its border width reset to an invalid value when artwork arrived and the rounded clip then hid every child; it is set explicitly now. The tab bar lost its icons to release resource shrinking (they are looked up by name at run time) and hid inactive labels in Material's automatic mode; a keep file and labeled mode fix both. Library, Calendar and Search show a styled coming-soon state instead of raw text. The emulator job signs in against the fake Trakt, uploads screenshots of all four tabs in light and dark on every run, asserts all four tab labels, and takes four minutes. Before and after screenshots are on PR #51. The network harness tests now wait on the client's own events instead of polling. |
|
Merged: pull request validation in minutes, 468 lines added and 416 deleted (net +52; workflow and script text alone net -20). Sizes unchanged (tester APK 37.93 MB, Play estimate 17.24 MB, Expo bundles 5.01 and 5.04 MB). A JavaScript-only push now validates in 12 minutes 26 seconds, green, down from 37 to 45 minutes: a fingerprint job hashes the native inputs, the iOS and Android builds are restored from cache when the hash is unchanged, fresh JavaScript is re-embedded into the cached apps, and the simulator and emulator flows run against that. A native change still rebuilds (about 18 minutes measured). The footprint job no longer builds anything (29 seconds). Every check remains a gate. The wrong Node version is refused at install, pre-push and check. The Android emulator sign-in failure was Maestro reusing a host-chosen port that was taken inside the emulator; the port is pinned. |
|
Merged: show detail and the episode sheet, 2,775 lines added and 153 deleted (net +2,622: two screens, their components, Jest and render-count tests, two Maestro flows, core domain functions and the undo fix). Sizes against the previous tip: Expo iOS bundle 5.01 to 5.13 MB, Expo Android bundle 5.04 to 5.17 MB, Firebase tester APK 37.93 to 38.57 MB, Play download estimate 17.24 to 17.74 MB. Binary-Size: two native modules the design names arrive with these screens, expo-image and expo-web-browser. Show detail has the opaque bar, the continue bar in four variants, seasons with Specials last, About, a native overflow menu and the season confirm in its platform shapes. The episode sheet has the mark row, plays badge, spoiler guard, unaired and missing-still variants and a replacing pager; its body does not scroll, because a scroll view under this kind of sheet does not attach reliably, so the still is the elastic element and large text opens the sheet at its tall height. Undo now repaints the episode immediately (this also fixes Up Next's undo). The fake Trakt keeps a device grant pending until approved, as Trakt does, which removes the sign-in flake from every flow. Stills in light, dark and the largest text size and a clip per flow are on PR #43. |
|
Merged: gates with a basis, 899 lines added and 1,504 deleted (net -605). Sizes unchanged. PR validation 13 minutes 31 seconds. Gone: the complexity table, comment densities, top-ten dependency tables, most line tables, the simulator app size, every measured-plus-headroom size ceiling and the 64 kB allowance, with the scripts that measured them. The footprint comment now carries net product, test and product-comment lines and the sizes a user receives, base against head. Any size increase needs a Binary-Size line, net product growth a Product-Growth line, added product comments a Comment-Load line, and a missing base fails instead of passing. Startup is the median of five returning-user launches at or under 1,000 ms; tap to visible feedback for mark and undo is the median of five at or under 100 ms; the emulator run fails on any ANR; coverage requires lines changed in the core to be covered instead of percentage floors. Render counts stay gated at zero deviation; render durations are not gated or reported, because Reassure's own verdict flipped between two attempts on identical code on hosted runners (the full output is an artifact). Distributor limits (Play 500 MB base module, Firebase 2,048 MiB) live only in the release workflow. |
…he native launch (#210)
… one sign-out (#208) - Search's "Added ✓" and "+ Watchlist" pills draw the platform's check and plus symbols. - Sign out lives on Profile only; Settings no longer repeats it. Profile shows the Trakt username. - Library's sort is a bar item beside the avatar: a native bar menu on iOS, a sort icon button with a dropdown on Android. BarItems takes the menu and redeclares the bar only when the menu's look changes. - Row titles in Up Next, History, Calendar and Search stop at two lines; detail shows them whole and the row's spoken label keeps the full title. - A filter with no match in the current chip names the other chips it matches in, as buttons that switch to them. - Chevrons and the remaining hand-drawn icons (sort, bell, swipe check and stop, avatar placeholder) come from SF Symbols and Material icons through PlatformIcon. A lint rule keeps react-native-svg to the Cue mark, and a test rejects typed check and cross glyphs. - The iOS role override on Toggle's Switch is a registered platform-owned exception judged against react-native 0.86.3, so a React Native upgrade reopens it (react/react-native#59333, #59334).
… Trakt reads (#209) * Count episodes left from the next-up episode, and recover from failed Trakt reads - "N left" counts the aired, unwatched episodes from the next-up episode on, from the progress read's season tree, so seasons skipped before it no longer count. Entries and show progress carry the count; the persisted cache buster is bumped. - Whenever Trakt answers a poll or a pull, active reads that failed are fetched again. - The library load retries each failed member read on its own, with jittered backoff, instead of failing and re-reading everything; it publishes the failure it is retrying past, and Up Next says "Still trying to reach Trakt" instead of a long skeleton. - A 429 without Retry-After backs off per read with full jitter rather than pausing every read for one second; a write honors Retry-After up to five minutes. - A read whose token refresh failed on Trakt's side or offline fails that way, not as a sign-out; detail screens name the actual failure; a dead refresh token ends the session once. * Cover the library load's retry signal and the poll's teardown guard - A behaviour test drives the real library load against a Trakt whose hidden list fails once: the screen hears it is still trying, with the failure, until the read lands, and stops saying so once its own fetch is cancelled. - A poll whose session ends while a failed read is fetched again never advances the activity baseline. - While the load retries, the status names the failure it is retrying past rather than an older one. * Share the held-read teardown setup across the activities poll tests
… for them (#211) A Perfetto trace of an Android cold start (perf probe run 39911621692) shows the first network request loading the system WebView on an OkHttp thread, from 2388 to 2430 ms, while four other request threads wait about 35 ms each on it. React Native's NetworkingModule uses CookieJar.NO_COOKIES only when a request's withCredentials is false. Otherwise its cookie jar forwards to WebView's CookieManager, and the first CookieManager.getInstance() loads Chromium. React Native's XHR defaults withCredentials to true, and whatwg-fetch changes it only for credentials "include" or "omit", so every fetch with the default "same-origin" went through that jar. Trakt authenticates with a bearer token and Cue never reads a cookie, so the Trakt client, the OAuth calls and the runtime's authorized fetch now send credentials "omit". The harness perf channel does the same, so a probe measures the app without it. The persisted shape witness is reseeded: only a function and two defaults changed, no type.
…eres (#213) * Calendar: one row per season drop, and watchlist shows only for premieres When a show releases several episodes at the same moment, the Calendar now shows a single row for them, such as "S2 E1–E8 · 8 episodes", and reads it out as "season 2, episodes 1 to 8, 8 episodes". Episode reminders and the daily summary read the same rows, so a season drop is still one notification and the summary names its range. By default the Calendar and On the way now list every episode of the shows you have played, but only premieres (episode 1 of a season) of shows you have never played, such as ones that are only on your watchlist. Whether you have played a show comes from the library Cue already loads, so this adds no Trakt requests. A new Settings switch under Content, "Every watchlist episode", brings back all episodes of watchlist shows. It is off by default. * Bump the persisted cache buster for the new calendar row shape * Calendar: wait for the library before listing episodes Which episodes the Calendar lists depends on the shows you have played, which comes from the library. On a first sync the calendar can arrive before the library, and the screen used to show every episode and then remove some once the library loaded. It now keeps its loading placeholders until both have arrived. The library is saved between launches, so this only affects the first sync. On the way in Up Next already waits, because Up Next shows it only once the library has loaded. * Reminders: still alert for a show's later episode that day A show's alert for the day was timed to its first episode. If that episode had already aired when Cue planned its reminders, the whole alert was skipped, so a later episode the same day got no alert. For example, with episodes at 10:00 and 21:00 and Cue opened at noon, the 21:00 episode was never announced. The alert now goes off at the next episode that has not aired yet, and names only the episodes still to come. When every episode that day has aired, there is still no alert. The persisted cache buster is bumped because the reminders module is one of the files it tracks. * Reminders: one alert per drop, at that drop's air time A show used to get one alert per day, timed to its first episode and naming every episode that day. With episodes at 20:00 and 21:00, the 20:00 alert said both were out, which was false for the second one. Reminders now follow the same grouping as the Calendar rows: one alert per drop, where a drop is a show's episodes airing at the same moment. Each alert goes off at its drop's air time and names only that drop's episodes, so a full season released at once is still one alert. Drops that have already aired get no alert. The persisted cache buster is bumped because the reminders module is one of the files it tracks. * Settings: give the watchlist switch its own Calendar section The "Every watchlist episode" switch sat right under the note that explains the TV shows and Movies switches, so it read as part of that note. It now has its own section, headed Calendar, right after Notifications, whose alerts are about the same episodes.
… offline (#214) * Use TanStack Query's online and focus managers, and pause reads while offline Nothing told TanStack Query when the device went offline. Its onlineManager only listens for the browser's online and offline window events, which React Native does not have, and nothing in the app called it. Every read therefore ran while offline and used up its retries before showing an error, instead of pausing and resuming on reconnect. focusManager had the same gap for the background. - Feed onlineManager from expo-network and focusManager from AppState, as TanStack's React Native guide describes. This replaces the Network and AppVisibility ports, their native adapters and their providers. - useIsOffline reads onlineManager, so the offline strip and search share one source of truth with the query layer. - The activities poll is now a query with a 60 second refetchInterval plus focus and reconnect refetches. It still flushes pending writes first and commits activities only after the invalidated reads succeed. Reconnect still flushes pending writes while the app is in the background. - A read paused offline with nothing cached now counts as loading, so a screen shows its skeleton and the offline strip rather than an empty state. - refetchFailedReads skips reads already being fetched, so a poll that once failed cannot cancel and restart itself. * Tighten network, focus and baseline handling after review - Subscribe to network changes before reading the launch state, and ignore the launch read if a change event has already arrived, so a stale launch answer cannot overwrite a newer event. - Treat the device as offline when either isConnected or isInternetReachable is false. A missing or null value still counts as online. - Seed focus from AppState.currentState when the listener is installed, so an app launched in the background starts unfocused. - Flush pending writes from the reconnect listener only in the background. In the foreground the poll query already flushes on reconnect. - Advance the activities baseline only when every active, enabled read it invalidated has finished successfully and is idle. A read paused by a dropped connection now keeps the baseline, so the next poll sees the change again. * Count any launch state but background as focused, and trace focus on pull - Seed focusManager with AppState.currentState !== "background". iOS launches the app in the inactive state, and React Native reports "unknown" for any state it does not map, so an active-only seed could leave the app unfocused until a change event arrives. Change events still count only "active" as focused. - The activities poll now requires a runtime, like the other hooks RoutedApp calls. It only ever renders under RuntimeBoot, so the no-session branch could not run. - The pull-to-refresh harness trace now records focus and network changes, so an end-to-end pull that never starts shows whether either one moved.
* Remove the UI test harness so the tested app behaves like the store app The Maestro lanes ran a special build: Metro swapped in readiness and timing markers, a perf command channel, a scroll and pull trace, and an Undo snackbar that never dismissed itself. The store build carried stand-ins instead, so the app under test differed from the app users get. Delete the harness modules, the Metro swap and the Babel plugin that stripped perf hooks, and keep each call site's store behaviour. Undo snackbars now always dismiss on their timer, as in the store app. The fixed test clock (EXPO_PUBLIC_CLOCK_START) stays, because the fake Trakt server's seed counts from it. Delete the tests that only exercised harness code and maestro-ids.test.ts, drop test ids nothing reads any more, and add Jest coverage for two behaviours only the deleted device flows checked: a show marked from the lapsed drawer files back into the queue, and the TV-off empty state leads to Settings. * Replace the custom device-lane CI with a standard Expo smoke suite Pull request CI now runs what ordinary Expo apps run: lint, types, Jest and Vitest in check, plus six Maestro smoke flows on an Android emulator and an iOS simulator. The flows sign in through the device code against the fake Trakt server, mark the next episode from Up Next, visit every tab, open a show and a movie and mark an episode, search, and sign out. Each one asserts the state its taps produce and takes screenshots of what it visits. Each e2e job fingerprints the native project with the @expo/fingerprint CLI and keeps the release build in actions/cache under that hash. On a hit, @expo/repack-app swaps the commit's JavaScript into the cached build; on a miss the job builds and saves the cache straight away, so a failing flow does not throw the build away. The emulator comes from reactivecircus/android-emulator-runner with its documented AVD cache, the simulator from xcrun simctl, and Maestro from its official installer at a pinned version. Nothing retries. Delete what only served the old lanes: the tap proxy and patched Maestro runner, lane health and first-attempt checks, the simulator daemon, spotlight and accessibility tweaks (except disabling apsd and chronod, which slowed the first launch), the JavaScript re-embed scripts, the pixel diff and PR media, the perf probe, the native size measurements, the old flows, and the test/ci tests and config entries for all of them. The footprint job becomes a report-only PR comment of line counts and JavaScript bundle sizes. CI can be dispatched by hand, which is how the suite's determinism is checked. * Remove the braces audit ignore, which no longer applies pnpm audit no longer reports GHSA-vfj7-8cjw-p6xm against the locked braces, so check-stale-audit-ignores fails on any change that touches the lockfile. * Scroll to Season 1 in the detail smoke flow Show detail opens with the next episode's season expanded and lists seasons newest first, so Season 1 starts below the fold and Season 3's episodes sit above it. * Keep the detail smoke flow to opening a show and marking an episode On Android, Maestro did not find Season 1's episode rows after the season was expanded, although the screen showed them. Expanding a season is covered by season-row.test.tsx, so the smoke flow no longer scrolls to and expands Season 1. * Run the Android smoke flows on the Automated Test Device image A run failed because Android's launcher (Quickstep) stopped responding over Cue's welcome screen on a freshly booted emulator. Cue itself had drawn its first screen in 2.8 s. The ATD system image is Android's image for automated tests: it removes Launcher3QuickStep, SystemUI and other apps tests do not use (developer.android.com/studio/test/managed-devices#gmd-atd). android-emulator-runner tests itself on aosp_atd at API 34. * Go back to the default emulator image, with four cores The ATD image captured every Android screenshot blank, because it turns off hardware rendering. Instead the emulator gets the runner's four vCPUs (android-emulator-runner's cores input; the default is two), since the launcher stopped responding on a software-rendered emulator. Repeat runs will show whether that is enough. * Disable the emulator's launcher before the Android smoke flows Two of three runs failed on "Quickstep isn't responding": Android's launcher, not Cue, stopped responding over Cue's screen. Four emulator cores did not change that. Android's Automated Test Device image leaves Launcher3QuickStep out because tests do not need a launcher (developer.android.com/studio/test/managed-devices#gmd-atd), but that image also turns off hardware rendering and captured every screenshot blank. So the default image keeps rendering and the flows disable the launcher package. * Keep the app running between smoke flows and pin the iOS runtime In 50 repeat runs, two iOS taps went to SpringBoard's app switcher right after Maestro stopped and relaunched Cue between flows, so later flows now launch with stopApp: false and switch to Up Next. The simulator now runs the iOS runtime matching the selected Xcode SDK; it had picked iOS 26.4. The sign-out flow stops at the confirmation dialog: the confirm tap was cancelled by the simulator in one run, and profile.test.tsx covers confirming sign-out. The Android emulator package is cached with actions/cache (docs.github.com/actions/reference/workflows-and-actions/dependency-caching), since one run failed unpacking a fresh download before the emulator started. * Give the sign-out dialog a 60 s wait in the smoke flow In one of 50 repeat runs the tap reached Cue and the dialog appeared, but the simulator's accessibility service stalled for about 33 s, past the 30 s wait. * Re-run the iOS smoke suite once from a clean state when it fails After the fixable causes were removed, 50 repeat runs passed 50/50 on Android and 48/50 on iOS; the remaining iOS failure is the simulator cancelling a synthesized tap, which has no documented fix (Maestro's iOS tap retry is a no-op, mobile-dev-inc/Maestro#3685). As EAS Maestro jobs do with retries: 1, a failed iOS run is repeated once after resetting the fake Trakt server and reinstalling the app. Both runs' screenshots and logs are uploaded, and a real defect still fails both. Android stays a single run. * Swipe to mark on Android, and tighten the Android job's caches The Android smoke flow now swipes a queue row to mark it, so a gesture library change that broke or inverted the swipe fails on a device rather than only in Jest's fake. Gradle's cache is restored only when the native build cache missed, instead of 1.6 GB on every run, and the emulator package cache is keyed on the runner image version. Also drops an unused permission, an upload path nothing reads, a stale comment basis and an unused spelling entry. * Update zod to 4.6.7 for GHSA-mrhx-c7xc-m3w5 Zod before 4.6.6 can exhaust memory validating a large array against an uncapped array schema; Cue's core parses Trakt responses with it. A pnpm override moves every copy to 4.6.6 or later, including Expo CLI's zod 3, which only uses basic schema builders (string, object, enum, array, literal, union) in its dev-tools plugin. The braces audit ignore no longer applies and is removed.
* Add one Trakt fake built on MSW, checked against recorded Trakt The fake lives in packages/core/test/trakt-fake: the account, its read bodies, the write endpoints, OAuth sessions, the rate limiter and every route the app calls, as MSW handlers. Tests can run it in process, and serve.ts puts the same handlers behind a small node:http server for the smoke flows. It follows recordings of real Trakt made on a throwaway account: refresh rejections answer 400 invalid_grant, a pending device poll answers 400 with an empty body, 404 and 401 bodies are plain text, pages default to 100 rows and cap at 250, watchlist writes move only their own type's activity and answer with the list's item count, a season mark splits added from updated and echoes a missing season's whole entry, and removing a movie removes every play of it. Behavior that cannot be recorded follows Trakt's documentation: 7-day tokens, the device token statuses, and one authed POST per second and 500 GETs per 5 minutes on the fake's clock. recordings.json merges the recorded read shapes, the earlier behavior transcripts and the new recordings. conformance.test.ts drives the fake through scenarios.ts and compares statuses, content types, body shapes and write semantics with them, and fails when a recorded read answers 404. * Run the core tests against the one Trakt fake The tests that wrote their own MSW Trakt responses now read the fake's default account, check what the app sent through the fake's request log, and get failures from its one-shot helpers: fail, hold and drop. Bodies that are deliberately off-spec stay as decode tests over a stubbed fetch. The fake's own internals tests, the request journal, the in-process server wrapper and the third fake inside read-budget.integration are gone. That file's cold-sync cases that the fake can express moved into scenario-budget; the ones about watched rows the fake does not produce (a restarted show, more plays than aired episodes, an unstamped row, a broken progress body) are unit tests in read-budget.test.ts. A short fake.test.ts covers what the fake does from Trakt's documentation: token lifetime, the device token statuses and the rate limits. The fake now stamps each play with its own watched_at, can peek at a read without spending a request, and resets to either Multiple Plays setting. scenario-budget waits out a rate-limit pause the way a person would. A heavy library's first sync sends more than the 500 GETs Trakt allows in five minutes, so with the limit enforced it pauses before it settles; that scenario was already marked over budget. TRAKT_API_BASE is no longer exported from oauth.ts and client.ts, since no test imports it. The persisted cache shape witness is reseeded for that; no persisted shape changed. * Serve the fake to the smoke flows and delete scripts/mock-trakt The smoke jobs start the fake with `node packages/core/test/trakt-fake/serve.ts`, which Node 22 runs directly by stripping the types. It answers on 127.0.0.1:8787 as before and keeps the two controls the flows and the iOS re-run use, POST /__approve and POST /__reset. Its artwork points at three placeholder images it serves itself, so screenshots still show posters without reaching Trakt's image host. The old server, its fault, scale and journal modules and its seed are deleted. `pnpm fake:trakt` replaces `pnpm mock:trakt`, and the README describes the one fake and how it is checked. * Allow MSW imports only inside the Trakt fake oxlint's no-restricted-imports now rejects `msw` and `msw/*` everywhere except packages/core/test/trakt-fake, so a test cannot grow its own hand-written Trakt responses again; it has to go through the fake. * Calendar: tell premieres by Trakt's episode type For a show you have never played, the Calendar and On the way list only premieres. A premiere used to mean episode 1 of a season. It now means an episode Trakt types as series_premiere or season_premiere, which Trakt sends on every calendar row (seen on a real account). A season numbered on from the last one, such as an anime whose second season starts at episode 62, now counts. Episode 1 is still the rule when a row carries no type. Calendar entries keep the type, so the persisted cache buster is bumped. * Write the fake's seed word lists one line each * Run the progress budget's selection tests through the fake The fake now models Trakt's "restart show": a show can carry a reset date, its watched row sends reset_at, and progress counts only plays after it. A show spec can also override two watched-row fields the play counter cannot derive, a missing last_watched_at and a different aired_episodes, so the cases about unusual watched rows read the fake instead of a stub fetch. This also restores the case lost when read-budget.integration was folded: 20 recently watched shows that are caught up, ahead of 80 with episodes left. The cold sync still spends all 60 progress reads on shows with episodes left; it fails if the budget is cut before caught-up shows are filtered out. The broken progress body case moved to the tolerant decoding tests, which are the only ones that stub fetch with off-spec bodies. * Fail read-retry's hidden list through the fake's fault helper * Restore the request total, entry count and rotated-token checks the migration weakened * Stamp the fake's activities to the second, with no bump past earlier ones Real Trakt stamps each activity with the time of the write, cut to the second. The fake used to push every stamp at least 1 ms past the previous one; no test depended on that. * Keep every device code valid for its own 600 seconds A new device code used to replace the previous one, so polling an earlier code answered 404. Each code now stays valid until its own expires_in runs out on the fake's clock, and approving or denying acts on the latest code, the one on screen. fake.test.ts drops the case that only checked the fault helper answered what it was told. * Allow only erasable syntax in the fake, and tidy scenarios.ts The core test tsconfig turns on erasableSyntaxOnly, since serve.ts and the modules it imports run under Node's type stripping. The scenario recorder's constructor no longer uses parameter properties, its pass-through call method is gone, and a stale doc comment is removed. The file's header no longer claims the scenarios were run against real Trakt as written. * Document the fake and where each of its recordings came from The fake's own README now describes its modules, the rules for tests that use it (only the tolerant decoding tests stub fetch, with off-spec bodies), how conformance works, and the provenance of each group in recordings.json: the date, the throwaway account and the paced, guarded recorder. The repository README points to it.
* Leave the aired frontier unknown for a backlog show without a progress read A show past the 60-show progress budget took its last aired episode from the last episode the user watched. For a show with episodes still to watch that frontier is behind the real one, so the recent calendar counted episodes Trakt's aired_episodes already counted, flagged the show as having new episodes, and the Up Next snapshot read its season tree and specials progress to place it. On a large library that was two reads for every backlog show with an episode in the last 33 days, on every launch. The last watched episode is the last aired one only when nothing is left to watch. For any other show without a progress read the frontier is now null, which the calendar reconcile already treats as unknown and leaves alone. Caught-up shows keep their frontier, so a new season of a show the user finished is still found and placed. * Filter calendars by the library's hidden flags Each calendar window read the hidden and dropped lists itself, so a first sync read them three times: once for the library and once for each of the two calendar windows. Stopping a show in the app also re-read both calendar windows, with their hidden lists, only to filter one show out. The library already marks every hidden or dropped show, and every show on the user's calendar is in the library. The calendar read now returns only its episodes. The Calendar tab, Up Next's coming-up list, episode reminders and the recently-aired reconcile filter by the library's hidden flags through a new useHiddenShowIds hook, and stopping a show no longer invalidates the calendars. A show stopped or dropped on another device now leaves the calendar as soon as the library reloads, instead of when the calendar's hour-long cache next refreshes. * Read the shows watchlist once, through the library Search's add-to-watchlist button kept its own query over the shows watchlist, so a first sync read every watchlist page twice: once for the library and once for that query. The button already treated every library show as listed, and the library holds every watchlisted show, so the second read added nothing. Show membership now comes from the library alone. The movies watchlist query stays, because the movie library is not loaded until the Library tab asks for it. * Share a pooled read with callers still waiting for a slot The Trakt client joined identical GETs only once a request had been sent. Every pooled read takes one of six slots before it reaches the client, so when two screens asked for the browse rails while Trakt was slow, the second screen's reads that were still queued for a slot found the first ones finished and sent their own: six requests for four rails. Identical pooled reads now share one promise from the moment they are asked for, so a caller waiting for a slot joins the read already queued or in flight. This replaces the client's own in-flight map, which only covered reads that had already left. * Keep library shows' season trees across launches When the recent calendar shows a new episode of a show the user had caught up on, Up Next reads that show's season tree to find the next episode. The trees were not persisted, so every launch read them all again, two requests each, even when the persisted library and calendar were fresh and nothing had changed on Trakt. A season tree is now persisted for a show in the library, by the same rule as the show art a library card paints. It keeps its one-hour content stale time, so a launch within the hour reuses it and a later one refreshes it. * Assert the launch scenarios' read counts and bump the cache buster The scenario replay now fails if Trakt's rate limit refuses any read, if a scenario that writes nothing fetches the same URL twice, or if a warm launch on a fresh cache reads anything beyond the activities poll and the Search tab's four rails. The calendar hidden-flag change is no longer listed as pending in the known-over table. The calendar data no longer carries hidden show ids, so the persisted cache buster is bumped and older caches are discarded. * Keep only the movie watchlist query Show watchlist membership now comes from the library, so the watchlist query, its runtime loader and Search's add and remove paths only ever handle movies. They are narrowed to movies, and the unused shows paths are removed. * Hold the calendar until the library says which shows are hidden The calendar filters stopped and dropped shows by the library's flags, but with "every watchlist episode" on it did not wait for the library, so a hidden show could show until the library arrived. useCalendarFocus now owns both sets the calendar needs from the library, played and hidden, and holds the Calendar on its loading plates until the library answers, whatever that setting is. If the library read fails, the calendar shows every row unfiltered, as it already did for played shows. Up Next's coming-up list takes the hidden set from the same hook, and reminders take it from the library snapshot they already read, so the separate useHiddenShowIds hook is gone. * Test the pooled read sharing directly Pooled reads are shared only between callers asking for the same thing, a failure reaches every caller of a shared read, and the next call after it reads afresh. These replace the client-level coalescing test removed when the sharing moved into the pool. * Refresh show trees when watched history changes on another device A show's season tree, progress and episode details each merge in the user's per-episode watched state, but an activity change only refetched the library and history. With season trees now kept across launches, a tree could show an episode as unwatched after it was watched on another device. Changes to watched history, progress or hidden shows now invalidate every show's progress, season tree and episode queries, so the ones on screen are read again. Season trees are now persisted only while a screen uses them: Up Next placing a card, or a detail screen that is open. A detail visit no longer adds its tree to the persisted cache for good. The replay gains two scenarios. One opens a show, writes a play for it straight to the fake as another device would, relaunches warm, and checks that the tree is read again and shows the episode watched. The other checks that a backlog show past the progress budget stays out of Up Next even when an episode aired lately, and that Library counts its episodes left. The cache witness is reseeded; no persisted shape changed. * Share the calendar's held-library test steps The two tests that hold the library read and check the Calendar waits for it now share one helper instead of repeating the same steps.
Remove repository scripts and tests that reimplemented checks already provided by pnpm, oxlint, actions/cache, TanStack Query and Reassure: - The changed-coverage gate and its Coverage-Rationale escape. Nothing else read the coverage report, so Vitest no longer collects it. - write-buster.mjs and the generated persist-buster.ts. The persisted query cache is now busted by the running expo-updates update id, or the native version and build number when updates are off, so each build or update starts with one cold sync. - check-node-version.mjs. engineStrict makes pnpm install and pnpm run fail under any Node other than engines.node. - jest-cache-key.mjs, replaced by a hashFiles() cache key. - check-quality-budget.mjs, replaced by oxlint ban-ts-comment and no-warning-comments over suppression directives. - The Vitest stall guard, the IPC, git-env, local Jest worker, spell coverage and executable-script tests. Test git processes now run without the hook's GIT_* variables, stripped in the Vitest config. The job timeout table is now a check that every job sets one. - check-native-assets.mjs and .native-assets.json. - The render-count gate, its baselines, its base-tree checkout and the use-tree-node action. The Reassure tests assert each scenario's render count themselves.
… Search's rails on first visit (#221) * Refresh a show's watched state only when its library entry changed Since season trees are kept across launches, any change to watched history or progress invalidated every show's progress, season tree and episode queries, so the trees Up Next places cards with were read again. That included changes this device made itself: after a mark and its undo, the next poll saw the activity stamp move and re-read every tree on screen, 80 reads for the large test library (89 GETs became 169). Trakt stamps activity to the second, so the stamp cannot tell this device's write from another device's write in the same second. The library reload the poll already does can: each show's count of episodes watched and its last play are compared with the cached entry, which already carries the app's own optimistic marks and undos. Only shows that differ have their progress, tree and episode queries refreshed. A play this device sent matches what Trakt returns once both are read to the minute, which is how Trakt stores play times. A change to the hidden list still refreshes every show, because a hidden season changes a show's tree without changing its library entry. The fake now moves a show's last watched time when a second play is added with Multiple Plays on, as Trakt's watched list does. Tests replay a mark, an undo and the next poll with exact reads per route, and check that an open show's tree is read again when another app writes it in the same second as this device marks a different show, when another app writes the show this device just marked before the next poll, and when another app adds a second play, and that it is not read again after this device's own mark. The cache witness is reseeded; no persisted shape changed. * Load the Search tab's browse rails on its first visit The Search tab mounts as soon as the opening tab settles, and its browse rails were read on mount, so every warm launch spent four reads on the trending and popular rails whether or not Search was opened. A warm launch with a fresh cache made five reads instead of only the activities poll. The rails query is now enabled once the Search screen is first focused, through expo-router's useFocusEffect, and stays enabled after that, so leaving Search and coming back does not read them again. Until the rails arrive, including the moment before the first focus registers, Search shows its existing browse skeleton rather than empty space. The scenario replay's stand-in for the tab roots no longer reads the rails, and a warm launch now asserts exactly one read: the activities poll. A native test checks that the rails wait for the first visit, show the skeleton while they load and are not read again on the next visit; the tab switching test allows Search to render once, on that first visit. * Let the library query refresh the shows a reload finds changed The rule that keeps query-invalidation.ts behind library-cache.ts exists so a new mark surface cannot refresh a show's detail reads without also moving its Up Next row. The library query reaches the same module for the opposite job: after a reload it refreshes the detail reads of each show whose entry changed. It is now allowed alongside library-cache.ts. * Match the Trakt fake to recorded plays and last play times A recording of real Trakt with Multiple Plays on settled how a show's watched row and progress move when an episode has several plays: - a play dated now moves last_watched_at forward, a backdated one does not, and plays goes up either way: last_watched_at is the newest play by date - removing the newest play by history id rolls last_watched_at back to the previous play, and plays drops - an unaired special's play counts in plays but never moves last_watched_at, and progress ignores it - an aired special that is the show's only play sets last_watched_at, and removing it takes the show off the watched list The fake now keeps every play of an episode rather than at most two, counts them all in the show's plays, and recomputes the show's last play after each write. Two scenarios repeat the recorded steps and their transcripts are in recordings.json. A shelf show with specials now has twelve, so some of them have not aired yet. The README notes the provenance and the two cases still unrecorded. * Say browsing needs a connection when Search first opens offline With the rails loading on the first visit, opening Search for the first time while offline paused the rails read with nothing cached, and the browse area showed its skeleton for as long as the device stayed offline. It now shows the same "Search needs a connection." state a query shows offline. The tab switching test now requires exactly one render, from Search, on the first round of visits. Search sets its opened flag only while it is unset: setting the same state again on a later visit still rendered the screen once more. * Compare libraries after each committed reload, and compare plays too The per-show comparison ran inside the library query's fetch. It also ran for a fetch that a mark then cancelled, and it compared server rows read before a write landed with the cache's optimistic state, so a show could be refreshed while its own write was still on the way. During an undo that re-read the tree before the removal reached Trakt and showed the episode watched again. The comparison now lives in a QueryCache subscriber in the runtime, mounted with the activities poll. It reacts only to a fetched, committed result of the library query and compares it with the library it replaces. Shows named by a queued or in-flight write, directly or through an episode in a cached tree or episode page, are left to that write's own refresh. The dependency rule exception for the library query is gone. Each library entry now carries the show's plays from the watched list, and the comparison uses plays alongside the count of episodes watched and the last play. Real Trakt keeps last_watched_at at the newest play by date, so a backdated play, a second play in the same minute, or removing one of several plays changes only plays. A mark from Up Next adds one play and its undo restores the entry it replaced. After any of this device's own writes lands, the refreshed progress now also sets the entry's last play, and its plays become unknown until the library next loads, because the app does not track how many plays its own write added or removed. Plays are compared only when both sides know them. Tests: a reload that lands during an undo's write leaves the tree and its checkmark alone; marking and undoing from Up Next, or toggling an episode on the show's page, re-reads no tree at the next poll; another app's backdated play, same-minute play and removal of one of several plays each refresh the tree. Unit tests cover the subscriber (manual edits and cancelled reloads ignored, written shows skipped) and the comparison. * Share the mark, undo and poll steps of the season tree tests The season tree tests repeated the same setup: mark a queued show from Up Next with its page open, press undo, and count the tree reads of the next poll. They now share three helpers, which jscpd asked for.
Pull request footprint
|
















Cue becomes a native app. iOS and Android ship as one React Native app built with Expo, over a shared TypeScript core. When this merges, the Capacitor shells, the web UI and their tooling are gone;
packages/coreholds the domain, the sync contract and the Trakt data layer once, andpackages/nativeis the app.What ships
previewprofile inpackages/native/eas.jsonbuilds with EAS internal distribution, an ad hoc IPA for registered iPhones and an APK for Android, on thepreviewupdate channel. The README lists the one-time owner steps: EAS sign-in, the Trakt client id in the EASpreviewenvironment, device registration and signing.Tester builds have shipped from
release/expothroughout; the latest is 2.0.0 (2101) on TestFlight internal testing and Firebase App Distribution. That build predates the runtime change, so it gets no published updates; the next tester build will.How it is checked
Every pull request runs lint, format, spelling, types, dead code, duplication, cognitive complexity (15 per function), dependency rules, the audit, core coverage on changed lines, render counts, request budgets per user flow, selected-state contrast in both themes (3:1 for state and glyphs, 4.5:1 for text, from WCAG 2.2), the size delta gate, the native fingerprint, both native builds, and on the Android emulator the 12 shared Maestro flows and the dark screenshot traversal. Three flows (pull to refresh, swipe to mark and stop, the lapsed drawer) are not in a CI suite yet.
Store builds and published updates contain no UI test harness. Metro replaces the harness with an empty module unless
EXPO_PUBLIC_UI_HARNESS=1, a dependency rule keeps every harness module behind that entry, and bothpnpm checkand Publish update fail if a harness module appears in an exported bundle. Publish update ships the same export it checked.The iOS lane (three flow shards, the dark traversal with large-text captures, contact sheets from both platforms) runs on every push to this branch, to
mainand to release branches, nightly, whenever the iOS fingerprint changes, and on any pull request branch whose last iOS run did not pass. It gates two timings on the simulator. Startup: the median of five launches must be at most 1,000 ms, timed from the JavaScript bundle load to the first Up Next render that has library data. Tap feedback: for mark and for undo, the 75th percentile of 15 taps must be at most 100 ms.With cached native builds, a pull request takes 13 to 16 minutes when the iOS lane is skipped and about 20 minutes when it runs, plus any wait for a macOS runner. A change to native inputs rebuilds both apps; the last one took 99 minutes, most of it queued for macOS runners.
Releases ship only a commit whose CI passed, and the iOS release lane waits for App Store Connect processing.
Before merging
mainruleset requirescheck,audit,actionlint,standardrb,androidande2e.androidande2ebelonged to the deleted web and Capacitor jobs and never report. Replace them with the checks the release gate requires:render-performance,native-android,native-ios,native-e2e,android-e2e,ui-screenshots-ios-dark,ui-screenshots-android-dark,codeql (javascript-typescript)andcodeql (actions).footprintand the coverage gate read the lines at the end of this description; without them both fail.maintree. None are open today.Product-Growth: +17,061 lines of product code, all new because main has no native app. This pull request also deletes the web and Capacitor app in
src/, 18,591 lines by the same count, so product code shrinks by 1,530 lines overall.Comment-Load: +152 comment lines. 96 are doc comments in the Swift and Kotlin native module, and the rest are one-line platform, Trakt and WCAG facts. The deleted
src/tree carried 4,139.Binary-Size: main has no native app, so each delta is the whole artifact: Hermes bundles of 5.36 MB on iOS and 5.40 MB on Android, a 41.96 MB tester APK and a 20.15 MB Play download estimate. That is the React Native and Expo runtime plus the app, and it replaces the Capacitor app.
Coverage-Rationale: against main every core line counts as changed, and core code written before the changed-lines gate existed has lines without tests. Every pull request into this branch since that gate landed has covered its own changed lines.