submit_review reached the deliver step but failed with "could not find
an editable version for 'IOS'". With skip_metadata + skip_screenshots,
deliver does not auto-create the "Prepare for Submission" entry on App
Store Connect, so submitting fails when the marketing version has not
been created yet.
Use Spaceship's ensure_version! to create or reuse the editable App
Store version for the build's marketing version, and pass app_version
to deliver so it targets the right entry.
latest_testflight_build_number returns the build number (e.g. "2"), not
the marketing version. Previous fix used the version: kwarg which maps
to filter[preReleaseVersion.version] — that's the marketing version
("0.1.70") and never matched the integer build number, so the query
returned no builds and the lane spun on "processing state: unknown"
until timeout.
Apple's API names are confusing: filter[version] is the build number,
filter[preReleaseVersion.version] is the marketing version. The
build_number: kwarg in spaceship maps to filter[version], which is
what we want.
The submit_review lane was crashing with "unknown keyword: :filter"
because Spaceship::ConnectAPI::Build.all doesn't accept a filter hash;
it takes specific kwargs (app_id, version, build_number, etc.) and
constructs the JSON:API filter internally.
Also drop includes: "preReleaseVersion" — it's part of ESSENTIAL_INCLUDES
by default.
Add resubmit-ios-review.yml workflow so we can re-run only the
fastlane submit_review lane (no rebuild) against an existing
TestFlight build via workflow_dispatch.
The "shows only the changelog action once the update is installed"
case in update-callout-source.test.tsx pinned the rendering path that
2d9c7747 deleted. The callout now early-returns for any status other
than available/installing/error, so the assertion that the container
contains "Update installed" can never be satisfied.
The install flow briefly flashed an "Update installed / Restart to use
the new version" callout between clicking Install & restart and the
actual app quit. The button implies action; the restart is already in
flight.
Run quitAndInstall inline (no 1.5s setTimeout) so the IPC sequences
download → install → daemon stop → app quit before returning. Drop the
"installed" rendering branch from the callout so the success path goes
straight from "Installing..." to the window unmounting.
* Replace skills auto-sync with explicit install/update/uninstall
Skills no longer sync silently on every desktop launch. Settings now
shows Install (fresh), Update (with a confirm dialog listing per-skill
add/update/delete ops), or Uninstall depending on what's on disk.
A manifest at ~/.agents/skills/.paseo-manifest.json tracks Paseo-owned
skills so uninstall removes only what we wrote, never user content.
Existing users migrate transparently — first read synthesises a
manifest from on-disk hashes, so an unchanged install flips silently
to up-to-date.
Also restructures the integrations layer: skills/ and cli-install/
each own their files behind a designed index.ts. The shared
integrations-manager.ts is gone.
* Rewrite skills install flow with disk-truth model
Drops the manifest sidecar, migration logic, and the
agentsDirHasSkillContent heuristic that misread user-authored skills
as Paseo content. Replaces them with a hardcoded PASEO_SKILL_NAMES
list (current bundle + paseo-chat for retired-skill cleanup); the
disk is the source of truth for what's installed.
Renames the SkillsState "fresh" → "not-installed" everywhere
(server, IPC parser, hook test mocks). Install and update share an
applySkills body. Errors propagate instead of being swallowed.
Fixes the case where Uninstall left state at "drift" because the
user had personal skills under ~/.agents/skills/.
* Stop symlinking claude skills, write real files
If the user has ~/.claude/skills symlinked to ~/.agents/skills, the
per-skill symlink path resolved through the parent symlink and fs.rm'd
the agents-side files we'd just written. Mirror real files to claude
the same way as codex; agents stays the canonical source-of-truth dir.
* Make Uninstall button visible (outline, not ghost)
The cold workspace URL navigation fix changed the / startup path for a matching persisted workspace. Index no longer renders a workspace Redirect there; it calls navigateToWorkspace so the centralized workspace navigation path owns the transition, then keeps the startup splash visible while that imperative navigation runs.
Update the test mock to expose navigateToWorkspace and assert that behavior directly. This keeps the test aligned with the route-shape fix and prevents CI failures on branches rebased over main.
Cmd+R was scoped through a stale Electron-side active browser value. Opening or focusing a browser pane set activeBrowserPaneId, but switching to an agent tab only updated workspace layout state. Electron still remembered the old browser and the app menu reloaded that hidden/previous browser instead of the app.
Make workspace layout the authority. Derive the active browser from focusedPaneId plus the focused pane's focusedTabId, and sync browserId|null to Electron from one WorkspaceScreen effect. BrowserPane no longer marks itself active from URL-bar or webview focus handlers, so call sites do not have to remember to clear Electron state when focus moves to an agent, terminal, or another pane.
Electron now treats the synced value as the workspace-active browser, not an independently observed focus source. Menu reload uses that workspace-derived browser when present; otherwise it reloads the app window. Direct Cmd+R events that originate inside a browser guest still reload that guest through the guest before-input-event path.
Browser webviews now own their guest context menu in the main process. Add copy/paste/select-all and a development Inspect Element action for attached browser guest WebContents.
DevTools opening is routed through the main process by browserId. Docked-right DevTools did not become visible for browser guest contents in practice, even though inspect mode activated, so the final behavior opens detached DevTools for both the toolbar button and Inspect Element. The renderer logs bridge/result failures, and main logs registration/open/inspect outcomes for future debugging.
Regression coverage is at the workspace layout derivation layer: focused browser id follows the focused pane's active browser tab and becomes null when the active tab is not a browser. Verified with targeted layout-store tests, navigation E2E, settings E2E, npm run typecheck, and npm run lint.
Directly opening a workspace URL created a different React Navigation state shape than entering from /. Expo Router parsed the URL as a workspace layout route with a nested index child whose route.path and params pointed at the original workspace. Later sidebar navigation used router.dismissTo for another workspace. React Navigation's POP_TO reused the existing workspace route by name and updated the parent params, but preserved the nested child state. The result was a hybrid state: parent workspace params for B, child index path for A. Expo Router serialized the focused child path, so the address bar stayed on A and sidebar clicks looked like no-ops.
The fix is to stop using workspace/[workspaceId]/_layout.tsx as the actual screen. That layout did not render a Slot, so it only added an extra navigation level for Expo Router to preserve. Move the workspace UI, bootstrap boundary, open-intent consumption, and retained WorkspaceDeck into the leaf index route, making /h/:serverId/workspace/:workspaceId one real rendered route.
Keep workspace switches on navigateToWorkspace/router.dismissTo. This is not another replace workaround: replace previously created duplicate mounted workspace shells. The route-shape fix removes the stale nested child state that made dismissTo fail after cold deep links.
Also route archive/new-agent and notification flows through the centralized workspace navigation path so they do not bypass the same route contract.
Regression coverage: cold-load workspace A, click workspace B in the sidebar, and assert the URL changes to B. Verified with workspace-navigation-regression.spec.ts, settings-navigation.spec.ts, npm run typecheck, and npm run lint.
Centralize workspace navigation behind a small route-derived API so the workspace shell reads active selection from the current Expo Router route instead of a separately synchronized store.
Remove the stale layout-driven active-workspace write that regressed web after 02e5b564.
* refactor(server,app): lift return types and parse at boundaries (T2 typeaware production sweep)
* fix(server): align acp-agent test assertions with pino call shape
* test(app/e2e): cover sessions-screen empty state (Cluster G7)
Adds one E2E test — opens Sessions on a fresh workspace with no agents
and asserts the "No sessions yet" placeholder renders. Uses `withWorkspace`
fixture (no agent seeding) so the empty branch runs for the first time.
Also exports `expectSessionsEmptyState` helper from archive-tab helpers
for reuse in future session-related specs.
* fix(app/e2e): run sessions-empty test before archive-tab agents are created
The sessions screen shows global agent history for the daemon. Running
the empty-state test last meant the reconciliation tests had already
created 6 agents, so "No sessions yet" never rendered.
Moving the describe block first ensures it runs on a clean daemon
(workers:1, fullyParallel:false, archive-tab.spec.ts is first alphabetically).
* refactor(app/e2e): guard sessions-empty ordering + dedup selector constant
Addresses reviewer feedback on the ordering fragility:
- Add NOTE comment above Sessions screen empty state describe explaining
why it must remain first in the file (daemon history is global; the
reconciliation tests below call createIdleAgent which would pollute it).
- Add a fast-fail guard in expectSessionsEmptyState: asserts 0 agent rows
with a 5s timeout so a future maintainer sees an immediately actionable
failure message rather than a mysterious "No sessions yet" timeout.
- Extract AGENT_ROW_SELECTOR constant to eliminate the duplicated
[data-testid^="agent-row-"] string shared by getSessionRowByTitle and
the new guard.
* fix(app/e2e): move sessions-empty to 00-prefixed file to survive new specs
agent-stream-ui.spec.ts (merged in #743) sorts before archive-tab.spec.ts
and creates agents, breaking the empty-state test. Any future a*-*.spec.ts
has the same risk.
Fix: move the test to 00-sessions-empty.spec.ts — digit prefix sorts
before all alpha-named specs, making the ordering constraint explicit at
the filesystem level.
Also adds a beforeAll daemon probe that fails fast with a clear message
if any pre-existing agents are found, covering both ordering violations
and stale daemon state from a previous run.
Supporting changes:
- Add fetchAgentHistory to ArchiveTabDaemonClient interface (typed as
Array<{ id: string }> — enough for the count check)
- Remove the test and NOTE comment from archive-tab.spec.ts
- Update expectSessionsEmptyState guard comment to reference the new file
* test(app/e2e): add picker keyboard-interaction tests (Cluster G8)
Cover branch-picker keyboard contract: open via Space, navigate with
ArrowDown/ArrowUp, select with Enter, close with Escape.
Adds six helpers to helpers/new-workspace.ts:
openBranchPicker, selectPickerOptionByKeyboard, closeBranchPicker,
expectPickerOpen, expectPickerClosed, expectPickerSelected.
Also moves delayBrowserAgentCreatedStatus and its private helpers out of
new-workspace.spec.ts into the helpers module where they belong.
* fix(app/e2e): address picker keyboard test review feedback
- Add { timeout: 30_000 } to expectPickerClosed (FadeOut animation safety)
- Simplify selectPickerOptionByKeyboard: ArrowDown → Enter (remove redundant ArrowUp)
- Migrate expectStartingRefPickerTriggerPr trigger selector from testID to ARIA role
* fix(app/e2e): fix picker keyboard test — open via click not Space
RN Web Pressable renders as <div role="button"> which does not fire
onPress from a programmatic Space key event. Switch openBranchPicker
to trigger.click() so the picker reliably opens in CI headless Chrome.
Keyboard behaviour (ArrowDown + Enter, Escape) is still exercised by
selectPickerOptionByKeyboard and closeBranchPicker respectively.
* test(app/e2e): sidebar query pause + mobile open/close transition (Cluster G6)
G6.1: assert fetch_workspaces_request stops being sent when desktop sidebar
is closed (CDP WebSocket frame counting, no store injection).
G6.2: assert mobile sidebar panel animates in/out at 390×844 viewport via
toBeInViewport on translateX-animated element.
Adds installWorkspaceFetchMonitor, expectWorkspaceListSubscribed,
closeSidebar, openMobileAgentSidebar, closeMobileAgentSidebar,
expectMobileAgentSidebarVisible, and expectMobileAgentSidebarHidden helpers.
* test(app/e2e): mobile sidebar open/close transition (Cluster G6)
G6.1 (query-pause perf invariant) reclassified as a unit-test follow-up
— counting internal RPC frames in E2E is banned per updated roadmap.
G6.2: asserts mobile sidebar panel animates in/out at 390×844 viewport
via toBeInViewport on the translateX-animated sidebar-sessions element.
Adds openMobileAgentSidebar, closeMobileAgentSidebar,
expectMobileAgentSidebarVisible, and expectMobileAgentSidebarHidden helpers.
* test(app/e2e): stream auto-scroll and working-indicator→copy-button (Cluster G5)
Wire in the unused agent-bottom-anchor helpers and add two new agent stream
UI specs: auto-scroll stays pinned to the bottom across token bursts, and
the inline working-indicator transitions to a copy-button when the stream
ends. Both tests use the mock provider so there is no real LLM dependency
in CI.
Adds three helpers to helpers/agent-stream.ts:
- expectInlineWorkingIndicator
- expectTurnCopyButton
- expectScrolledToBottom (wraps agent-bottom-anchor)
* fixup: address review blockers and nits
- Replace expectScrolledToBottom passthrough with expectScrollFollowsNewContent
that encapsulates readScrollMetrics → waitForContentGrowth → expectNearBottom
- Remove direct agent-bottom-anchor imports from spec body (DSL leak)
- Add awaitAssistantMessage before expectInlineWorkingIndicator to anchor on
real content before asserting the working indicator
- Add comment to expectInlineWorkingIndicator explaining why testId is used
(animated spinner View has no ARIA role)
* fix(app/e2e): fix composer-lock test — use mock provider + prompt so lock releases
Two bugs in the "composer is locked while new workspace agent is being
created" test:
1. expectComposerDisabled used toBeDisabled() but React Native TextInput
with editable={false} renders as <textarea readonly> on web, not
<textarea disabled>. Fixed to not.toBeEditable().
2. clickNewWorkspaceButton created an agent with no prompt, leaving the
agent permanently "idle". showPendingCreateSubmitLoading only clears
when authoritativeStatus is non-bootstrapping, but "idle" counts as
bootstrapping — so the composer lock never released. Fixed by opening
the new-workspace composer, filling a prompt, then clicking Create so
the mock agent transitions to "running" after agent_created is released.
Also switches buildCreateAgentPreferences to provider "mock" with the
ten-second-stream model so E2E tests exercise app behavior without
depending on real provider availability.
* fix(app): add aria-checked to Switch for E2E toBeChecked() assertions
React Native Web does not map accessibilityState.checked to aria-checked
for role="switch", so Playwright's toBeChecked() always finds the element
unchecked. Adding aria-checked={value} directly to the Pressable sets the
attribute explicitly.
Update the switch.test.tsx mock to accept and pass through the explicit
aria-checked prop, keeping the mock faithful to the fixed component.
* test(app/e2e): cover 5 error-UX paths + host indicator + script removal for project settings
Add helpers/project-settings.ts DSL helpers and extend projects-settings.spec.ts
with 5 new tests covering the error paths dropped by PR #725:
- stale_project_config callout + disabled save + reload recovery
- invalid_project_config read callout + reload after fix
- write_failed callout + retry + reload recovery
- single-host static indicator vs picker chip
- script removal via kebab menu + confirm dialog
* refactor(app/e2e): unslop project-settings helpers and spec
- Fix removeProjectScript: derive trigger testID from row testID instead
of using scoped locator (which was timing out)
- Extract inline writeFile call in invalid-config test to restorePaseoConfig helper
- Replace raw testID click in write_failed test with clickReloadProjectSettings
- Remove writeFile from spec imports; drop defensive ?? "" fallback
* test(app/e2e): add read-transport and offline no-target tests for project settings
- Add read-transport failure test: WS-level drop during readProjectConfig triggers
read-transport-callout; Reload retries until WS reconnects and refetch succeeds.
- Add no-target test: WS drop after form load triggers NoEditableTarget via live
connectionStatus check (useHostRuntimeSnapshot) on the selected host.
- Hoist openProjects/editWorktreeSetup from spec body into project-settings helper.
- Fix expectNoProjectSettingsError to accept optional timeout (needed for toPass loop).
- Add isHostGone to renderContent: after readQuery errors are checked, offline/error
connectionStatus renders NoEditableTarget without unmounting ProjectSettingsBody.
* fixup(app/e2e): correct misleading comments on WS close → error-state mapping
* test(app/e2e): add composer-attachments spec covering 8 attachment behaviors
Restores E2E coverage for composer attachment behaviors dropped in PR #720:
plus-menu visibility, GitHub combobox lazy search, image lightbox, pill render,
pill removal (with hover-reveal), queue-on-running-agent, review-pill suppression
(test.fixme pending store seeding bridge), and Escape interrupt draft preservation.
Includes accessibilityRole="button" on QueuedMessageRow Pressables (was rendering
as generic, breaking role-based selectors) and an opt-in E2E debug surface on
useWorkspaceAttachmentsStore (localStorage gated, needed for the fixme test).
* refactor(app/e2e): unslop composer-attachments spec and helpers
Remove dead `expectComposerLocked` export (never imported), trim verbose
JSDoc on `pressInterruptShortcut`, and correct the test.fixme comment
which said the store wasn't window-exposed (it is, as of the parent commit).
* fix(app/e2e): address PR #734 review blockers for composer-attachments spec
- Remove window.__paseoWorkspaceAttachmentsStore exposure (hard ban on internal
state injection)
- Merge helpers/composer-attachments.ts into helpers/composer.ts; delete old file
- Add openGithubWorkspace, selectGithubOption, expectGithubAttachmentPill,
expectComposerDisabled, expectAttachButtonDisabled helpers to composer.ts
- Extract delayBrowserAgentCreatedStatus from new-workspace.spec.ts into
helpers/new-workspace.ts so it can be shared
- Add real GH issue/PR pill tests using createTempGithubRepo fixtures
- Rewrite lock-state test to assert textarea disabled + attach button disabled
during in-flight workspace creation (submitBehavior=preserve-and-lock)
- Add test.fixme with detailed explanation for workspace-review pill (requires
diff pane automation not yet in E2E harness)
- Add test.fixme for browser-element pill (Electron-only, not testable in
headless Chromium)
* refactor(app/e2e): unslop composer helpers and spec after blockers fix
- Remove AI section headers from composer.ts (not in project convention)
- Fix fillComposerDraft: drop redundant click() before fill()
- Fix selectGithubOption: extract locator to local var instead of double getByTestId()
- Use clickNewWorkspaceButton in lock-state test instead of raw Create button locator
- Drop obvious PNG constant comment
* feat(app/e2e): add PR pane E2E spec with fixture-based seeding
Adds 7 tests covering open/merged/closed/draft states, check pill
counts, activity row count, and the empty-checks graceful render.
Fake gh CLI now reads .paseo-e2e-pr.json and .paseo-e2e-timeline.json
from the workspace cwd so each test gets isolated fixture data.
* refactor(app/e2e): switch PR pane spec to real GitHub fixtures
Replace the fixture-file seeding approach with ephemeral real GitHub
repos created via the gh CLI. `helpers/github-fixtures.ts` creates a
single private repo per test run, pushes one branch per PR scenario,
and seeds commit statuses and comments as needed. The fake gh binary
now forwards unhandled calls (no fixture file present) to the real gh
so the daemon can query live GitHub data.
All 7 tests skip gracefully when gh auth is unavailable.
* refactor(app/e2e): address PR review feedback on pr-pane spec
- helpers/pr-pane: extract assertCheckPill helper so expectPrPaneCheckSummary
is a flat 3-call sequence; remove branching on rendering shape
- helpers/pr-pane: drop .first() on explorer button and redundant toBeVisible
before click; import getStateLabel from @/utils/pr-pane-data instead of
duplicating the map
- pr-pane.spec.ts: move test.skip and test.setTimeout into beforeEach; replace
positional IDX_* constants with workspaceByTitle Map keyed by PR title
- helpers/github-fixtures: add IssueSpec/GhIssueFixture and issues[] option;
extract seedPr/seedIssue to satisfy complexity limit; make prs/issues optional
* test(app/e2e): add desktop-updates spec covering update banner and daemon lifecycle (cluster G4)
Adds a new Electron-only E2E spec and companion helper module covering:
- Update callout renders with correct version and shows Installing… on click
- Daemon management toggle confirm dialog copy, cancel, and confirm flows
- Daemon status panel seeded from the real running E2E daemon (version, PID, log path)
- Stopping then re-enabling management observes a fresh PID from the stateful IPC mock
Exports E2E_PASEO_HOME from globalSetup so tests can read the paseo.pid lock file
and derive the daemon log path without hardcoding paths.
* fix(app/e2e): address PR review blockers on desktop-updates spec
Blocker 1 — ARIA for install button: replace index-based testId locators
in clickInstallUpdate and expectInstallInProgress with getByRole("button")
using the accessible name ("Install & restart" / "Installing...").
Blocker 2 — Electron dialog path: add dialog.ask to the mock bridge so
confirmDialog() hits the Electron code path instead of falling back to
window.confirm. The mock stores captured args on window.__capturedDialogCall;
interceptDaemonManagementConfirmDialog reads them via waitForFunction+evaluate.
Add confirmShouldAccept config flag so tests control accept/dismiss without
a Playwright dialog event. Update all daemon management tests to set the flag.
Also: console.warn on PID file read failure, comment explaining the no-Electron-
runner approach, rename dialog → dialogArgs at call sites.
Rewrites 7 spec files to use DSL helpers throughout — zero raw
page.locator/getByText/getByTestId in test() bodies. Adds 30+ new
helper primitives across 7 existing helper modules.
Replace 21 raw page.locator/getByTestId/getByText calls in the spec body
with named DSL operations in helpers/settings.ts. Each test body now reads
as prose: open settings, navigate to section, expect content.
New helpers: openCompactSettings, expectCompactSettingsList,
expectSettingsSidebarVisible/Hidden/Sections, goBackInSettings,
expectSettingsBackButton, clickSettingsBackToWorkspace,
verifyLegacyHostSettingsRedirect, openCompactSettingsHost,
expectHostSettingsUrl, expectAddHostMethodOptions, fillDirectHostUri,
expectDirectHostFormValues, expectDirectHostSslEnabled,
expectDirectHostUriValue/Hidden, expectDiagnosticsContent,
expectAboutContent, expectGeneralContent.
Export requireServerId from sidebar.ts so helpers encapsulate serverId
logic — spec has no direct env reads.
Also fix pre-existing typecheck error in use-keyboard-shortcuts.ts:
cast action.route to the expo-router Href type at call sites.
Deletes four screen-level .test.tsx files (2600+ lines of vi.mock + JSDOM
slop) and replaces each with verified coverage:
- sessions-screen.test.tsx: covered by archive-tab.spec.ts which exercises
the sessions screen via openSessions() with real agents
- workspace-draft-agent-tab.test.tsx: covered by new-workspace.spec.ts
"redirects to optimistic draft tab before agent creation resolves"
- new-workspace-screen.test.tsx: covered by new-workspace.spec.ts (main
submit, branch selection, PR selection, optimistic draft tab) plus
new-workspace-picker-item.test.ts (pickerItemToCheckoutRequest)
- project-settings-screen.test.tsx: covered by project-config-form.test.ts
(all round-trip string/array lifecycle semantics) and projects-settings.spec.ts
(save flow with passthrough field preservation)
Extracts syncPickerPrAttachment from new-workspace-screen.tsx into a new
pure module new-workspace-picker-state.ts with 5 zero-mock unit tests
covering: initial PR selection, branch selection without change, PR
replacement, PR removal on branch switch, and no-duplicate guard.
- Delete clickNewTabButton (duplicate of clickNewChat) and clickNewTerminalButton (duplicate of clickNewTerminal); update all call sites
- Rename clickTerminal → clickNewTerminal to match clickNewChat naming pattern
- Delete waitForLauncherPanel (deprecated no-op)
- Delete waitForAgentFinishUI and getToolCallCount (dead exports never imported)
- Remove ensureE2EStorageSeeded, assertE2EUsesSeededTestDaemon, and related helpers from app.ts — the paseoE2ESetup auto-fixture seeds via addInitScript on every navigation, making these redundant
- Simplify gotoAppShell to a one-liner; simplify gotoHome to use .or() instead of three-way if-else chain
- Strip try/catch self-heal from ensureHostSelected — the fixture guarantees preferences alignment, so the workaround is never needed
* test(app): triage desktop test files — delete 16-mock component slop, tighten attachment store assertion
Removes desktop-updates-section.test.tsx (16 internal mocks, JSDOM, toHaveBeenCalledWith chains on component renders).
Tightens desktop-attachment-store.test.ts: toHaveBeenCalled() → toHaveBeenCalledWith(attachment).
* refactor(app): extract daemon management toggle coordinator with unit tests
Fills the coverage gap left by deleting desktop-updates-section.test.tsx.
Extracts executeDaemonManagementToggle from useDaemonManagementToggle — pure
async coordinator with injected deps (no vi.mock). Unit tests verify the three
key invariants: settings persist before stop, stop is skipped when not
desktop-managed, and start/stop are invoked on enable/disable.
Drops the per-test manual boilerplate (connectWorkspaceSetupClient,
createTempGitRepo, seedProjectForWorkspaceSetup, openProject,
openHomeWithProject, navigateToWorkspaceViaSidebar, try/finally cleanup)
in favour of the withWorkspace fixture landed in #717.
Both tests — main checkout and worktree — verified green locally.
Move composer cancel/queue/attachment/dispatch orchestration out of
composer.tsx into a React-free actions module. The module takes its
dependencies (send client, queue writer, stream writer, attachment
persister, image picker) as injected ports, so the new
composer-actions.test.ts can drive every action with inline fakes —
zero vi.mock of internal modules, zero JSDOM, zero React.
The old composer.test.tsx (heavy mocked component test) is removed
and replaced with composer-actions.test.ts (31 unit tests).
Also extract isWorkspaceAttachment / userAttachmentsOnly /
workspaceAttachmentToSubmitAttachment from composer-workspace-attachments.tsx
into a sibling .ts so the actions module (and composer-attachments.ts)
can use them without dragging React/RN/lucide into a pure test graph.
Moves all preference-resolution logic into a pure `resolveAgentForm(state, action)` reducer
with a discriminated-union action type. The hook becomes a thin React container: it holds state
via `useReducer`, dispatches actions, and owns the hydration-ref timing logic.
Removes the `__private__` export and the vi.mock-heavy live test in favour of direct pure-function
unit tests (53 tests, zero vi.mock of internal modules).
Replace 8-mock hook test (expo-router, layout, platform, navigation,
4 stores) with a pure unit test of the routing decision. The hook now
reads pathname/layout/key, calls routeKeyboardShortcut, and dispatches
the resulting ShortcutAction — no behaviour change.
Extract DraftStorage interface and createAgentInputDraftCore factory
to a new use-agent-input-draft-core.ts. Pure helper functions (resolveDraftKey,
resolveEffectiveComposerModelId, etc.) move there too, breaking the test
file's transitive dependency on AsyncStorage and useAgentFormState.
The test now imports directly from the core module, uses a Map-backed
in-memory storage, and has zero vi.mock calls. Tests assert behavior
("save then load returns the same draft") rather than call counts.
Adds a `withWorkspace` Playwright fixture plus composable helpers
(permissions, sidebar, composer, agent-stream, settings) so specs read
as user-level intent. Migrates workspace-lifecycle and settings-host-page
to the new DSL as proof.
Replace toBeDefined/toBeTruthy guards with concrete shape assertions
using toContainEqual(expect.objectContaining(...)) in diff-highlighter
tests, and toMatch(/^script-draft-\d+$/) for the ID check in
project-config-form tests.
Move getStateLabel and getActivityVerb out of pr-pane.tsx into pr-pane-data.ts
so they can be tested without JSDOM, React, or vi.mock. Add exhaustive unit tests
for both helpers alongside the existing mapPrPaneData/formatAge/deriveAvatarColor
coverage. Delete pr-pane.test.tsx — the component test was asserting on derived
label/icon strings that are now covered by pure function tests.
Rewrites two store tests to verify observable state instead of implementation-level call counts.
navigation-active-workspace-store: the "persists" test now uses a functional in-memory AsyncStorage mock that retains written values across vi.resetModules(), then rehydrates a fresh store instance and asserts the resulting selection state matches what was written.
checkout-git-actions-store: removes toHaveBeenCalledWith/toHaveBeenCalledTimes assertions on client methods; replaces with getStatus() assertions that reflect the store's actual state after each action completes or fails.