Improve app tests (#1197)
* docs(testing): require ports-and-adapters unit tests or real e2e — no in-between
State the end-state explicitly so the test-suite cleanup has a written bar.
* Make useProjects a ports-and-adapters unit test
Extract the per-host workspace aggregation out of useProjects into a
pure fetchAggregatedProjects(input) that takes a typed ProjectsRuntime
adapter. The hook becomes a thin useQuery shim.
The test loses jsdom, @testing-library/react, QueryClientProvider, and
all vi.mock/vi.hoisted of @/runtime/host-runtime. It now exercises the
real aggregation against an in-memory ProjectsRuntime adapter.
* Make useLoadOlderAgentHistory a ports-and-adapters unit test
Extract the load-older sequence into a pure async function that takes
its client, in-flight tracker, toast, and logger as injected
dependencies. The hook reads the session store at call time and wires
the real adapters in. Tests now drive the pure function with typed
fakes — no JSDOM, no @testing-library/react renderHook, no console
spy.
* Make useAgentHistory a ports-and-adapters unit test
Drop the dead __private__ namespace from use-agent-history.ts, expose
fetchAgentHistoryPage and an AgentHistoryClient port as normal exports.
The hook keeps its useInfiniteQuery wiring; the page fetcher is now
testable in isolation.
The test loses jsdom, @testing-library/react, QueryClientProvider, the
vi.hoisted/vi.mock of @/runtime/host-runtime, and the renderHook timing
loops. It exercises the page fetcher directly against an in-memory
AgentHistoryClient adapter that records each call.
* Make usePrPaneData a ports-and-adapters unit test
Extract the timeline fetch into a pure fetchPrPaneTimelinePage that
takes a PrPaneTimelineClient adapter and an UnsupportedTimelineRegistry
port. Extract the rest of the hook's wiring into pure exports:
extractPrRepoIdentity, shouldFetchTimelineFrom, selectPrPaneState. The
module-level unsupported-tuple Set becomes a swappable in-memory
registry; production still wires the same module-level default.
The test loses JSDOM, vi.hoisted + vi.mock of @/runtime/host-runtime,
QueryClientProvider, createRoot, act, focusManager/onlineManager
toggling, and a hand-rolled waitForExpectation polling loop. It drives
each pure function and the timeline fetcher directly against typed
fakes and asserts on recorded state.
* Make useProvidersSnapshot a ports-and-adapters unit test
Extract the network and cache work into pure functions taking a
typed ProvidersSnapshotClient adapter: fetchProvidersSnapshot,
refreshAndApplyProvidersSnapshot, applyProvidersSnapshotUpdate, and
selectorOpenRefetchDecision. The hook still drives them through
react-query and the host-runtime websocket subscription; production
wires the same DaemonClient as before.
The test loses @vitest-environment jsdom, vi.hoisted + vi.mock of
@/runtime/host-runtime, renderHook + QueryClientProvider, act, and a
hand-rolled listener-bucket trap that reached into the spied
client.on subscriber. It drives each pure function directly against
a typed FakeProvidersSnapshotClient and a real QueryClient.
* Make useSettings a ports-and-adapters unit test
Extract the pure load/save logic into use-settings.pure.ts taking a
typed SettingsDeps adapter: a KeyValueStorage port and a
DesktopSettingsBridge port. The hook file keeps the public 0-arg API
(loadAppSettingsFromStorage, loadSettingsFromStorage, persistAppSettings,
saveAppSettings) by wrapping the pure functions with productionDeps that
wire AsyncStorage, isElectronRuntime, loadDesktopSettings, and
migrateLegacyDesktopSettings as before.
The test loses three vi.mock blocks (async-storage, @/desktop/host,
@/desktop/settings/desktop-settings), two vi.hoisted blobs, and the
vi.resetModules + await import("./use-settings") per-test pattern. It
drives the pure functions directly against an in-memory
InMemoryKeyValueStorage and a FakeDesktopBridge that records applied
migrations, asserting on observable storage state rather than mock
function calls.
* Make useAgentCommandsQuery a ports-and-adapters unit test
Extract the daemon call into fetchAgentCommands, a pure async
function taking a typed AgentCommandsClient adapter. The hook still
wraps it in useQuery and resolves the host-runtime client; production
wires the same DaemonClient as before.
The test loses @vitest-environment jsdom, vi.hoisted + vi.mock of
@/runtime/host-runtime, renderHook + QueryClientProvider, and
waitFor. It calls fetchAgentCommands directly against a typed
FakeAgentCommandsClient.
* Make useChangesPreferences a ports-and-adapters unit test
Extract the AsyncStorage-touching load and save logic into
use-changes-preferences.pure.ts taking a typed KeyValueStorage adapter.
The hook file keeps the public API (useChangesPreferences,
loadChangesPreferencesFromStorage) by wrapping the pure functions with a
productionStorage wired to AsyncStorage as before.
The test loses vi.hoisted + vi.mock of
@react-native-async-storage/async-storage, vi.resetModules, and the
per-test dynamic await import("./use-changes-preferences") pattern. It
drives the pure functions directly against an in-memory
InMemoryKeyValueStorage, asserting on observable storage entries rather
than mock function calls. Adds coverage for saveChangesPreferences,
including the no-prior-cache fallback path.
* Make useArchiveAgent a ports-and-adapters unit test
Extract the queryClient-only pure helpers (toArchiveKey,
selectPendingArchiveAgentIds, setAgentArchiving, isAgentArchiving,
removeAgentFromListPayload, markAgentArchivedInHistoryPayload, and the
queryClient-mutating cache helpers) into use-archive-agent.pure.ts. The
hook file keeps the React surface (usePendingArchiveAgentIds,
useArchiveAgent, applyArchivedAgentCloseResults) by importing from the
pure module, and the __private__ reach-around export is gone.
The test loses @vitest-environment jsdom, @testing-library/react, the
renderHook/act/waitFor imports, and the __private__ reach-around. It
calls the pure helpers directly against a real QueryClient and the real
session store. The renderHook test of usePendingArchiveAgentIds is
dropped — it asserted on react-query's subscription mechanics rather
than on our logic.
* Make useSidebarWorkspacesList a ports-and-adapters unit test
Extract the pure pieces — types, applyStoredOrdering,
appendMissingOrderKeys, buildSidebarProjectsFromStructure, and the new
computeSidebarOrderUpdates + deriveSidebarLoadingState helpers — into
use-sidebar-workspaces-list.pure.ts. The hook reads its persistent
sidebar order via the pure helper inside useEffect and derives its
loading state via the pure helper, instead of inlining the logic across
two effects. createSidebarWorkspaceEntry stays in the hook file since
it pulls selectPrHintFromStatus, but the hook re-exports it for
existing callers.
The test loses @vitest-environment jsdom, react-dom/client,
@testing-library/react, and the three Probe components that mounted
React just to assert on effect mechanics. It calls
computeSidebarOrderUpdates and deriveSidebarLoadingState directly, with
no module mocks or hoisted globals. The "does not subscribe while
disabled" assertion is dropped — it was testing useSyncExternalStore's
subscribe gate, not our logic.
* Make useAgentInitialization a ports-and-adapters unit test
Extract ensureAgentIsInitialized and refreshAgent — the entire
imperative bodies of the hook's two callbacks — into
use-agent-initialization.pure.ts, taking setAgentInitializing as an
injected port. Add createSetAgentInitializing as a factory that binds
serverId to the zustand setInitializingAgents action. The hook itself
collapses to ~20 lines of useMemo + useCallback bindings.
The test drops @vitest-environment jsdom, @testing-library/react,
renderHook, and act. It calls ensureAgentIsInitialized and refreshAgent
directly with a bound setAgentInitializing fake. No React mounting, no
module mocks, no hoisted globals.
* Make useClientActivity a ports-and-adapters unit test
Extract the activity-tracker state machine — lastActivityAt bookkeeping,
heartbeat throttling, app-visibility transitions, system-idle monotonic
update, and focused-agent change handling — into
use-client-activity.pure.ts. createClientActivityTracker takes the
heartbeat client, deviceType, and a now() port; the hook wires DOM /
AppState / Electron-idle listeners to tracker methods.
The test drops @vitest-environment jsdom, react-dom/client mounting,
act, and four module mocks (@/constants/platform, @/desktop/electron/idle,
react-native, @getpaseo/client/internal/daemon-client). It calls the
tracker directly with a fake heartbeat client and a test clock; the
fake records emitted heartbeats as observable state.
* Make useHoverSafeZone a ports-and-adapters unit test
Extract the safe-zone state machine — wasInside dedupe, bridge-rect
geometry between trigger and content, and inside/outside transitions —
into use-hover-safe-zone.pure.ts. createHoverSafeZoneTracker takes
getTriggerRect / getContentRect / onEnterSafeZone / onLeaveSafeZone;
the hook just wires document pointermove, window pointerout, and
window blur listeners to tracker methods.
The test drops @vitest-environment jsdom, react-dom/client mounting,
act, @testing-library/react renderHook, the vi.mock("@/constants/platform")
shim, the IS_REACT_ACT_ENVIRONMENT stub, and the getBoundingClientRect
patching helper. It calls the tracker directly with fake rect getters
and asserts on recorded enter/leave counts.
* Make useArchiveSubagent a ports-and-adapters unit test
* Make openImagePathsWithDesktopDialog a ports-and-adapters unit test
Inject the DesktopDialogBridge into openImagePathsWithDesktopDialog instead
of reaching for getDesktopHost() inside the function. useImageAttachmentPicker
passes getDesktopHost()?.dialog at the call site; the native sibling matches
the new signature.
The test drops vi.mock("@/desktop/host", ...) and vi.hoisted() in favor of a
typed in-memory fake dialog that records the options it was called with. No
global module substitution, no spies — the test reads the fake's recorded
state.
* Make useIosHardwareKeyboardSubmit a ports-and-adapters unit test
* Make UpdateCalloutSource a ports-and-adapters unit test
Extract resolveUpdateCalloutDescriptor as a pure function that maps
updater state to a structured callout descriptor. UpdateCalloutSource
becomes a thin React shim that materializes the descriptor's icon and
description as ReactNodes before registering with the sidebar callout
API.
Replaces a 259-line JSDOM + react-dom/client test that mocked five
modules (unistyles theme, lucide icons, async-storage, openExternalUrl,
useDesktopAppUpdater) and mounted SidebarCalloutProvider/Slot just to
assert deterministic title/description/action/dismissal-key derivations.
The new test exercises the resolver directly with zero React, zero DOM,
zero mocks.
* Delete dead useWorkspaceNavigation hook + collapse re-export indirection
The hook had zero production callers — only its own test, which used
vi.hoisted + vi.mock + jsdom + @testing-library/react/renderHook to
verify a useCallback wrapper. The file also re-exported navigateToWorkspace
from the navigation store, so five production importers and two sibling
tests reached the store through a hook-module path that had nothing to
do with hooks.
Retargets every importer to @/stores/navigation-active-workspace-store
directly and removes the indirection module + its slop test.
* Make useCheckoutStatusQuery a ports-and-adapters unit test
Extract peekOrFetchCheckoutStatus and applyCheckoutStatusUpdate to a
sibling checkout-status-cache.ts so both pure functions operate on an
injected QueryClient and CheckoutStatusClient, with no React or host
runtime imports. useCheckoutStatusQuery becomes a thin shell that
composes useQuery + useEffect and delegates the cwd-filter + cache write
to applyCheckoutStatusUpdate.
Replaces a 318-line JSDOM + react-dom/client + fake-timers test that
mocked @/runtime/host-runtime via vi.hoisted, mounted a Probe component
to read the hook's data, and captured the subscription handler in a
hoisted Set. The new test exercises both functions directly against a
real QueryClient — zero React, zero DOM, zero mocks, zero fake timers.
* Make workspace-navigation a ports-and-adapters unit test
* Make navigateToAgent a ports-and-adapters unit test
* Make redirectIfArchivingActiveWorkspace a ports-and-adapters unit test
* Make openProjectDirectly a ports-and-adapters unit test
* Make navigation-active-workspace-store a ports-and-adapters unit test
* Make desktop-attachment-store a ports-and-adapters unit test
* Make readDesktopSystemIdleTimeMs a ports-and-adapters unit test
Rename getDesktopSystemIdleTimeMs to readDesktopSystemIdleTimeMs and
take the desktop IPC invoker as a parameter. The single caller in
use-client-activity.ts now imports invokeDesktopCommand directly and
passes it; the test wires a typed fake invoker.
Drops vi.mock("@/desktop/electron/invoke"), vi.hoisted, and the four
vi.spyOn(console, "warn") log assertions. Tests now assert the
documented behaviour (returns ms, or null) against an in-memory fake.
* Make useDesktopAppUpdater a ports-and-adapters unit test
Extract the check/install state machine from the React hook into a pure
createDesktopAppUpdater runtime that exposes getSnapshot/subscribe/
checkForUpdates/installUpdate. The hook now wires real production deps
into the runtime and bridges its snapshot via useSyncExternalStore;
React-driven concerns (pending-update interval, initial silent check)
stay in the hook.
Drops JSDOM, @testing-library/react, renderHook, the three vi.mock
calls and the vi.hoisted state shim from the test. The new test wires
a typed FakeDesktopAppUpdaterPort recording recordedChecks/recordedInstalls
and exposes deferNextCheck/failNextCheck/nextInstallResult so the
behaviour assertions read as plain English (status transitions through
checking, available, pending, up-to-date, error; race cancellation drops
older results; install errors get reported once).
* Make sidebar-collapsed-sections-store a ports-and-adapters unit test
* Make session-store-hooks a ports-and-adapters unit test
* Make panel-store a ports-and-adapters unit test
* Make workspace-tabs-store a ports-and-adapters unit test
* Make desktop-preview-url a ports-and-adapters unit test
* Make client-id a ports-and-adapters unit test
* Make browser-store a ports-and-adapters unit test
* Make local-file-attachment-store a ports-and-adapters unit test
* Make draft-store a ports-and-adapters unit test
* Make rich-clipboard a ports-and-adapters unit test
* Make desktop-daemon-transport a ports-and-adapters unit test
* Make image-attachment-picker.native a ports-and-adapters unit test
Extract the pure normalize logic into image-attachment-picker.native.pure.ts
and take the PNG exporter as a port. The .native.ts entry wires the real
expo-image-manipulator adapter; the test wires a fake exporter and asserts
on its recorded uris.
Drops vi.mock("expo-image-manipulator") and the inline ImageManipulator
stub from the test. Production callers of normalizePickedImageAssets are
unchanged.
* Make tool-call-icon a ports-and-adapters unit test
Split the pure icon-identity decision into tool-call-icon-name.ts and
keep the lucide/PaseoLogo component lookup in tool-call-icon.ts. The
resolver returns a ToolCallIcon string ("bot", "brain", "paseo", ...);
componentForToolCallIcon does the React component mapping; the existing
resolveToolCallIcon is the composition.
Drops vi.mock("lucide-react-native") from the test and the brittle
"expect(icon).toBe(iconMocks.Bot)" pattern — the unit project couldn't
evaluate lucide-react-native, which is why the mock existed in the
first place. The test now asserts on string identifiers and imports
only the pure module.
buildToolCallPresentation's resolveIcon port is unchanged; the single
caller (components/message.tsx) keeps passing resolveToolCallIcon and
gets the same component back.
* Make desktop-permissions a ports-and-adapters unit test
The test was the heaviest globalThis-juggling test in the app package:
vi.doMock("react-native") + vi.resetModules() per case to swap the
platform, ensureWindow/setNavigator/restoreGlobals to swap
globalThis.Notification, globalThis.navigator, and window.paseoDesktop.
That whole setup existed because the production module reached into
four ambient sources (Notification, navigator, getDesktopHost(),
isWeb/isNative) without a port.
Extract a DesktopPermissionEnvironment interface — { isWeb,
getDesktopHost, getNotification, getNavigator } — and rebuild the
module around a createDesktopPermissions(env) factory. The real
environment binds to the actual globals at module load and the
existing top-level exports (shouldShowDesktopPermissionSection,
getDesktopPermissionSnapshot, requestDesktopPermission) are thin
references onto it, so the only caller (use-desktop-permissions.ts) is
unchanged.
The test now constructs a fakeEnvironment per case and calls into
createDesktopPermissions directly — no JSDOM, no vi.doMock, no
vi.resetModules, no globalThis writes. The eight behaviors are
preserved.
* Make provider-icons a ports-and-adapters unit test
Split the pure provider→icon-identity decision into provider-icon-name.ts
and keep the lucide/SvgXml/catalog component lookup in provider-icons.ts.
resolveProviderIconName returns a ProviderIconName ({kind:"builtin"|"catalog"|"bot", id?}),
and getProviderIcon composes it with the catalog/builtin component maps.
Drops vi.mock("lucide-react-native") from the test — the previous
"expect(icon).toBe(iconMocks.Bot)" pattern only existed because the unit
project couldn't evaluate lucide-react-native. The new test asserts on
discriminated-union identifiers and imports only the pure module.
The 13 outside callers of getProviderIcon are unchanged.
* Make crypto polyfill a ports-and-adapters unit test
* Delete redundant JSDOM Index route test
packages/app/src/app/index.test.tsx mounted the Index component through
JSDOM + createRoot + @testing-library/react, mocked five modules
(expo-router, _layout, desktop-daemon, startup-splash-screen,
navigation-active-workspace-store), and asserted that <Redirect> got
rendered with the right href across six scenarios.
Each of those six scenarios is a one-to-one duplicate of a pure case
already covered in host-runtime-bootstrap.test.ts, which tests the same
two decision functions (resolveStartupRedirectRoute,
resolveStartupWorkspaceSelection) directly. The component is pure
wiring: it reads four hooks, calls the two decision functions, and
renders the result. There is no logic in Index to verify that the pure
tests do not already cover.
Drop the JSDOM file; the wiring is covered by app E2E.
* Make review-draft-store a ports-and-adapters unit test
* Make new-workspace-empty a ports-and-adapters unit test
* Extract shared drag-reorder state machine for web sortable lists
* Extract pure subagents track presentation helpers
Move formatHeaderLabel and resolveRowLabel out of track.tsx into a
colocated track-presentation.ts. Six header-copy tests that previously
mounted React under JSDOM with eight vi.mocks now run as plain unit
tests against the pure helpers.
* Make terminal-file-drop a ports-and-adapters unit test
* Extract pure import-session-sheet view-model helpers
Move resolveProvidersToFetch, buildProviderLabelMap, aggregateSessionEntries,
sumFilteredAlreadyImportedCount, collectErroredProviderLabels, getSessionTitle,
getPromptPreview, and computeEmptyState out of import-session-sheet.tsx into a
colocated import-session-sheet.pure.ts. Add 28 pure unit tests that exercise
provider resolution, dedupe/sort, error label fallback, title fallback, and
the empty-state state machine directly — no JSDOM, no vi.mock.
The existing 859-line JSDOM import-session-sheet.test.tsx stays for now; next
ticks can replace its status-message and empty-state cases with the pure
coverage.
* Make subagents track a ports-and-adapters unit test
Move the row-presentation data builder from track.tsx into the colocated
pure track-presentation.ts and cover statusBucket, titleState, and label
in plain node tests. Delete the 235-line JSDOM track.test.tsx with its
eight vi.mocks and createRoot harness — the remaining behaviours (empty
returns null, useState toggle, onPress wiring) are React idioms covered
by the framework, not domain logic.
* Make isolated-bottom-sheet-modal a ports-and-adapters unit test
* Stop UI from leaking through subagents barrel
The subagents/index.ts barrel re-exported SubagentsTrack (the React
component) alongside pure logic like selectSubagentsForParent. Any pure
consumer of the barrel transitively pulled in lucide-react-native and
react-native-unistyles, forcing tests that only touch pure logic to
declare cosmetic vi.mock blocks for icons, theme, tooltip, and provider
icons just to get the import graph to load.
Drop SubagentsTrack from the barrel. The one external caller
(panels/agent-panel.tsx) deep-imports from @/subagents/track instead.
The workspace-subagents-integration test loses 56 lines of pretend-UI
mocks; only the AsyncStorage mock remains because workspace-layout-store
uses it directly through persist middleware.
* Collapse use-hover-safe-zone.pure.ts into hover-safe-zone-tracker.ts
The `.pure.ts` suffix mimics tooling-resolved variants (`.web.ts`,
`.native.ts`, `.test.ts`) without being one. The file exports a single
tracker; rename it after its role. Test sits next to its subject by name.
* Promote use-settings to a directory module
* Promote sidebar-collapsed-sections-store to a directory module
* Promote navigate-to-agent to a directory module
The `.pure.ts` filename suffix mimics tooling-resolved variants
(`.web.ts`, `.native.ts`, `.test.ts`) without being one. Move the pure
resolver and its wired wrapper into `utils/navigate-to-agent/`, where
the directory carries the domain and `resolve.ts` names the role. The
pure function is renamed `resolveNavigateToAgent` so it no longer
collides with the wrapper's exported `navigateToAgent`.
* Collapse use-client-activity.pure.ts into client-activity-tracker.ts
* Collapse open-project.pure.ts into open-project.ts
* Promote use-changes-preferences to a directory module
Replaces the .pure.ts / .test-utils.ts double-suffix with a
directory home that mirrors hooks/use-settings/:
hooks/use-changes-preferences/
index.ts ← React hook; wires AsyncStorage
storage.ts ← pure load/save
storage.test.ts ← tests against storage
fakes.ts ← in-memory KeyValueStorage adapter
External callers continue importing @/hooks/use-changes-preferences
unchanged (resolves to index.ts).
* Promote draft-store to a directory module
* Collapse use-archive-agent.pure.ts into use-archive-agent.ts
* Rename import-session-sheet.pure.ts to import-session-sheet-view-model.ts
* Rename image-attachment-picker.native.pure.ts to picked-image-normalizer.ts
* Inline use-agent-initialization.pure.ts into use-agent-initialization.ts
* Rename use-sidebar-workspaces-list.pure.ts to sidebar-workspaces-view-model.ts
* Rename review/store.pure.ts to review/state.ts
* Promote browser-store to a directory module
* Promote navigation-active-workspace-store to a directory module
* Promote panel-store to a directory module
* Promote session-store-hooks to a directory module
* Promote workspace-tabs-store to a directory module
* Rename use-archive-subagent.pure.ts to archive-subagent.ts
* Rename sidebar-workspace-archive-redirect.pure.ts to workspace-archive-redirect.ts
* Rename workspace-navigation.pure.ts to prepare-workspace-tab.ts
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDesktopAppUpdater,
|
||||
formatStatusText,
|
||||
type DesktopAppUpdater,
|
||||
type DesktopAppUpdaterErrorReport,
|
||||
} from "./desktop-app-updater";
|
||||
import {
|
||||
buildFakeCheckResult,
|
||||
buildFakeInstallResult,
|
||||
createFakeDesktopAppUpdaterPort,
|
||||
type FakeDesktopAppUpdaterPort,
|
||||
} from "./test-utils/fake-desktop-app-updater-port";
|
||||
|
||||
function createUpdater(
|
||||
overrides: {
|
||||
port?: FakeDesktopAppUpdaterPort;
|
||||
now?: () => number;
|
||||
reportInstallError?: (report: DesktopAppUpdaterErrorReport) => void;
|
||||
} = {},
|
||||
): {
|
||||
updater: DesktopAppUpdater;
|
||||
port: FakeDesktopAppUpdaterPort;
|
||||
reportedInstallErrors: DesktopAppUpdaterErrorReport[];
|
||||
} {
|
||||
const port = overrides.port ?? createFakeDesktopAppUpdaterPort();
|
||||
const reportedInstallErrors: DesktopAppUpdaterErrorReport[] = [];
|
||||
const updater = createDesktopAppUpdater({
|
||||
port,
|
||||
now: overrides.now ?? (() => 1_700_000_000_000),
|
||||
reportInstallError:
|
||||
overrides.reportInstallError ??
|
||||
((report) => {
|
||||
reportedInstallErrors.push(report);
|
||||
}),
|
||||
});
|
||||
return { updater, port, reportedInstallErrors };
|
||||
}
|
||||
|
||||
describe("desktop app updater — check", () => {
|
||||
it("forwards the requested release channel to the port", async () => {
|
||||
const { updater, port } = createUpdater();
|
||||
port.nextCheckResult(buildFakeCheckResult());
|
||||
|
||||
await updater.checkForUpdates({ releaseChannel: "beta" });
|
||||
|
||||
expect(port.recordedChecks).toEqual([{ releaseChannel: "beta" }]);
|
||||
});
|
||||
|
||||
it("moves to 'checking' during a non-silent check", async () => {
|
||||
const { updater, port } = createUpdater();
|
||||
const deferred = port.deferNextCheck();
|
||||
|
||||
const pending = updater.checkForUpdates({ releaseChannel: "stable" });
|
||||
expect(updater.getSnapshot().status).toBe("checking");
|
||||
expect(updater.getSnapshot().isChecking).toBe(true);
|
||||
|
||||
deferred.resolve(buildFakeCheckResult());
|
||||
await pending;
|
||||
});
|
||||
|
||||
it("stays on the current status during a silent check", async () => {
|
||||
const { updater, port } = createUpdater();
|
||||
port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true }));
|
||||
await updater.checkForUpdates({ releaseChannel: "stable" });
|
||||
expect(updater.getSnapshot().status).toBe("available");
|
||||
|
||||
const deferred = port.deferNextCheck();
|
||||
const pending = updater.checkForUpdates({ releaseChannel: "stable", silent: true });
|
||||
expect(updater.getSnapshot().status).toBe("available");
|
||||
|
||||
deferred.resolve(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true }));
|
||||
await pending;
|
||||
});
|
||||
|
||||
it("reports 'available' when the check resolves with a downloaded update", async () => {
|
||||
const { updater, port } = createUpdater({ now: () => 42 });
|
||||
port.nextCheckResult(
|
||||
buildFakeCheckResult({ hasUpdate: true, readyToInstall: true, latestVersion: "1.2.3" }),
|
||||
);
|
||||
|
||||
await updater.checkForUpdates({ releaseChannel: "stable" });
|
||||
|
||||
expect(updater.getSnapshot()).toMatchObject({
|
||||
status: "available",
|
||||
availableUpdate: { latestVersion: "1.2.3" },
|
||||
lastCheckedAt: 42,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports 'pending' when the check resolves with an update that is not yet downloaded", async () => {
|
||||
const { updater, port } = createUpdater();
|
||||
port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: false }));
|
||||
|
||||
await updater.checkForUpdates({ releaseChannel: "stable" });
|
||||
|
||||
expect(updater.getSnapshot()).toMatchObject({
|
||||
status: "pending",
|
||||
availableUpdate: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports 'up-to-date' when the check resolves with no update", async () => {
|
||||
const { updater, port } = createUpdater();
|
||||
port.nextCheckResult(buildFakeCheckResult({ hasUpdate: false, readyToInstall: false }));
|
||||
|
||||
await updater.checkForUpdates({ releaseChannel: "stable" });
|
||||
|
||||
expect(updater.getSnapshot().status).toBe("up-to-date");
|
||||
});
|
||||
|
||||
it("reports 'error' when a non-silent check throws", async () => {
|
||||
const { updater, port } = createUpdater();
|
||||
port.failNextCheck(new Error("network down"));
|
||||
|
||||
await updater.checkForUpdates({ releaseChannel: "stable" });
|
||||
|
||||
expect(updater.getSnapshot()).toMatchObject({
|
||||
status: "error",
|
||||
errorMessage: "network down",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not move to 'error' when a silent check throws", async () => {
|
||||
const { updater, port } = createUpdater();
|
||||
port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true }));
|
||||
await updater.checkForUpdates({ releaseChannel: "stable" });
|
||||
const statusBeforeSilent = updater.getSnapshot().status;
|
||||
|
||||
port.failNextCheck(new Error("boom"));
|
||||
await updater.checkForUpdates({ releaseChannel: "stable", silent: true });
|
||||
|
||||
expect(updater.getSnapshot().status).toBe(statusBeforeSilent);
|
||||
});
|
||||
|
||||
it("ignores the older result when a newer check supersedes it mid-flight", async () => {
|
||||
const { updater, port } = createUpdater();
|
||||
const firstDeferred = port.deferNextCheck();
|
||||
port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true }));
|
||||
|
||||
const firstPending = updater.checkForUpdates({ releaseChannel: "stable" });
|
||||
const secondPending = updater.checkForUpdates({ releaseChannel: "stable" });
|
||||
await secondPending;
|
||||
expect(updater.getSnapshot().status).toBe("available");
|
||||
|
||||
firstDeferred.resolve(buildFakeCheckResult({ hasUpdate: false, readyToInstall: false }));
|
||||
await firstPending;
|
||||
|
||||
expect(updater.getSnapshot().status).toBe("available");
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop app updater — install", () => {
|
||||
it("forwards the requested release channel to the port", async () => {
|
||||
const { updater, port } = createUpdater();
|
||||
port.nextInstallResult(buildFakeInstallResult({ installed: true }));
|
||||
|
||||
await updater.installUpdate({ releaseChannel: "beta" });
|
||||
|
||||
expect(port.recordedInstalls).toEqual([{ releaseChannel: "beta" }]);
|
||||
});
|
||||
|
||||
it("moves to 'installed' when the install reports installation succeeded", async () => {
|
||||
const { updater, port } = createUpdater();
|
||||
port.nextInstallResult(
|
||||
buildFakeInstallResult({ installed: true, message: "Restart to finish" }),
|
||||
);
|
||||
|
||||
await updater.installUpdate({ releaseChannel: "stable" });
|
||||
|
||||
expect(updater.getSnapshot()).toMatchObject({
|
||||
status: "installed",
|
||||
installMessage: "Restart to finish",
|
||||
isInstalling: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("moves to 'up-to-date' when the install reports nothing to install", async () => {
|
||||
const { updater, port } = createUpdater();
|
||||
port.nextInstallResult(buildFakeInstallResult({ installed: false }));
|
||||
|
||||
await updater.installUpdate({ releaseChannel: "stable" });
|
||||
|
||||
expect(updater.getSnapshot().status).toBe("up-to-date");
|
||||
});
|
||||
|
||||
it("reports the install error and moves to 'error' when the install throws", async () => {
|
||||
const { updater, port, reportedInstallErrors } = createUpdater();
|
||||
const error = new Error("install failed");
|
||||
port.failNextInstall(error);
|
||||
|
||||
await updater.installUpdate({ releaseChannel: "stable" });
|
||||
|
||||
expect(updater.getSnapshot()).toMatchObject({
|
||||
status: "error",
|
||||
errorMessage: "install failed",
|
||||
isInstalling: false,
|
||||
});
|
||||
expect(reportedInstallErrors).toEqual([
|
||||
{
|
||||
error,
|
||||
message: "Unable to install the desktop app update.",
|
||||
logLabel: "[DesktopUpdater] Failed to install app update",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("desktop app updater — subscribe", () => {
|
||||
it("notifies subscribers when the status changes", async () => {
|
||||
const { updater, port } = createUpdater();
|
||||
port.nextCheckResult(buildFakeCheckResult({ hasUpdate: true, readyToInstall: true }));
|
||||
|
||||
const notifications: string[] = [];
|
||||
const unsubscribe = updater.subscribe(() => {
|
||||
notifications.push(updater.getSnapshot().status);
|
||||
});
|
||||
|
||||
await updater.checkForUpdates({ releaseChannel: "stable" });
|
||||
unsubscribe();
|
||||
|
||||
expect(notifications).toEqual(["checking", "available"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatStatusText", () => {
|
||||
const formatVersion = (version: string | null | undefined) =>
|
||||
version ? `v${version.replace(/^v/i, "")}` : "\u2014";
|
||||
|
||||
it("uses the latest version in the 'available' message when present", () => {
|
||||
expect(
|
||||
formatStatusText({
|
||||
status: "available",
|
||||
availableUpdate: buildFakeCheckResult({ latestVersion: "1.2.3" }),
|
||||
installMessage: null,
|
||||
formatVersion,
|
||||
}),
|
||||
).toBe("Update ready: v1.2.3");
|
||||
});
|
||||
|
||||
it("falls back to a generic 'available' message when no version is reported", () => {
|
||||
expect(
|
||||
formatStatusText({
|
||||
status: "available",
|
||||
availableUpdate: null,
|
||||
installMessage: null,
|
||||
formatVersion,
|
||||
}),
|
||||
).toBe("An app update is ready to install.");
|
||||
});
|
||||
|
||||
it("uses the install message in the 'installed' state when present", () => {
|
||||
expect(
|
||||
formatStatusText({
|
||||
status: "installed",
|
||||
availableUpdate: null,
|
||||
installMessage: "Restart now",
|
||||
formatVersion,
|
||||
}),
|
||||
).toBe("Restart now");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import type {
|
||||
DesktopAppUpdateCheckResult,
|
||||
DesktopAppUpdateInstallResult,
|
||||
DesktopReleaseChannel,
|
||||
} from "@/desktop/updates/desktop-updates";
|
||||
|
||||
export type DesktopAppUpdateStatus =
|
||||
| "idle"
|
||||
| "checking"
|
||||
| "pending"
|
||||
| "up-to-date"
|
||||
| "available"
|
||||
| "installing"
|
||||
| "installed"
|
||||
| "error";
|
||||
|
||||
export const PENDING_RECHECK_MS = 10_000;
|
||||
|
||||
export interface DesktopAppUpdaterSnapshot {
|
||||
status: DesktopAppUpdateStatus;
|
||||
availableUpdate: DesktopAppUpdateCheckResult | null;
|
||||
errorMessage: string | null;
|
||||
installMessage: string | null;
|
||||
lastCheckedAt: number | null;
|
||||
isChecking: boolean;
|
||||
isInstalling: boolean;
|
||||
}
|
||||
|
||||
export interface DesktopAppUpdaterPort {
|
||||
checkDesktopAppUpdate(input: {
|
||||
releaseChannel: DesktopReleaseChannel;
|
||||
}): Promise<DesktopAppUpdateCheckResult>;
|
||||
installDesktopAppUpdate(input: {
|
||||
releaseChannel: DesktopReleaseChannel;
|
||||
}): Promise<DesktopAppUpdateInstallResult>;
|
||||
}
|
||||
|
||||
export interface DesktopAppUpdaterErrorReport {
|
||||
error: unknown;
|
||||
message: string;
|
||||
logLabel: string;
|
||||
}
|
||||
|
||||
export interface DesktopAppUpdaterDeps {
|
||||
port: DesktopAppUpdaterPort;
|
||||
now(): number;
|
||||
reportInstallError?(report: DesktopAppUpdaterErrorReport): void;
|
||||
}
|
||||
|
||||
export interface DesktopAppUpdater {
|
||||
getSnapshot(): DesktopAppUpdaterSnapshot;
|
||||
subscribe(listener: () => void): () => void;
|
||||
checkForUpdates(options?: {
|
||||
releaseChannel: DesktopReleaseChannel;
|
||||
silent?: boolean;
|
||||
}): Promise<DesktopAppUpdateCheckResult | null>;
|
||||
installUpdate(options: {
|
||||
releaseChannel: DesktopReleaseChannel;
|
||||
}): Promise<DesktopAppUpdateInstallResult | null>;
|
||||
}
|
||||
|
||||
interface InternalState {
|
||||
status: DesktopAppUpdateStatus;
|
||||
availableUpdate: DesktopAppUpdateCheckResult | null;
|
||||
errorMessage: string | null;
|
||||
installMessage: string | null;
|
||||
lastCheckedAt: number | null;
|
||||
isInstalling: boolean;
|
||||
requestVersion: number;
|
||||
}
|
||||
|
||||
const INITIAL_STATE: InternalState = {
|
||||
status: "idle",
|
||||
availableUpdate: null,
|
||||
errorMessage: null,
|
||||
installMessage: null,
|
||||
lastCheckedAt: null,
|
||||
isInstalling: false,
|
||||
requestVersion: 0,
|
||||
};
|
||||
|
||||
function buildSnapshot(state: InternalState): DesktopAppUpdaterSnapshot {
|
||||
return {
|
||||
status: state.status,
|
||||
availableUpdate: state.availableUpdate,
|
||||
errorMessage: state.errorMessage,
|
||||
installMessage: state.installMessage,
|
||||
lastCheckedAt: state.lastCheckedAt,
|
||||
isChecking: state.status === "checking",
|
||||
isInstalling: state.status === "installing" || state.isInstalling,
|
||||
};
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && typeof error.message === "string") {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export function formatStatusText(input: {
|
||||
status: DesktopAppUpdateStatus;
|
||||
availableUpdate: DesktopAppUpdateCheckResult | null;
|
||||
installMessage: string | null;
|
||||
formatVersion: (version: string | null | undefined) => string;
|
||||
}): string {
|
||||
const { status, availableUpdate, installMessage, formatVersion } = input;
|
||||
|
||||
if (status === "checking") {
|
||||
return "Checking for app updates...";
|
||||
}
|
||||
|
||||
if (status === "installing") {
|
||||
return "Installing app update...";
|
||||
}
|
||||
|
||||
if (status === "up-to-date") {
|
||||
return "App is up to date.";
|
||||
}
|
||||
|
||||
if (status === "pending") {
|
||||
return "We'll let you know when the update is ready.";
|
||||
}
|
||||
|
||||
if (status === "available") {
|
||||
if (availableUpdate?.latestVersion) {
|
||||
return `Update ready: ${formatVersion(availableUpdate.latestVersion)}`;
|
||||
}
|
||||
return "An app update is ready to install.";
|
||||
}
|
||||
|
||||
if (status === "installed") {
|
||||
return installMessage ?? "App update installed. Restart required.";
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return "Failed to update app.";
|
||||
}
|
||||
|
||||
return "Update status has not been checked yet.";
|
||||
}
|
||||
|
||||
export function createDesktopAppUpdater(deps: DesktopAppUpdaterDeps): DesktopAppUpdater {
|
||||
let state: InternalState = { ...INITIAL_STATE };
|
||||
let cachedSnapshot: DesktopAppUpdaterSnapshot = buildSnapshot(state);
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function commit(next: InternalState): void {
|
||||
state = next;
|
||||
cachedSnapshot = buildSnapshot(state);
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdates(options?: {
|
||||
releaseChannel: DesktopReleaseChannel;
|
||||
silent?: boolean;
|
||||
}): Promise<DesktopAppUpdateCheckResult | null> {
|
||||
if (!options) {
|
||||
return null;
|
||||
}
|
||||
const { releaseChannel, silent = false } = options;
|
||||
const requestVersion = state.requestVersion + 1;
|
||||
|
||||
commit({
|
||||
...state,
|
||||
requestVersion,
|
||||
status: silent ? state.status : "checking",
|
||||
errorMessage: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await deps.port.checkDesktopAppUpdate({ releaseChannel });
|
||||
if (requestVersion !== state.requestVersion) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const nextLastCheckedAt = deps.now();
|
||||
let nextStatus: DesktopAppUpdateStatus;
|
||||
let nextAvailable: DesktopAppUpdateCheckResult | null;
|
||||
|
||||
if (result.readyToInstall) {
|
||||
nextStatus = "available";
|
||||
nextAvailable = result;
|
||||
} else if (result.hasUpdate) {
|
||||
nextStatus = "pending";
|
||||
nextAvailable = null;
|
||||
} else {
|
||||
nextStatus = "up-to-date";
|
||||
nextAvailable = null;
|
||||
}
|
||||
|
||||
commit({
|
||||
...state,
|
||||
status: nextStatus,
|
||||
availableUpdate: nextAvailable,
|
||||
installMessage: null,
|
||||
lastCheckedAt: nextLastCheckedAt,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (requestVersion !== state.requestVersion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = getErrorMessage(error);
|
||||
if (silent) {
|
||||
console.warn("[DesktopUpdater] Silent update check failed", message);
|
||||
commit({ ...state });
|
||||
} else {
|
||||
commit({
|
||||
...state,
|
||||
status: "error",
|
||||
errorMessage: message,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function installUpdate(options: {
|
||||
releaseChannel: DesktopReleaseChannel;
|
||||
}): Promise<DesktopAppUpdateInstallResult | null> {
|
||||
commit({
|
||||
...state,
|
||||
status: "installing",
|
||||
errorMessage: null,
|
||||
isInstalling: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await deps.port.installDesktopAppUpdate({
|
||||
releaseChannel: options.releaseChannel,
|
||||
});
|
||||
const nextLastCheckedAt = deps.now();
|
||||
commit({
|
||||
...state,
|
||||
status: result.installed ? "installed" : "up-to-date",
|
||||
availableUpdate: null,
|
||||
installMessage: result.message,
|
||||
lastCheckedAt: nextLastCheckedAt,
|
||||
isInstalling: false,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
deps.reportInstallError?.({
|
||||
error,
|
||||
message: "Unable to install the desktop app update.",
|
||||
logLabel: "[DesktopUpdater] Failed to install app update",
|
||||
});
|
||||
commit({
|
||||
...state,
|
||||
status: "error",
|
||||
errorMessage: message,
|
||||
isInstalling: false,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getSnapshot: () => cachedSnapshot,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
checkForUpdates,
|
||||
installUpdate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveUpdateCalloutDescriptor,
|
||||
type ResolveUpdateCalloutInput,
|
||||
} from "./resolve-update-callout";
|
||||
|
||||
function input(overrides: Partial<ResolveUpdateCalloutInput> = {}): ResolveUpdateCalloutInput {
|
||||
return {
|
||||
isDesktopApp: true,
|
||||
status: "available",
|
||||
isInstalling: false,
|
||||
availableUpdate: { latestVersion: "1.2.3" },
|
||||
errorMessage: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveUpdateCalloutDescriptor", () => {
|
||||
it("returns null when not running as a desktop app", () => {
|
||||
expect(resolveUpdateCalloutDescriptor(input({ isDesktopApp: false }))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for idle / checking / up-to-date / pending statuses", () => {
|
||||
for (const status of ["idle", "checking", "up-to-date", "pending"] as const) {
|
||||
expect(resolveUpdateCalloutDescriptor(input({ status }))).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("builds an update-available descriptor with changelog + install actions", () => {
|
||||
const descriptor = resolveUpdateCalloutDescriptor(input());
|
||||
|
||||
expect(descriptor).not.toBeNull();
|
||||
expect(descriptor?.id).toBe("desktop-update");
|
||||
expect(descriptor?.priority).toBe(200);
|
||||
expect(descriptor?.testID).toBe("update-callout");
|
||||
expect(descriptor?.title).toBe("Update available");
|
||||
expect(descriptor?.variant).toBe("default");
|
||||
expect(descriptor?.showGiftIcon).toBe(true);
|
||||
expect(descriptor?.body).toEqual({ kind: "available", versionLabel: "v1.2.3" });
|
||||
expect(descriptor?.actions).toEqual([
|
||||
{ role: "changelog", label: "What's new" },
|
||||
{ role: "install", label: "Install & restart", variant: "primary", disabled: false },
|
||||
]);
|
||||
expect(descriptor?.dismissalKey).toBe("desktop-update:available:1.2.3");
|
||||
});
|
||||
|
||||
it("normalizes a leading v in the latest version", () => {
|
||||
const descriptor = resolveUpdateCalloutDescriptor(
|
||||
input({ availableUpdate: { latestVersion: "v2.0.0" } }),
|
||||
);
|
||||
expect(descriptor?.body).toEqual({ kind: "available", versionLabel: "v2.0.0" });
|
||||
});
|
||||
|
||||
it("omits the version label when no latest version is known", () => {
|
||||
const descriptor = resolveUpdateCalloutDescriptor(
|
||||
input({ availableUpdate: { latestVersion: null } }),
|
||||
);
|
||||
expect(descriptor?.body).toEqual({ kind: "available", versionLabel: null });
|
||||
expect(descriptor?.dismissalKey).toBe("desktop-update:available:unknown");
|
||||
});
|
||||
|
||||
it("disables the install action and labels it Installing... while installing", () => {
|
||||
const descriptor = resolveUpdateCalloutDescriptor(
|
||||
input({ status: "installing", isInstalling: true }),
|
||||
);
|
||||
|
||||
expect(descriptor?.title).toBe("Installing update");
|
||||
expect(descriptor?.body).toEqual({ kind: "installing" });
|
||||
expect(descriptor?.showGiftIcon).toBe(false);
|
||||
expect(descriptor?.variant).toBe("default");
|
||||
expect(descriptor?.actions).toEqual([
|
||||
{ role: "changelog", label: "What's new" },
|
||||
{ role: "install", label: "Installing...", variant: "primary", disabled: true },
|
||||
]);
|
||||
expect(descriptor?.dismissalKey).toBe("desktop-update:installing:1.2.3");
|
||||
});
|
||||
|
||||
it("shows a retry action and surfaces the error message on error", () => {
|
||||
const descriptor = resolveUpdateCalloutDescriptor(
|
||||
input({ status: "error", errorMessage: "Download failed", availableUpdate: null }),
|
||||
);
|
||||
|
||||
expect(descriptor?.title).toBe("Update failed");
|
||||
expect(descriptor?.body).toEqual({ kind: "error", message: "Download failed" });
|
||||
expect(descriptor?.variant).toBe("error");
|
||||
expect(descriptor?.showGiftIcon).toBe(false);
|
||||
expect(descriptor?.actions).toEqual([
|
||||
{ role: "changelog", label: "What's new" },
|
||||
{ role: "retry", label: "Retry", variant: "primary" },
|
||||
]);
|
||||
expect(descriptor?.dismissalKey).toBe("desktop-update:error:unknown");
|
||||
});
|
||||
|
||||
it("falls back to a generic error message when none is provided", () => {
|
||||
const descriptor = resolveUpdateCalloutDescriptor(
|
||||
input({ status: "error", errorMessage: null, availableUpdate: null }),
|
||||
);
|
||||
expect(descriptor?.body).toEqual({ kind: "error", message: "Something went wrong." });
|
||||
});
|
||||
|
||||
it("encodes status and version into the dismissal key", () => {
|
||||
expect(
|
||||
resolveUpdateCalloutDescriptor(input({ availableUpdate: { latestVersion: "1.2.4" } }))
|
||||
?.dismissalKey,
|
||||
).toBe("desktop-update:available:1.2.4");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { DesktopAppUpdateStatus } from "@/desktop/updates/use-desktop-app-updater";
|
||||
|
||||
export type UpdateCalloutBody =
|
||||
| { kind: "available"; versionLabel: string | null }
|
||||
| { kind: "installing" }
|
||||
| { kind: "error"; message: string };
|
||||
|
||||
export type UpdateCalloutActionRole = "changelog" | "install" | "retry";
|
||||
|
||||
export interface UpdateCalloutActionDescriptor {
|
||||
role: UpdateCalloutActionRole;
|
||||
label: string;
|
||||
variant?: "primary" | "secondary";
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateCalloutDescriptor {
|
||||
id: "desktop-update";
|
||||
dismissalKey: string;
|
||||
priority: number;
|
||||
title: string;
|
||||
body: UpdateCalloutBody;
|
||||
showGiftIcon: boolean;
|
||||
variant: "default" | "error";
|
||||
actions: UpdateCalloutActionDescriptor[];
|
||||
testID: "update-callout";
|
||||
}
|
||||
|
||||
export interface ResolveUpdateCalloutInput {
|
||||
isDesktopApp: boolean;
|
||||
status: DesktopAppUpdateStatus;
|
||||
isInstalling: boolean;
|
||||
availableUpdate: { latestVersion?: string | null } | null;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
function formatVersionLabel(latestVersion: string | null | undefined): string | null {
|
||||
if (!latestVersion) return null;
|
||||
return `v${latestVersion.replace(/^v/i, "")}`;
|
||||
}
|
||||
|
||||
export function resolveUpdateCalloutDescriptor(
|
||||
input: ResolveUpdateCalloutInput,
|
||||
): UpdateCalloutDescriptor | null {
|
||||
if (!input.isDesktopApp) return null;
|
||||
if (input.status !== "available" && input.status !== "installing" && input.status !== "error") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isError = input.status === "error";
|
||||
const isInstalling = input.isInstalling;
|
||||
const isAvailable = !isInstalling && !isError;
|
||||
|
||||
const latestVersion = input.availableUpdate?.latestVersion ?? null;
|
||||
const dismissalKey = `desktop-update:${input.status}:${latestVersion ?? "unknown"}`;
|
||||
|
||||
let title: string;
|
||||
let body: UpdateCalloutBody;
|
||||
if (isInstalling) {
|
||||
title = "Installing update";
|
||||
body = { kind: "installing" };
|
||||
} else if (isError) {
|
||||
title = "Update failed";
|
||||
body = { kind: "error", message: input.errorMessage ?? "Something went wrong." };
|
||||
} else {
|
||||
title = "Update available";
|
||||
body = { kind: "available", versionLabel: formatVersionLabel(latestVersion) };
|
||||
}
|
||||
|
||||
const actions: UpdateCalloutActionDescriptor[] = [{ role: "changelog", label: "What's new" }];
|
||||
if (isError) {
|
||||
actions.push({ role: "retry", label: "Retry", variant: "primary" });
|
||||
} else {
|
||||
actions.push({
|
||||
role: "install",
|
||||
label: isInstalling ? "Installing..." : "Install & restart",
|
||||
variant: "primary",
|
||||
disabled: isInstalling,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: "desktop-update",
|
||||
dismissalKey,
|
||||
priority: 200,
|
||||
title,
|
||||
body,
|
||||
showGiftIcon: isAvailable,
|
||||
variant: isError ? "error" : "default",
|
||||
actions,
|
||||
testID: "update-callout",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type {
|
||||
DesktopAppUpdateCheckResult,
|
||||
DesktopAppUpdateInstallResult,
|
||||
DesktopReleaseChannel,
|
||||
} from "@/desktop/updates/desktop-updates";
|
||||
import type { DesktopAppUpdaterPort } from "@/desktop/updates/desktop-app-updater";
|
||||
|
||||
export interface FakeDesktopAppUpdaterPort extends DesktopAppUpdaterPort {
|
||||
readonly recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }>;
|
||||
readonly recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }>;
|
||||
nextCheckResult(result: DesktopAppUpdateCheckResult): void;
|
||||
deferNextCheck(): {
|
||||
resolve(result: DesktopAppUpdateCheckResult): void;
|
||||
reject(error: unknown): void;
|
||||
};
|
||||
failNextCheck(error: unknown): void;
|
||||
nextInstallResult(result: DesktopAppUpdateInstallResult): void;
|
||||
failNextInstall(error: unknown): void;
|
||||
}
|
||||
|
||||
type CheckOutcome =
|
||||
| { kind: "result"; result: DesktopAppUpdateCheckResult }
|
||||
| { kind: "error"; error: unknown }
|
||||
| { kind: "deferred"; promise: Promise<DesktopAppUpdateCheckResult> };
|
||||
|
||||
type InstallOutcome =
|
||||
| { kind: "result"; result: DesktopAppUpdateInstallResult }
|
||||
| { kind: "error"; error: unknown };
|
||||
|
||||
function buildCheckResult(
|
||||
overrides: Partial<DesktopAppUpdateCheckResult> = {},
|
||||
): DesktopAppUpdateCheckResult {
|
||||
return {
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
currentVersion: null,
|
||||
latestVersion: null,
|
||||
body: null,
|
||||
date: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildInstallResult(
|
||||
overrides: Partial<DesktopAppUpdateInstallResult> = {},
|
||||
): DesktopAppUpdateInstallResult {
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
message: "Update completed.",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createFakeDesktopAppUpdaterPort(): FakeDesktopAppUpdaterPort {
|
||||
const recordedChecks: Array<{ releaseChannel: DesktopReleaseChannel }> = [];
|
||||
const recordedInstalls: Array<{ releaseChannel: DesktopReleaseChannel }> = [];
|
||||
const checkOutcomes: CheckOutcome[] = [];
|
||||
const installOutcomes: InstallOutcome[] = [];
|
||||
|
||||
return {
|
||||
recordedChecks,
|
||||
recordedInstalls,
|
||||
nextCheckResult(result) {
|
||||
checkOutcomes.push({ kind: "result", result });
|
||||
},
|
||||
deferNextCheck() {
|
||||
let resolve!: (value: DesktopAppUpdateCheckResult) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<DesktopAppUpdateCheckResult>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
checkOutcomes.push({ kind: "deferred", promise });
|
||||
return { resolve, reject };
|
||||
},
|
||||
failNextCheck(error) {
|
||||
checkOutcomes.push({ kind: "error", error });
|
||||
},
|
||||
nextInstallResult(result) {
|
||||
installOutcomes.push({ kind: "result", result });
|
||||
},
|
||||
failNextInstall(error) {
|
||||
installOutcomes.push({ kind: "error", error });
|
||||
},
|
||||
async checkDesktopAppUpdate(input) {
|
||||
recordedChecks.push(input);
|
||||
const outcome = checkOutcomes.shift();
|
||||
if (!outcome) {
|
||||
return buildCheckResult();
|
||||
}
|
||||
if (outcome.kind === "result") {
|
||||
return outcome.result;
|
||||
}
|
||||
if (outcome.kind === "error") {
|
||||
throw outcome.error;
|
||||
}
|
||||
return outcome.promise;
|
||||
},
|
||||
async installDesktopAppUpdate(input) {
|
||||
recordedInstalls.push(input);
|
||||
const outcome = installOutcomes.shift();
|
||||
if (!outcome) {
|
||||
return buildInstallResult();
|
||||
}
|
||||
if (outcome.kind === "result") {
|
||||
return outcome.result;
|
||||
}
|
||||
throw outcome.error;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildFakeCheckResult(
|
||||
overrides: Partial<DesktopAppUpdateCheckResult> = {},
|
||||
): DesktopAppUpdateCheckResult {
|
||||
return buildCheckResult(overrides);
|
||||
}
|
||||
|
||||
export function buildFakeInstallResult(
|
||||
overrides: Partial<DesktopAppUpdateInstallResult> = {},
|
||||
): DesktopAppUpdateInstallResult {
|
||||
return buildInstallResult(overrides);
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { SidebarCalloutSlot } from "@/components/sidebar-callout-slot";
|
||||
import { SidebarCalloutProvider } from "@/contexts/sidebar-callout-context";
|
||||
|
||||
const { theme } = vi.hoisted(() => ({
|
||||
theme: {
|
||||
spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 },
|
||||
iconSize: { sm: 14, md: 18 },
|
||||
borderWidth: { 1: 1 },
|
||||
borderRadius: { md: 6 },
|
||||
fontSize: { xs: 11, sm: 13 },
|
||||
fontWeight: { medium: "500", semibold: "600" },
|
||||
shadow: { md: {} },
|
||||
colors: {
|
||||
surface0: "#000",
|
||||
foreground: "#fff",
|
||||
foregroundMuted: "#aaa",
|
||||
border: "#555",
|
||||
destructive: "#ff4444",
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const asyncStorage = vi.hoisted(() => ({
|
||||
values: new Map<string, string>(),
|
||||
getItem: vi.fn(async (key: string) => asyncStorage.values.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
asyncStorage.values.set(key, value);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorage,
|
||||
}));
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
StyleSheet: {
|
||||
create: (factory: unknown) =>
|
||||
typeof factory === "function" ? (factory as (t: typeof theme) => unknown)(theme) : factory,
|
||||
},
|
||||
useUnistyles: () => ({ theme }),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react-native", () => {
|
||||
const X = (props: Record<string, unknown>) => React.createElement("span", props);
|
||||
const Gift = (props: Record<string, unknown>) =>
|
||||
React.createElement("span", { ...props, "data-icon": "Gift" });
|
||||
return { Gift, X };
|
||||
});
|
||||
|
||||
vi.mock("@/utils/open-external-url", () => ({
|
||||
openExternalUrl: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const updaterState = vi.hoisted(() => ({
|
||||
value: {
|
||||
isDesktopApp: true,
|
||||
status: "available" as
|
||||
| "idle"
|
||||
| "checking"
|
||||
| "pending"
|
||||
| "up-to-date"
|
||||
| "available"
|
||||
| "installing"
|
||||
| "installed"
|
||||
| "error",
|
||||
availableUpdate: {
|
||||
hasUpdate: true,
|
||||
readyToInstall: true,
|
||||
currentVersion: "1.2.2",
|
||||
latestVersion: "1.2.3",
|
||||
body: null,
|
||||
date: null,
|
||||
} as unknown,
|
||||
errorMessage: null as string | null,
|
||||
lastCheckedAt: null as number | null,
|
||||
isChecking: false,
|
||||
isInstalling: false,
|
||||
statusText: "",
|
||||
checkForUpdates: vi.fn(async () => null),
|
||||
installUpdate: vi.fn(async () => null),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/updates/use-desktop-app-updater", () => ({
|
||||
useDesktopAppUpdater: () => updaterState.value,
|
||||
}));
|
||||
|
||||
vi.stubGlobal("React", React);
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
|
||||
import { UpdateCalloutSource } from "./update-callout-source";
|
||||
|
||||
function resetUpdaterState(): void {
|
||||
updaterState.value = {
|
||||
isDesktopApp: true,
|
||||
status: "available",
|
||||
availableUpdate: {
|
||||
hasUpdate: true,
|
||||
readyToInstall: true,
|
||||
currentVersion: "1.2.2",
|
||||
latestVersion: "1.2.3",
|
||||
body: null,
|
||||
date: null,
|
||||
},
|
||||
errorMessage: null,
|
||||
lastCheckedAt: null,
|
||||
isChecking: false,
|
||||
isInstalling: false,
|
||||
statusText: "",
|
||||
checkForUpdates: vi.fn(async () => null),
|
||||
installUpdate: vi.fn(async () => null),
|
||||
};
|
||||
}
|
||||
|
||||
function UpdateCalloutHarness({ withSlot = true }: { withSlot?: boolean }) {
|
||||
return (
|
||||
<SidebarCalloutProvider>
|
||||
<UpdateCalloutSource />
|
||||
{withSlot ? <SidebarCalloutSlot /> : null}
|
||||
</SidebarCalloutProvider>
|
||||
);
|
||||
}
|
||||
|
||||
async function renderHarness(root: Root, withSlot = true): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(<UpdateCalloutHarness withSlot={withSlot} />);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe("UpdateCalloutSource", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
resetUpdaterState();
|
||||
asyncStorage.values.clear();
|
||||
asyncStorage.getItem.mockClear();
|
||||
asyncStorage.setItem.mockClear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
});
|
||||
|
||||
it("registers an update-available sidebar callout with changelog + install actions", async () => {
|
||||
await renderHarness(root!);
|
||||
|
||||
expect(container?.querySelector('[data-testid="update-callout"]')).not.toBeNull();
|
||||
expect(container?.textContent).toContain("Update available");
|
||||
expect(container?.textContent).toContain("1.2.3");
|
||||
expect(container?.textContent).toContain(
|
||||
"Upgrading the app will stop running agents and close terminal sessions.",
|
||||
);
|
||||
expect(container?.querySelector('[data-icon="Gift"]')).not.toBeNull();
|
||||
expect(
|
||||
container?.querySelector('[data-testid="update-callout-action-0"]')?.textContent,
|
||||
).toContain("What's new");
|
||||
expect(
|
||||
container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent,
|
||||
).toContain("Install & restart");
|
||||
});
|
||||
|
||||
it("renders only through the sidebar slot", async () => {
|
||||
await renderHarness(root!, false);
|
||||
|
||||
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("disables the install action and shows Installing... while installing", async () => {
|
||||
updaterState.value = {
|
||||
...updaterState.value,
|
||||
status: "installing",
|
||||
isInstalling: true,
|
||||
};
|
||||
await renderHarness(root!);
|
||||
|
||||
expect(
|
||||
container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent,
|
||||
).toContain("Installing");
|
||||
});
|
||||
|
||||
it("shows a retry action on error and surfaces the error message", async () => {
|
||||
updaterState.value = {
|
||||
...updaterState.value,
|
||||
status: "error",
|
||||
errorMessage: "Download failed",
|
||||
availableUpdate: null,
|
||||
};
|
||||
await renderHarness(root!);
|
||||
|
||||
expect(container?.textContent).toContain("Update failed");
|
||||
expect(container?.textContent).toContain("Download failed");
|
||||
expect(
|
||||
container?.querySelector('[data-testid="update-callout-action-1"]')?.textContent,
|
||||
).toContain("Retry");
|
||||
});
|
||||
|
||||
it("renders nothing when not running as a desktop app", async () => {
|
||||
updaterState.value = { ...updaterState.value, isDesktopApp: false };
|
||||
await renderHarness(root!);
|
||||
|
||||
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when status is idle / checking / up-to-date / pending", async () => {
|
||||
for (const status of ["idle", "checking", "up-to-date", "pending"] as const) {
|
||||
resetUpdaterState();
|
||||
updaterState.value = { ...updaterState.value, status };
|
||||
const innerContainer = document.createElement("div");
|
||||
document.body.appendChild(innerContainer);
|
||||
const innerRoot = createRoot(innerContainer);
|
||||
await act(async () => {
|
||||
innerRoot.render(<UpdateCalloutHarness />);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(innerContainer.querySelector('[data-testid="update-callout"]')).toBeNull();
|
||||
act(() => {
|
||||
innerRoot.unmount();
|
||||
});
|
||||
innerContainer.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("dismisses the registered update callout", async () => {
|
||||
await renderHarness(root!);
|
||||
|
||||
const dismiss = container?.querySelector(
|
||||
'[data-testid="update-callout-dismiss"]',
|
||||
) as HTMLElement | null;
|
||||
expect(dismiss).not.toBeNull();
|
||||
act(() => {
|
||||
dismiss?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
expect(container?.querySelector('[data-testid="update-callout"]')).toBeNull();
|
||||
expect(asyncStorage.setItem).toHaveBeenCalledWith(
|
||||
"@paseo:sidebar-callout-dismissals",
|
||||
JSON.stringify(["desktop-update:available:1.2.3"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
SidebarCalloutDescriptionText,
|
||||
} from "@/components/sidebar-callout";
|
||||
import { useSidebarCallouts } from "@/contexts/sidebar-callout-context";
|
||||
import {
|
||||
resolveUpdateCalloutDescriptor,
|
||||
type UpdateCalloutActionDescriptor,
|
||||
type UpdateCalloutBody,
|
||||
} from "@/desktop/updates/resolve-update-callout";
|
||||
import { useDesktopAppUpdater } from "@/desktop/updates/use-desktop-app-updater";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
@@ -13,47 +18,22 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
const CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const CHANGELOG_URL = "https://paseo.sh/changelog";
|
||||
|
||||
function resolveUpdateCalloutTitle(args: { isInstalling: boolean; isError: boolean }): string {
|
||||
if (args.isInstalling) return "Installing update";
|
||||
if (args.isError) return "Update failed";
|
||||
return "Update available";
|
||||
function renderBody(body: UpdateCalloutBody): ReactNode {
|
||||
if (body.kind === "installing") return "Installing and restarting...";
|
||||
if (body.kind === "error") return body.message;
|
||||
return <UpdateAvailableDescription versionLabel={body.versionLabel ?? undefined} />;
|
||||
}
|
||||
|
||||
function resolveUpdateCalloutDescription(args: {
|
||||
isInstalling: boolean;
|
||||
isError: boolean;
|
||||
errorMessage: string | null;
|
||||
latestVersion: string | undefined;
|
||||
}): ReactNode {
|
||||
if (args.isInstalling) return "Installing and restarting...";
|
||||
if (args.isError) return args.errorMessage ?? "Something went wrong.";
|
||||
if (args.latestVersion) {
|
||||
return (
|
||||
<UpdateAvailableDescription versionLabel={`v${args.latestVersion.replace(/^v/i, "")}`} />
|
||||
);
|
||||
}
|
||||
return <UpdateAvailableDescription />;
|
||||
}
|
||||
|
||||
function buildUpdateCalloutActions(args: {
|
||||
isInstalling: boolean;
|
||||
isError: boolean;
|
||||
openChangelog: () => void;
|
||||
retry: () => void;
|
||||
install: () => void;
|
||||
}): SidebarCalloutAction[] {
|
||||
const actions: SidebarCalloutAction[] = [{ label: "What's new", onPress: args.openChangelog }];
|
||||
if (args.isError) {
|
||||
actions.push({ label: "Retry", onPress: args.retry, variant: "primary" });
|
||||
} else {
|
||||
actions.push({
|
||||
label: args.isInstalling ? "Installing..." : "Install & restart",
|
||||
onPress: args.install,
|
||||
variant: "primary",
|
||||
disabled: args.isInstalling,
|
||||
});
|
||||
}
|
||||
return actions;
|
||||
function materializeActions(
|
||||
actions: readonly UpdateCalloutActionDescriptor[],
|
||||
handlers: { changelog: () => void; install: () => void; retry: () => void },
|
||||
): SidebarCalloutAction[] {
|
||||
return actions.map((action) => ({
|
||||
label: action.label,
|
||||
onPress: handlers[action.role],
|
||||
variant: action.variant,
|
||||
disabled: action.disabled,
|
||||
}));
|
||||
}
|
||||
|
||||
export function UpdateCalloutSource() {
|
||||
@@ -96,46 +76,34 @@ export function UpdateCalloutSource() {
|
||||
}, [isDesktopApp, checkForUpdates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
if (status !== "available" && status !== "installing" && status !== "error") {
|
||||
return;
|
||||
}
|
||||
|
||||
const isError = status === "error";
|
||||
const isAvailable = !isInstalling && !isError;
|
||||
|
||||
const title = resolveUpdateCalloutTitle({ isInstalling, isError });
|
||||
const description = resolveUpdateCalloutDescription({
|
||||
const descriptor = resolveUpdateCalloutDescriptor({
|
||||
isDesktopApp,
|
||||
status,
|
||||
isInstalling,
|
||||
isError,
|
||||
availableUpdate,
|
||||
errorMessage,
|
||||
latestVersion: availableUpdate?.latestVersion ?? undefined,
|
||||
});
|
||||
const actions = buildUpdateCalloutActions({
|
||||
isInstalling,
|
||||
isError,
|
||||
openChangelog,
|
||||
retry,
|
||||
install,
|
||||
});
|
||||
if (!descriptor) return;
|
||||
|
||||
return callouts.show({
|
||||
id: "desktop-update",
|
||||
dismissalKey: `desktop-update:${status}:${availableUpdate?.latestVersion ?? "unknown"}`,
|
||||
priority: 200,
|
||||
title,
|
||||
description,
|
||||
icon: isAvailable ? (
|
||||
id: descriptor.id,
|
||||
dismissalKey: descriptor.dismissalKey,
|
||||
priority: descriptor.priority,
|
||||
title: descriptor.title,
|
||||
description: renderBody(descriptor.body),
|
||||
icon: descriptor.showGiftIcon ? (
|
||||
<Gift size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
) : undefined,
|
||||
variant: isError ? "error" : "default",
|
||||
actions,
|
||||
testID: "update-callout",
|
||||
variant: descriptor.variant,
|
||||
actions: materializeActions(descriptor.actions, {
|
||||
changelog: openChangelog,
|
||||
install,
|
||||
retry,
|
||||
}),
|
||||
testID: descriptor.testID,
|
||||
});
|
||||
}, [
|
||||
availableUpdate?.latestVersion,
|
||||
availableUpdate,
|
||||
callouts,
|
||||
errorMessage,
|
||||
install,
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React from "react";
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDesktopAppUpdater } from "./use-desktop-app-updater";
|
||||
|
||||
const desktopUpdatesMock = vi.hoisted(() => ({
|
||||
shouldShowDesktopUpdateSection: vi.fn(() => true),
|
||||
checkDesktopAppUpdate: vi.fn(async () => ({
|
||||
hasUpdate: false,
|
||||
readyToInstall: false,
|
||||
latestVersion: null,
|
||||
})),
|
||||
installDesktopAppUpdate: vi.fn(async () => ({
|
||||
installed: false,
|
||||
message: null,
|
||||
})),
|
||||
formatVersionWithPrefix: vi.fn((version: string | null) => version ?? "\u2014"),
|
||||
}));
|
||||
|
||||
const settingsState = vi.hoisted(() => ({
|
||||
releaseChannel: "stable" as "stable" | "beta",
|
||||
}));
|
||||
|
||||
vi.mock("@/desktop/updates/desktop-updates", () => desktopUpdatesMock);
|
||||
|
||||
vi.mock("@/desktop/settings/desktop-settings", () => ({
|
||||
useDesktopSettings: () => ({
|
||||
settings: {
|
||||
releaseChannel: settingsState.releaseChannel,
|
||||
daemon: { manageBuiltInDaemon: true, keepRunningAfterQuit: true },
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
updateSettings: vi.fn(async () => {}),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/contexts/toast-context", () => ({
|
||||
useToast: () => ({ error: vi.fn(), show: vi.fn(), copied: vi.fn() }),
|
||||
}));
|
||||
|
||||
function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderUpdaterHook() {
|
||||
const queryClient = createQueryClient();
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
|
||||
return renderHook(() => useDesktopAppUpdater(), { wrapper });
|
||||
}
|
||||
|
||||
describe("useDesktopAppUpdater", () => {
|
||||
beforeEach(() => {
|
||||
settingsState.releaseChannel = "stable";
|
||||
desktopUpdatesMock.checkDesktopAppUpdate.mockClear();
|
||||
desktopUpdatesMock.installDesktopAppUpdate.mockClear();
|
||||
});
|
||||
|
||||
it("uses the effective desktop release channel when checking for updates", async () => {
|
||||
settingsState.releaseChannel = "beta";
|
||||
|
||||
renderUpdaterHook();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(desktopUpdatesMock.checkDesktopAppUpdate).toHaveBeenCalledWith({
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the effective desktop release channel when installing updates", async () => {
|
||||
settingsState.releaseChannel = "beta";
|
||||
const { result } = renderUpdaterHook();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.installUpdate();
|
||||
});
|
||||
|
||||
expect(desktopUpdatesMock.installDesktopAppUpdate).toHaveBeenCalledWith({
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import {
|
||||
checkDesktopAppUpdate,
|
||||
formatVersionWithPrefix,
|
||||
@@ -10,18 +9,14 @@ import {
|
||||
} from "@/desktop/updates/desktop-updates";
|
||||
import { useDesktopSettings } from "@/desktop/settings/desktop-settings";
|
||||
import { useDesktopIpcErrorReporter } from "@/desktop/hooks/desktop-ipc-error";
|
||||
import {
|
||||
PENDING_RECHECK_MS,
|
||||
createDesktopAppUpdater,
|
||||
formatStatusText,
|
||||
type DesktopAppUpdateStatus,
|
||||
} from "@/desktop/updates/desktop-app-updater";
|
||||
|
||||
export type DesktopAppUpdateStatus =
|
||||
| "idle"
|
||||
| "checking"
|
||||
| "pending"
|
||||
| "up-to-date"
|
||||
| "available"
|
||||
| "installing"
|
||||
| "installed"
|
||||
| "error";
|
||||
|
||||
const PENDING_RECHECK_MS = 10_000;
|
||||
export type { DesktopAppUpdateStatus };
|
||||
|
||||
export interface UseDesktopAppUpdaterReturn {
|
||||
isDesktopApp: boolean;
|
||||
@@ -36,142 +31,57 @@ export interface UseDesktopAppUpdaterReturn {
|
||||
installUpdate: () => Promise<DesktopAppUpdateInstallResult | null>;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && typeof error.message === "string") {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function formatStatusText(input: {
|
||||
status: DesktopAppUpdateStatus;
|
||||
availableUpdate: DesktopAppUpdateCheckResult | null;
|
||||
installMessage: string | null;
|
||||
}): string {
|
||||
const { status, availableUpdate, installMessage } = input;
|
||||
|
||||
if (status === "checking") {
|
||||
return "Checking for app updates...";
|
||||
}
|
||||
|
||||
if (status === "installing") {
|
||||
return "Installing app update...";
|
||||
}
|
||||
|
||||
if (status === "up-to-date") {
|
||||
return "App is up to date.";
|
||||
}
|
||||
|
||||
if (status === "pending") {
|
||||
return "We'll let you know when the update is ready.";
|
||||
}
|
||||
|
||||
if (status === "available") {
|
||||
if (availableUpdate?.latestVersion) {
|
||||
return `Update ready: ${formatVersionWithPrefix(availableUpdate.latestVersion)}`;
|
||||
}
|
||||
return "An app update is ready to install.";
|
||||
}
|
||||
|
||||
if (status === "installed") {
|
||||
return installMessage ?? "App update installed. Restart required.";
|
||||
}
|
||||
|
||||
if (status === "error") {
|
||||
return "Failed to update app.";
|
||||
}
|
||||
|
||||
return "Update status has not been checked yet.";
|
||||
}
|
||||
|
||||
export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
const isDesktopApp = shouldShowDesktopUpdateSection();
|
||||
const { settings: desktopSettings } = useDesktopSettings();
|
||||
const releaseChannel = desktopSettings.releaseChannel;
|
||||
const reportError = useDesktopIpcErrorReporter();
|
||||
const requestVersionRef = useRef(0);
|
||||
const [status, setStatus] = useState<DesktopAppUpdateStatus>("idle");
|
||||
const [availableUpdate, setAvailableUpdate] = useState<DesktopAppUpdateCheckResult | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [installMessage, setInstallMessage] = useState<string | null>(null);
|
||||
const [lastCheckedAt, setLastCheckedAt] = useState<number | null>(null);
|
||||
const { mutateAsync: installAppUpdate, isPending: isInstallingAppUpdate } = useMutation<
|
||||
DesktopAppUpdateInstallResult,
|
||||
Error
|
||||
>({
|
||||
mutationFn: () => installDesktopAppUpdate({ releaseChannel }),
|
||||
onError: (error) => {
|
||||
reportError({
|
||||
error,
|
||||
message: "Unable to install the desktop app update.",
|
||||
logLabel: "[DesktopUpdater] Failed to install app update",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updater = useMemo(
|
||||
() =>
|
||||
createDesktopAppUpdater({
|
||||
port: {
|
||||
checkDesktopAppUpdate,
|
||||
installDesktopAppUpdate,
|
||||
},
|
||||
now: () => Date.now(),
|
||||
reportInstallError: reportError,
|
||||
}),
|
||||
[reportError],
|
||||
);
|
||||
|
||||
const snapshot = useSyncExternalStore(
|
||||
updater.subscribe,
|
||||
updater.getSnapshot,
|
||||
updater.getSnapshot,
|
||||
);
|
||||
|
||||
const checkForUpdates = useCallback(
|
||||
async (options: { silent?: boolean } = {}) => {
|
||||
if (!isDesktopApp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestVersion = requestVersionRef.current + 1;
|
||||
requestVersionRef.current = requestVersion;
|
||||
|
||||
if (!options.silent) {
|
||||
setStatus("checking");
|
||||
}
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const result = await checkDesktopAppUpdate({ releaseChannel });
|
||||
if (requestVersion !== requestVersionRef.current) {
|
||||
return result;
|
||||
}
|
||||
|
||||
setInstallMessage(null);
|
||||
setLastCheckedAt(Date.now());
|
||||
|
||||
if (result.readyToInstall) {
|
||||
setAvailableUpdate(result);
|
||||
setStatus("available");
|
||||
} else if (result.hasUpdate) {
|
||||
setAvailableUpdate(null);
|
||||
setStatus("pending");
|
||||
} else {
|
||||
setAvailableUpdate(null);
|
||||
setStatus("up-to-date");
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (requestVersion !== requestVersionRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = getErrorMessage(error);
|
||||
if (options.silent) {
|
||||
console.warn("[DesktopUpdater] Silent update check failed", message);
|
||||
} else {
|
||||
setStatus("error");
|
||||
setErrorMessage(message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return updater.checkForUpdates({ releaseChannel, silent: options.silent });
|
||||
},
|
||||
[isDesktopApp, releaseChannel],
|
||||
[isDesktopApp, releaseChannel, updater],
|
||||
);
|
||||
|
||||
const installUpdate = useCallback(async () => {
|
||||
if (!isDesktopApp) {
|
||||
return null;
|
||||
}
|
||||
return updater.installUpdate({ releaseChannel });
|
||||
}, [isDesktopApp, releaseChannel, updater]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktopApp) {
|
||||
return;
|
||||
}
|
||||
|
||||
void checkForUpdates({ silent: true });
|
||||
}, [checkForUpdates, isDesktopApp]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktopApp || status !== "pending") {
|
||||
if (!isDesktopApp || snapshot.status !== "pending") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -182,52 +92,22 @@ export function useDesktopAppUpdater(): UseDesktopAppUpdaterReturn {
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [checkForUpdates, isDesktopApp, status]);
|
||||
|
||||
const installUpdate = useCallback(async () => {
|
||||
if (!isDesktopApp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setStatus("installing");
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const result = await installAppUpdate();
|
||||
setLastCheckedAt(Date.now());
|
||||
|
||||
if (result.installed) {
|
||||
setAvailableUpdate(null);
|
||||
setInstallMessage(result.message);
|
||||
setStatus("installed");
|
||||
} else {
|
||||
setAvailableUpdate(null);
|
||||
setInstallMessage(result.message);
|
||||
setStatus("up-to-date");
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
setStatus("error");
|
||||
setErrorMessage(message);
|
||||
return null;
|
||||
}
|
||||
}, [installAppUpdate, isDesktopApp]);
|
||||
}, [checkForUpdates, isDesktopApp, snapshot.status]);
|
||||
|
||||
return {
|
||||
isDesktopApp,
|
||||
status,
|
||||
status: snapshot.status,
|
||||
statusText: formatStatusText({
|
||||
status,
|
||||
availableUpdate,
|
||||
installMessage,
|
||||
status: snapshot.status,
|
||||
availableUpdate: snapshot.availableUpdate,
|
||||
installMessage: snapshot.installMessage,
|
||||
formatVersion: formatVersionWithPrefix,
|
||||
}),
|
||||
availableUpdate,
|
||||
errorMessage,
|
||||
lastCheckedAt,
|
||||
isChecking: status === "checking",
|
||||
isInstalling: status === "installing" || isInstallingAppUpdate,
|
||||
availableUpdate: snapshot.availableUpdate,
|
||||
errorMessage: snapshot.errorMessage,
|
||||
lastCheckedAt: snapshot.lastCheckedAt,
|
||||
isChecking: snapshot.isChecking,
|
||||
isInstalling: snapshot.isInstalling,
|
||||
checkForUpdates,
|
||||
installUpdate,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user