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
45 changes: 45 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,50 @@ Enable the **Next DevTools MCP server** so agents can query live routes, errors,

---

## Sentry Error Handling

When addressing issues reported by Sentry, **always attempt to fix the root cause first**. Silencing errors should only be considered as a last resort when fixing is genuinely not possible.

### Default Workflow

1. **Fix the code to prevent the issue** (primary action)
* Analyze the error stack trace and identify the root cause
* Implement a proper fix that addresses the underlying problem
* Add appropriate error handling, validation, or defensive checks
* Update tests to cover the fix

2. **Ask about silencing only if fixing is not possible** (fallback)
* Only proceed to silencing if the error is genuinely unfixable, such as:
* Third-party library errors that cannot be patched
* Browser extension noise (e.g., injected scripts)
* Known browser bugs that cannot be worked around
* Expected errors that are already handled gracefully in the UI

### Where Silencing Happens

If silencing is necessary, errors are filtered in the `beforeSend` callback of Sentry initialization:

* **Client-side**: [`instrumentation-client.ts`](instrumentation-client.ts) — contains `noisyPatterns`, `referenceErrors`, and `filenameExceptions` arrays
* **Server-side**: [`sentry.server.config.ts`](sentry.server.config.ts) — server runtime configuration
* **Edge runtime**: [`sentry.edge.config.ts`](sentry.edge.config.ts) — edge runtime configuration

### Examples

**Fixable (default action):**
* Null reference errors → add null checks or optional chaining
* Type errors → fix type definitions or add runtime validation
* Network errors → implement retry logic or better error handling
* Missing dependencies → add proper dependency arrays or fix imports

**Non-fixable (ask about silencing):**
* Browser extension injecting scripts (`inpage.js` errors)
* Known browser bugs (e.g., `ResizeObserver loop limit exceeded` in some browsers)
* Third-party library errors that cannot be patched without forking

This aligns with the "Fix with modernization" principle: prioritize meaningful fixes over suppressing symptoms.

---

## Next.js 16: What this means for agents

* **Proxy instead of Middleware:** `middleware.ts` is **renamed to** `proxy.ts` (Node runtime). If you touch request‑boundary logic, ensure the file and exported function are named `proxy`. Legacy `middleware.ts` still exists for edge‑only cases but our default is `proxy.ts`. ([Next.js][6])
Expand Down Expand Up @@ -182,6 +226,7 @@ If you add or modify `proxy.ts`, keep it at the root (or `src/`) alongside `app/
* Tests live in `__tests__/` or `ComponentName.test.tsx`.
* Mock external dependencies and APIs in tests.
* When parsing Seize URLs (or similar), **do not** fall back to placeholder origins; fail fast if base origin is unavailable.
* **React imports:** Prefer direct named imports (`useMemo`, `useRef`, `FC`, etc.) over `React.` namespace usage (`React.useMemo`, `React.useRef`, `React.FC`, etc.). Import hooks and types directly: `import { useMemo, useRef, FC, memo } from "react"` rather than `import React from "react"` and using `React.useMemo`.

---

Expand Down
117 changes: 69 additions & 48 deletions __tests__/components/notifications/NotificationsContext.test.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,49 @@
import React from "react";
import { renderHook, act } from "@testing-library/react";
import {
NotificationsProvider,
useNotificationsContext,
} from "@/components/notifications/NotificationsContext";
import { act, renderHook, waitFor } from "@testing-library/react";
import React from "react";

const push = jest.fn();
const mockUseRouter = jest.fn(() => ({ push }));

jest.mock("@/hooks/useCapacitor", () => () => ({
isCapacitor: true,
isIos: true,
}));
jest.mock("next/navigation", () => ({
__esModule: true,
useRouter: jest.fn(() => ({ push: jest.fn() })),
useRouter: () => mockUseRouter(),
}));
jest.mock("@/components/auth/Auth", () => ({
useAuth: () => ({ connectedProfile: null }),
useAuth: () => ({ connectedProfile: { id: "test-profile-id" } }),
}));
jest.mock("@/services/api/common-api", () => ({
commonApiPost: jest.fn().mockResolvedValue({}),
commonApiPostWithoutBodyAndResponse: jest.fn().mockResolvedValue({}),
}));

jest.mock("@capacitor/push-notifications", () => {
return {
PushNotifications: {
removeAllListeners: jest.fn().mockResolvedValue(undefined),
addListener: jest.fn(),
requestPermissions: jest.fn().mockResolvedValue({ receive: "granted" }),
register: jest.fn().mockResolvedValue(undefined),
getDeliveredNotifications: jest
.fn()
.mockResolvedValue({ notifications: [{ data: { wave_id: "w1" } }] }),
removeDeliveredNotifications: jest.fn().mockResolvedValue(undefined),
removeAllDeliveredNotifications: jest.fn().mockResolvedValue(undefined),
},
PushNotificationSchema: {},
};
});
jest.mock("@capacitor/device", () => ({
Device: { getInfo: jest.fn().mockResolvedValue({ platform: "ios" }) },
}));

const wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<NotificationsProvider>{children}</NotificationsProvider>
);
Expand All @@ -44,69 +68,66 @@ describe("NotificationsContext", () => {
});
});

jest.mock("@capacitor/push-notifications", () => ({
PushNotifications: {
removeAllListeners: jest.fn(),
addListener: jest.fn(),
requestPermissions: jest.fn().mockResolvedValue({ receive: "granted" }),
register: jest.fn(),
getDeliveredNotifications: jest
.fn()
.mockResolvedValue({ notifications: [{ data: { wave_id: "w1" } }] }),
removeDeliveredNotifications: jest.fn(),
removeAllDeliveredNotifications: jest.fn(),
},
}));

jest.mock("@capacitor/device", () => ({
Device: { getInfo: jest.fn().mockResolvedValue({ platform: "ios" }) },
}));

it("removes notifications when functions called", async () => {
const { PushNotifications } = require("@capacitor/push-notifications");
const { result } = renderHook(() => useNotificationsContext(), { wrapper });

await waitFor(() => {
expect(PushNotifications.removeAllListeners).toHaveBeenCalled();
});

await act(async () => {
await result.current.removeWaveDeliveredNotifications("w1");
await result.current.removeAllDeliveredNotifications();
});
const { PushNotifications } = require("@capacitor/push-notifications");
expect(PushNotifications.getDeliveredNotifications).toHaveBeenCalled();
expect(PushNotifications.removeDeliveredNotifications).toHaveBeenCalled();
expect(PushNotifications.removeAllDeliveredNotifications).toHaveBeenCalled();
});

describe("push notification action handling", () => {
beforeEach(() => {
push.mockClear();
const { PushNotifications } = require("@capacitor/push-notifications");
PushNotifications.addListener.mockClear();
PushNotifications.removeDeliveredNotifications.mockClear();
});

it("redirects based on notification data", async () => {
const push = jest.fn();
jest
.spyOn(require("next/navigation"), "useRouter")
.mockReturnValue({ push } as any);

const addListenerMock = jest.fn((evt, cb) => {
if (evt === "pushNotificationActionPerformed") {
setTimeout(
() =>
cb({
notification: {
data: {
redirect: "profile",
handle: "abc",
notification_id: "1",
},
},
}),
0
);
}
const { PushNotifications } = require("@capacitor/push-notifications");
const { result } = renderHook(() => useNotificationsContext(), { wrapper });

await waitFor(() => {
expect(PushNotifications.addListener).toHaveBeenCalled();
});

const { PushNotifications } = require("@capacitor/push-notifications");
PushNotifications.addListener = addListenerMock;
const addListenerCalls = PushNotifications.addListener.mock.calls;
const actionPerformedCall = addListenerCalls.find(
(call: any[]) => call[0] === "pushNotificationActionPerformed"
);
const callback = actionPerformedCall?.[1];

expect(callback).toBeDefined();

const { result } = renderHook(() => useNotificationsContext(), { wrapper });
await act(async () => {
if (callback) {
await callback({
notification: {
data: {
redirect: "profile",
handle: "abc",
notification_id: "1",
},
},
});
}
await new Promise((r) => setTimeout(r, 100));
});
expect(push).toHaveBeenCalledWith("/abc");

await waitFor(() => {
expect(push).toHaveBeenCalledWith("/abc");
});

expect(PushNotifications.removeDeliveredNotifications).toHaveBeenCalled();
});
});
32 changes: 19 additions & 13 deletions __tests__/components/waves/CreateDropStormParts.test.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import CreateDropStormParts from '@/components/waves/CreateDropStormParts';
import { AuthContext } from '@/components/auth/Auth';
import { AuthContext } from "@/components/auth/Auth";
import CreateDropStormParts from "@/components/waves/CreateDropStormParts";
import { render, screen } from "@testing-library/react";

jest.mock('@/components/waves/CreateDropStormPart', () => ({
jest.mock("@/components/waves/CreateDropStormPart", () => ({
__esModule: true,
default: ({ partIndex }: any) => <div data-testid={`part-${partIndex}`} />,
}));

jest.mock("framer-motion", () => ({
AnimatePresence: ({ children }: any) => children,
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
},
}));

const authValue = {
connectedProfile: { handle: 'user', pfp: 'img.png', level: 1, cic: 0 },
connectedProfile: { handle: "user", pfp: "img.png", level: 1, cic: 0 },
} as any;

describe('CreateDropStormParts', () => {
it('renders parts with profile info', () => {
const parts = [{ content: 'a' }, { content: 'b' }] as any;
describe("CreateDropStormParts", () => {
it("renders parts with profile info", () => {
const parts = [{ content: "a" }, { content: "b" }] as any;
render(
<AuthContext.Provider value={authValue}>
<CreateDropStormParts
Expand All @@ -25,9 +31,9 @@ describe('CreateDropStormParts', () => {
/>
</AuthContext.Provider>
);
expect(screen.getByTestId('part-0')).toBeInTheDocument();
expect(screen.getByTestId('part-1')).toBeInTheDocument();
expect(screen.getByRole('img')).toHaveAttribute('src', 'img.png');
expect(screen.getByRole('link')).toHaveAttribute('href', '/user');
expect(screen.getByTestId("part-0")).toBeInTheDocument();
expect(screen.getByTestId("part-1")).toBeInTheDocument();
expect(screen.getByRole("img")).toHaveAttribute("src", "img.png");
expect(screen.getByRole("link")).toHaveAttribute("href", "/user");
});
});
47 changes: 30 additions & 17 deletions components/waves/CreateDropContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -609,23 +609,27 @@ const CreateDropContent: React.FC<CreateDropContentProps> = ({
allMentions: ApiDropMentionedUser[],
allNfts: ReferencedNft[]
): CreateDropConfig => {
const parts = ensurePartsWithFallback(
[
...(drop?.parts ?? []),
{
content: markdown?.length ? markdown : null,
quoted_drop:
activeDrop?.action === ActiveDropAction.QUOTE
? {
drop_id: activeDrop.drop.id,
drop_part_id: activeDrop.partId,
}
: null,
media: files,
},
],
hasMetadata
);
const hasPartsInDrop = (drop?.parts.length ?? 0) > 0;
const hasCurrentContent = !!(markdown?.trim().length || files.length);

const newParts = hasPartsInDrop && !hasCurrentContent
? drop?.parts ?? []
: [
...(drop?.parts ?? []),
{
content: markdown?.length ? markdown : null,
quoted_drop:
activeDrop?.action === ActiveDropAction.QUOTE
? {
drop_id: activeDrop.drop.id,
drop_part_id: activeDrop.partId,
}
: null,
media: files,
},
];

const parts = ensurePartsWithFallback(newParts, hasMetadata);

return {
title: null,
Expand Down Expand Up @@ -869,6 +873,15 @@ const CreateDropContent: React.FC<CreateDropContentProps> = ({
) {
return;
}

const hasPartsInDrop = (drop?.parts.length ?? 0) > 0;
const hasCurrentContent = !!(getMarkdown?.trim().length || files.length);

if (hasPartsInDrop && hasCurrentContent) {
finalizeAndAddDropPart();
return;
}

await prepareAndSubmitDrop(getUpdatedDrop());
};

Expand Down
Loading