|
19 | 19 | * request it aborted was accepted. |
20 | 20 | */ |
21 | 21 |
|
22 | | -import { act, type ReactNode, StrictMode, useEffect, useState } from 'react' |
| 22 | +import { type ReactNode, act as reactAct, StrictMode, useEffect, useState } from 'react' |
23 | 23 | import { authClientMock, authClientMockFns } from '@sim/testing/mocks/auth-client.mock' |
24 | 24 | import { libDesktopMock, libDesktopMockFns } from '@sim/testing/mocks/lib-desktop.mock' |
25 | 25 | import { nextNavigationMock, nextNavigationMockFns } from '@sim/testing/mocks/next-navigation.mock' |
@@ -103,6 +103,36 @@ import { useExecutionStore } from '@/stores/execution/store' |
103 | 103 | import { useMothershipEffortStore } from '@/stores/mothership-effort/store' |
104 | 104 | import { useMothershipQueueStore } from '@/stores/mothership-queue/store' |
105 | 105 |
|
| 106 | +/** Captured before any test fakes timers, so the act budget below runs in real time. */ |
| 107 | +const realSetTimeout = globalThis.setTimeout |
| 108 | +const realClearTimeout = globalThis.clearTimeout |
| 109 | +/** Well under the 10s test timeout: the scope must end before the runner abandons the test. */ |
| 110 | +const ACT_BUDGET_MS = 6_000 |
| 111 | + |
| 112 | +/** |
| 113 | + * React's `act`, with async callbacks bounded. A callback that never settles (a |
| 114 | + * regressed send stuck reconnecting) used to run into the test timeout while |
| 115 | + * still inside React's act scope, and the renders of every later test queued |
| 116 | + * behind it ("Hook result is not ready"). Failing the act after a budget ends |
| 117 | + * the scope, so one regression is one red test. Sync callbacks stay synchronous. |
| 118 | + */ |
| 119 | +function act(callback: () => unknown): Promise<void> { |
| 120 | + return reactAct((): undefined | Promise<void> => { |
| 121 | + const result = callback() |
| 122 | + if (!(result instanceof Promise)) return undefined |
| 123 | + let budget: ReturnType<typeof setTimeout> | undefined |
| 124 | + const budgetSpent = new Promise<never>((_, reject) => { |
| 125 | + budget = realSetTimeout( |
| 126 | + () => reject(new Error(`act callback still pending after ${ACT_BUDGET_MS}ms`)), |
| 127 | + ACT_BUDGET_MS |
| 128 | + ) |
| 129 | + }) |
| 130 | + return Promise.race([result.then(() => undefined), budgetSpent]).finally(() => |
| 131 | + realClearTimeout(budget) |
| 132 | + ) |
| 133 | + }) |
| 134 | +} |
| 135 | + |
106 | 136 | authClientMockFns.mockUseSession.mockImplementation(() => ({ |
107 | 137 | data: { user: { id: 'test-viewer' } }, |
108 | 138 | })) |
@@ -2022,6 +2052,119 @@ describe('useChat remount send recovery', () => { |
2022 | 2052 | expect(state.abortBodies[0]).not.toHaveProperty('chatId') |
2023 | 2053 | }) |
2024 | 2054 |
|
| 2055 | + /** |
| 2056 | + * The first POST on the new-chat surface never answers; later POSTs open a |
| 2057 | + * turn in the chat the first message created. The abort endpoint and the |
| 2058 | + * stream lookup fail, as they would for a Stop that cannot reach the server. |
| 2059 | + */ |
| 2060 | + function stubFirstPostPendingThenAdmitted() { |
| 2061 | + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { |
| 2062 | + const url = String(input) |
| 2063 | + if (url === '/api/mothership/chat' && init?.method === 'POST') { |
| 2064 | + state.postBodies.push(JSON.parse(String(init.body))) |
| 2065 | + if (state.postBodies.length === 1) { |
| 2066 | + return new Promise<Response>((_, reject) => { |
| 2067 | + init.signal?.addEventListener('abort', () => reject(init.signal?.reason), { |
| 2068 | + once: true, |
| 2069 | + }) |
| 2070 | + }) |
| 2071 | + } |
| 2072 | + return new Response( |
| 2073 | + new ReadableStream<Uint8Array>({ |
| 2074 | + start(controller) { |
| 2075 | + controller.close() |
| 2076 | + }, |
| 2077 | + }), |
| 2078 | + { |
| 2079 | + status: 200, |
| 2080 | + headers: { |
| 2081 | + 'Content-Type': 'text/event-stream', |
| 2082 | + 'x-mothership-chat-id': DEDUPED_CHAT_ID, |
| 2083 | + }, |
| 2084 | + } |
| 2085 | + ) |
| 2086 | + } |
| 2087 | + if (url.includes('/api/copilot/chat/abort')) { |
| 2088 | + state.abortBodies.push(JSON.parse(String(init?.body))) |
| 2089 | + return Response.json({ error: 'Internal error' }, { status: 500 }) |
| 2090 | + } |
| 2091 | + if (url.includes('/api/mothership/chat/stream') && state.postBodies.length === 1) { |
| 2092 | + return Response.json({ error: 'Internal error' }, { status: 500 }) |
| 2093 | + } |
| 2094 | + return fetchStub(input, init) |
| 2095 | + }) |
| 2096 | + } |
| 2097 | + |
| 2098 | + /** |
| 2099 | + * A follow-up typed on the new-chat surface while the first message waits for |
| 2100 | + * the server is queued behind it. If the surface remounts then, the first |
| 2101 | + * message is withdrawn and both must reach the next mount in the order they |
| 2102 | + * were written: the first message (under its own id), then the follow-up. |
| 2103 | + */ |
| 2104 | + it('sends a withdrawn first message before its follow-up when the new-chat surface remounts', async () => { |
| 2105 | + stubFirstPostPendingThenAdmitted() |
| 2106 | + const first = renderHomeLikeSurface() |
| 2107 | + await act(async () => { |
| 2108 | + void first.getResult().sendMessage('inspect the workspace') |
| 2109 | + }) |
| 2110 | + await waitFor(() => state.postBodies.length === 1) |
| 2111 | + await act(async () => { |
| 2112 | + void first.getResult().sendMessage('follow-up while admission pending') |
| 2113 | + }) |
| 2114 | + await waitFor(() => allQueuedMessages().length === 1) |
| 2115 | + first.unmount() |
| 2116 | + |
| 2117 | + const second = renderHomeLikeSurface() |
| 2118 | + await waitFor(() => state.postBodies.length >= 3, 4_000) |
| 2119 | + await act(async () => { |
| 2120 | + await sleep(300) |
| 2121 | + }) |
| 2122 | + |
| 2123 | + const afterRemount = state.postBodies.slice(1) |
| 2124 | + expect(afterRemount.map((body) => body.message)).toEqual([ |
| 2125 | + 'inspect the workspace', |
| 2126 | + 'follow-up while admission pending', |
| 2127 | + ]) |
| 2128 | + expect(afterRemount[0].userMessageId).toBe(state.postBodies[0].userMessageId) |
| 2129 | + expect(afterRemount[1].chatId).toBe(DEDUPED_CHAT_ID) |
| 2130 | + expect(second.claimedByOwnListener()).toBe(0) |
| 2131 | + expect(allQueuedMessages()).toHaveLength(0) |
| 2132 | + }) |
| 2133 | + |
| 2134 | + /** |
| 2135 | + * After a Stop of the first message, only the follow-up was the user's |
| 2136 | + * intent: the Stop's POST is left to the server, nothing withdraws it, and the |
| 2137 | + * next mount sends just the follow-up, once. |
| 2138 | + */ |
| 2139 | + it('sends only the follow-up after a failed Stop when the new-chat surface remounts', async () => { |
| 2140 | + stubFirstPostPendingThenAdmitted() |
| 2141 | + const first = renderHomeLikeSurface() |
| 2142 | + await act(async () => { |
| 2143 | + void first.getResult().sendMessage('inspect the workspace') |
| 2144 | + }) |
| 2145 | + await waitFor(() => state.postBodies.length === 1) |
| 2146 | + await act(async () => { |
| 2147 | + void first |
| 2148 | + .getResult() |
| 2149 | + .stopGeneration() |
| 2150 | + .catch(() => {}) |
| 2151 | + void first.getResult().sendMessage('Sent while the Stop was failing') |
| 2152 | + await sleep(1_000) |
| 2153 | + }) |
| 2154 | + first.unmount() |
| 2155 | + |
| 2156 | + renderHomeLikeSurface() |
| 2157 | + await waitFor(() => state.postBodies.length >= 2, 4_000) |
| 2158 | + await act(async () => { |
| 2159 | + await sleep(500) |
| 2160 | + }) |
| 2161 | + |
| 2162 | + expect(state.postBodies.slice(1).map((body) => body.message)).toEqual([ |
| 2163 | + 'Sent while the Stop was failing', |
| 2164 | + ]) |
| 2165 | + expect(allQueuedMessages()).toHaveLength(0) |
| 2166 | + }) |
| 2167 | + |
2025 | 2168 | it('stopping a chat preserves an unrelated manual workflow execution', async () => { |
2026 | 2169 | const executionStore = useExecutionStore.getState() |
2027 | 2170 | executionStore.setIsExecuting('manual-workflow', true) |
|
0 commit comments