fix(suite): support bundle import over plain HTTP (#890)

Signed-off-by: dongmucat <1127093059@qq.com>
This commit is contained in:
dongmucat 2026-09-21 14:14:23 +08:00 committed by GitHub
parent 342d59472d
commit f5a58616b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 84 additions and 7 deletions

View file

@ -111,6 +111,31 @@ describe('SuiteBundleImport', () => {
}))
})
it('previews and confirms on browsers without crypto.randomUUID', async () => {
vi.stubGlobal('crypto', {
getRandomValues: (bytes: Uint8Array) => {
bytes.fill(1)
return bytes
},
})
mocks.preview.mutateAsync.mockResolvedValue(preview({ members: [] }))
mocks.confirm.mutateAsync.mockResolvedValue({ operationId: 'operation-1', status: 'RUNNING' })
render(<SuiteBundleImport expectedMode="CREATE" />)
fireEvent.click(screen.getByRole('button', { name: 'pick-zip' }))
await waitFor(() => expect(
screen.getByRole('button', { name: 'suite.bundle.confirm' }).hasAttribute('disabled')
).toBe(false))
fireEvent.click(screen.getByRole('button', { name: 'suite.bundle.confirm' }))
await waitFor(() => expect(mocks.confirm.mutateAsync).toHaveBeenCalledWith({
previewToken: 'preview-1',
warningDigest: 'digest-1',
idempotencyKey: '01010101010101010101010101010101',
}))
expect(mocks.toast.error).not.toHaveBeenCalled()
})
it('requires warning acceptance for every affected member', async () => {
mocks.preview.mutateAsync.mockResolvedValue(preview({
members: [

View file

@ -13,6 +13,7 @@ import {
import { Button } from '@/shared/ui/button'
import { Card } from '@/shared/ui/card'
import { toast } from '@/shared/lib/toast'
import { newIdempotencyKey } from '@/shared/lib/idempotency-key'
import { validateSuiteBundleFolder, validateSuiteBundleZip } from './suite-bundle-folder'
type BundleMode = 'CREATE' | 'UPDATE'
@ -136,20 +137,22 @@ export function SuiteBundleImport({ expectedMode, expectedCoordinate, returnToSu
const controller = new AbortController()
requestRef.current = controller
setFileName(file.name)
let result: SkillSuiteBundlePreview
try {
const result = await previewMutation.mutateAsync({ file, signal: controller.signal })
if (!controller.signal.aborted && selectionVersion === selectionVersionRef.current) {
idempotencyKeyRef.current = crypto.randomUUID()
setNow(Date.now())
setPreview(result)
}
result = await previewMutation.mutateAsync({ file, signal: controller.signal })
} catch (error) {
if (!controller.signal.aborted && selectionVersion === selectionVersionRef.current) {
toast.error(t('suite.bundle.previewFailed'), error instanceof Error ? error.message : '')
}
return
} finally {
if (requestRef.current === controller) requestRef.current = null
}
if (!controller.signal.aborted && selectionVersion === selectionVersionRef.current) {
idempotencyKeyRef.current = newIdempotencyKey()
setNow(Date.now())
setPreview(result)
}
}
const previewFile = async (file: File) => {
@ -200,7 +203,7 @@ export function SuiteBundleImport({ expectedMode, expectedCoordinate, returnToSu
const confirm = async () => {
if (!preview?.previewToken || !preview.warningDigest || !canConfirm) return
const idempotencyKey = idempotencyKeyRef.current ?? crypto.randomUUID()
const idempotencyKey = idempotencyKeyRef.current ?? newIdempotencyKey()
idempotencyKeyRef.current = idempotencyKey
try {
const result = await confirmMutation.mutateAsync({

View file

@ -0,0 +1,35 @@
/** @vitest-environment node */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { newIdempotencyKey } from './idempotency-key'
describe('newIdempotencyKey', () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('uses crypto.randomUUID when it is available', () => {
vi.stubGlobal('crypto', { randomUUID: () => 'request-1' })
expect(newIdempotencyKey()).toBe('request-1')
})
it('uses crypto.getRandomValues when randomUUID is unavailable', () => {
vi.stubGlobal('crypto', {
getRandomValues: (bytes: Uint8Array) => {
bytes.fill(1)
return bytes
},
})
expect(newIdempotencyKey()).toBe('01010101010101010101010101010101')
})
it('falls back to Math.random when Web Crypto is unavailable', () => {
vi.stubGlobal('crypto', undefined)
vi.spyOn(Math, 'random').mockReturnValue(0)
expect(newIdempotencyKey()).toBe('00000000000000000000000000000000')
})
})

View file

@ -0,0 +1,14 @@
export function newIdempotencyKey(): string {
const cryptoApi = typeof globalThis.crypto !== 'undefined' ? globalThis.crypto : undefined
if (typeof cryptoApi?.randomUUID === 'function') return cryptoApi.randomUUID()
const bytes = new Uint8Array(16)
if (typeof cryptoApi?.getRandomValues === 'function') {
cryptoApi.getRandomValues(bytes)
} else {
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256)
}
}
return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('')
}