llm-client.usage.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. import { beforeEach, describe, expect, it, vi } from "vitest"
  2. import type { LlmConfig } from "@/stores/wiki-store"
  3. import { streamChat } from "./llm-client"
  4. import { estimateChatMessagesTokens } from "./chat-request-budget"
  5. import type { ChatMessage } from "./llm-providers"
  6. import { thinkingMinMaxTokens } from "./llm-providers"
  7. import {
  8. RESPONSE_RESERVE_FRAC,
  9. planLlmRequestBudget,
  10. } from "./context-budget"
  11. import { normalizeUserLlmMaxOutputTokens } from "./llm-context-size"
  12. const mocks = vi.hoisted(() => ({
  13. fetch: vi.fn(),
  14. isFetchNetworkError: vi.fn(() => false),
  15. streamClaudeCodeCli: vi.fn(),
  16. }))
  17. vi.mock("./tauri-fetch", () => ({
  18. getHttpFetch: vi.fn(async () => mocks.fetch),
  19. isFetchNetworkError: (...args: unknown[]) => mocks.isFetchNetworkError(...args),
  20. }))
  21. vi.mock("./local-cli-config", () => ({
  22. resolveRuntimeLocalCliConfig: vi.fn(async (config: LlmConfig) => config),
  23. }))
  24. vi.mock("./claude-cli-transport", () => ({
  25. streamClaudeCodeCli: (...args: unknown[]) => mocks.streamClaudeCodeCli(...args),
  26. }))
  27. const config: LlmConfig = {
  28. provider: "openai",
  29. apiKey: "sk-test",
  30. model: "gpt-test",
  31. ollamaUrl: "",
  32. customEndpoint: "",
  33. maxContextSize: 128_000,
  34. }
  35. describe("streamChat usage", () => {
  36. beforeEach(() => {
  37. mocks.fetch.mockReset()
  38. mocks.isFetchNetworkError.mockReset()
  39. mocks.isFetchNetworkError.mockReturnValue(false)
  40. mocks.streamClaudeCodeCli.mockReset()
  41. })
  42. it("requests and emits OpenAI stream usage once", async () => {
  43. const encoder = new TextEncoder()
  44. const body = new ReadableStream<Uint8Array>({
  45. start(controller) {
  46. controller.enqueue(encoder.encode([
  47. 'data: {"choices":[{"delta":{"content":"完成"}}]}',
  48. 'data: {"choices":[],"usage":{"prompt_tokens":1200,"completion_tokens":80,"total_tokens":1280,"prompt_tokens_details":{"cached_tokens":1024}}}',
  49. "data: [DONE]",
  50. "",
  51. ].join("\n")))
  52. controller.close()
  53. },
  54. })
  55. mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
  56. const onUsage = vi.fn()
  57. const onDone = vi.fn()
  58. const onError = vi.fn()
  59. const onRequestTrace = vi.fn()
  60. await streamChat(config, [
  61. {
  62. role: "system",
  63. content: [
  64. { type: "text", text: "固定规则" },
  65. { type: "text", text: "项目稳定核心", cacheControl: true },
  66. { type: "text", text: "动态任务" },
  67. ],
  68. },
  69. { role: "user", content: "测试" },
  70. ], {
  71. onToken: vi.fn(),
  72. onUsage,
  73. onRequestTrace,
  74. onDone,
  75. onError,
  76. })
  77. const request = mocks.fetch.mock.calls[0][1] as RequestInit
  78. expect(JSON.parse(String(request.body))).toMatchObject({
  79. stream: true,
  80. stream_options: { include_usage: true },
  81. })
  82. expect(onUsage).toHaveBeenCalledOnce()
  83. expect(onUsage).toHaveBeenCalledWith({
  84. inputTokens: 1200,
  85. outputTokens: 80,
  86. totalTokens: 1280,
  87. cachedInputTokens: 1024,
  88. })
  89. expect(onDone).toHaveBeenCalledOnce()
  90. expect(onError).not.toHaveBeenCalled()
  91. expect(onRequestTrace).toHaveBeenCalledOnce()
  92. expect(onRequestTrace).toHaveBeenCalledWith(expect.objectContaining({
  93. provider: "openai",
  94. model: "gpt-test",
  95. prefixFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
  96. inputTokens: 1200,
  97. outputTokens: 80,
  98. cacheReadTokens: 1024,
  99. status: "success",
  100. }))
  101. })
  102. it("同行 tool_calls 仍触发 onReasoningToken", async () => {
  103. const encoder = new TextEncoder()
  104. const body = new ReadableStream<Uint8Array>({
  105. start(controller) {
  106. controller.enqueue(encoder.encode([
  107. 'data: {"choices":[{"delta":{"reasoning_content":"需要读章","tool_calls":[{"index":0,"id":"call_1","function":{"name":"read_chapter","arguments":"{}"}}]}}]}',
  108. "data: [DONE]",
  109. "",
  110. ].join("\n")))
  111. controller.close()
  112. },
  113. })
  114. mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
  115. const onReasoningToken = vi.fn()
  116. const onToolCallDelta = vi.fn()
  117. await streamChat(config, [{ role: "user", content: "写第一章" }], {
  118. onToken: vi.fn(),
  119. onReasoningToken,
  120. onToolCallDelta,
  121. onDone: vi.fn(),
  122. onError: vi.fn(),
  123. })
  124. expect(onReasoningToken).toHaveBeenCalledWith("需要读章")
  125. expect(onToolCallDelta).toHaveBeenCalledWith(expect.objectContaining({
  126. id: "call_1",
  127. name: "read_chapter",
  128. }))
  129. })
  130. it("does not treat reasoning plus tool calls as a reasoning-only failure", async () => {
  131. const thinking = "先列出大纲和章节再决定怎么写。".repeat(20)
  132. expect(thinking.length).toBeGreaterThan(200)
  133. const encoder = new TextEncoder()
  134. const body = new ReadableStream<Uint8Array>({
  135. start(controller) {
  136. controller.enqueue(encoder.encode([
  137. `data: {"choices":[{"delta":{"reasoning_content":${JSON.stringify(thinking)}}}]}`,
  138. 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"list_outlines","arguments":"{}"}}]}}]}',
  139. "data: [DONE]",
  140. "",
  141. ].join("\n")))
  142. controller.close()
  143. },
  144. })
  145. mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
  146. const onError = vi.fn()
  147. const onToolCallDelta = vi.fn()
  148. const onDone = vi.fn()
  149. await streamChat(config, [{ role: "user", content: "写第45章" }], {
  150. onToken: vi.fn(),
  151. onToolCallDelta,
  152. onDone,
  153. onError,
  154. })
  155. expect(onToolCallDelta).toHaveBeenCalled()
  156. expect(onDone).toHaveBeenCalledOnce()
  157. expect(onError).not.toHaveBeenCalled()
  158. })
  159. it("disables thinking and drops empty reasoning before a tool-follow-up request", async () => {
  160. mocks.fetch.mockResolvedValue(new Response([
  161. 'data: {"choices":[{"delta":{"content":"继续"}}]}',
  162. "data: [DONE]",
  163. "",
  164. ].join("\n"), { status: 200 }))
  165. const deepseekConfig: LlmConfig = {
  166. ...config,
  167. provider: "custom",
  168. model: "deepseek/deepseek-v4-flash",
  169. customEndpoint: "https://api.deepseek.com/v1",
  170. reasoning: { mode: "high" },
  171. }
  172. await streamChat(deepseekConfig, [
  173. { role: "user", content: "写第45章" },
  174. {
  175. role: "assistant",
  176. content: "",
  177. tool_calls: [{
  178. id: "call_1",
  179. type: "function",
  180. function: { name: "list_outlines", arguments: "{}" },
  181. }],
  182. reasoning_content: "",
  183. },
  184. { role: "tool", content: "大纲列表", tool_call_id: "call_1", name: "list_outlines" },
  185. ], {
  186. onToken: vi.fn(),
  187. onDone: vi.fn(),
  188. onError: vi.fn(),
  189. })
  190. const request = mocks.fetch.mock.calls[0][1] as RequestInit
  191. const body = JSON.parse(String(request.body)) as {
  192. thinking?: { type: string }
  193. messages: Array<{ reasoning_content?: string }>
  194. }
  195. expect(body.thinking).toEqual({ type: "disabled" })
  196. expect(body.messages[1]).not.toHaveProperty("reasoning_content")
  197. })
  198. it("retries a reasoning_content 400 once with thinking disabled", async () => {
  199. const encoder = new TextEncoder()
  200. mocks.fetch
  201. .mockResolvedValueOnce(new Response(
  202. JSON.stringify({
  203. error: {
  204. message: "The reasoning_content in the thinking mode must be passed back to the API.",
  205. type: "invalid_request_error",
  206. },
  207. }),
  208. { status: 400 },
  209. ))
  210. .mockResolvedValueOnce(new Response(new ReadableStream<Uint8Array>({
  211. start(controller) {
  212. controller.enqueue(encoder.encode([
  213. 'data: {"choices":[{"delta":{"content":"已继续"}}]}',
  214. "data: [DONE]",
  215. "",
  216. ].join("\n")))
  217. controller.close()
  218. },
  219. }), { status: 200 }))
  220. const deepseekConfig: LlmConfig = {
  221. ...config,
  222. provider: "custom",
  223. model: "deepseek/deepseek-v4-flash",
  224. customEndpoint: "https://api.deepseek.com/v1",
  225. reasoning: { mode: "high" },
  226. }
  227. const onToken = vi.fn()
  228. const onError = vi.fn()
  229. await streamChat(deepseekConfig, [
  230. { role: "user", content: "写第45章" },
  231. {
  232. role: "assistant",
  233. content: "先读大纲",
  234. reasoning_content: "看起来像思考但接口仍拒收",
  235. },
  236. ], {
  237. onToken,
  238. onDone: vi.fn(),
  239. onError,
  240. })
  241. expect(mocks.fetch).toHaveBeenCalledTimes(2)
  242. const retryBody = JSON.parse(String((mocks.fetch.mock.calls[1][1] as RequestInit).body)) as {
  243. thinking?: { type: string }
  244. }
  245. expect(retryBody.thinking).toEqual({ type: "disabled" })
  246. expect(onToken).toHaveBeenCalledWith("已继续")
  247. expect(onError).not.toHaveBeenCalled()
  248. })
  249. it("发送前按 token 预算裁剪并保持系统与当前请求非空", async () => {
  250. mocks.fetch.mockResolvedValue(new Response([
  251. 'data: {"choices":[{"delta":{"content":"完成"}}]}',
  252. "data: [DONE]",
  253. "",
  254. ].join("\n"), { status: 200 }))
  255. // Window clamps to ≥204800; overflow with a large middle user turn so trim
  256. // must drop history while keeping the system + current-user ends intact.
  257. const windowTokens = 204_800
  258. const outputReserve = Math.floor(windowTokens * RESPONSE_RESERVE_FRAC)
  259. await streamChat({ ...config, maxContextSize: windowTokens }, [
  260. { role: "system", content: "系统".repeat(20_000) },
  261. { role: "user", content: "旧请求".repeat(90_000) },
  262. { role: "assistant", content: "旧回复".repeat(90_000) },
  263. { role: "user", content: `任务目标:续写。${"正文".repeat(40_000)}结尾限制:保持人物关系。` },
  264. ], {
  265. onToken: vi.fn(),
  266. onDone: vi.fn(),
  267. onError: vi.fn(),
  268. })
  269. const request = mocks.fetch.mock.calls[0][1] as RequestInit
  270. const body = JSON.parse(String(request.body)) as {
  271. messages: ChatMessage[]
  272. max_tokens?: number
  273. }
  274. expect(estimateChatMessagesTokens(body.messages)).toBeLessThanOrEqual(
  275. windowTokens - outputReserve,
  276. )
  277. expect(String(body.messages[0]?.content).trim()).not.toBe("")
  278. expect(body.messages.at(-1)?.content).toContain("任务目标")
  279. expect(body.messages.at(-1)?.content).toContain("保持人物关系")
  280. })
  281. it("超大受保护消息在归一化窗口下会被压缩后仍发送", async () => {
  282. // With maxContextSize clamped to ≥204800, the old "tiny window → hard fail"
  283. // path is unreachable through streamChat; protected ends are compressed
  284. // instead so the request can still leave.
  285. mocks.fetch.mockResolvedValue(new Response([
  286. 'data: {"choices":[{"delta":{"content":"完成"}}]}',
  287. "data: [DONE]",
  288. "",
  289. ].join("\n"), { status: 200 }))
  290. await streamChat({ ...config, maxContextSize: 204_800 }, [
  291. { role: "system", content: "系统".repeat(120_000) },
  292. { role: "user", content: "生成".repeat(120_000) },
  293. ], {
  294. onToken: vi.fn(),
  295. onDone: vi.fn(),
  296. onError: vi.fn(),
  297. })
  298. expect(mocks.fetch).toHaveBeenCalledTimes(1)
  299. const body = JSON.parse(String((mocks.fetch.mock.calls[0][1] as RequestInit).body)) as {
  300. messages: ChatMessage[]
  301. }
  302. expect(estimateChatMessagesTokens(body.messages)).toBeLessThan(204_800)
  303. expect(String(body.messages[0]?.content).trim()).not.toBe("")
  304. expect(String(body.messages.at(-1)?.content).trim()).not.toBe("")
  305. })
  306. it("调用方未传 max_tokens 时仍外发窗口比例预算", async () => {
  307. mocks.fetch.mockResolvedValue(new Response([
  308. 'data: {"choices":[{"delta":{"content":"完成"}}]}',
  309. "data: [DONE]",
  310. "",
  311. ].join("\n"), { status: 200 }))
  312. const planned = planLlmRequestBudget({
  313. maxContextSize: config.maxContextSize,
  314. desiredOutputTokens: Math.floor(
  315. Math.max(204_800, config.maxContextSize) * RESPONSE_RESERVE_FRAC,
  316. ),
  317. scaffoldReserveTokens: 0,
  318. minimumContextTokens: 64,
  319. maxOutputTokensCap: normalizeUserLlmMaxOutputTokens(config.maxOutputTokens),
  320. })
  321. await streamChat(config, [{ role: "user", content: "写第一章" }], {
  322. onToken: vi.fn(),
  323. onDone: vi.fn(),
  324. onError: vi.fn(),
  325. })
  326. const request = mocks.fetch.mock.calls[0][1] as RequestInit
  327. expect(JSON.parse(String(request.body))).toMatchObject({
  328. max_tokens: planned.outputTokens,
  329. })
  330. })
  331. it("reasoning.mode=auto 且调用方未传 max_tokens 时仍外发窗口比例预算", async () => {
  332. mocks.fetch.mockResolvedValue(new Response([
  333. 'data: {"choices":[{"delta":{"content":"完成"}}]}',
  334. "data: [DONE]",
  335. "",
  336. ].join("\n"), { status: 200 }))
  337. const planned = planLlmRequestBudget({
  338. maxContextSize: config.maxContextSize,
  339. desiredOutputTokens: Math.floor(
  340. Math.max(204_800, config.maxContextSize) * RESPONSE_RESERVE_FRAC,
  341. ),
  342. scaffoldReserveTokens: 0,
  343. minimumContextTokens: 64,
  344. maxOutputTokensCap: normalizeUserLlmMaxOutputTokens(config.maxOutputTokens),
  345. })
  346. await streamChat(
  347. { ...config, reasoning: { mode: "auto" } },
  348. [{ role: "user", content: "写第一章" }],
  349. { onToken: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
  350. )
  351. const request = mocks.fetch.mock.calls[0][1] as RequestInit
  352. expect(JSON.parse(String(request.body))).toMatchObject({
  353. max_tokens: planned.outputTokens,
  354. })
  355. })
  356. it("reasoning.mode=high 且调用方未传 max_tokens 时发送预算规划的 max_tokens", async () => {
  357. mocks.fetch.mockResolvedValue(new Response([
  358. 'data: {"choices":[{"delta":{"content":"完成"}}]}',
  359. "data: [DONE]",
  360. "",
  361. ].join("\n"), { status: 200 }))
  362. const reasoning = { mode: "high" as const }
  363. const thinkingFloorTokens = thinkingMinMaxTokens(reasoning)
  364. expect(thinkingFloorTokens).toBeGreaterThan(0)
  365. const windowTokens = Math.max(204_800, config.maxContextSize)
  366. const planned = planLlmRequestBudget({
  367. maxContextSize: windowTokens,
  368. desiredOutputTokens: Math.floor(windowTokens * RESPONSE_RESERVE_FRAC),
  369. scaffoldReserveTokens: 0,
  370. minimumContextTokens: 64,
  371. maxOutputTokensCap: normalizeUserLlmMaxOutputTokens(config.maxOutputTokens),
  372. thinkingFloorTokens,
  373. })
  374. await streamChat(
  375. { ...config, reasoning },
  376. [{ role: "user", content: "写第一章" }],
  377. { onToken: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
  378. )
  379. const request = mocks.fetch.mock.calls[0][1] as RequestInit
  380. expect(JSON.parse(String(request.body))).toMatchObject({
  381. max_tokens: planned.outputTokens,
  382. })
  383. expect(planned.outputTokens).toBeGreaterThanOrEqual(thinkingFloorTokens)
  384. })
  385. it("调用方显式传入的超大 max_tokens 收敛到输出上限", async () => {
  386. mocks.fetch.mockResolvedValue(new Response([
  387. 'data: {"choices":[{"delta":{"content":"完成"}}]}',
  388. "data: [DONE]",
  389. "",
  390. ].join("\n"), { status: 200 }))
  391. await streamChat(
  392. { ...config, maxContextSize: 1_000_000, maxOutputTokens: 65_536 },
  393. [{ role: "user", content: "写第一章" }],
  394. { onToken: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
  395. undefined,
  396. { max_tokens: 300_000 },
  397. )
  398. const request = mocks.fetch.mock.calls[0][1] as RequestInit
  399. expect(JSON.parse(String(request.body))).toMatchObject({ max_tokens: 65_536 })
  400. })
  401. it("本地 CLI 供应商不外发 max_tokens", async () => {
  402. mocks.streamClaudeCodeCli.mockImplementation(async (
  403. _config: LlmConfig,
  404. _messages: ChatMessage[],
  405. callbacks: { onToken: (token: string) => void; onDone: () => void },
  406. _signal?: AbortSignal,
  407. overrides?: { max_tokens?: number },
  408. ) => {
  409. expect(overrides).not.toHaveProperty("max_tokens")
  410. callbacks.onToken("ok")
  411. callbacks.onDone()
  412. })
  413. await streamChat(
  414. {
  415. ...config,
  416. provider: "claude-code",
  417. model: "claude-sonnet-5",
  418. },
  419. [{ role: "user", content: "写第一章" }],
  420. { onToken: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
  421. undefined,
  422. { max_tokens: 30_720 },
  423. )
  424. expect(mocks.fetch).not.toHaveBeenCalled()
  425. expect(mocks.streamClaudeCodeCli).toHaveBeenCalledTimes(1)
  426. })
  427. it("服务商回报 max_tokens 超限时按其上限重试一次", async () => {
  428. mocks.fetch
  429. .mockResolvedValueOnce(new Response(
  430. JSON.stringify({
  431. error: {
  432. message: "max_tokens is too large: this model supports at most 8192 output tokens",
  433. },
  434. }),
  435. { status: 400 },
  436. ))
  437. .mockResolvedValueOnce(new Response([
  438. 'data: {"choices":[{"delta":{"content":"完成"}}]}',
  439. "data: [DONE]",
  440. "",
  441. ].join("\n"), { status: 200 }))
  442. const onError = vi.fn()
  443. const onRequestTrace = vi.fn()
  444. await streamChat(config, [{ role: "user", content: "写第一章" }], {
  445. onToken: vi.fn(),
  446. onDone: vi.fn(),
  447. onRequestTrace,
  448. onError,
  449. })
  450. expect(mocks.fetch).toHaveBeenCalledTimes(2)
  451. const retryBody = JSON.parse(String((mocks.fetch.mock.calls[1][1] as RequestInit).body))
  452. expect(retryBody.max_tokens).toBe(8_192)
  453. expect(onError).not.toHaveBeenCalled()
  454. expect(onRequestTrace.mock.calls.map(([trace]) => trace.status)).toEqual(["error", "success"])
  455. })
  456. it("脏 SSE 行不会中断整轮流式响应", async () => {
  457. const encoder = new TextEncoder()
  458. const body = new ReadableStream<Uint8Array>({
  459. start(controller) {
  460. controller.enqueue(encoder.encode([
  461. 'data: {"choices":[{"delta":{"content":"前半"}}]}',
  462. "data: {不是合法 JSON",
  463. 'data: {"choices":[{"delta":{"content":"后半"}}]}',
  464. "data: [DONE]",
  465. "",
  466. ].join("\n")))
  467. controller.close()
  468. },
  469. })
  470. mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
  471. const onToken = vi.fn()
  472. const onDone = vi.fn()
  473. const onError = vi.fn()
  474. await streamChat(config, [{ role: "user", content: "写第一章" }], {
  475. onToken,
  476. onDone,
  477. onError,
  478. })
  479. expect(onToken).toHaveBeenCalledWith("前半")
  480. expect(onToken).toHaveBeenCalledWith("后半")
  481. expect(onDone).toHaveBeenCalledOnce()
  482. expect(onError).not.toHaveBeenCalled()
  483. })
  484. it("records a mid-stream network failure as network_error", async () => {
  485. const body = new ReadableStream<Uint8Array>({
  486. start(controller) {
  487. controller.error(new Error("connection dropped"))
  488. },
  489. })
  490. mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
  491. mocks.isFetchNetworkError.mockReturnValue(true)
  492. const onRequestTrace = vi.fn()
  493. const onError = vi.fn()
  494. await streamChat(config, [{ role: "user", content: "测试网络中断" }], {
  495. onToken: vi.fn(),
  496. onRequestTrace,
  497. onDone: vi.fn(),
  498. onError,
  499. })
  500. expect(onRequestTrace).toHaveBeenCalledWith(expect.objectContaining({ status: "network_error" }))
  501. expect(onError).toHaveBeenCalledWith(expect.objectContaining({
  502. message: expect.stringContaining("流式响应读取中断"),
  503. }))
  504. })
  505. it("records an aborted supplier attempt as cancelled", async () => {
  506. mocks.fetch.mockRejectedValue(new DOMException("aborted", "AbortError"))
  507. const controller = new AbortController()
  508. controller.abort()
  509. const onRequestTrace = vi.fn()
  510. const onDone = vi.fn()
  511. await streamChat(config, [{ role: "user", content: "取消请求" }], {
  512. onToken: vi.fn(),
  513. onRequestTrace,
  514. onDone,
  515. onError: vi.fn(),
  516. }, controller.signal)
  517. expect(onRequestTrace).toHaveBeenCalledWith(expect.objectContaining({ status: "cancelled" }))
  518. expect(onDone).toHaveBeenCalledOnce()
  519. })
  520. })