diff --git a/TESTING.md b/TESTING.md index f07d49c..4606673 100644 --- a/TESTING.md +++ b/TESTING.md @@ -11,7 +11,8 @@ original implementation, then run the relevant suites after the fix. | JavaScript | Production extension hooks and callback behavior | Minimal host objects; controlled timers for focused regressions | | Browser | All shipped extension imports and lifecycle registration; real plugin startup, HTTP routes, PNG thumbnails, selection persistence, and image loading | ComfyUI graph/widgets/settings/event interfaces and its saved-image serving endpoint | -Browser workflows cover swaps, local image browsing/selection, saved paths, +Browser workflows cover swaps, local image browsing/selection, pagination, +sorting, hidden folders, directory autocomplete, saved paths, denied paths and network recovery, display output, seed persistence/cleanup, and workflow timing, autocomplete insertion, and saved-image previews. They run in Chromium, Firefox, WebKit, and touch Chromium. The harness binds to localhost and writes configuration, selections, and fixture diff --git a/tests/browser/server.py b/tests/browser/server.py index a91a17f..397c03e 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -50,14 +50,16 @@ def create_app(root: Path) -> web.Application: loader.CONFIG_FILE = str(root / "config.json") loader.SELECTIONS_FILE = str(root / "selections.json") - def reset() -> None: + def reset(extra_images: int = 0) -> None: (root / "selections.json").unlink(missing_ok=True) - shutil.rmtree(root / "output") - (root / "output").mkdir() + for kind in ("input", "output"): + shutil.rmtree(root / kind) + (root / kind).mkdir() loader.save_config({"saved_paths": [], "last_path": str(root / "input")}) for path, color in [ ("input/cat & dog.png", "red"), ("input/sub/forest.png", "green"), + ("input/.hidden/hidden.png", "green"), ("outside/secret.png", "blue"), ]: target = root / path @@ -65,9 +67,22 @@ def create_app(root: Path) -> web.Application: with Image.new("RGB", (16, 12), color) as image: image.save(target) + for index in range(extra_images): + target = root / "input" / "bulk" / f"{index:04}.png" + target.parent.mkdir(exist_ok=True) + shutil.copyfile(root / "input" / "cat & dog.png", target) + @routes.post("/__test__/reset") async def reset_route(request: web.Request) -> web.Response: - await asyncio.to_thread(reset) + data = await request.json() if request.can_read_body else {} + extra_images = data.get("extra_images", 0) + if ( + isinstance(extra_images, bool) + or not isinstance(extra_images, int) + or not 0 <= extra_images <= 60 + ): + raise web.HTTPBadRequest() + await asyncio.to_thread(reset, extra_images) return web.json_response({"success": True}) @routes.get("/__test__/state") diff --git a/tests/browser/workflows.spec.mjs b/tests/browser/workflows.spec.mjs index 860f1c2..1cba6eb 100644 --- a/tests/browser/workflows.spec.mjs +++ b/tests/browser/workflows.spec.mjs @@ -233,3 +233,53 @@ test('Display Any renders real multiline output and restores serialized values w await expect(page.locator('textarea')).toHaveCount(1); await expect(page.getByLabel('display_0')).toHaveValue('Restored value'); }); + +test('gallery pagination sorting and hidden-folder controls use real directory pages', async ({ page, request }) => { + await expect(await request.post('/__test__/reset', { data: { extra_images: 52 } })).toBeOK(); + await openHost(page); + await createNode(page, 'KikoLocalImageLoader'); + const folder = name => page.locator('.kiko-folder-name').filter({ hasText: new RegExp(`^${name}$`) }); + await expect(folder('sub')).toBeVisible(); + await expect(folder('\\.hidden')).toHaveCount(0); + await page.locator('.kiko-hide-dot-folders').uncheck(); + await expect(folder('\\.hidden')).toBeVisible(); + await folder('bulk').click(); + await expect(page.locator('.kiko-card-info-panel')).toHaveCount(50); + await expect(page.locator('.kiko-card-info-panel').first()).toHaveText('0000.png'); + await page.locator('.kiko-sort-order').selectOption('desc'); + await expect(page.locator('.kiko-card-info-panel').first()).toHaveText('0051.png'); + await page.locator('.kiko-pagination').getByRole('button', { name: '2', exact: true }).click(); + await expect(page.locator('.kiko-card-info-panel')).toHaveCount(2); + await expect(page.getByAltText('0000.png', { exact: true })).toBeVisible(); + await page.locator('.kiko-pagination').getByRole('button', { name: '◀', exact: true }).click(); + await expect(page.locator('.kiko-card-info-panel')).toHaveCount(50); + await page.locator('.kiko-pagination').getByRole('button', { name: '▶', exact: true }).click(); + await expect(page.locator('.kiko-card-info-panel')).toHaveCount(2); +}); + +test('gallery directory autocomplete handles keyboard selection escape and navigation', async ({ page, request }) => { + await openHost(page); + await createNode(page, 'KikoLocalImageLoader'); + await expect(page.getByAltText('cat & dog.png', { exact: true })).toBeVisible(); + const input = page.locator('.kiko-path-input'); + const dropdown = page.locator('.kiko-autocomplete-dropdown'); + const directory = (await state(request)).input; + await input.fill(`${directory}/su`); + await expect(dropdown).toBeVisible(); + await expect(dropdown.locator('.folder-name')).toContainText('sub'); + await input.press('ArrowDown'); + await expect(dropdown.locator('.selected')).toHaveCount(1); + await input.press('ArrowUp'); + await expect(dropdown.locator('.selected')).toHaveCount(0); + await input.press('Escape'); + await expect(dropdown).toBeHidden(); + await input.fill(`${directory}/su`); + await expect(dropdown).toBeVisible(); + await input.press('ArrowDown'); + await input.press('Tab'); + await expect(input).toBeFocused(); + await expect(input).toHaveValue(`${directory}/sub/`); + await expect(dropdown).toBeHidden(); + await input.press('Enter'); + await expect(page.getByAltText('forest.png', { exact: true })).toBeVisible(); +}); diff --git a/tests/js/extensions.test.mjs b/tests/js/extensions.test.mjs index 4ccf43f..de8843b 100644 --- a/tests/js/extensions.test.mjs +++ b/tests/js/extensions.test.mjs @@ -144,3 +144,17 @@ test('gallery HTTP errors preserve actionable server detail and handle non-JSON assert.match(await describe({ status: 403, json: async () => { throw new Error('not JSON'); } }), /allowed folders/); assert.match(await describe({ status: 400, json: async () => ({}) }), /Invalid path/); }); + +test('gallery HTTP errors cover null bodies, message details, and generic status fallbacks', async () => { + const { context } = load('kiko_local_image_loader.js', { document: { getElementById: () => ({}) } }); + const describe = vm.runInContext('describeHttpError', context); + for (const [status, body, expected] of [ + [400, { error: 'Invalid directory' }, 'Invalid directory'], + [403, { message: 'Not an allowed root' }, 'Not an allowed root'], + [400, null, 'Invalid path or request'], + [500, { error: 'Storage unavailable' }, 'Storage unavailable'], + [404, { message: 'Missing image' }, 'Missing image'], + [502, null, 'Request failed (HTTP 502)'], + [503, {}, 'Request failed (HTTP 503)'], + ]) assert.equal(await describe({ status, json: async () => body }), expected); +});