diff --git a/packages/@uppy/aws-s3/tests/index.test.ts b/packages/@uppy/aws-s3/tests/index.test.ts
index d3c9490af..35201311e 100644
--- a/packages/@uppy/aws-s3/tests/index.test.ts
+++ b/packages/@uppy/aws-s3/tests/index.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import 'whatwg-fetch'
import Core, { type Meta, type UppyFile } from '@uppy/core'
@@ -7,6 +7,103 @@ import AwsS3, { type AwsBody, type AwsS3Options } from '../src/index.js'
const KB = 1024
const MB = KB * KB
+// ---------------------------------------------------------------------------
+// Helpers for multipart upload tests
+// ---------------------------------------------------------------------------
+
+/** Minimal XML responses that S3 returns for multipart operations */
+const s3Responses = {
+ createMultipart: (uploadId: string, key: string) =>
+ `
+
+ ${uploadId}
+ ${key}
+ `,
+
+ uploadPart: (etag: string) => '',
+
+ listParts: (parts: { partNumber: number; etag: string }[]) =>
+ `
+
+ ${parts.map((p) => `${p.partNumber}${p.etag}`).join('')}
+ `,
+
+ completeMultipart: (location: string, key: string) =>
+ `
+
+ ${location}
+ ${key}
+ test-bucket
+ `,
+
+ abortMultipart: () => '',
+}
+
+/**
+ * Creates mock signRequest + fetch for multipart upload tests.
+ * signRequest encodes the operation in the URL so fetchMock can route correctly.
+ */
+function createMultipartMocks(opts: { uploadId?: string; key?: string } = {}) {
+ const uploadId = opts.uploadId ?? 'test-upload-id'
+ const key = opts.key ?? 'test-key'
+
+ // signRequest encodes operation details in the URL for fetchMock routing
+ const signRequest = vi.fn().mockImplementation(async (req: any) => {
+ const params = new URLSearchParams()
+ if (req.uploadId) params.set('uploadId', req.uploadId)
+ if (req.partNumber) params.set('partNumber', String(req.partNumber))
+ params.set('method', req.method)
+ return {
+ url: `https://test-bucket.s3.us-east-1.amazonaws.com/${req.key || key}?${params}`,
+ }
+ })
+
+ const operations: string[] = []
+
+ const fetchMock = vi.fn().mockImplementation(async (url: string | Request, init?: any) => {
+ const urlStr = typeof url === 'string' ? url : url.url
+ const method = init?.method || 'GET'
+ const params = new URL(urlStr).searchParams
+ const hasUploadId = params.has('uploadId')
+
+ if (method === 'POST' && !hasUploadId) {
+ operations.push('createMultipart')
+ return new Response(s3Responses.createMultipart(uploadId, key), {
+ status: 200,
+ headers: { 'Content-Type': 'application/xml' },
+ })
+ }
+ if (method === 'PUT') {
+ operations.push('uploadPart')
+ return new Response('', {
+ status: 200,
+ headers: { ETag: '"etag-1"' },
+ })
+ }
+ if (method === 'POST' && hasUploadId) {
+ operations.push('completeMultipart')
+ return new Response(
+ s3Responses.completeMultipart(`https://test-bucket.s3.amazonaws.com/${key}`, key),
+ { status: 200, headers: { 'Content-Type': 'application/xml' } },
+ )
+ }
+ if (method === 'GET' && hasUploadId) {
+ operations.push('listParts')
+ return new Response(s3Responses.listParts([]), {
+ status: 200,
+ headers: { 'Content-Type': 'application/xml' },
+ })
+ }
+ if (method === 'DELETE') {
+ operations.push('abortMultipart')
+ return new Response('', { status: 204 })
+ }
+ return new Response('Not Found', { status: 404 })
+ })
+
+ return { signRequest, fetchMock, operations, uploadId, key }
+}
+
describe('AwsS3', () => {
it('Registers AwsS3 upload plugin', () => {
const core = new Core().use(AwsS3, {
@@ -244,4 +341,153 @@ describe('AwsS3', () => {
expect(result?.successful).toHaveLength(0)
})
})
+
+ describe('Golden Retriever resume state (s3Multipart)', () => {
+ const originalFetch = globalThis.fetch
+
+ afterEach(() => {
+ globalThis.fetch = originalFetch
+ })
+
+ it('persists s3Multipart on file state after creating multipart upload', async () => {
+ const { signRequest, fetchMock, uploadId } = createMultipartMocks()
+ // After createMultipart succeeds, hang on uploadPart so we can inspect state
+ fetchMock.mockImplementation(async (url: string | Request, init?: any) => {
+ const urlStr = typeof url === 'string' ? url : url.url
+ const method = init?.method || 'GET'
+ const hasUploadId = new URL(urlStr).searchParams.has('uploadId')
+
+ if (method === 'POST' && !hasUploadId) {
+ return new Response(
+ s3Responses.createMultipart(uploadId, 'test-key'),
+ { status: 200, headers: { 'Content-Type': 'application/xml' } },
+ )
+ }
+ // Hang on everything else — we only need createMultipart to complete
+ return new Promise(() => {})
+ })
+ globalThis.fetch = fetchMock
+
+ const core = new Core().use(AwsS3, {
+ bucket: 'test-bucket',
+ region: 'us-east-1',
+ signRequest,
+ shouldUseMultipart: true,
+ })
+
+ const fileId = core.addFile({
+ source: 'test',
+ name: 'big.dat',
+ type: 'application/octet-stream',
+ data: new File([new Uint8Array(6 * MB)], 'big.dat'),
+ })
+
+ const uploadPromise = core.upload()
+
+ // Wait for createMultipart to complete and state to be persisted
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ const file = core.getFile(fileId)
+ expect(file.s3Multipart).toBeDefined()
+ expect(file.s3Multipart?.uploadId).toBe(uploadId)
+
+ // Clean up
+ core.cancelAll()
+ await uploadPromise
+ })
+
+ it('clears s3Multipart when upload is aborted via cancelAll', async () => {
+ const { signRequest, fetchMock } = createMultipartMocks()
+ fetchMock.mockImplementation(async (url: string | Request, init?: any) => {
+ const urlStr = typeof url === 'string' ? url : url.url
+ const method = init?.method || 'GET'
+ const hasUploadId = new URL(urlStr).searchParams.has('uploadId')
+
+ if (method === 'POST' && !hasUploadId) {
+ return new Response(
+ s3Responses.createMultipart('cancel-test-id', 'cancel-key'),
+ { status: 200, headers: { 'Content-Type': 'application/xml' } },
+ )
+ }
+ if (method === 'DELETE') {
+ return new Response('', { status: 204 })
+ }
+ // Hang on everything else
+ return new Promise(() => {})
+ })
+ globalThis.fetch = fetchMock
+
+ const core = new Core().use(AwsS3, {
+ bucket: 'test-bucket',
+ region: 'us-east-1',
+ signRequest,
+ shouldUseMultipart: true,
+ })
+
+ const fileId = core.addFile({
+ source: 'test',
+ name: 'big.dat',
+ type: 'application/octet-stream',
+ data: new File([new Uint8Array(6 * MB)], 'big.dat'),
+ })
+
+ const uploadPromise = core.upload()
+
+ // Wait for createMultipart, then cancel
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ core.cancelAll()
+
+ await uploadPromise
+
+ const file = core.getFile(fileId)
+ // s3Multipart should be cleared so retries don't use a dead uploadId
+ expect(file?.s3Multipart).toBeUndefined()
+ })
+
+ it('uses persisted s3Multipart key for resume (listParts, not createMultipart)', async () => {
+ const persistedKey = 'persisted-object-key'
+ const persistedUploadId = 'persisted-upload-id'
+ const { signRequest, fetchMock, operations } = createMultipartMocks({
+ uploadId: persistedUploadId,
+ key: persistedKey,
+ })
+ globalThis.fetch = fetchMock
+
+ const core = new Core().use(AwsS3, {
+ bucket: 'test-bucket',
+ region: 'us-east-1',
+ signRequest,
+ shouldUseMultipart: false, // Would normally be simple upload
+ })
+
+ const fileId = core.addFile({
+ source: 'test',
+ name: 'big.dat',
+ type: 'application/octet-stream',
+ data: new File([new Uint8Array(6 * MB)], 'big.dat'),
+ })
+
+ // Simulate Golden Retriever restoring s3Multipart state
+ core.setFileState(fileId, {
+ s3Multipart: { uploadId: persistedUploadId, key: persistedKey },
+ })
+
+ const uploadPromise = core.upload()
+
+ // Wait for the resume flow to call listParts (via fetch), then cancel
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ // Should have resumed (listParts) instead of creating a new multipart upload
+ expect(operations).toContain('listParts')
+ expect(operations).not.toContain('createMultipart')
+
+ // The signRequest calls should use the persisted key, not a generated one
+ const signedKeys = signRequest.mock.calls.map((call: any) => call[0].key)
+ expect(signedKeys.every((k: string) => k === persistedKey)).toBe(true)
+
+ // Clean up
+ core.cancelAll()
+ await uploadPromise
+ })
+ })
})