test: additional glsl coverage

- validate max preview size
- validate multipass
- validate image crossing subgraph boundary
This commit is contained in:
pythongosssss
2026-04-24 02:59:19 -07:00
parent 17d980dbc8
commit bf3ff83cac
2 changed files with 343 additions and 13 deletions

View File

@@ -0,0 +1,181 @@
{
"id": "ee111111-2222-4333-8444-000000000003",
"revision": 0,
"last_node_id": 2,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "LoadImage",
"pos": [50, 200],
"size": [315, 314],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [1] },
{ "name": "MASK", "type": "MASK", "links": null }
],
"properties": { "Node name for S&R": "LoadImage" },
"widgets_values": ["example.png", "image"]
},
{
"id": 1,
"type": "aa999999-8888-4777-a666-555555555557",
"pos": [500, 200],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "image0",
"type": "IMAGE",
"link": 1
}
],
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": null }],
"title": "GLSL Subgraph With Image",
"properties": {},
"widgets_values": []
}
],
"links": [[1, 2, 0, 1, 0, "IMAGE"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "aa999999-8888-4777-a666-555555555557",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "GLSL Subgraph With Image",
"inputNode": {
"id": -10,
"bounding": [50, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [900, 200, 120, 60]
},
"inputs": [
{
"id": "cc777777-6666-4555-a444-333333333333",
"name": "image0",
"type": "IMAGE",
"linkIds": [2],
"pos": { "0": 180, "1": 220 }
}
],
"outputs": [
{
"id": "bb888888-7777-4666-a555-444444444446",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [1],
"pos": { "0": 920, "1": 220 }
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "GLSLShader",
"pos": [400, 180],
"size": [460, 320],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"label": "image0",
"localized_name": "images.image0",
"name": "images.image0",
"shape": 7,
"type": "IMAGE",
"link": 2
},
{
"localized_name": "fragment_shader",
"name": "fragment_shader",
"type": "STRING",
"widget": { "name": "fragment_shader" },
"link": null
},
{
"localized_name": "size_mode",
"name": "size_mode",
"type": "COMFY_DYNAMICCOMBO_V3",
"widget": { "name": "size_mode" },
"link": null
}
],
"outputs": [
{
"localized_name": "IMAGE0",
"name": "IMAGE0",
"type": "IMAGE",
"links": [1]
},
{
"localized_name": "IMAGE1",
"name": "IMAGE1",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE2",
"name": "IMAGE2",
"type": "IMAGE",
"links": null
},
{
"localized_name": "IMAGE3",
"name": "IMAGE3",
"type": "IMAGE",
"links": null
}
],
"properties": { "Node name for S&R": "GLSLShader" },
"widgets_values": [
"#version 300 es\nprecision highp float;\nuniform sampler2D u_image0;\nin vec2 v_texCoord;\nlayout(location = 0) out vec4 fragColor0;\nvoid main() {\n fragColor0 = texture(u_image0, v_texCoord);\n}\n",
"from_input"
]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 2,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": { "offset": [0, 0], "scale": 1 }
},
"version": 0.4
}

View File

@@ -70,27 +70,27 @@ class GLSLShaderNode {
) {
this.node = comfyPage.vueNodes.getNodeLocator(nodeId)
this.previewImage = this.node.locator('img[src^="blob:"]')
this.shaderTextbox = this.node.getByRole('textbox', {
name: 'fragment_shader'
})
this.widthInput = this.node
.getByLabel('size_mode.width', { exact: true })
this.shaderTextbox = comfyPage.vueNodes.getWidgetByName(
title,
'fragment_shader'
)
this.widthInput = comfyPage.vueNodes
.getWidgetByName(title, 'size_mode.width')
.locator('input')
this.heightInput = this.node
.getByLabel('size_mode.height', { exact: true })
this.heightInput = comfyPage.vueNodes
.getWidgetByName(title, 'size_mode.height')
.locator('input')
}
/**
* Fire `execution_start` + `executed` with an image output for this node,
* which satisfies the `hasExecutionOutput` gate in `useGLSLPreview`.
*/
async simulateExecutionOutput(ws: WebSocketRoute) {
async simulateExecutionOutput(
ws: WebSocketRoute,
executionNodeId: string = this.nodeId
) {
const exec = new ExecutionHelper(this.comfyPage, ws)
const jobId = await exec.run()
await this.comfyPage.nextFrame()
exec.executionStart(jobId)
exec.executed(jobId, this.nodeId, {
exec.executed(jobId, executionNodeId, {
images: [{ filename: 'glsl_test.png', subfolder: '', type: 'output' }]
})
exec.executionSuccess(jobId)
@@ -587,4 +587,153 @@ test.describe('GLSL Shader Preview', { tag: ['@vue-nodes', '@node'] }, () => {
await subgraph.expectEveryPixelToBe([255, 0, 0, 255])
})
})
test.describe('renderer paths', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('nodes/glsl_shader_standalone')
await comfyPage.vueNodes.waitForNodes(1)
})
test('scales custom resolution down to the max preview dimension', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
await glsl.selectSizeMode('custom')
await expect(glsl.widthInput).toBeVisible()
await glsl.widthInput.fill('2048')
await glsl.widthInput.blur()
await glsl.heightInput.fill('1024')
await glsl.heightInput.blur()
await glsl.simulateExecutionOutput(ws)
await glsl.waitForBlobSrc()
// MAX_PREVIEW_DIMENSION is 1024 → clampResolution scales 2048x1024 to 1024x512.
await expect
.poll(() => glsl.getPreviewNaturalSize())
.toEqual({ width: 1024, height: 512 })
})
test('runs a multi-pass shader via #pragma passes', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
// Pass 0 writes red to the ping-pong FBO; pass 1 swizzles it to green.
// If pass 0 didn't run, u_image0 is the black fallback and output is black.
const MULTIPASS_SHADER = [
'#version 300 es',
'#pragma passes 2',
'precision highp float;',
'uniform int u_pass;',
'uniform sampler2D u_image0;',
'in vec2 v_texCoord;',
'layout(location = 0) out vec4 fragColor0;',
'void main() {',
' if (u_pass == 0) {',
' fragColor0 = vec4(1.0, 0.0, 0.0, 1.0);',
' } else {',
' vec4 prev = texture(u_image0, v_texCoord);',
' fragColor0 = vec4(prev.g, prev.r, 0.0, 1.0);',
' }',
'}'
].join('\n')
await glsl.shaderTextbox.fill(MULTIPASS_SHADER)
await glsl.simulateExecutionOutput(ws)
await glsl.waitForBlobSrc()
// RGBA16F → WebP round-trip drifts a couple of levels on saturated channels.
await glsl.expectEveryPixelToBe([0, 255, 0, 255], 2)
})
test('revokes the preview blob URL when the GLSL node is removed', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
const glsl = new GLSLShaderNode(comfyPage, GLSL_NODE_ID, GLSL_NODE_TITLE)
await glsl.simulateExecutionOutput(ws)
const blobUrl = await glsl.waitForBlobSrc()
// Sanity check: the blob URL resolves before the node is deleted.
await expect(
comfyPage.page.evaluate((url) => fetch(url), blobUrl)
).resolves.toBeTruthy()
await comfyPage.vueNodes.deleteNode(GLSL_NODE_ID)
await expect(glsl.node).toHaveCount(0)
// Delete tears down the inner scope → URL.revokeObjectURL → fetch rejects.
await expect
.poll(() =>
comfyPage.page.evaluate(async (url) => {
try {
await fetch(url)
return 'resolved'
} catch {
return 'rejected'
}
}, blobUrl)
)
.toBe('rejected')
})
})
test.describe('inner GLSL sampling image through subgraph boundary', () => {
// Outer ids come from the workflow JSON; inner ids are reassigned on load.
const OUTER_LOAD_IMAGE_NODE_ID = '2'
const OUTER_SUBGRAPH_NODE_ID = '1'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'nodes/glsl_shader_subgraph_with_loadimage'
)
await comfyPage.vueNodes.waitForNodes(2)
})
test('inner GLSL node reads upstream image via subgraph boundary', async ({
comfyPage,
getWebSocket
}) => {
const ws = await getWebSocket()
await dropImageOntoLoadImage(
comfyPage,
OUTER_LOAD_IMAGE_NODE_ID,
'image64x64.webp'
)
// Enter the subgraph so the inner GLSL node mounts its LGraphNode.vue
await comfyPage.vueNodes.enterSubgraph(OUTER_SUBGRAPH_NODE_ID)
await comfyPage.vueNodes.waitForNodes(1)
const innerNode = comfyPage.vueNodes
.getNodeByTitle(GLSL_NODE_TITLE)
.first()
await innerNode.waitFor({ state: 'visible' })
const innerGlslId = await innerNode.getAttribute('data-node-id')
if (!innerGlslId) {
throw new Error('inner GLSLShader node is missing data-node-id')
}
const glsl = new GLSLShaderNode(comfyPage, innerGlslId, GLSL_NODE_TITLE)
await glsl.simulateExecutionOutput(
ws,
`${OUTER_SUBGRAPH_NODE_ID}:${innerGlslId}`
)
await expect(glsl.previewImage).toBeVisible()
await glsl.waitForBlobSrc()
await expect
.poll(() => glsl.getPreviewNaturalSize())
.toEqual({ width: 64, height: 64 })
})
})
})