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
99 changes: 99 additions & 0 deletions frontend/src/v2/__tests__/V2PodsSidebar.pollRecovery.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// @ts-nocheck
import React from 'react';
import { act, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { i18nReady } from '../../i18n';
import V2PodsSidebar from '../components/V2PodsSidebar';

// TASK-184, the recovery cell. This file mocks the API LAYER, not the hook, so
// the real useV2Pods and the real sidebar are exercised end to end: a first
// load that fails must not leave a tab that polls successfully every minute
// showing an error and no rows forever.

const NOW = Date.parse('2026-09-28T05:00:00.000Z');
const mockGet = jest.fn();
// Stable identity: a fresh object per render would change `refresh`'s dependency
// and re-run the mount effect forever (the real hook memoizes this).
const mockApi = { get: mockGet, post: jest.fn(), patch: jest.fn(), del: jest.fn() };

jest.mock('../hooks/useV2Api', () => ({
useV2Api: () => mockApi,
}));

jest.mock('../hooks/useV2Pinned', () => ({
useV2Pinned: () => ({ pinned: new Set(), toggle: jest.fn(), isPinned: () => false }),
}));

jest.mock('../../context/AuthContext', () => ({
useAuth: () => ({ currentUser: { _id: 'me', username: 'me' } }),
}));

// The real hook fetches on mount, so the first paint is `loading: true` and the
// settled state arrives a few microtasks later; fake timers do not advance
// microtasks, so drain them explicitly instead of waiting on a timer.
const settle = async () => {
await act(async () => { await Promise.resolve(); });
};

const podRow = [{
_id: 'p1',
name: 'Sharpen',
type: 'team',
members: [{ _id: 'me', username: 'me', isBot: false }],
lastMessage: {
content: 'x',
createdAt: new Date(NOW - 2 * 3600 * 1000).toISOString(),
username: 'a',
},
}];

describe('V2PodsSidebar — a failed mount recovers on the next poll', () => {
beforeAll(async () => { await i18nReady; });

beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers({ now: NOW });
});

afterEach(() => {
jest.useRealTimers();
});

it('shows the rows again once a poll succeeds, without a reload', async () => {
mockGet.mockRejectedValueOnce(new Error('network down'));
render(
<MemoryRouter initialEntries={['/v2/pods/p1']}>
<V2PodsSidebar selectedPodId="p1" />
</MemoryRouter>,
);

await settle();
expect(screen.getByText('network down')).toBeInTheDocument();
expect(screen.queryByText('Sharpen')).not.toBeInTheDocument();

mockGet.mockResolvedValueOnce(podRow);
await act(async () => { jest.advanceTimersByTime(60 * 1000); });

expect(mockGet).toHaveBeenCalledWith('/api/pods');
expect(screen.getByText('Sharpen')).toBeInTheDocument();
expect(screen.queryByText('network down')).not.toBeInTheDocument();
});

it('keeps the error while the poll keeps failing', async () => {
mockGet.mockRejectedValueOnce(new Error('network down'));
render(
<MemoryRouter initialEntries={['/v2/pods/p1']}>
<V2PodsSidebar selectedPodId="p1" />
</MemoryRouter>,
);
await settle();
expect(screen.getByText('network down')).toBeInTheDocument();

mockGet.mockRejectedValue(new Error('still down'));
await act(async () => { jest.advanceTimersByTime(3 * 60 * 1000); });
await settle();

expect(screen.getByText('network down')).toBeInTheDocument();
expect(screen.queryByText('Sharpen')).not.toBeInTheDocument();
});
});
1 change: 1 addition & 0 deletions frontend/src/v2/__tests__/V2PodsSidebar.scale.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ const renderSidebar = (pods, selectedPodId = 'sharpen', extra = {}) => render(
attentionCountByPod={{ sharpen: 2, connectors: 1, naming: 0, hq: 91 }}
podsState={{
pods, loading: false, error: null, createPod: mockCreatePod, patchLastMessage: jest.fn(),
refresh: jest.fn(() => Promise.resolve()),
}}
{...extra}
/>
Expand Down
124 changes: 124 additions & 0 deletions frontend/src/v2/__tests__/V2PodsSidebar.timestampFreshness.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// @ts-nocheck
import React from 'react';
import { act, render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { i18nReady } from '../../i18n';
import V2PodsSidebar from '../components/V2PodsSidebar';

// TASK-184. The row time is a DATUM from the last /api/pods, and the minute
// tick only recomputes the label — so a tab left open kept ageing a frozen
// timestamp and read further from the truth the longer it stayed open. This
// test drives the surface: the label moves when the datum does, and does not
// move while the tab is hidden.

const NOW = Date.parse('2026-09-28T05:00:00.000Z');
const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString();

const row = (createdAt: string) => ([{
_id: 'p1',
name: 'Sharpen',
type: 'team',
members: [{ _id: 'me', username: 'me', isBot: false }],
lastMessage: { content: 'x', createdAt, username: 'a' },
}]);

const mockRefresh = jest.fn();
let mockSetPods = () => {};
// `jest.mock` factories may only close over `mock`-prefixed names.
const mockInitialPods = row(new Date(NOW - 2 * 3600 * 1000).toISOString());

jest.mock('../hooks/useV2Pods', () => ({
useV2Pods: () => {
const { useState } = require('react');
const [pods, setPods] = useState(() => mockInitialPods);
mockSetPods = setPods;
return {
pods,
loading: false,
error: null,
createPod: jest.fn(),
patchLastMessage: jest.fn(),
refresh: mockRefresh,
};
},
}));

jest.mock('../hooks/useV2Api', () => ({
useV2Api: () => ({ get: jest.fn(() => Promise.resolve([])), post: jest.fn(), patch: jest.fn(), del: jest.fn() }),
}));

jest.mock('../hooks/useV2Pinned', () => ({
useV2Pinned: () => ({ pinned: new Set(), toggle: jest.fn(), isPinned: () => false }),
}));

jest.mock('../../context/AuthContext', () => ({
useAuth: () => ({ currentUser: { _id: 'me', username: 'me' } }),
}));

let visibility = 'visible';

const renderSidebar = () => render(
<MemoryRouter initialEntries={['/v2/pods/p1']}>
<V2PodsSidebar selectedPodId="p1" />
</MemoryRouter>,
);

describe('V2PodsSidebar — row time follows the datum', () => {
beforeAll(async () => { await i18nReady; });

beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers({ now: NOW });
visibility = 'visible';
Object.defineProperty(document, 'visibilityState', {
configurable: true,
get: () => visibility,
});
// The poll re-reads the row times; the surface must show the new datum.
mockRefresh.mockImplementation(() => {
mockSetPods(row(iso(0)));
return Promise.resolve();
});
});

afterEach(() => {
jest.useRealTimers();
});

it('re-reads the datum on the minute tick and re-renders the label', async () => {
renderSidebar();
expect(await screen.findByText('2h')).toBeInTheDocument();

await act(async () => { jest.advanceTimersByTime(60 * 1000); });

expect(mockRefresh).toHaveBeenCalledWith({ silent: true });
// NOW + the tick's own minute = 1 minute after the refreshed datum.
expect(await screen.findByText('1m')).toBeInTheDocument();
expect(screen.queryByText('2h')).not.toBeInTheDocument();
});

it('does not poll a hidden tab, and re-reads the moment it becomes visible', async () => {
renderSidebar();
expect(await screen.findByText('2h')).toBeInTheDocument();

visibility = 'hidden';
await act(async () => { jest.advanceTimersByTime(10 * 60 * 1000); });
expect(mockRefresh).not.toHaveBeenCalled();
expect(screen.getByText('2h')).toBeInTheDocument();

visibility = 'visible';
await act(async () => { document.dispatchEvent(new Event('visibilitychange')); });
expect(mockRefresh).toHaveBeenCalledWith({ silent: true });
});

it('the tick is a minute, not a render', async () => {
renderSidebar();
await screen.findByText('2h');

await act(async () => { jest.advanceTimersByTime(59 * 1000); });
expect(mockRefresh).not.toHaveBeenCalled();

await act(async () => { jest.advanceTimersByTime(1 * 1000); });
expect(mockRefresh).toHaveBeenCalledTimes(1);
});
});
127 changes: 127 additions & 0 deletions frontend/src/v2/__tests__/useV2PodsSilentRefresh.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { useV2Pods } from '../hooks/useV2Pods';

const mockApi = {
get: jest.fn(),
post: jest.fn(),
patch: jest.fn(),
del: jest.fn(),
};

jest.mock('../hooks/useV2Api', () => ({
useV2Api: () => mockApi,
}));

const row = (createdAt: string) => ({
_id: 'p1',
name: 'Sharpen',
lastMessage: { content: 'x', createdAt, username: 'a' },
});

// TASK-184. A poll that re-reads the sidebar's row times must not be
// observable as a state change: `loading` blanks the list into a spinner
// (V2PodsSidebar renders `v2-spinner` while loading), so a non-silent refresh
// on the minute tick would blink the whole sidebar once a minute, and a single
// failed poll would replace the last good rows with an error. Both arms are
// asserted here — silent, and the loud default that proves silent differs.
describe('useV2Pods silent refresh', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('re-reads the list in place without entering the loading state', async () => {
mockApi.get.mockResolvedValue([row('2026-09-28T04:00:00.000Z')]);
const { result } = renderHook(() => useV2Pods());
await waitFor(() => expect(result.current.loading).toBe(false));

let release: (value: unknown) => void = () => {};
mockApi.get.mockImplementationOnce(() => new Promise((resolve) => { release = resolve; }));
let pending: Promise<void> = Promise.resolve();
await act(async () => {
pending = result.current.refresh({ silent: true });
});

// In flight: the loud path is `true` here (next test), so this is the
// observable difference the option buys.
expect(result.current.loading).toBe(false);

await act(async () => {
release([row('2026-09-28T05:00:00.000Z')]);
await pending;
});
expect(result.current.loading).toBe(false);
expect(result.current.pods[0].lastMessage?.createdAt).toBe('2026-09-28T05:00:00.000Z');
});

it('keeps the last good rows and the last error when a silent read fails', async () => {
mockApi.get.mockResolvedValue([row('2026-09-28T04:00:00.000Z')]);
const { result } = renderHook(() => useV2Pods());
await waitFor(() => expect(result.current.loading).toBe(false));

mockApi.get.mockRejectedValueOnce(new Error('network down'));
await act(async () => {
await result.current.refresh({ silent: true });
});

expect(result.current.error).toBeNull();
expect(result.current.pods).toHaveLength(1);
expect(result.current.pods[0].lastMessage?.createdAt).toBe('2026-09-28T04:00:00.000Z');
});

it('the loud default does enter the loading state and does surface a failure', async () => {
mockApi.get.mockResolvedValue([row('2026-09-28T04:00:00.000Z')]);
const { result } = renderHook(() => useV2Pods());
await waitFor(() => expect(result.current.loading).toBe(false));

let release: (value: unknown) => void = () => {};
mockApi.get.mockImplementationOnce(() => new Promise((resolve) => { release = resolve; }));
let pending: Promise<void> = Promise.resolve();
await act(async () => {
pending = result.current.refresh();
});
expect(result.current.loading).toBe(true);
await act(async () => {
release([row('2026-09-28T05:00:00.000Z')]);
await pending;
});
expect(result.current.loading).toBe(false);

mockApi.get.mockRejectedValueOnce(new Error('network down'));
await act(async () => {
await result.current.refresh();
});
expect(result.current.error).toBe('network down');
});
it('a successful poll clears a stale error instead of refreshing data nobody can see', async () => {
// The cell the first draft missed: the mount FAILED, so `error` is set and
// V2PodsSidebar (:501/:502) renders the error INSTEAD of the rows. A poll
// that swaps the data but cannot clear the error leaves the view broken
// while fetching correctly every minute.
mockApi.get.mockRejectedValueOnce(new Error('network down'));
const { result } = renderHook(() => useV2Pods());
await waitFor(() => expect(result.current.error).toBe('network down'));
expect(result.current.pods).toHaveLength(0);

mockApi.get.mockResolvedValueOnce([row('2026-09-28T05:00:00.000Z')]);
await act(async () => {
await result.current.refresh({ silent: true });
});

expect(result.current.error).toBeNull();
expect(result.current.pods).toHaveLength(1);
});

it('a silent failure still never clears an error it did not earn', async () => {
// The complement: only a SUCCESS is evidence the error is stale.
mockApi.get.mockRejectedValueOnce(new Error('network down'));
const { result } = renderHook(() => useV2Pods());
await waitFor(() => expect(result.current.error).toBe('network down'));

mockApi.get.mockRejectedValueOnce(new Error('still down'));
await act(async () => {
await result.current.refresh({ silent: true });
});

expect(result.current.error).toBe('network down');
});
});
27 changes: 27 additions & 0 deletions frontend/src/v2/__tests__/v2-layout-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1157,6 +1157,33 @@ describe('v2 layout invariants (CSS rule presence)', () => {
expect(thread).toContain('delete el.dataset.historyAnchor');
});

test('the pod list opts out of native anchoring, because the minute poll re-sorts it under the reader', () => {
// ux-lead gate on #1989: with Everything open and scrolled, a message to a
// pod in view moves that row to the top, native anchoring drags the list
// ~14 rows and 4 of 17 rows stayed in view. Opting the scroller out holds
// 16 of 17. Same property the chat transcript uses, for the opposite
// reason — see the note on the declaration in v2.css.
const list = lastRuleBody(v2, '.v2-pods__list');
// The rule being reset must still exist, or a green here means nothing.
expect(list).toContain('overflow-y: auto');
expect(list).toContain('overflow-anchor: none');
expect(podsSidebar).toContain('className="v2-pods__list"');
// `.v2-pods__list` is declared twice (from #251 and #1578 "sidebar at
// scale"), both top-level, so the LATER block wins every conflicting
// declaration and is the effective rule — hence `lastRuleBody` here, which
// reads it, rather than `ruleBody`, which reads the superseded one.
//
// Do NOT add `expect(list).not.toBe(ruleBody(...))` to pin that choice. It
// cannot fail: `lastRuleBody` slices from the leading newline and `ruleBody`
// from `lineStart + 1`, so the two differ by whitespace whatever the sheet
// contains (measured — deleting the superseded block outright leaves this
// suite 153/153 green). Trimming the compare to make it bite is worse: it
// then reds on that very de-dup, which is harmless. The two `toContain`s
// above are what carry the property, and they fail in both hazard
// directions — declaration missing from the effective block, or the
// effective block gone so this falls through to the old one.
});

test('history recovery is positioned against the chat viewport, outside the scroller', () => {
const transcript = ruleBody(v2, '.v2-thread__transcript');
expect(transcript).toContain('position: relative');
Expand Down
Loading
Loading