Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5465d6fc41 | ||
|
|
ce82dfb912 | ||
|
|
5ded3189c1 |
@@ -15,6 +15,11 @@ reviewed. Add a regression test for each bug; prove it fails before the fix.
|
||||
Page workflows cover save/edit/reload, duplicate-edit rejection, gallery image
|
||||
identity, Tags selection and viewer cleanup, delayed responses, auto-tag review
|
||||
and persistence, and infinite scrolling without replacing existing cards.
|
||||
Data safety workflows download and upload actual SQLite backups, verify restored
|
||||
prompts/tags/image links, reject corrupt uploads without changing live data, and
|
||||
exercise cancellation and network-failure recovery for restores and deletion.
|
||||
Duplicate cleanup verifies only the selected file disappears; prompt deletion
|
||||
verifies the other prompt and original image files survive.
|
||||
They run in Chromium, Firefox, WebKit, and Chromium touch emulation. No GPU,
|
||||
model downloads, running ComfyUI instance, or personal configuration is required.
|
||||
The server binds to localhost and creates disposable directories and a database;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Shared ComfyAssets UI adoption
|
||||
|
||||
Status: proposed; no runtime behavior changes in this document.
|
||||
|
||||
Tracking: [PromptManager #186](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues/186)
|
||||
and [KikoTools #81](https://github.com/ComfyAssets/ComfyUI-KikoTools/issues/81).
|
||||
|
||||
The [KikoTools foundation proposal and mockup PR](https://github.com/ComfyAssets/ComfyUI-KikoTools/pull/82)
|
||||
defines the common architecture and acceptance criteria. KikoTools' Local Image
|
||||
Loader is the first pilot. PromptManager adoption follows validation of that
|
||||
pilot and extraction of a versioned, original ComfyAssets UI core.
|
||||
|
||||
## PromptManager requirements
|
||||
|
||||
- Preserve existing node names, inputs/outputs, API routes, saved workflows,
|
||||
gallery behavior, and existing test/coverage gates.
|
||||
- Use shared control factories, editor slots, settings/help/window behavior and
|
||||
the ComfyUI adapter where applicable. Keep PromptManager controllers and HTTP
|
||||
behavior in this repository.
|
||||
- Both projects must look identical: one shared set of controls, typography,
|
||||
spacing, colors and interaction states. Follow ComfyUI light/dark mode with
|
||||
our own styling, a blue primary accent and strong red errors/destructive actions.
|
||||
Use compact buttons, pill switches, segmented selections and responsive rows.
|
||||
Review the pilot mockup before production styling. Existing `--pm-*` tokens
|
||||
are migration context, not an approved design to retain.
|
||||
- Theme standalone pages explicitly: `window.open` pages do not inherit the
|
||||
host's CSS variables. Test the agreed palette policy, live changes, fallbacks
|
||||
and persistence, including errors when host settings are unavailable.
|
||||
- Ship a pinned, reproducible browser distribution. Installation must work
|
||||
without npm or KikoTools, including when both packs ship different core versions.
|
||||
- Attribute [Pixaroma](https://github.com/pixaroma/ComfyUI-Pixaroma) as architectural
|
||||
inspiration. Do not copy/vendor his code, styles, or assets.
|
||||
|
||||
## First migration
|
||||
|
||||
After the pilot, inventory the gallery dialogs and choose one bounded dialog as
|
||||
the second consumer. Record its current behavior with tests before changing it.
|
||||
Verify keyboard opening/closing, focus restoration, accessible labels, confirm
|
||||
and cancel behavior, network failures, and light/dark appearances. Keep the
|
||||
existing gallery and destructive-action browser workflows passing.
|
||||
|
||||
Build shared-source extraction and distribution checks before importing the core.
|
||||
Test both packs alone and together, scoped styling, isolated settings/storage,
|
||||
and independent cleanup. ComfyUI-facing changes require the real-host Classic,
|
||||
Nodes 2.0 and live-switching matrix from the foundation proposal. Standalone
|
||||
dialogs additionally run through PromptManager's existing browser harness.
|
||||
|
||||
## Delivery
|
||||
|
||||
Use small issue-linked PRs and TDD: verify each new behavioral test fails before
|
||||
implementing it, then run relevant suites and required CI checks. Include visual
|
||||
evidence for appearance changes. Migrate additional dialogs/editors only after
|
||||
the first consumer validates the package boundaries; do not rewrite all pages
|
||||
or change backend schemas as part of this initiative.
|
||||
@@ -139,3 +139,206 @@ test('gallery infinite scroll appends a real backend page while retaining existi
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(110);
|
||||
await expect(first).toHaveAttribute('data-preserved', 'yes');
|
||||
});
|
||||
|
||||
async function promptSnapshot(request) {
|
||||
const response = await request.get('/prompt_manager/recent?limit=100');
|
||||
await expect(response).toBeOK();
|
||||
return (await response.json()).results.map(({ id, text, tags, images }) => ({
|
||||
id, text, tags: [...tags].sort(),
|
||||
images: images.map(image => image.image_path).sort(),
|
||||
})).sort((a, b) => a.id - b.id);
|
||||
}
|
||||
|
||||
async function downloadBackup(page) {
|
||||
const downloading = page.waitForEvent('download');
|
||||
await page.locator('#backupBtn').click();
|
||||
const download = await downloading;
|
||||
expect(download.suggestedFilename()).toMatch(/^prompts_backup_.*\.db$/);
|
||||
expect(await download.failure()).toBeNull();
|
||||
const chunks = [];
|
||||
for await (const chunk of await download.createReadStream()) chunks.push(chunk);
|
||||
const buffer = Buffer.concat(chunks);
|
||||
expect(buffer.subarray(0, 16).toString()).toBe('SQLite format 3\0');
|
||||
return { name: download.suggestedFilename(), mimeType: 'application/octet-stream', buffer };
|
||||
}
|
||||
|
||||
async function chooseRestore(page, file) {
|
||||
await page.locator('#restoreBtn').click();
|
||||
await expect(page.locator('#restoreModal')).toBeVisible();
|
||||
await page.locator('#restoreFileInput').setInputFiles(file);
|
||||
}
|
||||
|
||||
function confirmNextDialog(page, accept, message) {
|
||||
page.once('dialog', async dialog => {
|
||||
expect(dialog.type()).toBe('confirm');
|
||||
expect(dialog.message()).toContain(message);
|
||||
await (accept ? dialog.accept() : dialog.dismiss());
|
||||
});
|
||||
}
|
||||
|
||||
async function deletePromptButton(page, text) {
|
||||
const prompt = page.locator('.prompt-text').filter({ hasText: text });
|
||||
await expect(prompt).toBeVisible();
|
||||
const id = await prompt.getAttribute('data-id');
|
||||
return page.locator(`[data-action="delete"][data-prompt-id="${id}"]`);
|
||||
}
|
||||
|
||||
test('downloaded backup restores deleted prompts, tags, and image links through the actual upload form', async ({ page, request }) => {
|
||||
await page.goto('/prompt_manager/admin');
|
||||
await expect(page.locator('.prompt-text')).toHaveCount(2);
|
||||
const before = await promptSnapshot(request);
|
||||
expect(before.map(prompt => prompt.tags)).toEqual([['cats'], ['dogs']]);
|
||||
expect(before.every(prompt => prompt.images.length === 1)).toBe(true);
|
||||
const backup = await downloadBackup(page);
|
||||
const button = await deletePromptButton(page, 'Cat portrait');
|
||||
confirmNextDialog(page, true, 'delete this prompt');
|
||||
await button.click();
|
||||
await expect(page.locator('.prompt-text')).toHaveCount(1);
|
||||
await expect(await request.post('/prompt_manager/save', { data: { text: 'Added after backup', tags: ['temporary'] } })).toBeOK();
|
||||
expect(await promptSnapshot(request)).not.toEqual(before);
|
||||
await chooseRestore(page, backup);
|
||||
confirmNextDialog(page, true, backup.name);
|
||||
const restored = page.waitForResponse(response => response.url().endsWith('/prompt_manager/restore'));
|
||||
await page.locator('#confirmRestore').click();
|
||||
const response = await restored;
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(await response.json()).toMatchObject({ success: true, prompt_count: 2, backup_created: expect.any(String) });
|
||||
await expect(page.locator('#restoreModal')).toBeHidden();
|
||||
await expect(page.locator('.prompt-text')).toHaveCount(2);
|
||||
await page.reload();
|
||||
await expect(page.locator('.prompt-text').filter({ hasText: 'Cat portrait' })).toBeVisible();
|
||||
await expect(page.locator('.prompt-text').filter({ hasText: 'Added after backup' })).toHaveCount(0);
|
||||
expect(await promptSnapshot(request)).toEqual(before);
|
||||
await page.goto('/prompt_manager/admin#/tags');
|
||||
await page.locator('[data-tag-name="cats"]').click();
|
||||
await expect(page.locator('#tagPromptsGrid')).toContainText('Cat portrait');
|
||||
await page.locator('#tagPromptsGrid img').first().click();
|
||||
await expect(page.locator('.viewer-canvas img')).toBeVisible();
|
||||
await expect.poll(() => page.locator('.viewer-canvas img').evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
|
||||
});
|
||||
|
||||
test('restore validation and cancellation preserve live data and allow a valid retry', async ({ page, request }) => {
|
||||
await page.goto('/prompt_manager/admin');
|
||||
const before = await promptSnapshot(request);
|
||||
const backup = await downloadBackup(page);
|
||||
let uploads = 0;
|
||||
page.on('request', request => { if (request.url().endsWith('/prompt_manager/restore')) uploads++; });
|
||||
await page.locator('#restoreBtn').click();
|
||||
await page.locator('#confirmRestore').click();
|
||||
await expect(page.getByText('Please select a database file to restore', { exact: true })).toBeVisible();
|
||||
await page.locator('#restoreFileInput').setInputFiles({ ...backup, name: 'backup.txt' });
|
||||
await page.locator('#confirmRestore').click();
|
||||
await expect(page.getByText('Please select a valid .db file', { exact: true })).toBeVisible();
|
||||
expect(uploads).toBe(0);
|
||||
await page.locator('#restoreFileInput').setInputFiles(backup);
|
||||
confirmNextDialog(page, false, 'replace your current database');
|
||||
await page.locator('#confirmRestore').click();
|
||||
await expect(page.locator('#restoreModal')).toBeVisible();
|
||||
expect(uploads).toBe(0);
|
||||
await page.locator('#restoreFileInput').setInputFiles({ name: 'corrupt.db', mimeType: 'application/octet-stream', buffer: Buffer.from('not a SQLite database') });
|
||||
confirmNextDialog(page, true, 'corrupt.db');
|
||||
const rejected = page.waitForResponse(response => response.url().endsWith('/prompt_manager/restore'));
|
||||
await page.locator('#confirmRestore').click();
|
||||
expect((await rejected).status()).toBe(400);
|
||||
await expect(page.getByText(/Restore failed:/)).toBeVisible();
|
||||
await expect(page.locator('#restoreModal')).toBeVisible();
|
||||
await expect(page.locator('#confirmRestore')).toBeEnabled();
|
||||
expect(await promptSnapshot(request)).toEqual(before);
|
||||
await page.locator('#restoreFileInput').setInputFiles(backup);
|
||||
confirmNextDialog(page, true, backup.name);
|
||||
await page.locator('#confirmRestore').click();
|
||||
await expect(page.locator('#restoreModal')).toBeHidden();
|
||||
expect(await promptSnapshot(request)).toEqual(before);
|
||||
});
|
||||
|
||||
test('backup and restore recover from interrupted network requests without losing data', async ({ page, request }) => {
|
||||
await page.goto('/prompt_manager/admin');
|
||||
const before = await promptSnapshot(request);
|
||||
await page.route('**/prompt_manager/backup', route => route.abort('failed'), { times: 1 });
|
||||
await page.locator('#backupBtn').click();
|
||||
await expect(page.getByText(/Backup failed:/)).toBeVisible();
|
||||
const backup = await downloadBackup(page);
|
||||
await chooseRestore(page, backup);
|
||||
await page.route('**/prompt_manager/restore', route => route.abort('failed'), { times: 1 });
|
||||
confirmNextDialog(page, true, backup.name);
|
||||
await page.locator('#confirmRestore').click();
|
||||
await expect(page.getByText(/Restore failed:/)).toBeVisible();
|
||||
await expect(page.locator('#confirmRestore')).toBeEnabled();
|
||||
await expect(page.locator('#restoreModal')).toBeVisible();
|
||||
expect(await promptSnapshot(request)).toEqual(before);
|
||||
confirmNextDialog(page, true, backup.name);
|
||||
await page.locator('#confirmRestore').click();
|
||||
await expect(page.locator('#restoreModal')).toBeHidden();
|
||||
expect(await promptSnapshot(request)).toEqual(before);
|
||||
});
|
||||
|
||||
test('prompt deletion requires confirmation and can retry after a network failure', async ({ page, request }) => {
|
||||
await page.goto('/prompt_manager/admin');
|
||||
const before = await promptSnapshot(request);
|
||||
const button = await deletePromptButton(page, 'Cat portrait');
|
||||
let deletes = 0;
|
||||
page.on('request', request => { if (request.method() === 'DELETE') deletes++; });
|
||||
confirmNextDialog(page, false, 'delete this prompt');
|
||||
await button.click();
|
||||
expect(deletes).toBe(0);
|
||||
expect(await promptSnapshot(request)).toEqual(before);
|
||||
await page.route('**/prompt_manager/delete/*', route => route.abort('failed'), { times: 1 });
|
||||
confirmNextDialog(page, true, 'delete this prompt');
|
||||
await button.click();
|
||||
await expect(page.getByText('Failed to delete prompt', { exact: true })).toBeVisible();
|
||||
expect(await promptSnapshot(request)).toEqual(before);
|
||||
confirmNextDialog(page, true, 'delete this prompt');
|
||||
await button.click();
|
||||
await expect(page.locator('.prompt-text')).toHaveCount(1);
|
||||
await page.reload();
|
||||
await expect(page.locator('.prompt-text')).toHaveCount(1);
|
||||
expect(await promptSnapshot(request)).toEqual(before.filter(prompt => prompt.text !== 'Cat portrait'));
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2);
|
||||
for (const url of await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original))) {
|
||||
await expect(await request.get(url)).toBeOK();
|
||||
}
|
||||
});
|
||||
|
||||
test('duplicate cleanup cancels safely and deletes only the selected file after a retry', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/reset', { data: { extra_images: 2 } })).toBeOK();
|
||||
await page.goto('/prompt_manager/gallery');
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(4);
|
||||
const originals = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original));
|
||||
await page.locator('#settingsBtn').click();
|
||||
await page.locator('#scanDuplicatesBtn').click();
|
||||
await expect(page.locator('#duplicatesCount')).toHaveText('1');
|
||||
const candidates = page.locator('.duplicate-checkbox');
|
||||
await expect(candidates).toHaveCount(2);
|
||||
const selected = await candidates.first().getAttribute('data-image-path');
|
||||
const retained = await candidates.last().getAttribute('data-image-path');
|
||||
const serve = path => `/prompt_manager/images/serve/${path.split(/[\\/]/).map(encodeURIComponent).join('/')}`;
|
||||
await candidates.first().check();
|
||||
let deletes = 0;
|
||||
page.on('request', request => { if (request.url().endsWith('/delete_duplicate_images')) deletes++; });
|
||||
confirmNextDialog(page, false, 'permanently delete 1 duplicate image files');
|
||||
await page.locator('#removeDuplicatesBtn').click();
|
||||
expect(deletes).toBe(0);
|
||||
await expect(await request.get(serve(selected))).toBeOK();
|
||||
await page.route('**/prompt_manager/delete_duplicate_images', route => route.abort('failed'), { times: 1 });
|
||||
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
|
||||
await page.locator('#removeDuplicatesBtn').click();
|
||||
await expect(page.getByText('Failed to delete duplicate images', { exact: true })).toBeVisible();
|
||||
await expect(page.locator('#removeDuplicatesBtn')).toBeEnabled();
|
||||
await expect(await request.get(serve(selected))).toBeOK();
|
||||
confirmNextDialog(page, true, 'permanently delete 1 duplicate image files');
|
||||
const deleted = page.waitForResponse(response => response.url().endsWith('/delete_duplicate_images'));
|
||||
await page.locator('#removeDuplicatesBtn').click();
|
||||
expect((await (await deleted).json())).toMatchObject({ success: true, deleted_count: 1, failed_count: 0 });
|
||||
await expect(page.locator('#duplicatesModal')).toBeHidden();
|
||||
expect((await request.get(serve(selected))).status()).toBe(404);
|
||||
await expect(await request.get(serve(retained))).toBeOK();
|
||||
await page.reload();
|
||||
await expect(page.locator('#galleryGrid .image-item')).toHaveCount(3);
|
||||
const remaining = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original));
|
||||
expect(remaining).toHaveLength(originals.length - 1);
|
||||
for (const url of remaining) {
|
||||
expect(originals).toContain(url);
|
||||
await expect(await request.get(url)).toBeOK();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1867,7 +1867,7 @@ class TestScanReadsVideos(AdminAPITestCase):
|
||||
(self.output_dir / "bad.png").write_bytes(b"\x89PNG not really")
|
||||
GalleryConfig.MONITORING_DIRECTORIES = [self._root(self.output_dir)]
|
||||
|
||||
with self.assertLogs(level="WARNING") as cm:
|
||||
with self.assertLogs("prompt_manager", level="WARNING") as cm:
|
||||
body = await (
|
||||
await self.client.request("POST", "/prompt_manager/scan")
|
||||
).text()
|
||||
|
||||
@@ -263,5 +263,35 @@ class TestConfiguration(IsolatedLoggerTestCase):
|
||||
self.manager.update_config({"console_logging": False})
|
||||
|
||||
|
||||
class TestConsoleNoise(IsolatedLoggerTestCase):
|
||||
"""ComfyUI's console only sees warnings unless the level is DEBUG."""
|
||||
|
||||
def _console_handler(self) -> logging.StreamHandler:
|
||||
return next(
|
||||
h
|
||||
for h in logging.getLogger("prompt_manager").handlers
|
||||
if type(h) is logging.StreamHandler
|
||||
)
|
||||
|
||||
def test_records_do_not_propagate_to_comfyui_root_logger(self) -> None:
|
||||
self.assertFalse(logging.getLogger("prompt_manager").propagate)
|
||||
|
||||
def test_console_hides_info_at_the_default_level(self) -> None:
|
||||
self.manager.update_config({"console_logging": True})
|
||||
self.assertEqual(self._console_handler().level, logging.WARNING)
|
||||
self.logger.info("startup chatter")
|
||||
self.assertEqual(self.manager.get_recent_logs(limit=1)[0]["level"], "INFO")
|
||||
log_file = pathlib.Path(self.log_dir) / "prompt_manager.log"
|
||||
self.assertIn("startup chatter", log_file.read_text())
|
||||
self.manager.update_config({"console_logging": False})
|
||||
|
||||
def test_debug_level_opens_the_console(self) -> None:
|
||||
self.manager.update_config({"console_logging": True, "level": "DEBUG"})
|
||||
self.assertEqual(self._console_handler().level, logging.DEBUG)
|
||||
self.manager.update_config({"level": "INFO"})
|
||||
self.assertEqual(self._console_handler().level, logging.WARNING)
|
||||
self.manager.update_config({"console_logging": False})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"admin.js": {
|
||||
"lines": 24,
|
||||
"branches": 53,
|
||||
"functions": 20,
|
||||
"statements": 24
|
||||
"lines": 28,
|
||||
"branches": 58,
|
||||
"functions": 23,
|
||||
"statements": 28
|
||||
},
|
||||
"api-client.js": {
|
||||
"lines": 98,
|
||||
|
||||
@@ -85,6 +85,8 @@ class PromptManagerLogger:
|
||||
# Main logger
|
||||
self.logger = logging.getLogger("prompt_manager")
|
||||
self.logger.setLevel(getattr(logging, self.config["level"]))
|
||||
# ComfyUI's root handler would print every record a second time.
|
||||
self.logger.propagate = False
|
||||
|
||||
# Clear existing handlers
|
||||
for handler in self.logger.handlers[:]:
|
||||
@@ -100,6 +102,7 @@ class PromptManagerLogger:
|
||||
# Console handler
|
||||
if self.config["console_logging"]:
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(self._console_level())
|
||||
console_handler.setFormatter(formatter)
|
||||
self.logger.addHandler(console_handler)
|
||||
|
||||
@@ -124,6 +127,15 @@ class PromptManagerLogger:
|
||||
# Component-specific loggers
|
||||
self._setup_component_loggers()
|
||||
|
||||
def _console_level(self) -> int:
|
||||
"""Console threshold: everything at DEBUG, otherwise warnings only.
|
||||
|
||||
INFO still reaches the log file and the web log viewer.
|
||||
"""
|
||||
if str(self.config["level"]).upper() == "DEBUG":
|
||||
return logging.DEBUG
|
||||
return logging.WARNING
|
||||
|
||||
def _setup_component_loggers(self):
|
||||
"""Set up loggers for specific components.
|
||||
|
||||
@@ -363,6 +375,10 @@ class PromptManagerLogger:
|
||||
logger = logging.getLogger(logger_name)
|
||||
logger.setLevel(level)
|
||||
|
||||
for handler in self.logger.handlers:
|
||||
if type(handler) is logging.StreamHandler:
|
||||
handler.setLevel(self._console_level())
|
||||
|
||||
# Re-setup loggers if handlers changed
|
||||
if any(
|
||||
key in new_config
|
||||
|
||||
Reference in New Issue
Block a user