diff --git a/browser_tests/tests/nodeHelp.spec.ts b/browser_tests/tests/nodeHelp.spec.ts new file mode 100644 index 000000000..b4e252e77 --- /dev/null +++ b/browser_tests/tests/nodeHelp.spec.ts @@ -0,0 +1,556 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../fixtures/ComfyPage' + +// TODO: there might be a better solution for this +// Helper function to pan canvas and select node +async function selectNodeWithPan(comfyPage: any, nodeRef: any) { + const nodePos = await nodeRef.getPosition() + + await comfyPage.page.evaluate((pos) => { + const app = window['app'] + const canvas = app.canvas + canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2 + canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100 + canvas.setDirty(true, true) + }, nodePos) + + await comfyPage.nextFrame() + await nodeRef.click('title') +} + +test.describe('Node Help', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setup() + await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') + }) + + test.describe('Selection Toolbox', () => { + test('Should open help menu for selected node', async ({ comfyPage }) => { + // Load a workflow with a node + await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true) + await comfyPage.loadWorkflow('default') + + // Select a single node (KSampler) using node references + const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler') + if (ksamplerNodes.length === 0) { + throw new Error('No KSampler nodes found in the workflow') + } + + // Select the node with panning to ensure toolbox is visible + await selectNodeWithPan(comfyPage, ksamplerNodes[0]) + + // Wait for selection overlay container and toolbox to appear + await expect( + comfyPage.page.locator('.selection-overlay-container') + ).toBeVisible() + await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible() + + // Click the help button in the selection toolbox + const helpButton = comfyPage.page.locator( + '.selection-toolbox button:has(.pi-question-circle)' + ) + await expect(helpButton).toBeVisible() + await helpButton.click() + + // Verify that the node library sidebar is opened + await expect( + comfyPage.menu.nodeLibraryTab.selectedTabButton + ).toBeVisible() + + // Verify that the help page is shown for the correct node + const helpPage = comfyPage.page.locator('.sidebar-content-container') + await expect(helpPage).toContainText('KSampler') + await expect(helpPage.locator('.node-help-content')).toBeVisible() + }) + }) + + test.describe('Node Library Sidebar', () => { + test('Should open help menu from node library', async ({ comfyPage }) => { + // Open the node library sidebar + await comfyPage.menu.nodeLibraryTab.open() + + // Wait for node library to load + await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible() + + // Search for KSampler to make it easier to find + await comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput.fill( + 'KSampler' + ) + + // Find the KSampler node in search results + const ksamplerNode = comfyPage.page + .locator('.tree-explorer-node-label') + .filter({ hasText: 'KSampler' }) + .first() + await expect(ksamplerNode).toBeVisible() + + // Hover over the node to show action buttons + await ksamplerNode.hover() + + // Click the help button + const helpButton = ksamplerNode.locator('button:has(.pi-question)') + await expect(helpButton).toBeVisible() + await helpButton.click() + + // Verify that the help page is shown + const helpPage = comfyPage.page.locator('.sidebar-content-container') + await expect(helpPage).toContainText('KSampler') + await expect(helpPage.locator('.node-help-content')).toBeVisible() + }) + + test('Should show node library tab when clicking back from help page', async ({ + comfyPage + }) => { + // Open the node library sidebar + await comfyPage.menu.nodeLibraryTab.open() + + // Wait for node library to load + await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible() + + // Search for KSampler + await comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput.fill( + 'KSampler' + ) + + // Find and interact with the node + const ksamplerNode = comfyPage.page + .locator('.tree-explorer-node-label') + .filter({ hasText: 'KSampler' }) + .first() + await ksamplerNode.hover() + const helpButton = ksamplerNode.locator('button:has(.pi-question)') + await helpButton.click() + + // Verify help page is shown + const helpPage = comfyPage.page.locator('.sidebar-content-container') + await expect(helpPage).toContainText('KSampler') + + // Click the back button - use a more specific selector + const backButton = comfyPage.page.locator('button:has(.pi-arrow-left)') + await expect(backButton).toBeVisible() + await backButton.click() + + // Verify that we're back to the node library view + await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible() + await expect( + comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput + ).toBeVisible() + + // Verify help page is no longer visible + await expect(helpPage.locator('.node-help-content')).not.toBeVisible() + }) + }) + + test.describe('Help Content', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true) + }) + + test('Should display loading state while fetching help', async ({ + comfyPage + }) => { + // Mock slow network response + await comfyPage.page.route('**/docs/**/*.md', async (route) => { + await new Promise((resolve) => setTimeout(resolve, 1000)) + await route.fulfill({ + status: 200, + body: '# Test Help Content\nThis is test help content.' + }) + }) + + // Load workflow and select a node + await comfyPage.loadWorkflow('default') + const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler') + await selectNodeWithPan(comfyPage, ksamplerNodes[0]) + + // Click help button + const helpButton = comfyPage.page.locator( + '.selection-toolbox button:has(.pi-question-circle)' + ) + await helpButton.click() + + // Verify loading spinner is shown + const helpPage = comfyPage.page.locator('.sidebar-content-container') + await expect(helpPage.locator('.p-progressspinner')).toBeVisible() + + // Wait for content to load + await expect(helpPage).toContainText('Test Help Content') + }) + + test('Should display fallback content when help file not found', async ({ + comfyPage + }) => { + // Mock 404 response for help files + await comfyPage.page.route('**/docs/**/*.md', async (route) => { + await route.fulfill({ + status: 404, + body: 'Not Found' + }) + }) + + // Load workflow and select a node + await comfyPage.loadWorkflow('default') + const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler') + await selectNodeWithPan(comfyPage, ksamplerNodes[0]) + + // Click help button + const helpButton = comfyPage.page.locator( + '.selection-toolbox button:has(.pi-question-circle)' + ) + await helpButton.click() + + // Verify fallback content is shown (description, inputs, outputs) + const helpPage = comfyPage.page.locator('.sidebar-content-container') + await expect(helpPage).toContainText('Description') + await expect(helpPage).toContainText('Inputs') + await expect(helpPage).toContainText('Outputs') + }) + + test('Should render markdown with images correctly', async ({ + comfyPage + }) => { + // Mock response with markdown containing images + await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => { + await route.fulfill({ + status: 200, + body: `# KSampler Documentation + +![Example Image](example.jpg) +![External Image](https://example.com/image.png) + +## Parameters +- **steps**: Number of steps +` + }) + }) + + await comfyPage.loadWorkflow('default') + const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler') + await selectNodeWithPan(comfyPage, ksamplerNodes[0]) + + const helpButton = comfyPage.page.locator( + '.selection-toolbox button:has(.pi-question-circle)' + ) + await helpButton.click() + + const helpPage = comfyPage.page.locator('.sidebar-content-container') + await expect(helpPage).toContainText('KSampler Documentation') + + // Check that relative image paths are prefixed correctly + const relativeImage = helpPage.locator('img[alt="Example Image"]') + await expect(relativeImage).toBeVisible() + await expect(relativeImage).toHaveAttribute( + 'src', + /.*\/docs\/KSampler\/example\.jpg/ + ) + + // Check that absolute URLs are not modified + const externalImage = helpPage.locator('img[alt="External Image"]') + await expect(externalImage).toHaveAttribute( + 'src', + 'https://example.com/image.png' + ) + }) + + test('Should render video elements with source tags in markdown', async ({ + comfyPage + }) => { + // Mock response with video elements + await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => { + await route.fulfill({ + status: 200, + body: `# KSampler Demo + + + + + +` + }) + }) + + await comfyPage.loadWorkflow('default') + const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler') + await selectNodeWithPan(comfyPage, ksamplerNodes[0]) + + const helpButton = comfyPage.page.locator( + '.selection-toolbox button:has(.pi-question-circle)' + ) + await helpButton.click() + + const helpPage = comfyPage.page.locator('.sidebar-content-container') + + // Check relative video paths are prefixed + const relativeVideo = helpPage.locator('video[src*="demo.mp4"]') + await expect(relativeVideo).toBeVisible() + await expect(relativeVideo).toHaveAttribute( + 'src', + /.*\/docs\/KSampler\/demo\.mp4/ + ) + await expect(relativeVideo).toHaveAttribute('controls', '') + await expect(relativeVideo).toHaveAttribute('autoplay', '') + + // Check absolute paths are not modified + const absoluteVideo = helpPage.locator('video[src="/absolute/video.mp4"]') + await expect(absoluteVideo).toHaveAttribute('src', '/absolute/video.mp4') + + // Check video source elements + const relativeVideoSource = helpPage.locator('source[src*="video.mp4"]') + await expect(relativeVideoSource).toHaveAttribute( + 'src', + /.*\/docs\/KSampler\/video\.mp4/ + ) + + const externalVideoSource = helpPage.locator( + 'source[src="https://example.com/video.webm"]' + ) + await expect(externalVideoSource).toHaveAttribute( + 'src', + 'https://example.com/video.webm' + ) + }) + + test('Should handle custom node documentation paths', async ({ + comfyPage + }) => { + // First load workflow with custom node + await comfyPage.loadWorkflow('group_node_v1.3.3') + + // Mock custom node documentation with fallback + await comfyPage.page.route( + '**/extensions/*/docs/*/en.md', + async (route) => { + await route.fulfill({ status: 404 }) + } + ) + + await comfyPage.page.route('**/extensions/*/docs/*.md', async (route) => { + await route.fulfill({ + status: 200, + body: `# Custom Node Documentation + +This is documentation for a custom node. + +![Custom Image](assets/custom.png) +` + }) + }) + + // Find and select a custom/group node + const nodeRefs = await comfyPage.page.evaluate(() => { + return window['app'].graph.nodes.map((n: any) => n.id) + }) + if (nodeRefs.length > 0) { + const firstNode = await comfyPage.getNodeRefById(nodeRefs[0]) + await selectNodeWithPan(comfyPage, firstNode) + } + + const helpButton = comfyPage.page.locator( + '.selection-toolbox button:has(.pi-question-circle)' + ) + if (await helpButton.isVisible()) { + await helpButton.click() + + const helpPage = comfyPage.page.locator('.sidebar-content-container') + await expect(helpPage).toContainText('Custom Node Documentation') + + // Check image path for custom nodes + const image = helpPage.locator('img[alt="Custom Image"]') + await expect(image).toHaveAttribute( + 'src', + /.*\/extensions\/.*\/docs\/assets\/custom\.png/ + ) + } + }) + + test('Should sanitize dangerous HTML content', async ({ comfyPage }) => { + // Mock response with potentially dangerous content + await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => { + await route.fulfill({ + status: 200, + body: `# Safe Content + + + +Dangerous Link + + + + +Safe Image +` + }) + }) + + await comfyPage.loadWorkflow('default') + const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler') + await selectNodeWithPan(comfyPage, ksamplerNodes[0]) + + const helpButton = comfyPage.page.locator( + '.selection-toolbox button:has(.pi-question-circle)' + ) + await helpButton.click() + + const helpPage = comfyPage.page.locator('.sidebar-content-container') + + // Dangerous elements should be removed + await expect(helpPage.locator('script')).toHaveCount(0) + await expect(helpPage.locator('iframe')).toHaveCount(0) + + // Check that onerror attribute is removed + const images = helpPage.locator('img') + const imageCount = await images.count() + for (let i = 0; i < imageCount; i++) { + const img = images.nth(i) + const onError = await img.getAttribute('onerror') + expect(onError).toBeNull() + } + + // Check that javascript: links are sanitized + const links = helpPage.locator('a') + const linkCount = await links.count() + for (let i = 0; i < linkCount; i++) { + const link = links.nth(i) + const href = await link.getAttribute('href') + if (href !== null) { + expect(href).not.toContain('javascript:') + } + } + + // Safe content should remain + await expect(helpPage.locator('video[src*="safe.mp4"]')).toBeVisible() + await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible() + }) + + test('Should handle locale-specific documentation', async ({ + comfyPage + }) => { + // Mock different responses for different locales + await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => { + await route.fulfill({ + status: 200, + body: `# KSamplerノード + +これは日本語のドキュメントです。 +` + }) + }) + + await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => { + await route.fulfill({ + status: 200, + body: `# KSampler Node + +This is English documentation. +` + }) + }) + + // Set locale to Japanese + await comfyPage.setSetting('Comfy.Locale', 'ja') + + await comfyPage.loadWorkflow('default') + const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler') + await selectNodeWithPan(comfyPage, ksamplerNodes[0]) + + const helpButton = comfyPage.page.locator( + '.selection-toolbox button:has(.pi-question-circle)' + ) + await helpButton.click() + + const helpPage = comfyPage.page.locator('.sidebar-content-container') + await expect(helpPage).toContainText('KSamplerノード') + await expect(helpPage).toContainText('これは日本語のドキュメントです') + + // Reset locale + await comfyPage.setSetting('Comfy.Locale', 'en') + }) + + test('Should handle network errors gracefully', async ({ comfyPage }) => { + // Mock network error + await comfyPage.page.route('**/docs/**/*.md', async (route) => { + await route.abort('failed') + }) + + await comfyPage.loadWorkflow('default') + const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler') + await selectNodeWithPan(comfyPage, ksamplerNodes[0]) + + const helpButton = comfyPage.page.locator( + '.selection-toolbox button:has(.pi-question-circle)' + ) + await helpButton.click() + + const helpPage = comfyPage.page.locator('.sidebar-content-container') + + // Should show fallback content (node description) + await expect(helpPage).toBeVisible() + await expect(helpPage.locator('.p-progressspinner')).not.toBeVisible() + + // Should show some content even on error + const content = await helpPage.textContent() + expect(content).toBeTruthy() + }) + + test('Should update help content when switching between nodes', async ({ + comfyPage + }) => { + // Mock different help content for different nodes + await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => { + await route.fulfill({ + status: 200, + body: '# KSampler Help\n\nThis is KSampler documentation.' + }) + }) + + await comfyPage.page.route( + '**/docs/CheckpointLoaderSimple/en.md', + async (route) => { + await route.fulfill({ + status: 200, + body: '# Checkpoint Loader Help\n\nThis is Checkpoint Loader documentation.' + }) + } + ) + + await comfyPage.loadWorkflow('default') + + // Select KSampler first + const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler') + await selectNodeWithPan(comfyPage, ksamplerNodes[0]) + + const helpButton = comfyPage.page.locator( + '.selection-toolbox button:has(.pi-question-circle)' + ) + await helpButton.click() + + const helpPage = comfyPage.page.locator('.sidebar-content-container') + await expect(helpPage).toContainText('KSampler Help') + await expect(helpPage).toContainText('This is KSampler documentation') + + // Now select Checkpoint Loader + const checkpointNodes = await comfyPage.getNodeRefsByType( + 'CheckpointLoaderSimple' + ) + await selectNodeWithPan(comfyPage, checkpointNodes[0]) + + // Click help button again + const helpButton2 = comfyPage.page.locator( + '.selection-toolbox button:has(.pi-question-circle)' + ) + await helpButton2.click() + + // Content should update + await expect(helpPage).toContainText('Checkpoint Loader Help') + await expect(helpPage).toContainText( + 'This is Checkpoint Loader documentation' + ) + await expect(helpPage).not.toContainText('KSampler documentation') + }) + }) +}) diff --git a/package-lock.json b/package-lock.json index 01e3a4f89..6fd2cbb76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,12 +29,14 @@ "@xterm/xterm": "^5.5.0", "algoliasearch": "^5.21.0", "axios": "^1.8.2", + "dompurify": "^3.2.5", "dotenv": "^16.4.5", "firebase": "^11.6.0", "fuse.js": "^7.0.0", "jsondiffpatch": "^0.6.0", "lodash": "^4.17.21", "loglevel": "^1.9.2", + "marked": "^15.0.11", "pinia": "^2.1.7", "primeicons": "^7.0.0", "primevue": "^4.2.5", @@ -55,6 +57,7 @@ "@pinia/testing": "^0.1.5", "@playwright/test": "^1.44.1", "@trivago/prettier-plugin-sort-imports": "^5.2.0", + "@types/dompurify": "^3.0.5", "@types/fs-extra": "^11.0.4", "@types/lodash": "^4.17.6", "@types/node": "^20.14.8", @@ -4351,6 +4354,16 @@ "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", @@ -4478,6 +4491,13 @@ "meshoptimizer": "~0.18.1" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", @@ -7218,6 +7238,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz", + "integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "2.8.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", @@ -10952,6 +10981,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "15.0.11", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.11.tgz", + "integrity": "sha512-1BEXAU2euRCG3xwgLVT1y0xbJEld1XOrmRJpUwRCcy7rxhSCwMrmEu9LXoPhHSCJG41V7YcQ2mjKRr5BA3ITIA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", diff --git a/package.json b/package.json index a463a3d9a..2cb5230a2 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@pinia/testing": "^0.1.5", "@playwright/test": "^1.44.1", "@trivago/prettier-plugin-sort-imports": "^5.2.0", + "@types/dompurify": "^3.0.5", "@types/fs-extra": "^11.0.4", "@types/lodash": "^4.17.6", "@types/node": "^20.14.8", @@ -92,12 +93,14 @@ "@xterm/xterm": "^5.5.0", "algoliasearch": "^5.21.0", "axios": "^1.8.2", + "dompurify": "^3.2.5", "dotenv": "^16.4.5", "firebase": "^11.6.0", "fuse.js": "^7.0.0", "jsondiffpatch": "^0.6.0", "lodash": "^4.17.21", "loglevel": "^1.9.2", + "marked": "^15.0.11", "pinia": "^2.1.7", "primeicons": "^7.0.0", "primevue": "^4.2.5", diff --git a/src/components/graph/SelectionToolbox.vue b/src/components/graph/SelectionToolbox.vue index 4ff1eee92..762d90c26 100644 --- a/src/components/graph/SelectionToolbox.vue +++ b/src/components/graph/SelectionToolbox.vue @@ -18,6 +18,7 @@ :key="command.id" :command="command" /> + @@ -30,6 +31,7 @@ import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerBu import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue' import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue' import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue' +import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue' import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue' import PinButton from '@/components/graph/selectionToolbox/PinButton.vue' import RefreshButton from '@/components/graph/selectionToolbox/RefreshButton.vue' diff --git a/src/components/graph/selectionToolbox/HelpButton.vue b/src/components/graph/selectionToolbox/HelpButton.vue new file mode 100644 index 000000000..e77701bd4 --- /dev/null +++ b/src/components/graph/selectionToolbox/HelpButton.vue @@ -0,0 +1,49 @@ + + + diff --git a/src/components/sidebar/tabs/NodeLibrarySidebarTab.vue b/src/components/sidebar/tabs/NodeLibrarySidebarTab.vue index 4f78cfb51..c80e1cc2f 100644 --- a/src/components/sidebar/tabs/NodeLibrarySidebarTab.vue +++ b/src/components/sidebar/tabs/NodeLibrarySidebarTab.vue @@ -1,113 +1,124 @@ + + diff --git a/src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue b/src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue index d5199a647..d9546086f 100644 --- a/src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue +++ b/src/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue @@ -22,6 +22,15 @@ severity="secondary" @click.stop="toggleBookmark" /> +