mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-11 16:30:57 +00:00
## Summary Adds test coverage for webcam node & fixes issue found in testing where the captured image does not show in nodes 2.0 ## Changes - **What**: - call `setNodePreviewsByNodeId` alongside `node.imgs = [img]` - add tests for general coverage ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11549-fix-fix-webcam-node-not-showing-preview-in-nodes-2-0-34a6d73d3650810c89eee9c25cd07700) by [Unito](https://www.unito.io) --------- Co-authored-by: GitHub Action <action@github.com>
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
import type {
|
|
PromptResponse,
|
|
UploadImageResponse
|
|
} from '@comfyorg/ingest-types'
|
|
import { expect } from '@playwright/test'
|
|
import type { Locator, Page, Request } from '@playwright/test'
|
|
|
|
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
|
|
|
const NODE_TITLE = 'Webcam Capture'
|
|
|
|
function denyCameraAccess(page: Page): Promise<void> {
|
|
return page.evaluate(() => {
|
|
Object.defineProperty(navigator, 'mediaDevices', {
|
|
configurable: true,
|
|
value: {
|
|
getUserMedia: () =>
|
|
Promise.reject(new Error('Permission denied by test'))
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
function holdCameraAccess(page: Page): Promise<void> {
|
|
return page.evaluate(() => {
|
|
Object.defineProperty(navigator, 'mediaDevices', {
|
|
configurable: true,
|
|
value: { getUserMedia: () => new Promise(() => {}) }
|
|
})
|
|
})
|
|
}
|
|
|
|
function denyAccessInInsecureContext(page: Page): Promise<void> {
|
|
return page.evaluate(() => {
|
|
Object.defineProperty(window, 'isSecureContext', {
|
|
configurable: true,
|
|
value: false
|
|
})
|
|
Object.defineProperty(navigator, 'mediaDevices', {
|
|
configurable: true,
|
|
value: {
|
|
getUserMedia: () =>
|
|
Promise.reject(new Error('Insecure context rejection'))
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
async function parseMultipartRequest(request: Request): Promise<FormData> {
|
|
const body = request.postDataBuffer()
|
|
if (!body) throw new Error('request has no body')
|
|
return new Response(new Uint8Array(body), {
|
|
headers: { 'content-type': request.headers()['content-type'] ?? '' }
|
|
}).formData()
|
|
}
|
|
|
|
/**
|
|
* Stub /upload/image + /api/prompt so queueing succeeds without a backend.
|
|
* Returns the mutable list of captured upload requests - callers poll its
|
|
* length to wait for the upload to fire.
|
|
*/
|
|
async function captureUploadsDuringQueue(page: Page): Promise<Request[]> {
|
|
const uploadRequests: Request[] = []
|
|
const uploadResponse: UploadImageResponse = {
|
|
name: 'captured.png',
|
|
subfolder: 'webcam',
|
|
type: 'temp'
|
|
}
|
|
const promptResponse: PromptResponse = {
|
|
prompt_id: 'test',
|
|
number: 1,
|
|
node_errors: {}
|
|
}
|
|
await page.route('**/upload/image', (route) => {
|
|
uploadRequests.push(route.request())
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(uploadResponse)
|
|
})
|
|
})
|
|
await page.route('**/api/prompt', (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify(promptResponse)
|
|
})
|
|
)
|
|
return uploadRequests
|
|
}
|
|
|
|
class WebcamCaptureFixture {
|
|
public readonly node: Locator
|
|
public readonly captureButton: Locator
|
|
public readonly waitingButton: Locator
|
|
public readonly errorMessage: Locator
|
|
public readonly widthInput: Locator
|
|
public readonly heightInput: Locator
|
|
public readonly captureOnQueueToggle: Locator
|
|
public readonly previewImage: Locator
|
|
|
|
constructor(comfyPage: ComfyPage) {
|
|
const { vueNodes } = comfyPage
|
|
this.node = vueNodes.getNodeByTitle(NODE_TITLE)
|
|
this.captureButton = this.node.getByRole('button', {
|
|
name: 'capture',
|
|
exact: true
|
|
})
|
|
this.waitingButton = this.node.getByRole('button', {
|
|
name: 'waiting for camera...',
|
|
exact: true
|
|
})
|
|
this.errorMessage = this.node.getByText('Unable to load webcam')
|
|
this.widthInput = vueNodes.getInputNumberControls(
|
|
vueNodes.getWidgetByName(NODE_TITLE, 'width')
|
|
).input
|
|
this.heightInput = vueNodes.getInputNumberControls(
|
|
vueNodes.getWidgetByName(NODE_TITLE, 'height')
|
|
).input
|
|
this.captureOnQueueToggle = this.node.getByRole('switch', {
|
|
name: 'capture_on_queue'
|
|
})
|
|
this.previewImage = this.node.locator('img[src^="data:image/png"]').first()
|
|
}
|
|
|
|
async waitForStreamReady(): Promise<void> {
|
|
await expect(this.captureButton).toBeEnabled()
|
|
}
|
|
}
|
|
|
|
test.use({
|
|
launchOptions: {
|
|
args: [
|
|
'--use-fake-device-for-media-stream',
|
|
'--use-fake-ui-for-media-stream'
|
|
]
|
|
}
|
|
})
|
|
|
|
test.describe(
|
|
'Webcam Capture',
|
|
{ tag: ['@widget', '@canvas', '@vue-nodes'] },
|
|
() => {
|
|
test('enables the capture button once the stream is ready', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('nodes/webcam_capture')
|
|
const webcam = new WebcamCaptureFixture(comfyPage)
|
|
|
|
await expect(webcam.captureButton).toBeEnabled()
|
|
await expect(webcam.waitingButton).toBeHidden()
|
|
await expect(webcam.errorMessage).toBeHidden()
|
|
})
|
|
|
|
test('auto-populates width and height from the video stream', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('nodes/webcam_capture')
|
|
const webcam = new WebcamCaptureFixture(comfyPage)
|
|
|
|
// Workflow ships with width/height set to 0; the extension overwrites
|
|
// them with the resolved track's intrinsic resolution once the stream
|
|
// loads so users aren't left with a 0x0 capture surface.
|
|
await webcam.waitForStreamReady()
|
|
await expect(webcam.widthInput).not.toHaveValue('0')
|
|
await expect(webcam.heightInput).not.toHaveValue('0')
|
|
})
|
|
|
|
test('shows the waiting state while the permission prompt is pending', async ({
|
|
comfyPage
|
|
}) => {
|
|
await holdCameraAccess(comfyPage.page)
|
|
await comfyPage.workflow.loadWorkflow('nodes/webcam_capture')
|
|
const webcam = new WebcamCaptureFixture(comfyPage)
|
|
|
|
await expect(webcam.waitingButton).toBeDisabled()
|
|
await expect(webcam.captureButton).toBeHidden()
|
|
await expect(webcam.errorMessage).toBeHidden()
|
|
})
|
|
|
|
test('surfaces the underlying rejection reason when denied', async ({
|
|
comfyPage
|
|
}) => {
|
|
await denyCameraAccess(comfyPage.page)
|
|
await comfyPage.workflow.loadWorkflow('nodes/webcam_capture')
|
|
const webcam = new WebcamCaptureFixture(comfyPage)
|
|
|
|
await expect(webcam.errorMessage).toBeVisible()
|
|
await expect(
|
|
webcam.errorMessage.filter({ hasText: 'Permission denied by test' })
|
|
).toBeVisible()
|
|
await expect(webcam.waitingButton).toBeDisabled()
|
|
await expect(webcam.captureButton).toBeHidden()
|
|
})
|
|
|
|
test('auto-captures and uploads a frame when queued with capture-on-queue', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('nodes/webcam_capture')
|
|
const webcam = new WebcamCaptureFixture(comfyPage)
|
|
await webcam.waitForStreamReady()
|
|
await expect(
|
|
webcam.captureOnQueueToggle,
|
|
'workflow asset ships with capture_on_queue enabled'
|
|
).toBeChecked()
|
|
|
|
const uploads = await captureUploadsDuringQueue(comfyPage.page)
|
|
await comfyPage.runButton.click()
|
|
|
|
await expect.poll(() => uploads.length).toBeGreaterThan(0)
|
|
const form = await parseMultipartRequest(uploads[0])
|
|
expect(form.get('subfolder')).toBe('webcam')
|
|
expect(form.get('type')).toBe('temp')
|
|
expect(form.get('image')).toBeInstanceOf(Blob)
|
|
})
|
|
|
|
test('renders a preview image inside the node after clicking capture', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('nodes/webcam_capture')
|
|
const webcam = new WebcamCaptureFixture(comfyPage)
|
|
await webcam.waitForStreamReady()
|
|
|
|
await expect(webcam.previewImage).toBeHidden()
|
|
await webcam.captureButton.click()
|
|
await expect(webcam.previewImage).toBeVisible()
|
|
})
|
|
|
|
test('re-clicking capture replaces the preview with a fresh frame', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('nodes/webcam_capture')
|
|
const webcam = new WebcamCaptureFixture(comfyPage)
|
|
await webcam.waitForStreamReady()
|
|
|
|
await webcam.captureButton.click()
|
|
await expect(webcam.previewImage).toBeVisible()
|
|
const firstSrc = await webcam.previewImage.getAttribute('src')
|
|
|
|
// Chromium's fake device cycles frame content, so a second capture a
|
|
// moment later must produce a different data URL.
|
|
await expect
|
|
.poll(async () => {
|
|
await webcam.captureButton.click()
|
|
return webcam.previewImage.getAttribute('src')
|
|
})
|
|
.not.toBe(firstSrc)
|
|
})
|
|
|
|
test('uploads the manually captured frame when queued with capture-on-queue off', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('nodes/webcam_capture')
|
|
const webcam = new WebcamCaptureFixture(comfyPage)
|
|
await webcam.waitForStreamReady()
|
|
await webcam.captureOnQueueToggle.click()
|
|
await expect(
|
|
webcam.captureOnQueueToggle,
|
|
'precondition: capture_on_queue toggled off'
|
|
).not.toBeChecked()
|
|
|
|
await webcam.captureButton.click()
|
|
await expect(webcam.previewImage).toBeVisible()
|
|
|
|
const uploads = await captureUploadsDuringQueue(comfyPage.page)
|
|
await comfyPage.runButton.click()
|
|
await expect.poll(() => uploads.length).toBeGreaterThan(0)
|
|
const form = await parseMultipartRequest(uploads[0])
|
|
const uploaded = form.get('image')
|
|
if (!(uploaded instanceof Blob))
|
|
throw new Error('uploaded image is not a Blob')
|
|
const uploadedBase64 = Buffer.from(await uploaded.arrayBuffer()).toString(
|
|
'base64'
|
|
)
|
|
await expect(webcam.previewImage).toHaveAttribute(
|
|
'src',
|
|
`data:image/png;base64,${uploadedBase64}`
|
|
)
|
|
})
|
|
|
|
test('explains the secure-context requirement on insecure origins', async ({
|
|
comfyPage
|
|
}) => {
|
|
await denyAccessInInsecureContext(comfyPage.page)
|
|
await comfyPage.workflow.loadWorkflow('nodes/webcam_capture')
|
|
const webcam = new WebcamCaptureFixture(comfyPage)
|
|
|
|
await expect(
|
|
webcam.errorMessage.filter({ hasText: 'secure context is required' })
|
|
).toBeVisible()
|
|
await expect(
|
|
webcam.errorMessage.filter({ hasText: 'Insecure context rejection' })
|
|
).toBeVisible()
|
|
})
|
|
|
|
test('preserves user-set width and height across stream ready', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('nodes/webcam_capture_preset')
|
|
const webcam = new WebcamCaptureFixture(comfyPage)
|
|
await webcam.waitForStreamReady()
|
|
|
|
await expect(webcam.widthInput).toHaveValue('123')
|
|
await expect(webcam.heightInput).toHaveValue('456')
|
|
})
|
|
|
|
test('surfaces an error toast when the upload fails', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('nodes/webcam_capture')
|
|
const webcam = new WebcamCaptureFixture(comfyPage)
|
|
await webcam.waitForStreamReady()
|
|
|
|
await comfyPage.page.route('**/upload/image', (route) =>
|
|
route.fulfill({ status: 500, body: 'Server exploded' })
|
|
)
|
|
await comfyPage.runButton.click()
|
|
|
|
await expect(
|
|
comfyPage.toast.visibleToasts
|
|
.filter({ hasText: 'Error uploading camera image' })
|
|
.first()
|
|
).toBeVisible()
|
|
})
|
|
|
|
test('alerts the user when queued with no captured image', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow('nodes/webcam_capture')
|
|
const webcam = new WebcamCaptureFixture(comfyPage)
|
|
await webcam.waitForStreamReady()
|
|
await webcam.captureOnQueueToggle.click()
|
|
await expect(
|
|
webcam.captureOnQueueToggle,
|
|
'precondition: capture_on_queue toggled off'
|
|
).not.toBeChecked()
|
|
|
|
let uploadCalled = false
|
|
await comfyPage.page.route('**/upload/image', (route) => {
|
|
uploadCalled = true
|
|
return route.fulfill({ status: 200, body: '{}' })
|
|
})
|
|
|
|
await comfyPage.runButton.click()
|
|
|
|
await expect(
|
|
comfyPage.toast.visibleToasts
|
|
.filter({ hasText: 'No webcam image captured' })
|
|
.first()
|
|
).toBeVisible()
|
|
expect(uploadCalled).toBe(false)
|
|
})
|
|
}
|
|
)
|