Files
ComfyUI_frontend/browser_tests/tests/maskEditorLoadSave.spec.ts
Matt Miller ac56ecf009 refactor: unify image editor upload contract (FE-750) (#12318)
## Summary

Collapse the OSS vs Cloud branching in the mask editor and painter
uploads so both runtimes use the same contract — POST to `/upload/image`
with `type: input` and no `subfolder`, then reference the result by
filename only.

## Related

- Companion spec change (Comfy-Org/ComfyUI):
https://github.com/Comfy-Org/ComfyUI/pull/13968 deprecates
`/api/upload/mask` and documents `/api/upload/image` as the unified
upload contract. No runtime behavior changes on the server.

## Changes

- **What**:
- `useMaskEditorSaver.ts`: replace the separate `uploadMask` +
`uploadImage` helpers with a single `uploadLayer`. All four layers
(masked, paint, painted, paintedMasked) now go through `/upload/image`.
Dropped the `original_ref` form field and the `clipspace` subfolder.
`uploadLayer` throws on a non-ok status, on a non-JSON response body,
and on a 200 response missing `data.name` (no more silent fallback to
the pre-upload ref).
- `usePainter.ts`: removed the runtime branch on upload type/subfolder
and on the returned widget value. Always uploads as `type: input` with
no subfolder; widget value is `${filename} [input]`. Added a `data.name`
guard alongside the existing non-ok and JSON-parse guards.
- `usePainter.test.ts`: assert the upload FormData carries `type=input`
with no `subfolder`, plus new coverage for the missing-name and
JSON-parse-failure error branches.
- `browser_tests/tests/maskEditor.spec.ts`: drop the now-dead
`**/upload/mask` Playwright route interceptors and tighten the
save-success assertion to confirm exactly four `/upload/image` calls
(one per layer).
- **Breaking**: yes for downstream consumers — the mask editor no longer
calls `/upload/mask`, and saved widget values for both editors no longer
contain a subfolder prefix. Existing nodes will continue to load their
referenced inputs because the filename is preserved; new saves emit the
unified shape.

## Review Focus

- Confirm the mask editor's four-layer upload (masked, paint, painted,
paintedMasked) is still correct without `original_ref` — the layers are
now independent uploads rather than alpha-composited server-side. The
four blobs are composited client-side in `prepareOutputData` before
upload, so the previous `original_ref` chain was vestigial — but worth a
second look.
- OSS-side painter widget back-compat: previously stored as
`painter/foo.png [temp]`, new saves emit `foo.png [input]`. The old
format remains valid in saved workflows; only the *new save* shape
changes. Loading a pre-existing OSS workflow with a `painter/foo.png
[temp]` widget value still resolves via the existing parser path.

## Test plan

Unit:
- [x] `usePainter.test.ts` — 30 tests pass, including the three new ones
covering FormData payload shape, missing `data.name`, and bad JSON.

E2E:
- [x] `browser_tests/tests/maskEditor.spec.ts` — `save uploads all
layers and closes dialog` and `save failure keeps dialog open` updated
to the unified contract.

Smoke-tested manually against a Cloud staging instance:
- [x] **Painter node**: paint strokes → save → reload workflow → widget
value resolves and the canvas re-renders cleanly. Upload responds with
`subfolder: ""`, `type: "input"`.
- [x] **Mask editor**: open editor with a base image, paint + mask,
save. All four layers (`clipspace-mask-*`, `clipspace-paint-*`,
`clipspace-painted-*`, `clipspace-painted-masked-*`) POST successfully
to `/upload/image` and return 200 with the asset-aware response shape
(`{ name, subfolder: "", type: "input", asset: { id, hash, tags } }`).
The resulting `LoadImage` node references the painted-masked filename by
basename only and re-renders.

Not validated here: OSS ComfyUI core-side smoke. The contract is
symmetric (the OSS `/upload/image` endpoint accepts the same fields the
FE now sends), but a smoke against OSS HEAD is recommended before merge.

---------

Co-authored-by: GitHub Action <action@github.com>
2026-06-23 16:54:59 +00:00

88 lines
2.6 KiB
TypeScript

import { expect } from '@playwright/test'
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
interface UploadResponse {
name: string
subfolder: string
type: 'input' | 'output' | 'temp'
}
const IMAGE_CANVAS_INDEX = 0
const MASK_CANVAS_INDEX = 2
const successResponse = (name: string): UploadResponse => ({
name,
subfolder: 'clipspace',
type: 'input'
})
const fulfillJson = (body: UploadResponse) => ({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body)
})
test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
test('Save with drawn mask uploads non-empty mask data', async ({
comfyPage,
maskEditor
}) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeAndExpectPixels(dialog)
let observedContentType = ''
let observedBodyLength = 0
await comfyPage.page.route('**/upload/image', async (route) => {
const request = route.request()
if (!observedContentType) {
observedContentType = (await request.headerValue('content-type')) ?? ''
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
}
await route.fulfill(
fulfillJson(successResponse('clipspace-mask-123.png'))
)
})
await dialog.getByRole('button', { name: 'Save' }).click()
await expect(dialog).toBeHidden()
expect(observedContentType).toContain('multipart/form-data')
expect(observedBodyLength).toBeGreaterThan(256)
})
test('Canvas dimensions match the loaded image', async ({ maskEditor }) => {
const dialog = await maskEditor.openDialog()
const imageDimensions =
await maskEditor.getCanvasPixelData(IMAGE_CANVAS_INDEX)
const maskDimensions =
await maskEditor.getCanvasPixelData(MASK_CANVAS_INDEX)
expect(imageDimensions).not.toBeNull()
expect(maskDimensions).not.toBeNull()
expect(imageDimensions?.totalPixels).toBe(64 * 64)
expect(maskDimensions?.totalPixels).toBe(64 * 64)
await expect(dialog).toBeVisible()
})
test('Save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
const dialog = await maskEditor.openDialog()
await maskEditor.drawStrokeAndExpectPixels(dialog)
let imageUploadHit = false
await comfyPage.page.route('**/upload/image', (route) => {
imageUploadHit = true
return route.fulfill({ status: 500 })
})
const saveButton = dialog.getByRole('button', { name: 'Save' })
await saveButton.click()
await expect.poll(() => imageUploadHit).toBe(true)
await expect(dialog).toBeVisible()
await expect(saveButton).toBeVisible()
})
})