ComfyUI-Manager/tests/playwright/legacy-ui-manager-menu.spec.ts
Dr.Lt.Data 4410ebc6a6
Some checks are pending
Publish to PyPI / build-and-publish (push) Waiting to run
Python Linting / Run Ruff (push) Waiting to run
fix(security): harden CSRF with Content-Type gate and expand E2E coverage (#2818)
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)
2026-04-22 05:04:30 +09:00

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)
});
});