mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
fix(recipes): make batch-import directory browser work on Windows (#1106)
The browse endpoint and its frontend were written with POSIX-only
assumptions, so on Windows pressing Browse immediately failed with
"Access denied to this directory":
- The frontend opened the browser at "/", which resolves to the
current drive root on Windows.
- The allowlist check used Path("/"), which has no drive letter on
Windows, so relative_to() rejected every drive-qualified path —
anything outside the user profile was denied.
Fixes:
- Empty browse path now defaults to the user home directory instead of
erroring; the frontend sends "" rather than the POSIX-only "/".
- The access check is platform-aware (drive-qualified on Windows,
absolute on POSIX).
- Parent navigation uses the server-provided parent_path; the root
check is now path.parent == path (the old str/anchor comparison
self-looped at Windows drive roots).
- Browsing up from a Windows drive root shows a virtual list of
available drives so users can switch drives without typing a path.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderTemplate } from '../utils/domFixtures.js';
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
setupAutoNewlineOnPaste: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: (key, params = {}, fallback = null) => fallback ?? key,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
WS_ENDPOINTS: {},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||
getStorageItem: vi.fn(() => true),
|
||||
setStorageItem: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('BatchImportManager directory browser (#1106)', () => {
|
||||
let batchImportManager;
|
||||
let fetchMock;
|
||||
let showToast;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
document.body.innerHTML = '';
|
||||
renderTemplate('components/batch_import_modal.html');
|
||||
|
||||
fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true }),
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const uiHelpers = await import('../../../static/js/utils/uiHelpers.js');
|
||||
showToast = uiHelpers.showToast;
|
||||
|
||||
const batchModule = await import('../../../static/js/managers/BatchImportManager.js');
|
||||
batchImportManager = new batchModule.BatchImportManager();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function lastRequestBody() {
|
||||
return JSON.parse(fetchMock.mock.calls.at(-1)[1].body);
|
||||
}
|
||||
|
||||
it('opens the browser with an empty path so the server picks the default', async () => {
|
||||
// The old POSIX-only "/" initial path fails the access check on Windows.
|
||||
document.getElementById('batchDirectoryInput').value = '';
|
||||
|
||||
batchImportManager.toggleDirectoryBrowser();
|
||||
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
expect(lastRequestBody().path).toBe('');
|
||||
expect(document.getElementById('batchDirectoryBrowser').style.display).toBe('block');
|
||||
});
|
||||
|
||||
it('navigates to the parent using the server-provided path (Windows-safe)', async () => {
|
||||
fetchMock.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
current_path: 'C:\\Users\\miao\\Pictures',
|
||||
parent_path: 'C:\\Users\\miao',
|
||||
directories: [],
|
||||
image_files: [],
|
||||
image_count: 0,
|
||||
directory_count: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
await batchImportManager.loadDirectory('C:\\Users\\miao\\Pictures');
|
||||
fetchMock.mockClear();
|
||||
|
||||
batchImportManager.navigateToParentDirectory();
|
||||
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
expect(lastRequestBody().path).toBe('C:\\Users\\miao');
|
||||
});
|
||||
|
||||
it('refuses to select a virtual level that has no current path (drive list)', async () => {
|
||||
fetchMock.mockImplementation(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
current_path: '',
|
||||
parent_path: null,
|
||||
directories: [{ name: 'C:\\', path: 'C:\\', is_parent: false }],
|
||||
image_files: [],
|
||||
image_count: 0,
|
||||
directory_count: 1,
|
||||
}),
|
||||
}));
|
||||
|
||||
await batchImportManager.loadDirectory('__drives__');
|
||||
batchImportManager.selectCurrentDirectory();
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith('toast.recipes.batchImportNoDirectory', {}, 'error');
|
||||
expect(document.getElementById('batchDirectoryInput').value).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from py.routes.handlers.recipe_handlers import BatchImportHandler
|
||||
|
||||
|
||||
def _make_handler() -> BatchImportHandler:
|
||||
return BatchImportHandler(
|
||||
ensure_dependencies_ready=None, # browse_directory never calls it
|
||||
recipe_scanner_getter=lambda: None,
|
||||
civitai_client_getter=lambda: None,
|
||||
logger=logging.getLogger(__name__),
|
||||
batch_import_service=None,
|
||||
)
|
||||
|
||||
|
||||
class _Request:
|
||||
def __init__(self, path: str) -> None:
|
||||
self._path = path
|
||||
|
||||
async def json(self):
|
||||
return {"path": self._path}
|
||||
|
||||
|
||||
async def _browse(handler: BatchImportHandler, path: str):
|
||||
response = await handler.browse_directory(_Request(path))
|
||||
return response, json.loads(response.text)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_directory_lists_subdirs_and_images(tmp_path):
|
||||
(tmp_path / "subdir").mkdir()
|
||||
(tmp_path / "photo.png").write_bytes(b"x")
|
||||
(tmp_path / "notes.txt").write_text("not an image")
|
||||
|
||||
response, payload = await _browse(_make_handler(), str(tmp_path))
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["current_path"] == str(tmp_path)
|
||||
assert [d["name"] for d in payload["directories"]] == ["subdir"]
|
||||
assert [f["name"] for f in payload["image_files"]] == ["photo.png"]
|
||||
assert payload["parent_path"] == str(tmp_path.parent)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_directory_empty_path_defaults_to_home(tmp_path, monkeypatch):
|
||||
# The frontend no longer sends the POSIX-only "/" as the initial path; an
|
||||
# empty path must resolve to the user's home directory (#1106).
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
|
||||
response, payload = await _browse(_make_handler(), "")
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["current_path"] == str(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="POSIX root semantics")
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_directory_root_has_no_parent():
|
||||
_, payload = await _browse(_make_handler(), os.path.abspath(os.sep))
|
||||
|
||||
assert payload["success"] is True
|
||||
assert payload["parent_path"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_directory_windows_drives_token(monkeypatch):
|
||||
# The token branch returns before any pathlib use, so faking os.name is
|
||||
# enough to exercise it on POSIX (#1106).
|
||||
monkeypatch.setattr(os, "name", "nt")
|
||||
monkeypatch.setattr(os, "listdrives", lambda: ["C:\\", "D:\\"], raising=False)
|
||||
|
||||
response, payload = await _browse(
|
||||
_make_handler(), BatchImportHandler.WINDOWS_DRIVES_TOKEN
|
||||
)
|
||||
|
||||
assert response.status == 200
|
||||
assert payload["success"] is True
|
||||
assert payload["current_path"] == ""
|
||||
assert payload["parent_path"] is None
|
||||
assert [d["name"] for d in payload["directories"]] == ["C:\\", "D:\\"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_directory_missing_directory_returns_404(tmp_path):
|
||||
response, payload = await _browse(
|
||||
_make_handler(), str(tmp_path / "does-not-exist")
|
||||
)
|
||||
|
||||
assert response.status == 404
|
||||
assert payload["success"] is False
|
||||
Reference in New Issue
Block a user