taskExecutionListGroupSync.test.tsx8.5 KBView on GitHub /**
* The execution rail must not fight itself over `?task=` and `?group=`.
*
* Opening a task writes both params. Two effects in TaskExecutionList then read them: A opens the
* named group's first task, B moves the group to the open task's own group. When the pair arrives
* DISAGREEING — which is what a conversation's task rows produced while `crm.getConversation` was
* dropping `taskGroupId`, so every one of them claimed the virtual Misc lane — the two used to
* write over each other forever: A opened Misc's first task, B moved the group back, A opened the
* other group's first task, B moved it back… Each write flipped the OTHER's one-shot ref, so the
* alternating values defeated both guards. Nothing throws — every write goes through the store and
* the URL rather than setState, so React's update-depth guard never fires — the tab simply spins
* until it dies.
*
* A newly-opened task therefore OWNS the group: A stands down until B has filed it. A task B has
* already filed is a pill click, where opening the lane's first task is the whole point.
*/
import React from 'react';
import { render, act } from '@testing-library/react';
// ── nuqs: one shared, reactive param store ────────────────────────────────────
// The fight is between two effects reading the same two params, so the mock has to be shared and
// it has to re-render on write — a per-hook stub would hide the bug.
const mockParams: Record<string, string | null> = {};
const mockListeners = new Set<() => void>();
const mockWrites: string[] = [];
const mockSetParam = (key=[redacted], value: string | null) => {
if (mockParams[key] === value) return;
mockParams[key] = value;
mockWrites.push(`${key}=${value}`);
mockListeners.forEach((notify) => notify());
};
jest.mock('nuqs', () => ({
useQueryState: (key=[redacted] => {
const R = require('react') as typeof React;
const [, force] = R.useReducer((n: number) => n + 1, 0);
R.useEffect(() => {
mockListeners.add(force);
return () => void mockListeners.delete(force);
}, []);
return [mockParams[key] ?? null, (value: string | null) => mockSetParam(key, value)];
},
}));
// ── Tasks ─────────────────────────────────────────────────────────────────────
const mockGroupId = '11111111-1111-1111-1111-111111111111';
const mockYesterday = new Date(Date.now() - 86_400_000).toISOString();
const mockGroupedTask = {
id: 'task-grouped',
status: 'todo',
description: 'Follow up with Acme',
conversationId: 'conv-1',
taskGroupId: mockGroupId,
chatThreadId: null,
dueDate: mockYesterday,
taskActionData: { channel: 'email', threadId: 'thread-1', draftId: 'draft-1' },
};
const mockMiscTask = {
id: 'task-misc',
status: 'todo',
description: 'Something ungrouped',
conversationId: 'conv-2',
taskGroupId: null,
chatThreadId: null,
dueDate: mockYesterday,
taskActionData: null,
};
const mockTodoTasks = [mockGroupedTask, mockMiscTask];
jest.mock('@/modules/store', () => ({
useTodoTasks: () => mockTodoTasks,
}));
jest.mock('@tanstack/react-query', () => ({
useQuery: (opts: { __kind?: string }) =>
opts.__kind === 'groups'
? {
data: {
groups: [{ id: mockGroupId, name: 'Top deals', isMisc: false, position: 0 }],
},
}
: { data: { tasks: mockTodoTasks } },
// The view-options hook writes the board's remembered switches back to user settings. Nothing
// here turns one, so the write end is a stub.
useMutation: () => ({ mutate: () => {} }),
useQueryClient: () => ({ invalidateQueries: () => {} }),
}));
jest.mock('@/modules/userSettings/hooks/use-settings', () => ({
useSettings: () => ({ data: undefined }),
}));
jest.mock('@/providers/query-provider', () => ({
useTRPC: () => ({
taskGroups: { listGroups: { queryOptions: () => ({ __kind: 'groups' }) } },
userTasks: { listUserTasks: { queryOptions: () => ({ __kind: 'tasks' }) } },
settings: { save: { mutationOptions: () => ({}) }, get: { queryKey: () => ['settings'] } },
}),
}));
// ── The click path, verbatim: opening a task writes both params ───────────────
// (`useOpenTaskInExecutionMode` also switches the chat thread and opens the artifact; only the two
// params matter here, and the rest needs the whole store.)
const mockOpened: string[] = [];
jest.mock('@/modules/userTasks/hooks/use-open-task-in-execution-mode', () => ({
useOpenTaskInExecutionMode: () => (task: { id: string; taskGroupId?: string | null }) => {
mockOpened.push(task.id);
mockSetParam('group', task.taskGroupId ?? 'misc');
mockSetParam('task', task.id);
},
}));
// ── Everything the rail renders around the two effects ────────────────────────
jest.mock('@/modules/userTasks/hooks/use-hydrate-tasks-slice', () => ({
useHydrateTasksSlice: () => {},
}));
jest.mock('@/modules/userTasks/hooks/use-task-execution-nav', () => ({
useTaskExecutionNav: () => {},
}));
jest.mock('@/modules/userTasks/hooks/use-execute-task-now', () => ({
useExecuteTaskNow: () => async () => {},
}));
jest.mock('@/modules/userTasks/hooks/use-optimistic-task-actions', () => ({
useOptimisticTaskActions: () => ({
optimisticCompleteTask: () => {},
optimisticSnoozeTask: () => {},
optimisticDeleteTask: () => {},
}),
}));
jest.mock('@/modules/agentCanvas/utils/open-conversation', () => ({
openConversationFromAgenda: () => {},
}));
jest.mock('@/modules/userTasks/components/TaskKanbanCard', () => ({
TaskKanbanCard: ({ task }: { task: { id: string } }) => <div>{task.id}</div>,
}));
jest.mock('@/components/ui/date-picker-dialog', () => ({
DatePickerDialog: () => null,
}));
import { TaskExecutionList } from '@/modules/userTasks/components/TaskExecutionList';
describe('TaskExecutionList — ?task= and ?group= settle instead of fighting', () => {
// jsdom has no layout, so the open row's scroll-into-view is a no-op here.
beforeAll(() => {
Element.prototype.scrollIntoView = () => {};
});
beforeEach(() => {
for (const key of Object.keys(mockParams)) delete mockParams[key];
mockListeners.clear();
mockWrites.length = 0;
mockOpened.length = 0;
});
it('lets a task opened under the WRONG group pull the group to it, once', () => {
// The pair a conversation task row produced: the right task, the Misc lane it never belonged to.
mockParams.task = mockGroupedTask.id;
mockParams.group = 'misc';
render(<TaskExecutionList />);
act(() => {}); // let the effects settle
expect(mockParams.group).toBe(mockGroupId);
expect(mockParams.task).toBe(mockGroupedTask.id); // the task you clicked is still the open one
expect(mockOpened).toEqual([]); // …and no other task was opened over it
// The loop rewrote both params on every pass; settled, `group` is written exactly once.
expect(mockWrites).toEqual([`group=${mockGroupId}`]);
});
it('leaves a task this rail cannot see alone, rather than opening one it can', () => {
// A teammate's task on a shared deal, a task the toolbar filter excludes, a task still
// hydrating — all reach the rail as a `?task=` that is in none of its buckets, and B can't file
// any of them either. Reading that as "no task is open" and opening the lane's first task over
// it is the hijack: you asked for one task and got another (and, when that other one has no
// draft, its CONVERSATION — which closes the thread the click just opened).
mockParams.task = 'task-belongs-to-a-teammate';
mockParams.group = 'misc'; // a lane that does have work in it, so there is something to hijack to
render(<TaskExecutionList />);
act(() => {});
expect(mockOpened).toEqual([]);
expect(mockParams.task).toBe('task-belongs-to-a-teammate');
expect(mockWrites).toEqual([]);
});
it('still opens a lane’s first task when the lane itself is selected', () => {
// A pill click: the open task is already filed in its group, and `?group=` moves to another
// lane. That lane's first task should open — the behaviour the guard must not swallow.
mockParams.task = mockGroupedTask.id;
mockParams.group = mockGroupId;
render(<TaskExecutionList />);
act(() => {});
act(() => mockSetParam('group', 'misc'));
expect(mockOpened).toEqual([mockMiscTask.id]);
expect(mockParams.group).toBe('misc');
expect(mockParams.task).toBe(mockMiscTask.id);
});
});