From 47bd4718cd796d4f0004aef2fc20a70bb41b6b46 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 15:45:15 +0100 Subject: [PATCH] test(wallet): cover WalletAction status/disconnect + availableToStake --- tests/actions/walletStatusDisconnect.test.ts | 273 +++++++++++++++++++ tests/libs/availableToStake.test.ts | 47 ++++ 2 files changed, 320 insertions(+) create mode 100644 tests/actions/walletStatusDisconnect.test.ts create mode 100644 tests/libs/availableToStake.test.ts diff --git a/tests/actions/walletStatusDisconnect.test.ts b/tests/actions/walletStatusDisconnect.test.ts new file mode 100644 index 00000000..f05737b4 --- /dev/null +++ b/tests/actions/walletStatusDisconnect.test.ts @@ -0,0 +1,273 @@ +import {describe, test, expect, beforeEach, afterEach, vi} from "vitest"; + +// Hermetic: no real daemon, tab, descriptor file, or spawn. Control the +// descriptor/pid primitives and the session client (mirrors walletConnect.test.ts). +vi.mock("../../src/lib/wallet/sessionDescriptor", () => ({ + descriptorPath: vi.fn(() => "/tmp/wallet-session.json"), + readDescriptor: vi.fn(), + removeDescriptor: vi.fn(), + isPidAlive: vi.fn(), +})); +vi.mock("../../src/lib/wallet/sessionClient", () => ({ + WalletSessionClient: vi.fn(), +})); + +import {WalletAction} from "../../src/commands/wallet/WalletAction"; +import {readDescriptor, removeDescriptor, isPidAlive} from "../../src/lib/wallet/sessionDescriptor"; +import {WalletSessionClient} from "../../src/lib/wallet/sessionClient"; +import {HEARTBEAT_DEAD_MS} from "../../src/lib/wallet/sessionConstants"; + +const ADDRESS = "0xConnected0000000000000000000000000000001"; + +const DESCRIPTOR: any = { + version: 1, + pid: 4242, + port: 7, + token: "tok", + address: ADDRESS, + chainId: 4221, + network: "testnet-bradbury", + rpcUrl: "https://rpc.example", + createdAt: Date.now() - 5 * 60_000, + lastUsed: Date.now() - 2 * 60_000, +}; + +describe("WalletAction.status", () => { + let action: WalletAction; + let logInfo: any; + let logWarning: any; + let log: any; + let savedExitCode: number | string | undefined; + + beforeEach(() => { + vi.mocked(readDescriptor).mockReset(); + vi.mocked(removeDescriptor).mockReset(); + vi.mocked(isPidAlive).mockReset(); + vi.mocked(WalletSessionClient).mockReset(); + + savedExitCode = process.exitCode; + process.exitCode = 0; + + action = new WalletAction(); + log = vi.spyOn(action as any, "log").mockImplementation(() => {}); + logInfo = vi.spyOn(action as any, "logInfo").mockImplementation(() => {}); + logWarning = vi.spyOn(action as any, "logWarning").mockImplementation(() => {}); + }); + afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = savedExitCode; + }); + + test("no descriptor → 'No active wallet session.' and exit code 1", async () => { + vi.mocked(readDescriptor).mockReturnValue(null); + + await action.status(); + + expect(logInfo).toHaveBeenCalledWith("No active wallet session."); + expect(process.exitCode).toBe(1); + expect(WalletSessionClient).not.toHaveBeenCalled(); + }); + + test("live connected session → prints the status object, exit code 0", async () => { + vi.mocked(readDescriptor).mockReturnValue(DESCRIPTOR); + vi.mocked(isPidAlive).mockReturnValue(true); + const client = { + ping: vi.fn().mockResolvedValue(true), + state: vi.fn().mockResolvedValue({ + connected: true, + address: ADDRESS, + chainId: 4221, + url: "http://127.0.0.1:7/gl-wallet#s=tok", + queuedCount: 0, + createdAt: DESCRIPTOR.createdAt, + lastPagePollAt: Date.now(), + }), + }; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + + await action.status(); + + expect(log).toHaveBeenCalledWith( + "Wallet session:", + expect.objectContaining({ + status: "connected", + address: ADDRESS, + network: DESCRIPTOR.network, + chainId: 4221, + port: DESCRIPTOR.port, + url: "http://127.0.0.1:7/gl-wallet#s=tok", + tabHeartbeat: "fresh", + queuedTransactions: 0, + }), + ); + expect(process.exitCode).toBe(0); + expect(removeDescriptor).not.toHaveBeenCalled(); + }); + + test("live-but-connecting session (not yet connected) → status 'connecting', exit code 1", async () => { + vi.mocked(readDescriptor).mockReturnValue({...DESCRIPTOR, address: null}); + vi.mocked(isPidAlive).mockReturnValue(true); + const client = { + ping: vi.fn().mockResolvedValue(true), + state: vi.fn().mockResolvedValue({ + connected: false, + address: null, + chainId: 4221, + url: "http://127.0.0.1:7/gl-wallet#s=tok", + queuedCount: 0, + createdAt: DESCRIPTOR.createdAt, + lastPagePollAt: Date.now(), + }), + }; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + + await action.status(); + + expect(log).toHaveBeenCalledWith( + "Wallet session:", + expect.objectContaining({status: "connecting", address: "(not connected)"}), + ); + expect(process.exitCode).toBe(1); + }); + + test("connected but stale tab heartbeat → reports heartbeat stale (still connected, exit 0)", async () => { + vi.mocked(readDescriptor).mockReturnValue(DESCRIPTOR); + vi.mocked(isPidAlive).mockReturnValue(true); + const client = { + ping: vi.fn().mockResolvedValue(true), + state: vi.fn().mockResolvedValue({ + connected: true, + address: ADDRESS, + chainId: 4221, + url: "http://127.0.0.1:7/gl-wallet#s=tok", + queuedCount: 2, + createdAt: DESCRIPTOR.createdAt, + lastPagePollAt: Date.now() - (HEARTBEAT_DEAD_MS + 30_000), + }), + }; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + + await action.status(); + + expect(log).toHaveBeenCalledWith( + "Wallet session:", + expect.objectContaining({ + tabHeartbeat: expect.stringMatching(/stale/), + queuedTransactions: 2, + }), + ); + expect(process.exitCode).toBe(0); + }); + + test("stale descriptor (pid dead) → warns + cleans up, exit code 1, never reads state", async () => { + vi.mocked(readDescriptor).mockReturnValue(DESCRIPTOR); + vi.mocked(isPidAlive).mockReturnValue(false); + const client = { + ping: vi.fn().mockResolvedValue(true), + state: vi.fn(), + }; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + + await action.status(); + + expect(logWarning).toHaveBeenCalledWith(expect.stringMatching(/stale/i)); + expect(removeDescriptor).toHaveBeenCalledWith("/tmp/wallet-session.json"); + expect(process.exitCode).toBe(1); + expect(client.state).not.toHaveBeenCalled(); + }); + + test("stale descriptor (pid alive but ping fails) → warns + cleans up, exit code 1", async () => { + vi.mocked(readDescriptor).mockReturnValue(DESCRIPTOR); + vi.mocked(isPidAlive).mockReturnValue(true); + const client = { + ping: vi.fn().mockResolvedValue(false), + state: vi.fn(), + }; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + + await action.status(); + + expect(logWarning).toHaveBeenCalledWith(expect.stringMatching(/stale/i)); + expect(removeDescriptor).toHaveBeenCalledWith("/tmp/wallet-session.json"); + expect(process.exitCode).toBe(1); + expect(client.state).not.toHaveBeenCalled(); + }); +}); + +describe("WalletAction.disconnect", () => { + let action: WalletAction; + let logInfo: any; + let logSuccess: any; + let killSpy: any; + + beforeEach(() => { + vi.mocked(readDescriptor).mockReset(); + vi.mocked(removeDescriptor).mockReset(); + vi.mocked(isPidAlive).mockReset(); + vi.mocked(WalletSessionClient).mockReset(); + + action = new WalletAction(); + logInfo = vi.spyOn(action as any, "logInfo").mockImplementation(() => {}); + logSuccess = vi.spyOn(action as any, "logSuccess").mockImplementation(() => {}); + // Never send a real signal at a real pid. + killSpy = vi.spyOn(process, "kill").mockImplementation(() => true as any); + }); + afterEach(() => vi.restoreAllMocks()); + + test("no descriptor → 'No active wallet session.', no throw, no client", async () => { + vi.mocked(readDescriptor).mockReturnValue(null); + + await expect(action.disconnect()).resolves.toBeUndefined(); + + expect(logInfo).toHaveBeenCalledWith("No active wallet session."); + expect(WalletSessionClient).not.toHaveBeenCalled(); + expect(removeDescriptor).not.toHaveBeenCalled(); + expect(killSpy).not.toHaveBeenCalled(); + }); + + test("live daemon, pid exits cleanly → shutdown + removeDescriptor, no SIGTERM", async () => { + vi.mocked(readDescriptor).mockReturnValue(DESCRIPTOR); + const client = {shutdown: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + // Daemon exits within the grace window. + vi.spyOn(action as any, "waitForPidGone").mockResolvedValue(true); + + await action.disconnect(); + + expect(client.shutdown).toHaveBeenCalledTimes(1); + expect(removeDescriptor).toHaveBeenCalledWith("/tmp/wallet-session.json"); + expect(killSpy).not.toHaveBeenCalled(); + expect(logSuccess).toHaveBeenCalledWith("Disconnected."); + }); + + test("live daemon, pid lingers → SIGTERM fallback then removeDescriptor", async () => { + vi.mocked(readDescriptor).mockReturnValue(DESCRIPTOR); + const client = {shutdown: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + // Grace window elapses with the daemon still alive → SIGTERM path. + vi.spyOn(action as any, "waitForPidGone").mockResolvedValue(false); + vi.mocked(isPidAlive).mockReturnValue(true); + + await action.disconnect(); + + expect(client.shutdown).toHaveBeenCalledTimes(1); + expect(killSpy).toHaveBeenCalledWith(DESCRIPTOR.pid, "SIGTERM"); + expect(removeDescriptor).toHaveBeenCalledWith("/tmp/wallet-session.json"); + expect(logSuccess).toHaveBeenCalledWith("Disconnected."); + }); + + test("pid lingers but a racing exit makes isPidAlive false → no SIGTERM, still cleans up", async () => { + vi.mocked(readDescriptor).mockReturnValue(DESCRIPTOR); + const client = {shutdown: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + vi.spyOn(action as any, "waitForPidGone").mockResolvedValue(false); + // waitForPidGone timed out, but the daemon has since exited. + vi.mocked(isPidAlive).mockReturnValue(false); + + await action.disconnect(); + + expect(killSpy).not.toHaveBeenCalled(); + expect(removeDescriptor).toHaveBeenCalledWith("/tmp/wallet-session.json"); + expect(logSuccess).toHaveBeenCalledWith("Disconnected."); + }); +}); diff --git a/tests/libs/availableToStake.test.ts b/tests/libs/availableToStake.test.ts new file mode 100644 index 00000000..086e36a3 --- /dev/null +++ b/tests/libs/availableToStake.test.ts @@ -0,0 +1,47 @@ +import {describe, test, expect, vi} from "vitest"; +import {vestingAvailableToStake} from "../../src/lib/vesting/availableToStake"; +import type {Address} from "genlayer-js/types"; + +const VESTING: Address = "0xVesting000000000000000000000000000000001" as Address; + +describe("vestingAvailableToStake", () => { + test("revoked contract → returns 0n and never reads the balance", async () => { + const getBalance = vi.fn(); + const client = {getBalance}; + + const result = await vestingAvailableToStake(client, VESTING, true); + + expect(result).toBe(0n); + // Revoked staking is disabled outright — no RPC read should happen. + expect(getBalance).not.toHaveBeenCalled(); + }); + + test("not revoked → returns the on-chain balance, read against the vesting address", async () => { + const getBalance = vi.fn().mockResolvedValue(12345678901234567890n); + const client = {getBalance}; + + const result = await vestingAvailableToStake(client, VESTING, false); + + expect(result).toBe(12345678901234567890n); + expect(getBalance).toHaveBeenCalledTimes(1); + expect(getBalance).toHaveBeenCalledWith({address: VESTING}); + }); + + test("not revoked, zero balance → returns 0n (still consulted the chain)", async () => { + const getBalance = vi.fn().mockResolvedValue(0n); + const client = {getBalance}; + + const result = await vestingAvailableToStake(client, VESTING, false); + + expect(result).toBe(0n); + expect(getBalance).toHaveBeenCalledWith({address: VESTING}); + }); + + test("not revoked → a failing balance read propagates (no silent 0)", async () => { + const boom = new Error("rpc down"); + const getBalance = vi.fn().mockRejectedValue(boom); + const client = {getBalance}; + + await expect(vestingAvailableToStake(client, VESTING, false)).rejects.toThrow("rpc down"); + }); +});