mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-10 11:32:13 -03:00
fix(sidecars): report over-long migration paths and fix root relocation (#1142)
- Surface Windows WinError 206 / ENAMETOOLONG during sidecar migration as an actionable per-model error instead of a bare OSError - Offer root relocation based on the resolved sidecar root, so moving off (or back to) the default storage directory relocates existing sidecars - Hint in the migration confirm dialog when the destination is the default location, so a custom folder can be set before migrating
This commit is contained in:
+2
-1
@@ -1749,7 +1749,8 @@
|
||||
"titleToAlongside": "Sidecar-Dateien zurück neben die Modelldateien verschieben?",
|
||||
"confirmButton": "Jetzt verschieben",
|
||||
"titleRelocateRoot": "Sidecar-Dateien in das neue Speicherverzeichnis verschieben?",
|
||||
"destination": "Ziel: {path}"
|
||||
"destination": "Ziel: {path}",
|
||||
"defaultLocationHint": "Es wird der Standard-Speicherort verwendet. Um einen eigenen Ordner zu nutzen, brechen Sie ab, setzen Sie oben den Speicherpfad und klicken Sie dann auf „Sidecar-Dateien jetzt verschieben“."
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "Sidecar-Migrationsübersicht",
|
||||
|
||||
+2
-1
@@ -1749,7 +1749,8 @@
|
||||
"titleToAlongside": "Move sidecars back next to model files?",
|
||||
"confirmButton": "Migrate Now",
|
||||
"titleRelocateRoot": "Move sidecars to the new storage directory?",
|
||||
"destination": "Destination: {path}"
|
||||
"destination": "Destination: {path}",
|
||||
"defaultLocationHint": "This uses the default location. To use a custom folder, cancel, set the storage path above, then click \"Migrate Sidecars Now\"."
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "Sidecar Migration Summary",
|
||||
|
||||
+2
-1
@@ -1749,7 +1749,8 @@
|
||||
"titleToAlongside": "¿Devolver los archivos sidecar junto a los archivos de modelo?",
|
||||
"confirmButton": "Migrar ahora",
|
||||
"titleRelocateRoot": "¿Mover los archivos sidecar al nuevo directorio de almacenamiento?",
|
||||
"destination": "Destino: {path}"
|
||||
"destination": "Destino: {path}",
|
||||
"defaultLocationHint": "Se usará la ubicación predeterminada. Para usar una carpeta personalizada, cancela, define la ruta de almacenamiento arriba y luego haz clic en «Migrar archivos sidecar ahora»."
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "Resumen de la migración de archivos sidecar",
|
||||
|
||||
+2
-1
@@ -1749,7 +1749,8 @@
|
||||
"titleToAlongside": "Remettre les fichiers sidecar à côté des fichiers de modèle ?",
|
||||
"confirmButton": "Migrer maintenant",
|
||||
"titleRelocateRoot": "Déplacer les fichiers sidecar vers le nouveau dossier de stockage ?",
|
||||
"destination": "Destination : {path}"
|
||||
"destination": "Destination : {path}",
|
||||
"defaultLocationHint": "L’emplacement par défaut sera utilisé. Pour utiliser un dossier personnalisé, annulez, définissez le chemin de stockage ci-dessus, puis cliquez sur « Migrer les fichiers sidecar maintenant »."
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "Résumé de la migration des fichiers sidecar",
|
||||
|
||||
+2
-1
@@ -1749,7 +1749,8 @@
|
||||
"titleToAlongside": "להחזיר את קובצי הלוואי לצד קובצי המודל?",
|
||||
"confirmButton": "העבר כעת",
|
||||
"titleRelocateRoot": "להעביר את קובצי הלוואי לתיקיית האחסון החדשה?",
|
||||
"destination": "יעד: {path}"
|
||||
"destination": "יעד: {path}",
|
||||
"defaultLocationHint": "נעשה שימוש במיקום ברירת המחדל. כדי להשתמש בתיקייה מותאמת אישית, בטל, הגדר את נתיב האחסון למעלה ולאחר מכן לחץ על «העבר קובצי לוואי כעת»."
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "סיכום העברת קובצי לוואי",
|
||||
|
||||
+2
-1
@@ -1749,7 +1749,8 @@
|
||||
"titleToAlongside": "サイドカーファイルをモデルファイルの隣に戻しますか?",
|
||||
"confirmButton": "今すぐ移動",
|
||||
"titleRelocateRoot": "サイドカーファイルを新しい保存ディレクトリに移動しますか?",
|
||||
"destination": "移動先:{path}"
|
||||
"destination": "移動先:{path}",
|
||||
"defaultLocationHint": "デフォルトの保存場所が使用されます。カスタムフォルダを使用するには、キャンセルして上の保存先パスを設定してから、「今すぐサイドカーファイルを移動」をクリックしてください。"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "サイドカーファイル移動の概要",
|
||||
|
||||
+2
-1
@@ -1749,7 +1749,8 @@
|
||||
"titleToAlongside": "사이드카 파일을 모델 파일 옆으로 되돌릴까요?",
|
||||
"confirmButton": "지금 이동",
|
||||
"titleRelocateRoot": "사이드카 파일을 새 저장 디렉터리로 이동할까요?",
|
||||
"destination": "대상 위치: {path}"
|
||||
"destination": "대상 위치: {path}",
|
||||
"defaultLocationHint": "기본 저장 위치가 사용됩니다. 사용자 지정 폴더를 사용하려면 취소하고 위에서 저장 경로를 설정한 다음 '지금 사이드카 파일 이동'을 클릭하세요."
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "사이드카 파일 이동 요약",
|
||||
|
||||
+2
-1
@@ -1749,7 +1749,8 @@
|
||||
"titleToAlongside": "Вернуть sidecar-файлы рядом с файлами моделей?",
|
||||
"confirmButton": "Перенести сейчас",
|
||||
"titleRelocateRoot": "Перенести sidecar-файлы в новый каталог хранилища?",
|
||||
"destination": "Назначение: {path}"
|
||||
"destination": "Назначение: {path}",
|
||||
"defaultLocationHint": "Будет использовано расположение по умолчанию. Чтобы использовать свою папку, отмените, укажите путь хранилища выше, затем нажмите «Перенести sidecar-файлы сейчас»."
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "Сводка переноса sidecar-файлов",
|
||||
|
||||
+2
-1
@@ -1749,7 +1749,8 @@
|
||||
"titleToAlongside": "要将附属文件移回模型文件旁边吗?",
|
||||
"confirmButton": "立即迁移",
|
||||
"titleRelocateRoot": "要将附属文件移到新的存储目录吗?",
|
||||
"destination": "目标位置:{path}"
|
||||
"destination": "目标位置:{path}",
|
||||
"defaultLocationHint": "将使用默认存储位置。如需使用自定义文件夹,请取消,在上方设置存储路径,然后点击“立即迁移附属文件”。"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "附属文件迁移摘要",
|
||||
|
||||
+2
-1
@@ -1749,7 +1749,8 @@
|
||||
"titleToAlongside": "要將附屬檔案移回模型檔案旁邊嗎?",
|
||||
"confirmButton": "立即遷移",
|
||||
"titleRelocateRoot": "要將附屬檔案移到新的儲存目錄嗎?",
|
||||
"destination": "目標位置:{path}"
|
||||
"destination": "目標位置:{path}",
|
||||
"defaultLocationHint": "將使用預設儲存位置。如需使用自訂資料夾,請取消,在上方設定儲存路徑,然後點擊「立即遷移附屬檔案」。"
|
||||
},
|
||||
"sidecarMigrationResult": {
|
||||
"title": "附屬檔案遷移摘要",
|
||||
|
||||
@@ -645,6 +645,23 @@ class SidecarMigrationUseCase:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _raise_path_error(exc: OSError, dst: str) -> None:
|
||||
"""Re-raise path-length OS errors with an actionable message.
|
||||
|
||||
Windows rejects paths over MAX_PATH (260 chars) with WinError 206
|
||||
(ERROR_FILENAME_EXCED_RANGE); Linux reports ENAMETOOLONG. A bare
|
||||
OSError gives no hint that the sidecar root is simply too deep.
|
||||
"""
|
||||
|
||||
if getattr(exc, "winerror", None) == 206 or exc.errno == errno.ENAMETOOLONG:
|
||||
raise OSError(
|
||||
f"Destination path is too long for the OS ({len(dst)} chars; "
|
||||
f"Windows limit is 260): {dst}. Choose a shallower sidecar "
|
||||
f"storage directory or shorten the file name."
|
||||
) from exc
|
||||
raise exc
|
||||
|
||||
def _transfer(self, src: str, dst: str, result: Dict[str, Any]) -> bool:
|
||||
"""Move ``src`` to ``dst`` with keep-newer conflict resolution.
|
||||
|
||||
@@ -667,7 +684,10 @@ class SidecarMigrationUseCase:
|
||||
)
|
||||
os.remove(src)
|
||||
return False
|
||||
self._move_file(src, dst)
|
||||
try:
|
||||
self._move_file(src, dst)
|
||||
except OSError as exc:
|
||||
self._raise_path_error(exc, dst)
|
||||
result["moved"] += 1
|
||||
return True
|
||||
|
||||
|
||||
@@ -3419,8 +3419,13 @@ export class SettingsManager {
|
||||
}
|
||||
// Baseline used to detect a mode change in handleSidecarStorageModeChange
|
||||
this._loadedSidecarStorageMode = currentMode;
|
||||
// Baseline used to detect a root change in handleSidecarStoragePathChange
|
||||
// Baseline of the raw path input, refreshed in handleSidecarStoragePathChange
|
||||
this._loadedSidecarStoragePath = state.global.settings.sidecar_storage_path || '';
|
||||
// Baseline used to detect a root change in handleSidecarStoragePathChange.
|
||||
// Must be the RESOLVED root, not the raw setting: with no custom path the
|
||||
// setting is empty while sidecars live in the default root, and that
|
||||
// default->custom transition is exactly the relocation we must offer.
|
||||
this._loadedSidecarStorageRoot = state.global.settings.sidecar_storage_root || '';
|
||||
|
||||
const pathInput = document.getElementById('sidecarStoragePath');
|
||||
if (pathInput) {
|
||||
@@ -3527,26 +3532,30 @@ export class SettingsManager {
|
||||
|
||||
// Path change while centralized storage is active: the assets under the
|
||||
// previous root do not move by themselves, so offer a root relocation.
|
||||
// Compare RESOLVED roots (the raw setting is empty for the default root,
|
||||
// which still holds sidecars and must be relocatable).
|
||||
async handleSidecarStoragePathChange() {
|
||||
const pathInput = document.getElementById('sidecarStoragePath');
|
||||
if (!pathInput) return;
|
||||
|
||||
const previousPath = this._loadedSidecarStoragePath || '';
|
||||
const previousRoot = this._loadedSidecarStorageRoot || '';
|
||||
|
||||
await this.saveInputSetting('sidecarStoragePath', 'sidecar_storage_path');
|
||||
|
||||
const newPath = pathInput.value.trim();
|
||||
this._loadedSidecarStoragePath = newPath;
|
||||
this._loadedSidecarStoragePath = pathInput.value.trim();
|
||||
|
||||
// The resolved root is server-side; refresh before any relocate
|
||||
// confirm so the dialog can name the real destination.
|
||||
await this.refreshSidecarStorageInfo();
|
||||
|
||||
const newRoot = state.global.settings.sidecar_storage_root || '';
|
||||
this._loadedSidecarStorageRoot = newRoot;
|
||||
|
||||
const centralized = state.global.settings.sidecar_storage_mode === 'centralized';
|
||||
if (centralized && previousPath && previousPath !== newPath) {
|
||||
if (centralized && previousRoot && previousRoot !== newRoot) {
|
||||
const confirmed = await this.confirmSidecarMigration('relocate_root');
|
||||
if (confirmed) {
|
||||
await this.migrateSidecars('relocate_root', { old_root: previousPath });
|
||||
await this.migrateSidecars('relocate_root', { old_root: previousRoot });
|
||||
} else {
|
||||
showToast('settings.sidecarStorage.migrationDeferred', {}, 'info');
|
||||
}
|
||||
@@ -3606,6 +3615,22 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Migrating into the default location: let users pick a custom folder
|
||||
// first instead of migrating now and relocating afterwards.
|
||||
const hintElement = modalElement.querySelector('[data-role="default-location-hint"]');
|
||||
if (hintElement) {
|
||||
if (isToCentralized && state.global.settings.sidecar_storage_root_is_default) {
|
||||
hintElement.textContent = translate(
|
||||
'modals.sidecarMigrationConfirm.defaultLocationHint',
|
||||
{},
|
||||
'This uses the default location. To use a custom folder, cancel, set the storage path above, then click "Migrate Sidecars Now".'
|
||||
);
|
||||
hintElement.style.display = 'block';
|
||||
} else {
|
||||
hintElement.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
const confirmButton = modalElement.querySelector('[data-action="confirm-sidecar-migration"]');
|
||||
const cancelButton = modalElement.querySelector('[data-action="cancel-sidecar-migration"]');
|
||||
if (!confirmButton || !cancelButton) {
|
||||
|
||||
@@ -104,6 +104,7 @@
|
||||
<h2 data-role="title"></h2>
|
||||
<p class="delete-message" data-role="message"></p>
|
||||
<p class="delete-message sidecar-migration-destination" data-role="destination" style="display: none;"></p>
|
||||
<p class="delete-message sidecar-migration-hint" data-role="default-location-hint" style="display: none;"></p>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" data-action="cancel-sidecar-migration">{{ t('common.actions.cancel') }}</button>
|
||||
<button class="primary-btn" data-action="confirm-sidecar-migration"></button>
|
||||
|
||||
@@ -117,6 +117,7 @@ const appendMigrationModal = () => {
|
||||
<h2 data-role="title"></h2>
|
||||
<p data-role="message"></p>
|
||||
<p data-role="destination" style="display:none"></p>
|
||||
<p data-role="default-location-hint" style="display:none"></p>
|
||||
<button data-action="confirm-sidecar-migration"></button>
|
||||
<button data-action="cancel-sidecar-migration"></button>`;
|
||||
document.body.appendChild(modal);
|
||||
@@ -130,6 +131,26 @@ const mockFetchOk = (payload = { success: true }) => {
|
||||
});
|
||||
};
|
||||
|
||||
// Fetch mock where the GET /api/lm/settings refresh reports ``resolvedRoot``
|
||||
// as the new resolved sidecar root; every other call succeeds generically.
|
||||
const mockFetchWithResolvedRoot = (resolvedRoot) => {
|
||||
global.fetch = vi.fn((url, options) => {
|
||||
if (url === '/api/lm/settings' && !options) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
settings: { sidecar_storage_root: resolvedRoot },
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
@@ -255,6 +276,53 @@ describe('SettingsManager sidecar storage', () => {
|
||||
await changePromise;
|
||||
});
|
||||
|
||||
it('hints at setting a custom folder first when migrating to the default location', async () => {
|
||||
const manager = createManager();
|
||||
const { select } = appendSidecarControls();
|
||||
const modal = appendMigrationModal();
|
||||
state.global.settings = {
|
||||
sidecar_storage_mode: 'alongside',
|
||||
sidecar_storage_root: '/settings/sidecars',
|
||||
sidecar_storage_root_is_default: true,
|
||||
};
|
||||
manager._loadedSidecarStorageMode = 'alongside';
|
||||
select.value = 'centralized';
|
||||
mockFetchOk();
|
||||
|
||||
const changePromise = manager.handleSidecarStorageModeChange();
|
||||
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||
|
||||
const hint = modal.querySelector('[data-role="default-location-hint"]');
|
||||
expect(hint.style.display).toBe('block');
|
||||
expect(hint.textContent).toContain('default location');
|
||||
|
||||
modal.querySelector('[data-action="cancel-sidecar-migration"]').click();
|
||||
await changePromise;
|
||||
});
|
||||
|
||||
it('hides the default-location hint for custom roots and other directions', async () => {
|
||||
const manager = createManager();
|
||||
const { select } = appendSidecarControls();
|
||||
const modal = appendMigrationModal();
|
||||
state.global.settings = {
|
||||
sidecar_storage_mode: 'alongside',
|
||||
sidecar_storage_root: '/data/sidecars',
|
||||
sidecar_storage_root_is_default: false,
|
||||
};
|
||||
manager._loadedSidecarStorageMode = 'alongside';
|
||||
select.value = 'centralized';
|
||||
mockFetchOk();
|
||||
|
||||
const changePromise = manager.handleSidecarStorageModeChange();
|
||||
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||
|
||||
const hint = modal.querySelector('[data-role="default-location-hint"]');
|
||||
expect(hint.style.display).toBe('none');
|
||||
|
||||
modal.querySelector('[data-action="cancel-sidecar-migration"]').click();
|
||||
await changePromise;
|
||||
});
|
||||
|
||||
it('shows a deferred notice and skips migration when the user cancels', async () => { const manager = createManager();
|
||||
const { select } = appendSidecarControls();
|
||||
const modal = appendMigrationModal();
|
||||
@@ -311,14 +379,20 @@ describe('SettingsManager sidecar storage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleSidecarStoragePathChange', () => { it('offers root relocation when the path changes in centralized mode', async () => {
|
||||
describe('handleSidecarStoragePathChange', () => {
|
||||
it('offers root relocation when the path changes in centralized mode', async () => {
|
||||
const manager = createManager();
|
||||
const { pathInput } = appendSidecarControls();
|
||||
const modal = appendMigrationModal();
|
||||
state.global.settings = { sidecar_storage_mode: 'centralized', sidecar_storage_path: '/old/root' };
|
||||
state.global.settings = {
|
||||
sidecar_storage_mode: 'centralized',
|
||||
sidecar_storage_path: '/old/root',
|
||||
sidecar_storage_root: '/old/root',
|
||||
};
|
||||
manager._loadedSidecarStoragePath = '/old/root';
|
||||
manager._loadedSidecarStorageRoot = '/old/root';
|
||||
pathInput.value = '/new/root';
|
||||
mockFetchOk();
|
||||
mockFetchWithResolvedRoot('/new/root');
|
||||
|
||||
const changePromise = manager.handleSidecarStoragePathChange();
|
||||
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||
@@ -329,16 +403,93 @@ describe('SettingsManager sidecar storage', () => {
|
||||
body: JSON.stringify({ direction: 'relocate_root', force: true, old_root: '/old/root' }),
|
||||
}));
|
||||
expect(manager._loadedSidecarStoragePath).toBe('/new/root');
|
||||
expect(manager._loadedSidecarStorageRoot).toBe('/new/root');
|
||||
});
|
||||
|
||||
it('offers relocation from the default root when no custom path was set (#1142)', async () => {
|
||||
const manager = createManager();
|
||||
const { pathInput } = appendSidecarControls();
|
||||
const modal = appendMigrationModal();
|
||||
// No custom path: the raw setting is empty, but sidecars live in
|
||||
// the resolved default root.
|
||||
state.global.settings = {
|
||||
sidecar_storage_mode: 'centralized',
|
||||
sidecar_storage_path: '',
|
||||
sidecar_storage_root: '/settings/sidecars',
|
||||
};
|
||||
manager._loadedSidecarStoragePath = '';
|
||||
manager._loadedSidecarStorageRoot = '/settings/sidecars';
|
||||
pathInput.value = '/custom/dir';
|
||||
mockFetchWithResolvedRoot('/custom/dir');
|
||||
|
||||
const changePromise = manager.handleSidecarStoragePathChange();
|
||||
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||
modal.querySelector('[data-action="confirm-sidecar-migration"]').click();
|
||||
await changePromise;
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/sidecars/migrate', expect.objectContaining({
|
||||
body: JSON.stringify({ direction: 'relocate_root', force: true, old_root: '/settings/sidecars' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('offers relocation back to the default root when the path is cleared', async () => {
|
||||
const manager = createManager();
|
||||
const { pathInput } = appendSidecarControls();
|
||||
const modal = appendMigrationModal();
|
||||
state.global.settings = {
|
||||
sidecar_storage_mode: 'centralized',
|
||||
sidecar_storage_path: '/custom/dir',
|
||||
sidecar_storage_root: '/custom/dir',
|
||||
};
|
||||
manager._loadedSidecarStoragePath = '/custom/dir';
|
||||
manager._loadedSidecarStorageRoot = '/custom/dir';
|
||||
pathInput.value = '';
|
||||
mockFetchWithResolvedRoot('/settings/sidecars');
|
||||
|
||||
const changePromise = manager.handleSidecarStoragePathChange();
|
||||
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||
modal.querySelector('[data-action="confirm-sidecar-migration"]').click();
|
||||
await changePromise;
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/sidecars/migrate', expect.objectContaining({
|
||||
body: JSON.stringify({ direction: 'relocate_root', force: true, old_root: '/custom/dir' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not prompt when the resolved root is unchanged', async () => {
|
||||
const manager = createManager();
|
||||
const { pathInput } = appendSidecarControls();
|
||||
appendMigrationModal();
|
||||
state.global.settings = {
|
||||
sidecar_storage_mode: 'centralized',
|
||||
sidecar_storage_path: '/old/root',
|
||||
sidecar_storage_root: '/old/root',
|
||||
};
|
||||
manager._loadedSidecarStoragePath = '/old/root';
|
||||
manager._loadedSidecarStorageRoot = '/old/root';
|
||||
// Whitespace-only edit: saveInputSetting no-ops, root stays put.
|
||||
pathInput.value = '/old/root';
|
||||
mockFetchWithResolvedRoot('/old/root');
|
||||
|
||||
await manager.handleSidecarStoragePathChange();
|
||||
|
||||
const migrateCalls = global.fetch.mock.calls.filter(([url]) => url === '/api/lm/sidecars/migrate');
|
||||
expect(migrateCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not prompt when the path changes in alongside mode', async () => {
|
||||
const manager = createManager();
|
||||
const { pathInput } = appendSidecarControls();
|
||||
appendMigrationModal();
|
||||
state.global.settings = { sidecar_storage_mode: 'alongside', sidecar_storage_path: '/old/root' };
|
||||
state.global.settings = {
|
||||
sidecar_storage_mode: 'alongside',
|
||||
sidecar_storage_path: '/old/root',
|
||||
sidecar_storage_root: '/old/root',
|
||||
};
|
||||
manager._loadedSidecarStoragePath = '/old/root';
|
||||
manager._loadedSidecarStorageRoot = '/old/root';
|
||||
pathInput.value = '/new/root';
|
||||
mockFetchOk();
|
||||
mockFetchWithResolvedRoot('/new/root');
|
||||
|
||||
await manager.handleSidecarStoragePathChange();
|
||||
|
||||
@@ -350,10 +501,15 @@ describe('SettingsManager sidecar storage', () => {
|
||||
const manager = createManager();
|
||||
const { pathInput } = appendSidecarControls();
|
||||
const modal = appendMigrationModal();
|
||||
state.global.settings = { sidecar_storage_mode: 'centralized', sidecar_storage_path: '/old/root' };
|
||||
state.global.settings = {
|
||||
sidecar_storage_mode: 'centralized',
|
||||
sidecar_storage_path: '/old/root',
|
||||
sidecar_storage_root: '/old/root',
|
||||
};
|
||||
manager._loadedSidecarStoragePath = '/old/root';
|
||||
manager._loadedSidecarStorageRoot = '/old/root';
|
||||
pathInput.value = '/new/root';
|
||||
mockFetchOk();
|
||||
mockFetchWithResolvedRoot('/new/root');
|
||||
|
||||
const changePromise = manager.handleSidecarStoragePathChange();
|
||||
await vi.waitFor(() => expect(modal.classList.contains('show')).toBe(true));
|
||||
|
||||
@@ -619,3 +619,112 @@ async def test_migrate_covers_excluded_models(
|
||||
# The excluded model is not in the cache, so cache reconciliation is a
|
||||
# no-op for it and only the cached entry gets persisted.
|
||||
assert use_case._test_scanner.persist_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_reports_destination_path_too_long(
|
||||
library_root: Path, sidecar_root: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Windows WinError 206 becomes an actionable per-model error (#1142)."""
|
||||
|
||||
_set_mode("centralized")
|
||||
model = _write_model(library_root, "model")
|
||||
sidecar = _write_sidecar(library_root, "model", model, preview_ext=None)
|
||||
|
||||
use_case = _make_use_case([str(model)])
|
||||
|
||||
def failing_move(src: str, dst: str) -> None:
|
||||
exc = OSError("The filename or extension is too long")
|
||||
exc.winerror = 206 # ERROR_FILENAME_EXCED_RANGE
|
||||
raise exc
|
||||
|
||||
monkeypatch.setattr(use_case, "_move_file", failing_move)
|
||||
summary = await use_case.migrate_to_centralized(force=True)
|
||||
|
||||
assert summary["success"] is False
|
||||
assert summary["error_count"] == 1
|
||||
assert summary["moved"] == 0
|
||||
message = summary["errors"][0]["error"]
|
||||
assert "too long" in message
|
||||
assert "shallower sidecar storage directory" in message
|
||||
# Failed move leaves the source untouched: no partial migration.
|
||||
assert sidecar.exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_reports_enametoolong(
|
||||
library_root: Path, sidecar_root: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""ENAMETOOLONG gets the same actionable message as WinError 206."""
|
||||
|
||||
import errno as errno_module
|
||||
|
||||
_set_mode("centralized")
|
||||
model = _write_model(library_root, "model")
|
||||
_write_sidecar(library_root, "model", model, preview_ext=None)
|
||||
|
||||
use_case = _make_use_case([str(model)])
|
||||
|
||||
def failing_move(src: str, dst: str) -> None:
|
||||
raise OSError(errno_module.ENAMETOOLONG, "File name too long")
|
||||
|
||||
monkeypatch.setattr(use_case, "_move_file", failing_move)
|
||||
summary = await use_case.migrate_to_centralized(force=True)
|
||||
|
||||
assert summary["success"] is False
|
||||
assert "too long" in summary["errors"][0]["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_keeps_unrelated_oserror_message(
|
||||
library_root: Path, sidecar_root: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Non-path-length OSErrors propagate with their original message."""
|
||||
|
||||
import errno as errno_module
|
||||
|
||||
_set_mode("centralized")
|
||||
model = _write_model(library_root, "model")
|
||||
_write_sidecar(library_root, "model", model, preview_ext=None)
|
||||
|
||||
use_case = _make_use_case([str(model)])
|
||||
|
||||
def failing_move(src: str, dst: str) -> None:
|
||||
raise OSError(errno_module.EACCES, "Permission denied")
|
||||
|
||||
monkeypatch.setattr(use_case, "_move_file", failing_move)
|
||||
summary = await use_case.migrate_to_centralized(force=True)
|
||||
|
||||
assert summary["success"] is False
|
||||
message = summary["errors"][0]["error"]
|
||||
assert "Permission denied" in message
|
||||
assert "too long" not in message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migrate_root_reports_destination_path_too_long(
|
||||
library_root: Path, sidecar_root: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Root relocation surfaces path-length failures per file (#1142)."""
|
||||
|
||||
_set_mode("centralized")
|
||||
old_root = tmp_path / "old_sidecars"
|
||||
old_mirror = old_root / "component" / "sub"
|
||||
old_mirror.mkdir(parents=True)
|
||||
(old_mirror / "model.metadata.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
use_case = _make_use_case([])
|
||||
|
||||
def failing_move(src: str, dst: str) -> None:
|
||||
exc = OSError("The filename or extension is too long")
|
||||
exc.winerror = 206
|
||||
raise exc
|
||||
|
||||
monkeypatch.setattr(use_case, "_move_file", failing_move)
|
||||
summary = await use_case.migrate_root(str(old_root), force=True)
|
||||
|
||||
assert summary["success"] is False
|
||||
assert summary["moved"] == 0
|
||||
assert any("too long" in entry["error"] for entry in summary["errors"])
|
||||
# Source file stays in the old tree.
|
||||
assert (old_mirror / "model.metadata.json").exists()
|
||||
|
||||
Reference in New Issue
Block a user