mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-13 07:12:00 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The board UI sidebar shows the person's recent tasks on every page, including the Mine inbox > - The sidebar refreshes those rows through `GET /companies/:id/issues/summaries`, which accepted at most five IDs > - `useRecentTasks` sent the chunks one after another, so a 25-task history cost five sequential requests > - On staging each request took 3–6 s, so the recent-task rows settled about 20 s after load and every request competed with the inbox rows > - This pull request raises the per-request cap to 50 and fetches any remaining chunks in parallel > - The benefit is one summaries request for a typical history instead of a serial chain ## Linked Issues or Issue Description **What existing behavior does this improve?** The refresh of the sidebar's recent tasks on page load. **Subsystem affected** Board UI (`ui/src/hooks/useRecentTasks.ts`) and the `/issues/summaries` route bound (`packages/shared`, `server/src/routes/issues.ts`). **Current behavior** A history of N recent tasks sends ceil(N/5) summaries requests in series. Measured on staging on 2026-11-09 for a cold Mine load: five requests of five IDs, started at 1.0 s, 4.3 s, 8.1 s, 12.3 s and 18.4 s, each taking 3.3–6.1 s. **Proposed behavior** Up to 50 recent tasks are one request. Longer histories send their chunks at the same time. **Reason and benefit** The staging server is saturated, so each extra serial round trip delays the sidebar and the inbox rows. One request removes four round trips from a typical load. ## What Changed - `ISSUE_SUMMARIES_MAX_IDS` is now 50 (was 5). The shared validator, the route's 400 message, the service guard and the OpenAPI description use the new bound. - `useRecentTasks` builds all chunks first and fetches them at most three at a time (`RECENT_TASK_SUMMARY_CONCURRENCY`). The recent list has no size cap, so this keeps a long history from becoming a burst. The cache key, omission handling and pruning are unchanged. A failed chunk still fails the batch, so nothing is pruned on a partial failure. - Tests: the route test accepts exactly `MAX` IDs and rejects `MAX + 1`; a hook test checks that at most three chunks are in flight; the API test uses the constant; `useRecentTasks` tests cover a 25-task history in one request and a 103-task history with three chunks in flight together, with and without a failed chunk. - New browser check `tests/perf/collections/recent-task-summaries.spec.ts`: a Mine load with a 12-task history sends summaries requests that each carry all 12 IDs, and the server answers 200 with all 12 tasks. ## Verification - `pnpm verify` (selected Vitest files) passes. - `server`: `npx vitest run src/__tests__/issue-summaries-routes.test.ts src/__tests__/issue-display-status.test.ts` passes (embedded PostgreSQL). - `ui`: `npx vitest run src/hooks/useRecentTasks.test.tsx src/api/issue-summaries.test.ts src/components/task-chat/TaskChatBlockerLinks.test.tsx src/components/CoordinatorSidebar.test.tsx src/components/SidebarRecentTasks.test.tsx` passes. - Browser check against a disposable local instance: `PAPERCLIP_COLLECTION_PERF_BASE_URL=http://127.0.0.1:<port> pnpm exec playwright test --config tests/perf/collections/playwright.config.ts recent-task-summaries.spec.ts`. It passes on this branch. With `master`'s hook and constant it fails: three requests of 5, 5 and 2 IDs. ## Risks - Low risk. The route still validates UUIDs, deduplicates, and applies the same read authorization per row. Blocker attention is computed with set-based queries, so 50 IDs is the same number of queries as 5. - During a rolling deploy, a new UI against an old server would get a 400 for a history above five tasks. The server serves the UI bundle, so both change together on a normal deploy. The sidebar keeps its stored snapshot when the refresh fails. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edits, browser). 1M-token context window. Extended thinking was off. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
338 lines
17 KiB
TypeScript
338 lines
17 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { ISSUE_SUMMARIES_MAX_IDS } from "@paperclipai/shared";
|
|
import { ApiError } from "@/api/client";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import {
|
|
getRecentTasksStorageKey,
|
|
readRecentTasks,
|
|
recordRecentTask,
|
|
RECENT_TASKS_UPDATED_EVENT,
|
|
} from "@/lib/recent-tasks";
|
|
import { RECENT_TASK_SUMMARY_CONCURRENCY, useRecentTasks } from "./useRecentTasks";
|
|
|
|
const { getIssue, summaries } = vi.hoisted(() => ({ getIssue: vi.fn(), summaries: vi.fn() }));
|
|
vi.mock("@/api/issues", () => ({ issuesApi: { get: getIssue, summaries } }));
|
|
|
|
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
const task = (updatedAt: number, title: string, status: "todo" | "done" = "todo") => ({
|
|
id: "issue-1", companyId: "company-1", identifier: "TASK-1", title, status,
|
|
updatedAt: new Date(updatedAt), hiddenAt: null, projectId: null,
|
|
});
|
|
const storageKey = getRecentTasksStorageKey("company-1", "user-1");
|
|
// Node 24 uses jsdom Storage; newer Node versions use the setup's storage shim.
|
|
// jsdom's Storage proxy does not support spying on instance methods.
|
|
const storageMethods = window.localStorage instanceof window.Storage ? window.Storage.prototype : window.localStorage;
|
|
|
|
describe("recent task synchronization", () => {
|
|
let container: HTMLDivElement;
|
|
let root: Root;
|
|
let clients: QueryClient[];
|
|
let renders: number;
|
|
|
|
function RecentTasks({ name, companyId = "company-1", userId = "user-1" }: { name: string; companyId?: string; userId?: string }) {
|
|
const { entries } = useRecentTasks({ companyId, userId });
|
|
renders += 1;
|
|
// Fail promptly if a regression restores the cross-cache render loop.
|
|
if (renders > 100) throw new Error("Recent Tasks did not settle");
|
|
return <div data-tab={name}>{entries.map((entry) => `${entry.title}:${entry.status}`).join(",")}</div>;
|
|
}
|
|
|
|
function client(snapshot?: ReturnType<typeof task>) {
|
|
const value = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
|
|
if (snapshot) {
|
|
value.setQueryData(queryKeys.issues.detail(snapshot.id), snapshot);
|
|
value.setQueryData(queryKeys.issues.summaryBatch("company-1", "user-1", [snapshot.id]), [snapshot]);
|
|
}
|
|
clients.push(value);
|
|
return value;
|
|
}
|
|
|
|
async function flush() {
|
|
await act(async () => {
|
|
for (let index = 0; index < 5; index += 1) await new Promise((resolve) => setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
getIssue.mockReset();
|
|
summaries.mockReset();
|
|
container = document.createElement("div");
|
|
document.body.append(container);
|
|
root = createRoot(container);
|
|
clients = [];
|
|
renders = 0;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await act(async () => root.unmount());
|
|
clients.forEach((value) => value.clear());
|
|
container.remove();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("settles independent stale and fresh query caches and displays the newer snapshot in both", async () => {
|
|
const older = task(10, "Old title");
|
|
const newer = task(20, "New title", "done");
|
|
recordRecentTask(older, "user-1");
|
|
const staleClient = client(older);
|
|
const freshClient = client(newer);
|
|
const setItem = vi.spyOn(storageMethods, "setItem");
|
|
await act(async () => root.render(<>
|
|
<QueryClientProvider client={staleClient}><RecentTasks name="stale" /></QueryClientProvider>
|
|
<QueryClientProvider client={freshClient}><RecentTasks name="fresh" /></QueryClientProvider>
|
|
</>));
|
|
await flush();
|
|
expect(container.querySelector('[data-tab="stale"]')?.textContent).toBe("New title:done");
|
|
expect(container.querySelector('[data-tab="fresh"]')?.textContent).toBe("New title:done");
|
|
expect(setItem).toHaveBeenCalledTimes(1);
|
|
expect(getIssue).not.toHaveBeenCalled();
|
|
const settledRenders = renders;
|
|
await flush();
|
|
expect(renders).toBe(settledRenders);
|
|
|
|
// A real query update still propagates after the two caches settle.
|
|
await act(async () => {
|
|
freshClient.setQueryData(queryKeys.issues.detail(newer.id), task(30, "Renamed", "done"));
|
|
});
|
|
await flush();
|
|
expect(container.querySelector('[data-tab="stale"]')?.textContent).toBe("Renamed:done");
|
|
expect(readRecentTasks(storageKey, "company-1")[0]?.snapshotUpdatedAt).toBe(30);
|
|
expect(setItem).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("receives storage updates without republishing cached data", async () => {
|
|
const cached = task(20, "Cached", "done");
|
|
recordRecentTask(cached, "user-1");
|
|
await act(async () => root.render(
|
|
<QueryClientProvider client={client(cached)}><RecentTasks name="receiver" /></QueryClientProvider>,
|
|
));
|
|
await flush();
|
|
// Simulate a concurrent write from another tab with an older snapshot.
|
|
const incoming = { ...readRecentTasks(storageKey, "company-1")[0], title: "Older", snapshotUpdatedAt: 10 };
|
|
localStorage.setItem(storageKey, JSON.stringify([incoming]));
|
|
const setItem = vi.spyOn(storageMethods, "setItem");
|
|
const publish = vi.fn();
|
|
window.addEventListener(RECENT_TASKS_UPDATED_EVENT, publish);
|
|
try {
|
|
await act(async () => { window.dispatchEvent(new StorageEvent("storage", { key: storageKey })); });
|
|
await flush();
|
|
expect(container.textContent).toBe("Cached:done");
|
|
expect(setItem).not.toHaveBeenCalled();
|
|
expect(publish).not.toHaveBeenCalled();
|
|
} finally {
|
|
window.removeEventListener(RECENT_TASKS_UPDATED_EVENT, publish);
|
|
}
|
|
});
|
|
|
|
it("ignores writes from the old application after migration", async () => {
|
|
const legacyKey = "paperclip.recentTasks:company-1:user-1";
|
|
localStorage.setItem(legacyKey, JSON.stringify([{ ...task(10, "Legacy"), recordedAt: 100 }]));
|
|
await act(async () => root.render(
|
|
<QueryClientProvider client={client(task(20, "Current", "done"))}><RecentTasks name="current" /></QueryClientProvider>,
|
|
));
|
|
await flush();
|
|
expect(container.textContent).toBe("Current:done");
|
|
const settledRenders = renders;
|
|
await act(async () => {
|
|
localStorage.setItem(legacyKey, "[]");
|
|
window.dispatchEvent(new StorageEvent("storage", { key: legacyKey }));
|
|
window.dispatchEvent(new CustomEvent(RECENT_TASKS_UPDATED_EVENT, { detail: { storageKey: legacyKey, entries: [] } }));
|
|
});
|
|
await flush();
|
|
expect(renders).toBe(settledRenders);
|
|
expect(container.textContent).toBe("Current:done");
|
|
expect(readRecentTasks(storageKey, "company-1")[0]?.recordedAt).toBe(100);
|
|
});
|
|
|
|
it.each([403, 500])("retains snapshots when the batch fails with %s", async (status) => {
|
|
recordRecentTask(task(10, "Unavailable"), "user-1");
|
|
summaries.mockRejectedValue(new ApiError("Unavailable", status, null));
|
|
await act(async () => root.render(
|
|
<QueryClientProvider client={client()}><RecentTasks name="unavailable" /></QueryClientProvider>,
|
|
));
|
|
await flush();
|
|
expect(readRecentTasks(storageKey, "company-1")).toHaveLength(1);
|
|
expect(container.textContent).toBe("Unavailable:todo");
|
|
expect(summaries).toHaveBeenCalledTimes(1);
|
|
expect(getIssue).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fetches five tasks once without activating detail queries", async () => {
|
|
const tasks = Array.from({ length: 5 }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${i}` }));
|
|
tasks.forEach(issue => recordRecentTask(issue, "user-1"));
|
|
summaries.mockResolvedValue(tasks);
|
|
const cache = client();
|
|
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="batch" /></QueryClientProvider>));
|
|
await flush();
|
|
expect(summaries).toHaveBeenCalledTimes(1);
|
|
expect(summaries.mock.calls[0][1]).toEqual(tasks.map(issue => issue.id).sort());
|
|
expect(getIssue).not.toHaveBeenCalled();
|
|
expect(cache.getQueryCache().findAll({ queryKey: ["issues", "detail"] })).toEqual([]);
|
|
});
|
|
|
|
it("does not fetch empty history", async () => {
|
|
await act(async () => root.render(<QueryClientProvider client={client()}><RecentTasks name="empty" /></QueryClientProvider>));
|
|
await flush();
|
|
expect(summaries).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("fetches a 25-task history in one request", async () => {
|
|
const tasks = Array.from({ length: 25 }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${String(i).padStart(2, "0")}` }));
|
|
tasks.forEach(issue => recordRecentTask(issue, "user-1"));
|
|
summaries.mockResolvedValue(tasks);
|
|
await act(async () => root.render(<QueryClientProvider client={client()}><RecentTasks name="staging-size" /></QueryClientProvider>));
|
|
await flush();
|
|
expect(summaries).toHaveBeenCalledTimes(1);
|
|
expect(summaries.mock.calls[0][1]).toHaveLength(25);
|
|
});
|
|
|
|
it.each([false, true])("refreshes long histories in parallel bounded requests without pruning on a partial failure (%s)", async fails => {
|
|
// Three chunks, all in flight together within the concurrency limit.
|
|
const count = ISSUE_SUMMARIES_MAX_IDS * 2 + 3;
|
|
const tasks = Array.from({ length: count }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${String(i).padStart(3, "0")}` }));
|
|
tasks.forEach(issue => recordRecentTask(issue, "user-1"));
|
|
const pending: Array<() => void> = [];
|
|
summaries.mockImplementation(async (_company, ids: string[]) => {
|
|
expect(ids.length).toBeLessThanOrEqual(ISSUE_SUMMARIES_MAX_IDS);
|
|
const call = summaries.mock.calls.length;
|
|
// Each chunk waits until the test releases it, so all three must be in flight together.
|
|
await new Promise<void>(resolve => pending.push(resolve));
|
|
if (fails && call === 2) throw new ApiError("Unavailable", 500, null);
|
|
return tasks.filter(issue => ids.includes(issue.id)).map(issue => ({ ...issue, title: `Refreshed ${issue.title}`, updatedAt: new Date(10_000) }));
|
|
});
|
|
await act(async () => root.render(<QueryClientProvider client={client()}><RecentTasks name="long-history" /></QueryClientProvider>));
|
|
await flush();
|
|
expect(summaries).toHaveBeenCalledTimes(3);
|
|
expect(pending).toHaveLength(3);
|
|
pending.forEach(resolve => resolve());
|
|
await flush();
|
|
const entries = readRecentTasks(storageKey, "company-1");
|
|
expect(entries).toHaveLength(count);
|
|
expect(entries.every(entry => entry.title.startsWith("Refreshed"))).toBe(!fails);
|
|
expect(getIssue).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("limits how many summary chunks are in flight at once", async () => {
|
|
const count = ISSUE_SUMMARIES_MAX_IDS * 5;
|
|
const tasks = Array.from({ length: count }, (_, i) => ({ ...task(i + 1, `Task ${i}`), id: `issue-${String(i).padStart(3, "0")}` }));
|
|
tasks.forEach(issue => recordRecentTask(issue, "user-1"));
|
|
const pending: Array<() => void> = [];
|
|
summaries.mockImplementation(async (_company, ids: string[]) => {
|
|
await new Promise<void>(resolve => pending.push(resolve));
|
|
return tasks.filter(issue => ids.includes(issue.id));
|
|
});
|
|
await act(async () => root.render(<QueryClientProvider client={client()}><RecentTasks name="many" /></QueryClientProvider>));
|
|
await flush();
|
|
expect(summaries).toHaveBeenCalledTimes(RECENT_TASK_SUMMARY_CONCURRENCY);
|
|
while (pending.length) {
|
|
pending.splice(0).forEach(resolve => resolve());
|
|
await flush();
|
|
}
|
|
expect(summaries).toHaveBeenCalledTimes(5);
|
|
expect(readRecentTasks(storageKey, "company-1")).toHaveLength(count);
|
|
});
|
|
|
|
it("prunes successful omissions without resurrecting cached full details", async () => {
|
|
const missing = task(10, "Removed");
|
|
recordRecentTask(missing, "user-1");
|
|
const cache = client();
|
|
cache.setQueryData(queryKeys.issues.detail(missing.id), task(30, "Cached secret"));
|
|
summaries.mockResolvedValue([]);
|
|
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="missing" /></QueryClientProvider>));
|
|
await flush();
|
|
expect(readRecentTasks(storageKey, "company-1")).toEqual([]);
|
|
expect(container.textContent).toBe("");
|
|
});
|
|
|
|
it("does not prune a task added while an older batch is pending", async () => {
|
|
recordRecentTask(task(10, "Missing old task"), "user-1");
|
|
let resolveFirst!: (rows: unknown[]) => void;
|
|
summaries.mockReturnValueOnce(new Promise(resolve => { resolveFirst = resolve; }))
|
|
.mockReturnValue(new Promise(() => {}));
|
|
await act(async () => root.render(<QueryClientProvider client={client()}><RecentTasks name="race" /></QueryClientProvider>));
|
|
const added = { ...task(20, "New task"), id: "issue-2" };
|
|
await act(async () => { recordRecentTask(added, "user-1"); });
|
|
await act(async () => { resolveFirst([]); });
|
|
await flush();
|
|
expect(readRecentTasks(storageKey, "company-1").some(entry => entry.id === added.id)).toBe(true);
|
|
expect(container.textContent).toContain("New task");
|
|
});
|
|
|
|
it("isolates an account switch while the old request is pending", async () => {
|
|
recordRecentTask(task(10, "Old account task"), "user-1");
|
|
recordRecentTask({ ...task(20, "New account task"), id: "issue-2" }, "user-2");
|
|
let resolveOld!: (rows: unknown[]) => void;
|
|
summaries.mockReturnValueOnce(new Promise(resolve => { resolveOld = resolve; }))
|
|
.mockResolvedValue([{ ...task(20, "New account task"), id: "issue-2" }]);
|
|
const cache = client();
|
|
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="account" /></QueryClientProvider>));
|
|
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="account" userId="user-2" /></QueryClientProvider>));
|
|
await act(async () => { resolveOld([task(30, "Old response")]); });
|
|
await flush();
|
|
expect(container.textContent).toBe("New account task:todo");
|
|
expect(readRecentTasks(getRecentTasksStorageKey("company-1", "user-2"), "company-1")[0]?.id).toBe("issue-2");
|
|
expect(summaries.mock.calls.map(call => call[1])).toEqual([["issue-1"], ["issue-2"]]);
|
|
});
|
|
|
|
it("refreshes a cached omission when the user revisits an accessible task", async () => {
|
|
const first = task(10, "First");
|
|
const second = { ...task(20, "Second"), id: "issue-2" };
|
|
recordRecentTask(first, "user-1");
|
|
recordRecentTask(second, "user-1");
|
|
summaries.mockResolvedValue([second]);
|
|
const cache = client();
|
|
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="revisited" /></QueryClientProvider>));
|
|
await flush();
|
|
expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id)).toEqual([second.id]);
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
expect(cache.getQueryState(queryKeys.issues.summaryBatch("company-1", "user-1", [second.id]))?.status).toBe("success");
|
|
summaries.mockResolvedValue([first, second]);
|
|
await act(async () => { recordRecentTask(first, "user-1", 30); });
|
|
await flush();
|
|
expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id).sort()).toEqual([first.id, second.id]);
|
|
expect(summaries.mock.calls.filter(call => call[1].length === 2)).toHaveLength(2);
|
|
});
|
|
|
|
it("keeps a revisited task when refreshing its cached omission fails", async () => {
|
|
const first = task(10, "First");
|
|
const second = { ...task(20, "Second"), id: "issue-2" };
|
|
recordRecentTask(first, "user-1");
|
|
recordRecentTask(second, "user-1");
|
|
summaries.mockResolvedValue([second]);
|
|
const cache = client();
|
|
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="retry-failed" /></QueryClientProvider>));
|
|
await flush();
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
summaries.mockRejectedValue(new ApiError("Unavailable", 500, null));
|
|
await act(async () => { recordRecentTask(first, "user-1", 30); });
|
|
await flush();
|
|
expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id).sort()).toEqual([first.id, second.id]);
|
|
expect(cache.getQueryState(queryKeys.issues.summaryBatch("company-1", "user-1", [first.id, second.id]))?.status).toBe("error");
|
|
expect(summaries.mock.calls.filter(call => call[1].length === 2)).toHaveLength(2);
|
|
});
|
|
|
|
it("prunes a revisited task when a fresh response repeats the cached omission", async () => {
|
|
const first = task(10, "First");
|
|
const second = { ...task(20, "Second"), id: "issue-2" };
|
|
recordRecentTask(first, "user-1");
|
|
recordRecentTask(second, "user-1");
|
|
const rows = [second];
|
|
summaries.mockResolvedValue(rows);
|
|
const cache = client();
|
|
await act(async () => root.render(<QueryClientProvider client={cache}><RecentTasks name="still-missing" /></QueryClientProvider>));
|
|
await flush();
|
|
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
|
|
await act(async () => { recordRecentTask(first, "user-1", 30); });
|
|
await flush();
|
|
expect(summaries.mock.calls.filter(call => call[1].length === 2)).toHaveLength(2);
|
|
expect(readRecentTasks(storageKey, "company-1").map(entry => entry.id)).toEqual([second.id]);
|
|
});
|
|
});
|