ComfyUI-Manager/tests/playwright/legacy-ui-snapshot.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

125 lines
4.9 KiB
TypeScript

/**
* E2E tests: Legacy Snapshot Manager dialog.
*
* Tests UI-driven save and remove operations.
* Requires ComfyUI running with --enable-manager-legacy-ui on PORT.
*/
import { test, expect } from '@playwright/test';
import { waitForComfyUI, openManagerMenu, clickMenuButton } from './helpers';
const SNAPSHOT_ROW_SELECTOR = '#snapshot-manager-dialog tr, #snapshot-manager-dialog li';
async function getSnapshotNames(page: import('@playwright/test').Page): Promise<string[]> {
const resp = await page.request.get('/v2/snapshot/getlist');
if (!resp.ok()) return [];
const data = await resp.json();
return Array.isArray(data?.items) ? data.items : [];
}
test.describe('Snapshot Manager', () => {
// Track snapshots created during each test so afterEach can clean them up.
// Prevents test-run accumulation on disk across runs.
const createdDuringTest = new Set<string>();
test.beforeEach(async ({ page }) => {
createdDuringTest.clear();
await page.goto('/');
await waitForComfyUI(page);
await openManagerMenu(page);
});
test.afterEach(async ({ page }) => {
// Cleanup snapshots newly created during the test to avoid state leak.
for (const name of createdDuringTest) {
await page.request.post(`/v2/snapshot/remove?target=${encodeURIComponent(name)}`);
}
});
test('opens snapshot manager from Manager menu', async ({ page }) => {
await clickMenuButton(page, 'Snapshot Manager');
// Snapshot manager should appear
await page.waitForSelector('#snapshot-manager-dialog', {
timeout: 10_000,
});
});
test('SS1 Save button creates a new snapshot row', async ({ page }) => {
await clickMenuButton(page, 'Snapshot Manager');
await page.waitForSelector('#snapshot-manager-dialog', { timeout: 10_000 });
// Baseline snapshot names (not row count — more reliable)
const namesBefore = await getSnapshotNames(page);
// Click Save button (UI-driven). Hard fail if the button doesn't exist.
const saveBtn = page
.locator('#snapshot-manager-dialog button:has-text("Save"), #snapshot-manager-dialog button:has-text("Create")')
.first();
await expect(saveBtn).toBeVisible({ timeout: 5_000 });
await saveBtn.click();
// Wait for new snapshot to appear in backend list (UI row count may lag)
await expect
.poll(async () => (await getSnapshotNames(page)).length, { timeout: 15_000 })
.toBeGreaterThan(namesBefore.length);
const namesAfter = await getSnapshotNames(page);
const newNames = namesAfter.filter((n) => !namesBefore.includes(n));
expect(newNames.length).toBeGreaterThanOrEqual(1);
// Register for afterEach cleanup
newNames.forEach((n) => createdDuringTest.add(n));
// UI row count should also reflect the new snapshot
const rowsAfter = await page.locator(SNAPSHOT_ROW_SELECTOR).count();
expect(rowsAfter).toBeGreaterThan(0);
});
test('UI Remove button deletes a snapshot row', async ({ page }) => {
// SETUP: create a snapshot via API so we have a deterministic target
const saveResp = await page.request.post('/v2/snapshot/save');
expect(saveResp.ok()).toBe(true);
const namesAfterSave = await getSnapshotNames(page);
expect(namesAfterSave.length).toBeGreaterThan(0);
const targetName = namesAfterSave[0]; // desc-sorted — newest at [0]
// Open the Snapshot Manager via UI
await clickMenuButton(page, 'Snapshot Manager');
await page.waitForSelector('#snapshot-manager-dialog', { timeout: 10_000 });
// Locate the row containing our target snapshot
const targetRow = page
.locator(SNAPSHOT_ROW_SELECTOR, { hasText: targetName })
.first();
await expect(targetRow).toBeVisible({ timeout: 10_000 });
// Click the Remove/Delete button inside that row (UI-driven)
const removeBtn = targetRow.locator(
'button:has-text("Remove"), button:has-text("Delete"), button[title*="emove" i], button[title*="elete" i]',
);
if (!(await removeBtn.first().isVisible({ timeout: 2_000 }).catch(() => false))) {
// If the Remove UI is a right-click / hover / icon without text, register for
// cleanup via the afterEach and report a specific failure so the test surfaces
// the UI gap rather than pretending it verified deletion.
createdDuringTest.add(targetName);
throw new Error(
'Remove/Delete button not found in snapshot row — ' +
'UI regression or selector change; update selector to match current UI',
);
}
// Accept confirmation dialog if the UI raises one
page.once('dialog', async (d) => {
await d.accept();
});
await removeBtn.first().click();
// Effect verification: snapshot disappears from backend AND from UI
await expect
.poll(async () => (await getSnapshotNames(page)).includes(targetName), { timeout: 10_000 })
.toBe(false);
await expect(page.locator(SNAPSHOT_ROW_SELECTOR, { hasText: targetName })).toHaveCount(0);
});
});