diff --git a/AGENTS.md b/AGENTS.md index e1607875e..a7ca09ffb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,58 +1,77 @@ -# Project Instructions - -## Pre-Commit Quality Gates - -All quality gates run automatically via Husky on every `git commit`, scoped to the file types you staged: - -| Staged file type | Checks that run automatically | -|---|---| -| `.ts` / `.tsx` / `.js` / `.jsx` | eslint (staged only), `tsc --noEmit`, `npm test` | -| `.swift` | swiftlint (staged only), `npm run test:ios` | -| `.kt` / `.kts` | `compileDebugKotlin` (type check), `lintDebug`, `npm run test:android` | - -**Requirements:** -- SwiftLint: `brew install swiftlint` (skipped with a warning if not installed) -- Android checks require the Gradle wrapper in `android/` - -Before writing new code, ensure tests exist for your changes. If the hook fails, fix the issue and recommit — never skip with `--no-verify`. - -## Testing Requirements - -Always write **both** unit tests and integration tests for new features and significant changes: - -- **Unit tests** (`__tests__/unit/`): Test individual functions, hooks, and store actions in isolation with mocked dependencies. -- **Integration tests** (`__tests__/integration/`): Test how multiple modules work together end-to-end (e.g., service A calls service B which writes to database C). Use mocked native modules but real logic across layers. - -Do not consider a feature complete with only unit tests. Integration tests catch wiring bugs, incorrect data flow between layers, and lifecycle issues that unit tests miss. - -## Push = Create PR + Address Review - -When asked to push code, follow this full workflow: - -0. ensure that you are on a branch that is specific to this change i.e feat/new-feature or fix/bug-fix or docs/update-readme or chore/update-dependencies, or test/new-test, etc -1. Push the branch to the remote (`git push -u origin `) -2. Create a PR using `gh pr create`. Ensure that you are adhering to the PR template. **Do NOT include "Generated with Codex" or any AI attribution in PR descriptions.** -3. Wait for Gemini to review the PR (poll with `gh pr checks` and `gh api repos/{owner}/{repo}/pulls/{number}/reviews` until a review appears) -4. Once a review exists, pull down the review comments: `gh api repos/{owner}/{repo}/pulls/{number}/comments` and `gh api repos/{owner}/{repo}/pulls/{number}/reviews` -5. Address every review comment — fix the code, re-run the quality gates (tests, lint, tsc). -6. Reply to **each** review comment individually on the PR using `gh api` (use `/pulls/comments/{id}/replies` endpoint). Every comment must get its own reply confirming what was done — do not post a single summary comment. -7. Push the fixes -8. Report what was changed in response to the review - -## CI Review Loop - -The repo has three automated reviewers on every PR. After pushing, loop until all are green: - -| Reviewer | What it checks | How to address | -|---|---|---| -| **Gemini Bot** | Code quality, style, logic issues | Read comments via `gh api`, fix code or reply explaining why it's fine, then comment `/gemini review` to trigger a fresh pass | -| **Codecov** | Test coverage thresholds | Add missing tests, ensure new code is covered. Check the Codecov report for uncovered lines | -| **SonarCloud** | Security hotspots, code smells, duplications, bugs | Fix flagged issues — especially security hotspots and duplications. Resolve quality gate failures before merging | - -**Workflow:** -1. Push code → wait for all three reviewers to report -2. Pull down Gemini comments, Codecov report, and SonarCloud findings -3. Fix issues: code changes for Gemini/SonarCloud, add tests for Codecov -4. Re-run local quality gates (`npm run lint && npm test && npx tsc --noEmit`) -5. Push fixes, comment `/gemini review` on the PR to re-trigger Gemini -6. Repeat until all three reviewers pass with no blocking issues +# Off Grid Mobile Engineering + +This file is the canonical instruction source for this repository. Keep it short. The engineering +standard is simple: write clean production code, exercise the real product, and do not make tests +pass by replacing the code they are meant to prove. + +## Engineering ethos + +- Follow SOLID, DRY, and clear separation of concerns. +- Put each decision and resource under one owner. UI renders state and sends intent; services own + business rules, side effects, and state machines. +- Depend on stable abstractions. Callers must not branch on concrete engines, providers, or platform + mechanisms. Add a seam when two real implementations need one; do not abstract speculatively. +- Reuse existing components, hooks, services, stores, and tokens before creating another version. +- Keep production code easy to test through explicit inputs and boundary interfaces. +- Follow the repository ESLint and Prettier configuration. Do not weaken a rule to land a change. + +## Testing + +No mockist tests. + +- Never mock Off Grid code. This includes modules under `src/` and `pro/`, plus our services, stores, + hooks, components, screens, navigators, parsers, and registries. +- Product behavior is proven with integration tests. Mount the real screen on the real navigation + stack, reach the state through real user gestures, run the real production path, and assert only + what the user can observe. +- Fake only a boundary the test environment cannot control or reproduce faithfully, such as a native + device API, model runtime, remote server, filesystem exhaustion, or OOM. Put the fake at that + boundary and keep all Off Grid logic above it real. +- Do not use direct store `setState` to manufacture an integration-test precondition. Do not use + `toHaveBeenCalled` as proof of product behavior. +- Unit tests are appropriate for pure functions and narrow contracts. They do not replace the + integration test for changed product behavior. +- When behavior is owned by Swift or Kotlin and can be tested there, add XCTest or JUnit coverage. + Keep a shared contract test when both platforms implement the same capability. +- For a bug fix, prove the regression test fails against the broken code before accepting green. +- New features and significant behavior changes require both focused unit coverage where useful and + a real integration test for the user journey. + +## Repository boundaries + +All paid feature code lives in the private `pro/` submodule. Core may expose registries and contracts, +but must not contain or directly import Pro implementations. A Pro change is committed and reviewed +in the Pro repository. + +For UI work, read `../brand/DESIGN_PHILOSOPHY.md` and the relevant files under `docs/design/`. Use the +shared typography, color, and spacing tokens. For copy or documentation, read +`docs/brand_tone_voice.md`. + +For physical-device diagnosis, use the dev-only `offgrid-debug.log` file exposed in Settings -> +Debug Logs or pull it from the `ai.offgridmobile.dev` app container. React Native logs from a physical +iOS device do not appear in Metro. + +## Quality gates + +Commits are intentionally ungated. The Husky gate is `.husky/pre-push` and checks the files in the +push range: + +- JS/TS: ESLint, `tsc --noEmit`, related Jest suites, dependency-cruiser, and knip. +- Swift: SwiftLint when installed and the iOS tests. +- Kotlin: compilation, Android lint, and the Android tests. +- Changed code also runs the configured Sonar scan. + +Before a push, run the relevant tests plus ESLint, Prettier, and TypeScript checks for the files you +changed. Fix failures. Never use `--no-verify`. + +## Branch and PR workflow + +- Never push directly to `main`. Work on a change-specific `feat/`, `fix/`, `docs/`, `chore/`, or + `test/` branch. +- Commit small green steps. Use merge commits for PRs; never squash or rebase-merge. +- A request to push means: push the branch, create or update the PR using the repository template, + wait for Gemini, Codecov, SonarCloud, and CI, then address every finding. +- Reply to each review comment individually with the change made or the evidence for keeping the + code. Re-run the gates and re-request review until no blocking issue remains. +- Do not merge without the user's explicit approval. +- Do not add AI attribution to commits or PR descriptions. diff --git a/CLAUDE.md b/CLAUDE.md index c3b9b4ad0..69507980a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,209 +1,4 @@ -# Project Instructions +# Repository instructions -## Repository Layout - -**All Pro feature code lives in the `pro/` submodule (its own git repo, `@offgrid/pro`) - not in core.** When changing or adding a Pro feature (e.g. TTS/audio, MCP/tools, and other paid surfaces), edit files under `pro/` and commit/PR them in that repo. Core only wires Pro in through the slot/hook registries; it never imports Pro code directly. Pro changes are a separate branch + PR from core (see `pro/CLAUDE.md`). - -## Device Logs (how to see what's actually happening on the device) - -**RN 0.83 moved JS `console.log` off the Metro terminal into React Native DevTools, and RN's console never reaches the iOS device syslog.** So `metro` stdout, `idevicesyslog`, and `npx react-native log-ios` (simulator-only) all capture NOTHING from a physical device. Do not waste time tailing Metro for app logs. - -Instead, a **dev-only persistent file sink** (`src/utils/debugLogFile.ts`, wired in `App.tsx` behind `__DEV__`) mirrors every `logger.*` line - which is where ALL the state-machine traces go (`[TTS-SM]`, `[GEN-SM]`, `[MODEL-SM]`, `[DL-SM]`, `[ROUTE-SM]`, `[IMG-SM]`, `[MEM-SM]`, `[FAIL-SM]`) - into a file in the app container. Pull it over the cable to read the real trace: - -The debug (Debug-config) build's bundle id is **`ai.offgridmobile.dev`** — Debug carries a -`.dev` suffix so it installs alongside the App Store / TestFlight build (`ai.offgridmobile`, -the Release config). The log sink is `__DEV__`-only, so you are almost always pulling from the -`.dev` container. Get the device UDID from `xcrun devicectl list devices` (it is per-device — do -not hardcode one): - -```sh -# Read the connected device's UDID from devicectl's JSON (parsing the human-readable table with -# awk is brittle — the last column is the device model, not the UDID). Or just paste the UDID. -xcrun devicectl list devices --json-output /tmp/devs.json >/dev/null 2>&1 -DEVICE=$(python3 -c "import json;ds=json.load(open('/tmp/devs.json'))['result']['devices'];print(next(d['hardwareProperties']['udid'] for d in ds if d.get('connectionProperties',{}).get('tunnelState')=='connected'))") -xcrun devicectl device copy from \ - --device "$DEVICE" \ - --domain-type appDataContainer --domain-identifier ai.offgridmobile.dev \ - --source Documents/offgrid-debug.log --destination /tmp/offgrid-debug.log -``` - -(A Release/TestFlight build uses `--domain-identifier ai.offgridmobile` and has no dev log sink.) - -Then `grep`/read `/tmp/offgrid-debug.log`. The file appends a `===== session start … =====` marker on each launch and is size-capped (rotates, keeping the tail). The in-app **Debug Logs** screen (Settings → Debug Logs) shows the same lines live for quick visual checks. **When diagnosing a device issue, pull this file rather than guessing.** - -## Branch Policy - -**Never push directly to `main`.** All changes must go through a pull request: - -0. Always create a branch specific to the change before committing: `feat/`, `fix/`, `docs/`, `chore/`, `test/`, etc. -1. Push the branch and open a PR - never `git push origin main`. -2. If you find yourself on `main`, create a branch first: `git checkout -b `. - -**Merge strategy: ALWAYS a merge commit. NEVER squash (and never rebase-merge).** When merging a PR, use `gh pr merge --merge` (or the "Create a merge commit" button) so the full commit history is preserved on `main`. Do not squash under any circumstances - the small, meaningful per-concern commits are the record and must survive the merge. This applies to both the core repo and the `pro` submodule. - -### Commit early, commit often - never lose progress (agents especially) - -**A long task is a chain of small, GREEN, committed steps - not one giant uncommitted diff.** Agents run against context/session limits; anything uncommitted is lost when the session ends. So: - -- **Commit each cohesive step as soon as it is green** (typecheck + the relevant tests pass), with a real per-concern message. A refactor done in slices commits after each slice, not at the end. -- **Never leave a large uncommitted working tree across a risky/long operation.** If you are about to start something big, commit what already works first so there is a clean restore point. -- **Every commit is a safe restore point:** it must be behavior-neutral-or-better and pass the gates for the files it touches. Do not commit a knowingly-broken tree; if mid-refactor is unavoidably broken, finish to green before committing (or stash), never push broken. -- **Prefer many small commits over few large ones.** They survive a merge (we never squash), make review tractable, and mean a lost session costs one step, not the whole task. -- This is not optional polish - it is how work is not lost. Treat "a lot of uncommitted changes" as a bug to fix immediately by landing them as small commits. - -## Copy & Content Standards - -**Any change to website copy, essays, docs text, UI strings, or marketing content must follow the brand voice guide:** - -- Read `docs/brand_tone_voice.md` before writing or editing any copy. -- The full quality checklist is at the bottom of that file - run every item before committing content changes. - -Key rules that are easy to miss: - -| Rule | Wrong | Right | -|---|---|---| -| Proof-first | "fast" | "15-30 tok/s on flagship devices" | -| Privacy as mechanism | "we value your privacy" | "the model runs in your phone's RAM, nothing is sent anywhere" | -| No exclamation marks | "It works!" | "It works." | -| No em dashes | "private - always" | "private - always" | -| No forbidden words | revolutionary, seamlessly, empower, leverage, robust, comprehensive, crucial, pivotal, delve, tapestry, testament, underscore, foster, cultivate, showcase, enhance | use specific, plain words instead | -| No AI slop phrases | "serves as", "stands as", "represents a", "marks a turning point", "it is worth noting" | just say "is" | -| No structural clichés | "Not just X, but Y" / "It's not X, it's Y" | state the thing directly | -| No curly quotes | "private" | "private" | - -The emotional arc for all content: **Recognition -> Return -> Freedom**. Name what's been happening, show what's being given back, hand over the capability without condition. - ---- - -## Design Standards - -**Any change that touches UI (screens, components, styles) must comply with the design system.** Inherit the shared Off Grid design philosophy from **`../brand/DESIGN_PHILOSOPHY.md`** (the source of truth - brutalist/terminal, Menlo mono, emerald accent, tokens in `@offgrid/design`). Platform specifics: **`docs/design/DESIGN_PHILOSOPHY_SYSTEM.md`** + **`docs/design/VISUAL_HIERARCHY_STANDARD.md`**. - -- Read `docs/design/VISUAL_HIERARCHY_STANDARD.md` before writing or modifying any UI code. -- Check `docs/design/` for any other relevant design documents. -- Use `TYPOGRAPHY` tokens - never hardcode font sizes or weights. -- Use `COLORS` tokens - never hardcode color values. -- Use `SPACING` tokens - never hardcode margin/padding values. -- Weights must stay ≤ 400 (no bold). -- Never use emojis or emoticons in UI text - always use `react-native-vector-icons` instead. Feather is the default; MaterialIcons is allowed only when Feather lacks a suitable icon (e.g. `whatshot` for trending). -- Never use `lucide-react` or any other icon library - only `react-native-vector-icons`. -- Follow the 5-category text hierarchy: TITLE → BODY → SUBTITLE/DESCRIPTION → META. - -## Reuse Before Building - -**Before writing any new component, style, hook, or service, search for an existing one and reuse it.** Building a parallel version of something that already exists creates visual and behavioural drift (e.g. a search box that looks different from every other search box). - -- For UI: grep `src/components/` and the relevant screen folder for an existing component or shared style (e.g. `ModelCard`, `Card`, `Button`, shared `searchContainer`/`searchInput` styles) before creating your own. Two screens that show the same kind of thing must use the same component. -- For logic: check for an existing hook/service/store action (`grep -rn`) before adding a new one. -- If an existing component is close but not exact, extend it with a prop rather than forking a copy. -- Only build new when nothing fits - and say so in the PR description. - -## Architecture & Abstractions (SOLID) - -**Design to abstractions, not concrete implementations.** When there are multiple interchangeable implementations of a thing (TTS engines, model backends, providers, storage), the rest of the app must depend on a single interface/service layer - never branch on a concrete type. - -**Before every code edit, stop and ask three questions - out loud, in the response:** - -1. **Is there enough here to abstract?** Two or more concrete cases handled by the same caller (text vs vision vs image models, Slack vs Mail surfaces, kokoro vs piper TTS) means there's a seam. One case, used once, is not - don't abstract speculatively (YAGNI). -2. **Can we apply SOLID here?** Mainly: does one thing own one responsibility (SRP), and do callers depend on an interface rather than the concretes (DSP)? A `kind === 'x'` / `instanceof` / per-type `switch` in a caller - *especially in the renderer* - is the tell that the decision belongs behind a service. -3. **Are we actually using it?** A mapping or rule must be defined ONCE and reused. If the same kind→modality map, the same routing `if`, or the same capability check appears in two layers (e.g. main process AND renderer), that's duplication, not abstraction - collapse it to a single source of truth and have both sides call it. - -If the answer to 1 is "no", say so and write the simple version. If "yes", build the seam before piling on the second concrete branch - retrofitting after drift is the expensive path. - -- **No leaking implementation details upward.** UI and stores must not do `instanceof SpecificEngine`, check `engineId === 'kokoro'`, or branch on capabilities to decide *how* to do something. Push that decision behind the abstraction (the engine/provider implements it; or a service layer dispatches once). If you find yourself writing `if (engine X) … else …` in a component, the abstraction is wrong. -- **Single uniform entry point.** Prefer one polymorphic method (e.g. `engine.play(text, opts)`) that every implementation satisfies over several mechanism-specific methods (`speak` vs `playFromFile`) that callers must choose between. -- **Service layer between UI and implementations.** Implementations (engines/adapters) are swappable; a service abstracts them and exposes a normalized API + state. Adding a new implementation must require zero changes to UI/store. -- **Dependency Inversion / Liskov:** any implementation must be substitutable through the interface without callers knowing which one is active. Normalize gaps (e.g. an engine that can't report playback position) inside the service, not in the UI. -- Apply the rest of SOLID: single responsibility per module, open for extension (add an implementation) / closed for modification (don't touch callers), segregated interfaces (don't force implementations to stub methods they can't support - model that with the abstraction). -- **Think from first principles and keep a reference architecture in mind.** Before changing a subsystem, know its intended shape: what owns which state and resources, and how the pieces compose. Make changes consistent with that architecture. -- **Fix the seam - never patch around a missing abstraction.** When a subsystem has shared state or resources spread across multiple implementations (e.g. audio playback: the iOS AVAudioSession + AudioContext lifecycle + playback state across the streaming-TTS / file-player / PCM-replay paths), build/extend the *single owning service* and route everything through it. Do NOT add gates, guards, or flags in callers/UI/stores to compensate for the missing owner. Point-patches layered on shared mutable state cause cascading regressions - one fix silently breaks another path - and the subsystem becomes chaotic and flaky. If the owning abstraction doesn't exist yet, that's the work: create it, then migrate every path onto it with no bypass. -- **Migrations to an owning abstraction MUST be backward-compatible / behavior-neutral for existing paths.** When you route existing code through a new service, preserve its exact prior behavior - the refactor should be *additive* (it may fix a missing case), never change a behavior callers depended on. Example: the old TTS/recorder paths re-activated the iOS AVAudioSession on *every* call; making the new session owner "idempotent" silently dropped that re-activation and broke TTS. Verify each migrated path behaves exactly as before, then layer the fix on top. -- **Reactive stores are for UI projection - NOT for coordinating side-effects or owning resources.** Zustand/reactive state is the right tool for rendering; it is the wrong source of truth for imperative coordination (audio session/context, model loads, playback control, any hardware/resource). Most of the audio flakiness came from making imperative decisions (play vs block, which session category) by branching on a reactive store snapshot that several code paths write and desync. Follow a clear presentation separation (MVVM/MVP): the **Service/Model** owns the authoritative state machine + resources + side-effects; the reactive store is a **thin read-only projection** of that service; the **View** observes the projection and dispatches *intents* to the service. Never make an imperative decision (or fire a side-effect) by reading a reactive snapshot that multiple writers can mutate - that is the recipe for the desync/race bugs. -- **State and data MUST NOT live in the presentation layer.** A screen/component/hook (the View) holds NO authoritative state, NO business logic, and NO side-effecting data operations - it observes a service's projection and dispatches intents. Concretely: no retry/cancel/delete/finalize logic, no platform-branched mechanism, no store-mutation orchestration, no "compute the real value from several sources" in a screen or a `useXxxScreen`/`useXxxManager` hook. That logic belongs in the owning **service** (which carries the state machine + permanent logs). If a UI hook is doing the work instead of calling a service, that is the bug - move the work into the service and have the hook delegate. (This is why download retry/remove moved out of `useDownloadManager`/`retryHandlers` into `ModelDownloadService` + its providers.) - -## Platform Abstraction (no iOS-only / Android-only bugs) - -**A platform-specific bug is the symptom of a leaked platform detail.** With the right abstraction every bug is catchable on both platforms at once - that is the goal. We are writing ONE common layer, not two parallel apps. - -- **One typed TS contract per native capability; both Swift and Kotlin must satisfy it.** Downloads, audio session, model load, image gen, STT - each has a single interface the JS calls. A method that exists on one platform but not the other is a contract violation, not an acceptable difference. Make the missing method a *compile error* (the TS interface requires it), never a runtime `"only available on Android"` throw. -- **Never branch on `Platform.OS` to decide HOW to do something.** Branching to choose a *mechanism* (which download path, which retry strategy, which audio setup) is the missing-abstraction smell - push that decision into the native module / a service that dispatches once. Branching for a genuine presentation value (a keyboard event name, a style inset) is fine. -- **Genuine OS capability gaps are declared DATA, not silent divergence.** When one platform truly can't do something (iOS URLSession dies on app-kill while Android WorkManager survives; an engine can't cancel), model it as a capability flag on the object (like `DownloadCapabilities`), normalize the gap ONCE inside the service, and let the UI render from the flag. The gap is then testable - never an `if (ios)` scattered through callers. -- **Contract tests run against the abstraction, so they catch both platforms.** Test the common interface + the capability flags; a single test then guards iOS and Android together. If a test can only be written per-platform, the abstraction is wrong. -- **Native module contract parity is mandatory.** The Swift and Kotlin implementations of a module must expose the SAME method names, the SAME events (names + payloads), and the SAME semantics (persistence, cleanup, error cascading). Contract drift between Swift and Kotlin is the root cause of platform-only bugs - when you touch a native module on one platform, verify/mirror the other side against the shared TS contract. - -## Quality Gates run on PRE-PUSH (not pre-commit) - -**Commits are intentionally ungated so red-first / work-in-progress tests can land as small commits.** -The full quality gate runs via Husky on `git push` (`.husky/pre-push`), scoped to the files pushed -since upstream: - -| Pushed file type | Checks that run automatically (pre-push) | -|---|---| -| `.ts` / `.tsx` / `.js` / `.jsx` | eslint, `tsc --noEmit`, `jest --findRelatedTests`, `npm run depcruise` | -| `.swift` | `npm run test:ios` | -| `.kt` / `.kts` | `npm run test:android` | - -**Requirements:** -- SwiftLint: `brew install swiftlint` (skipped with a warning if not installed) -- Android checks require the Gradle wrapper in `android/` - -**Workflow implication (TDD / adversarial red-first):** write a failing test, commit it red (commit is -free), then drive it green; the branch must be green before `git push` (the gate blocks a red push). -Never bypass the push gate with `--no-verify`. `core.hooksPath` is `.husky/_` (husky v9); there is no -pre-commit hook by design. - -## Testing (lean — this is the whole doctrine) - -**One rendered integration test per fix. Nothing more.** - -- Mount the real screen, arrive via real gestures, assert what the user SEES. Fakes ONLY at the device boundary (`__tests__/harness/`); never mock our own code. -- **While iterating, run ONLY that test's file.** Do NOT run `--findRelatedTests` or the whole suite per fix — the full suite runs once at pre-push (the gate is the safety net). -- **No unit tests required. No coverage thresholds.** If a mockist test (mocks our own code, or asserts `toHaveBeenCalled`) fails, DELETE it — never repair it. -- "Show the red" (stash the fix, watch it fail) is optional: do it only for genuinely new behavior, skip it for a clear bug fix. -- Confirm a device fix against the log FIRST — pull only the live-session tail (from the last `===== session start =====`), never the whole file. - -## Push = Create PR + Address Review - -When the user says "push" (or any equivalent like "ship it", "send it", "push this"), follow this full workflow: - -### Before pushing -0. Write tests for any new or changed logic if they don't already exist. -1. Run `npm run lint && npx tsc --noEmit && npm test` - fix any failures before continuing. -2. Commit all staged changes with a descriptive message. -3. Ensure you are NOT on `main`. If you are, create an appropriately named branch first: `git checkout -b feat/...` or `fix/...` or `chore/...` etc. - -### Pushing & PR -4. Push the branch: `git push -u origin ` -5. If no PR exists for this branch, create one with `gh pr create`. **Do NOT include "Generated with Codex" or any AI attribution in PR descriptions.** -6. If a PR already exists, update its description to reflect **all commits in the PR** (not just the latest push). Read the full commit history with `git log main..HEAD` and write a coherent description that summarises the entire change set - what it does, why, and how. - -### Review loop -7. Wait for Gemini to review the PR (poll with `gh pr checks` and `gh api repos/{owner}/{repo}/pulls/{number}/reviews` until a review appears). -8. Pull down review comments: `gh api repos/{owner}/{repo}/pulls/{number}/comments` and `gh api repos/{owner}/{repo}/pulls/{number}/reviews`. -9. Address every review comment - fix the code, re-run quality gates (lint, tsc, test). -10. Reply to **each** review comment individually using `gh api` (`/pulls/comments/{id}/replies`). Every comment gets its own reply - do not post a single summary comment. -11. Push fixes, update the PR description again to stay coherent across all commits. -12. Report what was changed in response to the review. - -## CI Review Loop - -The repo has three automated reviewers on every PR. After pushing, loop until all are green: - -| Reviewer | What it checks | How to address | -|---|---|---| -| **Gemini Bot** | Code quality, style, logic issues | Read comments via `gh api`, fix code or reply explaining why it's fine, then comment `/gemini review` to trigger a fresh pass | -| **Codecov** | Test coverage thresholds | Add missing tests, ensure new code is covered. Check the Codecov report for uncovered lines | -| **SonarCloud** | Security hotspots, code smells, duplications, bugs | Fix flagged issues - especially security hotspots and duplications. Resolve quality gate failures before merging | - -**Workflow:** -1. Push code → wait for all three reviewers to report -2. Pull down Gemini comments, Codecov report, and SonarCloud findings -3. Fix issues: code changes for Gemini/SonarCloud, add tests for Codecov -4. Re-run local quality gates (`npm run lint && npm test && npx tsc --noEmit`) -5. Push fixes, comment `/gemini review` on the PR to re-trigger Gemini -6. Repeat until all three reviewers pass with no blocking issues - -## PR hygiene (lean) - -- One concern per PR, small diff. Ship the one rendered test that would fail without the change. -- No Provit journey, no self-audit comment, no mandatory ceremony. Multi-agent fan-out is opt-in, only when asked. +Read and follow `AGENTS.md`. It is the canonical engineering and workflow standard for this +repository. diff --git a/__tests__/integration/audio/voiceModeImageJourney.rendered.happy.test.tsx b/__tests__/integration/audio/voiceModeImageJourney.rendered.happy.test.tsx index 48f172f85..6cffbbe27 100644 --- a/__tests__/integration/audio/voiceModeImageJourney.rendered.happy.test.tsx +++ b/__tests__/integration/audio/voiceModeImageJourney.rendered.happy.test.tsx @@ -24,9 +24,9 @@ describe('T084 (rendered) — voice-mode image journey (STT → route → image h.render(); // Place + load (activate) an image model via the real path — hasImageModel=true, imageMode stays 'auto'. await h.placeImageModel({ backend: 'mnn' }); - /* eslint-disable @typescript-eslint/no-var-requires */ + const { activeModelService } = require('../../../src/services/activeModelService'); - /* eslint-enable @typescript-eslint/no-var-requires */ + await activeModelService.loadImageModel('sd'); // Voice mode, then voice-send "draw a dog" → the pattern router → IMAGE. @@ -34,7 +34,7 @@ describe('T084 (rendered) — voice-mode image journey (STT → route → image await h.voiceSend('draw a dog'); // The image generation ran... - await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); }, { timeout: 6000 }); + await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage).toHaveLength(1); }, { timeout: 6000 }); // ...and the generated image renders on screen (the terminal artifact the user sees). await h.rtl.waitFor(() => { expect(h.view!.queryByTestId('generated-image')).not.toBeNull(); }, { timeout: 6000 }); }); diff --git a/__tests__/integration/audio/voiceModeResendEnhancedImage.rendered.redflow.test.tsx b/__tests__/integration/audio/voiceModeResendEnhancedImage.rendered.redflow.test.tsx index 47498c9fe..c459f82b1 100644 --- a/__tests__/integration/audio/voiceModeResendEnhancedImage.rendered.redflow.test.tsx +++ b/__tests__/integration/audio/voiceModeResendEnhancedImage.rendered.redflow.test.tsx @@ -30,7 +30,7 @@ describe('T062 (voice + enhancement) — resend of an enhanced image request re- h.render(); await h.placeImageModel({ backend: 'mnn' }); - // eslint-disable-next-line @typescript-eslint/no-var-requires + const { activeModelService } = require('../../../src/services/activeModelService'); await activeModelService.loadImageModel('sd'); // Enable prompt enhancement — the setting that ran on the device before the failing resend. @@ -42,7 +42,7 @@ describe('T062 (voice + enhancement) — resend of an enhanced image request re- // then the image is drawn. h.boundary.litert.scriptTurn({ content: 'a photorealistic dog in a park' }); await h.voiceSend('draw a dog'); - await h.rtl.waitFor(() => { expect(h.view!.queryAllByTestId('generated-image-content').length).toBe(1); }, { timeout: 6000 }); + await h.rtl.waitFor(() => { expect(h.view!.queryAllByTestId('generated-image-content')).toHaveLength(1); }, { timeout: 6000 }); // RESEND via the real action menu (3-dots) on the image-result message → Retry. Regenerate REPLACES the // reply, so a correct re-draw leaves one rendered image; a misroute-to-text would leave ZERO (the image @@ -53,7 +53,7 @@ describe('T062 (voice + enhancement) — resend of an enhanced image request re- // SPEC: the resend re-ran the IMAGE pipeline (a second generateImage) AND the user still sees a rendered // image (not a text answer). RED (B33) would be zero rendered images + the text answer. - expect(h.boundary.diffusion.calls.generateImage.length).toBe(2); - expect(h.view!.queryAllByTestId('generated-image-content').length).toBe(1); + expect(h.boundary.diffusion.calls.generateImage).toHaveLength(2); + expect(h.view!.queryAllByTestId('generated-image-content')).toHaveLength(1); }); }); diff --git a/__tests__/integration/audio/voiceModeResendImageRoutes.rendered.redflow.test.tsx b/__tests__/integration/audio/voiceModeResendImageRoutes.rendered.redflow.test.tsx index 8a4a8a9d1..0af39a910 100644 --- a/__tests__/integration/audio/voiceModeResendImageRoutes.rendered.redflow.test.tsx +++ b/__tests__/integration/audio/voiceModeResendImageRoutes.rendered.redflow.test.tsx @@ -36,14 +36,14 @@ describe('T062 (voice-mode) — resend of an image request re-draws, not text (D // Place + load (activate) an image model via the REAL load path — hasImageModel=true, imageMode stays // 'auto'. Matches the device (auto + pattern classifier routes "draw a dog" → image; log part28/38). await h.placeImageModel({ backend: 'mnn' }); - // eslint-disable-next-line @typescript-eslint/no-var-requires + const { activeModelService } = require('../../../src/services/activeModelService'); await activeModelService.loadImageModel('sd'); // Switch to Voice mode via the chat-input quick-settings, then VOICE-send "draw a dog" → IMAGE. await h.enterVoiceMode(); await h.voiceSend('draw a dog'); - await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); }, { timeout: 6000 }); + await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage).toHaveLength(1); }, { timeout: 6000 }); // RESEND via the real action menu (3-dots) on the image-result message → Retry. In audio mode the image // result renders as the same core ChatMessage, so the action menu is identical to text mode. @@ -52,7 +52,7 @@ describe('T062 (voice-mode) — resend of an image request re-draws, not text (D // SPEC: resend re-runs the IMAGE pipeline → a SECOND generateImage; NO text answer leaked. // RED (B33 in voice mode): resend goes to the text model → generateImage stays 1 + the scripted text renders. - expect(h.boundary.diffusion.calls.generateImage.length).toBe(2); + expect(h.boundary.diffusion.calls.generateImage).toHaveLength(2); expect(h.view!.queryByText(/domestic animal/)).toBeNull(); }); }); diff --git a/__tests__/integration/chat/voiceNoteChatModeEmptyTurn.redflow.test.ts b/__tests__/integration/chat/voiceNoteChatModeEmptyTurn.redflow.test.ts index 2d798c02d..3eb835229 100644 --- a/__tests__/integration/chat/voiceNoteChatModeEmptyTurn.redflow.test.ts +++ b/__tests__/integration/chat/voiceNoteChatModeEmptyTurn.redflow.test.ts @@ -22,7 +22,7 @@ describe('chat-mode STT is dictation-to-the-input-box on every engine (LiteRT to // downloadedModelId alone (with no file) makes whisperService.loadModel throw "not found" → whisper never // becomes resident → nothing to transcribe: a fabricated precondition that never exercises the real path. const boundary = installNativeBoundary({ fs: true, whisper: true, ram: { platform: 'ios', totalBytes: 12 * 1024 ** 3, availBytes: 8 * 1024 ** 3 } }); - /* eslint-disable @typescript-eslint/no-var-requires */ + const { renderHook, act } = require('../../harness/nativeBoundary').requireRTL(); const RNFS = require('react-native-fs'); const { liteRTService } = require('../../../src/services/litert'); @@ -30,7 +30,6 @@ describe('chat-mode STT is dictation-to-the-input-box on every engine (LiteRT to const { useAppStore } = require('../../../src/stores'); const { useUiModeStore } = require('../../../src/stores/uiModeStore'); const { useWhisperStore } = require('../../../src/stores'); - /* eslint-enable @typescript-eslint/no-var-requires */ // A direct-audio-capable LiteRT model is active and loaded WITH audio support. await liteRTService.loadModel('/models/gemma.litertlm', 'gpu', { supportsAudio: true, maxNumTokens: 4096 }); @@ -58,13 +57,12 @@ describe('chat-mode STT is dictation-to-the-input-box on every engine (LiteRT to expect(result.current.isRecording).toBe(true); await act(async () => { await result.current.stopRecording(); }); - void boundary; // NEW unified behavior: the transcript lands in the COMPOSER (onTranscript) — dictation-to-the-input-box, // exactly like a non-audio (llama) model. expect(transcriptArgs.some(t => t.trim() === 'draw a dog')).toBe(true); // And it is NOT dispatched as a voice-note attachment, nor auto-sent — the user reviews/edits then sends. // RED before the fix: the transcript went to onAudioAttachment (a dispatched note), composer stayed empty. - expect(attachmentArgs.length).toBe(0); - expect(autoSendArgs.length).toBe(0); + expect(attachmentArgs).toHaveLength(0); + expect(autoSendArgs).toHaveLength(0); }); }); diff --git a/__tests__/integration/downloads/downloadedCountBadge.rendered.happy.test.tsx b/__tests__/integration/downloads/downloadedCountBadge.rendered.happy.test.tsx index d8446c6ec..2e9d27d24 100644 --- a/__tests__/integration/downloads/downloadedCountBadge.rendered.happy.test.tsx +++ b/__tests__/integration/downloads/downloadedCountBadge.rendered.happy.test.tsx @@ -35,13 +35,12 @@ const RECOMMENDED_IDS = [ async function mountWithNDownloaded(n: number) { const boundary = installNativeBoundary({ fs: true }); - /* eslint-disable @typescript-eslint/no-var-requires */ + const React = require('react'); const { render, waitFor } = requireRTL(); const AsyncStorage = require('@react-native-async-storage/async-storage').default ?? require('@react-native-async-storage/async-storage'); const { ModelsScreen } = require('../../../src/screens/ModelsScreen'); - /* eslint-enable @typescript-eslint/no-var-requires */ const docs = boundary.fs!.DocumentDirectoryPath; // Seed N downloaded models: a file on the in-memory disk + a persisted record whose id is prefixed by @@ -69,7 +68,7 @@ describe('T012 (rendered) — ModelsScreen reflects N downloaded models', () => // The count of downloaded marks the user sees on ModelsScreen must equal N. await waitFor(() => { - expect(view.queryAllByTestId(/^model-card-\d+-downloaded$/).length).toBe(N); + expect(view.queryAllByTestId(/^model-card-\d+-downloaded$/)).toHaveLength(N); }, { timeout: 4000 }); }); }); diff --git a/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.redflow.test.ts b/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.redflow.test.ts index 5a6e1c71b..0c72e868b 100644 --- a/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.redflow.test.ts +++ b/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.redflow.test.ts @@ -104,7 +104,7 @@ describe.each(['ios', 'android'] as const)( }; // Precondition: no native download in flight, and the row is a dead 'failed' (the screenshot state). - expect(boundary.download!.active().length).toBe(0); + expect(boundary.download!.active()).toHaveLength(0); expect(useDownloadStore.getState().downloads[modelKey].status).toBe('failed'); // ACT: the retry/resume-finalize path for an all-bytes-present image download. diff --git a/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.rendered.redflow.test.tsx b/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.rendered.redflow.test.tsx index c56d49969..84f2de223 100644 --- a/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.rendered.redflow.test.tsx +++ b/__tests__/integration/downloads/iosImageStagingPurgedRedownloads.rendered.redflow.test.tsx @@ -66,7 +66,7 @@ describe('rendered — iOS image staging purged: Retry recovers the failed card' return btn; }); expect(view.queryByText(/SDXL|coreml_apple/)).not.toBeNull(); - expect(boundary.download!.active().length).toBe(0); + expect(boundary.download!.active()).toHaveLength(0); // GESTURE: tap Retry, the way the user did on the device. fireEvent.press(retry!); diff --git a/__tests__/integration/downloads/iosInterruptedNoFailedEntry.redflow.test.ts b/__tests__/integration/downloads/iosInterruptedNoFailedEntry.redflow.test.ts index 2cf7ee89a..8e6eb56f9 100644 --- a/__tests__/integration/downloads/iosInterruptedNoFailedEntry.redflow.test.ts +++ b/__tests__/integration/downloads/iosInterruptedNoFailedEntry.redflow.test.ts @@ -11,14 +11,13 @@ import { installNativeBoundary } from '../../harness/nativeBoundary'; describe('D4 — iOS interrupted download leaves no failed entry (red-flow)', () => { it('surfaces an interrupted iOS download as a failed/retriable entry after app-kill', async () => { const boundary = installNativeBoundary({ download: true, ram: { platform: 'ios', totalBytes: 8 * 1024 ** 3, availBytes: 4 * 1024 ** 3 } }); - /* eslint-disable @typescript-eslint/no-var-requires */ + const { hydrateDownloadStore } = require('../../../src/services/downloadHydration'); const { useDownloadStore } = require('../../../src/stores/downloadStore'); - /* eslint-enable @typescript-eslint/no-var-requires */ boundary.download!.seedActive({ downloadId: 'dl-txt', fileName: 'gemma-4b.gguf', modelId: 'gemma-4b', modelType: 'text', status: 'running', bytesDownloaded: 2 * 1024 ** 3, totalBytes: 6 * 1024 ** 3 }); await hydrateDownloadStore(); - expect(Object.keys(useDownloadStore.getState().downloads).length).toBe(1); // precondition: shown while running + expect(Object.keys(useDownloadStore.getState().downloads)).toHaveLength(1); // precondition: shown while running // iOS force-quit: URLSession drops the row entirely. boundary.download!.simulateRelaunch(); diff --git a/__tests__/integration/downloads/iosTextRetryReissues.rendered.redflow.test.tsx b/__tests__/integration/downloads/iosTextRetryReissues.rendered.redflow.test.tsx index d5ea3409d..020cc29aa 100644 --- a/__tests__/integration/downloads/iosTextRetryReissues.rendered.redflow.test.tsx +++ b/__tests__/integration/downloads/iosTextRetryReissues.rendered.redflow.test.tsx @@ -39,13 +39,13 @@ const failedTextEntry = (over: Record = {}) => ({ describe('iOS text retry (rendered, red-flow)', () => { it('re-issues the download on retry even when the store entry lost its downloadId (app-kill)', async () => { const boundary = installNativeBoundary({ download: true, fs: true, ram: { platform: 'ios', totalBytes: 8 * 1024 ** 3, availBytes: 6 * 1024 ** 3 } }); - /* eslint-disable @typescript-eslint/no-var-requires */ + const React = require('react'); const { render, waitFor, fireEvent } = requireRTL(); const { useDownloadStore } = require('../../../src/stores/downloadStore'); const { DownloadManagerScreen } = require('../../../src/screens/DownloadManagerScreen'); const { registerCoreDownloadProviders } = require('../../../src/services/modelDownloadService/registerProviders'); - /* eslint-enable @typescript-eslint/no-var-requires */ + // The service routes retry() to the owning provider — register it (app boot does this; a bare // screen render does not, which is why retry was silently REFUSED: not found). registerCoreDownloadProviders(); @@ -57,7 +57,7 @@ describe('iOS text retry (rendered, red-flow)', () => { await waitFor(() => { expect(view.queryByText(/model\.gguf/)).not.toBeNull(); }); // Pre-condition: nothing is downloading at the native boundary yet (so a false green can't hide). - expect(boundary.download!.active().length).toBe(0); + expect(boundary.download!.active()).toHaveLength(0); fireEvent.press(view.getByTestId('failed-retry-button')); @@ -69,13 +69,13 @@ describe('iOS text retry (rendered, red-flow)', () => { it('marks a retried download as no-longer-failed immediately (queued feedback)', async () => { installNativeBoundary({ download: true, fs: true, ram: { platform: 'ios', totalBytes: 8 * 1024 ** 3, availBytes: 6 * 1024 ** 3 } }); - /* eslint-disable @typescript-eslint/no-var-requires */ + const React = require('react'); const { render, waitFor, fireEvent } = requireRTL(); const { useDownloadStore } = require('../../../src/stores/downloadStore'); const { DownloadManagerScreen } = require('../../../src/screens/DownloadManagerScreen'); const { registerCoreDownloadProviders } = require('../../../src/services/modelDownloadService/registerProviders'); - /* eslint-enable @typescript-eslint/no-var-requires */ + // The service routes retry() to the owning provider — register it (app boot does this; a bare // screen render does not, which is why retry was silently REFUSED: not found). registerCoreDownloadProviders(); diff --git a/__tests__/integration/downloads/queuedDownloadsSurviveKill.redflow.test.ts b/__tests__/integration/downloads/queuedDownloadsSurviveKill.redflow.test.ts index 1ad8961da..44c878b80 100644 --- a/__tests__/integration/downloads/queuedDownloadsSurviveKill.redflow.test.ts +++ b/__tests__/integration/downloads/queuedDownloadsSurviveKill.redflow.test.ts @@ -204,7 +204,7 @@ describe('queued downloads survive an app kill (red-flow)', () => { // so a second kill right now would not lose them (the found=11 → found=1 loss). await flush(); const persisted = await loadQueuedDownloads(); - expect(persisted.length).toBe(3); + expect(persisted).toHaveLength(3); }); it('no regression: an in-flight (started) download still hydrates via the native path', async () => { diff --git a/__tests__/integration/downloads/textRetryReissuesOnIosLostDownloadId.rendered.redflow.test.tsx b/__tests__/integration/downloads/textRetryReissuesOnIosLostDownloadId.rendered.redflow.test.tsx index 30b76dba9..66b7833e0 100644 --- a/__tests__/integration/downloads/textRetryReissuesOnIosLostDownloadId.rendered.redflow.test.tsx +++ b/__tests__/integration/downloads/textRetryReissuesOnIosLostDownloadId.rendered.redflow.test.tsx @@ -47,13 +47,11 @@ describe('iOS text retry re-issues a rehydrated failed download that lost its do }, })); - /* eslint-disable @typescript-eslint/no-var-requires */ const React = require('react'); const { render, fireEvent, waitFor, act } = requireRTL(); const { useDownloadStore } = require('../../../src/stores/downloadStore'); const { registerCoreDownloadProviders } = require('../../../src/services/modelDownloadService/registerProviders'); const { ModelsScreen } = require('../../../src/screens/ModelsScreen'); - /* eslint-enable @typescript-eslint/no-var-requires */ // Providers must be registered so modelDownloadService can route text retries to textProvider. registerCoreDownloadProviders(); @@ -100,7 +98,7 @@ describe('iOS text retry re-issues a rehydrated failed download that lost its do await waitFor(() => expect(getByText('Retry')).toBeTruthy(), { timeout: 4000 }); // No native download exists yet (the row was lost on the kill). - expect(boundary.download!.active().length).toBe(0); + expect(boundary.download!.active()).toHaveLength(0); // Tap Retry. await act(async () => { fireEvent.press(getByText('Retry')); }); diff --git a/__tests__/integration/generation/enhancementNoThinking.rendered.redflow.test.tsx b/__tests__/integration/generation/enhancementNoThinking.rendered.redflow.test.tsx index 25be1c40e..e1a09ffa9 100644 --- a/__tests__/integration/generation/enhancementNoThinking.rendered.redflow.test.tsx +++ b/__tests__/integration/generation/enhancementNoThinking.rendered.redflow.test.tsx @@ -34,7 +34,7 @@ describe('T071 (rendered) — prompt enhancement must not think (DEV-B30)', () = h.render(); await h.placeImageModel({ backend: 'coreml' }); - // eslint-disable-next-line @typescript-eslint/no-var-requires + const { activeModelService } = require('../../../src/services/activeModelService'); await activeModelService.loadImageModel('sd'); await h.cycleImageMode(); // auto → ON(force): "draw a cat" routes to IMAGE @@ -45,7 +45,7 @@ describe('T071 (rendered) — prompt enhancement must not think (DEV-B30)', () = h.boundary.llama!.scriptCompletion({ text: 'a photorealistic cat in a garden' }); // the rewritten prompt await h.tapSend('draw a cat'); - await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); }, { timeout: 6000 }); + await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage).toHaveLength(1); }, { timeout: 6000 }); // The enhancement completion reached the text engine. const enhancementReq = h.boundary.llama!.calls.completion.map(c => c[0] as { enable_thinking?: boolean; messages?: Array<{ role: string; content?: string }> }).find(isEnhancementRequest); diff --git a/__tests__/integration/generation/enhancementStreamingProgress.rendered.redflow.test.tsx b/__tests__/integration/generation/enhancementStreamingProgress.rendered.redflow.test.tsx index 392b51ea9..8523f441d 100644 --- a/__tests__/integration/generation/enhancementStreamingProgress.rendered.redflow.test.tsx +++ b/__tests__/integration/generation/enhancementStreamingProgress.rendered.redflow.test.tsx @@ -52,7 +52,7 @@ describe('T073 (rendered) — enhancement must stream / show live progress (DEV- h.render(); await h.placeImageModel({ backend: 'coreml' }); - // eslint-disable-next-line @typescript-eslint/no-var-requires + const { activeModelService } = require('../../../src/services/activeModelService'); await activeModelService.loadImageModel('sd'); await h.cycleImageMode(); // auto → ON(force): "draw a cat" routes to IMAGE @@ -82,7 +82,7 @@ describe('T073 (rendered) — enhancement must stream / show live progress (DEV- // Release the held stream so the turn completes cleanly (no dangling promise / open handle). h.boundary.llama!.releaseStream(); await h.rtl.waitFor( - () => { expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); }, + () => { expect(h.boundary.diffusion.calls.generateImage).toHaveLength(1); }, { timeout: 6000 }, ); }); diff --git a/__tests__/integration/generation/generationFlow.test.ts b/__tests__/integration/generation/generationFlow.test.ts index a71ec4b46..f36046ebe 100644 --- a/__tests__/integration/generation/generationFlow.test.ts +++ b/__tests__/integration/generation/generationFlow.test.ts @@ -283,7 +283,7 @@ describe('Generation Flow Integration', () => { // Verify message was finalized const chatState = getChatState(); expect(chatState.streamingMessage).toBe(''); - expect(chatState.streamingForConversationId).toBe(null); + expect(chatState.streamingForConversationId).toBeNull(); expect(chatState.isStreaming).toBe(false); // Verify assistant message was added @@ -368,7 +368,7 @@ describe('Generation Flow Integration', () => { // Verify streaming state was cleared const chatState = getChatState(); expect(chatState.streamingMessage).toBe(''); - expect(chatState.streamingForConversationId).toBe(null); + expect(chatState.streamingForConversationId).toBeNull(); expect(chatState.isStreaming).toBe(false); }); }); diff --git a/__tests__/integration/generation/imageGenerationFlow.test.ts b/__tests__/integration/generation/imageGenerationFlow.test.ts index 8854e6aba..1f0dd2d51 100644 --- a/__tests__/integration/generation/imageGenerationFlow.test.ts +++ b/__tests__/integration/generation/imageGenerationFlow.test.ts @@ -687,7 +687,7 @@ describe('Image Generation Flow Integration', () => { // Should have: system + context messages + user enhance prompt // system (1) + conversation messages (3) + user enhance (1) = 5 - expect(enhancementMessages.length).toBe(5); + expect(enhancementMessages).toHaveLength(5); expect(enhancementMessages[0].role).toBe('system'); expect(enhancementMessages[0].content).toContain('conversation history'); expect(enhancementMessages[1].content).toBe('Draw me a cat'); @@ -709,7 +709,7 @@ describe('Image Generation Flow Integration', () => { const enhancementMessages = callArgs[0] as Message[]; // Should have: system + user enhance prompt only (no context) - expect(enhancementMessages.length).toBe(2); + expect(enhancementMessages).toHaveLength(2); expect(enhancementMessages[0].role).toBe('system'); expect(enhancementMessages[0].content).not.toContain('conversation history'); expect(enhancementMessages[1].role).toBe('user'); @@ -736,7 +736,7 @@ describe('Image Generation Flow Integration', () => { // The context message should be truncated to 500 chars const contextMsg = enhancementMessages.find(m => m.id.startsWith('ctx-')); expect(contextMsg).toBeDefined(); - expect(contextMsg!.content.length).toBe(500); + expect(contextMsg!.content).toHaveLength(500); }); it('should limit conversation context to last 10 messages', async () => { @@ -761,7 +761,7 @@ describe('Image Generation Flow Integration', () => { const enhancementMessages = callArgs[0] as Message[]; // system (1) + last 10 context messages + user enhance (1) = 12 - expect(enhancementMessages.length).toBe(12); + expect(enhancementMessages).toHaveLength(12); // First context message should be message 6 (index 5), not message 1 const firstContextMsg = enhancementMessages[1]; expect(firstContextMsg.content).toBe('Message 6'); @@ -786,7 +786,7 @@ describe('Image Generation Flow Integration', () => { const enhancementMessages = callArgs[0] as Message[]; // system (1) + 2 context (user + assistant, system skipped) + user enhance (1) = 4 - expect(enhancementMessages.length).toBe(4); + expect(enhancementMessages).toHaveLength(4); const contextMessages = enhancementMessages.filter(m => m.id.startsWith('ctx-')); expect(contextMessages).toHaveLength(2); expect(contextMessages.every(m => m.role !== 'system')).toBe(true); @@ -835,7 +835,7 @@ describe('Image Generation Flow Integration', () => { const enhancementMessages = callArgs[0] as Message[]; // system + user enhance only (no context from empty conversation) - expect(enhancementMessages.length).toBe(2); + expect(enhancementMessages).toHaveLength(2); expect(enhancementMessages[0].role).toBe('system'); expect(enhancementMessages[0].content).not.toContain('conversation history'); }); @@ -1180,62 +1180,42 @@ describe('Image Generation Flow Integration', () => { mockLlmService.isCurrentlyGenerating.mockReturnValue(false); }; - it('should strip tags from thinking model responses', async () => { + it.each([ + { + name: 'should strip tags from thinking model responses', + // Simulate a thinking model that wraps reasoning in tags + inputPrompt: 'sunset over mountains', + enhancedResponse: + 'Let me enhance this prompt by adding artistic details...A majestic sunset over mountains, golden hour lighting, oil painting style', + // The prompt passed to image generation should NOT contain tags + expectedPrompt: 'A majestic sunset over mountains, golden hour lighting, oil painting style', + }, + { + name: 'should handle thinking model response that is only a think block', + // Simulate a model that only outputs thinking with no actual response + inputPrompt: 'a cat', + enhancedResponse: 'I need to think about how to enhance this prompt...', + // When stripping produces empty string, should fall back to original prompt + expectedPrompt: 'a cat', + }, + { + name: 'should handle response without think tags normally', + // Non-thinking model returns plain enhanced prompt + inputPrompt: 'simple prompt', + enhancedResponse: 'A beautiful enhanced prompt with details', + expectedPrompt: 'A beautiful enhanced prompt with details', + }, + ])('$name', async ({ inputPrompt, enhancedResponse, expectedPrompt }) => { setupThinkingModelEnhancement(); - // Simulate a thinking model that wraps reasoning in tags - mockLlmService.generateResponse.mockResolvedValue( - 'Let me enhance this prompt by adding artistic details...A majestic sunset over mountains, golden hour lighting, oil painting style' - ); - - await imageGenerationService.generateImage({ - prompt: 'sunset over mountains', - }); - - // The prompt passed to image generation should NOT contain tags - expect(mockLocalDreamService.generateImage).toHaveBeenCalledWith( - expect.objectContaining({ - prompt: 'A majestic sunset over mountains, golden hour lighting, oil painting style', - }), - expect.any(Function), - expect.any(Function), - ); - }); - - it('should handle thinking model response that is only a think block', async () => { - setupThinkingModelEnhancement(); - // Simulate a model that only outputs thinking with no actual response - mockLlmService.generateResponse.mockResolvedValue( - 'I need to think about how to enhance this prompt...' - ); - - await imageGenerationService.generateImage({ - prompt: 'a cat', - }); - - // When stripping produces empty string, should fall back to original prompt - expect(mockLocalDreamService.generateImage).toHaveBeenCalledWith( - expect.objectContaining({ - prompt: 'a cat', - }), - expect.any(Function), - expect.any(Function), - ); - }); - - it('should handle response without think tags normally', async () => { - setupThinkingModelEnhancement(); - // Non-thinking model returns plain enhanced prompt - mockLlmService.generateResponse.mockResolvedValue( - 'A beautiful enhanced prompt with details' - ); + mockLlmService.generateResponse.mockResolvedValue(enhancedResponse); await imageGenerationService.generateImage({ - prompt: 'simple prompt', + prompt: inputPrompt, }); expect(mockLocalDreamService.generateImage).toHaveBeenCalledWith( expect.objectContaining({ - prompt: 'A beautiful enhanced prompt with details', + prompt: expectedPrompt, }), expect.any(Function), expect.any(Function), diff --git a/__tests__/integration/generation/queuedForceImagePreservesMode.rendered.redflow.test.tsx b/__tests__/integration/generation/queuedForceImagePreservesMode.rendered.redflow.test.tsx index 5c36e517b..4043175c0 100644 --- a/__tests__/integration/generation/queuedForceImagePreservesMode.rendered.redflow.test.tsx +++ b/__tests__/integration/generation/queuedForceImagePreservesMode.rendered.redflow.test.tsx @@ -64,7 +64,7 @@ describe('#510 (rendered) — a queued force-image send preserves its force flag await h.settle(50); // let handleSendFn enqueue // No image generated yet — turn #2 is queued, turn #1 still holds. - expect(h.boundary.diffusion.calls.generateImage.length).toBe(0); + expect(h.boundary.diffusion.calls.generateImage).toHaveLength(0); // ---- Release turn #1 → the queue drains and dispatches the queued force-image message. ---- h.boundary.llama!.releaseStream(); @@ -73,10 +73,10 @@ describe('#510 (rendered) — a queued force-image send preserves its force flag // SPEC: the queued force-image send draws → the generated-image bubble renders on screen, and the // scripted TEXT reply must NOT appear for turn #2. // RED (#510): force lost → classified text → QUEUED_TEXT_LEAK renders again as a second reply, no image. - await rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); }, { timeout: 4000 }); + await rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage).toHaveLength(1); }, { timeout: 4000 }); expect(view.queryByTestId('generated-image')).not.toBeNull(); // Only turn #1's single text reply may exist — turn #2 must NOT have produced a second text reply. - expect(view.queryAllByText(new RegExp(QUEUED_TEXT_LEAK)).length).toBe(1); + expect(view.queryAllByText(new RegExp(QUEUED_TEXT_LEAK))).toHaveLength(1); }); it('COALESCE (M16): two sends queue together and one forced image — the merged dispatch draws an image', async () => { @@ -101,12 +101,12 @@ describe('#510 (rendered) — a queued force-image send preserves its force flag await rtl.waitFor(() => { expect(view.queryByTestId('image-mode-force-badge')).not.toBeNull(); }); await h.tapSend('tell me about cats'); // force → queued (now 2 queued, one forced) await h.settle(50); - expect(h.boundary.diffusion.calls.generateImage.length).toBe(0); // nothing drawn while #1 holds + expect(h.boundary.diffusion.calls.generateImage).toHaveLength(0); // nothing drawn while #1 holds // Drain: the 2 queued messages coalesce; because one was force, the merged dispatch must draw. h.boundary.llama!.releaseStream(); await h.settle(400); - await rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); }, { timeout: 4000 }); + await rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage).toHaveLength(1); }, { timeout: 4000 }); expect(view.queryByTestId('generated-image')).not.toBeNull(); }); }); diff --git a/__tests__/integration/generation/queuedSendFeedback.test.ts b/__tests__/integration/generation/queuedSendFeedback.test.ts index 076899226..5d1865286 100644 --- a/__tests__/integration/generation/queuedSendFeedback.test.ts +++ b/__tests__/integration/generation/queuedSendFeedback.test.ts @@ -118,7 +118,7 @@ describe('BUG #29(b) — second send while generating surfaces a queued indicato await handleSendFn(deps, { text: 'second message', startGeneration, setDebugInfo: jest.fn() }); await flushPromises(); - expect(generationService.getState().queuedMessages.length).toBe(1); + expect(generationService.getState().queuedMessages).toHaveLength(1); // The subscription the screen reads now reflects the queued message → QueueRow renders. expect(queueCount).toBe(1); expect(queuedTexts).toEqual(['second message']); diff --git a/__tests__/integration/generation/reloadRaceKeepsThinking.rendered.redflow.test.tsx b/__tests__/integration/generation/reloadRaceKeepsThinking.rendered.redflow.test.tsx index 27125ee3b..df0509485 100644 --- a/__tests__/integration/generation/reloadRaceKeepsThinking.rendered.redflow.test.tsx +++ b/__tests__/integration/generation/reloadRaceKeepsThinking.rendered.redflow.test.tsx @@ -47,7 +47,7 @@ function pressByWalkingUp(node: unknown): void { /** Arrive-via-UI: change the text inference backend on the real BackendSelector (Model Settings control). */ function selectBackendViaUI(h: Awaited>, backendId: string) { - // eslint-disable-next-line @typescript-eslint/no-var-requires + const { BackendSelector } = require('../../../src/components/settings/textGenAdvancedSections'); const s = h.rtl.render(h.React.createElement(BackendSelector, {})); h.rtl.fireEvent.press(s.getByTestId(`backend-${backendId}-button`)); @@ -61,7 +61,7 @@ describe('reload race — a send during the load window keeps thinking (device 2 it('renders the thinking block for a turn sent while the reload is still detecting capabilities', async () => { const h = await setupChatScreen({ engine: 'llama', platform: 'android' }); // Device boundary: an Adreno (Qualcomm) SoC so the OpenCL backend choice is allowed (as on device). - // eslint-disable-next-line @typescript-eslint/no-var-requires + const DeviceInfo = require('react-native-device-info'); (DeviceInfo.getHardware as jest.Mock).mockResolvedValue('qcom'); h.render(); @@ -108,7 +108,7 @@ describe('reload race — a send during the load window keeps thinking (device 2 expect(h.view!.queryByText(/seventeen has no divisors below its root/)).not.toBeNull(); // BOTH turns carry the thinking affordance (the block collapses to its preview after completion). const blocks = h.view!.queryAllByTestId('thinking-block'); - expect(blocks.length).toBe(2); + expect(blocks).toHaveLength(2); // Expand the racing turn's block: the full reasoning renders in the block content. // (walking-up press: the toggle's onPress lives on the composite above the testID host) const toggles = h.view!.queryAllByTestId('thinking-block-toggle'); diff --git a/__tests__/integration/generation/remoteParallelTools.rendered.happy.test.tsx b/__tests__/integration/generation/remoteParallelTools.rendered.happy.test.tsx index aa8b6be56..1e15df30a 100644 --- a/__tests__/integration/generation/remoteParallelTools.rendered.happy.test.tsx +++ b/__tests__/integration/generation/remoteParallelTools.rendered.happy.test.tsx @@ -50,7 +50,7 @@ describe('T048 (rendered) — remote parallel tool_calls render as bubbles + fin await h.tapSend('compute 47*83, 128*256, and 0.3*400'); // The three parallel calculator calls each render a tool-result bubble (accumulate-by-index worked). - await h.rtl.waitFor(() => { expect(h.view!.queryAllByTestId('tool-result-label-calculator').length).toBe(3); }, { timeout: 6000 }); + await h.rtl.waitFor(() => { expect(h.view!.queryAllByTestId('tool-result-label-calculator')).toHaveLength(3); }, { timeout: 6000 }); // ...and the remote model's final reply renders. await h.rtl.waitFor(() => { expect(h.view!.queryByText(/Results: 3901, 32768, and 120/)).not.toBeNull(); }, { timeout: 6000 }); }); diff --git a/__tests__/integration/generation/resendImageRoutes.rendered.redflow.test.tsx b/__tests__/integration/generation/resendImageRoutes.rendered.redflow.test.tsx index 95f4bc80d..dc5ee0bfa 100644 --- a/__tests__/integration/generation/resendImageRoutes.rendered.redflow.test.tsx +++ b/__tests__/integration/generation/resendImageRoutes.rendered.redflow.test.tsx @@ -25,7 +25,7 @@ describe('T062 (rendered) — resend of an image request re-draws, not text (DEV // Original send → IMAGE (device-confirmed correct). await h.tapSend('draw a dog'); - await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); }); + await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage).toHaveLength(1); }); // RESEND via the real action menu (3-dots) on the image-result message → Retry. await h.regenerateLast({ content: 'A dog is a domestic animal.' }, 'dots'); // scripted text is what leaks if it misroutes @@ -33,7 +33,7 @@ describe('T062 (rendered) — resend of an image request re-draws, not text (DEV // SPEC: resend re-runs the IMAGE pipeline → a SECOND generateImage; NO text answer leaked. // RED (B33): resend goes to the text model → generateImage stays 1 + the scripted text renders. - expect(h.boundary.diffusion.calls.generateImage.length).toBe(2); + expect(h.boundary.diffusion.calls.generateImage).toHaveLength(2); expect(h.view!.queryByText(/domestic animal/)).toBeNull(); }); }); diff --git a/__tests__/integration/generation/resendImageRoutesLlama.rendered.redflow.test.tsx b/__tests__/integration/generation/resendImageRoutesLlama.rendered.redflow.test.tsx index ba7309227..c9950b460 100644 --- a/__tests__/integration/generation/resendImageRoutesLlama.rendered.redflow.test.tsx +++ b/__tests__/integration/generation/resendImageRoutesLlama.rendered.redflow.test.tsx @@ -26,20 +26,20 @@ describe('T062 (llama) — resend of an image request re-draws on the llama engi const h = await setupChatScreen({ engine: 'llama', platform: 'android' }); h.render(); await h.placeImageModel({ backend: 'coreml' }); - // eslint-disable-next-line @typescript-eslint/no-var-requires + const { activeModelService } = require('../../../src/services/activeModelService'); await activeModelService.loadImageModel('sd'); await h.cycleImageMode(); // auto → ON(force) await h.rtl.waitFor(() => { expect(h.view!.queryByTestId('image-mode-force-badge')).not.toBeNull(); }); await h.tapSend('draw a dog'); - await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); }, { timeout: 6000 }); + await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage).toHaveLength(1); }, { timeout: 6000 }); // RESEND via the real action menu (3-dots) → the scripted text is what leaks if it misroutes to llama. await h.regenerateLast({ text: 'A dog is a domestic animal.' }, 'dots'); await h.settle(500); - expect(h.boundary.diffusion.calls.generateImage.length).toBe(2); + expect(h.boundary.diffusion.calls.generateImage).toHaveLength(2); expect(h.view!.queryByText(/domestic animal/)).toBeNull(); }); }); diff --git a/__tests__/integration/generation/resendImageTurnFallsBackToText.rendered.redflow.test.tsx b/__tests__/integration/generation/resendImageTurnFallsBackToText.rendered.redflow.test.tsx index 5b1da5955..047dbecd0 100644 --- a/__tests__/integration/generation/resendImageTurnFallsBackToText.rendered.redflow.test.tsx +++ b/__tests__/integration/generation/resendImageTurnFallsBackToText.rendered.redflow.test.tsx @@ -39,7 +39,7 @@ describe('send/resend parity — resending an image turn with no image model fal await h.cycleImageMode(); // auto → ON (force) await h.rtl.waitFor(() => { expect(h.view!.queryByTestId('image-mode-force-badge')).not.toBeNull(); }); await h.tapSend('a castle on a hill'); - await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); }, { timeout: 8000 }); + await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage).toHaveLength(1); }, { timeout: 8000 }); await h.rtl.waitFor(() => { expect(h.view!.queryByTestId('generated-image')).not.toBeNull(); }, { timeout: 8000 }); // The user unloads the image model (store transition — the picker is behind a fragile nested sheet; @@ -55,6 +55,6 @@ describe('send/resend parity — resending an image turn with no image model fal await h.rtl.waitFor(() => { expect(h.view!.queryByText(/A castle is a fortified stone structure\./)).not.toBeNull(); }, { timeout: 8000 }); expect(h.view!.queryByText('No image model loaded.')).toBeNull(); // The failed image path must NOT have fired a second diffusion call. - expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); + expect(h.boundary.diffusion.calls.generateImage).toHaveLength(1); }, 60000); }); diff --git a/__tests__/integration/happy/imageBackends.happy.test.tsx b/__tests__/integration/happy/imageBackends.happy.test.tsx index 05510d961..2fa95653c 100644 --- a/__tests__/integration/happy/imageBackends.happy.test.tsx +++ b/__tests__/integration/happy/imageBackends.happy.test.tsx @@ -36,7 +36,7 @@ describe('happy — image generation shows the correct backend label (heavy entr await h.tapSend('a fox in snow'); // A real image was produced through the real service + native generateImage... - await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); }); + await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage).toHaveLength(1); }); // ...and the user sees the correct backend label in the message details. await h.rtl.waitFor(() => { expect(h.view!.queryByText(new RegExp(cfg.expected.replace(/[()]/g, '\\$&')))).not.toBeNull(); }); }); diff --git a/__tests__/integration/happy/imageIntentRouting.happy.test.tsx b/__tests__/integration/happy/imageIntentRouting.happy.test.tsx index 5579dfbc8..250f4f56a 100644 --- a/__tests__/integration/happy/imageIntentRouting.happy.test.tsx +++ b/__tests__/integration/happy/imageIntentRouting.happy.test.tsx @@ -36,7 +36,7 @@ describe('happy — prompt routing picks the right model (heavy entry point)', ( await h.send('draw a cat wearing a hat', { content: 'unused-text-turn' }); // Routed to the image model: the native image generator ran exactly once. - await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); }); + await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage).toHaveLength(1); }); }); it('control: a normal prompt routes to the text model (no image generated)', async () => { @@ -48,6 +48,6 @@ describe('happy — prompt routing picks the right model (heavy entry point)', ( // Routed to the text model: the answer renders and the image generator never ran. await h.rtl.waitFor(() => { expect(h.view!.queryByText(/The capital of France is Paris\./)).not.toBeNull(); }); - expect(h.boundary.diffusion.calls.generateImage.length).toBe(0); + expect(h.boundary.diffusion.calls.generateImage).toHaveLength(0); }); }); diff --git a/__tests__/integration/happy/imageLightbox.happy.test.tsx b/__tests__/integration/happy/imageLightbox.happy.test.tsx index 43e5bd4af..ed39afc5f 100644 --- a/__tests__/integration/happy/imageLightbox.happy.test.tsx +++ b/__tests__/integration/happy/imageLightbox.happy.test.tsx @@ -24,7 +24,7 @@ async function generateImage(h: Awaited>) { await h.rtl.waitFor(() => { expect(h.view!.queryByTestId('image-mode-force-badge')).not.toBeNull(); }); await h.tapSend('a fox in the snow'); // The image is produced through the real service + native generateImage and rendered in the chat. - await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); }); + await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage).toHaveLength(1); }); await h.rtl.waitFor(() => { expect(h.view!.queryByTestId('generated-image')).not.toBeNull(); }); } diff --git a/__tests__/integration/happy/imageModeToggle.happy.test.tsx b/__tests__/integration/happy/imageModeToggle.happy.test.tsx index 17853352a..8960ed1aa 100644 --- a/__tests__/integration/happy/imageModeToggle.happy.test.tsx +++ b/__tests__/integration/happy/imageModeToggle.happy.test.tsx @@ -29,7 +29,7 @@ describe('happy — image-mode toggle routes correctly (heavy entry point)', () await h.tapSend('tell me about the ocean'); // not a draw request // ON forces image regardless of the text → the native image generator runs. - await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); }); + await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage).toHaveLength(1); }); }); it('OFF (disabled): the badge clears and a "draw …" prompt does NOT generate an image', async () => { @@ -45,6 +45,6 @@ describe('happy — image-mode toggle routes correctly (heavy entry point)', () // OFF disables image routing → the draw request is answered as text, no image generated. await h.rtl.waitFor(() => { expect(h.view!.queryByText(/A dragon is a mythical reptile\./)).not.toBeNull(); }); - expect(h.boundary.diffusion.calls.generateImage.length).toBe(0); + expect(h.boundary.diffusion.calls.generateImage).toHaveLength(0); }); }); diff --git a/__tests__/integration/happy/imageOomCard.happy.test.tsx b/__tests__/integration/happy/imageOomCard.happy.test.tsx index 7bfa709d0..3a81c2ef8 100644 --- a/__tests__/integration/happy/imageOomCard.happy.test.tsx +++ b/__tests__/integration/happy/imageOomCard.happy.test.tsx @@ -39,6 +39,6 @@ describe('happy — image-gen OOM surfaces the graceful "Not Enough Memory" card // Graceful outcome: the user sees the memory card + the override, and NO image was generated. await h.rtl.waitFor(() => { expect(h.view!.queryByText(/Not Enough Memory/)).not.toBeNull(); }); expect(h.view!.queryByText('Load Anyway')).not.toBeNull(); - expect(h.boundary.diffusion.calls.generateImage.length).toBe(0); + expect(h.boundary.diffusion.calls.generateImage).toHaveLength(0); }); }); diff --git a/__tests__/integration/happy/smartBudgeting.happy.test.tsx b/__tests__/integration/happy/smartBudgeting.happy.test.tsx index 96babcc81..11d9381f3 100644 --- a/__tests__/integration/happy/smartBudgeting.happy.test.tsx +++ b/__tests__/integration/happy/smartBudgeting.happy.test.tsx @@ -27,8 +27,8 @@ describe('happy — a fittable image gen succeeds with no failure card (heavy en await h.tapSend('a red bicycle'); // The fittable load succeeded through the REAL gate: the native image generator ran... - await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage.length).toBe(1); }); - /* eslint-disable-next-line @typescript-eslint/no-var-requires */ + await h.rtl.waitFor(() => { expect(h.boundary.diffusion.calls.generateImage).toHaveLength(1); }); + const { modelResidencyManager } = require('../../../src/services/modelResidency'); expect(modelResidencyManager.isResident('image')).toBe(true); diff --git a/__tests__/integration/happy/speakMessage.happy.test.tsx b/__tests__/integration/happy/speakMessage.happy.test.tsx index b38ddc8cd..365538d7a 100644 --- a/__tests__/integration/happy/speakMessage.happy.test.tsx +++ b/__tests__/integration/happy/speakMessage.happy.test.tsx @@ -23,9 +23,9 @@ jest.mock('@react-navigation/native', () => ({ // app and the test share the same hook registry instance. canSpeak=true enables the affordance; speak captures. function installTtsSeam(): { spoken: Array<{ text: string; id: string }> } { const spoken: Array<{ text: string; id: string }> = []; - /* eslint-disable @typescript-eslint/no-var-requires */ + const { registerHook, HOOKS } = require('../../../src/bootstrap/hookRegistry'); - /* eslint-enable @typescript-eslint/no-var-requires */ + registerHook(HOOKS.audioCanSpeak, () => true); registerHook(HOOKS.audioSpeak, (text: string, id: string) => { spoken.push({ text, id }); }); return { spoken }; @@ -44,7 +44,7 @@ describe.each(['longpress', 'dots'] as const)('happy — speak an assistant repl h.rtl.fireEvent.press(await h.rtl.waitFor(() => h.view!.getByTestId('action-speak'))); // The engine was asked to speak THIS reply's text (with its message id). - await h.rtl.waitFor(() => { expect(seam.spoken.length).toBe(1); }); + await h.rtl.waitFor(() => { expect(seam.spoken).toHaveLength(1); }); expect(seam.spoken[0].text).toContain('The capital of France is Paris.'); expect(seam.spoken[0].id).toBeTruthy(); }); diff --git a/__tests__/integration/happy/tools.happy.test.tsx b/__tests__/integration/happy/tools.happy.test.tsx index bfa4451de..00d529979 100644 --- a/__tests__/integration/happy/tools.happy.test.tsx +++ b/__tests__/integration/happy/tools.happy.test.tsx @@ -33,9 +33,9 @@ describe('happy — a tool runs and its result renders (heavy entry point)', () it('MCP: a registered MCP tool executes and its result reaches the answer', async () => { const h = await setupChatScreen({ engine: 'litert' }); - /* eslint-disable @typescript-eslint/no-var-requires */ + const { registerToolExtension, _clearExtensionsForTesting } = require('../../../src/services/tools/extensions'); - /* eslint-enable @typescript-eslint/no-var-requires */ + _clearExtensionsForTesting(); let executed = false; registerToolExtension({ @@ -70,7 +70,7 @@ describe('happy — a tool runs and its result renders (heavy entry point)', () content: 'Results: 160500 and 25.', }); // Two tool-result bubbles render (both calculator runs are visible). - await h.rtl.waitFor(() => { expect(h.view!.queryAllByTestId('tool-result-label-calculator').length).toBe(2); }); + await h.rtl.waitFor(() => { expect(h.view!.queryAllByTestId('tool-result-label-calculator')).toHaveLength(2); }); // ...and the answer with both results renders. await h.rtl.waitFor(() => { expect(h.view!.queryByText(/160500 and 25/)).not.toBeNull(); }); }); diff --git a/__tests__/integration/image/imageTunablesReadFreshFromStore.redflow.test.ts b/__tests__/integration/image/imageTunablesReadFreshFromStore.redflow.test.ts index 0ab9295fb..37744be6d 100644 --- a/__tests__/integration/image/imageTunablesReadFreshFromStore.redflow.test.ts +++ b/__tests__/integration/image/imageTunablesReadFreshFromStore.redflow.test.ts @@ -20,10 +20,9 @@ import { createONNXImageModel } from '../../utils/factories'; describe('image tunables read FRESH from the store, not a stale caller snapshot — device 2026-07-14', () => { it('steps + guidance reaching native are the current store values, not the (stale) deps.settings', async () => { const boundary = installNativeBoundary({ fs: true, ram: { platform: 'android', totalBytes: 12 * 1024 ** 3, availBytes: 8 * 1024 ** 3 } }); - /* eslint-disable @typescript-eslint/no-var-requires */ + const { handleImageGenerationFn } = require('../../../src/screens/ChatScreen/useChatGenerationActions'); const { useAppStore } = require('../../../src/stores'); - /* eslint-enable @typescript-eslint/no-var-requires */ // A downloaded + active image model (coreml = a non-empty dir on the in-memory disk). const imgModel = createONNXImageModel({ id: 'sd', name: 'SD', modelPath: '/models/sd', backend: 'coreml' }); @@ -56,7 +55,7 @@ describe('image tunables read FRESH from the store, not a stale caller snapshot // The REAL native generateImage ran once, and the params it received are the FRESH store values. await Promise.resolve(); const calls = boundary.diffusion.calls.generateImage; - expect(calls.length).toBe(1); + expect(calls).toHaveLength(1); expect(calls[0].steps).toBe(11); // RED before: 8 (stale deps snapshot) expect(calls[0].guidanceScale).toBe(3.5); // RED before: 7.5 (stale deps snapshot) }); diff --git a/__tests__/integration/models/activeModelService.test.ts b/__tests__/integration/models/activeModelService.test.ts index a789ba241..ff44a6244 100644 --- a/__tests__/integration/models/activeModelService.test.ts +++ b/__tests__/integration/models/activeModelService.test.ts @@ -399,7 +399,7 @@ describe('ActiveModelService Integration', () => { await activeModelService.unloadTextModel(); expect(mockLlmService.unloadModel).toHaveBeenCalled(); - expect(getAppState().activeModelId).toBe(null); + expect(getAppState().activeModelId).toBeNull(); }); it('should skip unload if no model loaded', async () => { @@ -573,7 +573,7 @@ describe('ActiveModelService Integration', () => { await activeModelService.unloadImageModel(); expect(mockLocalDreamService.unloadModel).toHaveBeenCalled(); - expect(getAppState().activeImageModelId).toBe(null); + expect(getAppState().activeImageModelId).toBeNull(); }); }); @@ -774,8 +774,8 @@ describe('ActiveModelService Integration', () => { await activeModelService.syncWithNativeState(); const loadedIds = activeModelService.getLoadedModelIds(); - expect(loadedIds.textModelId).toBe(null); - expect(loadedIds.imageModelId).toBe(null); + expect(loadedIds.textModelId).toBeNull(); + expect(loadedIds.imageModelId).toBeNull(); }); }); @@ -821,9 +821,9 @@ describe('ActiveModelService Integration', () => { const info = activeModelService.getActiveModels(); - expect(info.text.model).toBe(null); + expect(info.text.model).toBeNull(); expect(info.text.isLoaded).toBe(false); - expect(info.image.model).toBe(null); + expect(info.image.model).toBeNull(); expect(info.image.isLoaded).toBe(false); }); }); diff --git a/__tests__/integration/models/browseFitChipShowsLoadableModels.rendered.test.tsx b/__tests__/integration/models/browseFitChipShowsLoadableModels.rendered.test.tsx new file mode 100644 index 000000000..9c60fce5b --- /dev/null +++ b/__tests__/integration/models/browseFitChipShowsLoadableModels.rendered.test.tsx @@ -0,0 +1,56 @@ +/** + * Browse fit chip — a model that's OVER the balanced budget but still loadable ('tight', under the + * aggressive ceiling) now SHOWS in browse with a fit chip, instead of being hidden. + * + * SPEC: browse no longer hides loadable models behind fileExceedsBudget (the balanced budget). It keeps + * any model with a quant that isn't 'wontFit' (past the aggressive ceiling) and tags it with a device-fit + * chip (Easy / Fits / Tight). Only genuinely-too-big models are hidden. Recommended stays filtered and + * search/sort are untouched — this is only the browse-results relaxation + the chip. + * + * Mounts the REAL ModelsScreen, searches, and asserts the previously-hidden 'tight' model now renders with + * its Tight chip. RED before the relaxation (useTextModels hid it via fileExceedsBudget; ModelCardContent + * had no chip). Boundary fakes only: native download + fs + RAM + the HuggingFace transport. + */ +import { installNativeBoundary, requireRTL, GB } from '../../harness/nativeBoundary'; + +const MODEL_ID = 'org/tight-fit'; + +describe('browse shows a loadable-but-over-balanced-budget model with a fit chip (rendered)', () => { + it('renders the tight model (not hidden) and its Tight chip', async () => { + // 8GB Android: balanced soft = 4.8GB, aggressive ceil = 6.0GB. A single 5GB quant is OVER the balanced + // budget (old behaviour: hidden) but under the aggressive ceiling → 'tight' → now shown with a chip. + installNativeBoundary({ download: true, fs: true, ram: { platform: 'android', totalBytes: 8 * GB, availBytes: 5 * GB } }); + + const tightFile = { name: 'model-Q5_K_M.gguf', size: 5 * GB, quantization: 'Q5_K_M', downloadUrl: `https://hf.co/${MODEL_ID}/resolve/main/model-Q5_K_M.gguf` }; + const modelInfo = { id: MODEL_ID, name: 'Tight Fit Model', author: 'org', description: 'test', downloads: 50, likes: 1, tags: [], lastModified: '', files: [tightFile] }; + jest.doMock('../../../src/services/huggingface', () => ({ + huggingFaceService: { + searchModels: jest.fn(async () => [modelInfo]), + getModelFiles: jest.fn(async () => [tightFile]), + getModelDetails: jest.fn(async () => modelInfo), + getDownloadUrl: (m: string, f: string, r = 'main') => `https://hf.co/${m}/resolve/${r}/${f}`, + formatModelSize: jest.fn(() => '5.0 GB'), + formatFileSize: jest.fn((b: number) => `${(b / GB).toFixed(1)} GB`), + }, + })); + + const React = require('react'); + const { render, fireEvent, waitFor, act } = requireRTL(); + const { hardwareService } = require('../../../src/services/hardware'); + const { fitTier } = require('../../../src/services/memoryBudget'); + const { ModelsScreen } = require('../../../src/screens/ModelsScreen'); + + await hardwareService.refreshMemoryInfo(); + const ramGB = hardwareService.getTotalMemoryGB(); + // Precondition: this model is 'tight' — loadable, but over the balanced budget (old code hid it). + expect(fitTier(tightFile.size, ramGB)).toBe('tight'); + + const { getByTestId, getByText, queryByTestId } = render(React.createElement(ModelsScreen, {})); + await act(async () => { fireEvent.changeText(getByTestId('search-input'), 'tight'); }); + await act(async () => { fireEvent(getByTestId('search-input'), 'submitEditing'); await new Promise((r) => setTimeout(r, 600)); }); + + // TERMINAL artifact: the model is NOT hidden (renders), and carries the Tight fit chip. + await waitFor(() => expect(getByText('Tight Fit Model')).toBeTruthy(), { timeout: 6000 }); + expect(queryByTestId('fit-chip-tight')).not.toBeNull(); + }, 30000); +}); diff --git a/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx b/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx index a362b87de..c4456393a 100644 --- a/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx +++ b/__tests__/integration/models/detailFilesFitHintUsesOwnedBudget.rendered.test.tsx @@ -1,26 +1,25 @@ /** - * Detail "Available Files" device-fit hint — the single owned budget (memoryBudget.fileExceedsBudget). + * Detail "Available Files" device-fit list — the single owned budget (memoryBudget.fitTier). * - * SPEC (the OGAM user's view): in a model's detail view, the "Available Files" list offers exactly the - * quant files that FIT this device's RAM budget and hides the ones that don't — and that fit decision is - * the ONE owned primitive `fileExceedsBudget` (device-tier fraction of TOTAL RAM), never a hand-rolled - * copy of the budget arithmetic that could drift from the download-warning / picker / browse-list copies. + * SPEC (the OGAM user's view): in a model's detail view, the "Available Files" list offers every LOADABLE + * quant (fitTier !== 'wontFit' — down to the aggressive ceiling, since the load path can reach it via + * reclaim credit + Load Anyway) and hides only the genuinely-too-big ones. That decision is the ONE owned + * primitive `fitTier`, never a hand-rolled copy that could drift from the browse-list / picker copies. * * This mounts the REAL ModelsScreen, arrives at a model's detail via a real search+tap, and asserts the - * rendered file list against `fileExceedsBudget`'s verdict for each file: the under-budget quant renders, - * the over-budget quant is absent. Boundary fakes only: native download + fs + RAM (installNativeBoundary) - * and the HuggingFace network transport. The budget math, screen, hooks, ModelCard all run REAL. + * rendered file list against `fitTier`'s verdict per file: a loadable quant renders, a 'wontFit' quant is + * absent. Boundary fakes only: native download + fs + RAM (installNativeBoundary) and the HuggingFace + * network transport. The budget math, screen, hooks, ModelCard all run REAL. * - * Falsification (DRY): the expected present/absent set is computed from `fileExceedsBudget` itself, so if - * a caller's inline copy of the formula drifts from the owner (different fraction, wrong comparison, a - * unit slip), the rendered list stops matching the owner's verdict and this test goes red. + * Falsification (DRY): the expected present/absent set is computed from `fitTier` itself, so if a caller's + * inline copy of the formula drifts from the owner, the rendered list stops matching and this test reds. */ import { installNativeBoundary, requireRTL, GB } from '../../harness/nativeBoundary'; const MODEL_ID = 'org/fit-hint'; describe('detail Available Files fit hint matches the owned fileExceedsBudget verdict (rendered)', () => { - it('offers exactly the files fileExceedsBudget says fit — hides the over-budget quant', async () => { + it('offers every loadable quant (fitTier !== wontFit) — hides only the genuinely-too-big one', async () => { // Device: a 6GB Android phone → budget = 6 * modelBudgetFraction(6)=0.60 = 3.6GB. installNativeBoundary({ download: true, fs: true, ram: { platform: 'android', totalBytes: 6 * GB, availBytes: 4 * GB } }); @@ -39,20 +38,20 @@ describe('detail Available Files fit hint matches the owned fileExceedsBudget ve }, })); - /* eslint-disable @typescript-eslint/no-var-requires */ const React = require('react'); const { render, fireEvent, waitFor, act } = requireRTL(); const { hardwareService } = require('../../../src/services/hardware'); - const { fileExceedsBudget } = require('../../../src/services/memoryBudget'); + const { fitTier } = require('../../../src/services/memoryBudget'); const { ModelsScreen } = require('../../../src/screens/ModelsScreen'); - /* eslint-enable @typescript-eslint/no-var-requires */ await hardwareService.refreshMemoryInfo(); const ramGB = hardwareService.getTotalMemoryGB(); - // The owner's verdict is the source of truth for what the list must show. - expect(fileExceedsBudget(fitFile.size, ramGB)).toBe(false); // fits → must render - expect(fileExceedsBudget(bigFile.size, ramGB)).toBe(true); // exceeds → must be hidden + // The list now offers every LOADABLE quant (fitTier !== 'wontFit', up to the aggressive ceiling) + // and hides only the genuinely-too-big ones. On 6GB the aggressive ceiling is 4.5GB: 2GB is loadable, + // 5GB is past it → 'wontFit'. + expect(fitTier(fitFile.size, ramGB)).not.toBe('wontFit'); // loadable → must render + expect(fitTier(bigFile.size, ramGB)).toBe('wontFit'); // past aggressive ceiling → hidden const utils = render(React.createElement(ModelsScreen, {})); const { getByTestId, getByText, queryByText } = utils; @@ -75,46 +74,41 @@ describe('detail Available Files fit hint matches the owned fileExceedsBudget ve expect(queryByText('model-Q8_0')).toBeNull(); }, 30000); - it('BOUNDARY (M5a): a file EXACTLY at the budget is treated as over-budget (>=), one just under fits', async () => { - // Pins the exact budget comparison (the `>=` in fileExceedsBudget). The verifier's `>=`→`>` mutant - // survived because no test straddled equality. Device chosen so the budget is a WHOLE number of - // bytes: 4GB × balanced 0.50 = EXACTLY 2.0 GB (2147483648 B) — the only tier where integer bytes can - // hit exact equality (0.60×6GB is 3.6GB, not an integer, so >= and > can't differ there). A file of - // EXACTLY 2.0GB must be HIDDEN (>= = exceeds); 2.0GB−1byte must SHOW. Reverting to `>` flips the - // exact-budget file to "fits" → this test goes red (mutant killed). - installNativeBoundary({ download: true, fs: true, ram: { platform: 'android', totalBytes: 4 * GB, availBytes: 3 * GB } }); + it('BOUNDARY: a file EXACTLY at the aggressive ceiling is Won\'t-fit (hidden), one just under is loadable (shown)', async () => { + // The list hides only 'wontFit' — files past the AGGRESSIVE ceiling (fitTier's `size < ceil`). Pin + // that boundary on a device where the ceiling is a WHOLE number of bytes: 8GB × aggressive 0.75 = + // EXACTLY 6.0 GB. A file of EXACTLY 6.0GB is 'wontFit' (not `< ceil`) → HIDDEN; 6.0GB−1byte is 'tight' + // → SHOWN. Flipping fitTier's `<` to `<=` would make the exact-ceiling file 'tight'/shown → red. + installNativeBoundary({ download: true, fs: true, ram: { platform: 'android', totalBytes: 8 * GB, availBytes: 5 * GB } }); - /* eslint-disable @typescript-eslint/no-var-requires */ const { modelBudgetFraction } = require('../../../src/services/memoryBudget'); - /* eslint-enable @typescript-eslint/no-var-requires */ - const budgetBytes = 4 * modelBudgetFraction(4, 'android', 'balanced') * GB; // 4 × 0.50 × GB = exactly 2.0 GB (integer bytes) - const atBudget = { name: 'model-atbudget.gguf', size: budgetBytes, quantization: 'Q5', downloadUrl: `https://hf.co/${MODEL_ID}/resolve/main/model-atbudget.gguf` }; - const underBudget = { name: 'model-under.gguf', size: budgetBytes - 1, quantization: 'Q4', downloadUrl: `https://hf.co/${MODEL_ID}/resolve/main/model-under.gguf` }; - const modelInfo = { id: MODEL_ID, name: 'Boundary Model', author: 'org', description: 'test', downloads: 50, likes: 1, tags: [], lastModified: '', files: [underBudget, atBudget] }; + + const ceilBytes = 8 * modelBudgetFraction(8, 'android', 'aggressive') * GB; // 8 × 0.75 × GB = exactly 6.0 GB (integer bytes) + const atCeiling = { name: 'model-atceiling.gguf', size: ceilBytes, quantization: 'Q6', downloadUrl: `https://hf.co/${MODEL_ID}/resolve/main/model-atceiling.gguf` }; + const underCeiling = { name: 'model-under.gguf', size: ceilBytes - 1, quantization: 'Q5', downloadUrl: `https://hf.co/${MODEL_ID}/resolve/main/model-under.gguf` }; + const modelInfo = { id: MODEL_ID, name: 'Boundary Model', author: 'org', description: 'test', downloads: 50, likes: 1, tags: [], lastModified: '', files: [underCeiling, atCeiling] }; jest.doMock('../../../src/services/huggingface', () => ({ huggingFaceService: { searchModels: jest.fn(async () => [modelInfo]), - getModelFiles: jest.fn(async () => [underBudget, atBudget]), + getModelFiles: jest.fn(async () => [underCeiling, atCeiling]), getModelDetails: jest.fn(async () => modelInfo), getDownloadUrl: (m: string, f: string, r = 'main') => `https://hf.co/${m}/resolve/${r}/${f}`, - formatModelSize: jest.fn(() => '2.0 GB'), + formatModelSize: jest.fn(() => '6.0 GB'), formatFileSize: jest.fn((b: number) => `${(b / GB).toFixed(2)} GB`), }, })); - /* eslint-disable @typescript-eslint/no-var-requires */ const React = require('react'); const { render, fireEvent, waitFor, act } = requireRTL(); const { hardwareService } = require('../../../src/services/hardware'); - const { fileExceedsBudget } = require('../../../src/services/memoryBudget'); + const { fitTier } = require('../../../src/services/memoryBudget'); const { ModelsScreen } = require('../../../src/screens/ModelsScreen'); - /* eslint-enable @typescript-eslint/no-var-requires */ await hardwareService.refreshMemoryInfo(); const ramGB = hardwareService.getTotalMemoryGB(); - // Owner verdict is the source of truth: exact-budget EXCEEDS (>=), just-under FITS. - expect(fileExceedsBudget(atBudget.size, ramGB)).toBe(true); - expect(fileExceedsBudget(underBudget.size, ramGB)).toBe(false); + // Owner verdict: EXACTLY at the aggressive ceiling is 'wontFit'; just-under is loadable ('tight'). + expect(fitTier(atCeiling.size, ramGB)).toBe('wontFit'); + expect(fitTier(underCeiling.size, ramGB)).not.toBe('wontFit'); const { getByTestId, getByText, queryByText } = render(React.createElement(ModelsScreen, {})); await act(async () => { fireEvent.changeText(getByTestId('search-input'), 'boundary'); }); @@ -124,8 +118,8 @@ describe('detail Available Files fit hint matches the owned fileExceedsBudget ve await waitFor(() => expect(getByTestId('model-detail-screen')).toBeTruthy(), { timeout: 4000 }); await waitFor(() => expect(getByText('model-under')).toBeTruthy(), { timeout: 4000 }); - // TERMINAL artifact: just-under renders; EXACTLY-at-budget is hidden (the `>=` boundary). + // TERMINAL artifact: just-under (loadable) renders; EXACTLY-at-ceiling (Won't fit) is hidden. expect(queryByText('model-under')).not.toBeNull(); - expect(queryByText('model-atbudget')).toBeNull(); + expect(queryByText('model-atceiling')).toBeNull(); }, 30000); }); diff --git a/__tests__/integration/models/modelFilesLoadErrorShowsRetry.rendered.test.tsx b/__tests__/integration/models/modelFilesLoadErrorShowsRetry.rendered.test.tsx new file mode 100644 index 000000000..f1fb2d9fa --- /dev/null +++ b/__tests__/integration/models/modelFilesLoadErrorShowsRetry.rendered.test.tsx @@ -0,0 +1,92 @@ +/** + * RENDERED (UI integration) — model-detail "Available Files" shows a RETRY state when the + * file-list fetch fails, not the misleading "No compatible files found". + * + * REGRESSION exposed by the HF/AWS outage: tapping a model when HuggingFace is unreachable left + * the "Available Files" area either spinning forever (no request timeout) or — once the fetch + * failed — showing "No compatible files found for this model.", which blames the model when the + * truth is the network failed. + * + * SPEC (OGAM user's view): when the file list can't be fetched, the detail screen says so plainly + * ("Couldn't load files. Check your connection.") and offers a Retry that re-runs the fetch. A + * successful retry then renders the files. "No compatible files found" is reserved for a fetch that + * SUCCEEDED but returned nothing that fits. + * + * Boundary fakes only: native download + fs + RAM (installNativeBoundary) and global fetch (the + * HuggingFace transport). The real huggingFaceService, screen, useTextModels hook, + * handleSelectModel, timeout/fail-fast behavior, and filesLoadError state machine all run. + */ +import { installNativeBoundary, requireRTL, GB } from '../../harness/nativeBoundary'; + +const MODEL_ID = 'org/retry-model'; +const originalFetch = global.fetch; + +afterEach(() => { global.fetch = originalFetch; }); + +describe('model detail Available Files — fetch failure shows Retry, success renders files', () => { + it('shows the retry state on a failed file-list fetch, then renders files after Retry', async () => { + installNativeBoundary({ download: true, fs: true, ram: { platform: 'android', totalBytes: 8 * GB, availBytes: 6 * GB } }); + + const hfModel = { + id: MODEL_ID, + author: 'org', + downloads: 50, + likes: 1, + tags: ['gguf'], + lastModified: '', + siblings: [], + }; + let fileListAttempts = 0; + + // Fake the external HTTP transport, not our HuggingFace service. Search succeeds. The first + // tree request fails as an aborted request (the real 5s-timeout shape), and Retry succeeds. + global.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes('/models?')) { + return { ok: true, json: async () => url.includes('search=retry') ? [hfModel] : [] } as Response; + } + if (url.endsWith(`/models/${MODEL_ID}/tree/main`)) { + fileListAttempts += 1; + if (fileListAttempts === 1) { + const aborted = new Error('network request timed out'); + aborted.name = 'AbortError'; + throw aborted; + } + return { + ok: true, + json: async () => [{ type: 'file', path: 'model-Q4_K_M.gguf', size: 2 * GB }], + } as Response; + } + const modelId = decodeURIComponent(url.split('/models/')[1] || 'org/unknown'); + return { ok: true, json: async () => ({ ...hfModel, id: modelId }) } as Response; + }) as typeof fetch; + + const React = require('react'); + const { render, fireEvent, waitFor, act } = requireRTL(); + const { hardwareService } = require('../../../src/services/hardware'); + const { ModelsScreen } = require('../../../src/screens/ModelsScreen'); + + await hardwareService.refreshMemoryInfo(); + + const { getByTestId, getByText, queryByText, queryByTestId } = render(React.createElement(ModelsScreen, {})); + + // Arrive at the model's detail the way a user does: search, submit, tap the result. + await act(async () => { fireEvent.changeText(getByTestId('search-input'), 'retry'); }); + await act(async () => { + fireEvent(getByTestId('search-input'), 'submitEditing'); + await new Promise((r) => setTimeout(r, 600)); + }); + await waitFor(() => expect(getByText('retry-model')).toBeTruthy(), { timeout: 6000 }); + await act(async () => { fireEvent.press(getByText('retry-model')); }); + + // The first file-list fetch failed → the RETRY state renders, NOT "No compatible files found". + await waitFor(() => expect(getByTestId('model-files-load-error')).toBeTruthy(), { timeout: 4000 }); + expect(getByText(/Couldn't load files/)).toBeTruthy(); + expect(queryByText('No compatible files found for this model.')).toBeNull(); + + // Tapping Retry re-runs the fetch — which now succeeds — and the file renders. + await act(async () => { fireEvent.press(getByTestId('model-files-retry')); }); + await waitFor(() => expect(getByText('model-Q4_K_M')).toBeTruthy(), { timeout: 4000 }); + expect(queryByTestId('model-files-load-error')).toBeNull(); + }, 30000); +}); diff --git a/__tests__/integration/onboarding/deviceAnalysisDoesNotWaitForNetwork.rendered.test.tsx b/__tests__/integration/onboarding/deviceAnalysisDoesNotWaitForNetwork.rendered.test.tsx new file mode 100644 index 000000000..9d977ddf5 --- /dev/null +++ b/__tests__/integration/onboarding/deviceAnalysisDoesNotWaitForNetwork.rendered.test.tsx @@ -0,0 +1,86 @@ +/** + * RENDERED (UI integration) — onboarding renders when HuggingFace metadata is still pending. + * + * REGRESSION (0.0.103): fresh installs remained on "Analyzing your device..." for roughly 75 + * seconds because the screen awaited every model-file metadata request before clearing its loader. + * + * SPEC (OGAM user's view): device analysis and local recommendations render immediately. Network + * metadata may finish later and must never hold the onboarding screen hostage. + * + * Real ModelDownloadScreen + real hardwareService + real huggingFaceService. Fakes exist only at + * the native RAM sensor and global fetch, the external network boundary. + */ +import { + installNativeBoundary, + requireRTL, + GB, +} from '../../harness/nativeBoundary'; + +const originalFetch = global.fetch; + +afterEach(() => { + global.fetch = originalFetch; +}); + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ + navigate: () => {}, + goBack: () => {}, + setOptions: () => {}, + addListener: () => () => {}, + replace: () => {}, + }), + useRoute: () => ({ params: {} }), + useFocusEffect: () => {}, + useIsFocused: () => true, +})); + +describe('Onboarding device analysis — network metadata never gates the screen', () => { + it('renders the setup screen while every HuggingFace file-list request is still pending', async () => { + installNativeBoundary({ + ram: { platform: 'android', totalBytes: 11 * GB, availBytes: 4.57 * GB }, + }); + + const pendingResponses: Array<() => void> = []; + global.fetch = ((_input: RequestInfo | URL) => + new Promise(resolve => { + pendingResponses.push(() => + resolve({ ok: true, json: async () => [] } as Response), + ); + })) as typeof fetch; + + const React = require('react'); + const rtl = requireRTL(); + const { hardwareService } = require('../../../src/services/hardware'); + const { + ModelDownloadScreen, + } = require('../../../src/screens/ModelDownloadScreen'); + + await hardwareService.getDeviceInfo(); + + const navigation: any = { + navigate: () => {}, + goBack: () => {}, + setOptions: () => {}, + addListener: () => () => {}, + replace: () => {}, + }; + const view = rtl.render( + React.createElement(ModelDownloadScreen, { navigation }), + ); + + // Terminal artifact: onboarding is usable even though no metadata response has completed. + await rtl.waitFor( + () => expect(view.getByText('Set Up Your AI')).toBeTruthy(), + { timeout: 1500 }, + ); + expect(view.queryByText(/Analyzing your device/)).toBeNull(); + expect(pendingResponses.length).toBeGreaterThan(0); + + // Settle the boundary promises so this test leaves no timeout handles behind. + await rtl.act(async () => { + pendingResponses.forEach(resolve => resolve()); + await Promise.resolve(); + }); + }, 10000); +}); diff --git a/__tests__/integration/onboarding/deviceCardShowsTotalRam.rendered.test.tsx b/__tests__/integration/onboarding/deviceCardShowsTotalRam.rendered.test.tsx new file mode 100644 index 000000000..2f5265dd7 --- /dev/null +++ b/__tests__/integration/onboarding/deviceCardShowsTotalRam.rendered.test.tsx @@ -0,0 +1,50 @@ +/** + * RENDERED (UI integration) — onboarding "Your Device" card shows TOTAL physical RAM. + * + * REGRESSION (shipped in 0.0.103): the card labelled "Available Memory" rendered + * deviceInfo.availableMemory, which the memory-budget rework changed to the per-process + * allocatable ceiling (os_proc_available_memory) — a small number that reads as a wrong + * device spec. On the reported device with 11GB total but a ~4.57GB process ceiling, the card showed + * "4.57 GB" as if that were the phone's memory. + * + * SPEC (OGAM user's view): the "Your Device" card shows the device's TOTAL RAM — the number + * the user recognises and the same one the model recommendations gate on (getTotalMemoryGB). + * The per-process ceiling is a budget input, never surfaced here. + * + * BOUNDARY: 11GB Android device whose process-available snapshot is 4.57GB. RED before the fix: + * card shows "4.57 GB". GREEN after: it shows "11.00 GB" and never "4.57 GB". + * + * Real ModelDownloadScreen + real hardwareService/appStore; fakes ONLY at the native RAM sensor + * (installNativeBoundary). NEVER mocks our own code. + */ +import { installNativeBoundary, requireRTL, GB } from '../../harness/nativeBoundary'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: () => ({ navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {}, replace: () => {} }), + useRoute: () => ({ params: {} }), + useFocusEffect: () => {}, useIsFocused: () => true, +})); + +describe('Onboarding "Your Device" card — shows total RAM, not the per-process ceiling', () => { + it('shows 11.00 GB total on an 11GB device whose process-available is only 4.57GB', async () => { + installNativeBoundary({ ram: { platform: 'android', totalBytes: 11 * GB, availBytes: 4.57 * GB } }); + + const React = require('react'); + const rtl = requireRTL(); + const { hardwareService } = require('../../../src/services/hardware'); + const { ModelDownloadScreen } = require('../../../src/screens/ModelDownloadScreen'); + + // Prime the RAM cache the way the screen's own effect does (device-boundary read, not our state). + await hardwareService.getDeviceInfo(); + + const nav: any = { navigate: () => {}, goBack: () => {}, setOptions: () => {}, addListener: () => () => {}, replace: () => {} }; + const view = rtl.render(React.createElement(ModelDownloadScreen, { navigation: nav })); + + // The screen renders once device analysis is done (no longer blocked on the HF file fetch). + await rtl.waitFor(() => { expect(view.getByText('Set Up Your AI')).toBeTruthy(); }, { timeout: 10000 }); + + // The device card shows TOTAL RAM (11.00 GB) — NOT the per-process ceiling (4.57 GB). + expect(view.getByText('11.00 GB')).toBeTruthy(); + expect(view.queryByText('4.57 GB')).toBeNull(); + }, 30000); +}); diff --git a/__tests__/integration/onboarding/spotlightFlowIntegration.test.ts b/__tests__/integration/onboarding/spotlightFlowIntegration.test.ts index c015473a5..934d59cf1 100644 --- a/__tests__/integration/onboarding/spotlightFlowIntegration.test.ts +++ b/__tests__/integration/onboarding/spotlightFlowIntegration.test.ts @@ -88,13 +88,13 @@ describe('Onboarding Spotlight Flow Integration', () => { }); it('checklist step completes when model finishes downloading', () => { - expect(getAppState().downloadedModels.length).toBe(0); + expect(getAppState().downloadedModels).toHaveLength(0); // Simulate download completion useAppStore.getState().addDownloadedModel(createDownloadedModel()); const state = getAppState(); - expect(state.downloadedModels.length).toBe(1); + expect(state.downloadedModels).toHaveLength(1); // useOnboardingSteps checks: downloadedModels.length > 0 }); }); @@ -361,14 +361,14 @@ describe('Onboarding Spotlight Flow Integration', () => { // 4 is NOT enough const fourProjects = Array.from({ length: 4 }, (_, i) => createProject({ id: `proj-${i}` })); useProjectStore.setState({ projects: fourProjects }); - expect(useProjectStore.getState().projects.length).toBe(4); - expect(useProjectStore.getState().projects.length > 4).toBe(false); + expect(useProjectStore.getState().projects).toHaveLength(4); + expect(useProjectStore.getState().projects.length).toBeLessThanOrEqual(4); // 5 completes it const fiveProjects = [...fourProjects, createProject({ id: 'proj-4' })]; useProjectStore.setState({ projects: fiveProjects }); - expect(useProjectStore.getState().projects.length).toBe(5); - expect(useProjectStore.getState().projects.length > 4).toBe(true); + expect(useProjectStore.getState().projects).toHaveLength(5); + expect(useProjectStore.getState().projects.length).toBeGreaterThan(4); }); }); @@ -442,7 +442,7 @@ describe('Onboarding Spotlight Flow Integration', () => { expect(state.shownSpotlights).toEqual({}); // App data preserved - expect(state.downloadedModels.length).toBe(1); + expect(state.downloadedModels).toHaveLength(1); expect(state.activeModelId).toBe('model-1'); }); @@ -462,9 +462,9 @@ describe('Onboarding Spotlight Flow Integration', () => { // Initial state: no reactive conditions met let state = getAppState(); - expect(state.downloadedImageModels.length).toBe(0); + expect(state.downloadedImageModels).toHaveLength(0); expect(state.activeImageModelId).toBeNull(); - expect(state.generatedImages.length).toBe(0); + expect(state.generatedImages).toHaveLength(0); // Part 2 condition not yet met (no image model downloaded) expect( @@ -516,7 +516,7 @@ describe('Onboarding Spotlight Flow Integration', () => { // Part 5 condition not yet met (no image generated) state = getAppState(); - expect(state.generatedImages.length).toBe(0); + expect(state.generatedImages).toHaveLength(0); // Generate image → Part 5 triggers store.addGeneratedImage(createGeneratedImage()); diff --git a/__tests__/integration/rag/embeddingFlow.test.ts b/__tests__/integration/rag/embeddingFlow.test.ts index a016b222e..91b6f8ae5 100644 --- a/__tests__/integration/rag/embeddingFlow.test.ts +++ b/__tests__/integration/rag/embeddingFlow.test.ts @@ -194,7 +194,7 @@ describe('Embedding Flow Integration', () => { const embInserts = mockExecuteSync.mock.calls.filter( (c: any[]) => typeof c[0] === 'string' && c[0].includes('INSERT INTO rag_embeddings') ); - expect(embInserts.length).toBe(2); + expect(embInserts).toHaveLength(2); }); }); @@ -208,7 +208,7 @@ describe('Embedding Flow Integration', () => { const deleteCalls = mockExecuteSync.mock.calls.filter( (c: any[]) => typeof c[0] === 'string' && c[0].includes('DELETE') ); - expect(deleteCalls.length).toBe(3); + expect(deleteCalls).toHaveLength(3); // Order: embeddings, chunks, document expect(deleteCalls[0][0]).toContain('rag_embeddings'); expect(deleteCalls[0][1]).toEqual([42]); diff --git a/__tests__/integration/rag/ragFlow.test.ts b/__tests__/integration/rag/ragFlow.test.ts index 2ccb383e9..e53d0dba2 100644 --- a/__tests__/integration/rag/ragFlow.test.ts +++ b/__tests__/integration/rag/ragFlow.test.ts @@ -93,7 +93,7 @@ describe('RAG Flow Integration', () => { const docInserts = mockExecuteSync.mock.calls.filter( (c: any[]) => typeof c[0] === 'string' && c[0].includes('INSERT INTO rag_documents') ); - expect(docInserts.length).toBe(1); + expect(docInserts).toHaveLength(1); expect(docInserts[0][1]).toEqual(expect.arrayContaining(['proj-1', 'guide.pdf'])); // Verify chunks were inserted @@ -188,7 +188,7 @@ describe('RAG Flow Integration', () => { }); expect(result.truncated).toBe(true); - expect(result.chunks.length).toBe(0); // First chunk exceeds budget + expect(result.chunks).toHaveLength(0); // First chunk exceeds budget }); it('includes all results when within budget', async () => { @@ -213,7 +213,7 @@ describe('RAG Flow Integration', () => { }); expect(result.truncated).toBe(false); - expect(result.chunks.length).toBe(2); + expect(result.chunks).toHaveLength(2); }); }); @@ -249,7 +249,7 @@ describe('RAG Flow Integration', () => { const updateCalls = mockExecuteSync.mock.calls.filter( (c: any[]) => typeof c[0] === 'string' && c[0].includes('UPDATE') ); - expect(updateCalls.length).toBe(1); + expect(updateCalls).toHaveLength(1); expect(updateCalls[0][1]).toEqual([0, 1]); // enabled=0, docId=1 }); @@ -259,7 +259,7 @@ describe('RAG Flow Integration', () => { const deleteCalls = mockExecuteSync.mock.calls.filter( (c: any[]) => typeof c[0] === 'string' && c[0].includes('DELETE') ); - expect(deleteCalls.length).toBe(3); + expect(deleteCalls).toHaveLength(3); expect(deleteCalls[0][0]).toContain('rag_embeddings'); expect(deleteCalls[1][0]).toContain('rag_chunks'); expect(deleteCalls[2][0]).toContain('rag_documents'); @@ -272,7 +272,7 @@ describe('RAG Flow Integration', () => { (c: any[]) => typeof c[0] === 'string' && c[0].includes('DELETE') ); // 1 embeddings delete + 1 chunks delete + 1 docs delete - expect(deleteCalls.length).toBe(3); + expect(deleteCalls).toHaveLength(3); expect(deleteCalls[0][0]).toContain('rag_embeddings'); expect(deleteCalls[1][0]).toContain('rag_chunks'); expect(deleteCalls[2][0]).toContain('rag_documents'); @@ -376,7 +376,7 @@ describe('RAG Flow Integration', () => { it('chunking handles empty paragraphs gracefully', () => { const text = 'First paragraph is here.\n\n\n\n\n\nSecond paragraph is here.'; const chunks = chunkDocument(text, { chunkSize: 500 }); - expect(chunks.length).toBe(1); + expect(chunks).toHaveLength(1); expect(chunks[0].content).toContain('First'); expect(chunks[0].content).toContain('Second'); }); diff --git a/__tests__/integration/stores/chatStoreIntegration.test.ts b/__tests__/integration/stores/chatStoreIntegration.test.ts index 172fc3e64..37494ae22 100644 --- a/__tests__/integration/stores/chatStoreIntegration.test.ts +++ b/__tests__/integration/stores/chatStoreIntegration.test.ts @@ -80,7 +80,7 @@ describe('ChatStore Streaming Integration', () => { // Streaming state should be cleared expect(state.streamingMessage).toBe(''); - expect(state.streamingForConversationId).toBe(null); + expect(state.streamingForConversationId).toBeNull(); expect(state.isStreaming).toBe(false); // Message should be added to conversation @@ -162,7 +162,7 @@ describe('ChatStore Streaming Integration', () => { // Everything should be cleared expect(state.streamingMessage).toBe(''); - expect(state.streamingForConversationId).toBe(null); + expect(state.streamingForConversationId).toBeNull(); expect(state.isStreaming).toBe(false); expect(state.isThinking).toBe(false); @@ -191,7 +191,7 @@ describe('ChatStore Streaming Integration', () => { it('should return idle state when not streaming', () => { const streamingState = useChatStore.getState().getStreamingState(); - expect(streamingState.conversationId).toBe(null); + expect(streamingState.conversationId).toBeNull(); expect(streamingState.content).toBe(''); expect(streamingState.isStreaming).toBe(false); expect(streamingState.isThinking).toBe(false); diff --git a/__tests__/rntl/screens/ModelDownloadScreen.test.tsx b/__tests__/rntl/screens/ModelDownloadScreen.test.tsx index 96c3a11ac..d91de84cd 100644 --- a/__tests__/rntl/screens/ModelDownloadScreen.test.tsx +++ b/__tests__/rntl/screens/ModelDownloadScreen.test.tsx @@ -303,14 +303,11 @@ describe('ModelDownloadScreen', () => { expect(result.getByText(/Connect to a model server/)).toBeTruthy(); }); - it('renders device info card after loading', async () => { - const result = render(); - await flushPromises(); - - expect(result.getByText('Your Device')).toBeTruthy(); - expect(result.getByText('Test Device')).toBeTruthy(); - expect(result.getByText('Available Memory')).toBeTruthy(); - }); + // NOTE: the "device info card" case that lived here mocked hardwareService (our own code) and + // asserted the old "Available Memory" label. It is superseded by the real rendered integration + // test __tests__/integration/onboarding/deviceCardShowsTotalRam.rendered.test.tsx, which drives + // the REAL hardwareService over the RAM boundary and guards the total-RAM-vs-process-ceiling + // regression. Per the testing doctrine, a failing mockist test is deleted, not repaired. it('renders the NetworkSection', async () => { const result = render(); diff --git a/__tests__/unit/services/huggingface.test.ts b/__tests__/unit/services/huggingface.test.ts index f2bfa5785..f0459d1f1 100644 --- a/__tests__/unit/services/huggingface.test.ts +++ b/__tests__/unit/services/huggingface.test.ts @@ -588,6 +588,39 @@ describe('HuggingFaceService', () => { }); }); + describe('HuggingFace request timeout', () => { + it('aborts an unresponsive file-list request after 5s without starting a fallback request', async () => { + jest.useFakeTimers(); + const requestedUrls: string[] = []; + let requestSignal: AbortSignal | undefined; + global.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + requestedUrls.push(String(input)); + requestSignal = init?.signal ?? undefined; + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => { + reject(new DOMException('The operation was aborted.', 'AbortError')); + }); + }); + }) as typeof fetch; + + try { + const request = huggingFaceService.getModelFiles('org/unresponsive'); + const outcome = request.catch(error => error as Error); + + await jest.advanceTimersByTimeAsync(4999); + expect(requestSignal?.aborted).toBe(false); + await jest.advanceTimersByTimeAsync(1); + expect(requestSignal?.aborted).toBe(true); + await expect(outcome).resolves.toMatchObject({ name: 'AbortError' }); + + expect(requestedUrls).toHaveLength(1); + expect(requestedUrls[0]).toContain('/models/org/unresponsive/tree/main'); + } finally { + jest.useRealTimers(); + } + }); + }); + describe('getModelFilesFromSiblings — sort with multiple files', () => { it('sorts sibling files by size ascending', async () => { global.fetch = jest.fn() diff --git a/__tests__/unit/services/modelManager.test.ts b/__tests__/unit/services/modelManager.test.ts index 3cf81be69..fe60a588e 100644 --- a/__tests__/unit/services/modelManager.test.ts +++ b/__tests__/unit/services/modelManager.test.ts @@ -474,7 +474,7 @@ describe('ModelManager', () => { .mockResolvedValueOnce(true); // mmProjExists (no mmproj) mockedBackgroundDownloadService.startDownload.mockResolvedValue({ - downloadId: 42, + downloadId: '42', fileName: 'bg-model.gguf', modelId: 'test/model', status: 'pending', @@ -486,7 +486,7 @@ describe('ModelManager', () => { const result = await modelManager.downloadModelBackground('test/model', file); expect(mockedBackgroundDownloadService.startDownload).toHaveBeenCalled(); - expect(result.downloadId).toBe(42); + expect(result.downloadId).toBe('42'); }); it('sets up progress listener during start and complete/error via watchDownload', async () => { @@ -498,7 +498,7 @@ describe('ModelManager', () => { .mockResolvedValueOnce(true); mockedBackgroundDownloadService.startDownload.mockResolvedValue({ - downloadId: 42, + downloadId: '42', fileName: 'bg-model.gguf', modelId: 'test/model', status: 'pending', @@ -510,9 +510,9 @@ describe('ModelManager', () => { const info = await modelManager.downloadModelBackground('test/model', file); modelManager.watchDownload(info.downloadId, jest.fn(), jest.fn()); - expect(mockedBackgroundDownloadService.onProgress).toHaveBeenCalledWith(42, expect.any(Function)); - expect(mockedBackgroundDownloadService.onComplete).toHaveBeenCalledWith(42, expect.any(Function)); - expect(mockedBackgroundDownloadService.onError).toHaveBeenCalledWith(42, expect.any(Function)); + expect(mockedBackgroundDownloadService.onProgress).toHaveBeenCalledWith('42', expect.any(Function)); + expect(mockedBackgroundDownloadService.onComplete).toHaveBeenCalledWith('42', expect.any(Function)); + expect(mockedBackgroundDownloadService.onError).toHaveBeenCalledWith('42', expect.any(Function)); }); it('calls metadata callback with download info', async () => { @@ -524,7 +524,7 @@ describe('ModelManager', () => { .mockResolvedValueOnce(true); mockedBackgroundDownloadService.startDownload.mockResolvedValue({ - downloadId: 42, + downloadId: '42', fileName: 'bg-model.gguf', modelId: 'test/model', status: 'pending', @@ -538,7 +538,7 @@ describe('ModelManager', () => { await modelManager.downloadModelBackground('test/model', file); - expect(metadataCallback).toHaveBeenCalledWith(42, expect.objectContaining({ + expect(metadataCallback).toHaveBeenCalledWith('42', expect.objectContaining({ modelId: 'test/model', fileName: 'bg-model.gguf', })); @@ -562,7 +562,7 @@ describe('ModelManager', () => { mockedBackgroundDownloadService.startDownload .mockResolvedValueOnce({ - downloadId: 42, + downloadId: '42', fileName: 'vision.gguf', modelId: 'test/model', status: 'pending', @@ -1674,7 +1674,7 @@ describe('ModelManager', () => { mockedBackgroundDownloadService.startDownload .mockResolvedValueOnce({ - downloadId: 42, + downloadId: '42', fileName: 'bg-vision.gguf', modelId: 'test/model', status: 'pending', diff --git a/android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt b/android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt index dc06ca102..b807af328 100644 --- a/android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt +++ b/android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt @@ -9,6 +9,7 @@ import android.os.Build import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import android.os.Environment +import android.util.Log import android.os.PowerManager import android.provider.Settings import androidx.lifecycle.Observer @@ -162,7 +163,7 @@ class DownloadManagerModule(reactContext: ReactApplicationContext) : if (download != null) { downloadDao.updateStatus(downloadId, DownloadStatus.CANCELLED, DownloadReason.USER_CANCELLED) val file = File(download.destination) - if (file.exists()) file.delete() + if (file.exists() && !file.delete()) Log.w(NAME, "Failed to delete cancelled download file: ${file.path}") } } WorkerDownload.cancel(reactApplicationContext, downloadId) diff --git a/android/app/src/main/java/ai/offgridmobile/download/WorkerDownload.kt b/android/app/src/main/java/ai/offgridmobile/download/WorkerDownload.kt index 26e3830fc..1f35b8253 100644 --- a/android/app/src/main/java/ai/offgridmobile/download/WorkerDownload.kt +++ b/android/app/src/main/java/ai/offgridmobile/download/WorkerDownload.kt @@ -275,7 +275,7 @@ class WorkerDownload( val current = downloadDao.getDownload(downloadId) ?: download return if (current.status == DownloadStatus.CANCELLED) { val partialFile = File(current.destination) - if (partialFile.exists()) partialFile.delete() + if (partialFile.exists() && !partialFile.delete()) Log.w(TAG, "Failed to delete partial file on cancel: ${partialFile.path}") Result.failure() } else { // System stopped the worker — retry silently, no JS state change. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 0b497ff12..1c4068760 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -253,7 +253,11 @@ platform :android do skip_upload_changelogs: true, skip_upload_images: true, skip_upload_screenshots: true, - release_status: "draft" # human confirms rollout in Play Console + # A track PROMOTION reads track_promote_release_status, NOT release_status — the latter only + # applies to a fresh upload. Without this it defaulted to "completed", so the promotion went + # straight to review/100% rollout instead of parking as a draft (device 2026-07-16, 0.0.103). + release_status: "draft", + track_promote_release_status: "draft" # human confirms rollout in Play Console ) UI.success("Promoted internal build (versionCode #{version_code}) to Play production (draft - confirm rollout in console)") end diff --git a/scripts/uat.sh b/scripts/uat.sh index 965b28144..bab7f4650 100755 --- a/scripts/uat.sh +++ b/scripts/uat.sh @@ -34,7 +34,7 @@ cd "$ROOT_DIR" RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BOLD='\033[1m'; NC='\033[0m' info() { echo -e "${GREEN}[INFO]${NC} $*"; } warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } -error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } +error() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; } DO_IOS=1; DO_ANDROID=1 case "${1:-}" in @@ -46,12 +46,12 @@ esac command -v node >/dev/null || error "node not installed" command -v gh >/dev/null || error "gh CLI not installed" command -v bundle >/dev/null || error "bundler not installed (bundle install)" -[ -f fastlane/Fastfile ] || error "fastlane/Fastfile not found" +[[ -f fastlane/Fastfile ]] || error "fastlane/Fastfile not found" # Ignore fastlane/README.md — fastlane regenerates it on every run, so it is dirty by the time a # second build starts (and after any prior run). It is not source we build from. -[ -z "$(git status --porcelain | grep -vE 'fastlane/README\.md$' || true)" ] || error "Working tree is dirty. Commit or stash first." -[ "$DO_ANDROID" = 0 ] || { [ -f android/gradlew ] || error "android/gradlew not found"; [ -n "${ANDROID_HOME:-}" ] || error "ANDROID_HOME not set"; } -[ "$DO_IOS" = 0 ] || command -v xcodebuild >/dev/null || error "xcodebuild not installed" +[[ -z "$(git status --porcelain | grep -vE 'fastlane/README\.md$' || true)" ]] || error "Working tree is dirty. Commit or stash first." +[[ "$DO_ANDROID" = 0 ]] || { [[ -f android/gradlew ]] || error "android/gradlew not found"; [[ -n "${ANDROID_HOME:-}" ]] || error "ANDROID_HOME not set"; } +[[ "$DO_IOS" = 0 ]] || command -v xcodebuild >/dev/null || error "xcodebuild not installed" # ── compute the beta version ─────────────────────────────────────── # A beta targets the NEXT version, not the live one (the live train is closed - see header). @@ -70,7 +70,7 @@ BUILD_NUMBER=$(date +%s) info "Beta build: ${BOLD}${BETA_VERSION}${NC} (build ${BUILD_NUMBER}) - pre-release of ${TARGET_VERSION} (current live: ${CURRENT_VERSION})" # ── apply the build-number / beta-versionName bump (working tree; committed only on success) ── -if [ "$DO_ANDROID" = 1 ]; then +if [[ "$DO_ANDROID" = 1 ]]; then sed -i '' "s/versionCode .*/versionCode $BUILD_NUMBER/" android/app/build.gradle # versionName = the PRODUCTION version (no -beta suffix), matching iOS's MARKETING_VERSION # below. versionName is frozen into the AAB and is user-visible, so a "-beta" suffix here @@ -80,7 +80,7 @@ if [ "$DO_ANDROID" = 1 ]; then # "-beta.N" label lives in the git tag, the GitHub prerelease, and the store release notes. sed -i '' "s/versionName .*/versionName \"$TARGET_VERSION\"/" android/app/build.gradle fi -if [ "$DO_IOS" = 1 ]; then +if [[ "$DO_IOS" = 1 ]]; then # iOS marketing version = NEXT plain numeric version (App Store rejects "-beta", and this # opens a fresh TestFlight train since the live version's train is closed); build no. bumps. sed -i '' "s/MARKETING_VERSION = .*/MARKETING_VERSION = $TARGET_VERSION;/" ios/OffgridMobile.xcodeproj/project.pbxproj @@ -109,7 +109,7 @@ gen_notes_with_claude() { 2>/dev/null } -if NOTES=$(gen_notes_with_claude) && [ -n "$NOTES" ]; then +if NOTES=$(gen_notes_with_claude) && [[ -n "$NOTES" ]]; then printf '%s\n' "$NOTES" > "$NOTES_FILE" info "Release notes generated by claude -p" else @@ -132,29 +132,29 @@ info "Notes:"; sed 's/^/ /' "$NOTES_FILE"; echo "" # every retry). So we build ALL artifacts up front - the steps that actually fail (compile, # signing, export) happen before a single upload - and only publish once every artifact # exists. The fastlane beta lanes read UAT_CHANGELOG_PATH at upload time. -if [ "$DO_ANDROID" = 1 ]; then +if [[ "$DO_ANDROID" = 1 ]]; then info "Android → building signed AAB…"; bundle exec fastlane android build # Also build the sideloadable APK for the GitHub prerelease (the AAB isn't installable; # testers grabbing the build off GitHub need the APK - same as scripts/release.sh). info "Android → building installable APK for GitHub…"; (cd android && ./gradlew assembleRelease) AAB_SRC="android/app/build/outputs/bundle/release/app-release.aab" APK_SRC="android/app/build/outputs/apk/release/app-release.apk" - [ -f "$AAB_SRC" ] || error "AAB not found at $AAB_SRC" - [ -f "$APK_SRC" ] || error "APK not found at $APK_SRC" + [[ -f "$AAB_SRC" ]] || error "AAB not found at $AAB_SRC" + [[ -f "$APK_SRC" ]] || error "APK not found at $APK_SRC" fi -if [ "$DO_IOS" = 1 ]; then +if [[ "$DO_IOS" = 1 ]]; then info "iOS → building signed IPA…"; bundle exec fastlane ios build - [ -f build/OffgridMobile.ipa ] || error "IPA not found at build/OffgridMobile.ipa" + [[ -f build/OffgridMobile.ipa ]] || error "IPA not found at build/OffgridMobile.ipa" fi # ── PUBLISH - reached only if every build above succeeded ─────────── -if [ "$DO_ANDROID" = 1 ]; then info "Android → Play internal (AAB)…"; bundle exec fastlane android upload_beta; fi -if [ "$DO_IOS" = 1 ]; then info "iOS → TestFlight…"; bundle exec fastlane ios upload_beta; fi +if [[ "$DO_ANDROID" = 1 ]]; then info "Android → Play internal (AAB)…"; bundle exec fastlane android upload_beta; fi +if [[ "$DO_IOS" = 1 ]]; then info "iOS → TestFlight…"; bundle exec fastlane ios upload_beta; fi # ── success → commit the bump, cut the PRERELEASE tag, GH prerelease ── trap - EXIT # keep the bump now that the build shipped -FILES=(); [ "$DO_ANDROID" = 1 ] && FILES+=(android/app/build.gradle) -[ "$DO_IOS" = 1 ] && FILES+=(ios/OffgridMobile.xcodeproj/project.pbxproj) +FILES=(); [[ "$DO_ANDROID" = 1 ]] && FILES+=(android/app/build.gradle) +[[ "$DO_IOS" = 1 ]] && FILES+=(ios/OffgridMobile.xcodeproj/project.pbxproj) git add "${FILES[@]}" git commit -m "chore(beta): ${BETA_VERSION} (build ${BUILD_NUMBER}) [skip ci]" # Annotate the tag with the tested store build id (Android versionCode / iOS @@ -173,10 +173,10 @@ git push && git push origin "$TAG" GH_ARGS=() APK_DST="$ROOT_DIR/OffgridMobile-${BETA_VERSION}.apk" AAB_DST="$ROOT_DIR/OffgridMobile-${BETA_VERSION}.aab" -if [ "$DO_ANDROID" = 1 ]; then - [ -f android/app/build/outputs/apk/release/app-release.apk ] && \ +if [[ "$DO_ANDROID" = 1 ]]; then + [[ -f android/app/build/outputs/apk/release/app-release.apk ]] && \ { cp android/app/build/outputs/apk/release/app-release.apk "$APK_DST"; GH_ARGS+=("$APK_DST"); } - [ -f android/app/build/outputs/bundle/release/app-release.aab ] && \ + [[ -f android/app/build/outputs/bundle/release/app-release.aab ]] && \ { cp android/app/build/outputs/bundle/release/app-release.aab "$AAB_DST"; GH_ARGS+=("$AAB_DST"); } fi # Carry the tested build id into the prerelease body too (redundant with the annotated tag), @@ -195,7 +195,7 @@ SLACK_WEBHOOK_URL="${SLACK_WEBHOOK_URL:-$(sed -n 's/^SLACK_WEBHOOK_URL=//p' "$RO rm -f "$NOTES_FILE" "${ANDROID_CHANGELOG:-}" "$APK_DST" "$AAB_DST" echo "" info "${BOLD}Beta ${BETA_VERSION} shipped.${NC}" -[ "$DO_IOS" = 1 ] && info " iOS: TestFlight (App Store Connect → TestFlight)" -[ "$DO_ANDROID" = 1 ] && info " Android: Play Console → Internal testing" +[[ "$DO_IOS" = 1 ]] && info " iOS: TestFlight (App Store Connect → TestFlight)" +[[ "$DO_ANDROID" = 1 ]] && info " Android: Play Console → Internal testing" info " GitHub: $(gh release view "$TAG" --json url -q .url 2>/dev/null)" info "Approve the beta, then run scripts/release.sh to cut the real version to production." diff --git a/src/components/ModelCardContent.tsx b/src/components/ModelCardContent.tsx index a5c5c1862..4f9529f04 100644 --- a/src/components/ModelCardContent.tsx +++ b/src/components/ModelCardContent.tsx @@ -7,8 +7,15 @@ import type { ThemeColors } from '../theme'; import { createStyles } from './ModelCard.styles'; import { huggingFaceService } from '../services/huggingface'; import { ModelCredibility } from '../types'; +import { fitTierLabel, type FitTier } from '../services/memoryBudget'; +import type { ThemeColors as TC } from '../theme'; import { triggerHaptic } from '../utils/haptics'; +/** Chip accent per fit tier: emerald for a comfortable easy/fits, muted for a tight (snug, still + * loadable) fit. Browse never shows 'wontFit' (those models are filtered out), so it isn't styled. */ +const fitTierColor = (colors: TC, tier: FitTier): string => + tier === 'tight' ? colors.textMuted : colors.primary; + interface CredibilityInfo { color: string; label: string; @@ -49,6 +56,7 @@ interface CompactModelCardContentProps { modelType?: 'text' | 'vision' | 'code'; paramCount?: number; minRamGB?: number; + fitTier?: FitTier; }; credibility?: ModelCredibility; credibilityInfo: CredibilityInfo | null; @@ -90,6 +98,46 @@ function modelTypeTextStyle( return null; } +/** The compact card's badge row (fit chip + NPU/GPU + type + params + RAM). Extracted to module + * scope so CompactModelCardContent stays under the complexity gate; renders null when empty. */ +const InfoBadgesRow: React.FC<{ + model: { modelType?: 'text' | 'vision' | 'code'; paramCount?: number; minRamGB?: number; fitTier?: FitTier }; + supportsAcceleration?: boolean; + styles: ReturnType; + colors: TC; +}> = ({ model, supportsAcceleration, styles, colors }) => { + if (!model.modelType && !model.paramCount && !supportsAcceleration && !model.fitTier) return null; + return ( + + {/* Device-fit chip: how snugly the best quant fits THIS phone (Easy/Fits/Tight). Browse shows + loadable models with this instead of hiding over-budget ones. */} + {model.fitTier && ( + + {fitTierLabel(model.fitTier)} + + )} + {supportsAcceleration && ( + + NPU/GPU + + )} + {model.modelType && ( + + {modelTypeLabel(model.modelType)} + + )} + {/* `!!` coerces a falsy 0/undefined to false — `{0 && …}` would render a bare "0" text node + outside and crash RN (CodeRabbit). A 0-param / 0-RAM badge is meaningless anyway. */} + {!!model.paramCount && ( + {model.paramCount}B params + )} + {!!model.minRamGB && ( + {model.minRamGB}GB+ RAM + )} + + ); +}; + export const CompactModelCardContent: React.FC = ({ model, credibility, @@ -150,33 +198,8 @@ export const CompactModelCardContent: React.FC = ( ))} - ) : (model.modelType || model.paramCount || supportsAcceleration) && ( - - {/* Capability badge: this model can run on the GPU/NPU (a LiteRT model or a - Q4_0/Q8_0 GGUF). K-quants silently fall back to CPU, so they get no badge. */} - {supportsAcceleration && ( - - NPU/GPU - - )} - {model.modelType && ( - - - {modelTypeLabel(model.modelType)} - - - )} - {model.paramCount && ( - - {model.paramCount}B params - - )} - {model.minRamGB && ( - - {model.minRamGB}GB+ RAM - - )} - + ) : ( + )} ); diff --git a/src/screens/DownloadManagerScreen/useVoiceDownloadItems.ts b/src/screens/DownloadManagerScreen/useVoiceDownloadItems.ts index d157055dc..c2da0312c 100644 --- a/src/screens/DownloadManagerScreen/useVoiceDownloadItems.ts +++ b/src/screens/DownloadManagerScreen/useVoiceDownloadItems.ts @@ -85,8 +85,9 @@ async function deleteItem(item: DownloadItem): Promise { // Models screen keep showing the deleted model as present/active. await useWhisperStore.getState().deleteModelById(item.modelId); } else { - const pending = callHook>(HOOKS.downloadsDeleteVoiceModel, item.modelId); - if (pending) await pending.catch(() => {}); + // callHook returns undefined when the hook isn't registered (free build); await the promise + // only when it exists. `?.` keeps a Promise out of a boolean condition (SonarJS). + await callHook>(HOOKS.downloadsDeleteVoiceModel, item.modelId)?.catch(() => {}); } } diff --git a/src/screens/ModelDownloadScreen.tsx b/src/screens/ModelDownloadScreen.tsx index ac96fc008..183922385 100644 --- a/src/screens/ModelDownloadScreen.tsx +++ b/src/screens/ModelDownloadScreen.tsx @@ -170,17 +170,28 @@ export const ModelDownloadScreen: React.FC = ({ navigation }) => { const compat = recommendedModelsForDevice(ram); if (cancelled) return; setRecommendedModels(compat); - const files = await fetchModelFiles(compat); - if (!cancelled) setModelFiles(files); + // Device analysis is done here — show the screen NOW. The recommended cards + // (curated LiteRT + GGUF) render from local data; their download file lists come + // over the network and MUST NOT gate the screen. Awaiting fetchModelFiles here left + // fresh installs stuck on "Analyzing your device…" for ~75s (the OS fetch timeout) + // whenever HuggingFace was slow or unreachable. Load files in the background instead. + if (!cancelled) setIsLoading(false); + try { + const files = await fetchModelFiles(compat); + if (!cancelled) setModelFiles(files); + } catch (fileError) { + logger.error('Error fetching model files:', fileError); + } } catch (error) { logger.error('Error initializing:', error); - if (!cancelled) setAlertState(showAlert('Error', 'Failed to initialize. Please try again.')); - } finally { - if (!cancelled) setIsLoading(false); + if (!cancelled) { + setAlertState(showAlert('Error', 'Failed to initialize. Please try again.')); + setIsLoading(false); + } } })(); return () => { cancelled = true; }; - }, []); + }, [setDeviceInfo, setModelRecommendation]); // Health-check persisted servers — only show reachable ones. // Returns { ran, reachable }: `ran` is false when the in-flight guard short-circuited this call @@ -378,8 +389,12 @@ export const ModelDownloadScreen: React.FC = ({ navigation }) => { {deviceInfo?.deviceModel} - Available Memory - {hardwareService.formatBytes(deviceInfo?.availableMemory || 0)} + Memory + {/* Show TOTAL physical RAM — the device spec the user expects and the SAME number the + model recommendations are gated on (getTotalMemoryGB). deviceInfo.availableMemory is + the per-process allocatable ceiling (os_proc_available_memory), a budget input that + reads as a wrong "device memory" here — e.g. 4.57GB on the reported 11GB device. */} + {hardwareService.formatBytes(deviceInfo?.totalMemory || 0)} diff --git a/src/screens/ModelsScreen/TextModelsTab.tsx b/src/screens/ModelsScreen/TextModelsTab.tsx index af1c8526e..2ef38066d 100644 --- a/src/screens/ModelsScreen/TextModelsTab.tsx +++ b/src/screens/ModelsScreen/TextModelsTab.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import { View, Text, FlatList, TextInput, ActivityIndicator, RefreshControl, TouchableOpacity, InteractionManager, Platform } from 'react-native'; import DeviceInfo from 'react-native-device-info'; import Icon from 'react-native-vector-icons/Feather'; -import { fileExceedsBudget } from '../../services/memoryBudget'; +import { fileExceedsBudget, isLoadableOnDevice } from '../../services/memoryBudget'; import { AttachStep, useSpotlightTour } from 'react-native-spotlight-tour'; import { Card, ModelCard } from '../../components'; import { AnimatedEntry } from '../../components/AnimatedEntry'; @@ -45,7 +45,7 @@ type Props = Pick; type DetailProps = Pick & { selectedModel: ModelInfo; onBack: () => void; }; // Build the file card's onDownload handler. Whether to show the curated confirm-download @@ -94,10 +94,10 @@ function buildFileDownloadHandler({ s, fileName, sizeBytes, ramGB, proceedDownlo } const ModelDetailView: React.FC = ({ - selectedModel, modelFiles, isLoadingFiles, filterState, ramGB, + selectedModel, modelFiles, isLoadingFiles, filesLoadError, filterState, ramGB, alertState, setAlertState, onBack, getDownloadedModel, isModelDownloaded, isRepairingVisionModel, - handleDownload, handleRepairMmProj, handleCancelDownload, handleDeleteModel, + handleDownload, handleRepairMmProj, handleCancelDownload, handleDeleteModel, handleSelectModel, }) => { const { colors } = useTheme(); const styles = useThemedStyles(createStyles); @@ -124,7 +124,9 @@ const ModelDetailView: React.FC = ({ for (const f of modelFiles) { if (!f.mmProjFile) continue; const rec = getDownloadedModel(selectedModel.id, f.name); - if (rec?.engine === 'llama' && !rec.isVisionModel) void modelManager.markVisionModel(rec.id); + if (rec?.engine === 'llama' && !rec.isVisionModel) { + modelManager.markVisionModel(rec.id).catch(() => {}); + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedModel.id, modelFiles]); @@ -252,10 +254,22 @@ const ModelDetailView: React.FC = ({ )} {isLoadingFiles ? ( + ) : filesLoadError ? ( + + + Couldn't load files. Check your connection and try again. + handleSelectModel(selectedModel)} testID="model-files-retry"> + + Retry + + ) : ( f.size > 0 && !fileExceedsBudget(f.size, ramGB) && (filterState.quant === 'all' || f.name.includes(filterState.quant))) + // Show every loadable quant (easy/fits/tight — down to the aggressive ceiling), not just + // those under the balanced budget, so a 'tight' model opened from browse still lists a + // downloadable file. isCompatible (below) still flags the snug ones for a Load-Anyway warning. + .filter(f => f.size > 0 && isLoadableOnDevice(f.size, ramGB) && (filterState.quant === 'all' || f.name.includes(filterState.quant))) .sort((a, b) => { if (selectedModel.id === LITERT_PARENT_ID) return a.size - b.size; // curated: small-first // Tier: Q4_K_M (CPU default, lowest size) → GPU/NPU Q4_0/Q8_0 → rest (CPU @@ -330,7 +344,7 @@ const SortPanel: React.FC = ({ filterState, setSortOption, style export const TextModelsTab: React.FC = (props) => { const { searchQuery, setSearchQuery, isLoading, isRefreshing, hasSearched, - selectedModel, setSelectedModel, modelFiles, setModelFiles, isLoadingFiles, + selectedModel, setSelectedModel, modelFiles, setModelFiles, isLoadingFiles, filesLoadError, filterState, textFiltersVisible, setTextFiltersVisible, filteredResults, recommendedAsModelInfo, trendingAsModelInfo, ramGB, deviceRecommendation, hasActiveFilters, downloadedModels, @@ -362,6 +376,7 @@ export const TextModelsTab: React.FC = (props) => { selectedModel={selectedModel} modelFiles={modelFiles} isLoadingFiles={isLoadingFiles} + filesLoadError={filesLoadError} filterState={filterState} ramGB={ramGB} alertState={alertState} @@ -374,6 +389,7 @@ export const TextModelsTab: React.FC = (props) => { handleRepairMmProj={handleRepairMmProj} handleCancelDownload={handleCancelDownload} handleDeleteModel={handleDeleteModel} + handleSelectModel={handleSelectModel} /> ); } diff --git a/src/screens/ModelsScreen/index.tsx b/src/screens/ModelsScreen/index.tsx index 94e854210..fa2069852 100644 --- a/src/screens/ModelsScreen/index.tsx +++ b/src/screens/ModelsScreen/index.tsx @@ -183,6 +183,7 @@ export const ModelsScreen: React.FC = () => { modelFiles={vm.modelFiles} setModelFiles={vm.setModelFiles} isLoadingFiles={vm.isLoadingFiles} + filesLoadError={vm.filesLoadError} filterState={vm.filterState} textFiltersVisible={vm.textFiltersVisible} setTextFiltersVisible={vm.setTextFiltersVisible} diff --git a/src/screens/ModelsScreen/styles.ts b/src/screens/ModelsScreen/styles.ts index df48f1ab7..dd675f1be 100644 --- a/src/screens/ModelsScreen/styles.ts +++ b/src/screens/ModelsScreen/styles.ts @@ -125,8 +125,10 @@ const createBaseStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ }, deviceBannerText: { ...TYPOGRAPHY.meta, color: colors.trending, flex: 1 }, deviceBannerWarning: { color: colors.error, marginTop: 2 }, - emptyCard: { alignItems: 'center' as const, padding: 32 }, + emptyCard: { alignItems: 'center' as const, padding: 32, gap: 12 }, emptyText: { color: colors.textSecondary, textAlign: 'center' as const }, + retryButton: { flexDirection: 'row' as const, alignItems: 'center' as const, gap: 6, borderWidth: 1, borderColor: colors.primary, borderRadius: 8, paddingVertical: 8, paddingHorizontal: 16 }, + retryButtonText: { ...TYPOGRAPHY.bodySmall, color: colors.primary }, }); const createFilterStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ diff --git a/src/screens/ModelsScreen/useModelsScreen.ts b/src/screens/ModelsScreen/useModelsScreen.ts index 0dbe79ce5..494d0dcb5 100644 --- a/src/screens/ModelsScreen/useModelsScreen.ts +++ b/src/screens/ModelsScreen/useModelsScreen.ts @@ -248,6 +248,7 @@ export function useModelsScreen() { modelFiles: text.modelFiles, setModelFiles: text.setModelFiles, isLoadingFiles: text.isLoadingFiles, + filesLoadError: text.filesLoadError, filterState: text.filterState, setFilterState: text.setFilterState, textFiltersVisible: text.textFiltersVisible, diff --git a/src/screens/ModelsScreen/useTextModels.ts b/src/screens/ModelsScreen/useTextModels.ts index a2c931079..9dd354d71 100644 --- a/src/screens/ModelsScreen/useTextModels.ts +++ b/src/screens/ModelsScreen/useTextModels.ts @@ -4,7 +4,7 @@ import { useFocusEffect } from '@react-navigation/native'; import { showAlert, AlertState } from '../../components/CustomAlert'; import { RECOMMENDED_MODELS, TRENDING_FAMILIES, MODEL_ORGS } from '../../constants'; import { useAppStore } from '../../stores'; -import { fileExceedsBudget } from '../../services/memoryBudget'; +import { fitTier, isLoadableOnDevice, type FitTier } from '../../services/memoryBudget'; import { useDownloadStore } from '../../stores/downloadStore'; import { huggingFaceService, modelManager, hardwareService, activeModelService } from '../../services'; import { startModelDownload } from '../../services/startModelDownload'; @@ -69,6 +69,26 @@ async function fetchRecommendedModelDetails(): Promise return details; } +const TIER_RANK: Record = { easy: 0, fits: 1, tight: 2, wontFit: 3 }; + +/** True when EVERY sized quant is past the aggressive ceiling ('wontFit') — the only case browse still + * hides. A model with any easy/fits/tight quant stays visible (shown with a fit chip). */ +function noLoadableQuant(model: ModelInfo, ramGB: number): boolean { + const sized = (model.files || []).filter(f => f.size > 0); + return sized.length > 0 && !sized.some(f => isLoadableOnDevice(f.size, ramGB)); +} + +/** The fit tier of a model's BEST (most-fitting) quant — what the browse chip shows. undefined when + * no file sizes are known yet (chip hidden until they load). */ +function bestFitTier(model: ModelInfo, ramGB: number): FitTier | undefined { + const sized = (model.files || []).filter(f => f.size > 0); + if (sized.length === 0) return undefined; + return sized.reduce((best, f) => { + const t = fitTier(f.size, ramGB); + return TIER_RANK[t] < TIER_RANK[best] ? t : best; + }, 'wontFit'); +} + function computeFilteredResults( searchResults: ModelInfo[], filterState: FilterState, @@ -85,14 +105,14 @@ function computeFilteredResults( if (sizeOpt && (params < sizeOpt.min || params >= sizeOpt.max)) return false; } } - const filesWithSize = (model.files || []).filter(f => f.size > 0); - if (filesWithSize.length > 0 && !filesWithSize.some(f => !fileExceedsBudget(f.size, ramGB))) return false; - return true; + // Browse no longer hides loadable models behind the balanced budget; keep a model unless EVERY + // quant is past the aggressive ceiling ('wontFit') — not loadable even with reclaim + Load Anyway. + return !noLoadableQuant(model, ramGB); }); return filtered.map(model => { const type = getModelType(model); const params = parseParamCount(model); - return { ...model, modelType: type === 'image-gen' ? undefined : type as 'text' | 'vision' | 'code', paramCount: params ?? undefined }; + return { ...model, modelType: type === 'image-gen' ? undefined : type as 'text' | 'vision' | 'code', paramCount: params ?? undefined, fitTier: bestFitTier(model, ramGB) }; }); } @@ -105,6 +125,10 @@ export function useTextModels(setAlertState: (s: AlertState) => void) { const [selectedModel, setSelectedModel] = useState(null); const [modelFiles, setModelFiles] = useState([]); const [isLoadingFiles, setIsLoadingFiles] = useState(false); + // True when the last file-list fetch FAILED (network/timeout) — distinct from "loaded, but no + // compatible files". Lets the detail view show a retry state instead of the misleading + // "No compatible files found" when the fetch simply couldn't reach HuggingFace. + const [filesLoadError, setFilesLoadError] = useState(false); const [filterState, setFilterState] = useState(initialFilterState); const [textFiltersVisible, setTextFiltersVisible] = useState(false); const [recommendedModelDetails, setRecommendedModelDetails] = useState>({}); @@ -183,7 +207,7 @@ export function useTextModels(setAlertState: (s: AlertState) => void) { }, [filterState.type, filterState.size, filterState.orgs.length]); const handleSelectModel = async (model: ModelInfo) => { - setSelectedModel(model); setIsLoadingFiles(true); + setSelectedModel(model); setIsLoadingFiles(true); setFilesLoadError(false); // Curated entries under the offgrid/ namespace (e.g. the synthetic LiteRT // parent) ship with their files baked into the ModelInfo — skip the // HuggingFace fetch and use them as-is. Real HF models always go through @@ -197,8 +221,10 @@ export function useTextModels(setAlertState: (s: AlertState) => void) { const files = await huggingFaceService.getModelFiles(model.id); setModelFiles(files); } catch { - setAlertState(showAlert('Error', 'Failed to load model files.')); + // Fetch failed (offline / HF unreachable / timeout). Surface an inline retry state rather + // than a transient modal — the detail view reads filesLoadError and offers Retry. setModelFiles([]); + setFilesLoadError(true); } finally { setIsLoadingFiles(false); } @@ -347,6 +373,7 @@ export function useTextModels(setAlertState: (s: AlertState) => void) { selectedModel, setSelectedModel, modelFiles, setModelFiles, isLoadingFiles, + filesLoadError, filterState, setFilterState, textFiltersVisible, setTextFiltersVisible, downloadedModels, diff --git a/src/services/activeDownloadPersistence.ts b/src/services/activeDownloadPersistence.ts index d57810126..766427da2 100644 --- a/src/services/activeDownloadPersistence.ts +++ b/src/services/activeDownloadPersistence.ts @@ -64,7 +64,7 @@ export function initActiveDownloadPersistence(): void { subscribed = true; useDownloadStore.subscribe((state) => { const active = serializeActiveDownloads(state.downloads); - const signature = active.map((e) => `${e.modelKey}:${e.status}`).sort().join('|'); + const signature = active.map((e) => `${e.modelKey}:${e.status}`).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)).join('|'); if (signature === lastSignature) return; lastSignature = signature; saveActiveDownloads(active).catch(() => { /* saveActiveDownloads already logs; never throws */ }); diff --git a/src/services/huggingface.ts b/src/services/huggingface.ts index 62d75f943..8f968c5fc 100644 --- a/src/services/huggingface.ts +++ b/src/services/huggingface.ts @@ -3,12 +3,42 @@ import { HF_API, QUANTIZATION_INFO, LMSTUDIO_AUTHORS, OFFICIAL_MODEL_AUTHORS, VE import { looksLikeVisionModel } from '../utils/visionModel'; import { isMMProjFile, pickMmProjForDownload } from './mmproj'; +// These are small metadata calls (model search, file listing) — NOT the multi-GB model +// downloads, which run on a separate native path with their own retry. A simple JSON API +// that hasn't answered in 5s is not slow, it's broken (or the network is): bound it here so +// the UI falls to a retry state fast instead of staring at a spinner. +const HF_REQUEST_TIMEOUT_MS = 5000; + +function isAbortError(error: unknown): boolean { + return typeof error === 'object' + && error !== null + && 'name' in error + && error.name === 'AbortError'; +} + class HuggingFaceService { private baseUrl = HF_API.baseUrl; private apiUrl = HF_API.apiUrl; + /** + * Every HuggingFace request goes through here so a slow or unreachable host can NEVER hang + * indefinitely. A bare fetch() has no timeout: on a fresh install with poor connectivity this + * left the onboarding screen stuck on "Analyzing your device…" until the OS-level socket timeout + * (~75s). AbortController bounds every request; on timeout the fetch rejects (AbortError) and the + * caller's existing catch handles it (falls back or returns empty). + */ + private async fetchWithTimeout(url: string, timeoutMs = HF_REQUEST_TIMEOUT_MS): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { headers: { Accept: 'application/json' }, signal: controller.signal }); + } finally { + clearTimeout(timer); + } + } + private async fetchJson(url: string): Promise { - const response = await fetch(url, { headers: { Accept: 'application/json' } }); + const response = await this.fetchWithTimeout(url); if (!response.ok) throw new Error(`API error: ${response.status}`); return response.json() as Promise; } @@ -32,7 +62,7 @@ class HuggingFaceService { async getModelFiles(modelId: string): Promise { try { - const response = await fetch(`${this.apiUrl}/models/${modelId}/tree/main`, { headers: { Accept: 'application/json' } }); + const response = await this.fetchWithTimeout(`${this.apiUrl}/models/${modelId}/tree/main`); if (!response.ok) return this.getModelFilesFromSiblings(modelId); const files: Array<{ type: string; path: string; size?: number; lfs?: { size: number } }> = await response.json(); const allGguf = files.filter(f => f.type === 'file' && f.path.endsWith('.gguf')); @@ -47,7 +77,12 @@ class HuggingFaceService { mmProjFile: this.findMatchingMMProj(file.path, mmProjFiles, modelId), })) .sort((a, b) => a.size - b.size); - } catch { + } catch (e) { + // A timed-out/aborted request means the network is down — don't pay a second 5s on the + // siblings fallback; fail fast so the UI can show retry. The fallback stays for its intended + // case: the tree endpoint returned a non-ok status (handled above) on a repo whose file list + // the model page can still provide. + if (isAbortError(e)) throw e; return this.getModelFilesFromSiblings(modelId); } } diff --git a/src/services/memoryBudget.ts b/src/services/memoryBudget.ts index 329e40b02..a56336995 100644 --- a/src/services/memoryBudget.ts +++ b/src/services/memoryBudget.ts @@ -158,6 +158,46 @@ export function fileExceedsBudget(sizeBytes: number, ramGB: number): boolean { return sizeBytes / BYTES_PER_GB >= ramGB * modelBudgetFraction(ramGB); } +export type FitTier = 'easy' | 'fits' | 'tight' | 'wontFit'; + +/** + * A device-fit TIER for a model file — a short chip label, NOT the load gate. The load path can + * attempt anything up to the aggressive ceiling (Android reclaim credit + Load Anyway), so browse + * no longer HIDES loadable models or says "Too large"; it shows how snug the fit is, and reserves an + * honest "Won't fit" only for models past even the aggressive ceiling (e.g. a 30B on a phone). + * Two thresholds, both from the SAME owned budgets — no new magic numbers: + * soft = ramGB * modelBudgetFraction(balanced) (recommended stays within this — see fileExceedsBudget) + * ceil = ramGB * modelBudgetFraction(aggressive) (the most Load-Anyway/aggressive can hold) + * size < 0.6*soft → 'easy' · < soft → 'fits' · < ceil → 'tight' · else → 'wontFit' + * Zero-IO; callers pass ramGB + platform from the device boundary (hardwareService). + */ +export function fitTier(sizeBytes: number, ramGB: number, platform: Plat = Platform.OS): FitTier { + const sizeGB = sizeBytes / BYTES_PER_GB; + const soft = ramGB * modelBudgetFraction(ramGB, platform, 'balanced'); + const ceil = ramGB * modelBudgetFraction(ramGB, platform, 'aggressive'); + if (sizeGB < 0.6 * soft) return 'easy'; + if (sizeGB < soft) return 'fits'; + if (sizeGB < ceil) return 'tight'; + return 'wontFit'; +} + +/** Loadable on THIS device: the tier is not 'wontFit' — i.e. within the aggressive ceiling, reachable + * via reclaim credit + Load Anyway. The ONE predicate the browse filter and the detail file list share, + * so "what counts as loadable" is defined once here, not copied inline per caller. */ +export function isLoadableOnDevice(sizeBytes: number, ramGB: number, platform: Plat = Platform.OS): boolean { + return fitTier(sizeBytes, ramGB, platform) !== 'wontFit'; +} + +/** The chip label for a fit tier. One source for the copy so every surface reads the same words. */ +export function fitTierLabel(tier: FitTier): string { + switch (tier) { + case 'easy': return 'Easy'; + case 'fits': return 'Fits'; + case 'tight': return 'Tight'; + case 'wontFit': return "Won't fit"; + } +} + /** * Block until a just-released native model's memory is reclaimed (process footprint drops by * ~minDropMB) or a bounded timeout — so the NEXT model load never allocates on top of the outgoing diff --git a/src/services/modelPreloader.ts b/src/services/modelPreloader.ts index aa03d844a..baf21c561 100644 --- a/src/services/modelPreloader.ts +++ b/src/services/modelPreloader.ts @@ -56,8 +56,9 @@ async function preloadText(): Promise { async function preloadTts(): Promise { // Pro implements the audio.preload hook (fits-gated + registers the engine); // no-op in free builds. - const pending = callHook>(HOOKS.audioPreload); - if (pending) await pending; + // callHook returns undefined in free builds (no audio.preload hook); awaiting undefined is a + // no-op, so this needs no Promise-in-condition check (SonarJS). + await callHook>(HOOKS.audioPreload); } async function preloadStt(): Promise { diff --git a/src/types/index.ts b/src/types/index.ts index e5d1d8249..bb62e0ef8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -22,6 +22,9 @@ export interface ModelInfo { modelType?: 'text' | 'vision' | 'code'; paramCount?: number; minRamGB?: number; + /** Device-fit tier of this model's BEST (most-fitting) quant, for the browse fit chip. Set by the + * models VM from memoryBudget.fitTier; undefined until the file sizes are known. */ + fitTier?: import('../services/memoryBudget').FitTier; } export interface ModelFile {