Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 227 additions & 0 deletions .claude/skills/add-test/instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
---
name: add-test
description: Add tests for a component or utility in the vision-next web app following established patterns and avoiding common pitfalls
argument-hint: [component-or-file-path]
disable-model-invocation: true
---

# Add Test

Write tests for components or utilities in the Ecency Vision web app.

## Step 1: Determine Test Location

| Source location | Test location |
|---|---|
| `src/features/<domain>/` | `src/specs/features/<domain>/` |
| `src/features/shared/` | `src/specs/features/shared/` |
| `src/utils/` | `src/specs/utils/` |
| `src/api/` | `src/specs/api/` |
| `src/core/` | `src/specs/core/` |

Test files use the pattern `<component-name>.spec.tsx` (or `.spec.ts` for non-React).

## Step 2: Understand Global Mocks

The setup file `src/specs/setup-any-spec.ts` globally mocks these modules in every test:

| Module | What's provided | What's NOT provided |
|---|---|---|
| `@ecency/sdk` | ConfigManager, CONFIG, getAccountFullQueryOptions, getPointsQueryOptions, getBookmarksQueryOptions, and ~10 more | Most query options, most mutation hooks |
| `@ecency/wallets` | validateKey, validateWif, EXTERNAL_BLOCKCHAINS, EcencyWalletCurrency | Most wallet queries |
| `@/utils` | **Only `random` and `getAccessToken`** | parseAsset, dateToFormattedUtc, formatNumber, and 70+ other exports |
| `@/core/hooks/use-active-account` | useActiveAccount returning null user | - |
| `i18next` | `t()` returns the key as-is | - |
| `react-tweet` | Empty object | - |

### Critical: @/utils Mock Limitation

If your component imports anything from `@/utils` beyond `random`/`getAccessToken`, you MUST add a local re-mock at the top of your test file:

```typescript
vi.mock("@/utils", async () => ({
...(await vi.importActual("@/utils")),
random: vi.fn(),
getAccessToken: vi.fn(() => "mock-token")
}));
```

Without this, tests fail with: `No "<export>" export is defined on the "@/utils" mock`.

## Step 3: Choose Test Pattern

### Pure Utility Functions

```typescript
import { describe, it, expect } from "vitest";
import { myFunction } from "@/utils/my-function";

describe("myFunction", () => {
it("should handle normal input", () => {
expect(myFunction("input")).toBe("expected");
});

it("should handle edge cases", () => {
expect(myFunction("")).toBe("");
expect(myFunction(undefined)).toBeNull();
});
});
```

### React Components (no queries)

```typescript
import { render, screen, fireEvent } from "@testing-library/react";
import { vi } from "vitest";
import { MyComponent } from "@/features/shared/my-component";

describe("MyComponent", () => {
test("renders content", () => {
render(<MyComponent title="Hello" />);
expect(screen.getByText("Hello")).toBeInTheDocument();
});

test("handles click", () => {
const onClick = vi.fn();
render(<MyComponent onClick={onClick} />);
fireEvent.click(screen.getByRole("button"));
expect(onClick).toHaveBeenCalled();
});
});
```

### React Components with React Query

```typescript
import { screen } from "@testing-library/react";
import { vi } from "vitest";
import { useQuery } from "@tanstack/react-query";
import { renderWithQueryClient } from "@/specs/test-utils";

// Mock React Query
vi.mock("@tanstack/react-query", async () => ({
...(await vi.importActual("@tanstack/react-query")),
useQuery: vi.fn()
}));

// Mock SDK query options your component uses
vi.mock("@ecency/sdk", async () => ({
...(await vi.importActual("@ecency/sdk")),
getSomeQueryOptions: vi.fn(() => ({ queryKey: ["some"], queryFn: vi.fn() }))
}));

describe("MyQueryComponent", () => {
beforeEach(() => {
vi.mocked(useQuery).mockReturnValue({
data: { /* mock data */ },
isLoading: false,
isError: false,
error: null,
refetch: vi.fn()
} as any);
});

test("displays data", () => {
renderWithQueryClient(<MyQueryComponent />);
expect(screen.getByText("expected text")).toBeInTheDocument();
});
});
```

### Multiple Queries (queryKey-based switching)

When a component calls `useQuery` multiple times with different options:

```typescript
vi.mocked(useQuery).mockImplementation(({ queryKey }: any) => {
if (queryKey[0] === "account") {
return { data: mockAccount, isLoading: false } as any;
}
if (queryKey[0] === "points") {
return { data: mockPoints, isLoading: false } as any;
}
return { data: null, isLoading: false } as any;
});
```

### Components with Dynamic Imports

When the component must be imported after mocks are set up:

```typescript
// Set up all vi.mock() calls first, then:
let MyComponent: typeof import("@/features/shared/my-component").MyComponent;

beforeAll(async () => {
const mod = await import("@/features/shared/my-component");
MyComponent = mod.MyComponent;
});
```

## Step 4: Mock Active User (when needed)

```typescript
import { useActiveAccount } from "@/core/hooks/use-active-account";

// For logged-in user:
vi.mocked(useActiveAccount).mockReturnValue({
activeUser: { username: "testuser" },
username: "testuser",
account: { name: "testuser", /* ... */ },
isLoading: false,
isPending: false,
isError: false,
isSuccess: true,
error: null,
refetch: vi.fn()
} as any);

// For anonymous user (default from global mock):
// No override needed - global mock returns null activeUser
```

## Step 5: Use Test Utilities

```typescript
import {
renderWithQueryClient, // Wraps component with QueryClientProvider
mockFullAccount, // Creates a full mock Hive account
mockEntry, // Creates a mock blog entry/post
setupModalContainers // Sets up DOM containers for modals
} from "@/specs/test-utils";
```

## Step 6: Run Tests

```bash
# Run your new test
pnpm --filter @ecency/web test -- path/to/test.spec.tsx

# Run all tests to verify no regressions
pnpm --filter @ecency/web test
```

## Common Gotchas

1. **@/utils mock** - Only `random` and `getAccessToken` are globally mocked. Use `importActual` pattern for components that import other utilities.
2. **SDK mock** - The global mock only covers ~15 SDK exports. If your component uses others, add them to your local mock.
3. **useActiveAccount** - Globally mocked to return null. Override in beforeEach for logged-in user tests.
4. **i18next** - `t("key")` returns the key string. Test against i18n keys, not translated text.
5. **Async rendering** - Use `waitFor` or `findBy*` queries for components that update after useEffect/useQuery.
6. **Modal containers** - Call `setupModalContainers()` in beforeEach if component renders portals/modals.
7. **next/navigation** - Mock `useParams`, `useRouter`, `usePathname` etc. when component uses them:
```typescript
vi.mock("next/navigation", () => ({
useParams: vi.fn(() => ({ username: "testuser" })),
useRouter: vi.fn(() => ({ push: vi.fn(), back: vi.fn() })),
usePathname: vi.fn(() => "/")
}));
```

## Checklist

- [ ] Test file in correct `src/specs/` subdirectory
- [ ] @/utils re-mocked with importActual if needed
- [ ] All SDK/wallet imports used by component are mocked
- [ ] Tests cover: normal rendering, edge cases (empty/null data), user interactions
- [ ] `pnpm --filter @ecency/web test` passes (all tests, not just new ones)
18 changes: 17 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -509,12 +509,28 @@ describe('MyComponent', () => {
**Global Mocks** (`setup-any-spec.ts`):
- External packages (@ecency/sdk, @ecency/wallets, @ecency/render-helper)
- i18next (returns keys as-is)
- Common utilities
- `@/utils` (only exports `random` and `getAccessToken`)
- `@/core/hooks/use-active-account` (returns null active user)
- uuid, react-tweet

**Per-Test Mocks**:
- API queries/mutations specific to the component
- Component-specific dependencies

**Important: `@/utils` Global Mock Limitation**

The global mock for `@/utils` only provides `random` and `getAccessToken`. If your component imports other utilities (e.g., `parseAsset`, `dateToFormattedUtc`, `formatNumber`), the test will fail with "No export is defined on the mock." Fix by adding a local re-mock with `importActual` at the top of your test file:

```typescript
vi.mock("@/utils", async () => ({
...(await vi.importActual("@/utils")),
random: vi.fn(),
getAccessToken: vi.fn(() => "mock-token")
}));
```

This preserves all real utility exports while keeping the globally-mocked functions stubbed.

### Component Testing Patterns

**1. Utility Functions** (pure functions):
Expand Down
2 changes: 1 addition & 1 deletion apps/web/public/sw.js

Large diffs are not rendered by default.

47 changes: 40 additions & 7 deletions apps/web/sentry.client.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
import * as Sentry from "@sentry/nextjs";
import appPackage from "./package.json";

Sentry.init({
// Defer Sentry initialization until after first user interaction or 5s idle.
// This moves ~1s of JS evaluation off the critical rendering path.
const SENTRY_CONFIG: Sentry.BrowserOptions = {
dsn: "https://8a5c1659d1c2ba3385be28dc7235ce56@o4507985141956608.ingest.de.sentry.io/4507985146609744",

enabled: process.env.NODE_ENV === "production",
release: appPackage.version,

// Disable performance tracing to reduce client bundle size (~100-150 KB).
// Only error/exception capture is needed for our use case.
tracesSampleRate: 0,
integrations: (defaults) =>
defaults.filter((i) => i.name !== "BrowserTracing"),
Expand All @@ -30,16 +30,49 @@ Sentry.init({
"Cannot set property ethereum of #<Window> which has only a getter",
"window.ethereum._handleChainChanged is not a function",
"Cannot destructure property 'register' of 'undefined' as it is undefined.",
// iOS Safari cross-origin security errors from post-renderer library
"null is not an object (evaluating 'a.parentNode')",
"null is not an object (evaluating 'b.parentNode')",
"null is not an object (evaluating 'c.parentNode')"
],
// Filter out errors originating from browser extension
denyUrls: [
/sui\.js/,
/extensionServiceWorker\.js$/,
/chrome-extension:\/\//
]
});
Sentry.setTag("source", "client");
};

if (typeof window !== "undefined" && process.env.NODE_ENV === "production") {
// Buffer errors that happen before Sentry initializes
const earlyErrors: { error: unknown; timestamp: number }[] = [];
const earlyHandler = (event: ErrorEvent) => {
earlyErrors.push({ error: event.error, timestamp: Date.now() });
};
const earlyRejectionHandler = (event: PromiseRejectionEvent) => {
earlyErrors.push({ error: event.reason, timestamp: Date.now() });
};
window.addEventListener("error", earlyHandler);
window.addEventListener("unhandledrejection", earlyRejectionHandler);

const init = () => {
// Remove early listeners before Sentry hooks its own
window.removeEventListener("error", earlyHandler);
window.removeEventListener("unhandledrejection", earlyRejectionHandler);

Sentry.init(SENTRY_CONFIG);
Sentry.setTag("source", "client");

// Flush buffered errors
for (const { error } of earlyErrors) {
Sentry.captureException(error);
}
};

if ("requestIdleCallback" in window) {
(window as any).requestIdleCallback(init, { timeout: 5000 });
} else {
setTimeout(init, 3000);
}
} else {
Sentry.init(SENTRY_CONFIG);
Sentry.setTag("source", "client");
}
Comment thread
feruzm marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"use client";

import { Suspense } from "react";
import { Suspense, useState } from "react";
import { Entry } from "@/entities";
import { EntryPageDiscussions } from "./entry-page-discussions";
import { useSuspenseQuery } from "@tanstack/react-query";
import { getDiscussionsQueryOptions } from "@ecency/sdk";
import { getDiscussionsQueryOptions, SortOrder } from "@ecency/sdk";
import { useActiveAccount } from "@/core/hooks/use-active-account";
import i18next from "i18next";
import { Button } from "@/features/ui";
import { UilComment } from "@tooni/iconscout-unicons-react";

interface Props {
entry: Entry;
Expand All @@ -15,8 +18,7 @@ interface Props {
function DiscussionsLoader({ entry, category }: Props) {
const { username: activeUsername } = useActiveAccount();

// Use useSuspenseQuery to properly trigger Suspense boundary
useSuspenseQuery(getDiscussionsQueryOptions(entry, "created", true, activeUsername));
useSuspenseQuery(getDiscussionsQueryOptions(entry, SortOrder.created, true, activeUsername ?? undefined));

return <EntryPageDiscussions entry={entry} category={category} />;
}
Expand All @@ -32,6 +34,25 @@ function DiscussionsSkeleton() {
}

export function EntryPageDiscussionsWrapper({ entry, category }: Props) {
const { activeUser } = useActiveAccount();
const [showDiscussions, setShowDiscussions] = useState(false);

const commentCount = entry.children;

// Auto-load for logged-in users, manual load for anonymous
if (!activeUser && !showDiscussions) {
return commentCount > 0 ? (
<div className="bg-white/80 dark:bg-dark-200/90 rounded-xl p-4 my-4 flex justify-center">
<Button
icon={<UilComment />}
onClick={() => setShowDiscussions(true)}
>
{i18next.t("discussion.reveal-comments", { n: commentCount, defaultValue: "Show {{n}} comments" })}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the i18n key exists in the en-US.json locale file
echo "Searching for 'reveal-comments' key in locale files:"
fd -e json -i 'en-US' --exec grep -l "reveal-comments" {} \;

echo ""
echo "Searching for the exact key pattern:"
rg -n "reveal-comments" --type json

Repository: ecency/vision-next

Length of output: 152


Add the i18n key discussion.reveal-comments to en-US.json.

The new translation key is missing from the locale file. Per coding guidelines, all new strings must be added to en-US.json only for internationalization.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/web/src/app/`(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-discussions-wrapper.tsx
at line 50, Add the missing i18n key used by entry-page-discussions-wrapper.tsx:
add "discussion.reveal-comments" to en-US.json with the value "Show {{n}}
comments" (matching the usage i18next.t("discussion.reveal-comments", { n:
commentCount, defaultValue: "Show {{n}} comments" })); update only en-US.json
per guidelines.

</Button>
</div>
) : null;
}

return (
<Suspense fallback={<DiscussionsSkeleton />}>
<DiscussionsLoader entry={entry} category={category} />
Expand Down
Loading