From f5a58616b7924c310ce45dfd5b34a4237cb10673 Mon Sep 17 00:00:00 2001 From: dongmucat <70678707+dongmucat@users.noreply.github.com> Date: Mon, 21 Sep 2026 14:14:23 +0800 Subject: [PATCH] fix(suite): support bundle import over plain HTTP (#890) Signed-off-by: dongmucat <1127093059@qq.com> --- .../suite/suite-bundle-import.test.tsx | 25 +++++++++++++ .../features/suite/suite-bundle-import.tsx | 17 +++++---- web/src/shared/lib/idempotency-key.test.ts | 35 +++++++++++++++++++ web/src/shared/lib/idempotency-key.ts | 14 ++++++++ 4 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 web/src/shared/lib/idempotency-key.test.ts create mode 100644 web/src/shared/lib/idempotency-key.ts diff --git a/web/src/features/suite/suite-bundle-import.test.tsx b/web/src/features/suite/suite-bundle-import.test.tsx index 6e300de7..b6b33dc4 100644 --- a/web/src/features/suite/suite-bundle-import.test.tsx +++ b/web/src/features/suite/suite-bundle-import.test.tsx @@ -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() + + 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: [ diff --git a/web/src/features/suite/suite-bundle-import.tsx b/web/src/features/suite/suite-bundle-import.tsx index bf124ca0..099b62aa 100644 --- a/web/src/features/suite/suite-bundle-import.tsx +++ b/web/src/features/suite/suite-bundle-import.tsx @@ -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({ diff --git a/web/src/shared/lib/idempotency-key.test.ts b/web/src/shared/lib/idempotency-key.test.ts new file mode 100644 index 00000000..3eab522d --- /dev/null +++ b/web/src/shared/lib/idempotency-key.test.ts @@ -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') + }) +}) diff --git a/web/src/shared/lib/idempotency-key.ts b/web/src/shared/lib/idempotency-key.ts new file mode 100644 index 00000000..94180551 --- /dev/null +++ b/web/src/shared/lib/idempotency-key.ts @@ -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('') +}