diff --git a/desktop/package.json b/desktop/package.json index 6726bfdcad..974d09dd38 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -14,7 +14,7 @@ "lint": "biome lint .", "check": "biome check . && pnpm check:file-sizes && pnpm check:px-text && pnpm check:pubkey-truncation", "format": "biome format --write .", - "test": "node --import ./test-loader.mjs --experimental-strip-types --test \"src/**/*.test.mjs\"", + "test": "node --import ./test-loader.mjs --experimental-strip-types --experimental-test-module-mocks --test \"src/**/*.test.mjs\"", "preview": "vite preview", "tauri": "tauri", "test:e2e": "pnpm build:e2e && playwright test", diff --git a/desktop/src/shared/api/hooks.ts b/desktop/src/shared/api/hooks.ts index 8902175f0d..f075f3194d 100644 --- a/desktop/src/shared/api/hooks.ts +++ b/desktop/src/shared/api/hooks.ts @@ -7,5 +7,14 @@ export function useIdentityQuery() { queryKey: ["identity"], queryFn: getIdentity, staleTime: Number.POSITIVE_INFINITY, + // A failure here means "no native identity backend" (e.g. web deployment + // outside Tauri) — retrying can't change that. This also sidesteps a + // query-core gotcha: the default queryClient's networkMode: "always" only + // bypasses the *online* check in the retryer, not focusManager.isFocused(). + // If the window isn't OS-focused at the moment the single default retry + // fires, canContinue() returns false and the retryer calls pause() — + // which then waits indefinitely for a focus/visibility event, leaving + // this query stuck on fetchStatus "paused" forever instead of "error". + retry: false, }); } diff --git a/desktop/src/shared/api/tauriIdentity.available.test.mjs b/desktop/src/shared/api/tauriIdentity.available.test.mjs new file mode 100644 index 0000000000..eba9fa36ce --- /dev/null +++ b/desktop/src/shared/api/tauriIdentity.available.test.mjs @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Split into its own file (not merged with tauriIdentity.test.mjs) because +// node's per-process ESM module cache means mixing an "isTauri: () => true" +// mock with an "isTauri: () => false" mock in the same test file causes +// later imports of tauriIdentity.ts to return the first test's cached module +// instance rather than re-evaluating against the new mock. node --test runs +// each matched file in its own process, which sidesteps that entirely. + +test("getIdentity calls the native bridge when Tauri is available", async (t) => { + t.mock.module("@tauri-apps/api/core", { + namedExports: { isTauri: () => true }, + }); + const invokeTauriFn = t.mock.fn(async () => ({ + pubkey: "abc", + display_name: "Test", + })); + t.mock.module("@/shared/api/tauri", { + namedExports: { invokeTauri: invokeTauriFn }, + }); + + const { getIdentity } = await import("@/shared/api/tauriIdentity"); + + const identity = await getIdentity(); + assert.equal(identity.pubkey, "abc"); + assert.equal(invokeTauriFn.mock.calls.length, 1); + assert.equal(invokeTauriFn.mock.calls[0].arguments[0], "get_identity"); +}); diff --git a/desktop/src/shared/api/tauriIdentity.test.mjs b/desktop/src/shared/api/tauriIdentity.test.mjs new file mode 100644 index 0000000000..2d095feccd --- /dev/null +++ b/desktop/src/shared/api/tauriIdentity.test.mjs @@ -0,0 +1,64 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Regression coverage for the standalone-web splash-screen hang: get_identity +// (and its siblings) must never reach invokeTauri when there is no Tauri +// runtime — reaching it produced a raw TypeError deep inside +// @tauri-apps/api/core's invoke(), and combined with the identity query's +// retry policy, left the query stuck on fetchStatus "paused" forever instead +// of settling to "error". These tests assert the guard short-circuits before +// any native call is attempted. +// +// Each test mocks fresh via its own TestContext (`t.mock.module`), which +// auto-restores at test end — a shared/global `mock.module` call here hits +// "module is already mocked" on the second test, since the mock registry is +// process-global and outlives a manual `.restore()` call made mid-run. +// +// The "Tauri is available" happy-path case lives in +// tauriIdentity.available.test.mjs, a separate file — see that file's +// comment for why it can't share a process with these "unavailable" tests. + +test("getIdentity rejects without invoking the native bridge when Tauri is unavailable", async (t) => { + t.mock.module("@tauri-apps/api/core", { + namedExports: { isTauri: () => false }, + }); + const invokeTauriFn = t.mock.fn(); + t.mock.module("@/shared/api/tauri", { + namedExports: { invokeTauri: invokeTauriFn }, + }); + + const { getIdentity } = await import("@/shared/api/tauriIdentity"); + + await assert.rejects(() => getIdentity(), /no native identity backend/); + assert.equal( + invokeTauriFn.mock.calls.length, + 0, + "invokeTauri must not be called when Tauri is unavailable", + ); +}); + +for (const [name, fn] of [ + ["getNsec", (m) => m.getNsec()], + ["importIdentity", (m) => m.importIdentity("nsec1test")], + ["persistCurrentIdentity", (m) => m.persistCurrentIdentity()], + ["signOut", (m) => m.signOut()], +]) { + test(`${name} rejects without invoking the native bridge when Tauri is unavailable`, async (t) => { + t.mock.module("@tauri-apps/api/core", { + namedExports: { isTauri: () => false }, + }); + const invokeTauriFn = t.mock.fn(); + t.mock.module("@/shared/api/tauri", { + namedExports: { invokeTauri: invokeTauriFn }, + }); + + const module = await import("@/shared/api/tauriIdentity"); + + await assert.rejects(() => fn(module)); + assert.equal( + invokeTauriFn.mock.calls.length, + 0, + `invokeTauri must not be called by ${name} when Tauri is unavailable`, + ); + }); +} diff --git a/desktop/src/shared/api/tauriIdentity.ts b/desktop/src/shared/api/tauriIdentity.ts index e6056a4a58..d2c209005c 100644 --- a/desktop/src/shared/api/tauriIdentity.ts +++ b/desktop/src/shared/api/tauriIdentity.ts @@ -1,3 +1,5 @@ +import { isTauri } from "@tauri-apps/api/core"; + import { invokeTauri } from "@/shared/api/tauri"; import type { Identity } from "@/shared/api/types"; @@ -19,21 +21,39 @@ function fromRawIdentity(raw: RawIdentity): Identity { }; } +// Web deployments serve this same bundle outside the Tauri shell, where +// there is no native keychain-backed identity at all. Without this guard, +// get_identity fell through to invokeTauri, which throws deep inside +// @tauri-apps/api/core's invoke() — the identity query never settled to a +// clean "error" (it hung on "pending"/"paused" indefinitely), which kept +// useMachineOnboardingState() stuck on the "blocking" stage forever. Failing +// fast here lets identityQuery.status reach "error" immediately, which +// machineOnboarding.ts already treats as a signal to proceed to "ready". +function requireTauri(command: string): void { + if (!isTauri()) { + throw new Error(`${command}: no native identity backend in web mode`); + } +} + export async function getIdentity(): Promise { + requireTauri("get_identity"); return fromRawIdentity(await invokeTauri("get_identity")); } export async function getNsec(): Promise { + requireTauri("get_nsec"); return invokeTauri("get_nsec"); } export async function importIdentity(nsec: string): Promise { + requireTauri("import_identity"); return fromRawIdentity( await invokeTauri("import_identity", { nsec }), ); } export async function persistCurrentIdentity(): Promise { + requireTauri("persist_current_identity"); return fromRawIdentity( await invokeTauri("persist_current_identity"), ); @@ -47,5 +67,6 @@ export async function persistCurrentIdentity(): Promise { * state until the process exits and only handle errors (e.g. display a toast). */ export async function signOut(): Promise { + requireTauri("sign_out"); await invokeTauri("sign_out"); }