fix(ui): harden resource restore and links
- Restore image resources when metadata exists but bytes are missing - Limit remote restore scope and sanitize external link handling - Reuse image resource backup helpers across restore packages - Extend restore safety coverage for data manager and snapshots
This commit is contained in:
1 parent
9711acb424
commit
8d2c024794
16 files changed
+362
-42
No files matched your search
@@ -653,7 +653,54 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="remote-field remote-restore-strategy">
|
||||
<div v-if="selectedRemoteBackup" class="remote-field remote-restore-scope">
|
||||
<NText depth="3" class="remote-field-label">
|
||||
{{ $t('dataManager.remote.restoreScope') }}
|
||||
</NText>
|
||||
<NText
|
||||
v-if="selectedRemoteBackupMissingAssetsCount > 0"
|
||||
depth="3"
|
||||
class="storage-note"
|
||||
>
|
||||
{{ $t('dataManager.remote.restoreMissingAssetsHint', { count: selectedRemoteBackupMissingAssetsCount }) }}
|
||||
</NText>
|
||||
<div class="local-scope-row">
|
||||
<label class="local-scope-pill">
|
||||
<NCheckbox
|
||||
v-model:checked="remoteRestoreAppData"
|
||||
:disabled="!availableRemoteRestoreSections.has('appData')"
|
||||
>
|
||||
{{ $t('dataManager.sections.appData') }}
|
||||
</NCheckbox>
|
||||
</label>
|
||||
<label class="local-scope-pill">
|
||||
<NCheckbox
|
||||
v-model:checked="remoteRestoreAppDataImages"
|
||||
:disabled="!remoteRestoreAppData || !availableRemoteRestoreSections.has('imageCache')"
|
||||
>
|
||||
{{ $t('dataManager.sections.appDataImages') }}
|
||||
</NCheckbox>
|
||||
</label>
|
||||
<label class="local-scope-pill">
|
||||
<NCheckbox
|
||||
v-model:checked="remoteRestoreFavorites"
|
||||
:disabled="!availableRemoteRestoreSections.has('favorites')"
|
||||
>
|
||||
{{ $t('dataManager.sections.favoritesBundle') }}
|
||||
</NCheckbox>
|
||||
</label>
|
||||
<label class="local-scope-pill">
|
||||
<NCheckbox
|
||||
v-model:checked="remoteRestoreFavoriteImages"
|
||||
:disabled="!remoteRestoreFavorites || !availableRemoteRestoreSections.has('favoriteImages')"
|
||||
>
|
||||
{{ $t('dataManager.sections.favoriteImages') }}
|
||||
</NCheckbox>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="remoteRestoreFavorites" class="remote-field remote-restore-strategy">
|
||||
<NText depth="3" class="remote-field-label">
|
||||
{{ $t('dataManager.import.favoriteMergeStrategy') }}
|
||||
</NText>
|
||||
@@ -676,7 +723,7 @@
|
||||
type="success"
|
||||
block
|
||||
:loading="isRestoringRemote"
|
||||
:disabled="!selectedRemoteBackup || isRemoteDataOperationActive"
|
||||
:disabled="!selectedRemoteBackup || isRemoteDataOperationActive || !hasSelectedRemoteRestoreSection"
|
||||
@click="handleRemoteRestore"
|
||||
>
|
||||
<template #icon>
|
||||
@@ -776,6 +823,7 @@ import {
|
||||
type RemoteSnapshotEntry,
|
||||
} from '../utils/remote-snapshot-backup'
|
||||
import { recordDataBackupCompleted } from '../utils/data-backup-reminder'
|
||||
import { openExternalUrl } from '../utils/open-external-url'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -833,6 +881,10 @@ const exportFavorites = ref(true)
|
||||
const importAppData = ref(true)
|
||||
const importAppDataImages = ref(true)
|
||||
const importFavorites = ref(true)
|
||||
const remoteRestoreAppData = ref(true)
|
||||
const remoteRestoreAppDataImages = ref(true)
|
||||
const remoteRestoreFavorites = ref(true)
|
||||
const remoteRestoreFavoriteImages = ref(true)
|
||||
const favoriteMergeStrategy = ref<DataManagerFavoritesMergeStrategy>('overwrite')
|
||||
|
||||
const storageSummary = ref<StorageBreakdownSummary | null>(null)
|
||||
@@ -942,6 +994,41 @@ const googleDriveAuthorizationLabel = computed(() =>
|
||||
const selectedRemoteBackup = computed(() =>
|
||||
remoteBackups.value.find((entry) => entry.id === selectedRemoteBackupId.value) ?? null
|
||||
)
|
||||
const getRemoteBackupIncludedSectionSet = (entry: RemoteSnapshotEntry | null): Set<DataManagerPackageSection> => {
|
||||
const included = entry?.manifest?.includedSections
|
||||
if (!Array.isArray(included) || included.length === 0) {
|
||||
return new Set(allPackageSections)
|
||||
}
|
||||
|
||||
const filtered = included.filter((section): section is DataManagerPackageSection =>
|
||||
allPackageSections.includes(section as DataManagerPackageSection),
|
||||
)
|
||||
return new Set(filtered.length > 0 ? filtered : allPackageSections)
|
||||
}
|
||||
const availableRemoteRestoreSections = computed(() =>
|
||||
getRemoteBackupIncludedSectionSet(selectedRemoteBackup.value)
|
||||
)
|
||||
const selectedRemoteBackupMissingAssetsCount = computed(() => {
|
||||
const missingAssets = selectedRemoteBackup.value?.manifest?.missingAssets
|
||||
return Array.isArray(missingAssets) ? missingAssets.length : 0
|
||||
})
|
||||
const resetRemoteRestoreSelection = () => {
|
||||
const available = availableRemoteRestoreSections.value
|
||||
remoteRestoreAppData.value = available.has('appData')
|
||||
remoteRestoreAppDataImages.value = available.has('imageCache')
|
||||
remoteRestoreFavorites.value = available.has('favorites')
|
||||
remoteRestoreFavoriteImages.value = available.has('favoriteImages')
|
||||
}
|
||||
const hasSelectedRemoteRestoreSection = computed(() =>
|
||||
(remoteRestoreAppData.value && availableRemoteRestoreSections.value.has('appData')) ||
|
||||
(remoteRestoreAppData.value && remoteRestoreAppDataImages.value && availableRemoteRestoreSections.value.has('imageCache')) ||
|
||||
(remoteRestoreFavorites.value && availableRemoteRestoreSections.value.has('favorites')) ||
|
||||
(remoteRestoreFavorites.value && remoteRestoreFavoriteImages.value && availableRemoteRestoreSections.value.has('favoriteImages'))
|
||||
)
|
||||
|
||||
watch(selectedRemoteBackup, () => {
|
||||
resetRemoteRestoreSelection()
|
||||
})
|
||||
|
||||
const cloudflareR2Provider = computed(() =>
|
||||
remoteSettings.value.provider.kind === 'cloudflare-r2'
|
||||
@@ -1273,23 +1360,10 @@ const finishRemoteProgress = (messageKey?: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const openExternalUrl = async (url: string) => {
|
||||
if (typeof window !== 'undefined' && window.electronAPI?.shell) {
|
||||
try {
|
||||
await window.electronAPI.shell.openExternal(url)
|
||||
return
|
||||
} catch (error) {
|
||||
console.error('[DataManager] Failed to open external URL in Electron:', error)
|
||||
}
|
||||
}
|
||||
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
const openCloudflareR2Link = (
|
||||
key: 'dashboard' | 'buckets' | 'apiTokens' | 'docs',
|
||||
) => {
|
||||
void openExternalUrl(cloudflareR2Links.value[key])
|
||||
void openExternalUrl(cloudflareR2Links.value[key], { logPrefix: 'DataManager' })
|
||||
}
|
||||
|
||||
const writeClipboardText = async (text: string): Promise<void> => {
|
||||
@@ -1606,7 +1680,16 @@ const toImportSectionSelection = (): DataManagerPackageSectionSelection => ({
|
||||
})
|
||||
|
||||
const toRemoteRestoreSectionSelection = (): DataManagerPackageSectionSelection => ({
|
||||
...DEFAULT_DATA_MANAGER_PACKAGE_SECTIONS,
|
||||
appData: remoteRestoreAppData.value && availableRemoteRestoreSections.value.has('appData'),
|
||||
favorites: remoteRestoreFavorites.value && availableRemoteRestoreSections.value.has('favorites'),
|
||||
imageCache:
|
||||
remoteRestoreAppData.value &&
|
||||
remoteRestoreAppDataImages.value &&
|
||||
availableRemoteRestoreSections.value.has('imageCache'),
|
||||
favoriteImages:
|
||||
remoteRestoreFavorites.value &&
|
||||
remoteRestoreFavoriteImages.value &&
|
||||
availableRemoteRestoreSections.value.has('favoriteImages'),
|
||||
})
|
||||
|
||||
const resetImportSelection = () => {
|
||||
|
||||
@@ -283,7 +283,9 @@ const messages = {
|
||||
"appDataImages": "Related images",
|
||||
"appDataImagesHint": "Images referenced by history and results; current workspace sessions are not included",
|
||||
"favoritesBundle": "Favorites",
|
||||
"favoritesBundleHint": "Favorites, categories, tags, prompt assets, examples, versions, and favorite images"
|
||||
"favoritesBundleHint": "Favorites, categories, tags, prompt assets, examples, versions, and favorite images",
|
||||
"favoriteImages": "Favorite images",
|
||||
"favoriteImagesHint": "Images referenced by favorite prompt assets, examples, and versions"
|
||||
},
|
||||
"contexts": {
|
||||
"title": "Context Collections Management",
|
||||
@@ -421,6 +423,8 @@ const messages = {
|
||||
"refreshList": "Refresh Remote Backup List",
|
||||
"listFailed": "Failed to load remote backup list: {message}",
|
||||
"restoreFrom": "Restore from remote backup",
|
||||
"restoreScope": "Restore contents",
|
||||
"restoreMissingAssetsHint": "This backup was created with {count} missing image resources. Clear image sections to restore only JSON data.",
|
||||
"noBackups": "No remote backups",
|
||||
"restoreSelected": "Restore Selected Backup",
|
||||
"restoreFailed": "Failed to restore remote backup: {message}",
|
||||
|
||||
@@ -283,7 +283,9 @@ const messages = {
|
||||
"appDataImages": "相关图片",
|
||||
"appDataImagesHint": "历史和结果中引用的图片,不包含当前工作区会话",
|
||||
"favoritesBundle": "收藏夹",
|
||||
"favoritesBundleHint": "收藏、分类、标签、提示词资产、示例、版本链和收藏图片"
|
||||
"favoritesBundleHint": "收藏、分类、标签、提示词资产、示例、版本链和收藏图片",
|
||||
"favoriteImages": "收藏图片",
|
||||
"favoriteImagesHint": "收藏提示词资产、示例和版本链引用的图片"
|
||||
},
|
||||
"contexts": {
|
||||
"title": "上下文集合管理",
|
||||
@@ -421,6 +423,8 @@ const messages = {
|
||||
"refreshList": "刷新远程备份列表",
|
||||
"listFailed": "获取远程备份列表失败:{message}",
|
||||
"restoreFrom": "从远程备份还原",
|
||||
"restoreScope": "还原内容",
|
||||
"restoreMissingAssetsHint": "此备份创建时有 {count} 个图片资源缺失,可取消图片内容后只还原 JSON 数据。",
|
||||
"noBackups": "暂无远程备份",
|
||||
"restoreSelected": "还原选中备份",
|
||||
"restoreFailed": "还原远程备份失败:{message}",
|
||||
|
||||
@@ -283,7 +283,9 @@ const messages = {
|
||||
"appDataImages": "相關圖片",
|
||||
"appDataImagesHint": "歷史和結果中引用的圖片,不包含目前工作區會話",
|
||||
"favoritesBundle": "收藏",
|
||||
"favoritesBundleHint": "收藏、分類、標籤、提示詞資產、範例、版本鏈和收藏圖片"
|
||||
"favoritesBundleHint": "收藏、分類、標籤、提示詞資產、範例、版本鏈和收藏圖片",
|
||||
"favoriteImages": "收藏圖片",
|
||||
"favoriteImagesHint": "收藏提示詞資產、範例和版本鏈引用的圖片"
|
||||
},
|
||||
"contexts": {
|
||||
"title": "情境集合管理",
|
||||
@@ -421,6 +423,8 @@ const messages = {
|
||||
"refreshList": "重新整理遠端備份列表",
|
||||
"listFailed": "取得遠端備份列表失敗:{message}",
|
||||
"restoreFrom": "從遠端備份還原",
|
||||
"restoreScope": "還原內容",
|
||||
"restoreMissingAssetsHint": "此備份建立時有 {count} 個圖片資源缺失,可取消圖片內容後只還原 JSON 資料。",
|
||||
"noBackups": "暫無遠端備份",
|
||||
"restoreSelected": "還原選中備份",
|
||||
"restoreFailed": "還原遠端備份失敗:{message}",
|
||||
|
||||
@@ -85,8 +85,8 @@ type ExportDataManagerResourcePackageOptions = {
|
||||
type ImportDataManagerResourcePackageOptions = {
|
||||
dataManager: Pick<IDataManager, 'importAllData'>
|
||||
favoriteManager: Pick<IFavoriteManager, 'importFavorites'> | null | undefined
|
||||
imageStorageService?: Pick<IImageStorageService, 'getMetadata' | 'saveImage'> | null
|
||||
favoriteImageStorageService?: Pick<IImageStorageService, 'getMetadata' | 'saveImage'> | null
|
||||
imageStorageService?: Pick<IImageStorageService, 'getImage' | 'saveImage'> | null
|
||||
favoriteImageStorageService?: Pick<IImageStorageService, 'getImage' | 'saveImage'> | null
|
||||
sections?: Partial<DataManagerPackageSectionSelection>
|
||||
favoriteMergeStrategy?: DataManagerFavoritesMergeStrategy
|
||||
}
|
||||
@@ -307,7 +307,7 @@ export const readDataManagerResourcePackage = (
|
||||
const getImportStorageService = (
|
||||
store: DataManagerImageStoreKey,
|
||||
options: ImportDataManagerResourcePackageOptions,
|
||||
): Pick<IImageStorageService, 'getMetadata' | 'saveImage'> | null | undefined =>
|
||||
): Pick<IImageStorageService, 'getImage' | 'saveImage'> | null | undefined =>
|
||||
store === 'favoriteImages'
|
||||
? options.favoriteImageStorageService
|
||||
: options.imageStorageService
|
||||
|
||||
@@ -78,7 +78,7 @@ type ExportFavoriteResourcePackageOptions = {
|
||||
|
||||
type ImportFavoriteResourcePackageOptions = {
|
||||
favoriteManager: Pick<IFavoriteManager, 'importFavorites'>
|
||||
imageStorageService?: Pick<IImageStorageService, 'getMetadata' | 'saveImage'> | null
|
||||
imageStorageService?: Pick<IImageStorageService, 'getImage' | 'saveImage'> | null
|
||||
mergeStrategy?: 'skip' | 'overwrite' | 'merge'
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export type ImageResourceRestoreReport<Problem> = {
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export type ImageResourceStorage = Pick<IImageStorageService, 'getMetadata' | 'saveImage'>
|
||||
export type ImageResourceStorage = Pick<IImageStorageService, 'getImage' | 'saveImage'>
|
||||
|
||||
export const copyBytes = (bytes: Uint8Array): Uint8Array => {
|
||||
const out = new globalThis.Uint8Array(bytes.byteLength)
|
||||
@@ -203,8 +203,8 @@ export const restoreImageResource = async (
|
||||
bytes: Uint8Array,
|
||||
storageService: ImageResourceStorage,
|
||||
): Promise<'restored' | 'skipped'> => {
|
||||
const existing = await storageService.getMetadata(entry.id)
|
||||
if (existing) {
|
||||
const existing = await storageService.getImage(entry.id)
|
||||
if (existing?.data) {
|
||||
return 'skipped'
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,28 @@ export interface OpenExternalUrlOptions {
|
||||
logPrefix?: string
|
||||
}
|
||||
|
||||
const SUPPORTED_EXTERNAL_URL_PROTOCOLS = new Set(['http:', 'https:'])
|
||||
|
||||
const isSupportedExternalUrl = (url: string): boolean => {
|
||||
try {
|
||||
return SUPPORTED_EXTERNAL_URL_PROTOCOLS.has(new URL(url).protocol)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const openExternalUrl = async (
|
||||
url: string,
|
||||
options: OpenExternalUrlOptions = {},
|
||||
): Promise<boolean> => {
|
||||
if (!url || typeof window === 'undefined') return false
|
||||
|
||||
const prefix = options.logPrefix ?? 'openExternalUrl'
|
||||
if (!isSupportedExternalUrl(url)) {
|
||||
console.error(`[${prefix}] Refused to open unsupported external URL:`, url)
|
||||
return false
|
||||
}
|
||||
|
||||
const target = options.target ?? '_blank'
|
||||
const features = options.features ?? 'noopener,noreferrer'
|
||||
|
||||
@@ -18,8 +34,8 @@ export const openExternalUrl = async (
|
||||
await window.electronAPI.shell.openExternal(url)
|
||||
return true
|
||||
} catch (error) {
|
||||
const prefix = options.logPrefix ?? 'openExternalUrl'
|
||||
console.error(`[${prefix}] Failed to open external URL in Electron:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -143,8 +143,8 @@ type RestoreRemoteSnapshotOptions = {
|
||||
snapshotId: string
|
||||
dataManager: Pick<IDataManager, 'importAllData'>
|
||||
favoriteManager: Pick<IFavoriteManager, 'importFavorites'> | null | undefined
|
||||
imageStorageService?: Pick<IImageStorageService, 'getMetadata' | 'saveImage'> | null
|
||||
favoriteImageStorageService?: Pick<IImageStorageService, 'getMetadata' | 'saveImage'> | null
|
||||
imageStorageService?: Pick<IImageStorageService, 'getImage' | 'saveImage'> | null
|
||||
favoriteImageStorageService?: Pick<IImageStorageService, 'getImage' | 'saveImage'> | null
|
||||
sections?: Partial<DataManagerPackageSectionSelection>
|
||||
favoriteMergeStrategy?: DataManagerFavoritesMergeStrategy
|
||||
onProgress?: RemoteSnapshotProgressReporter
|
||||
@@ -263,7 +263,7 @@ const validateJsonText = (text: string, label: string): void => {
|
||||
const getImportStorageService = (
|
||||
store: DataManagerImageStoreKey,
|
||||
options: RestoreRemoteSnapshotOptions,
|
||||
): Pick<IImageStorageService, 'getMetadata' | 'saveImage'> | null | undefined =>
|
||||
): Pick<IImageStorageService, 'getImage' | 'saveImage'> | null | undefined =>
|
||||
store === 'favoriteImages'
|
||||
? options.favoriteImageStorageService
|
||||
: options.imageStorageService
|
||||
@@ -551,8 +551,8 @@ const prepareRemoteSnapshotRestore = async (
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = await storageService.getMetadata(asset.id)
|
||||
if (existing) {
|
||||
const existing = await storageService.getImage(asset.id)
|
||||
if (existing?.data) {
|
||||
report.skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -575,6 +575,69 @@ describe('DataManager storage breakdown', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('allows remote restore to skip image sections from a partial backup', async () => {
|
||||
window.localStorage.setItem('prompt-optimizer:remote-backup-settings', JSON.stringify({
|
||||
provider: {
|
||||
kind: 'cloudflare-r2',
|
||||
accountId: 'account-123',
|
||||
bucket: 'prompt-optimizer-backups',
|
||||
accessKeyId: 'ak',
|
||||
secretAccessKey: 'sk',
|
||||
},
|
||||
}))
|
||||
|
||||
const wrapper = mountComponent()
|
||||
await flushPromises()
|
||||
|
||||
const setupState = (wrapper.vm as any).$?.setupState
|
||||
setupState.remoteConnectionStates = {
|
||||
...setupState.remoteConnectionStates,
|
||||
'cloudflare-r2': {
|
||||
...setupState.remoteConnectionStates['cloudflare-r2'],
|
||||
status: 'connected',
|
||||
},
|
||||
}
|
||||
setupState.remoteBackups = [{
|
||||
id: 'snap-partial',
|
||||
name: 'snap-partial',
|
||||
manifestPath: 'v1/snapshots/snap-partial/manifest.json',
|
||||
updatedAt: '2026-05-06T10:30:00.000Z',
|
||||
manifest: {
|
||||
includedSections: ['appData', 'favorites', 'imageCache', 'favoriteImages'],
|
||||
missingAssets: [
|
||||
{ store: 'imageCache', id: 'missing-image' },
|
||||
{ store: 'favoriteImages', id: 'missing-favorite-image' },
|
||||
],
|
||||
},
|
||||
}]
|
||||
setupState.selectedRemoteBackupId = 'snap-partial'
|
||||
await flushPromises()
|
||||
|
||||
setupState.remoteRestoreAppData = true
|
||||
setupState.remoteRestoreFavorites = true
|
||||
setupState.remoteRestoreAppDataImages = false
|
||||
setupState.remoteRestoreFavoriteImages = false
|
||||
await flushPromises()
|
||||
|
||||
const restoreButton = wrapper.findAll('button').find((button) =>
|
||||
button.text().includes('Restore Selected Backup')
|
||||
)
|
||||
expect(restoreButton).toBeTruthy()
|
||||
await restoreButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('missing image resources')
|
||||
expect(remoteSnapshotMocks.restoreRemoteSnapshotBackup).toHaveBeenCalledWith(expect.objectContaining({
|
||||
snapshotId: 'snap-partial',
|
||||
sections: {
|
||||
appData: true,
|
||||
favorites: true,
|
||||
imageCache: false,
|
||||
favoriteImages: false,
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
it('disables remote backup actions while a remote data operation is running', async () => {
|
||||
window.localStorage.setItem('prompt-optimizer:remote-backup-settings', JSON.stringify({
|
||||
provider: {
|
||||
|
||||
@@ -195,7 +195,7 @@ describe('FavoriteImportPanel', () => {
|
||||
}),
|
||||
}
|
||||
const favoriteImageStorageService = {
|
||||
getMetadata: vi.fn(async () => null),
|
||||
getImage: vi.fn(async () => null),
|
||||
saveImage: vi.fn(async (image: FullImageData) => {
|
||||
order.push(`save:${image.metadata.id}`)
|
||||
return image.metadata.id
|
||||
|
||||
@@ -140,6 +140,36 @@ describe('dataManagerResourcePackage', () => {
|
||||
expect(favoriteTarget.store.has('favorite-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('restores package resources when local metadata exists but image bytes are missing', async () => {
|
||||
const exported = await createDataManagerResourcePackage({
|
||||
dataManager: {
|
||||
exportAllData: vi.fn(async () => JSON.stringify({ version: 1, data: {} })),
|
||||
},
|
||||
favoriteManager: null,
|
||||
imageStorageService: createStorage([createImage('session-1', 'session-image')]),
|
||||
favoriteImageStorageService: createStorage([]),
|
||||
})
|
||||
const target = createStorage([])
|
||||
target.getMetadata.mockResolvedValue(createImage('session-1', 'metadata-only').metadata)
|
||||
|
||||
const result = await importDataManagerResourcePackage(await exported.blob.arrayBuffer(), {
|
||||
dataManager: { importAllData: vi.fn(async () => {}) },
|
||||
favoriteManager: null,
|
||||
imageStorageService: target,
|
||||
favoriteImageStorageService: createStorage([]),
|
||||
sections: {
|
||||
appData: false,
|
||||
favorites: false,
|
||||
imageCache: true,
|
||||
favoriteImages: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.resources.restored).toBe(1)
|
||||
expect(target.saveImage).toHaveBeenCalledTimes(1)
|
||||
expect(target.store.has('session-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('exports resource metadata from actual bytes instead of stale image metadata', async () => {
|
||||
const jpegBytes = [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0xff, 0xd9]
|
||||
const image: FullImageData = {
|
||||
|
||||
@@ -340,6 +340,32 @@ describe('favoriteResourcePackage', () => {
|
||||
expect(order).toEqual(['save:media-asset', 'import:favorites'])
|
||||
})
|
||||
|
||||
it('restores package resources when local metadata exists but image bytes are missing', async () => {
|
||||
const favorite = createMediaOnlyFavorite([])
|
||||
const exported = await createFavoriteResourcePackage({
|
||||
favoriteManager: {
|
||||
exportFavorites: vi.fn(async () => JSON.stringify({
|
||||
version: '1.0',
|
||||
favorites: [favorite],
|
||||
})),
|
||||
},
|
||||
imageStorageService: createStorage([createImage('cover-asset', 'cover')]),
|
||||
})
|
||||
const target = createStorage([])
|
||||
target.getMetadata.mockResolvedValue(createImage('cover-asset', 'metadata-only').metadata)
|
||||
|
||||
const result = await importFavoriteResourcePackage(await exported.blob.arrayBuffer(), {
|
||||
favoriteManager: {
|
||||
importFavorites: vi.fn(async () => ({ imported: 1, skipped: 0, errors: [] })),
|
||||
},
|
||||
imageStorageService: target,
|
||||
})
|
||||
|
||||
expect(result.resources.restored).toBe(1)
|
||||
expect(target.saveImage).toHaveBeenCalledTimes(1)
|
||||
expect(target.store.has('cover-asset')).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks favorites JSON import when package resources are corrupt', async () => {
|
||||
stubSha256Digest()
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
assertImageResourceRestoreReportSafe,
|
||||
createFullImageDataFromResource,
|
||||
restoreImageResource,
|
||||
safeImageResourceFileName,
|
||||
validateImageResourceBytes,
|
||||
} from '../../../src/utils/image-resource-backup'
|
||||
@@ -62,4 +63,41 @@ describe('image resource backup helpers', () => {
|
||||
expect(image.metadata.mimeType).toBe('image/jpeg')
|
||||
expect(image.metadata.source).toBe('generated')
|
||||
})
|
||||
|
||||
it('skips only when local storage can return complete image bytes', async () => {
|
||||
const bytes = new TextEncoder().encode('backup-image')
|
||||
const entry = {
|
||||
kind: 'image' as const,
|
||||
id: 'asset-3',
|
||||
path: 'resources/images/asset-3.png',
|
||||
mimeType: 'image/png',
|
||||
createdAt: 1,
|
||||
source: 'uploaded' as const,
|
||||
}
|
||||
const saveImage = vi.fn(async () => 'asset-3')
|
||||
const metadataOnlyStorage = {
|
||||
getImage: vi.fn(async () => null),
|
||||
saveImage,
|
||||
}
|
||||
|
||||
await expect(restoreImageResource(entry, bytes, metadataOnlyStorage)).resolves.toBe('restored')
|
||||
expect(saveImage).toHaveBeenCalledTimes(1)
|
||||
|
||||
const completeStorage = {
|
||||
getImage: vi.fn(async () => ({
|
||||
metadata: {
|
||||
id: 'asset-3',
|
||||
mimeType: 'image/png',
|
||||
sizeBytes: 1,
|
||||
createdAt: 1,
|
||||
source: 'uploaded' as const,
|
||||
},
|
||||
data: 'x',
|
||||
})),
|
||||
saveImage: vi.fn(async () => 'asset-3'),
|
||||
}
|
||||
|
||||
await expect(restoreImageResource(entry, bytes, completeStorage)).resolves.toBe('skipped')
|
||||
expect(completeStorage.saveImage).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -51,7 +51,7 @@ describe('openExternalUrl', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to window.open when the desktop bridge fails', async () => {
|
||||
it('does not fall back to window.open when the desktop bridge rejects the URL', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const openExternal = vi.fn().mockRejectedValue(new Error('shell failed'))
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
@@ -62,16 +62,32 @@ describe('openExternalUrl', () => {
|
||||
|
||||
const result = await openExternalUrl('https://example.com', { logPrefix: 'Test' })
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(result).toBe(false)
|
||||
expect(openExternal).toHaveBeenCalledWith('https://example.com')
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
'https://example.com',
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[Test] Failed to open external URL in Electron:',
|
||||
expect.any(Error),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects unsupported protocols before any runtime opener is called', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const openExternal = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(window, 'electronAPI', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: { shell: { openExternal } },
|
||||
})
|
||||
|
||||
const result = await openExternalUrl('file:///tmp/prompt.txt', { logPrefix: 'Test' })
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(openExternal).not.toHaveBeenCalled()
|
||||
expect(openSpy).not.toHaveBeenCalled()
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[Test] Refused to open unsupported external URL:',
|
||||
'file:///tmp/prompt.txt',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -325,6 +325,42 @@ describe('remote snapshot backup', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('restores remote image assets when local metadata exists but image bytes are missing', async () => {
|
||||
const objectStore = createObjectStore()
|
||||
const exported = await createRemoteSnapshotBackup({
|
||||
objectStore,
|
||||
dataManager: {
|
||||
exportAllData: vi.fn(async () => JSON.stringify({ version: 1, data: {} })),
|
||||
},
|
||||
favoriteManager: null,
|
||||
imageStorageService: createStorage([createImage('session-1', 'session-image')]),
|
||||
favoriteImageStorageService: createStorage([]),
|
||||
sections: {
|
||||
appData: false,
|
||||
favorites: false,
|
||||
imageCache: true,
|
||||
favoriteImages: false,
|
||||
},
|
||||
})
|
||||
const target = createStorage([])
|
||||
target.getMetadata.mockResolvedValue(createImage('session-1', 'metadata-only').metadata)
|
||||
|
||||
const result = await restoreRemoteSnapshotBackup({
|
||||
objectStore,
|
||||
snapshotId: exported.entry.id,
|
||||
dataManager: {
|
||||
importAllData: vi.fn(async () => {}),
|
||||
},
|
||||
favoriteManager: null,
|
||||
imageStorageService: target,
|
||||
favoriteImageStorageService: createStorage([]),
|
||||
})
|
||||
|
||||
expect(result.restored).toBe(1)
|
||||
expect(target.saveImage).toHaveBeenCalledTimes(1)
|
||||
expect(target.store.has('session-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not restore sections that were not included in the remote snapshot', async () => {
|
||||
const objectStore = createObjectStore()
|
||||
const exportAllData = vi.fn(async () => JSON.stringify({ version: 1, data: { history: ['local'] } }))
|
||||
|
||||
Reference in new issue
Block a user