diff --git a/TESTING.md b/TESTING.md index bee187c..db34c12 100644 --- a/TESTING.md +++ b/TESTING.md @@ -129,21 +129,26 @@ gh api repos/ComfyAssets/ComfyUI_PromptManager/branches/main/protection/required Update the manifest whenever required job names change. Keep the frontend check free of path filters so a required check is never silently skipped. -## Unified workspace shell +## Unified workspace -The opt-in `/prompt_manager/workspace` page uses the shipped toolkit and real -`/prompt_manager/stats` API. Existing admin, gallery and metadata URLs remain +The opt-in `/prompt_manager/workspace` page opens the native Images workspace. +Overview retains real `/prompt_manager/stats` counts. Both use the shipped toolkit. Existing admin, gallery and metadata URLs remain available while replacement workspaces are developed. Browser smoke tests in `tests/browser/workspace.spec.mjs` cover navigation/history, keyboard skip links, mobile layout, theme persistence/fallback, loading/retry and stale responses. -They run automatically in the existing four-browser-project workflow and frontend -coverage job. `tests/comfyui/workspace.spec.mjs` verifies the page against the +The Images cases in `tests/browser/workspace-images.spec.mjs` additionally cover +server-side filters, cursor append/retry, Grid/List/Masonry, bounded DOM, keyboard +navigation, restored browsing context, Viewer.js cleanup and actual video playback. +`tests/test_api_workspace_images.py` checks signed cursors, source identities, +legacy compatibility and header-only dimensions. Frontend stream/layout units +cover cancellation, malformed responses and bounded visibility lookup. These run +automatically in the existing four-browser-project workflow and frontend coverage job. `tests/comfyui/workspace.spec.mjs` verifies the page against the managed host with both renderer startup configurations. Run the focused page checks with: ```sh -npm run --prefix tools/vendor test:workflows -- workspace.spec.mjs +npm run --prefix tools/vendor test:workflows -- workspace.spec.mjs workspace-images.spec.mjs node --test tests/js/workspace-*.test.js ``` diff --git a/docs/application-redesign.md b/docs/application-redesign.md index be1f737..dc9013c 100644 --- a/docs/application-redesign.md +++ b/docs/application-redesign.md @@ -4,6 +4,12 @@ The maintainer approved the [interactive concept](concepts/workspace/README.md) on 2026-10-17. It is a design reference with simulated operations, not production code. This plan proposes implementation order and acceptance gates. +## Delivery status + +- Foundation merged in [#198](https://github.com/ComfyAssets/ComfyUI_PromptManager/pull/198). +- [#200](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/200) delivers native Images browsing, physical-file details, infinite scroll, Grid/List/Masonry, Viewer.js and video playback. The workspace opens on Images; original application entry points remain available. +- Prompt metadata, role-aware sending, tagging/review, library/history and System consolidation remain subsequent slices under #196. The physical-file inspector does not display prompt text until the shared prompt inspector/send flow is implemented. + ## Scope and compatibility Rebuild the standalone application around one shared shell. Preserve existing diff --git a/docs/concepts/workspace/README.md b/docs/concepts/workspace/README.md index be8a0c8..82e0db9 100644 --- a/docs/concepts/workspace/README.md +++ b/docs/concepts/workspace/README.md @@ -1,7 +1,9 @@ # Prompt Manager application redesign concept Status: visual concept approved by the maintainer, 2026-10-17. Production -implementation is pending; see the [development plan](../../application-redesign.md). +implementation is progressing in tested slices; see the [development plan](../../application-redesign.md). +The production workspace now has native Images browsing; this concept remains the +approved target for the complete application. ## Review the mockup diff --git a/docs/screenshots/workspace/images-dark.png b/docs/screenshots/workspace/images-dark.png new file mode 100644 index 0000000..212b676 Binary files /dev/null and b/docs/screenshots/workspace/images-dark.png differ diff --git a/docs/screenshots/workspace/images-light.png b/docs/screenshots/workspace/images-light.png new file mode 100644 index 0000000..a5f364b Binary files /dev/null and b/docs/screenshots/workspace/images-light.png differ diff --git a/docs/screenshots/workspace/images-masonry.png b/docs/screenshots/workspace/images-masonry.png new file mode 100644 index 0000000..d9272ab Binary files /dev/null and b/docs/screenshots/workspace/images-masonry.png differ diff --git a/docs/screenshots/workspace/images-mobile.png b/docs/screenshots/workspace/images-mobile.png new file mode 100644 index 0000000..03a6359 Binary files /dev/null and b/docs/screenshots/workspace/images-mobile.png differ diff --git a/py/api/images.py b/py/api/images.py index 18cb296..51f97b7 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -1,7 +1,10 @@ """Image and gallery API routes for PromptManager.""" import asyncio +import base64 +import binascii import hashlib +import hmac import json import os import re @@ -228,6 +231,70 @@ MAX_JSON_BODY_BYTES = 1024 * 1024 _READ_CHUNK = 64 * 1024 +_WORKSPACE_CURSOR_SECRET = os.urandom(32) + + +def _workspace_root(query: dict, roots: list[Path]) -> int | None: + value = query.get("root") + if value is None: + return None + try: + index = int(value) + except ValueError: + raise ValueError("Invalid gallery root") from None + if not 0 <= index < len(roots): + raise ValueError("Invalid gallery root") + return index + + +def _workspace_key(entry: tuple, order: str) -> tuple: + path, mtime, root, index = entry + relative = path.relative_to(root).as_posix() + tie = hashlib.sha256(relative.encode("utf-8")).hexdigest() + name = path.name.casefold() + if order in ("name", "name-desc"): + return (name, tie, index, mtime) + return ((-mtime if order == "newest" else mtime), name, tie, index) + + +def _workspace_cursor(key: tuple, scope: str) -> str: + payload = json.dumps( + {"v": 1, "scope": scope, "key": key}, separators=(",", ":"), ensure_ascii=False + ).encode() + signature = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest() + return base64.urlsafe_b64encode(signature + payload).decode().rstrip("=") + + +def _workspace_cursor_key(token: str, scope: str, order: str) -> tuple: + try: + if len(token) > 4096: + raise ValueError() + raw = base64.b64decode( + token + "=" * (-len(token) % 4), altchars=b"-_", validate=True + ) + signature, payload = raw[:32], raw[32:] + expected = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest() + if not hmac.compare_digest(signature, expected): + raise ValueError() + data = json.loads(payload) + key = data["key"] + if data["v"] != 1 or data["scope"] != scope or not isinstance(key, list): + raise ValueError() + types = ( + (str, str, int, (float, int)) + if order in ("name", "name-desc") + else ((float, int), str, str, int) + ) + if len(key) != len(types) or not all( + type(value) in (kind if isinstance(kind, tuple) else (kind,)) + for value, kind in zip(key, types) + ): + raise ValueError() + return tuple(key) + except (ValueError, KeyError, TypeError, UnicodeError, binascii.Error): + raise ValueError("Invalid gallery cursor; refresh the gallery") from None + + class _BodyTooLarge(Exception): """Raised when a request body exceeds MAX_JSON_BODY_BYTES.""" @@ -594,6 +661,8 @@ class ImageRoutesMixin: """Get all images from ComfyUI output folder(s).""" try: output_dirs = self._get_all_output_dirs() + if request.query.get("mode") == "workspace": + return await self._get_workspace_images(request, list(output_dirs)) if not output_dirs: return web.json_response( { @@ -629,6 +698,119 @@ class ImageRoutesMixin: {"success": False, "error": _safe_error(e)}, status=500 ) + async def _get_workspace_images( + self, request, output_dirs: list[Path] + ) -> web.Response: + try: + limit, _offset = parse_page_params(request.query, default_limit=100) + except ValueError: + return bad_request("limit and offset must be integers") + try: + root_index = _workspace_root(request.query, output_dirs) + query = request.query.get("q", "").strip().casefold() + media = request.query.get("media", "all") + order = request.query.get("sort", "newest") + folder = request.query.get("subfolder", "").strip().replace("\\", "/") + if ( + len(query) > 500 + or media not in ("all", "image", "video") + or order not in ("newest", "oldest", "name", "name-desc") + ): + raise ValueError("Invalid gallery filters") + if folder and (_has_traversal(folder) or len(folder) > 1024): + raise ValueError("Invalid gallery folder") + scope = hashlib.sha256( + json.dumps( + [ + query, + media, + order, + folder, + root_index, + [str(root) for root in output_dirs], + ] + ).encode() + ).hexdigest() + token = request.query.get("cursor") + after = _workspace_cursor_key(token, scope, order) if token else None + except ValueError as exc: + return bad_request(str(exc)) + + listings = [ + (index, root, await self._get_gallery_files(root)) + for index, root in enumerate(output_dirs) + if root_index is None or index == root_index + ] + page, total, has_more, next_cursor = await self._run_in_executor( + self._select_workspace_page, + listings, + folder, + query, + media, + order, + after, + limit, + scope, + ) + images = await self._run_in_executor(self._format_workspace_page, page) + return web.json_response( + { + "success": True, + "images": images, + "total": total, + "limit": limit, + "has_more": has_more, + "next_cursor": next_cursor, + } + ) + + @staticmethod + def _select_workspace_page( + listings: list[tuple], + folder: str, + query: str, + media: str, + order: str, + after: tuple | None, + limit: int, + scope: str, + ) -> tuple: + entries = [] + for index, root, files in listings: + for path, mtime in files: + relative = path.relative_to(root) + if folder and not _in_subfolder( + str(relative.parent), folder.replace("/", os.sep) + ): + continue + if query and query not in relative.as_posix().casefold(): + continue + kind = "video" if path.suffix.lower() in VIDEO_EXTENSIONS else "image" + if media != "all" and media != kind: + continue + entries.append((path, mtime, root, index)) + total = len(entries) + reverse = order == "name-desc" + entries.sort(key=lambda entry: _workspace_key(entry, order), reverse=reverse) + if after is not None: + entries = [ + entry + for entry in entries + if ( + _workspace_key(entry, order) < after + if reverse + else _workspace_key(entry, order) > after + ) + ] + page = entries[:limit] + has_more = len(entries) > limit + next_cursor = ( + _workspace_cursor(_workspace_key(page[-1], order), scope) + if has_more + else None + ) + return page, total, has_more, next_cursor + async def serve_image(self, request): """Serve the actual image file using streamed FileResponse.""" try: @@ -775,7 +957,20 @@ class ImageRoutesMixin: async def get_gallery_subfolders(self, request): """Get distinct subfolders from gallery output directories.""" try: - output_dirs = self._get_all_output_dirs() + output_dirs = list(self._get_all_output_dirs()) + workspace = request.query.get("mode") == "workspace" + roots = None + if workspace: + try: + selected = _workspace_root(request.query, output_dirs) + except ValueError as exc: + return bad_request(str(exc)) + roots = [ + {"index": index, "label": _public_path(root)} + for index, root in enumerate(output_dirs) + ] + if selected is not None: + output_dirs = [output_dirs[selected]] subfolders = set() for output_path in output_dirs: files = await self._get_gallery_files(output_path) @@ -795,9 +990,10 @@ class ImageRoutesMixin: ancestors.add("/".join(parts[:i])) subfolders.update(ancestors) - return web.json_response( - {"success": True, "subfolders": sorted(subfolders)} - ) + payload = {"success": True, "subfolders": sorted(subfolders)} + if workspace: + payload["roots"] = roots + return web.json_response(payload) except Exception as e: self.logger.error(f"Subfolders error: {e}") return web.json_response( @@ -1529,6 +1725,26 @@ class ImageRoutesMixin: if _in_subfolder(str(entry[0].relative_to(entry[2]).parent), subfolder) ] + def _format_workspace_page(self, page: list[tuple]) -> list[dict]: + images = self._format_output_page(page) + paths = { + (entry[3], entry[0].relative_to(entry[2]).as_posix()): entry[0] + for entry in page + } + for image in images: + image["width"] = None + image["height"] = None + if image["is_video"]: + continue + try: + with paths[(image["root_index"], image["path"])].open("rb") as stream: + with Image.open(stream) as source: + image["width"], image["height"] = source.size + except (OSError, ValueError, Image.DecompressionBombError): + # A corrupt or disappearing preview must not hide the listing. + continue + return images + def _format_output_page(self, page): entries = [] public_roots = {} diff --git a/tests/browser/server.py b/tests/browser/server.py index 2eee350..f1e36d0 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -78,7 +78,7 @@ def create_app(root: Path) -> web.Application: api = PromptManagerAPI() api.add_routes(routes) - def seed(extra_images: int = 0) -> None: + def seed(extra_images: int = 0, include_video: bool = False) -> None: with api.db.model.get_connection() as conn: for table in ( "image_prompt_pairs", @@ -114,25 +114,42 @@ def create_app(root: Path) -> web.Application: ) api.db.link_image_to_prompt(pid, str(image_path)) if index == 0: + video_prompt_id = pid for number in range(extra_images): extra = root / directory / "bulk" / f"{number:04}.png" extra.parent.mkdir(exist_ok=True) shutil.copyfile(image_path, extra) api.db.link_image_to_prompt(pid, str(extra)) api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150)) + if include_video: + # Seed after thumbnail generation to exercise a real unthumbnailed video. + video = root / "output" / "clip.webm" + shutil.copyfile( + Path(__file__).resolve().parents[1] / "fixtures" / "tiny.webm", video + ) + api.db.link_image_to_prompt(video_prompt_id, str(video)) api.invalidate_gallery_cache() @routes.post("/__test__/reset") async def reset(request: web.Request) -> web.Response: data = await request.json() if request.can_read_body else {} extra = data.get("extra_images", 0) + include_video = data.get("include_video", False) if ( isinstance(extra, bool) or not isinstance(extra, int) or not 0 <= extra <= 200 + or not isinstance(include_video, bool) ): raise web.HTTPBadRequest() - await api._run_in_executor(seed, extra) + await api._run_in_executor(seed, extra, include_video) + return web.json_response({"success": True}) + + @routes.post("/__test__/add-nested-image") + async def add_nested_image(request: web.Request) -> web.Response: + destination = root / "output" / "project" / "day" / "new-output.png" + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(root / "output" / "shared" / "same.png", destination) return web.json_response({"success": True}) @routes.get("/api/settings/Comfy.ColorPalette") diff --git a/tests/browser/workspace-images.spec.mjs b/tests/browser/workspace-images.spec.mjs new file mode 100644 index 0000000..9cd9931 --- /dev/null +++ b/tests/browser/workspace-images.spec.mjs @@ -0,0 +1,482 @@ +import { startCoverage, saveCoverage } from '../../tools/vendor/browser-coverage.mjs'; +import { test, expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs'; + +const workspace = '/prompt_manager/workspace#/images'; +const cards = page => page.locator('article[data-image-key]'); +const details = page => page.getByRole('region', { name: 'Image details', exact: true }); + +test.beforeEach(async ({ page, request, browserName }) => { + await startCoverage(page, browserName); + await expect(await request.post('/__test__/reset')).toBeOK(); + page.imageErrors = []; + page.on('pageerror', error => page.imageErrors.push(error.message)); +}); + +test.afterEach(async ({ page, browserName }) => { + await saveCoverage(page, browserName); + expect(page.imageErrors).toEqual([]); +}); + +test('native Images keeps colliding filenames distinct and inspects the selected source', async ({ page, request }) => { + const snapshot = await (await request.get('/prompt_manager/stats')).json(); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + expect(await cards(page).evaluateAll(elements => elements.map(element => element.dataset.imageKey))) + .toEqual(['0:shared/same.png', '1:shared/same.png']); + const originals = await cards(page).locator('img').evaluateAll(elements => elements.map(element => element.src)); + expect(new Set(originals).size).toBe(2); + await cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true }).click(); + await expect(details(page)).toContainText('same.png'); + await expect(details(page)).toContainText('shared/same.png'); + await expect(details(page)).toContainText(/input/i); + await cards(page).first().getByRole('button', { name: 'Open same.png', exact: true }).click(); + await expect(details(page)).toContainText(/output/i); + expect((await (await request.get('/prompt_manager/stats')).json()).stats).toEqual(snapshot.stats); +}); + +test('workspace defaults to the native Images collection without a hash route', async ({ page }) => { + await page.goto('/prompt_manager/workspace'); + await expect(page.locator('#workspace-main h1')).toHaveText('Images'); + await expect(cards(page)).toHaveCount(2); + await expect(page.getByRole('navigation', { name: 'Workspace', exact: true }) + .getByRole('link', { name: 'Images', exact: true })).toHaveAttribute('aria-current', 'page'); +}); + +test('image selection is announced on the selected image button', async ({ page }) => { + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + const selected = cards(page).last().getByRole('button', { name: 'Open same.png', exact: true }); + await selected.click(); + await expect(selected).toHaveAttribute('aria-pressed', 'true'); + await expect(cards(page).first().getByRole('button', { name: 'Open same.png', exact: true })) + .toHaveAttribute('aria-pressed', 'false'); +}); + +test('closing details preview restores its trigger focus', async ({ page }) => { + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + const selected = cards(page).last().getByRole('button', { name: 'Open same.png', exact: true }); + await selected.click(); + const preview = details(page).getByRole('button', { name: 'Open full preview', exact: true }); + await preview.click(); + await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(page.locator('dialog[open]')).toHaveCount(0); + await expect(preview).toBeFocused(); +}); + +test('a short visible batch automatically continues until the sentinel is filled or exhausted', async ({ page, request }) => { + const real = await (await request.get('/prompt_manager/images/output?limit=40')).json(); + let reads = 0; + await page.route('**/prompt_manager/images/output?*', async route => { + const current = reads++; + await route.fulfill({ json: { + success: true, images: [real.images[current]], total: 2, + has_more: current === 0, next_cursor: current === 0 ? 'next-short-batch' : null, + } }); + }); + await page.setViewportSize({ width: 1440, height: 1200 }); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + expect(reads).toBe(2); + await expect(page.getByRole('button', { name: 'Load more images', exact: true })).toBeHidden(); +}); + +test('source, folder and media filters operate on real backend records', async ({ page, request }) => { + await expect(await request.post('/__test__/reset', { data: { extra_images: 3 } })).toBeOK(); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(5); + await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('1'); + await expect(cards(page)).toHaveCount(1); + await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png'); + await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('all'); + await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption('bulk'); + await expect(cards(page)).toHaveCount(3); + await expect(cards(page).first()).toHaveAttribute('data-image-key', /^0:bulk\//); + await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('video'); + await expect(cards(page)).toHaveCount(0); + await expect(page.locator('#workspace-main')).toContainText(/no (matching )?(images|media)/i); + await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('image'); + await expect(cards(page)).toHaveCount(3); + await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption(''); + await expect(cards(page)).toHaveCount(5); +}); + +test('filename search finds records beyond the initial batch and sort orders them', async ({ page, request }) => { + await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK(); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(40); + await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('same.png'); + await expect(cards(page)).toHaveCount(2); + await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('0099'); + await expect(cards(page)).toHaveCount(1); + await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0099.png'); + await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('same.png'); + await expect(cards(page)).toHaveCount(2); + await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('oldest'); + await expect(cards(page).first()).toHaveAttribute('data-image-key', '1:shared/same.png'); + await page.getByRole('combobox', { name: 'Sort', exact: true }).selectOption('newest'); + await expect(cards(page).first()).toHaveAttribute('data-image-key', '0:shared/same.png'); +}); + +test('layout changes retain selection and use one collection in Grid, List and Masonry', async ({ page }) => { + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + await cards(page).nth(1).getByRole('button', { name: 'Open same.png', exact: true }).click(); + for (const layout of ['List', 'Masonry', 'Grid']) { + await page.getByRole('button', { name: layout, exact: true }).click(); + await expect(page.getByRole('button', { name: layout, exact: true })).toHaveAttribute('aria-pressed', 'true'); + await expect(cards(page)).toHaveCount(2); + await expect(details(page)).toContainText(/input/i); + const boxes = await cards(page).evaluateAll(elements => elements.map(element => { + const box = element.getBoundingClientRect(); + return { x: box.x, y: box.y, right: box.right, bottom: box.bottom }; + })); + expect(boxes[0].right <= boxes[1].x || boxes[1].right <= boxes[0].x || + boxes[0].bottom <= boxes[1].y || boxes[1].bottom <= boxes[0].y).toBe(true); + } +}); + +test('returning to Images restores the filtered stream, layout, selection and scroll context', async ({ page, request }) => { + await page.addInitScript(() => { window.IntersectionObserver = undefined; }); + await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK(); + await page.goto(workspace); + const search = page.getByRole('searchbox', { name: 'Search filenames', exact: true }); + await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('0'); + await search.fill('00'); + await expect(page.locator('.image-toolbar')).toContainText('40 of 100 images'); + await page.getByRole('button', { name: 'List', exact: true }).click(); + await page.getByRole('button', { name: 'Load more images', exact: true }).click(); + await expect(page.locator('.image-toolbar')).toContainText('80 of 100 images'); + await cards(page).first().getByRole('button', { name: /^Open / }).click(); + const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent(); + await cards(page).nth(30).scrollIntoViewIfNeeded(); + const previousScroll = await page.evaluate(() => window.scrollY); + expect(previousScroll).toBeGreaterThan(500); + // Hash navigation preserves the scroll position being tested; clicking the fixed + // navigation can otherwise scroll a non-sticky mobile sidebar into view first. + await page.evaluate(() => { window.location.hash = '/overview'; }); + await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); + await page.evaluate(() => { window.location.hash = '/images'; }); + await expect(page.locator('#workspace-main h1')).toHaveText('Images'); + await expect(search).toHaveValue('00'); + await expect(page.getByRole('combobox', { name: 'Source', exact: true })).toHaveValue('0'); + await expect(page.getByRole('button', { name: 'List', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await expect(page.locator('.image-toolbar')).toContainText('80 of 100 images'); + await expect(cards(page)).toHaveCount(80); + await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle); + await expect.poll(async () => Math.abs((await page.evaluate(() => window.scrollY)) - previousScroll)).toBeLessThan(120); +}); + +test('continuous loading appends without replacing cards or losing selected details', async ({ page, request }) => { + // Exercise the keyboard fallback independently from IntersectionObserver scrolling. + await page.addInitScript(() => { window.IntersectionObserver = undefined; }); + await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK(); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(40); + await cards(page).first().evaluate(element => { element.dataset.preserved = 'yes'; }); + await cards(page).first().getByRole('button', { name: /^Open / }).click(); + const selected = await cards(page).first().getAttribute('data-image-key'); + const more = page.getByRole('button', { name: 'Load more images', exact: true }); + await more.focus(); + await page.keyboard.press('Enter'); + await expect(cards(page)).toHaveCount(80); + await expect(cards(page).first()).toHaveAttribute('data-preserved', 'yes'); + await expect(cards(page).first()).toHaveAttribute('data-image-key', selected); + await expect(details(page)).toBeVisible(); + await expect(more).toBeFocused(); + await page.keyboard.press('Enter'); + await expect(cards(page)).toHaveCount(102); + expect(new Set(await cards(page).evaluateAll(elements => elements.map(element => element.dataset.imageKey))).size).toBe(102); + await expect(more).toBeHidden(); + await expect(page.getByRole('button', { name: /next page|previous page/i })).toHaveCount(0); +}); + +test('scrolling to the sentinel automatically appends the next backend batch', async ({ page, request }) => { + await expect(await request.post('/__test__/reset', { data: { extra_images: 100 } })).toBeOK(); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(40); + const first = cards(page).first(); + await first.evaluate(element => { element.dataset.preserved = 'scroll'; }); + await page.getByRole('button', { name: 'Load more images', exact: true }).scrollIntoViewIfNeeded(); + await expect(cards(page)).toHaveCount(80); + await expect(first).toHaveAttribute('data-preserved', 'scroll'); +}); + +test('large continuous collections bound the mounted cards and restore earlier images on backscroll', async ({ page, request }) => { + await page.addInitScript(() => { window.IntersectionObserver = undefined; }); + await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK(); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(40); + const first = cards(page).first(); + const firstKey = await first.getAttribute('data-image-key'); + await first.getByRole('button', { name: /^Open / }).click(); + const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent(); + const more = page.getByRole('button', { name: 'Load more images', exact: true }); + for (const loaded of [80, 120, 160, 200, 202]) { + await more.click(); + await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`); + } + await expect(more).toBeHidden(); + await expect.poll(() => cards(page).count()).toBeLessThan(120); + await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle); + expect(new Set(await cards(page).evaluateAll(elements => elements.map(element => element.dataset.imageKey))).size) + .toBe(await cards(page).count()); + await page.evaluate(() => window.scrollTo(0, 0)); + await expect(page.locator(`article[data-image-key="${firstKey}"]`)).toBeVisible(); + await expect.poll(() => cards(page).count()).toBeLessThan(120); + await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle); + await page.locator(`article[data-image-key="${firstKey}"]`) + .getByRole('button', { name: /^Preview / }).click(); + await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible(); + await page.getByRole('button', { name: 'Close preview', exact: true }).click(); +}); + +test('virtual gallery keyboard navigation reaches loaded endpoints and announces collection positions', async ({ page, request }) => { + await page.addInitScript(() => { window.IntersectionObserver = undefined; }); + await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK(); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(40); + const firstKey = await cards(page).first().getAttribute('data-image-key'); + const firstName = await cards(page).first().getByRole('button', { name: /^Open / }).getAttribute('aria-label'); + await cards(page).first().getByRole('button', { name: /^Open / }).click(); + const selectedTitle = await details(page).getByRole('heading', { level: 2 }).textContent(); + const more = page.getByRole('button', { name: 'Load more images', exact: true }); + for (const loaded of [80, 120, 160, 200, 202]) { + await more.click(); + await expect(page.locator('.image-toolbar')).toContainText(`${loaded} of 202 images`); + } + await page.evaluate(() => window.scrollTo(0, 0)); + const first = page.locator(`article[data-image-key="${firstKey}"]`); + await expect(first).toBeVisible(); + await first.getByRole('button', { name: firstName, exact: true }).focus(); + await page.keyboard.press('End'); + await expect(page.locator('article[data-image-key="1:shared/same.png"]') + .getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused(); + await expect(page.locator('article[data-image-key="1:shared/same.png"]')).toHaveAttribute('aria-posinset', '202'); + await expect(page.locator('article[data-image-key="1:shared/same.png"]')).toHaveAttribute('aria-setsize', '202'); + await expect(page.getByRole('list', { name: 'Image collection', exact: true })).toBeVisible(); + await expect(page.locator('article[data-image-key="1:shared/same.png"]')).toHaveAttribute('role', 'listitem'); + await page.keyboard.press('Home'); + await expect(first.getByRole('button', { name: firstName, exact: true })).toBeFocused(); + await expect(first).toHaveAttribute('aria-posinset', '1'); + await page.keyboard.press('ArrowRight'); + await expect(page.locator('article[aria-posinset="2"]').getByRole('button', { name: /^Open / })).toBeFocused(); + await expect(details(page).getByRole('heading', { level: 2 })).toHaveText(selectedTitle); + await expect.poll(() => cards(page).count()).toBeLessThan(120); +}); + +test('preview uses actual Viewer.js and cleans up when closing or leaving Images', async ({ page }) => { + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + await cards(page).last().getByRole('button', { name: 'Preview same.png', exact: true }).click(); + await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible(); + await expect(page.locator('dialog[open] .viewer-canvas img')).toHaveAttribute('src', /root=1/); + await page.getByRole('button', { name: 'Close preview', exact: true }).click(); + await expect(page.locator('dialog[open]')).toHaveCount(0); + await cards(page).first().getByRole('button', { name: 'Preview same.png', exact: true }).click(); + await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible(); + await page.evaluate(() => { window.location.hash = '/overview'; }); + await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); + await expect(page.locator('.viewer-container')).toHaveCount(0); + await expect(page.locator('dialog[open]')).toHaveCount(0); +}); + +test('real video media has a usable card and native playback cleans up on close and navigation', async ({ page, request }) => { + await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK(); + const media = await (await request.get('/prompt_manager/images/output?limit=40')).json(); + expect(media.images.find(image => image.filename === 'clip.webm').thumbnail_url).toBeNull(); + await page.goto(workspace); + await page.getByRole('combobox', { name: 'Media', exact: true }).selectOption('video'); + await expect(cards(page)).toHaveCount(1); + const card = cards(page).first(); + await expect(card).toHaveAttribute('data-image-key', '0:clip.webm'); + await expect(card.locator('img')).toHaveCount(0); + await expect(card.getByRole('button', { name: 'Open clip.webm', exact: true })).toBeVisible(); + await card.getByRole('button', { name: 'Preview clip.webm', exact: true }).click(); + const video = page.locator('dialog[open] video'); + await expect(video).toBeVisible(); + await expect(video).toHaveJSProperty('controls', true); + await expect.poll(() => video.evaluate(element => element.readyState)).toBeGreaterThanOrEqual(1); + const playing = await video.evaluate(async element => { + window.__previewVideo = element; + element.muted = true; + await element.play(); + return !element.paused; + }); + expect(playing).toBe(true); + await page.getByRole('button', { name: 'Close preview', exact: true }).click(); + await expect(page.locator('dialog video')).toHaveCount(0); + expect(await page.evaluate(() => window.__previewVideo.paused && !window.__previewVideo.isConnected)).toBe(true); + await card.getByRole('button', { name: 'Preview clip.webm', exact: true }).click(); + await expect(video).toBeVisible(); + await video.evaluate(async element => { + window.__previewVideo = element; + element.muted = true; + await element.play(); + }); + await page.evaluate(() => { window.location.hash = '/overview'; }); + await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); + await expect(page.locator('video')).toHaveCount(0); + expect(await page.evaluate(() => window.__previewVideo.paused && !window.__previewVideo.isConnected)).toBe(true); +}); + +test('failed image requests offer retry while stale results cannot replace a newer route', async ({ page }) => { + await page.route('**/prompt_manager/images/output?*', route => route.fulfill({ + status: 503, json: { success: false, error: 'Images unavailable' }, + }), { times: 1 }); + await page.goto(workspace); + await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible(); + await page.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect(cards(page)).toHaveCount(2); + let release; + const pending = new Promise(resolve => { release = resolve; }); + let arrived; + const received = new Promise(resolve => { arrived = resolve; }); + await page.route('**/prompt_manager/images/output?*', async route => { + const response = await route.fetch(); + arrived(); + await pending; + await route.fulfill({ response }).catch(() => {}); + }); + await page.getByRole('searchbox', { name: 'Search filenames', exact: true }).fill('nothing'); + await received; + await page.getByRole('navigation', { name: 'Workspace', exact: true }) + .getByRole('link', { name: 'Overview', exact: true }).click(); + release(); + await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); + await expect(cards(page)).toHaveCount(0); + await expect(details(page)).toHaveCount(0); +}); + +test('source and folder discovery failure stays visible and Refresh restores usable filters', async ({ page }) => { + await page.route('**/prompt_manager/gallery/subfolders?*', route => route.fulfill({ + status: 503, json: { success: false, error: 'Sources unavailable' }, + }), { times: 1 }); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + await expect(page.getByRole('alert')).toHaveText('Sources and folders could not be loaded. Refresh to retry.'); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await expect(page.getByRole('alert')).toHaveCount(0); + await page.getByRole('combobox', { name: 'Source', exact: true }).selectOption('1'); + await expect(cards(page)).toHaveCount(1); + await expect(cards(page)).toHaveAttribute('data-image-key', '1:shared/same.png'); + await page.getByRole('combobox', { name: 'Folder', exact: true }).selectOption('shared'); + await expect(cards(page)).toHaveCount(1); +}); + +test('mobile Images keeps filters, layouts and selected details within the viewport', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + for (const layout of ['List', 'Masonry', 'Grid']) { + await page.getByRole('button', { name: layout, exact: true }).click(); + await cards(page).last().getByRole('button', { name: 'Open same.png', exact: true }).click(); + await expect(details(page)).toContainText(/input/i); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); + } + await expect(page.getByRole('searchbox', { name: 'Search filenames', exact: true })).toBeVisible(); +}); + +test('a delayed search cannot replace results for a newer filename query', async ({ page, request }) => { + await expect(await request.post('/__test__/reset', { data: { extra_images: 3 } })).toBeOK(); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(5); + let release; + let arrived; + let complete; + const pending = new Promise(resolve => { release = resolve; }); + const received = new Promise(resolve => { arrived = resolve; }); + const completed = new Promise(resolve => { complete = resolve; }); + let delayed = false; + await page.route('**/prompt_manager/images/output?*', async route => { + if (delayed) return route.continue(); + delayed = true; + const response = await route.fetch(); + arrived(); + await pending; + await route.fulfill({ response }).catch(() => {}); + complete(); + }); + const search = page.getByRole('searchbox', { name: 'Search filenames', exact: true }); + await search.fill('same.png'); + await received; + await search.fill('0001'); + await expect(cards(page)).toHaveCount(1); + await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0001.png'); + release(); + await completed; + await expect(cards(page)).toHaveCount(1); + await expect(cards(page)).toHaveAttribute('data-image-key', '0:bulk/0001.png'); +}); + +test('selected layout stays blue with readable text on hover in either theme', async ({ page }) => { + await page.goto(workspace); + const layout = page.getByRole('group', { name: 'Image layout' }).getByRole('button', { name: 'Masonry', exact: true }); + for (const theme of ['light', 'dark']) { + await page.getByRole('combobox', { name: 'Appearance', exact: true }).selectOption(theme); + await layout.click(); + await layout.hover(); + await expect(layout).toHaveCSS('background-color', 'rgb(8, 119, 187)'); + await expect(layout).toHaveCSS('color', 'rgb(255, 255, 255)'); + } +}); + +test('refresh releases the previous inspector video source', async ({ page, request }) => { + await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK(); + await page.goto(workspace); + const card = cards(page).filter({ hasText: 'clip.webm' }); + await card.getByRole('button', { name: 'Open clip.webm', exact: true }).click(); + await expect(details(page).locator('video')).toBeVisible(); + await details(page).locator('video').evaluate(element => { window.__oldInspectorVideo = element; }); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await expect.poll(() => page.evaluate(() => !window.__oldInspectorVideo.isConnected)).toBe(true); + expect(await page.evaluate(() => window.__oldInspectorVideo.getAttribute('src'))).toBeNull(); +}); + +test('Refresh rescans cached files and preserves the collection when rescan fails', async ({ page, request }) => { + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + await expect(await request.post('/__test__/add-nested-image')).toBeOK(); + await page.route('**/prompt_manager/gallery/rescan', route => route.fulfill({ + status: 503, json: { success: false, error: 'Rescan unavailable' }, + }), { times: 1 }); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await expect(page.getByRole('alert')).toContainText('could not be refreshed'); + await expect(cards(page)).toHaveCount(2); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await expect(cards(page)).toHaveCount(3); + await expect(cards(page).filter({ hasText: 'new-output.png' })).toHaveCount(1); + await expect(page.getByRole('alert')).toBeHidden(); +}); + +test('folder discovery offers ancestors that filter nested images', async ({ page, request }) => { + await expect(await request.post('/__test__/add-nested-image')).toBeOK(); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(3); + const folder = page.getByRole('combobox', { name: 'Folder', exact: true }); + await expect(folder.locator('option[value="project"]')).toHaveCount(1); + await folder.selectOption('project'); + await expect(cards(page)).toHaveCount(1); + await expect(cards(page)).toContainText('new-output.png'); +}); + +test('leaving during Refresh cancels it without replacing the next workspace', async ({ page }) => { + let release; + const pending = new Promise(resolve => { release = resolve; }); + await page.route('**/prompt_manager/gallery/rescan', async route => { + await pending; + await route.fulfill({ json: { success: true } }).catch(() => {}); + }); + await page.goto(workspace); + await expect(cards(page)).toHaveCount(2); + const requested = page.waitForRequest('**/prompt_manager/gallery/rescan'); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await requested; + await page.getByRole('navigation', { name: 'Workspace', exact: true }) + .getByRole('link', { name: 'Overview', exact: true }).click(); + await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); + release(); + await expect(page.getByRole('heading', { name: 'Continue creating', exact: true })).toBeVisible(); + await expect(page.getByText('Images could not be refreshed. Try Refresh again.', { exact: true })).toHaveCount(0); +}); diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index c9f6715..ba64873 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -36,7 +36,7 @@ test('workspace starts with real persisted statistics without mutating data', as page.on('request', request => { if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) mutations.push(request.url()); }); - await page.goto(workspace); + await page.goto(`${workspace}#/overview`); await expect(page).toHaveTitle(/Prompt Manager/); await expect(page.getByRole('navigation', { name: 'Workspace', exact: true })).toBeVisible(); const main = page.locator('#workspace-main'); @@ -67,7 +67,7 @@ test('all shell destinations deep-link, reload and expose working existing appli }); test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => { - await page.goto(workspace); + await page.goto(`${workspace}#/overview`); const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true }); const images = navigation.getByRole('link', { name: 'Images', exact: true }); await images.focus(); @@ -80,8 +80,8 @@ test('keyboard navigation, browser history and unknown routes remain usable', as await page.goForward(); await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library'); await page.goto(`${workspace}#/not-a-page`); - await expect(page.locator('#workspace-main h1')).toHaveText('Overview'); - await expect(navigation.getByRole('link', { name: 'Overview', exact: true })) + await expect(page.locator('#workspace-main h1')).toHaveText('Images'); + await expect(navigation.getByRole('link', { name: 'Images', exact: true })) .toHaveAttribute('aria-current', 'page'); }); @@ -97,7 +97,7 @@ test('skip to content focuses the current view without changing its route', asyn }); test('returning to overview refreshes persisted counts without duplicating navigation', async ({ page, request }) => { - await page.goto(workspace); + await page.goto(`${workspace}#/overview`); const prompts = statistic(page, 'Prompts'); await expect(prompts).toHaveText('2'); const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true }); @@ -111,7 +111,7 @@ test('returning to overview refreshes persisted counts without duplicating navig test('appearance follows ComfyUI and persists an explicit light or dark preference', async ({ page }) => { await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ json: 'light' })); - await page.goto(workspace); + await page.goto(`${workspace}#/overview`); const theme = page.locator('[data-ca-theme]').first(); await expect(theme).toHaveAttribute('data-ca-theme', 'light'); const appearance = page.getByRole('combobox', { name: 'Appearance', exact: true }); @@ -129,7 +129,7 @@ test('appearance follows ComfyUI and persists an explicit light or dark preferen test('unavailable host palette falls back to system appearance and tracks system changes', async ({ page }) => { await page.route('**/api/settings/Comfy.ColorPalette', route => route.fulfill({ status: 503, json: {} })); await page.emulateMedia({ colorScheme: 'light' }); - await page.goto(workspace); + await page.goto(`${workspace}#/overview`); const theme = page.locator('[data-ca-theme]').first(); await expect(theme).toHaveAttribute('data-ca-theme', 'light'); await page.emulateMedia({ colorScheme: 'dark' }); @@ -143,7 +143,7 @@ test('overview reports loading, offers retry after failure and renders real empt await pending; await route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }); }); - await page.goto(workspace); + await page.goto(`${workspace}#/overview`); await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible(); release(); await expect(page.locator('#workspace-main').getByRole('alert')).toBeVisible(); @@ -160,7 +160,7 @@ test('overview reports loading, offers retry after failure and renders real empt test('small screens keep navigation and appearance accessible without horizontal page overflow', async ({ page }) => { await page.setViewportSize({ width: 375, height: 812 }); - await page.goto(workspace); + await page.goto(`${workspace}#/overview`); const navigation = page.getByRole('navigation', { name: 'Workspace', exact: true }); for (const [, label] of routes) { await navigation.getByRole('link', { name: label, exact: true }).click(); @@ -182,7 +182,7 @@ test('leaving a loading overview prevents its delayed result from replacing the } }).catch(() => {}); completed(); }); - await page.goto(workspace); + await page.goto(`${workspace}#/overview`); await expect(page.locator('#workspace-main').getByRole('status')).toBeVisible(); await page.getByRole('navigation', { name: 'Workspace', exact: true }) .getByRole('link', { name: 'Images', exact: true }).click(); @@ -199,7 +199,7 @@ test('cached-page restoration remounts one working shell and refreshes its data' page.on('request', request => { if (new URL(request.url()).pathname === '/prompt_manager/stats') reads++; }); - await page.goto(workspace); + await page.goto(`${workspace}#/overview`); await expect(statistic(page, 'Prompts')).toHaveText('2'); for (let restore = 0; restore < 2; restore++) { await page.evaluate(() => { diff --git a/tests/comfyui/workspace.spec.mjs b/tests/comfyui/workspace.spec.mjs index b4951be..ad561f2 100644 --- a/tests/comfyui/workspace.spec.mjs +++ b/tests/comfyui/workspace.spec.mjs @@ -13,16 +13,30 @@ test('workspace serves real library data beside either ComfyUI renderer', async const errors = []; workspace.on('pageerror', error => errors.push(error.message)); try { - await workspace.goto('/prompt_manager/workspace'); + await workspace.goto('/prompt_manager/workspace#/overview'); await expect(workspace.locator('#workspace-main h1')).toHaveText('Overview'); await expect(workspace.locator('dt').filter({ hasText: /^Prompts$/ }).locator('+ dd')) .toHaveText(stats.total_prompts.toLocaleString()); await workspace.getByRole('combobox', { name: 'Appearance' }).selectOption('light'); await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light'); + const imageResponse = workspace.waitForResponse(response => { + const url = new URL(response.url()); + return url.pathname === '/prompt_manager/images/output' && url.searchParams.get('mode') === 'workspace'; + }); await workspace.getByRole('navigation', { name: 'Workspace', exact: true }) .getByRole('link', { name: 'Images', exact: true }).click(); await expect(workspace.locator('#workspace-main h1')).toHaveText('Images'); - await expect(workspace.locator('#workspace-main a')).toHaveAttribute('href', '/prompt_manager/gallery'); + const images = await imageResponse; + expect(images.ok()).toBe(true); + const collection = await images.json(); + expect(collection.success).toBe(true); + await expect(workspace.getByRole('list', { name: 'Image collection', exact: true })).toBeAttached(); + if (collection.images.length) { + await expect(workspace.locator('article[data-image-key]').first()).toBeVisible(); + } else { + await expect(workspace.getByText('No matching images or media. Try changing the filters.', { exact: true })).toBeVisible(); + } + await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveAttribute('href', '/prompt_manager/gallery'); await workspace.reload(); await expect(workspace.locator('#workspace-main h1')).toHaveText('Images'); await expect(workspace.locator('body')).toHaveAttribute('data-ca-theme', 'light'); diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md new file mode 100644 index 0000000..46040f1 --- /dev/null +++ b/tests/fixtures/README.md @@ -0,0 +1,14 @@ +# Browser media fixtures + +`tiny.webm` is an original, silent, one-second VP8 color test pattern generated +for this repository. It contains no user media and is dedicated to the public +domain under CC0-1.0. + +Reproduce with FFmpeg: + +```sh +ffmpeg -f lavfi -i testsrc=size=32x32:rate=4:duration=1 \ + -c:v libvpx -pix_fmt yuv420p -an tiny.webm +``` + +Browser tests use the committed file; FFmpeg is not required to run them. diff --git a/tests/fixtures/tiny.webm b/tests/fixtures/tiny.webm new file mode 100644 index 0000000..5f02cf2 Binary files /dev/null and b/tests/fixtures/tiny.webm differ diff --git a/tests/js/workspace-images-state.test.js b/tests/js/workspace-images-state.test.js new file mode 100644 index 0000000..aa6c8ac --- /dev/null +++ b/tests/js/workspace-images-state.test.js @@ -0,0 +1,161 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = import('../../web/js/workspace/images-state.mjs'); +const image = (path, root_index = 0) => ({path, root_index, id:'same-hash'}); +const page = (images, total = images.length, next_cursor = null) => ({images, total, next_cursor, has_more:next_cursor !== null}); +const deferred = () => { let resolve, reject; const promise = new Promise((a,b) => {resolve=a;reject=b;}); return {promise,resolve,reject}; }; + +test('stream serializes allowed filters, appends distinct rooted paths, and emits immutable snapshots', async () => { + const { createImageStream, imageKey } = await source; + const calls = [], states = []; + const pages = [page([image('a'),image('a',1)],3,'next'), page([image('a',1),image('b')],3)]; + const stream = createImageStream({api:{get:async (url, options) => { calls.push([url, options]);return pages.shift(); }},onChange:s => states.push(s),batchSize:2}); + assert.equal(await stream.reset({q:'a & b',root:0,media:'image',sort:'oldest',subfolder:'nested/one',ignored:'unsafe'}), 2); + const query = new URL(calls[0][0],'http://local').searchParams; + assert.equal(query.get('q'),'a & b'); assert.equal(query.get('root'),'0'); + assert.equal(query.get('subfolder'),'nested/one'); assert.equal(query.get('mode'),'workspace'); + assert.equal(query.get('limit'),'2'); assert.equal(query.has('cursor'),false); assert.equal(query.has('ignored'),false); + assert.ok(calls[0][1].signal instanceof AbortSignal); + assert.equal(imageKey(image('a',0)),'0:a'); assert.notEqual(imageKey(image('a',0)), imageKey(image('a',1))); + const first = stream.state; + assert.ok(Object.isFrozen(first)); assert.ok(Object.isFrozen(first.items)); assert.ok(Object.isFrozen(first.items[0])); + const more = stream.loadMore(); + assert.strictEqual(stream.state.items,first.items); + assert.equal(await more,1); + assert.equal(new URL(calls[1][0],'http://local').searchParams.get('cursor'),'next'); + assert.deepEqual(stream.state.items.map(imageKey),['0:a','1:a','0:b']); + assert.equal(stream.state.loaded,3); assert.equal(stream.state.total,3); assert.equal(stream.state.hasMore,false); + assert.equal(await stream.loadMore(),0); assert.equal(calls.length,2); + assert.equal(first.items.length,2); assert.equal(states.at(-1),stream.state); +}); + +test('reset aborts pending requests and ignores stale success and failure', async () => { + const { createImageStream } = await source; + const requests=[]; const states=[]; + const stream=createImageStream({api:{get:(url,{signal})=>{const d=deferred();requests.push({...d,url,signal});return d.promise;}},onChange:s=>states.push(s)}); + const old=stream.reset({q:'old'}); const fresh=stream.reset({q:'fresh'}); + assert.equal(requests[0].signal.aborted,true); + requests[1].resolve(page([image('fresh')]));await fresh; + requests[0].resolve(page([image('old')])); assert.equal(await old,0); + assert.equal(stream.state.items[0].path,'fresh'); + const failing=stream.reset({q:'failure'}); const newest=stream.reset(); + requests[2].reject(new Error('stale'));requests[3].resolve(page([])); + await Promise.all([failing,newest]);assert.equal(stream.state.error,null); + assert.equal(stream.state.loading,false); +}); + +test('concurrent loads do not duplicate requests, and failures retain data and cursor for retry', async () => { + const {createImageStream}=await source; const pending=deferred(); const calls=[]; + const responses=[page([image('a')],2,'next'),pending.promise,page([image('b')],2)]; + const stream=createImageStream({api:{get:async url=>{calls.push(url);return responses.shift();}}}); + await stream.reset();const more=stream.loadMore();assert.equal(await stream.loadMore(),0);assert.equal(calls.length,2); + pending.reject(new Error('offline'));assert.equal(await more,0); + assert.equal(stream.state.items.length,1);assert.equal(stream.state.cursor,'next');assert.equal(stream.state.error.message,'offline'); + assert.equal(stream.state.hasMore,true);assert.equal(await stream.loadMore(),1);assert.equal(stream.state.error,null); + assert.equal(calls[1],calls[2]); +}); + +test('malformed pages fail atomically and allow retry without corrupting the stream', async () => { + const {createImageStream}=await source; + for(const invalid of [null,{},page('bad'),page([{}]),page([image('')]),page([image('x',-1)]),page([], -1),{...page([]),has_more:'yes'},page([],2,''),{...page([],2,'next'),has_more:false}]) { + const replies=[page([image('existing')],2,'next'),invalid,page([image('fixed')],2)]; + const stream=createImageStream({api:{get:async()=>replies.shift()}});await stream.reset(); + await stream.loadMore();assert.match(stream.state.error.message,/image|page|cursor/i); + assert.equal(stream.state.items[0].path,'existing'); assert.equal(stream.state.cursor,'next'); + await stream.loadMore();assert.equal(stream.state.items.length,2);assert.equal(stream.state.error,null); + } +}); + +test('duplicate pages can advance cursors, but repeated cursors fail instead of looping', async () => { + const {createImageStream}=await source; + const replies=[page([image('a')],2,'one'),page([image('a')],2,'two'),page([image('b')],2,'two')]; + const stream=createImageStream({api:{get:async()=>replies.shift()}});await stream.reset(); + assert.equal(await stream.loadMore(),0);assert.equal(stream.state.cursor,'two');assert.equal(stream.state.hasMore,true); + await stream.loadMore();assert.match(stream.state.error.message,/cursor/i);assert.equal(stream.state.items.length,1); +}); + +test('dispose aborts the active request and suppresses notifications and further requests', async () => { + const {createImageStream}=await source;const d=deferred();let signal,count=0,notifications=0; + const stream=createImageStream({api:{get:(url,opts)=>{signal=opts.signal;count++;return d.promise;}},onChange:()=>notifications++}); + const request=stream.reset();stream.dispose();stream.dispose();assert.equal(signal.aborted,true); + const before=notifications;d.reject(Object.assign(new Error('aborted'),{name:'AbortError'}));await request; + assert.equal(notifications,before);assert.equal(await stream.reset(),0);assert.equal(await stream.loadMore(),0);assert.equal(count,1); +}); + +test('stream validates constructor and filter input before requesting', async () => { + const {createImageStream}=await source; + assert.throws(()=>createImageStream({api:{}}),/api/i); + for(const batchSize of [0,-1,1.5,Infinity,201]) assert.throws(()=>createImageStream({api:{get(){}},batchSize}),/batch/i); + const stream=createImageStream({api:{get:async()=>page([])}}); + for(const filters of [{root:-1},{root:'oops'},{media:'executable'},{sort:'random'},{q:{}},{subfolder:[]}]) await assert.rejects(stream.reset(filters),/filter/i); + await stream.reset({root:'all',media:'all',sort:'newest'});assert.equal(stream.state.hasMore,false); +}); + +test('the metadata stream retains earlier images beyond one rendered window and deduplicates within a batch', async () => { + const {createImageStream}=await source; + let request=0; + const stream=createImageStream({api:{get:async()=>{ + const start=request++*40; + return page(Array.from({length:40},(_,index)=>image(`image-${start+index}`)),320,start+40<320?String(start+40):null); + }}}); + await stream.reset(); + while(stream.state.hasMore) await stream.loadMore(); + assert.equal(stream.state.loaded,320);assert.equal(stream.state.items[0].path,'image-0'); + assert.equal(stream.state.items.at(-1).path,'image-319'); + const duplicate=createImageStream({api:{get:async()=>page([image('a'),image('a')],1)}}); + assert.equal(await duplicate.reset(),1);assert.equal(duplicate.state.items.length,1); +}); + +test('all-source defaults are omitted and descending name sort remains supported', async () => { + const {createImageStream}=await source;let query; + const stream=createImageStream({api:{get:async url=>{query=new URL(url,'http://local').searchParams;return page([]);}}}); + await stream.reset({root:'all',media:'all',sort:'name-desc'}); + assert.equal(query.has('root'),false);assert.equal(query.has('media'),false); + assert.equal(query.get('sort'),'name-desc');assert.equal(query.get('limit'),'40'); +}); + +test('restored browsing state resumes its cursor and filters without an initial request', async () => { + const {createImageStream}=await source; + const calls=[]; + const cachedItems=[image('existing')]; + const cache={filters:{q:'saved search',root:1,sort:'oldest',media:'image',subfolder:'nested'},state:{items:cachedItems,total:3,cursor:'saved-cursor',hasMore:true,loaded:1,loading:true,error:new Error('old error')}}; + const stream=createImageStream({api:{get:async url=>{calls.push(url);return page([image('existing'),image('new')],3,'next');}},restore:cache}); + assert.equal(calls.length,0);assert.equal(stream.state.items.length,1); + assert.equal(stream.state.loading,false);assert.equal(stream.state.error,null); + assert.ok(Object.isFrozen(stream.state));assert.ok(Object.isFrozen(stream.state.items));assert.ok(Object.isFrozen(stream.state.items[0])); + cachedItems[0].path='external mutation';cachedItems.push(image('external')); + assert.equal(stream.state.items[0].path,'existing');assert.equal(stream.state.items.length,1); + assert.equal(await stream.loadMore(),1); + const query=new URL(calls[0],'http://local').searchParams; + for(const [key,value] of Object.entries({q:'saved search',root:'1',sort:'oldest',media:'image',subfolder:'nested',cursor:'saved-cursor'})) assert.equal(query.get(key),value); + assert.deepEqual(stream.state.items.map(item=>item.path),['existing','new']); + await stream.reset({q:'new search'}); + assert.equal(new URL(calls[1],'http://local').searchParams.has('cursor'),false); + assert.equal(new URL(calls[1],'http://local').searchParams.get('q'),'new search'); + stream.dispose();assert.equal(await stream.loadMore(),0); +}); + +test('restoring frozen state reuses immutable metadata and respects a completed collection', async () => { + const {createImageStream}=await source; + const first=createImageStream({api:{get:async()=>page([image('saved')])}}); + await first.reset();let requests=0; + const restored=createImageStream({api:{get:async()=>{requests++;return page([]);}},restore:{filters:{},state:first.state}}); + assert.strictEqual(restored.state.items,first.state.items);assert.equal(restored.state.loaded,1); + assert.equal(await restored.loadMore(),0);assert.equal(requests,0); +}); + +test('restored interrupted initial loading can restart from the first request',async()=>{ + const {createImageStream}=await source;let called=0; + const stream=createImageStream({api:{get:async url=>{called++;assert.equal(new URL(url,'http://local').searchParams.has('cursor'),false);return page([]);}}, + restore:{filters:{},state:{items:[],total:0,cursor:null,hasMore:true,loading:true}}}); + assert.equal(called,0);assert.equal(await stream.loadMore(),0);assert.equal(called,1);assert.equal(stream.state.hasMore,false); +}); + +test('malformed restored state is rejected before making requests',async()=>{ + const {createImageStream}=await source; + const base={items:[image('a')],total:2,cursor:'saved',hasMore:true}; + const invalid=[null,{}, {filters:{},state:null}, + ...[{items:'bad'},{items:[{}]},{items:[image('a'),image('a')]},{total:-1},{cursor:''},{cursor:null},{hasMore:'yes'},{hasMore:false}].map(change=>({filters:{},state:{...base,...change}})), + {filters:{root:-1},state:base}, {filters:null,state:base}]; + for(const restore of invalid) assert.throws(()=>createImageStream({api:{get(){assert.fail('unexpected request');}},restore}),TypeError); +}); diff --git a/tests/js/workspace-layout.test.js b/tests/js/workspace-layout.test.js new file mode 100644 index 0000000..bcfdccb --- /dev/null +++ b/tests/js/workspace-layout.test.js @@ -0,0 +1,80 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const source = import('../../web/js/workspace/image-layout.mjs'); + +test('layouts preserve data order, remain within width and never overlap', async () => { + const {layoutImages} = await source; + const images=Array.from({length:200},(_,i)=>({width:i%2?1024:640,height:i%2?640:1024})); + for(const width of [280,760,1400]) for(const mode of ['grid','list','masonry']) { + const {positions,height}=layoutImages(images,width,mode); + assert.equal(positions.length,200); + for(const [i,a] of positions.entries()) { + assert.ok(a.x>=0 && a.y>=0 && a.width>0 && a.height>0); + assert.ok(a.x+a.width<=width+.01); + assert.ok(a.y+a.height<=height+.01); + for(const b of positions.slice(i+1)) assert.ok(a.x+a.width<=b.x+.01 || b.x+b.width<=a.x+.01 || a.y+a.height<=b.y+.01 || b.y+b.height<=a.y+.01); + } + } +}); + +test('empty, unknown dimensions and narrow containers keep valid geometry',async()=>{ + const {layoutImages}=await source; + assert.deepEqual(layoutImages([],0,'grid'),{positions:[],height:0,maxHeight:0}); + for(const mode of ['grid','list','masonry']) { + const result=layoutImages([{}, {width:0,height:null}],1,mode); + assert.ok(Number.isFinite(result.height)); + } +}); + +test('virtual window retains visible rows and focused card while bounding rendered media',async()=>{ + const {layoutImages,visibleImages}=await source; + const {positions}=layoutImages(Array(5000).fill({}),900,'masonry'); + const indexes=visibleImages(positions,10000,900,0); + assert.ok(indexes.length<100); + assert.ok(indexes.includes(0)); + assert.ok(indexes.some(i=>positions[i].y>=10000 && positions[i].y<10900)); + assert.deepEqual(indexes,[...indexes].sort((a,b)=>a-b)); + assert.equal(new Set(indexes).size,indexes.length); + assert.ok(visibleImages(positions,0,800).includes(0)); +}); + +test('large virtual collections inspect a bounded range rather than every loaded position', async () => { + const {layoutImages,visibleImages}=await source; + for(const mode of ['grid','list','masonry']) { + const geometry=layoutImages(Array.from({length:50000},(_,index)=>({width:640,height:index%2?320:1024})),900,mode); + for(let index=1;index=geometry.positions[index-1].y); + let reads=0; + const positions=new Proxy(geometry.positions,{get(target,key,receiver){ + if(typeof key==='string' && /^\d+$/.test(key)) reads++; + return Reflect.get(target,key,receiver); + }}); + const top=geometry.positions[25000].y; + const indexes=visibleImages(positions,top,900,49999,geometry.maxHeight); + assert.ok(reads<500,`${mode} read ${reads} positions`); + const expected=geometry.positions.flatMap((position,index)=>(index===49999 || + (position.y+position.height>=top-900 && position.y<=top+1800))?[index]:[]); + assert.deepEqual(indexes,expected); + } +}); + +test('binary-searched windows handle boundaries, empty collections and focus without duplicates',async()=>{ + const {layoutImages,visibleImages}=await source; + assert.deepEqual(visibleImages([],0,800,0),[]); + const {positions}=layoutImages(Array(1000).fill({}),900,'grid'); + for(const focused of [-1,0,999,1000]) { + for(const top of [0,10000,1000000]) { + const actual=visibleImages(positions,top,600,focused); + const expected=positions.flatMap((position,index)=>(index===focused || + (position.y+position.height>=top-600 && position.y<=top+1200))?[index]:[]); + assert.deepEqual(actual,expected); + } + } + assert.deepEqual(visibleImages(positions,1000000,0),[]); +}); + +test('reported maximum height keeps unusually tall custom layouts inside the virtual window',async()=>{ + const {layoutImages,visibleImages}=await source; + const geometry=layoutImages([{}, {}, {}],10000,'grid',20000); + assert.equal(geometry.maxHeight,7572); + assert.deepEqual(visibleImages(geometry.positions,6000,200,-1,geometry.maxHeight),[0]); +}); diff --git a/tests/js/workspace-state.test.js b/tests/js/workspace-state.test.js index 490ab60..a38f1e4 100644 --- a/tests/js/workspace-state.test.js +++ b/tests/js/workspace-state.test.js @@ -8,7 +8,7 @@ test('workspace routes resolve only supported deep links', async () => { assert.equal(resolveRoute(`#/${route}`).id, route); } for (const hash of ['', '#', '#/unknown', '#/images/other', '#/
-
Prompt Manager / Workspace preview
+
ComfyAssets / Images