mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(chat): retain readable history during workspace scope confirmation * fix(chat): gate queue dispatch on the current transcript authority * test(chat): account for transcript read cancellation signals --------- Co-authored-by: lefarcen <935902669@qq.com>
444 lines
21 KiB
TypeScript
444 lines
21 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react';
|
|
import { buildWorkspacePermissions, type WorkspaceCollabContext } from '@open-design/contracts';
|
|
import { forwardRef, useImperativeHandle, type ReactNode } from 'react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import { ProjectView } from '../../src/components/ProjectView';
|
|
import type { ProjectWorkspaceScopeState } from '../../src/collab/useProjectWorkspaceScope';
|
|
import { streamViaDaemon } from '../../src/providers/daemon';
|
|
import {
|
|
createConversation,
|
|
listConversations,
|
|
listMessages,
|
|
ProjectConversationsHttpError,
|
|
ProjectMessageListError,
|
|
} from '../../src/state/projects';
|
|
import type { AgentInfo, AppConfig, ChatMessage, Conversation, Project } from '../../src/types';
|
|
|
|
const workspace = vi.hoisted(() => ({
|
|
caller: null as WorkspaceCollabContext | null,
|
|
scope: { loading: false, scope: null } as ProjectWorkspaceScopeState,
|
|
}));
|
|
|
|
vi.mock('../../src/i18n', () => ({
|
|
useI18n: () => ({ locale: 'en', setLocale: () => undefined, t: (key: string) => key }),
|
|
useT: () => (key: string) => key,
|
|
}));
|
|
vi.mock('../../src/router', () => ({ navigate: vi.fn() }));
|
|
vi.mock('../../src/providers/anthropic', () => ({ streamMessage: vi.fn() }));
|
|
vi.mock('../../src/providers/daemon', () => ({
|
|
fetchChatRunStatus: vi.fn(),
|
|
fetchVelaLoginStatus: vi.fn().mockResolvedValue(null),
|
|
listActiveChatRuns: vi.fn().mockResolvedValue([]),
|
|
listProjectRuns: vi.fn().mockResolvedValue([]),
|
|
publishDaemonRunFinishedEvent: vi.fn(),
|
|
reattachDaemonRun: vi.fn(),
|
|
streamViaDaemon: vi.fn(),
|
|
}));
|
|
vi.mock('../../src/providers/project-events', () => ({ useProjectFileEvents: vi.fn() }));
|
|
vi.mock('../../src/runtime/amr-balance-gate', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../src/runtime/amr-balance-gate')>()),
|
|
checkAmrBalanceGate: vi.fn().mockResolvedValue({ kind: 'allow' }),
|
|
}));
|
|
vi.mock('../../src/collab/useWorkspaceContext', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../src/collab/useWorkspaceContext')>()),
|
|
useWorkspaceContext: () => ({ context: workspace.caller, loading: false }),
|
|
}));
|
|
vi.mock('../../src/collab/useProjectWorkspaceScope', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../src/collab/useProjectWorkspaceScope')>()),
|
|
useProjectWorkspaceScope: () => workspace.scope,
|
|
}));
|
|
vi.mock('../../src/collab/useProjectCollab', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../src/collab/useProjectCollab')>()),
|
|
useProjectCollab: () => ({
|
|
enabled: true, member: null, present: [], publishedVersion: null,
|
|
// Until initial scope/ownership confirmation, the real collab hook is
|
|
// read-only. Otherwise a restored queue legitimately drains immediately
|
|
// after the first history read, before this fixture can change scope.
|
|
syncState: null, viewerOnly: workspace.scope.loading,
|
|
writerAuthority: workspace.scope.loading ? 'pending' : 'allowed',
|
|
isOwner: !workspace.scope.loading, ownerDisplayName: null, ownerRole: null, downloadPending: false,
|
|
reportChange: () => undefined, requestPublish: () => undefined,
|
|
refreshPresence: () => undefined, checkStatusNow: () => undefined,
|
|
}),
|
|
}));
|
|
vi.mock('../../src/providers/registry', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../src/providers/registry')>()),
|
|
deletePreviewComment: vi.fn(), fetchDesignSystem: vi.fn(), fetchSkill: vi.fn(),
|
|
fetchLiveArtifacts: vi.fn().mockResolvedValue([]),
|
|
fetchPreviewComments: vi.fn().mockResolvedValue([]),
|
|
fetchProjectFiles: vi.fn().mockResolvedValue([]),
|
|
getTemplate: vi.fn(), patchPreviewCommentStatus: vi.fn(),
|
|
upsertPreviewComment: vi.fn(), writeProjectTextFile: vi.fn(),
|
|
}));
|
|
vi.mock('../../src/runtime/brands', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../src/runtime/brands')>()),
|
|
fetchBrands: vi.fn().mockResolvedValue([]),
|
|
}));
|
|
vi.mock('../../src/state/projects', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../src/state/projects')>()),
|
|
createConversation: vi.fn(), listConversations: vi.fn(), listMessages: vi.fn(),
|
|
loadTabs: vi.fn().mockResolvedValue({ tabs: [], active: null }),
|
|
patchConversation: vi.fn(), patchProject: vi.fn(),
|
|
persistTabsToDaemonNow: vi.fn(), saveMessage: vi.fn(), saveTabs: vi.fn(),
|
|
}));
|
|
vi.mock('../../src/components/AppChromeHeader', () => ({
|
|
AppChromeHeader: ({ children }: { children: ReactNode }) => <header>{children}</header>,
|
|
}));
|
|
vi.mock('../../src/components/AvatarMenu', () => ({ AvatarMenu: () => null }));
|
|
vi.mock('../../src/components/FileWorkspace', () => ({
|
|
DESIGN_SYSTEM_TAB: '__design_system__', FileWorkspace: () => <div />,
|
|
}));
|
|
// Keep ProjectView, ChatPane, its loading UI and message rows real. The editor's
|
|
// Lexical lifecycle and assistant Markdown are outside this read-lifetime test.
|
|
vi.mock('../../src/components/ChatComposer', () => ({
|
|
ChatComposer: forwardRef((props: {
|
|
sendDisabled?: boolean;
|
|
onSend?: (prompt: string, attachments: [], comments: []) => unknown;
|
|
}, ref) => {
|
|
useImperativeHandle(ref, () => ({
|
|
focus: () => undefined, restoreDraft: () => undefined, setDraft: () => undefined,
|
|
}));
|
|
return (
|
|
<button
|
|
type="button"
|
|
data-testid="composer-fixture-send"
|
|
disabled={props.sendDisabled}
|
|
onClick={() => { void props.onSend?.('Follow-up prompt', [], []); }}
|
|
>
|
|
Send fixture prompt
|
|
</button>
|
|
);
|
|
}),
|
|
}));
|
|
vi.mock('../../src/components/AssistantMessage', () => ({
|
|
AssistantMessage: ({ message }: { message: ChatMessage }) => <div>{message.content}</div>,
|
|
}));
|
|
|
|
const OWNER = {
|
|
workspaceId: 'transcript-workspace', workspaceType: 'team',
|
|
workspaceMemberId: 'transcript-member', role: 'owner',
|
|
memberStatus: 'active', lifecycleState: 'active',
|
|
permissions: buildWorkspacePermissions({ role: 'owner', lifecycleState: 'active' }),
|
|
} as WorkspaceCollabContext;
|
|
const MEMBER = {
|
|
...OWNER, role: 'member',
|
|
permissions: buildWorkspacePermissions({ role: 'member', lifecycleState: 'active' }),
|
|
} as WorkspaceCollabContext;
|
|
const project: Project = {
|
|
id: 'transcript-authority-project', name: 'Transcript authority fixture',
|
|
workspaceId: OWNER.workspaceId, skillId: null, designSystemId: null,
|
|
createdAt: 1, updatedAt: 1, metadata: { kind: 'prototype' },
|
|
};
|
|
const conversation: Conversation = {
|
|
id: 'transcript-conversation', projectId: project.id,
|
|
title: null, createdAt: 1, updatedAt: 1,
|
|
};
|
|
const history: ChatMessage = {
|
|
id: 'persisted-message', role: 'user', content: 'Existing private conversation', createdAt: 1,
|
|
};
|
|
const config: AppConfig = {
|
|
mode: 'daemon', apiKey: '', baseUrl: '', model: 'deepseek-v4-flash', agentId: 'amr',
|
|
skillId: null, designSystemId: null,
|
|
};
|
|
|
|
function readableScope(context = MEMBER): ProjectWorkspaceScopeState {
|
|
return {
|
|
loading: false,
|
|
scope: {
|
|
kind: 'team', projectId: project.id, workspaceId: OWNER.workspaceId,
|
|
visibility: 'team', context: context as WorkspaceCollabContext & { workspaceType: 'team' },
|
|
},
|
|
};
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason: unknown) => void;
|
|
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
|
resolve = resolvePromise;
|
|
reject = rejectPromise;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
function projectView() {
|
|
return (
|
|
<ProjectView
|
|
project={project} routeFileName={null} config={config}
|
|
agents={[{
|
|
id: 'amr', name: 'amr', available: true,
|
|
models: [{ id: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash', default: true }],
|
|
}] as unknown as AgentInfo[]}
|
|
skills={[]} designTemplates={[]} designSystems={[]} daemonLive
|
|
onModeChange={vi.fn()} onAgentChange={vi.fn()} onAgentModelChange={vi.fn()}
|
|
onRefreshAgents={vi.fn()} onOpenSettings={vi.fn()} onBack={vi.fn()}
|
|
onClearPendingPrompt={vi.fn()} onTouchProject={vi.fn()}
|
|
onProjectChange={vi.fn()} onProjectsRefresh={vi.fn()}
|
|
/>
|
|
);
|
|
}
|
|
|
|
describe('ProjectView transcript visibility across authority confirmation', () => {
|
|
beforeEach(() => {
|
|
window.sessionStorage.clear();
|
|
window.localStorage.clear();
|
|
workspace.caller = OWNER;
|
|
workspace.scope = readableScope();
|
|
vi.mocked(listConversations).mockReset().mockResolvedValue([conversation]);
|
|
vi.mocked(createConversation).mockReset().mockResolvedValue(conversation);
|
|
vi.mocked(listMessages).mockReset().mockResolvedValue([history]);
|
|
vi.mocked(streamViaDaemon).mockReset().mockResolvedValue(undefined);
|
|
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.unstubAllGlobals();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('keeps an existing loading UI through the conversation-list and transcript reads', async () => {
|
|
const conversations = deferred<Conversation[]>();
|
|
const messages = deferred<ChatMessage[]>();
|
|
vi.mocked(listConversations).mockReturnValue(conversations.promise);
|
|
vi.mocked(listMessages).mockReturnValue(messages.promise);
|
|
const view = render(projectView());
|
|
|
|
// Before a conversation is selected, ProjectView already owns a loader.
|
|
expect(view.getByTestId('chat-pane-loading').querySelector('[role="status"]')).not.toBeNull();
|
|
expect(view.container.querySelector('.chat-empty-wrap')).toBeNull();
|
|
expect(listMessages).not.toHaveBeenCalled();
|
|
await act(async () => { conversations.resolve([conversation]); });
|
|
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(1));
|
|
expect(view.container.querySelector('.chat-loading-state')).not.toBeNull();
|
|
expect(view.container.querySelector('.chat-empty-wrap')).toBeNull();
|
|
|
|
await act(async () => { messages.resolve([history]); });
|
|
expect(view.getByText(history.content)).toBeTruthy();
|
|
expect(view.container.querySelector('.chat-loading-state')).toBeNull();
|
|
});
|
|
|
|
it('shows an actual empty conversation once its authoritative reads settle', async () => {
|
|
const messages = deferred<ChatMessage[]>();
|
|
vi.mocked(listConversations).mockResolvedValue([]);
|
|
vi.mocked(listMessages).mockReturnValue(messages.promise);
|
|
const view = render(projectView());
|
|
await waitFor(() => expect(createConversation).toHaveBeenCalledTimes(1));
|
|
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(1));
|
|
await act(async () => { messages.resolve([]); });
|
|
expect(view.queryByTestId('chat-pane-loading')).toBeNull();
|
|
expect(view.container.querySelector('.chat-loading-state')).toBeNull();
|
|
expect(view.container.querySelector('.chat-empty-wrap')).not.toBeNull();
|
|
});
|
|
|
|
it('exits the initial loading UI when the conversation read is refused', async () => {
|
|
const conversations = deferred<Conversation[]>();
|
|
vi.mocked(listConversations).mockReturnValue(conversations.promise);
|
|
const view = render(projectView());
|
|
await act(async () => {
|
|
conversations.reject(new ProjectConversationsHttpError(403, 'workspace access forbidden'));
|
|
});
|
|
await waitFor(() => expect(view.queryByTestId('chat-pane-loading')).toBeNull());
|
|
expect(view.getByTestId('chat-log')).toBeTruthy();
|
|
expect(view.container.querySelector('.chat-loading-state')).toBeNull();
|
|
expect(listMessages).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps already-visible history while the same readable principal confirms its restricted scope', async () => {
|
|
// The real runWorkspaceIdentity borrows the exact owner while the first
|
|
// scope read is pending, then takes the daemon's readable member scope.
|
|
workspace.scope = { loading: true, scope: null };
|
|
const refresh = deferred<ChatMessage[]>();
|
|
vi.mocked(listMessages).mockResolvedValueOnce([history]).mockReturnValueOnce(refresh.promise);
|
|
const view = render(projectView());
|
|
await waitFor(() => expect(view.getByText(history.content)).toBeTruthy());
|
|
expect(listMessages).toHaveBeenNthCalledWith(1, project.id, conversation.id, OWNER, expect.any(AbortSignal));
|
|
expect(vi.mocked(listMessages).mock.calls[0]?.[3]?.aborted).toBe(false);
|
|
|
|
workspace.scope = readableScope();
|
|
await act(async () => { view.rerender(projectView()); });
|
|
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
|
|
expect(listMessages).toHaveBeenNthCalledWith(2, project.id, conversation.id, MEMBER, expect.any(AbortSignal));
|
|
expect(vi.mocked(listMessages).mock.calls[1]?.[3]?.aborted).toBe(false);
|
|
try {
|
|
expect(view.queryByText(history.content)).not.toBeNull();
|
|
expect(view.container.querySelector('.chat-loading-state')).toBeNull();
|
|
} finally {
|
|
// Settle the controlled read even when the expected red assertion fails.
|
|
await act(async () => { refresh.resolve([history]); });
|
|
}
|
|
expect(view.getAllByText(history.content)).toHaveLength(1);
|
|
});
|
|
|
|
it.each(['different member', 'access refused', 'inactive member', 'deleted workspace'] as const)(
|
|
'hides the old transcript when authority changes: %s',
|
|
async (change) => {
|
|
const refresh = deferred<ChatMessage[]>();
|
|
vi.mocked(listMessages).mockResolvedValueOnce([history]).mockReturnValueOnce(refresh.promise);
|
|
const view = render(projectView());
|
|
await waitFor(() => expect(view.getByText(history.content)).toBeTruthy());
|
|
|
|
if (change === 'different member') {
|
|
workspace.caller = { ...MEMBER, workspaceMemberId: 'another-member' };
|
|
workspace.scope = readableScope(workspace.caller);
|
|
} else if (change === 'access refused') {
|
|
workspace.scope = { loading: false, scope: null, failure: 'forbidden' };
|
|
} else {
|
|
const context: WorkspaceCollabContext = change === 'inactive member'
|
|
? { ...MEMBER, memberStatus: 'removed' }
|
|
: { ...MEMBER, lifecycleState: 'deleted' };
|
|
workspace.scope = readableScope({
|
|
...context,
|
|
permissions: buildWorkspacePermissions(context),
|
|
});
|
|
}
|
|
await act(async () => { view.rerender(projectView()); });
|
|
expect(view.queryByText(history.content)).toBeNull();
|
|
|
|
await act(async () => { refresh.resolve([]); });
|
|
expect(view.queryByText(history.content)).toBeNull();
|
|
},
|
|
);
|
|
|
|
it('keeps sends blocked until the background transcript read settles', async () => {
|
|
workspace.scope = { loading: true, scope: null };
|
|
const refresh = deferred<ChatMessage[]>();
|
|
vi.mocked(listMessages).mockResolvedValueOnce([history]).mockReturnValueOnce(refresh.promise);
|
|
const view = render(projectView());
|
|
await waitFor(() => expect(view.getByText(history.content)).toBeTruthy());
|
|
workspace.scope = readableScope();
|
|
await act(async () => { view.rerender(projectView()); });
|
|
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
|
|
const send = view.getByTestId('composer-fixture-send');
|
|
try {
|
|
expect(send).toBeDisabled();
|
|
fireEvent.click(send);
|
|
expect(streamViaDaemon).not.toHaveBeenCalled();
|
|
} finally {
|
|
await act(async () => { refresh.resolve([history]); });
|
|
}
|
|
await waitFor(() => expect(send).not.toBeDisabled());
|
|
fireEvent.click(send);
|
|
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
|
|
});
|
|
|
|
it('clears retained history if the background transcript read refuses access', async () => {
|
|
workspace.scope = { loading: true, scope: null };
|
|
const refresh = deferred<ChatMessage[]>();
|
|
vi.mocked(listMessages).mockResolvedValueOnce([history]).mockReturnValueOnce(refresh.promise);
|
|
const view = render(projectView());
|
|
await waitFor(() => expect(view.getByText(history.content)).toBeTruthy());
|
|
workspace.scope = readableScope();
|
|
await act(async () => { view.rerender(projectView()); });
|
|
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
|
|
try {
|
|
expect(view.queryByText(history.content)).not.toBeNull();
|
|
} finally {
|
|
await act(async () => {
|
|
refresh.reject(new ProjectMessageListError('workspace access forbidden', 403, null, false));
|
|
});
|
|
}
|
|
expect(view.queryByText(history.content)).toBeNull();
|
|
expect(view.getByTestId('composer-fixture-send')).toBeDisabled();
|
|
expect(streamViaDaemon).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(['success', 'refused'] as const)(
|
|
'does not drain a persisted queue before the same-principal authority read settles: %s',
|
|
async (outcome) => {
|
|
workspace.scope = { loading: true, scope: null };
|
|
const refresh = deferred<ChatMessage[]>();
|
|
const refreshedHistory: ChatMessage = {
|
|
id: 'new-authoritative-message', role: 'user',
|
|
content: 'History visible only after the confirmed scope read', createdAt: 2,
|
|
};
|
|
const queuedPrompt = 'Previously queued follow-up';
|
|
const storageKey = `od:chat-queued-sends:${project.id}:v1`;
|
|
const queued = {
|
|
id: 'authority-queued-request', conversationId: conversation.id,
|
|
prompt: queuedPrompt, attachments: [], commentAttachments: [],
|
|
meta: { clientRequestId: 'authority-queued-request' }, createdAt: 3,
|
|
};
|
|
window.localStorage.setItem(storageKey, JSON.stringify([queued]));
|
|
vi.mocked(listMessages).mockResolvedValueOnce([history]).mockReturnValueOnce(refresh.promise);
|
|
const view = render(projectView());
|
|
await waitFor(() => expect(view.getByText(history.content)).toBeTruthy());
|
|
expect(view.getByTestId('chat-queued-send-strip')).toHaveTextContent(queuedPrompt);
|
|
expect(streamViaDaemon).not.toHaveBeenCalled();
|
|
|
|
workspace.scope = readableScope();
|
|
try {
|
|
// Real React effects run here: the scope read starts and the queue
|
|
// drain gets the same commit. No timer or effect-order mock intervenes.
|
|
await act(async () => { view.rerender(projectView()); });
|
|
expect(listMessages).toHaveBeenNthCalledWith(2, project.id, conversation.id, MEMBER, expect.any(AbortSignal));
|
|
expect(vi.mocked(listMessages).mock.calls[1]?.[3]?.aborted).toBe(false);
|
|
expect(streamViaDaemon).not.toHaveBeenCalled();
|
|
expect(view.getByText(history.content)).toBeTruthy();
|
|
expect(view.getByTestId('chat-queued-send-strip')).toHaveTextContent(queuedPrompt);
|
|
expect(JSON.parse(window.localStorage.getItem(storageKey) ?? '[]')).toEqual([queued]);
|
|
|
|
if (outcome === 'refused') {
|
|
await act(async () => {
|
|
refresh.reject(new ProjectMessageListError('workspace access forbidden', 403, null, false));
|
|
});
|
|
expect(view.queryByText(history.content)).toBeNull();
|
|
expect(streamViaDaemon).not.toHaveBeenCalled();
|
|
expect(JSON.parse(window.localStorage.getItem(storageKey) ?? '[]')).toEqual([queued]);
|
|
return;
|
|
}
|
|
|
|
await act(async () => { refresh.resolve([history, refreshedHistory]); });
|
|
await waitFor(() => expect(streamViaDaemon).toHaveBeenCalledTimes(1));
|
|
expect(streamViaDaemon).toHaveBeenCalledWith(expect.objectContaining({
|
|
clientRequestId: queued.meta.clientRequestId,
|
|
conversationId: conversation.id,
|
|
workspaceContext: MEMBER,
|
|
history: expect.arrayContaining([
|
|
expect.objectContaining({ role: 'user', content: refreshedHistory.content }),
|
|
expect.objectContaining({ role: 'user', content: queuedPrompt }),
|
|
]),
|
|
}));
|
|
await waitFor(() => expect(view.queryByTestId('chat-queued-send-strip')).toBeNull());
|
|
await act(async () => { view.rerender(projectView()); });
|
|
expect(streamViaDaemon).toHaveBeenCalledTimes(1);
|
|
expect(window.localStorage.getItem(storageKey)).toBeNull();
|
|
} finally {
|
|
view.unmount();
|
|
refresh.resolve([history, refreshedHistory]);
|
|
}
|
|
},
|
|
);
|
|
|
|
it('does not drain a persisted queue while a different principal transcript is pending', async () => {
|
|
workspace.scope = { loading: true, scope: null };
|
|
const refresh = deferred<ChatMessage[]>();
|
|
window.localStorage.setItem(`od:chat-queued-sends:${project.id}:v1`, JSON.stringify([{
|
|
id: 'other-principal-queued-request', conversationId: conversation.id,
|
|
prompt: 'Queued before changing member', attachments: [], commentAttachments: [], createdAt: 3,
|
|
}]));
|
|
vi.mocked(listMessages).mockResolvedValueOnce([history]).mockReturnValueOnce(refresh.promise);
|
|
const view = render(projectView());
|
|
await waitFor(() => expect(view.getByText(history.content)).toBeTruthy());
|
|
expect(view.getByTestId('chat-queued-send-strip')).toHaveTextContent('Queued before changing member');
|
|
expect(streamViaDaemon).not.toHaveBeenCalled();
|
|
|
|
const differentMember = { ...MEMBER, workspaceMemberId: 'new-confirmed-member' };
|
|
workspace.caller = differentMember;
|
|
workspace.scope = readableScope(differentMember);
|
|
try {
|
|
await act(async () => { view.rerender(projectView()); });
|
|
expect(listMessages).toHaveBeenNthCalledWith(2, project.id, conversation.id, differentMember, expect.any(AbortSignal));
|
|
expect(vi.mocked(listMessages).mock.calls[1]?.[3]?.aborted).toBe(false);
|
|
expect(view.queryByText(history.content)).toBeNull();
|
|
expect(streamViaDaemon).not.toHaveBeenCalled();
|
|
} finally {
|
|
view.unmount();
|
|
refresh.resolve([]);
|
|
}
|
|
});
|
|
});
|