file-upload.client.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. import { Context } from '@deepseek-ai/cordis'
  2. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  3. import { afterEach, describe, expect, it, vi } from 'vitest'
  4. import { apply } from '../src/client/index.ts'
  5. import { fileUploadWorker, FileUploadRuntime } from '../src/client/runtime.ts'
  6. import type { FileUploadBody } from '../src/client/contract.ts'
  7. import type { ClientFileUploadHooks } from '../src/types.ts'
  8. interface UploadGlobal {
  9. __DSH_FILE_UPLOAD__?: ClientFileUploadHooks
  10. }
  11. afterEach(() => {
  12. delete (globalThis as UploadGlobal).__DSH_FILE_UPLOAD__
  13. vi.restoreAllMocks()
  14. vi.unstubAllGlobals()
  15. })
  16. describe('file upload worker body', () => {
  17. it('sends a Blob with credentials and reports progress, completion, and failure', () => {
  18. const posted: unknown[] = []
  19. const scope: {
  20. onmessage: ((event: MessageEvent<{
  21. url: string
  22. body: FileUploadBody
  23. headers: Readonly<Record<string, string>>
  24. }>) => void) | null
  25. postMessage(message: unknown): void
  26. } = { onmessage: null, postMessage: (message: unknown) => { posted.push(message) } }
  27. const xhr = {
  28. upload: { onprogress: null as ((event: ProgressEvent) => void) | null },
  29. status: 201,
  30. responseText: '{"ok":true}',
  31. withCredentials: false,
  32. onload: null as ((event: ProgressEvent) => void) | null,
  33. onerror: null as ((event: ProgressEvent) => void) | null,
  34. open: vi.fn(),
  35. setRequestHeader: vi.fn(),
  36. send: vi.fn(),
  37. }
  38. fileUploadWorker(scope, () => xhr)
  39. const body = new Blob(['large'])
  40. scope.onmessage?.({
  41. data: { url: 'https://harness.test/upload', body, headers: { 'content-type': 'application/octet-stream' } },
  42. } as never)
  43. expect(xhr.open).toHaveBeenCalledWith('POST', 'https://harness.test/upload')
  44. expect(xhr.withCredentials).toBe(true)
  45. expect(xhr.setRequestHeader).toHaveBeenCalledWith('content-type', 'application/octet-stream')
  46. expect(xhr.send).toHaveBeenCalledWith(body)
  47. xhr.upload.onprogress?.({ loaded: 2, total: 4, lengthComputable: true } as ProgressEvent)
  48. xhr.upload.onprogress?.({ loaded: 3, total: 0, lengthComputable: false } as ProgressEvent)
  49. xhr.onload?.({} as ProgressEvent)
  50. xhr.onerror?.({} as ProgressEvent)
  51. expect(posted).toEqual([
  52. { kind: 'progress', loaded: 2, total: 4 },
  53. { kind: 'progress', loaded: 3 },
  54. { kind: 'complete', status: 201, body: '{"ok":true}' },
  55. { kind: 'error', message: 'background upload transport failed' },
  56. ])
  57. })
  58. it('streams Uint8Array chunks through fetch and reports consumed bytes', async () => {
  59. const posted: unknown[] = []
  60. const scope = {
  61. onmessage: null as ((event: MessageEvent) => void) | null,
  62. postMessage: (message: unknown) => { posted.push(message) },
  63. }
  64. const fetch = vi.fn(async (_url: string, init: RequestInit & { readonly duplex: 'half' }) => {
  65. const chunks: number[][] = []
  66. for await (const chunk of init.body as ReadableStream<Uint8Array>) chunks.push([...chunk])
  67. expect(chunks).toEqual([[1, 2], [3]])
  68. expect(init).toMatchObject({
  69. method: 'POST',
  70. headers: { 'x-test': 'yes' },
  71. credentials: 'include',
  72. duplex: 'half',
  73. })
  74. return new Response('stored', { status: 202 })
  75. })
  76. const body = new ReadableStream<Uint8Array>({
  77. start(controller) {
  78. controller.enqueue(Uint8Array.of(1, 2))
  79. controller.enqueue(Uint8Array.of(3))
  80. controller.close()
  81. },
  82. })
  83. fileUploadWorker(scope, () => { throw new Error('XHR must not handle streams') }, fetch)
  84. scope.onmessage?.({ data: { url: 'https://harness.test/upload', body, headers: { 'x-test': 'yes' } } } as never)
  85. await vi.waitFor(() => {
  86. expect(posted).toEqual([
  87. { kind: 'progress', loaded: 2 },
  88. { kind: 'progress', loaded: 3 },
  89. { kind: 'complete', status: 202, body: 'stored' },
  90. ])
  91. })
  92. })
  93. it('propagates cancellation from the fetch body to the source stream', async () => {
  94. const posted: unknown[] = []
  95. const scope = {
  96. onmessage: null as ((event: MessageEvent) => void) | null,
  97. postMessage: (message: unknown) => { posted.push(message) },
  98. }
  99. const cancel = vi.fn()
  100. const source = new ReadableStream<Uint8Array>({ cancel })
  101. fileUploadWorker(
  102. scope,
  103. () => { throw new Error('unused') },
  104. async (_url, init) => {
  105. await (init.body as ReadableStream<Uint8Array>).cancel('fetch stopped')
  106. return new Response('cancelled')
  107. },
  108. )
  109. scope.onmessage?.({ data: { url: '/upload', body: source, headers: {} } } as never)
  110. await vi.waitFor(() => {
  111. expect(cancel).toHaveBeenCalledWith('fetch stopped')
  112. expect(posted.at(-1)).toEqual({ kind: 'complete', status: 200, body: 'cancelled' })
  113. })
  114. })
  115. it('reports invalid bodies, stream chunks, and fetch failures', async () => {
  116. const posted: unknown[] = []
  117. const scope = {
  118. onmessage: null as ((event: MessageEvent) => void) | null,
  119. postMessage: (message: unknown) => { posted.push(message) },
  120. }
  121. fileUploadWorker(scope, () => { throw new Error('unused') })
  122. scope.onmessage?.({ data: { url: '/upload', body: 'bad', headers: {} } } as never)
  123. expect(posted).toEqual([{ kind: 'error', message: 'background upload worker received an invalid body' }])
  124. const badChunk = new ReadableStream({ start(controller) { controller.enqueue('bad'); controller.close() } })
  125. fileUploadWorker(
  126. scope,
  127. () => { throw new Error('unused') },
  128. async (_url, init) => {
  129. await new Response(init.body).arrayBuffer()
  130. return new Response()
  131. },
  132. )
  133. scope.onmessage?.({ data: { url: '/upload', body: badChunk, headers: {} } } as never)
  134. await vi.waitFor(() => {
  135. expect(posted.at(-1)).toEqual({
  136. kind: 'error', message: 'background upload stream produced a non-Uint8Array chunk',
  137. })
  138. })
  139. const body = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
  140. fileUploadWorker(
  141. scope,
  142. () => { throw new Error('unused') },
  143. () => Promise.reject(new Error('offline')),
  144. )
  145. scope.onmessage?.({ data: { url: '/upload', body, headers: {} } } as never)
  146. await vi.waitFor(() => {
  147. expect(posted.at(-1)).toEqual({ kind: 'error', message: 'offline' })
  148. })
  149. const failedSource = new ReadableStream<Uint8Array>({
  150. start(controller) { controller.error('source failed') },
  151. })
  152. fileUploadWorker(
  153. scope,
  154. () => { throw new Error('unused') },
  155. async (_url, init) => {
  156. await (init.body as ReadableStream<Uint8Array>).getReader().read()
  157. return new Response()
  158. },
  159. )
  160. scope.onmessage?.({ data: { url: '/upload', body: failedSource, headers: {} } } as never)
  161. await vi.waitFor(() => {
  162. expect(posted.at(-1)).toEqual({ kind: 'error', message: 'source failed' })
  163. })
  164. })
  165. it('uses Worker globals when the emitted body supplies no test seams', async () => {
  166. const posted: unknown[] = []
  167. const scope = {
  168. onmessage: null as ((event: MessageEvent) => void) | null,
  169. postMessage: (message: unknown) => { posted.push(message) },
  170. }
  171. const xhr = {
  172. upload: { onprogress: null },
  173. status: 204,
  174. responseText: '',
  175. withCredentials: false,
  176. onload: null,
  177. onerror: null,
  178. open: vi.fn(),
  179. setRequestHeader: vi.fn(),
  180. send: vi.fn(),
  181. }
  182. vi.stubGlobal('self', scope)
  183. vi.stubGlobal('XMLHttpRequest', vi.fn(function () { return xhr }))
  184. fileUploadWorker()
  185. scope.onmessage?.({ data: { url: '/upload', body: new Blob(), headers: {} } } as MessageEvent)
  186. expect(xhr.send).toHaveBeenCalledOnce()
  187. const fetch = vi.fn(async (_url: string, init: RequestInit) => {
  188. await new Response(init.body).arrayBuffer()
  189. return new Response(null, { status: 204 })
  190. })
  191. vi.stubGlobal('fetch', fetch)
  192. fileUploadWorker()
  193. const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
  194. scope.onmessage?.({ data: { url: '/stream', body: stream, headers: {} } } as MessageEvent)
  195. await vi.waitFor(() => {
  196. expect(fetch).toHaveBeenCalledOnce()
  197. expect(posted.at(-1)).toEqual({ kind: 'complete', status: 204, body: '' })
  198. })
  199. })
  200. })
  201. describe('file upload service', () => {
  202. it('uses a page-owned Host fetch for Blob and ReadableStream bodies', async () => {
  203. vi.stubGlobal('location', { origin: 'https://preview.test' })
  204. const fetch = vi.fn((_url: URL, _init?: RequestInit) =>
  205. Promise.resolve(new Response('accepted', { status: 202 })))
  206. ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch }
  207. const ctx = new Context()
  208. const fiber = ctx.plugin(FileUploadRuntime)
  209. await fiber
  210. const blob = new Blob(['opaque'])
  211. const signal = new AbortController().signal
  212. await expect((ctx.fileUpload as FileUploadRuntime).post({
  213. path: '/api/upload', body: blob, headers: { 'x-test': 'yes' }, signal,
  214. })).resolves.toEqual({ status: 202, body: 'accepted' })
  215. expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/api/upload'), {
  216. method: 'POST', headers: { 'x-test': 'yes' }, body: blob, signal,
  217. })
  218. const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
  219. await (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream })
  220. expect(fetch).toHaveBeenLastCalledWith(new URL('https://preview.test/stream'), {
  221. method: 'POST', body: stream, duplex: 'half',
  222. })
  223. await fiber.dispose()
  224. })
  225. it('mounts through the plugin entry and resolves non-browser URLs', async () => {
  226. vi.stubGlobal('location', { origin: 'null' })
  227. const fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 204 })))
  228. ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch }
  229. const ctx = new Context()
  230. const fiber = ctx.plugin({ apply })
  231. await fiber
  232. const body = new Blob()
  233. await (ctx.fileUpload as FileUploadRuntime).post({ path: '/fallback', body })
  234. expect(fetch).toHaveBeenCalledWith(new URL('http://dsh.internal/fallback'), {
  235. method: 'POST', body,
  236. })
  237. await fiber.dispose()
  238. })
  239. it('fails loud when a served browser has no Worker implementation', async () => {
  240. vi.stubGlobal('Worker', undefined)
  241. const ctx = new Context()
  242. const fiber = ctx.plugin(FileUploadRuntime)
  243. await fiber
  244. await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() }))
  245. .rejects.toThrow('background upload requires Web Worker support')
  246. await fiber.dispose()
  247. })
  248. it('forwards progress and completion from a dedicated Worker and then terminates it', async () => {
  249. const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker')
  250. const revoked = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
  251. class FakeWorker {
  252. static last: FakeWorker | undefined
  253. onmessage: ((event: MessageEvent) => void) | null = null
  254. onerror: ((event: ErrorEvent) => void) | null = null
  255. readonly postMessage = vi.fn()
  256. readonly terminate = vi.fn()
  257. constructor(readonly url: string, readonly options: WorkerOptions) { FakeWorker.last = this }
  258. }
  259. vi.stubGlobal('Worker', FakeWorker)
  260. vi.stubGlobal('location', { origin: 'https://harness.test' })
  261. const ctx = new Context()
  262. const fiber = ctx.plugin(FileUploadRuntime)
  263. await fiber
  264. const progress = vi.fn()
  265. const blob = new Blob(['bytes'])
  266. const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/api/upload', body: blob, onProgress: progress })
  267. const worker = FakeWorker.last
  268. if (worker === undefined) throw new Error('worker missing')
  269. expect(created).toHaveBeenCalledOnce()
  270. expect(revoked).toHaveBeenCalledWith('blob:worker')
  271. expect(worker.postMessage).toHaveBeenCalledWith({
  272. url: 'https://harness.test/api/upload', body: blob, headers: {},
  273. })
  274. worker.onmessage?.({ data: { kind: 'progress', loaded: 4, total: 5 } } as MessageEvent)
  275. worker.onmessage?.({ data: { kind: 'progress', loaded: 6 } } as MessageEvent)
  276. worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent)
  277. worker.onmessage?.({ data: { kind: 'complete', status: 500, body: 'late' } } as MessageEvent)
  278. await expect(pending).resolves.toEqual({ status: 200, body: 'done' })
  279. expect(progress.mock.calls).toEqual([
  280. [{ loaded: 4, total: 5 }],
  281. [{ loaded: 6 }],
  282. ])
  283. expect(worker.terminate).toHaveBeenCalledOnce()
  284. await fiber.dispose()
  285. })
  286. it('transfers stream ownership to the dedicated Worker', async () => {
  287. vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker')
  288. vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
  289. class FakeWorker {
  290. static last: FakeWorker | undefined
  291. onmessage: ((event: MessageEvent) => void) | null = null
  292. onerror: ((event: ErrorEvent) => void) | null = null
  293. readonly postMessage = vi.fn()
  294. readonly terminate = vi.fn()
  295. constructor() { FakeWorker.last = this }
  296. }
  297. vi.stubGlobal('Worker', FakeWorker)
  298. const ctx = new Context()
  299. const fiber = ctx.plugin(FileUploadRuntime)
  300. await fiber
  301. const stream = new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
  302. const pending = (ctx.fileUpload as FileUploadRuntime).post({ path: '/stream', body: stream })
  303. const worker = FakeWorker.last
  304. if (worker === undefined) throw new Error('worker missing')
  305. expect(worker.postMessage).toHaveBeenCalledWith(expect.objectContaining({ body: stream }), [stream])
  306. worker.onmessage?.({ data: { kind: 'complete', status: 200, body: 'done' } } as MessageEvent)
  307. await expect(pending).resolves.toEqual({ status: 200, body: 'done' })
  308. await fiber.dispose()
  309. })
  310. it('rejects worker messages, worker errors, and caller cancellation', async () => {
  311. vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:worker')
  312. vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
  313. class FakeWorker {
  314. static all: FakeWorker[] = []
  315. onmessage: ((event: MessageEvent) => void) | null = null
  316. onerror: ((event: ErrorEvent) => void) | null = null
  317. readonly postMessage = vi.fn()
  318. readonly terminate = vi.fn()
  319. constructor() { FakeWorker.all.push(this) }
  320. }
  321. vi.stubGlobal('Worker', FakeWorker)
  322. const ctx = new Context()
  323. const fiber = ctx.plugin(FileUploadRuntime)
  324. await fiber
  325. const reported = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })
  326. FakeWorker.all[0]?.onmessage?.({ data: { kind: 'error', message: 'network failed' } } as MessageEvent)
  327. await expect(reported).rejects.toThrow('network failed')
  328. const errored = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })
  329. FakeWorker.all[1]?.onerror?.({ message: 'worker crashed' } as ErrorEvent)
  330. await expect(errored).rejects.toThrow('worker crashed')
  331. const unnamed = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob() })
  332. FakeWorker.all[2]?.onerror?.({ message: '' } as ErrorEvent)
  333. await expect(unnamed).rejects.toThrow('background upload worker failed')
  334. const controller = new AbortController()
  335. const aborted = (ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: controller.signal })
  336. controller.abort()
  337. await expect(aborted).rejects.toMatchObject({ name: 'AbortError' })
  338. expect(FakeWorker.all[3]?.terminate).toHaveBeenCalledOnce()
  339. const already = new AbortController()
  340. already.abort()
  341. await expect((ctx.fileUpload as FileUploadRuntime).post({ path: '/upload', body: new Blob(), signal: already.signal }))
  342. .rejects.toMatchObject({ name: 'AbortError' })
  343. expect(FakeWorker.all[4]?.postMessage).not.toHaveBeenCalled()
  344. await fiber.dispose()
  345. })
  346. })
  347. describe('Session-addressed file upload', () => {
  348. const SESSION_ID = 's1' as SessionId
  349. async function scopedService(options: {
  350. readonly remote?: ReturnType<typeof vi.fn>
  351. } = {}) {
  352. const ctx = new Context()
  353. const remote = options.remote ?? vi.fn(() => Promise.resolve({
  354. ok: true,
  355. value: {
  356. receiptId: 'remote-receipt',
  357. file: { attachmentId: 'remote-file', name: 'file', bytes: 3 },
  358. },
  359. }))
  360. ctx.provide('remote', { fileUploads: { upload: remote } } as never)
  361. const fiber = ctx.plugin(FileUploadRuntime)
  362. await fiber
  363. return { ctx, fiber, remote, service: ctx.fileUpload }
  364. }
  365. it('assembles the scoped streaming request and parses progress and receipt fields', async () => {
  366. vi.stubGlobal('location', { origin: 'https://preview.test' })
  367. const progress = vi.fn()
  368. const fetch = vi.fn((_url: URL, init: RequestInit) => {
  369. expect(init.body).toBeInstanceOf(Blob)
  370. progress({ loaded: 2, total: 4 })
  371. return Promise.resolve(new Response(JSON.stringify({
  372. ok: true,
  373. value: {
  374. receiptId: 'receipt-1',
  375. file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 },
  376. },
  377. }), { status: 200 }))
  378. })
  379. ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = { fetch }
  380. const { fiber, service } = await scopedService()
  381. const signal = new AbortController().signal
  382. const file = new Blob(['data'])
  383. await expect(service.upload(SESSION_ID, file, 'notes & refs.pdf', signal, progress)).resolves.toEqual({
  384. ok: true,
  385. value: {
  386. receiptId: 'receipt-1',
  387. file: { attachmentId: 'file-1', name: 'notes & refs.pdf', bytes: 4 },
  388. },
  389. })
  390. expect(fetch).toHaveBeenCalledWith(
  391. new URL('https://preview.test/api/session/uploadFileBinary?sessionId=s1&name=notes+%26+refs.pdf'),
  392. expect.objectContaining({
  393. method: 'POST',
  394. headers: { 'content-type': 'application/octet-stream' },
  395. body: file,
  396. signal,
  397. }),
  398. )
  399. await fiber.dispose()
  400. })
  401. it('uses the direct Remote fallback for exact bytes', async () => {
  402. const remote = vi.fn(() => Promise.resolve({
  403. ok: true,
  404. value: {
  405. receiptId: 'remote-receipt',
  406. file: { attachmentId: 'remote-file', name: 'bytes.bin', bytes: 3 },
  407. },
  408. }))
  409. const { fiber, service } = await scopedService({ remote })
  410. await expect(service.upload(SESSION_ID, Uint8Array.of(0, 0, 0), 'bytes.bin'))
  411. .resolves.toMatchObject({ ok: true })
  412. await expect(service.upload(SESSION_ID, Uint8Array.of(1)))
  413. .resolves.toMatchObject({ ok: true })
  414. expect(remote.mock.calls).toEqual([
  415. [SESSION_ID, { data: 'AAAA', name: 'bytes.bin' }, undefined],
  416. [SESSION_ID, { data: 'AQ==' }, undefined],
  417. ])
  418. await fiber.dispose()
  419. })
  420. it('rejects malformed background results', async () => {
  421. vi.stubGlobal('location', { origin: 'https://preview.test' })
  422. ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = {
  423. fetch: () => Promise.resolve(new Response(null, { status: 413 })),
  424. }
  425. const rejected = await scopedService()
  426. await expect(rejected.service.upload(SESSION_ID, new Blob()))
  427. .rejects.toThrow('file upload transport failed with HTTP 413')
  428. await rejected.fiber.dispose()
  429. const bodies: unknown[] = [
  430. null,
  431. { ok: 'yes' },
  432. { ok: false, error: null },
  433. { ok: false, error: { code: 1, message: 'denied', details: {} } },
  434. { ok: false, error: { code: 'denied', message: 1, details: {} } },
  435. { ok: false, error: { code: 'denied', message: 'denied', details: null } },
  436. { ok: true, value: null },
  437. { ok: true, value: { receiptId: 'r', file: { attachmentId: 'a', name: 'x', bytes: -1 } } },
  438. ]
  439. for (const body of bodies) {
  440. ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = {
  441. fetch: () => Promise.resolve(new Response(JSON.stringify(body), { status: 200 })),
  442. }
  443. const malformed = await scopedService()
  444. await expect(malformed.service.upload(SESSION_ID, new Blob()))
  445. .rejects.toThrow(/file upload transport returned an invalid/)
  446. await malformed.fiber.dispose()
  447. }
  448. ;(globalThis as UploadGlobal).__DSH_FILE_UPLOAD__ = {
  449. fetch: () => Promise.resolve(new Response(JSON.stringify({
  450. ok: false,
  451. error: { code: 'session/attachment-invalid', message: 'denied', details: { reason: 'NOPE' } },
  452. }), { status: 200 })),
  453. }
  454. const failed = await scopedService()
  455. await expect(failed.service.upload(SESSION_ID, new Blob())).resolves.toMatchObject({
  456. ok: false,
  457. error: {
  458. code: 'session/attachment-invalid',
  459. message: 'denied',
  460. details: { reason: 'NOPE' },
  461. },
  462. })
  463. await failed.fiber.dispose()
  464. })
  465. })