Files
multica/packages/core/realtime/use-realtime-sync.test.ts
LinYushen 566d51f1c0 perf(chat): fix pending-task index mismatch + cut aggregate request storm (MUL-4159) (#5018)
* perf(chat): fix pending-task index mismatch + cut aggregate request storm (MUL-4159)

ListPendingChatTasksByCreator was a top DB hotspot. Root cause: the partial
index idx_agent_task_queue_chat_pending (migration 040) only covers
status IN (queued, dispatched, running), but migration 109 added a fourth
in-flight status (waiting_local_directory) that both pending chat queries now
filter on. Postgres can only use a partial index when the query predicate is a
subset of the index predicate, so the 4-status query stopped using it and
degraded to a Seq Scan over the whole agent_task_queue.

Implements the reviewed P0-P3 plan in one PR:

P0 index fix (split single-statement CONCURRENTLY migrations per repo convention)
- 143: CREATE INDEX CONCURRENTLY idx_agent_task_queue_chat_pending_v2 covering
  all four in-flight statuses (same column list, so GetPendingChatTask still
  benefits).
- 144: DROP the superseded 3-status index, in its own migration.

P1 SQL + handler hot path
- ListPendingChatTasksByCreator now returns cs.agent_id and states
  chat_session_id IS NOT NULL so the planner can prove the partial-index subset.
- ListPendingChatTasks filters private-agent access against the already-loaded
  accessible-agent set using the returned agent_id, dropping the extra
  ListAllChatSessionsByCreator scan on the hot path.
- Regenerated sqlc.

P2 frontend request amplification
- FAB uses the new boolean has-any query gated on enabled:!isOpen, so the
  minimised button never holds the full aggregate.
- use-realtime-sync maintains the pending aggregate (list + has-any) in place
  from task lifecycle events (queued/dispatch/running/waiting_local_directory
  -> upsert; completed/failed/cancelled -> remove) instead of invalidating on
  every chat:message/chat:done, with a debounced fallback invalidate for
  reconnect / unknown payloads.

P3 boolean endpoint
- GET /api/chat/pending-tasks/has-any backed by HasPendingChatTasksByCreator
  (EXISTS). Permission filtering is baked in via agent_id = ANY($3); an empty
  accessible-agent set short-circuits to false. The detailed list stays for the
  ChatWindow history / stop-task flows.

Tests: new handler tests cover the private-agent gate on both endpoints
(hidden from a creator who lost access, visible to the agent owner) plus the
boolean status/terminal semantics.

EXPLAIN (ANALYZE, BUFFERS) on a 300k-row reproduction:
- before (3-status index): Parallel Seq Scan, ~300k rows filtered,
  shared hit=3012, 12.1 ms.
- after (v2 index): Index Scan on idx_agent_task_queue_chat_pending_v2,
  shared hit=131, 0.07 ms.

Co-authored-by: multica-agent <github@multica.ai>

* fix(chat): stop optimistic cross-session pending aggregate writes from workspace-fanout task events (MUL-4159)

Review on PR #5018 flagged a real privilege-escalation bug in the P2 change:
use-realtime-sync optimistically upserted the cross-session pending aggregate
(pendingTasks / pendingTasksHasAny) from chat task:* events. Those events are a
workspace fanout delivered to every member (server still BroadcastToWorkspace,
see cmd/server/listeners.go), and the payload carries no creator / agent
visibility. So member B starting a chat task could flip member A's FAB to
has_pending=true, bypassing the server-side permission filter on
/api/chat/pending-tasks[/has-any].

Fix (option 1 from the review — the self-contained one): never optimistically
write the aggregate from task:* events. On every task lifecycle transition,
debounced-invalidate the aggregate so it is refetched through the
permission-filtering endpoint, which only returns the caller's own
creator-owned, accessible-agent tasks. The per-session pendingTask cache is
still written directly — it is keyed by chat_session_id and only rendered for
sessions the user can open (server-gated), so it is not a cross-user leak.
chat:message is still excluded from aggregate refresh, so the MUL-4159 request
storm stays fixed; task transitions are per-task and coalesced by the debounce.

- Removed upsertPendingAggregate / removePendingAggregate.
- Added exported refetchPendingChatAggregate(qc, wsId) — an invalidate, never a
  setQueryData — used by the debounced handler.
- Regression tests: refetchPendingChatAggregate leaves the cached
  has_pending/list untouched (no optimistic write) and only invalidates for an
  authoritative server-filtered refetch; no-ops without a workspace id.

Verified: @multica/core + @multica/views typecheck; full core vitest suite
(752 tests) green including the 2 new guard tests.

Co-authored-by: multica-agent <github@multica.ai>

* chore(chat): address review nits on pending-tasks endpoints (MUL-4159)

- Restore the GetPendingChatTask godoc first line that was clipped when the
  has-any handler was inserted (nit#1).
- ListPendingChatTasks short-circuits to an empty list when the caller has no
  accessible agents, mirroring HasPendingChatTasks — skips the DB round-trip
  (nit#2).
- Add a cross-creator negative test: user A's in-flight task on a
  workspace-visible agent returns has_pending=false / empty list for user B,
  locking the cs.creator_id tenant gate that the agent-visibility filter does
  not cover (nit#3).

Verified: go build ./... and go test ./internal/handler -run PendingChatTasks
(7 tests) green against live Postgres.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-07-07 13:20:30 +08:00

598 lines
20 KiB
TypeScript

import { QueryClient, type InfiniteData } from "@tanstack/react-query";
import { afterEach, describe, expect, it, vi } from "vitest";
import { setApiInstance } from "../api";
import type { ApiClient } from "../api/client";
import { chatKeys } from "../chat/queries";
import { inboxKeys } from "../inbox/queries";
import { issueKeys } from "../issues/queries";
import { notificationPreferenceKeys } from "../notification-preferences/queries";
import { workspaceKeys } from "../workspace/queries";
import type {
ChatDonePayload,
ChatMessage,
ChatPendingTask,
ChatMessagesPage,
InboxItem,
Workspace,
} from "../types";
import {
applyChatDoneToCache,
applyWorkspaceUpdatedToCache,
handleInboxNew,
invalidateChatMessageQueries,
refetchPendingChatAggregate,
resolveInboxSourceSlug,
} from "./use-realtime-sync";
const sessionId = "session-1";
const taskId = "task-1";
const messagesKey = chatKeys.messages(sessionId);
const pendingKey = chatKeys.pendingTask(sessionId);
function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
}
function userMessage(): ChatMessage {
return {
id: "msg-user",
chat_session_id: sessionId,
role: "user",
content: "hello",
task_id: null,
created_at: "2026-05-13T05:00:00Z",
};
}
function donePayload(overrides: Partial<ChatDonePayload> = {}): ChatDonePayload {
return {
chat_session_id: sessionId,
task_id: taskId,
message_id: "msg-assistant",
content: "done",
elapsed_ms: 1234,
created_at: "2026-05-13T05:00:02Z",
...overrides,
};
}
describe("applyChatDoneToCache", () => {
it("writes the assistant message before clearing pending task", () => {
const qc = createQueryClient();
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage()]);
qc.setQueryData<ChatPendingTask>(pendingKey, {
task_id: taskId,
status: "running",
});
const setQueryData = vi.spyOn(qc, "setQueryData");
applyChatDoneToCache(qc, donePayload());
expect(setQueryData.mock.calls[0]?.[0]).toEqual(messagesKey);
expect(setQueryData.mock.calls[2]?.[0]).toEqual(pendingKey);
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
userMessage(),
{
id: "msg-assistant",
chat_session_id: sessionId,
role: "assistant",
content: "done",
task_id: taskId,
created_at: "2026-05-13T05:00:02Z",
elapsed_ms: 1234,
},
]);
});
it("does not duplicate a replayed chat done event", () => {
const qc = createQueryClient();
const assistant: ChatMessage = {
id: "msg-assistant",
chat_session_id: sessionId,
role: "assistant",
content: "done",
task_id: taskId,
created_at: "2026-05-13T05:00:02Z",
elapsed_ms: 1234,
};
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage(), assistant]);
qc.setQueryData<ChatPendingTask>(pendingKey, {
task_id: taskId,
status: "running",
});
applyChatDoneToCache(qc, donePayload());
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
userMessage(),
assistant,
]);
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
});
it("falls back to invalidation-only when older servers omit message fields", () => {
const qc = createQueryClient();
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage()]);
qc.setQueryData<ChatPendingTask>(pendingKey, {
task_id: taskId,
status: "running",
});
applyChatDoneToCache(
qc,
donePayload({ message_id: undefined, content: undefined }),
);
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
userMessage(),
]);
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
});
});
describe("invalidateChatMessageQueries", () => {
it("invalidates both legacy and paged chat message caches", () => {
const qc = createQueryClient();
const invalidate = vi.spyOn(qc, "invalidateQueries");
invalidateChatMessageQueries(qc, sessionId);
expect(invalidate).toHaveBeenCalledWith({ queryKey: chatKeys.messages(sessionId) });
expect(invalidate).toHaveBeenCalledWith({ queryKey: chatKeys.messagesPage(sessionId) });
});
});
describe("refetchPendingChatAggregate (cross-session pending leak guard)", () => {
const wsId = "ws-1";
it("invalidates the aggregate instead of optimistically writing it, so another member's workspace-broadcast task:* event can't flip this user's has_pending", () => {
// Regression for the PR #5018 security review: task:* events are a
// workspace fanout with no creator/visibility, so member B's task must not
// be able to set member A's FAB to has_pending=true client-side. A's
// aggregate currently (correctly) says "nothing pending".
const qc = createQueryClient();
qc.setQueryData(chatKeys.pendingTasksHasAny(wsId), { has_pending: false });
qc.setQueryData(chatKeys.pendingTasks(wsId), { tasks: [] });
const invalidate = vi.spyOn(qc, "invalidateQueries");
const setData = vi.spyOn(qc, "setQueryData");
refetchPendingChatAggregate(qc, wsId);
// MUST NOT optimistically flip the boolean or inject a task from the
// untrusted event — the cached values are left untouched.
expect(qc.getQueryData(chatKeys.pendingTasksHasAny(wsId))).toEqual({
has_pending: false,
});
expect(qc.getQueryData(chatKeys.pendingTasks(wsId))).toEqual({ tasks: [] });
expect(setData).not.toHaveBeenCalled();
// Instead it marks the aggregate stale for an authoritative, server-side
// permission-filtered refetch. The has-any key is nested under
// pendingTasks, so this one invalidation refreshes both caches.
expect(invalidate).toHaveBeenCalledWith({
queryKey: chatKeys.pendingTasks(wsId),
});
});
it("no-ops without a workspace id (no accidental cross-workspace invalidation)", () => {
const qc = createQueryClient();
const invalidate = vi.spyOn(qc, "invalidateQueries");
refetchPendingChatAggregate(qc, undefined);
expect(invalidate).not.toHaveBeenCalled();
});
});
describe("applyWorkspaceUpdatedToCache", () => {
const wsId = "ws-1";
function workspace(overrides: Partial<Workspace> = {}): Workspace {
return {
id: wsId,
name: "Test",
slug: "test",
description: null,
context: null,
settings: {},
repos: [],
issue_prefix: "TES",
avatar_url: null,
created_at: "2026-05-18T00:00:00Z",
updated_at: "2026-05-18T00:00:00Z",
...overrides,
};
}
it("invalidates issue cache when issue_prefix changes", () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ issue_prefix: "TES" }),
]);
const invalidate = vi.spyOn(qc, "invalidateQueries");
applyWorkspaceUpdatedToCache(qc, {
workspace: workspace({ issue_prefix: "NEW" }),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: issueKeys.all(wsId),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: workspaceKeys.list(),
});
});
it("does not invalidate issue cache when only non-prefix fields change", () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ issue_prefix: "TES", name: "Old name" }),
]);
const invalidate = vi.spyOn(qc, "invalidateQueries");
applyWorkspaceUpdatedToCache(qc, {
workspace: workspace({ issue_prefix: "TES", name: "New name" }),
});
expect(invalidate).not.toHaveBeenCalledWith({
queryKey: issueKeys.all(wsId),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: workspaceKeys.list(),
});
});
it("invalidates issue cache when the workspace isn't in the cached list yet", () => {
// Conservative: a workspace appearing for the first time may correspond
// to issue queries that were primed without ever seeing the (possibly
// changing) prefix. Erring on the side of refresh keeps identifiers
// accurate at minimal cost.
const qc = createQueryClient();
const invalidate = vi.spyOn(qc, "invalidateQueries");
applyWorkspaceUpdatedToCache(qc, {
workspace: workspace({ issue_prefix: "NEW" }),
});
expect(invalidate).toHaveBeenCalledWith({
queryKey: issueKeys.all(wsId),
});
});
});
describe("applyChatDoneToCache paged messages", () => {
it("patches page zero and skips older pages without duplicating replayed events", () => {
const qc = createQueryClient();
const older = userMessage();
const latest: ChatMessage = {
id: "msg-latest",
chat_session_id: sessionId,
role: "user",
content: "latest",
task_id: null,
created_at: "2026-05-13T05:00:01Z",
};
qc.setQueryData<InfiniteData<ChatMessagesPage>>(chatKeys.messagesPage(sessionId), {
pages: [
{ messages: [latest], limit: 1, has_more: true, next_cursor: { created_at: latest.created_at, id: latest.id } },
{ messages: [older], limit: 1, has_more: false, next_cursor: null },
],
pageParams: [null, { created_at: latest.created_at, id: latest.id }],
});
applyChatDoneToCache(qc, donePayload());
applyChatDoneToCache(qc, donePayload());
const paged = qc.getQueryData<InfiniteData<ChatMessagesPage>>(chatKeys.messagesPage(sessionId));
expect(paged?.pages[0]?.messages.map((m) => m.id)).toEqual(["msg-latest", "msg-assistant"]);
expect(paged?.pages[1]?.messages.map((m) => m.id)).toEqual(["msg-user"]);
});
});
describe("resolveInboxSourceSlug", () => {
function workspace(overrides: Partial<Workspace> = {}): Workspace {
return {
id: "ws-a",
name: "Workspace A",
slug: "workspace-a",
description: null,
context: null,
settings: {},
repos: [],
issue_prefix: "WSA",
avatar_url: null,
created_at: "2026-05-18T00:00:00Z",
updated_at: "2026-05-18T00:00:00Z",
...overrides,
};
}
it("resolves the inbox item's source workspace, not another cached one", async () => {
// Regression for #3766: an `inbox:new` from workspace A arriving while
// workspace B is active must resolve A's slug for notification routing.
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ id: "ws-b", slug: "workspace-b", name: "Workspace B" }),
workspace(),
]);
await expect(resolveInboxSourceSlug(qc, "ws-a")).resolves.toBe("workspace-a");
});
it("returns null instead of falling back when the workspace is unknown", async () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ id: "ws-b", slug: "workspace-b" }),
]);
await expect(resolveInboxSourceSlug(qc, "ws-a")).resolves.toBeNull();
});
it("returns null for an empty workspace id without touching the cache", async () => {
const qc = createQueryClient();
const ensure = vi.spyOn(qc, "ensureQueryData");
await expect(resolveInboxSourceSlug(qc, "")).resolves.toBeNull();
expect(ensure).not.toHaveBeenCalled();
});
it("returns null when the workspace list cannot be fetched", async () => {
const qc = createQueryClient();
vi.spyOn(qc, "ensureQueryData").mockRejectedValueOnce(new Error("network down"));
await expect(resolveInboxSourceSlug(qc, "ws-a")).resolves.toBeNull();
});
});
describe("handleInboxNew", () => {
function workspace(overrides: Partial<Workspace> = {}): Workspace {
return {
id: "ws-a",
name: "Workspace A",
slug: "workspace-a",
description: null,
context: null,
settings: {},
repos: [],
issue_prefix: "WSA",
avatar_url: null,
created_at: "2026-05-18T00:00:00Z",
updated_at: "2026-05-18T00:00:00Z",
...overrides,
};
}
function inboxItem(overrides: Partial<InboxItem> = {}): InboxItem {
return {
id: "item-1",
workspace_id: "ws-a",
recipient_type: "member",
recipient_id: "member-1",
actor_type: "member",
actor_id: "member-2",
type: "mentioned",
severity: "info",
issue_id: "issue-1",
title: "Mentioned you",
body: "in a comment",
issue_status: null,
read: false,
archived: false,
created_at: "2026-05-18T00:00:00Z",
details: null,
...overrides,
};
}
function stubDesktopAPI() {
const showNotification = vi.fn();
(globalThis as Record<string, unknown>).desktopAPI = { showNotification };
return showNotification;
}
afterEach(() => {
delete (globalThis as Record<string, unknown>).desktopAPI;
});
it("still shows the banner when the slug can't be resolved, with an empty slug so the click is a no-op", async () => {
const qc = createQueryClient();
// Workspace list is cached but doesn't contain the item's workspace.
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ id: "ws-b", slug: "workspace-b" }),
]);
qc.setQueryData(notificationPreferenceKeys.all("ws-a"), {
preferences: { system_notifications: "all" },
});
const showNotification = stubDesktopAPI();
await handleInboxNew(qc, inboxItem());
expect(showNotification).toHaveBeenCalledWith({
slug: "",
itemId: "item-1",
issueKey: "issue-1",
title: "Mentioned you",
body: "in a comment",
});
});
it("invalidates the ITEM's workspace inbox cache and resolves its slug, not the active workspace's", async () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ id: "ws-b", slug: "workspace-b" }),
workspace(),
]);
qc.setQueryData(notificationPreferenceKeys.all("ws-a"), {
preferences: { system_notifications: "all" },
});
const invalidate = vi.spyOn(qc, "invalidateQueries");
const showNotification = stubDesktopAPI();
await handleInboxNew(qc, inboxItem());
expect(invalidate).toHaveBeenCalledWith({
queryKey: inboxKeys.list("ws-a"),
});
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ slug: "workspace-a" }),
);
});
it("honors the SOURCE workspace's mute preference", async () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [workspace()]);
qc.setQueryData(notificationPreferenceKeys.all("ws-a"), {
preferences: { system_notifications: "muted" },
});
const showNotification = stubDesktopAPI();
await handleInboxNew(qc, inboxItem());
expect(showNotification).not.toHaveBeenCalled();
});
// The tests below exercise the COLD-cache mute path (source preference not
// yet cached), where the request — not just the query key — must be scoped
// to the source workspace (#3766 follow-up). They install a fake API so the
// outgoing call's workspace argument is observable.
afterEach(() => {
setApiInstance(undefined as unknown as ApiClient);
});
it("fetches the SOURCE workspace's preference using its slug when the cache is cold", async () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ id: "ws-b", slug: "workspace-b", name: "Workspace B" }),
workspace(),
]);
// No cached preference for ws-a → the handler must fetch, and the fetch
// must target the source workspace's slug, not the active workspace's.
const getNotificationPreferences = vi
.fn()
.mockResolvedValue({ preferences: { system_notifications: "all" } });
setApiInstance({ getNotificationPreferences } as unknown as ApiClient);
const showNotification = stubDesktopAPI();
await handleInboxNew(qc, inboxItem());
expect(getNotificationPreferences).toHaveBeenCalledWith("workspace-a");
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ slug: "workspace-a" }),
);
});
it("suppresses the banner when the SOURCE workspace is muted on a cold cache", async () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [workspace()]);
const getNotificationPreferences = vi
.fn()
.mockResolvedValue({ preferences: { system_notifications: "muted" } });
setApiInstance({ getNotificationPreferences } as unknown as ApiClient);
const showNotification = stubDesktopAPI();
await handleInboxNew(qc, inboxItem());
expect(getNotificationPreferences).toHaveBeenCalledWith("workspace-a");
expect(showNotification).not.toHaveBeenCalled();
});
it("never fetches the active workspace's preference when the source slug can't be resolved", async () => {
const qc = createQueryClient();
// Item's workspace is absent from the cached list → slug unresolvable.
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
workspace({ id: "ws-b", slug: "workspace-b" }),
]);
const getNotificationPreferences = vi
.fn()
.mockResolvedValue({ preferences: { system_notifications: "muted" } });
setApiInstance({ getNotificationPreferences } as unknown as ApiClient);
const showNotification = stubDesktopAPI();
await handleInboxNew(qc, inboxItem());
// Must NOT fall back to the active workspace's preference — that both
// mis-mutes and pollutes the source workspace's cache key (#3766).
expect(getNotificationPreferences).not.toHaveBeenCalled();
expect(showNotification).toHaveBeenCalledWith(
expect.objectContaining({ slug: "" }),
);
});
// --- Web path: no desktopAPI → the browser Notification API ---
// Same focus/mute gating as desktop, but the desktop bridge is absent and a
// granted browser Notification stub is installed on `window`.
let webBanners: { title: string; options?: NotificationOptions }[] = [];
class FakeNotification {
static permission: NotificationPermission = "granted";
onclick: (() => void) | null = null;
close = vi.fn();
constructor(
public title: string,
public options?: NotificationOptions,
) {
webBanners.push({ title, options });
}
}
function installBrowserNotification(
permission: NotificationPermission = "granted",
) {
webBanners = [];
FakeNotification.permission = permission;
(globalThis as Record<string, unknown>).window = {
Notification: FakeNotification,
focus: vi.fn(),
};
}
afterEach(() => {
delete (globalThis as Record<string, unknown>).window;
});
it("shows a browser banner on web (no desktopAPI) when granted and not muted", async () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [workspace()]);
qc.setQueryData(notificationPreferenceKeys.all("ws-a"), {
preferences: { system_notifications: "all" },
});
installBrowserNotification("granted");
await handleInboxNew(qc, inboxItem());
expect(webBanners).toHaveLength(1);
expect(webBanners[0]?.title).toBe("Mentioned you");
});
it("shows no browser banner when the SOURCE workspace is muted", async () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [workspace()]);
qc.setQueryData(notificationPreferenceKeys.all("ws-a"), {
preferences: { system_notifications: "muted" },
});
installBrowserNotification("granted");
await handleInboxNew(qc, inboxItem());
expect(webBanners).toHaveLength(0);
});
it("shows no browser banner when permission is not granted", async () => {
const qc = createQueryClient();
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [workspace()]);
qc.setQueryData(notificationPreferenceKeys.all("ws-a"), {
preferences: { system_notifications: "all" },
});
installBrowserNotification("default");
await handleInboxNew(qc, inboxItem());
expect(webBanners).toHaveLength(0);
});
});