Skip to content

Commit d2f1afe

Browse files
test(wallet): cover WalletButton render branches + guard the removed keypair path
Adds jsdom + @testing-library/react (test-only) and extends the vitest include to components/**/*.test.tsx. WalletButton (mocking usePrivy / useWallet, asserting on i18n keys): - authenticated via Privy, no wallet → connect-wallet prompt + sign-out, and NOT the sign-in button - not authenticated, no wallet → sign-in button - wallet connected via SWK → account panel, with and without Privy auth Regression guard: no module under apps/web references Keypair.random / molotov_stellar_kp / molotov_funded (needles assembled from fragments so this test's own source doesn't match). No component code changed. pnpm --filter=web test (104), typecheck, lint pass.
1 parent b9b6bf8 commit d2f1afe

5 files changed

Lines changed: 583 additions & 9 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// @vitest-environment jsdom
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
4+
5+
// Hooks the component depends on are mocked so each render branch can be driven
6+
// directly. i18n echoes the key, so assertions are on the i18n key (user-visible
7+
// text), never on class names or specific copy.
8+
const { usePrivyMock, useWalletMock, useWalletsMock } = vi.hoisted(() => ({
9+
usePrivyMock: vi.fn(),
10+
useWalletMock: vi.fn(),
11+
useWalletsMock: vi.fn(() => ({ wallets: [] })),
12+
}));
13+
14+
vi.mock("@privy-io/react-auth", () => ({
15+
usePrivy: () => usePrivyMock(),
16+
useWallets: () => useWalletsMock(),
17+
}));
18+
19+
vi.mock("@/hooks/use-wallet", () => ({
20+
useWallet: () => useWalletMock(),
21+
}));
22+
23+
vi.mock("@/lib/i18n", () => ({
24+
useI18n: () => ({ t: (k: string) => k, locale: "en" }),
25+
}));
26+
27+
import { WalletButton } from "@/components/wallet-button";
28+
29+
function walletCtx(over: Record<string, unknown> = {}) {
30+
return {
31+
address: null,
32+
isConnected: false,
33+
isConnecting: false,
34+
connect: vi.fn(),
35+
prewarm: vi.fn(),
36+
disconnect: vi.fn(),
37+
connectViaPrivy: vi.fn(),
38+
signTransaction: vi.fn(),
39+
...over,
40+
};
41+
}
42+
43+
function privyCtx(over: Record<string, unknown> = {}) {
44+
return {
45+
ready: true,
46+
authenticated: false,
47+
user: null,
48+
logout: vi.fn(),
49+
login: vi.fn(),
50+
...over,
51+
};
52+
}
53+
54+
beforeEach(() => {
55+
// The account panel reads Horizon for a balance; keep it off the network and
56+
// pending so no state update fires outside act during these render assertions.
57+
vi.stubGlobal(
58+
"fetch",
59+
vi.fn(() => new Promise(() => {})),
60+
);
61+
});
62+
63+
afterEach(() => {
64+
cleanup();
65+
vi.unstubAllGlobals();
66+
vi.clearAllMocks();
67+
});
68+
69+
describe("WalletButton render branches", () => {
70+
it("authenticated via Privy, no wallet: connect-wallet prompt + sign-out, and NO sign-in button", () => {
71+
useWalletMock.mockReturnValue(walletCtx()); // not connected
72+
usePrivyMock.mockReturnValue(
73+
privyCtx({ authenticated: true, user: { google: { email: "artist@example.com" } } }),
74+
);
75+
76+
render(<WalletButton />);
77+
78+
// The anonymous sign-in button is not shown in this state.
79+
expect(screen.queryByText("nav.signIn")).toBeNull();
80+
81+
// Identity is shown as the trigger; open it to reveal the prompt + controls.
82+
fireEvent.click(screen.getByRole("button"));
83+
84+
expect(screen.getByText("wallet.noWalletHint")).toBeTruthy();
85+
expect(screen.getByText("wallet.connect")).toBeTruthy();
86+
expect(screen.getByText("account.signOut")).toBeTruthy();
87+
expect(screen.queryByText("nav.signIn")).toBeNull();
88+
});
89+
90+
it("not authenticated, no wallet: renders the sign-in button", () => {
91+
useWalletMock.mockReturnValue(walletCtx());
92+
usePrivyMock.mockReturnValue(privyCtx({ authenticated: false, user: null }));
93+
94+
render(<WalletButton />);
95+
96+
expect(screen.getByText("nav.signIn")).toBeTruthy();
97+
expect(screen.queryByText("wallet.noWalletHint")).toBeNull();
98+
});
99+
100+
it.each([
101+
{ label: "with Privy auth", authenticated: true, user: { google: { email: "a@b.com" } } },
102+
{ label: "without Privy auth", authenticated: false, user: null },
103+
])(
104+
"wallet connected via SWK ($label): renders the account panel as before",
105+
({ authenticated, user }) => {
106+
useWalletMock.mockReturnValue(
107+
walletCtx({
108+
address: "GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRST",
109+
isConnected: true,
110+
}),
111+
);
112+
usePrivyMock.mockReturnValue(privyCtx({ authenticated, user }));
113+
114+
render(<WalletButton />);
115+
fireEvent.click(screen.getByRole("button")); // open the account menu
116+
117+
// Account-panel content (unchanged), not the no-wallet prompt or sign-in.
118+
expect(screen.getByText("account.profile")).toBeTruthy();
119+
expect(screen.getByText("account.balance")).toBeTruthy();
120+
expect(screen.getByText("account.signOut")).toBeTruthy();
121+
expect(screen.queryByText("wallet.noWalletHint")).toBeNull();
122+
expect(screen.queryByText("nav.signIn")).toBeNull();
123+
},
124+
);
125+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, it } from "vitest";
2+
import { readdirSync, readFileSync, statSync } from "node:fs";
3+
import { fileURLToPath } from "node:url";
4+
import { dirname, join, resolve } from "node:path";
5+
6+
// Regression guard for ADR 0002 (Option D): the Privy-derived keypair persisted to
7+
// localStorage was removed, not migrated. This fails if any of its fingerprints
8+
// reappear anywhere under apps/web.
9+
//
10+
// The needles are assembled from fragments on purpose, so this test's own source
11+
// does not contain the contiguous strings it searches for.
12+
const FORBIDDEN = ["Keypair" + ".random", "molotov_stellar" + "_kp", "molotov_" + "funded"];
13+
14+
const HERE = dirname(fileURLToPath(import.meta.url));
15+
const WEB_ROOT = resolve(HERE, ".."); // apps/web
16+
const SKIP_DIRS = new Set(["node_modules", ".next", "dist", "coverage", ".turbo"]);
17+
const EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
18+
19+
function walk(dir: string): string[] {
20+
const out: string[] = [];
21+
for (const name of readdirSync(dir)) {
22+
if (SKIP_DIRS.has(name)) continue;
23+
const full = join(dir, name);
24+
if (statSync(full).isDirectory()) out.push(...walk(full));
25+
else if (EXTS.some((e) => full.endsWith(e))) out.push(full);
26+
}
27+
return out;
28+
}
29+
30+
describe("no localStorage keypair path (ADR 0002)", () => {
31+
it("no module under apps/web references the removed keypair path", () => {
32+
const offenders: string[] = [];
33+
for (const file of walk(WEB_ROOT)) {
34+
const src = readFileSync(file, "utf8");
35+
for (const needle of FORBIDDEN) {
36+
if (src.includes(needle)) offenders.push(`${file.slice(WEB_ROOT.length + 1)}${needle}`);
37+
}
38+
}
39+
expect(offenders, `Removed keypair path resurfaced:\n${offenders.join("\n")}`).toEqual([]);
40+
});
41+
});

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,13 @@
3535
},
3636
"devDependencies": {
3737
"@tailwindcss/postcss": "^4",
38+
"@testing-library/react": "^16.3.2",
3839
"@types/node": "^20",
3940
"@types/react": "^19",
4041
"@types/react-dom": "^19",
4142
"eslint": "^9",
4243
"eslint-config-next": "16.2.6",
44+
"jsdom": "^30.0.1",
4345
"sharp": "^0.34.5",
4446
"tailwindcss": "^4",
4547
"typescript": "^5",

apps/web/vitest.config.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ export default defineConfig({
1212
environment: "node",
1313
// Must cover lib/ too: a test file outside `include` does not fail, it simply
1414
// never runs — which looks identical to passing.
15-
include: ["app/api/**/*.test.ts", "hooks/**/*.test.ts", "lib/**/*.test.ts"],
15+
include: [
16+
"app/api/**/*.test.ts",
17+
"hooks/**/*.test.ts",
18+
"lib/**/*.test.ts",
19+
"components/**/*.test.tsx",
20+
],
1621
testTimeout: 120_000,
1722
hookTimeout: 120_000,
1823
},

0 commit comments

Comments
 (0)