feat(coding): add renderer conversation store
This commit is contained in:
82
tests/unit/coding-conversations-facade.test.ts
Normal file
82
tests/unit/coding-conversations-facade.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hostApi = vi.hoisted(() => ({
|
||||
fetch: vi.fn(),
|
||||
ensureToken: vi.fn(),
|
||||
createEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/host-api', () => ({
|
||||
hostApiFetch: hostApi.fetch,
|
||||
ensureHostApiToken: hostApi.ensureToken,
|
||||
createHostEventSource: hostApi.createEvents,
|
||||
}));
|
||||
|
||||
describe('coding Conversations Host facade', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('loads the target snapshot through the encoded Host route', async () => {
|
||||
const snapshot = { conversation: { id: 'conversation/a' } };
|
||||
hostApi.fetch.mockResolvedValueOnce({ snapshot });
|
||||
const { getCodingConversationSnapshot } = await import('@/lib/coding-conversations');
|
||||
|
||||
await expect(getCodingConversationSnapshot('conversation/a')).resolves.toBe(snapshot);
|
||||
expect(hostApi.fetch).toHaveBeenCalledWith(
|
||||
'/api/coding/conversations/conversation%2Fa/snapshot',
|
||||
);
|
||||
});
|
||||
|
||||
it('submits only the product prompt DTO and returns its acceptance', async () => {
|
||||
const acceptance = {
|
||||
accepted: true,
|
||||
conversationId: 'conversation/a',
|
||||
clientRequestId: 'request-1',
|
||||
runId: 'run-1',
|
||||
mode: 'prompt',
|
||||
};
|
||||
hostApi.fetch.mockResolvedValueOnce({ acceptance });
|
||||
const { submitCodingConversationPrompt } = await import('@/lib/coding-conversations');
|
||||
|
||||
await expect(submitCodingConversationPrompt({
|
||||
conversationId: 'conversation/a',
|
||||
clientRequestId: 'request-1',
|
||||
mode: 'prompt',
|
||||
text: 'Build it',
|
||||
attachments: [{ attachmentId: 'attachment-1' }],
|
||||
})).resolves.toBe(acceptance);
|
||||
expect(hostApi.fetch).toHaveBeenCalledWith(
|
||||
'/api/coding/conversations/conversation%2Fa/prompt',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
clientRequestId: 'request-1',
|
||||
mode: 'prompt',
|
||||
text: 'Build it',
|
||||
attachments: [{ attachmentId: 'attachment-1' }],
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('hydrates the Host token before constructing the EventSource URL', async () => {
|
||||
const order: string[] = [];
|
||||
const source = { close: vi.fn() } as unknown as EventSource;
|
||||
hostApi.ensureToken.mockImplementationOnce(async () => {
|
||||
order.push('token');
|
||||
return 'host-token';
|
||||
});
|
||||
hostApi.createEvents.mockImplementationOnce((path: string) => {
|
||||
order.push(`events:${path}`);
|
||||
return source;
|
||||
});
|
||||
const { openCodingConversationEvents } = await import('@/lib/coding-conversations');
|
||||
|
||||
await expect(openCodingConversationEvents('conversation/a')).resolves.toBe(source);
|
||||
expect(order).toEqual([
|
||||
'token',
|
||||
'events:/api/coding/events?conversationId=conversation%2Fa',
|
||||
]);
|
||||
});
|
||||
});
|
||||
443
tests/unit/coding-conversations-store.test.tsx
Normal file
443
tests/unit/coding-conversations-store.test.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { useStore } from 'zustand';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { AppError } from '@/lib/error-model';
|
||||
import {
|
||||
createCodingConversationStore,
|
||||
selectCodingConversationSnapshot,
|
||||
} from '@/stores/coding-conversations';
|
||||
import type {
|
||||
CodingConversationPatchEvent,
|
||||
CodingConversationSnapshotEvent,
|
||||
ConversationPatch,
|
||||
ConversationSnapshot,
|
||||
PromptAcceptance,
|
||||
} from '@/types/coding-conversation';
|
||||
import { createProductSnapshot } from '../fixtures/coding-conversation-product-fixtures';
|
||||
|
||||
class FakeEventSource {
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
readonly close = vi.fn();
|
||||
private readonly listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
|
||||
|
||||
addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
|
||||
const listeners = this.listeners.get(type) ?? new Set();
|
||||
listeners.add(listener);
|
||||
this.listeners.set(type, listeners);
|
||||
}
|
||||
|
||||
emit(type: 'snapshot' | 'patch', value: unknown): void {
|
||||
const event = new MessageEvent(type, { data: JSON.stringify(value) });
|
||||
for (const listener of this.listeners.get(type) ?? []) {
|
||||
if (typeof listener === 'function') listener(event);
|
||||
else listener.handleEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
open(): void {
|
||||
this.onopen?.(new Event('open'));
|
||||
}
|
||||
|
||||
fail(): void {
|
||||
this.onerror?.(new Event('error'));
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot(
|
||||
conversationId: string,
|
||||
workerGeneration = 1,
|
||||
seq = 0,
|
||||
): ConversationSnapshot {
|
||||
const value = createProductSnapshot(conversationId, workerGeneration);
|
||||
return {
|
||||
...value,
|
||||
conversation: {
|
||||
...value.conversation,
|
||||
title: `Conversation ${conversationId}`,
|
||||
},
|
||||
cursor: { ...value.cursor, seq },
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotEvent(value: ConversationSnapshot): CodingConversationSnapshotEvent {
|
||||
return {
|
||||
type: 'snapshot',
|
||||
conversationId: value.conversation.id,
|
||||
workerGeneration: value.cursor.workerGeneration,
|
||||
seq: value.cursor.seq,
|
||||
snapshot: value,
|
||||
};
|
||||
}
|
||||
|
||||
function patchEvent(
|
||||
conversationId: string,
|
||||
seq: number,
|
||||
patch: ConversationPatch,
|
||||
workerGeneration = 1,
|
||||
): CodingConversationPatchEvent {
|
||||
return {
|
||||
type: 'patch',
|
||||
conversationId,
|
||||
workerGeneration,
|
||||
seq,
|
||||
at: 1_000 + seq,
|
||||
patch,
|
||||
};
|
||||
}
|
||||
|
||||
function acceptance(
|
||||
conversationId: string,
|
||||
clientRequestId: string,
|
||||
): PromptAcceptance {
|
||||
return {
|
||||
accepted: true,
|
||||
conversationId,
|
||||
clientRequestId,
|
||||
runId: 'run-1',
|
||||
mode: 'prompt',
|
||||
};
|
||||
}
|
||||
|
||||
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 ids() {
|
||||
const counters = { request: 0, node: 0 };
|
||||
return (kind: 'request' | 'node') => `${kind}-${++counters[kind]}`;
|
||||
}
|
||||
|
||||
describe('coding Conversation store', () => {
|
||||
it('isolates interleaved snapshot and patch state for two Conversations', async () => {
|
||||
const source = new FakeEventSource();
|
||||
const getSnapshot = vi.fn(async (conversationId: string) => snapshot(conversationId));
|
||||
const store = createCodingConversationStore({
|
||||
getSnapshot,
|
||||
openEvents: vi.fn(async () => source as unknown as EventSource),
|
||||
submitPrompt: vi.fn(),
|
||||
createId: ids(),
|
||||
});
|
||||
|
||||
await store.getState().selectConversation('conversation-a');
|
||||
source.open();
|
||||
source.emit('snapshot', snapshotEvent(snapshot('conversation-b')));
|
||||
const aBeforeB = selectCodingConversationSnapshot('conversation-a')(store.getState());
|
||||
|
||||
source.emit('patch', patchEvent('conversation-a', 1, {
|
||||
op: 'run.state',
|
||||
run: { status: 'running', runId: 'run-a', mode: 'prompt' },
|
||||
}));
|
||||
const aAfterOwnPatch = selectCodingConversationSnapshot('conversation-a')(store.getState());
|
||||
source.emit('patch', patchEvent('conversation-b', 1, {
|
||||
op: 'message.upsert',
|
||||
node: {
|
||||
kind: 'message',
|
||||
id: 'assistant-b',
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
blocks: [{ kind: 'text', id: 'text-b', text: 'B', status: 'streaming' }],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(aBeforeB?.run.status).toBe('idle');
|
||||
expect(aAfterOwnPatch?.run.status).toBe('running');
|
||||
expect(selectCodingConversationSnapshot('conversation-a')(store.getState()))
|
||||
.toBe(aAfterOwnPatch);
|
||||
expect(selectCodingConversationSnapshot('conversation-b')(store.getState())?.nodes)
|
||||
.toHaveLength(1);
|
||||
expect(store.getState().summariesByConversationId['conversation-a'].unread).toBe(false);
|
||||
expect(store.getState().summariesByConversationId['conversation-b'].unread).toBe(true);
|
||||
expect(getSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(store.getState().connectionState).toBe('live');
|
||||
});
|
||||
|
||||
it('refreshes only the gapped target and drops an older worker generation', async () => {
|
||||
const recoveredA = snapshot('conversation-a', 1, 2);
|
||||
const getSnapshot = vi.fn(async (conversationId: string) => {
|
||||
expect(conversationId).toBe('conversation-a');
|
||||
return recoveredA;
|
||||
});
|
||||
const store = createCodingConversationStore({
|
||||
getSnapshot,
|
||||
openEvents: vi.fn(),
|
||||
submitPrompt: vi.fn(),
|
||||
createId: ids(),
|
||||
});
|
||||
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
|
||||
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-b')));
|
||||
const bBefore = selectCodingConversationSnapshot('conversation-b')(store.getState());
|
||||
|
||||
store.getState().applyPatchEvent(patchEvent('conversation-b', 1, {
|
||||
op: 'run.state',
|
||||
run: { status: 'running' },
|
||||
}, 0));
|
||||
store.getState().applyPatchEvent(patchEvent('conversation-a', 2, {
|
||||
op: 'run.state',
|
||||
run: { status: 'running' },
|
||||
}));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(store.getState().entriesByConversationId['conversation-a'].loadState).toBe('live');
|
||||
});
|
||||
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.cursor.seq).toBe(2);
|
||||
expect(selectCodingConversationSnapshot('conversation-b')(store.getState())).toBe(bBefore);
|
||||
expect(store.getState().entriesByConversationId['conversation-b'].reducer.invalidation).toBeNull();
|
||||
});
|
||||
|
||||
it('uses native EventSource reconnect without reopening or replaying a mutation', async () => {
|
||||
const source = new FakeEventSource();
|
||||
const openEvents = vi.fn(async () => source as unknown as EventSource);
|
||||
const submitPrompt = vi.fn(async (input) => acceptance(input.conversationId, input.clientRequestId));
|
||||
const store = createCodingConversationStore({
|
||||
getSnapshot: vi.fn(async (conversationId: string) => snapshot(conversationId)),
|
||||
openEvents,
|
||||
submitPrompt,
|
||||
createId: ids(),
|
||||
});
|
||||
await store.getState().selectConversation('conversation-a');
|
||||
store.getState().setDraft('conversation-a', 'one request');
|
||||
await store.getState().submitPrompt({ conversationId: 'conversation-a', mode: 'prompt' });
|
||||
|
||||
source.fail();
|
||||
expect(store.getState().connectionState).toBe('reconnecting');
|
||||
source.open();
|
||||
|
||||
expect(store.getState().connectionState).toBe('live');
|
||||
expect(openEvents).toHaveBeenCalledTimes(1);
|
||||
expect(submitPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not let a cancelled connection clear a newer connection flight', async () => {
|
||||
const first = deferred<EventSource>();
|
||||
const second = deferred<EventSource>();
|
||||
const firstSource = new FakeEventSource();
|
||||
const secondSource = new FakeEventSource();
|
||||
const openEvents = vi.fn()
|
||||
.mockImplementationOnce(() => first.promise)
|
||||
.mockImplementationOnce(() => second.promise);
|
||||
const store = createCodingConversationStore({
|
||||
getSnapshot: vi.fn(),
|
||||
openEvents,
|
||||
submitPrompt: vi.fn(),
|
||||
createId: ids(),
|
||||
});
|
||||
|
||||
const firstConnect = store.getState().connectEvents();
|
||||
store.getState().disconnectEvents();
|
||||
const secondConnect = store.getState().connectEvents();
|
||||
first.resolve(firstSource as unknown as EventSource);
|
||||
await firstConnect;
|
||||
const joinedSecondConnect = store.getState().connectEvents();
|
||||
|
||||
expect(openEvents).toHaveBeenCalledTimes(2);
|
||||
expect(firstSource.close).toHaveBeenCalledOnce();
|
||||
second.resolve(secondSource as unknown as EventSource);
|
||||
await Promise.all([secondConnect, joinedSecondConnect]);
|
||||
expect(openEvents).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('reconciles an accepted optimistic node by clientRequestId without changing its UI id', async () => {
|
||||
const pending = deferred<PromptAcceptance>();
|
||||
const store = createCodingConversationStore({
|
||||
getSnapshot: vi.fn(),
|
||||
openEvents: vi.fn(),
|
||||
submitPrompt: vi.fn(() => pending.promise),
|
||||
createId: ids(),
|
||||
});
|
||||
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
|
||||
store.getState().setDraft('conversation-a', 'Build it', [{
|
||||
attachmentId: 'attachment-1',
|
||||
mime: 'image/png',
|
||||
previewUrl: 'blob:preview',
|
||||
}]);
|
||||
|
||||
const submission = store.getState().submitPrompt({
|
||||
conversationId: 'conversation-a',
|
||||
mode: 'prompt',
|
||||
});
|
||||
const optimistic = selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0];
|
||||
expect(optimistic).toMatchObject({ id: 'node-1', clientRequestId: 'request-1', status: 'optimistic' });
|
||||
expect(store.getState().draftsByConversationId['conversation-a']).toMatchObject({
|
||||
text: '',
|
||||
attachments: [],
|
||||
});
|
||||
|
||||
pending.resolve(acceptance('conversation-a', 'request-1'));
|
||||
await submission;
|
||||
expect(store.getState().requestsByConversationId['conversation-a']['request-1'].status)
|
||||
.toBe('accepted');
|
||||
store.getState().applyPatchEvent(patchEvent('conversation-a', 1, {
|
||||
op: 'message.upsert',
|
||||
node: {
|
||||
kind: 'message',
|
||||
id: 'durable-user-1',
|
||||
sourceEntryId: 'entry-user-1',
|
||||
clientRequestId: 'request-1',
|
||||
role: 'user',
|
||||
status: 'complete',
|
||||
blocks: [{ kind: 'text', id: 'text-1', text: 'Build it', status: 'complete' }],
|
||||
},
|
||||
}));
|
||||
|
||||
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0])
|
||||
.toMatchObject({ id: 'node-1', sourceEntryId: 'entry-user-1', status: 'complete' });
|
||||
expect(store.getState().requestsByConversationId['conversation-a']).toEqual({});
|
||||
});
|
||||
|
||||
it('restores draft attachments and marks the optimistic node on definite rejection', async () => {
|
||||
const submitPrompt = vi.fn(async () => {
|
||||
throw new AppError('RUNTIME', 'Model unavailable', undefined, {
|
||||
backendCode: 'CODING_MODEL_UNAVAILABLE',
|
||||
});
|
||||
});
|
||||
const store = createCodingConversationStore({
|
||||
getSnapshot: vi.fn(),
|
||||
openEvents: vi.fn(),
|
||||
submitPrompt,
|
||||
createId: ids(),
|
||||
});
|
||||
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
|
||||
store.getState().setDraft('conversation-a', 'Try again', [{
|
||||
attachmentId: 'attachment-1',
|
||||
mime: 'image/png',
|
||||
previewUrl: 'blob:preview',
|
||||
}]);
|
||||
|
||||
await expect(store.getState().submitPrompt({
|
||||
conversationId: 'conversation-a',
|
||||
mode: 'prompt',
|
||||
})).rejects.toThrow('Model unavailable');
|
||||
|
||||
expect(store.getState().draftsByConversationId['conversation-a']).toMatchObject({
|
||||
text: 'Try again',
|
||||
attachments: [{ attachmentId: 'attachment-1', mime: 'image/png', previewUrl: 'blob:preview' }],
|
||||
});
|
||||
expect(store.getState().requestsByConversationId['conversation-a']['request-1'])
|
||||
.toMatchObject({ status: 'rejected', errorCode: 'CODING_MODEL_UNAVAILABLE' });
|
||||
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0])
|
||||
.toMatchObject({ id: 'node-1', status: 'error' });
|
||||
});
|
||||
|
||||
it('keeps an uncertain optimistic request recoverable without replaying it on reconnect', async () => {
|
||||
const source = new FakeEventSource();
|
||||
const submitPrompt = vi.fn(async () => {
|
||||
throw new AppError('NETWORK', 'Delivery is uncertain', undefined, {
|
||||
backendCode: 'CODING_REQUEST_UNCERTAIN',
|
||||
});
|
||||
});
|
||||
const store = createCodingConversationStore({
|
||||
getSnapshot: vi.fn(async (conversationId: string) => snapshot(conversationId)),
|
||||
openEvents: vi.fn(async () => source as unknown as EventSource),
|
||||
submitPrompt,
|
||||
createId: ids(),
|
||||
});
|
||||
await store.getState().selectConversation('conversation-a');
|
||||
store.getState().setDraft('conversation-a', 'Do not replay');
|
||||
|
||||
await expect(store.getState().submitPrompt({
|
||||
conversationId: 'conversation-a',
|
||||
mode: 'prompt',
|
||||
})).rejects.toThrow('Delivery is uncertain');
|
||||
source.fail();
|
||||
source.open();
|
||||
|
||||
expect(store.getState().draftsByConversationId['conversation-a'].text).toBe('Do not replay');
|
||||
expect(store.getState().requestsByConversationId['conversation-a']['request-1'])
|
||||
.toMatchObject({ status: 'uncertain', errorCode: 'CODING_REQUEST_UNCERTAIN' });
|
||||
expect(selectCodingConversationSnapshot('conversation-a')(store.getState())?.nodes[0])
|
||||
.toMatchObject({ id: 'node-1', status: 'optimistic' });
|
||||
expect(submitPrompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not rerender a selected timeline when a hidden Conversation streams', () => {
|
||||
const store = createCodingConversationStore({
|
||||
getSnapshot: vi.fn(),
|
||||
openEvents: vi.fn(),
|
||||
submitPrompt: vi.fn(),
|
||||
createId: ids(),
|
||||
});
|
||||
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-a')));
|
||||
store.getState().applySnapshotEvent(snapshotEvent(snapshot('conversation-b')));
|
||||
const selectANodes = (state: ReturnType<typeof store.getState>) => (
|
||||
selectCodingConversationSnapshot('conversation-a')(state)?.nodes ?? []
|
||||
);
|
||||
let renderCount = 0;
|
||||
const { result } = renderHook(() => {
|
||||
renderCount += 1;
|
||||
return useStore(store, selectANodes);
|
||||
});
|
||||
const initialRenderCount = renderCount;
|
||||
const aNodes = result.current;
|
||||
|
||||
act(() => {
|
||||
store.getState().applyPatchEvent(patchEvent('conversation-b', 1, {
|
||||
op: 'message.upsert',
|
||||
node: {
|
||||
kind: 'message',
|
||||
id: 'assistant-b',
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
blocks: [{ kind: 'text', id: 'text-b', text: 'token', status: 'streaming' }],
|
||||
},
|
||||
}));
|
||||
});
|
||||
expect(renderCount).toBe(initialRenderCount);
|
||||
expect(result.current).toBe(aNodes);
|
||||
const streamingSummary = store.getState().summariesByConversationId['conversation-b'];
|
||||
act(() => {
|
||||
store.getState().applyPatchEvent(patchEvent('conversation-b', 2, {
|
||||
op: 'message.block-delta',
|
||||
messageId: 'assistant-b',
|
||||
blockId: 'text-b',
|
||||
delta: ' two',
|
||||
}));
|
||||
});
|
||||
expect(store.getState().summariesByConversationId['conversation-b']).toBe(streamingSummary);
|
||||
expect(renderCount).toBe(initialRenderCount);
|
||||
|
||||
act(() => {
|
||||
store.getState().applyPatchEvent(patchEvent('conversation-a', 1, {
|
||||
op: 'message.upsert',
|
||||
node: {
|
||||
kind: 'message',
|
||||
id: 'assistant-a',
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
blocks: [{ kind: 'text', id: 'text-a', text: 'token', status: 'streaming' }],
|
||||
},
|
||||
}));
|
||||
});
|
||||
expect(renderCount).toBe(initialRenderCount + 1);
|
||||
expect(result.current).not.toBe(aNodes);
|
||||
});
|
||||
|
||||
it('keeps the Renderer surface vendor-neutral and both runtimes on the same reducer', async () => {
|
||||
const files = [
|
||||
'../../src/types/coding-conversation.ts',
|
||||
'../../src/lib/coding-conversations.ts',
|
||||
'../../src/stores/coding-conversations.ts',
|
||||
];
|
||||
for (const relativePath of files) {
|
||||
const source = await readFile(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8');
|
||||
expect(source).not.toMatch(/opencode|(?:import|export)[\s\S]*?from\s+['"][^'"]*pi(?:-|\/)/i);
|
||||
expect(source).not.toMatch(/\bPi[A-Z][A-Za-z]*/);
|
||||
}
|
||||
|
||||
const [shared, main] = await Promise.all([
|
||||
import('../../shared/coding-conversation-reducer'),
|
||||
import('../../electron/coding-runtime/conversation-reducer'),
|
||||
]);
|
||||
expect(main.createConversationReducerState).toBe(shared.createConversationReducerState);
|
||||
expect(main.reduceConversationPatch).toBe(shared.reduceConversationPatch);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user