+`
+ })
+ })
+
+ 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"
/>
+ ${markdown}
` + }) + }, + Renderer: class Renderer { + image = vi.fn( + ({ href, title, text }) => + `
'
+ })
+
+ nodeHelpStore.openHelp(mockCustomNode as any)
+ await flushPromises()
+ expect(nodeHelpStore.renderedHelpHtml).toContain(
+ 'src="/extensions/test_module/docs/image.png"'
+ )
+ expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Test image"')
+ })
+
+ it('should prefix relative img src in raw HTML for core nodes', async () => {
+ const nodeHelpStore = useNodeHelpStore()
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ text: async () => '# Test\n
'
+ })
+
+ nodeHelpStore.openHelp(mockCoreNode as any)
+ await flushPromises()
+ expect(nodeHelpStore.renderedHelpHtml).toContain(
+ `src="/docs/${mockCoreNode.name}/image.png"`
+ )
+ expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Test image"')
+ })
+
+ it('should not prefix absolute img src in raw HTML', async () => {
+ const nodeHelpStore = useNodeHelpStore()
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ text: async () => '
'
+ })
+
+ nodeHelpStore.openHelp(mockCustomNode as any)
+ await flushPromises()
+ expect(nodeHelpStore.renderedHelpHtml).toContain(
+ 'src="/absolute/image.png"'
+ )
+ expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Absolute"')
+ })
+
+ it('should not prefix external img src in raw HTML', async () => {
+ const nodeHelpStore = useNodeHelpStore()
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ text: async () =>
+ '
'
+ })
+
+ nodeHelpStore.openHelp(mockCustomNode as any)
+ await flushPromises()
+ expect(nodeHelpStore.renderedHelpHtml).toContain(
+ 'src="https://example.com/image.png"'
+ )
+ expect(nodeHelpStore.renderedHelpHtml).toContain('alt="External"')
+ })
+
+ it('should handle various quote styles in media src attributes', async () => {
+ const nodeHelpStore = useNodeHelpStore()
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ text: async () => `# Media Test
+
+Testing quote styles in properly formed HTML:
+
+
+
+
+
+
+
+
+The MEDIA_SRC_REGEX handles both single and double quotes in img, video and source tags.`
+ })
+
+ nodeHelpStore.openHelp(mockCoreNode as any)
+ await flushPromises()
+
+ // Check that all media elements with different quote styles are prefixed correctly
+ // Double quotes remain as double quotes
+ expect(nodeHelpStore.renderedHelpHtml).toContain(
+ `src="/docs/${mockCoreNode.name}/video1.mp4"`
+ )
+ expect(nodeHelpStore.renderedHelpHtml).toContain(
+ `src="/docs/${mockCoreNode.name}/image1.png"`
+ )
+ expect(nodeHelpStore.renderedHelpHtml).toContain(
+ `src="/docs/${mockCoreNode.name}/video3.mp4"`
+ )
+
+ // Single quotes remain as single quotes in the output
+ expect(nodeHelpStore.renderedHelpHtml).toContain(
+ `src='/docs/${mockCoreNode.name}/video2.mp4'`
+ )
+ expect(nodeHelpStore.renderedHelpHtml).toContain(
+ `src='/docs/${mockCoreNode.name}/image2.png'`
+ )
+ expect(nodeHelpStore.renderedHelpHtml).toContain(
+ `src='/docs/${mockCoreNode.name}/video3.webm'`
+ )
+ })
+})
diff --git a/vite.config.mts b/vite.config.mts
index 810dd8baa..d134e936c 100644
--- a/vite.config.mts
+++ b/vite.config.mts
@@ -52,6 +52,18 @@ export default defineConfig({
target: DEV_SERVER_COMFYUI_URL
},
+ // Proxy extension assets (images/videos) under /extensions to the ComfyUI backend
+ '/extensions': {
+ target: DEV_SERVER_COMFYUI_URL,
+ changeOrigin: true
+ },
+
+ // Proxy docs markdown from backend
+ '/docs': {
+ target: DEV_SERVER_COMFYUI_URL,
+ changeOrigin: true
+ },
+
...(!DISABLE_TEMPLATES_PROXY
? {
'/templates': {