mirror of
https://github.com/Comfy-Org/ComfyUI-Manager.git
synced 2026-05-09 00:22:51 +08:00
Defense-in-depth over GET→POST alone: reject the three CORS-safelisted simple-form Content-Types (x-www-form-urlencoded, multipart/form-data, text/plain) on 16 no-body POST handlers (glob + legacy) to block <form method=POST> CSRF that bypasses method-only gating. Move comfyui_switch_version to a JSON body so the preflight requirement applies. Split db_mode/policy/update/channel_url_list into GET(read) + POST(write). Tighten do_fix (high → high+) and gate three previously-ungated config setters at middle. Resynchronize openapi.yaml (27 paths, 30 operations, ComfyUISwitchVersionParams as a shared $ref component). Add E2E harness variants, Playwright config, CSRF/secgate suites, 39-endpoint coverage, and a CHANGELOG. Breaking: legacy per-op POST routes (install/uninstall/fix/disable/update/ reinstall/abort_current) are removed; callers already use queue/batch. Legacy /manager/notice (v1) is removed; /v2/manager/notice is retained. Reported-by: XlabAI Team of Tencent Xuanwu Lab CVSS: 8.1 (AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H)
335 lines
15 KiB
TypeScript
335 lines
15 KiB
TypeScript
/**
|
|
* E2E tests: Legacy Manager Menu Dialog.
|
|
*
|
|
* Verifies that the legacy UI manager menu opens correctly, renders
|
|
* all expected controls, and that settings dropdowns round-trip through
|
|
* the server API.
|
|
*
|
|
* Requires ComfyUI running with --enable-manager-legacy-ui on PORT.
|
|
*/
|
|
|
|
import { test, expect } from '@playwright/test';
|
|
import { waitForComfyUI, openManagerMenu, assertManagerMenuVisible, closeDialog } from './helpers';
|
|
|
|
test.describe('Manager Menu Dialog', () => {
|
|
test.beforeEach(async ({ page }) => {
|
|
await page.goto('/');
|
|
await waitForComfyUI(page);
|
|
});
|
|
|
|
test('opens via Manager button and shows 3-column layout', async ({ page }) => {
|
|
await openManagerMenu(page);
|
|
await assertManagerMenuVisible(page);
|
|
|
|
// The dialog should contain known buttons
|
|
const dialog = page.locator('#cm-manager-dialog').first();
|
|
await expect(dialog.locator('button:has-text("Custom Nodes Manager")')).toBeVisible();
|
|
await expect(dialog.locator('button:has-text("Model Manager")')).toBeVisible();
|
|
await expect(dialog.locator('button:has-text("Restart")')).toBeVisible();
|
|
});
|
|
|
|
test('shows DB mode and Update Policy dropdowns', async ({ page }) => {
|
|
// WI-OO Item 3 (bloat dev:ci-022 B8 title-mismatch): renamed from
|
|
// "shows settings dropdowns (DB, Channel, Policy)". The original title
|
|
// promised three dropdowns but the body only asserted DB + Policy; in
|
|
// this legacy-UI build the channel combo is populated via a separate
|
|
// code path and is not reliably surfaced as a <select> in `#cm-manager-dialog`
|
|
// at open time (the DB-mode combo's options overlap with channel names via
|
|
// the "Channel" entry, which is what the original filter regex accidentally
|
|
// caught). Renaming makes the test's actual contract match its name;
|
|
// channel-dropdown coverage belongs in a dedicated test once the combo's
|
|
// stable selector is established.
|
|
await openManagerMenu(page);
|
|
const dialog = page.locator('#cm-manager-dialog').first();
|
|
|
|
// DB mode combo — options include cache/local/channel/remote.
|
|
const dbCombo = dialog.locator('select').filter({ hasText: /Cache|Local|Channel/ }).first();
|
|
await expect(dbCombo).toBeVisible();
|
|
|
|
// Update policy combo — options include Stable/Nightly variants.
|
|
const policyCombo = dialog.locator('select').filter({ hasText: /Stable|Nightly/ }).first();
|
|
await expect(policyCombo).toBeVisible();
|
|
});
|
|
|
|
test('DB mode dropdown persists via UI (close-reopen verification)', async ({ page }) => {
|
|
// Wave3 WI-U Cluster H target 1: UI-only contract.
|
|
// No page.request / page.waitForResponse — pure UI interaction + dialog
|
|
// close-reopen as the persistence proof. networkidle is used only as a
|
|
// settle barrier (wait), never as assertion input. Close via the dialog's
|
|
// own `.p-dialog-close-button` (X button) because Escape doesn't close
|
|
// ComfyDialog reliably.
|
|
await openManagerMenu(page);
|
|
const dialog = page.locator('#cm-manager-dialog').first();
|
|
const dbCombo = dialog.locator('select').filter({ hasText: /Cache|Local|Channel/ }).first();
|
|
|
|
const original = await dbCombo.inputValue();
|
|
const newValue = original !== 'local' ? 'local' : 'cache';
|
|
|
|
try {
|
|
// Select via UI — the onchange handler fires the save. Wait for
|
|
// network quiescence so the save completes before we close.
|
|
await dbCombo.selectOption(newValue);
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
// Close + reopen (UI-only persistence proof)
|
|
await dialog.locator('.p-dialog-close-button').first().click();
|
|
// ComfyDialog.close() sets display:none but keeps the element in DOM,
|
|
// so check visibility (toBeHidden), not presence (toHaveCount 0).
|
|
await expect(page.locator('#cm-manager-dialog').first()).toBeHidden({ timeout: 5_000 });
|
|
await openManagerMenu(page);
|
|
|
|
const reopenedDialog = page.locator('#cm-manager-dialog').first();
|
|
const reopenedCombo = reopenedDialog
|
|
.locator('select')
|
|
.filter({ hasText: /Cache|Local|Channel/ })
|
|
.first();
|
|
const persistedValue = await reopenedCombo.inputValue();
|
|
expect(persistedValue).toBe(newValue);
|
|
} finally {
|
|
// UI-only restore: reopen if needed + selectOption back to original.
|
|
// ComfyDialog keeps the element in DOM on close (display:none), so
|
|
// test visibility rather than presence.
|
|
const existing = page.locator('#cm-manager-dialog').first();
|
|
if ((await existing.count()) === 0 || !(await existing.isVisible().catch(() => false))) {
|
|
await openManagerMenu(page);
|
|
}
|
|
const cleanupDialog = page.locator('#cm-manager-dialog').first();
|
|
const cleanupCombo = cleanupDialog
|
|
.locator('select')
|
|
.filter({ hasText: /Cache|Local|Channel/ })
|
|
.first();
|
|
// selectOption is idempotent; if the value is already `original` this
|
|
// is a no-op. networkidle guarantees the save settles before
|
|
// subsequent tests run.
|
|
await cleanupCombo.selectOption(original);
|
|
await page.waitForLoadState('networkidle');
|
|
}
|
|
});
|
|
|
|
test('Update Policy dropdown persists via UI (close-reopen verification)', async ({ page }) => {
|
|
// Wave3 WI-U Cluster H target 2: same UI-only pattern as the DB mode test.
|
|
await openManagerMenu(page);
|
|
const dialog = page.locator('#cm-manager-dialog').first();
|
|
const policyCombo = dialog.locator('select').filter({ hasText: /Stable|Nightly/ }).first();
|
|
|
|
const original = await policyCombo.inputValue();
|
|
const newValue = original !== 'nightly-comfyui' ? 'nightly-comfyui' : 'stable-comfyui';
|
|
|
|
try {
|
|
await policyCombo.selectOption(newValue);
|
|
await page.waitForLoadState('networkidle');
|
|
|
|
await dialog.locator('.p-dialog-close-button').first().click();
|
|
// ComfyDialog.close() sets display:none but keeps the element in DOM,
|
|
// so check visibility (toBeHidden), not presence (toHaveCount 0).
|
|
await expect(page.locator('#cm-manager-dialog').first()).toBeHidden({ timeout: 5_000 });
|
|
await openManagerMenu(page);
|
|
|
|
const reopenedDialog = page.locator('#cm-manager-dialog').first();
|
|
const reopenedCombo = reopenedDialog
|
|
.locator('select')
|
|
.filter({ hasText: /Stable|Nightly/ })
|
|
.first();
|
|
const persistedValue = await reopenedCombo.inputValue();
|
|
expect(persistedValue).toBe(newValue);
|
|
} finally {
|
|
// UI-only restore
|
|
if ((await page.locator('#cm-manager-dialog').count()) === 0) {
|
|
await openManagerMenu(page);
|
|
}
|
|
const cleanupDialog = page.locator('#cm-manager-dialog').first();
|
|
const cleanupCombo = cleanupDialog
|
|
.locator('select')
|
|
.filter({ hasText: /Stable|Nightly/ })
|
|
.first();
|
|
await cleanupCombo.selectOption(original);
|
|
await page.waitForLoadState('networkidle');
|
|
}
|
|
});
|
|
|
|
test('closes and reopens without duplicating', async ({ page }) => {
|
|
await openManagerMenu(page);
|
|
await assertManagerMenuVisible(page);
|
|
|
|
await closeDialog(page);
|
|
// ComfyDialog.close() sets display:none but keeps the element in DOM —
|
|
// assert the (single) instance is now hidden instead of detached.
|
|
await expect(page.locator('#cm-manager-dialog').first()).toBeHidden({ timeout: 5_000 });
|
|
|
|
// Reopen
|
|
await openManagerMenu(page);
|
|
await assertManagerMenuVisible(page);
|
|
|
|
// Exactly one dialog instance expected. `=== 1` guards against real
|
|
// duplication bugs (ComfyDialog reuses the element, so a duplicate
|
|
// instance would be a real regression).
|
|
await expect(page.locator('#cm-manager-dialog')).toHaveCount(1);
|
|
});
|
|
|
|
// WI-VV coverage — close 4 LOW-risk Playwright P-gaps from
|
|
// reports/api-coverage-matrix.md. Each test exercises a UI trigger that
|
|
// the spec suite previously missed, without destructive action.
|
|
|
|
test('WI-VV wi-001: Switch ComfyUI button fetches comfyui_versions', async ({ page }) => {
|
|
// Clicking 'Switch ComfyUI' triggers GET /v2/comfyui_manager/comfyui_versions
|
|
// (comfyui-manager.js:612) and opens a secondary version-selector dialog.
|
|
// We assert the GET response populated with a non-empty version list
|
|
// and DO NOT select a version (selection would trigger the downstream
|
|
// POST /v2/comfyui_manager/comfyui_switch_version — out of scope for
|
|
// safe P-closure).
|
|
await openManagerMenu(page);
|
|
const dialog = page.locator('#cm-manager-dialog').first();
|
|
const switchBtn = dialog.locator('button:has-text("Switch ComfyUI")').first();
|
|
await expect(switchBtn).toBeVisible();
|
|
|
|
// Race the click with the response interception so we capture the GET
|
|
// that the click fires.
|
|
const [resp] = await Promise.all([
|
|
page.waitForResponse(
|
|
(r) =>
|
|
r.url().includes('/v2/comfyui_manager/comfyui_versions') &&
|
|
r.request().method() === 'GET',
|
|
{ timeout: 15_000 },
|
|
),
|
|
switchBtn.click(),
|
|
]);
|
|
|
|
expect(resp.status()).toBe(200);
|
|
const payload = await resp.json();
|
|
expect(payload).toHaveProperty('versions');
|
|
expect(Array.isArray(payload.versions)).toBe(true);
|
|
expect(payload.versions.length).toBeGreaterThan(0);
|
|
|
|
// Dismiss the secondary version-selector dialog without selecting by
|
|
// navigating away. Reloading the page collapses all ComfyDialogs and
|
|
// restores a clean slate for subsequent tests.
|
|
await page.goto('/');
|
|
await waitForComfyUI(page);
|
|
});
|
|
|
|
test('WI-VV wi-005: channel dropdown populates from channel_url_list GET', async ({ page }) => {
|
|
// Opening the manager menu triggers GET /v2/manager/channel_url_list
|
|
// (comfyui-manager.js:963) which async-populates the channel combo.
|
|
// Stable selector per reports/legacy-ui-channel-combo-dom-mapping.md:
|
|
// select[title^="Configure the channel"]
|
|
// Options are appended from `data.list` after the fetch resolves;
|
|
// `expect.poll` waits for population without racing the async fetch.
|
|
await openManagerMenu(page);
|
|
const dialog = page.locator('#cm-manager-dialog').first();
|
|
const channelCombo = dialog.locator(
|
|
'select[title^="Configure the channel"]',
|
|
);
|
|
await expect(channelCombo).toBeVisible();
|
|
|
|
await expect
|
|
.poll(
|
|
async () => (await channelCombo.locator('option').count()),
|
|
{ timeout: 10_000, message: 'channel combo should populate from GET /v2/manager/channel_url_list' },
|
|
)
|
|
.toBeGreaterThan(0);
|
|
|
|
// Current selection should be a non-empty string (the server-side
|
|
// `selected` field from the endpoint response).
|
|
const value = await channelCombo.inputValue();
|
|
expect(value).not.toBe('');
|
|
});
|
|
|
|
test('WI-VV wi-017: changing channel combo POSTs channel_url_list', async ({ page }) => {
|
|
// Changing the channel dropdown fires the onchange handler at
|
|
// comfyui-manager.js:975-977 which POSTs the new value to
|
|
// /v2/manager/channel_url_list. Teardown in finally restores the
|
|
// original selection to keep downstream tests clean.
|
|
await openManagerMenu(page);
|
|
const dialog = page.locator('#cm-manager-dialog').first();
|
|
const channelCombo = dialog.locator(
|
|
'select[title^="Configure the channel"]',
|
|
);
|
|
await expect(channelCombo).toBeVisible();
|
|
|
|
// Wait for options to populate before reading values.
|
|
await expect
|
|
.poll(async () => (await channelCombo.locator('option').count()), {
|
|
timeout: 10_000,
|
|
})
|
|
.toBeGreaterThan(0);
|
|
|
|
const original = await channelCombo.inputValue();
|
|
const optionValues = await channelCombo
|
|
.locator('option')
|
|
.evaluateAll((opts) => opts.map((o) => (o as HTMLOptionElement).value));
|
|
const alternative = optionValues.find((v) => v !== original && v !== '');
|
|
|
|
// If the server exposes only one channel, skip with reason — we
|
|
// cannot exercise the POST without a different selectable option.
|
|
if (!alternative) {
|
|
test.skip(
|
|
true,
|
|
`channel combo only offers one value (${original}); POST path unreachable in this env`,
|
|
);
|
|
}
|
|
|
|
try {
|
|
const [postResp] = await Promise.all([
|
|
page.waitForResponse(
|
|
(r) =>
|
|
r.url().includes('/v2/manager/channel_url_list') &&
|
|
r.request().method() === 'POST',
|
|
{ timeout: 10_000 },
|
|
),
|
|
channelCombo.selectOption(alternative!),
|
|
]);
|
|
expect(postResp.status()).toBe(200);
|
|
} finally {
|
|
// Restore — accept the POST but do not re-assert; a failure here
|
|
// should not mask the assertion failure above.
|
|
const restoreCombo = page
|
|
.locator('#cm-manager-dialog')
|
|
.first()
|
|
.locator('select[title^="Configure the channel"]');
|
|
if ((await restoreCombo.count()) > 0 && (await restoreCombo.inputValue()) !== original) {
|
|
await restoreCombo.selectOption(original).catch(() => undefined);
|
|
await page.waitForLoadState('networkidle').catch(() => undefined);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('WI-VV wi-021: queue/reset POST succeeds at idle (API-level Playwright)', async ({ page, request }) => {
|
|
// UI-click path is NOT feasible at idle: comfyui-manager.js:795-802
|
|
// restart_stop_button reads "Restart" when no tasks are in progress and
|
|
// invokes rebootAPI() (server reboot) — clicking it at idle would
|
|
// kill the test server mid-run. The `.cn-manager-stop` /
|
|
// `.model-manager-stop` buttons that DO call `/v2/manager/queue/reset`
|
|
// (custom-nodes-manager.js:465, model-manager.js:173) are display:none
|
|
// at idle via CSS. Inducing in-progress state would require starting a
|
|
// real install — explicitly out-of-scope for this LOW-risk P-closure.
|
|
//
|
|
// Fallback: exercise the endpoint via page.request (Playwright's
|
|
// browser-context HTTP client). This verifies endpoint availability +
|
|
// idempotency at idle, which is the essential contract the UI-click
|
|
// would assert. The UI-wiring of the button is trivially visible from
|
|
// JS-source grep (3 callers, all with identical `fetchApi` POST).
|
|
await page.goto('/');
|
|
await waitForComfyUI(page);
|
|
|
|
// Pre-check: queue should be empty so reset is a true no-op.
|
|
const statusBefore = await request.get('/v2/manager/queue/status');
|
|
expect(statusBefore.status()).toBe(200);
|
|
const statusJson = await statusBefore.json();
|
|
|
|
const resetResp = await request.post('/v2/manager/queue/reset');
|
|
expect(resetResp.status()).toBe(200);
|
|
|
|
// Post-check: queue/status still callable (handler released locks
|
|
// cleanly) and the reset did not break queue introspection.
|
|
const statusAfter = await request.get('/v2/manager/queue/status');
|
|
expect(statusAfter.status()).toBe(200);
|
|
|
|
// Sanity: is_processing (or equivalent flag) should remain stable
|
|
// when reset was called on an empty queue — we don't strictly assert
|
|
// the flag here because the exact field name differs across Manager
|
|
// versions; the 200-on-status is the portable contract.
|
|
expect(await statusAfter.json()).toBeDefined();
|
|
void statusJson; // retained for debug, not asserted (pre/post shapes are impl-detail)
|
|
});
|
|
});
|