Compare commits

..

2 Commits

Author SHA1 Message Date
bymyself
560c15dd6f [fix] use relative import path in subgraph browser tests 2025-07-15 15:00:27 -07:00
bymyself
3ce6bbd567 [feat] sync node library when subgraph titles change
When a subgraph node is renamed, the node library sidebar now immediately reflects the updated display name. This ensures consistency between the canvas, breadcrumb navigation, and node library.

Key changes:
- Add updateNodeDefDisplayName method to nodeDefStore with reference preservation
- Update TitleEditor to call nodeDefStore for subgraph title changes
- Organize subgraph browser tests into dedicated folder
- Add comprehensive unit test coverage for nodeDefStore
2025-07-15 12:39:30 -07:00
31 changed files with 1240 additions and 1753 deletions

27
.github/workflows/danger.yaml vendored Normal file
View File

@@ -0,0 +1,27 @@
name: Danger PR Review
on:
pull_request:
types: [opened, edited, synchronize]
jobs:
danger:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Install dependencies
run: npm ci
- name: Run Danger
run: npx danger ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DANGER_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,154 +0,0 @@
name: PR Checks
on:
pull_request:
types: [opened, edited, synchronize, reopened]
permissions:
contents: read
pull-requests: read
jobs:
analyze:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.check-changes.outputs.should_run }}
has_browser_tests: ${{ steps.check-coverage.outputs.has_browser_tests }}
has_screen_recording: ${{ steps.check-recording.outputs.has_recording }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Ensure base branch is available
run: |
# Fetch the specific base commit to ensure it's available for git diff
git fetch origin ${{ github.event.pull_request.base.sha }}
- name: Check if significant changes exist
id: check-changes
run: |
# Get list of changed files
CHANGED_FILES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }})
# Filter for src/ files
SRC_FILES=$(echo "$CHANGED_FILES" | grep '^src/' || true)
if [ -z "$SRC_FILES" ]; then
echo "No src/ files changed"
echo "should_run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Count lines changed in src files
TOTAL_LINES=0
for file in $SRC_FILES; do
if [ -f "$file" ]; then
# Count added lines (non-empty)
ADDED=$(git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} -- "$file" | grep '^+' | grep -v '^+++' | grep -v '^+$' | wc -l)
# Count removed lines (non-empty)
REMOVED=$(git diff ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} -- "$file" | grep '^-' | grep -v '^---' | grep -v '^-$' | wc -l)
TOTAL_LINES=$((TOTAL_LINES + ADDED + REMOVED))
fi
done
echo "Total lines changed in src/: $TOTAL_LINES"
if [ $TOTAL_LINES -gt 3 ]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
else
echo "should_run=false" >> "$GITHUB_OUTPUT"
fi
- name: Check browser test coverage
id: check-coverage
if: steps.check-changes.outputs.should_run == 'true'
run: |
# Check if browser tests were updated
BROWSER_TEST_CHANGES=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} | grep '^browser_tests/.*\.ts$' || true)
if [ -n "$BROWSER_TEST_CHANGES" ]; then
echo "has_browser_tests=true" >> "$GITHUB_OUTPUT"
else
echo "has_browser_tests=false" >> "$GITHUB_OUTPUT"
fi
- name: Check for screen recording
id: check-recording
if: steps.check-changes.outputs.should_run == 'true'
run: |
# Check PR body for screen recording
PR_BODY="${{ github.event.pull_request.body }}"
# Check for GitHub user attachments or YouTube links
if echo "$PR_BODY" | grep -qiE 'github\.com/user-attachments/assets/[a-f0-9-]+|youtube\.com/watch|youtu\.be/'; then
echo "has_recording=true" >> "$GITHUB_OUTPUT"
else
echo "has_recording=false" >> "$GITHUB_OUTPUT"
fi
- name: Final check and create results
id: final-check
if: always()
run: |
# Initialize results
WARNINGS_JSON=""
# Only run checks if should_run is true
if [ "${{ steps.check-changes.outputs.should_run }}" == "true" ]; then
# Check browser test coverage
if [ "${{ steps.check-coverage.outputs.has_browser_tests }}" != "true" ]; then
if [ -n "$WARNINGS_JSON" ]; then
WARNINGS_JSON="${WARNINGS_JSON},"
fi
WARNINGS_JSON="${WARNINGS_JSON}{\"message\":\"⚠️ **Warning: E2E Test Coverage Missing**\\n\\nIf this PR modifies behavior that can be covered by browser-based E2E tests, those tests are required. PRs lacking applicable test coverage may not be reviewed until added. Please add or update browser tests to ensure code quality and prevent regressions.\"}"
fi
# Check screen recording
if [ "${{ steps.check-recording.outputs.has_recording }}" != "true" ]; then
if [ -n "$WARNINGS_JSON" ]; then
WARNINGS_JSON="${WARNINGS_JSON},"
fi
WARNINGS_JSON="${WARNINGS_JSON}{\"message\":\"⚠️ **Warning: Visual Documentation Missing**\\n\\nIf this PR changes user-facing behavior, visual proof (screen recording or screenshot) is required. PRs without applicable visual documentation may not be reviewed until provided.\\nYou can add it by:\\n\\n- GitHub: Drag & drop media directly into the PR description\\n\\n- YouTube: Include a link to a short demo\"}"
fi
fi
# Create results JSON
if [ -n "$WARNINGS_JSON" ]; then
# Create JSON with warnings
cat > pr-check-results.json << EOF
{
"fails": [],
"warnings": [$WARNINGS_JSON],
"messages": [],
"markdowns": []
}
EOF
echo "failed=false" >> "$GITHUB_OUTPUT"
else
# Create JSON with success
cat > pr-check-results.json << 'EOF'
{
"fails": [],
"warnings": [],
"messages": [],
"markdowns": []
}
EOF
echo "failed=false" >> "$GITHUB_OUTPUT"
fi
# Write PR metadata
echo "${{ github.event.pull_request.number }}" > pr-number.txt
echo "${{ github.event.pull_request.head.sha }}" > pr-sha.txt
- name: Upload results
uses: actions/upload-artifact@v4
if: always()
with:
name: pr-check-results-${{ github.run_id }}
path: |
pr-check-results.json
pr-number.txt
pr-sha.txt
retention-days: 1

View File

@@ -1,149 +0,0 @@
name: PR Comment
on:
workflow_run:
workflows: ["PR Checks"]
types: [completed]
permissions:
pull-requests: write
issues: write
statuses: write
jobs:
comment:
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
name: pr-check-results-${{ github.event.workflow_run.id }}
path: /tmp/pr-artifacts
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
- name: Post results
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const path = require('path');
// Helper function to safely read files
function safeReadFile(filePath) {
try {
if (!fs.existsSync(filePath)) return null;
return fs.readFileSync(filePath, 'utf8').trim();
} catch (e) {
console.error(`Error reading ${filePath}:`, e);
return null;
}
}
// Read artifact files
const artifactDir = '/tmp/pr-artifacts';
const prNumber = safeReadFile(path.join(artifactDir, 'pr-number.txt'));
const prSha = safeReadFile(path.join(artifactDir, 'pr-sha.txt'));
const resultsJson = safeReadFile(path.join(artifactDir, 'pr-check-results.json'));
// Validate PR number
if (!prNumber || isNaN(parseInt(prNumber))) {
throw new Error('Invalid or missing PR number');
}
// Parse and validate results
let results;
try {
results = JSON.parse(resultsJson || '{}');
} catch (e) {
console.error('Failed to parse check results:', e);
// Post error comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber),
body: `⚠️ PR checks failed to complete properly. Error parsing results: ${e.message}`
});
return;
}
// Format check messages
const messages = [];
if (results.fails && results.fails.length > 0) {
messages.push('### ❌ Failures\n' + results.fails.map(f => f.message).join('\n\n'));
}
if (results.warnings && results.warnings.length > 0) {
messages.push('### ⚠️ Warnings\n' + results.warnings.map(w => w.message).join('\n\n'));
}
if (results.messages && results.messages.length > 0) {
messages.push('### 💬 Messages\n' + results.messages.map(m => m.message).join('\n\n'));
}
if (results.markdowns && results.markdowns.length > 0) {
messages.push(...results.markdowns.map(m => m.message));
}
// Find existing bot comment
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber)
});
const botComment = comments.data.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('<!-- pr-checks-comment -->')
);
// Post comment if there are any messages
if (messages.length > 0) {
const body = messages.join('\n\n');
const commentBody = `<!-- pr-checks-comment -->\n${body}`;
if (botComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id,
body: commentBody
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: parseInt(prNumber),
body: commentBody
});
}
} else {
// No messages - delete existing comment if present
if (botComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: botComment.id
});
}
}
// Set commit status based on failures
if (prSha) {
const hasFailures = results.fails && results.fails.length > 0;
const hasWarnings = results.warnings && results.warnings.length > 0;
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: prSha,
state: hasFailures ? 'failure' : 'success',
context: 'pr-checks',
description: hasFailures
? `${results.fails.length} check(s) failed`
: hasWarnings
? `${results.warnings.length} warning(s)`
: 'All checks passed'
});
}

View File

@@ -1,382 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Feature Flags', () => {
test('Client and server exchange feature flags on connection', async ({
comfyPage
}) => {
// Navigate to a new page to capture the initial WebSocket connection
const newPage = await comfyPage.page.context().newPage()
// Set up monitoring before navigation
await newPage.addInitScript(() => {
// This runs before any page scripts
window.__capturedMessages = {
clientFeatureFlags: null,
serverFeatureFlags: null
}
// Capture outgoing client messages
const originalSend = WebSocket.prototype.send
WebSocket.prototype.send = function (data) {
try {
const parsed = JSON.parse(data)
if (parsed.type === 'feature_flags') {
window.__capturedMessages.clientFeatureFlags = parsed
}
} catch (e) {
// Not JSON, ignore
}
return originalSend.call(this, data)
}
// Monitor for server feature flags
const checkInterval = setInterval(() => {
if (
window['app']?.api?.serverFeatureFlags &&
Object.keys(window['app'].api.serverFeatureFlags).length > 0
) {
window.__capturedMessages.serverFeatureFlags =
window['app'].api.serverFeatureFlags
clearInterval(checkInterval)
}
}, 100)
// Clear after 10 seconds
setTimeout(() => clearInterval(checkInterval), 10000)
})
// Navigate to the app
await newPage.goto(comfyPage.url)
// Wait for both client and server feature flags
await newPage.waitForFunction(
() =>
window.__capturedMessages.clientFeatureFlags !== null &&
window.__capturedMessages.serverFeatureFlags !== null,
{ timeout: 10000 }
)
// Get the captured messages
const messages = await newPage.evaluate(() => window.__capturedMessages)
// Verify client sent feature flags
expect(messages.clientFeatureFlags).toBeTruthy()
expect(messages.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
expect(messages.clientFeatureFlags).toHaveProperty('data')
expect(messages.clientFeatureFlags.data).toHaveProperty(
'supports_preview_metadata'
)
expect(
typeof messages.clientFeatureFlags.data.supports_preview_metadata
).toBe('boolean')
// Verify server sent feature flags back
expect(messages.serverFeatureFlags).toBeTruthy()
expect(messages.serverFeatureFlags).toHaveProperty(
'supports_preview_metadata'
)
expect(typeof messages.serverFeatureFlags.supports_preview_metadata).toBe(
'boolean'
)
expect(messages.serverFeatureFlags).toHaveProperty('max_upload_size')
expect(typeof messages.serverFeatureFlags.max_upload_size).toBe('number')
expect(Object.keys(messages.serverFeatureFlags).length).toBeGreaterThan(0)
await newPage.close()
})
test('Server feature flags are received and accessible', async ({
comfyPage
}) => {
// Wait for connection to establish
await comfyPage.page.waitForTimeout(1000)
// Get the actual server feature flags from the backend
const serverFlags = await comfyPage.page.evaluate(() => {
return window['app'].api.serverFeatureFlags
})
// Verify we received real feature flags from the backend
expect(serverFlags).toBeTruthy()
expect(Object.keys(serverFlags).length).toBeGreaterThan(0)
// The backend should send feature flags
expect(serverFlags).toHaveProperty('supports_preview_metadata')
expect(typeof serverFlags.supports_preview_metadata).toBe('boolean')
expect(serverFlags).toHaveProperty('max_upload_size')
expect(typeof serverFlags.max_upload_size).toBe('number')
})
test('serverSupportsFeature method works with real backend flags', async ({
comfyPage
}) => {
// Wait for connection
await comfyPage.page.waitForTimeout(1000)
// Test serverSupportsFeature with real backend flags
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
return window['app'].api.serverSupportsFeature(
'supports_preview_metadata'
)
})
// The method should return a boolean based on the backend's value
expect(typeof supportsPreviewMetadata).toBe('boolean')
// Test non-existent feature - should always return false
const supportsNonExistent = await comfyPage.page.evaluate(() => {
return window['app'].api.serverSupportsFeature('non_existent_feature_xyz')
})
expect(supportsNonExistent).toBe(false)
// Test that the method only returns true for boolean true values
const testResults = await comfyPage.page.evaluate(() => {
// Temporarily modify serverFeatureFlags to test behavior
const original = window['app'].api.serverFeatureFlags
window['app'].api.serverFeatureFlags = {
bool_true: true,
bool_false: false,
string_value: 'yes',
number_value: 1,
null_value: null
}
const results = {
bool_true: window['app'].api.serverSupportsFeature('bool_true'),
bool_false: window['app'].api.serverSupportsFeature('bool_false'),
string_value: window['app'].api.serverSupportsFeature('string_value'),
number_value: window['app'].api.serverSupportsFeature('number_value'),
null_value: window['app'].api.serverSupportsFeature('null_value')
}
// Restore original
window['app'].api.serverFeatureFlags = original
return results
})
// serverSupportsFeature should only return true for boolean true values
expect(testResults.bool_true).toBe(true)
expect(testResults.bool_false).toBe(false)
expect(testResults.string_value).toBe(false)
expect(testResults.number_value).toBe(false)
expect(testResults.null_value).toBe(false)
})
test('getServerFeature method works with real backend data', async ({
comfyPage
}) => {
// Wait for connection
await comfyPage.page.waitForTimeout(1000)
// Test getServerFeature method
const previewMetadataValue = await comfyPage.page.evaluate(() => {
return window['app'].api.getServerFeature('supports_preview_metadata')
})
expect(typeof previewMetadataValue).toBe('boolean')
// Test getting max_upload_size
const maxUploadSize = await comfyPage.page.evaluate(() => {
return window['app'].api.getServerFeature('max_upload_size')
})
expect(typeof maxUploadSize).toBe('number')
expect(maxUploadSize).toBeGreaterThan(0)
// Test getServerFeature with default value for non-existent feature
const defaultValue = await comfyPage.page.evaluate(() => {
return window['app'].api.getServerFeature(
'non_existent_feature_xyz',
'default'
)
})
expect(defaultValue).toBe('default')
})
test('getServerFeatures returns all backend feature flags', async ({
comfyPage
}) => {
// Wait for connection
await comfyPage.page.waitForTimeout(1000)
// Test getServerFeatures returns all flags
const allFeatures = await comfyPage.page.evaluate(() => {
return window['app'].api.getServerFeatures()
})
expect(allFeatures).toBeTruthy()
expect(allFeatures).toHaveProperty('supports_preview_metadata')
expect(typeof allFeatures.supports_preview_metadata).toBe('boolean')
expect(allFeatures).toHaveProperty('max_upload_size')
expect(Object.keys(allFeatures).length).toBeGreaterThan(0)
})
test('Client feature flags are immutable', async ({ comfyPage }) => {
// Test that getClientFeatureFlags returns a copy
const immutabilityTest = await comfyPage.page.evaluate(() => {
const flags1 = window['app'].api.getClientFeatureFlags()
const flags2 = window['app'].api.getClientFeatureFlags()
// Modify the first object
flags1.test_modification = true
// Get flags again to check if original was modified
const flags3 = window['app'].api.getClientFeatureFlags()
return {
areEqual: flags1 === flags2,
hasModification: flags3.test_modification !== undefined,
hasSupportsPreview: flags1.supports_preview_metadata !== undefined,
supportsPreviewValue: flags1.supports_preview_metadata
}
})
// Verify they are different objects (not the same reference)
expect(immutabilityTest.areEqual).toBe(false)
// Verify modification didn't affect the original
expect(immutabilityTest.hasModification).toBe(false)
// Verify the flags contain expected properties
expect(immutabilityTest.hasSupportsPreview).toBe(true)
expect(typeof immutabilityTest.supportsPreviewValue).toBe('boolean') // From clientFeatureFlags.json
})
test('Server features are immutable when accessed via getServerFeatures', async ({
comfyPage
}) => {
// Wait for connection to establish
await comfyPage.page.waitForTimeout(1000)
const immutabilityTest = await comfyPage.page.evaluate(() => {
// Get a copy of server features
const features1 = window['app'].api.getServerFeatures()
// Try to modify it
features1.supports_preview_metadata = false
features1.new_feature = 'added'
// Get another copy
const features2 = window['app'].api.getServerFeatures()
return {
modifiedValue: features1.supports_preview_metadata,
originalValue: features2.supports_preview_metadata,
hasNewFeature: features2.new_feature !== undefined,
hasSupportsPreview: features2.supports_preview_metadata !== undefined
}
})
// The modification should only affect the copy
expect(immutabilityTest.modifiedValue).toBe(false)
expect(typeof immutabilityTest.originalValue).toBe('boolean') // Backend sends boolean for supports_preview_metadata
expect(immutabilityTest.hasNewFeature).toBe(false)
expect(immutabilityTest.hasSupportsPreview).toBe(true)
})
test('Feature flags are negotiated early in connection lifecycle', async ({
comfyPage
}) => {
// This test verifies that feature flags are available early in the app lifecycle
// which is important for protocol negotiation
// Create a new page to ensure clean state
const newPage = await comfyPage.page.context().newPage()
// Set up monitoring before navigation
await newPage.addInitScript(() => {
// Track when various app components are ready
;(window as any).__appReadiness = {
featureFlagsReceived: false,
apiInitialized: false,
appInitialized: false
}
// Monitor when feature flags arrive by checking periodically
const checkFeatureFlags = setInterval(() => {
if (
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined
) {
;(window as any).__appReadiness.featureFlagsReceived = true
clearInterval(checkFeatureFlags)
}
}, 10)
// Monitor API initialization
const checkApi = setInterval(() => {
if (window['app']?.api) {
;(window as any).__appReadiness.apiInitialized = true
clearInterval(checkApi)
}
}, 10)
// Monitor app initialization
const checkApp = setInterval(() => {
if (window['app']?.graph) {
;(window as any).__appReadiness.appInitialized = true
clearInterval(checkApp)
}
}, 10)
// Clean up after 10 seconds
setTimeout(() => {
clearInterval(checkFeatureFlags)
clearInterval(checkApi)
clearInterval(checkApp)
}, 10000)
})
// Navigate to the app
await newPage.goto(comfyPage.url)
// Wait for feature flags to be received
await newPage.waitForFunction(
() =>
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
undefined,
{
timeout: 10000
}
)
// Get readiness state
const readiness = await newPage.evaluate(() => {
return {
...(window as any).__appReadiness,
currentFlags: window['app'].api.serverFeatureFlags
}
})
// Verify feature flags are available
expect(readiness.currentFlags).toHaveProperty('supports_preview_metadata')
expect(typeof readiness.currentFlags.supports_preview_metadata).toBe(
'boolean'
)
expect(readiness.currentFlags).toHaveProperty('max_upload_size')
// Verify feature flags were received (we detected them via polling)
expect(readiness.featureFlagsReceived).toBe(true)
// Verify API was initialized (feature flags require API)
expect(readiness.apiInitialized).toBe(true)
await newPage.close()
})
test('Backend /features endpoint returns feature flags', async ({
comfyPage
}) => {
// Test the HTTP endpoint directly
const response = await comfyPage.page.request.get(
`${comfyPage.url}/api/features`
)
expect(response.ok()).toBe(true)
const features = await response.json()
expect(features).toBeTruthy()
expect(features).toHaveProperty('supports_preview_metadata')
expect(typeof features.supports_preview_metadata).toBe('boolean')
expect(features).toHaveProperty('max_upload_size')
expect(Object.keys(features).length).toBeGreaterThan(0)
})
})

View File

@@ -336,4 +336,54 @@ test.describe('Node library sidebar', () => {
await comfyPage.page.waitForTimeout(1000)
expect(await tab.getNode('KSampler (Advanced)').count()).toBe(2)
})
test('Subgraph node display name updates in library when renamed', async ({
comfyPage
}) => {
// Load a workflow with subgraphs to populate the node library
await comfyPage.loadWorkflow('nested-subgraph')
await comfyPage.nextFrame()
const tab = comfyPage.menu.nodeLibraryTab
// Navigate to subgraph folder in node library
await tab.getFolder('subgraph').click()
await comfyPage.nextFrame()
// Get initial subgraph node name in the library
const initialSubgraphNodeCount = await tab.getNode('Subgraph Node').count()
expect(initialSubgraphNodeCount).toBeGreaterThan(0)
// Get the subgraph node by ID (node 10 is the subgraph)
const subgraphNode = await comfyPage.getNodeRefById('10')
const nodePos = await subgraphNode.getPosition()
const nodeSize = await subgraphNode.getSize()
// Double-click on the title area of the subgraph node to edit
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y - 10 // Title area is above the node body
},
delay: 5
})
// Wait for title editor to appear
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
// Clear existing text and type new title
await comfyPage.page.keyboard.press('Control+a')
const newTitle = 'Renamed Subgraph Node'
await comfyPage.page.keyboard.type(newTitle)
await comfyPage.page.keyboard.press('Enter')
// Wait a frame for the update to complete
await comfyPage.nextFrame()
// Verify the node library shows the updated title
expect(await tab.getNode(newTitle).count()).toBeGreaterThan(0)
// Verify the old node name is no longer in the library
expect(await tab.getNode('Subgraph Node').count()).toBe(0)
})
})

View File

@@ -0,0 +1,55 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Subgraph Persistence', () => {
test('Node library updates when subgraph title changes', async ({
comfyPage
}) => {
// Load a workflow with subgraphs to populate the node library
await comfyPage.loadWorkflow('nested-subgraph')
await comfyPage.nextFrame()
const tab = comfyPage.menu.nodeLibraryTab
// Navigate to subgraph folder in node library
await tab.getFolder('subgraph').click()
await comfyPage.nextFrame()
// Get initial subgraph node name in the library
const initialSubgraphNodeCount = await tab.getNode('Subgraph Node').count()
expect(initialSubgraphNodeCount).toBeGreaterThan(0)
// Get the subgraph node by ID (node 10 is the subgraph)
const subgraphNode = await comfyPage.getNodeRefById('10')
const nodePos = await subgraphNode.getPosition()
const nodeSize = await subgraphNode.getSize()
// Double-click on the title area of the subgraph node to edit
await comfyPage.canvas.dblclick({
position: {
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y - 10 // Title area is above the node body
},
delay: 5
})
// Wait for title editor to appear
await expect(comfyPage.page.locator('.node-title-editor')).toBeVisible()
// Clear existing text and type new title
await comfyPage.page.keyboard.press('Control+a')
const newTitle = 'Renamed Subgraph Node'
await comfyPage.page.keyboard.type(newTitle)
await comfyPage.page.keyboard.press('Enter')
// Wait a frame for the update to complete
await comfyPage.nextFrame()
// Verify the node library shows the updated title
expect(await tab.getNode(newTitle).count()).toBeGreaterThan(0)
// Verify the old node name is no longer in the library
expect(await tab.getNode('Subgraph Node').count()).toBe(0)
})
})

72
dangerfile.ts Normal file
View File

@@ -0,0 +1,72 @@
import { danger, fail } from 'danger'
// Check if we should run the checks
const shouldRunChecks = async () => {
const allChangedFiles = [
...danger.git.modified_files,
...danger.git.created_files
]
const srcChanges = allChangedFiles.filter((file) => file.startsWith('src/'))
if (srcChanges.length === 0) {
return false
}
// Check total lines changed in src files
let totalLinesChanged = 0
for (const file of srcChanges) {
const diff = await danger.git.diffForFile(file)
if (diff) {
// Count only lines with actual content (non-empty after trimming whitespace)
// This excludes empty lines and lines containing only spaces/tabs
const additions =
diff.added?.split('\n').filter((line) => line.trim()).length || 0
const deletions =
diff.removed?.split('\n').filter((line) => line.trim()).length || 0
totalLinesChanged += additions + deletions
}
}
return totalLinesChanged > 3
}
// Check if browser tests were updated
const checkBrowserTestCoverage = () => {
const allChangedFiles = [
...danger.git.modified_files,
...danger.git.created_files
]
const hasBrowserTestChanges = allChangedFiles.some(
(file) => file.startsWith('browser_tests/') && file.endsWith('.ts')
)
if (!hasBrowserTestChanges) {
fail(`🧪 **E2E Test Coverage Missing**
All changes should be covered under E2E testing. Please add or update browser tests.`)
}
}
// Check for screen recording in PR description
const checkScreenRecording = () => {
const description = danger.github.pr.body || ''
const hasRecording =
/github\.com\/user-attachments\/assets\/[a-f0-9-]+/i.test(description) ||
/youtube\.com\/watch|youtu\.be\//i.test(description)
if (!hasRecording) {
fail(`📹 **Visual Documentation Missing**
Please add a screen recording or screenshot:
- GitHub: Drag & drop media to PR description
- YouTube: Add YouTube link`)
}
}
// Run the checks only if conditions are met
shouldRunChecks().then((shouldRun) => {
if (shouldRun) {
checkBrowserTestCoverage()
checkScreenRecording()
}
})

806
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,7 @@
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.19",
"chalk": "^5.3.0",
"danger": "^13.0.4",
"eslint": "^9.12.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-prettier": "^5.2.6",

View File

@@ -38,14 +38,23 @@ import { useI18n } from 'vue-i18n'
import FileDownload from '@/components/common/FileDownload.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { MODEL_SOURCES } from '@/constants/urls'
import { useSettingStore } from '@/stores/settingStore'
import { isElectron } from '@/utils/envUtil'
/** @todo Read this from server internal API rather than hardcoding here */
const allowedSources = MODEL_SOURCES.allowedDomains
// TODO: Read this from server internal API rather than hardcoding here
// as some installations may wish to use custom sources
const allowedSources = [
'https://civitai.com/',
'https://huggingface.co/',
'http://localhost:' // Included for testing usage only
]
const allowedSuffixes = ['.safetensors', '.sft']
const whiteListedUrls = new Set(MODEL_SOURCES.whitelistedUrls)
// Models that fail above conditions but are still allowed
const whiteListedUrls = new Set([
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
])
interface ModelInfo {
name: string

View File

@@ -22,9 +22,11 @@ import EditableText from '@/components/common/EditableText.vue'
import { useAbsolutePosition } from '@/composables/element/useAbsolutePosition'
import { app } from '@/scripts/app'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const showInput = ref(false)
const editedTitle = ref('')
@@ -48,6 +50,9 @@ const onEdit = (newValue: string) => {
const target = titleEditorStore.titleEditorTarget
if (target instanceof LGraphNode && target.isSubgraphNode?.()) {
target.subgraph.name = trimmedTitle
// Also update the node definition display name in the store
nodeDefStore.updateNodeDefDisplayName(target.type, trimmedTitle)
}
app.graph.setDirtyCanvas(true, true)

View File

@@ -123,7 +123,6 @@ import Button from 'primevue/button'
import { type CSSProperties, computed, nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { COMFY_URLS, GITHUB_REPOS, getDesktopGuideUrl } from '@/constants/urls'
import { type ReleaseNote } from '@/services/releaseService'
import { useCommandStore } from '@/stores/commandStore'
import { useReleaseStore } from '@/stores/releaseStore'
@@ -144,10 +143,11 @@ interface MenuItem {
// Constants
const EXTERNAL_LINKS = {
DOCS: COMFY_URLS.docs.base,
DISCORD: COMFY_URLS.community.discord,
GITHUB: GITHUB_REPOS.comfyui,
UPDATE_GUIDE: COMFY_URLS.docs.installation.update
DOCS: 'https://docs.comfy.org/',
DISCORD: 'https://www.comfy.org/discord',
GITHUB: 'https://github.com/comfyanonymous/ComfyUI',
DESKTOP_GUIDE: 'https://comfyorg.notion.site/',
UPDATE_GUIDE: 'https://docs.comfy.org/installation/update_comfyui'
} as const
const TIME_UNITS = {
@@ -199,7 +199,7 @@ const menuItems = computed<MenuItem[]>(() => {
type: 'item',
label: t('helpCenter.desktopUserGuide'),
action: () => {
openExternalLink(getDesktopGuideUrl(locale.value))
openExternalLink(EXTERNAL_LINKS.DESKTOP_GUIDE)
emit('close')
}
},
@@ -451,13 +451,13 @@ const onUpdate = (_: ReleaseNote): void => {
const getChangelogUrl = (): string => {
const isChineseLocale = locale.value === 'zh'
return isChineseLocale
? COMFY_URLS.docs.changelog.zh
: COMFY_URLS.docs.changelog.en
? 'https://docs.comfy.org/zh-CN/changelog'
: 'https://docs.comfy.org/changelog'
}
// Lifecycle
onMounted(async () => {
if (showVersionUpdates.value && !hasReleases.value) {
if (!hasReleases.value) {
await releaseStore.fetchReleases()
}
})

View File

@@ -48,7 +48,6 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { COMFY_URLS } from '@/constants/urls'
import type { ReleaseNote } from '@/services/releaseService'
import { useReleaseStore } from '@/stores/releaseStore'
import { formatVersionAnchor } from '@/utils/formatUtil'
@@ -73,8 +72,8 @@ const shouldShow = computed(
const changelogUrl = computed(() => {
const isChineseLocale = locale.value === 'zh'
const baseUrl = isChineseLocale
? COMFY_URLS.docs.changelog.zh
: COMFY_URLS.docs.changelog.en
? 'https://docs.comfy.org/zh-CN/changelog'
: 'https://docs.comfy.org/changelog'
if (latestRelease.value?.version) {
const versionAnchor = formatVersionAnchor(latestRelease.value.version)
@@ -121,7 +120,7 @@ const handleLearnMore = () => {
}
const handleUpdate = () => {
window.open(COMFY_URLS.docs.installation.update, '_blank')
window.open('https://docs.comfy.org/installation/update_comfyui', '_blank')
dismissToast()
}

View File

@@ -68,7 +68,6 @@ import { marked } from 'marked'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { COMFY_URLS } from '@/constants/urls'
import type { ReleaseNote } from '@/services/releaseService'
import { useReleaseStore } from '@/stores/releaseStore'
import { formatVersionAnchor } from '@/utils/formatUtil'
@@ -93,8 +92,8 @@ const shouldShow = computed(
const changelogUrl = computed(() => {
const isChineseLocale = locale.value === 'zh'
const baseUrl = isChineseLocale
? COMFY_URLS.docs.changelog.zh
: COMFY_URLS.docs.changelog.en
? 'https://docs.comfy.org/zh-CN/changelog'
: 'https://docs.comfy.org/changelog'
if (latestRelease.value?.version) {
const versionAnchor = formatVersionAnchor(latestRelease.value.version)
@@ -218,8 +217,7 @@ defineExpose({
display: flex;
flex-direction: column;
gap: 24px;
max-height: 80vh;
overflow-y: auto;
overflow: hidden;
padding: 32px 32px 24px;
border-radius: 12px;
}

View File

@@ -43,7 +43,7 @@ import Panel from 'primevue/panel'
import { ModelRef, computed, onMounted, ref } from 'vue'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
import { PYPI_MIRROR, PYTHON_MIRROR, UVMirror } from '@/constants/mirrors'
import { PYPI_MIRROR, PYTHON_MIRROR, UVMirror } from '@/constants/uvMirrors'
import { t } from '@/i18n'
import { isInChina } from '@/utils/networkUtil'
import { ValidationState, mergeValidationStates } from '@/utils/validationUtil'

View File

@@ -23,7 +23,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import UrlInput from '@/components/common/UrlInput.vue'
import { UVMirror } from '@/constants/mirrors'
import { UVMirror } from '@/constants/uvMirrors'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { checkMirrorReachable } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'

View File

@@ -11,7 +11,6 @@ import {
DEFAULT_DARK_COLOR_PALETTE,
DEFAULT_LIGHT_COLOR_PALETTE
} from '@/constants/coreColorPalettes'
import { COMFY_URLS, GITHUB_REPOS } from '@/constants/urls'
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
@@ -544,7 +543,10 @@ export function useCoreCommands(): ComfyCommand[] {
menubarLabel: 'ComfyUI Issues',
versionAdded: '1.5.5',
function: () => {
window.open(GITHUB_REPOS.comfyuiIssues, '_blank')
window.open(
'https://github.com/comfyanonymous/ComfyUI/issues',
'_blank'
)
}
},
{
@@ -554,7 +556,7 @@ export function useCoreCommands(): ComfyCommand[] {
menubarLabel: 'ComfyUI Docs',
versionAdded: '1.5.5',
function: () => {
window.open(COMFY_URLS.docs.base, '_blank')
window.open('https://docs.comfy.org/', '_blank')
}
},
{
@@ -564,7 +566,7 @@ export function useCoreCommands(): ComfyCommand[] {
menubarLabel: 'Comfy-Org Discord',
versionAdded: '1.5.5',
function: () => {
window.open(COMFY_URLS.community.discord, '_blank')
window.open('https://www.comfy.org/discord', '_blank')
}
},
{
@@ -644,7 +646,7 @@ export function useCoreCommands(): ComfyCommand[] {
menubarLabel: 'ComfyUI Forum',
versionAdded: '1.8.2',
function: () => {
window.open(COMFY_URLS.community.forum.base, '_blank')
window.open('https://forum.comfy.org/', '_blank')
}
},
{

View File

@@ -1,3 +0,0 @@
{
"supports_preview_metadata": false
}

View File

@@ -1,14 +0,0 @@
/**
* Base domain configuration and core website URLs
* Forkers can change the base domain to use their own
*/
export const COMFY_BASE_DOMAIN =
import.meta.env.VITE_COMFY_BASE_DOMAIN || 'comfy.org'
const WEBSITE_BASE_URL = `https://www.${COMFY_BASE_DOMAIN}`
export const COMFY_WEBSITE_URLS = {
base: WEBSITE_BASE_URL,
termsOfService: `${WEBSITE_BASE_URL}/terms-of-service`,
privacy: `${WEBSITE_BASE_URL}/privacy`
}

View File

@@ -1,95 +0,0 @@
/**
* URL constants for ComfyUI frontend
* Centralized location for all URL references
*/
import { COMFY_BASE_DOMAIN } from '@/config/comfyDomain'
const DOCS_BASE_URL = `https://docs.${COMFY_BASE_DOMAIN}`
const FORUM_BASE_URL = `https://forum.${COMFY_BASE_DOMAIN}`
const WEBSITE_BASE_URL = `https://www.${COMFY_BASE_DOMAIN}`
export const COMFY_URLS = {
website: {
base: WEBSITE_BASE_URL,
termsOfService: `${WEBSITE_BASE_URL}/terms-of-service`,
privacy: `${WEBSITE_BASE_URL}/privacy`
},
docs: {
base: DOCS_BASE_URL,
changelog: {
en: `${DOCS_BASE_URL}/changelog`,
zh: `${DOCS_BASE_URL}/zh-CN/changelog`
},
installation: {
update: `${DOCS_BASE_URL}/installation/update_comfyui`
},
tutorials: {
apiNodes: {
overview: `${DOCS_BASE_URL}/tutorials/api-nodes/overview`,
faq: `${DOCS_BASE_URL}/tutorials/api-nodes/faq`,
pricing: `${DOCS_BASE_URL}/tutorials/api-nodes/pricing`,
apiKeyLogin: `${DOCS_BASE_URL}/interface/user#logging-in-with-an-api-key`,
nonWhitelistedLogin: `${DOCS_BASE_URL}/tutorials/api-nodes/overview#log-in-with-api-key-on-non-whitelisted-websites`
}
},
getLocalized: (path: string, locale: string) => {
return locale === 'zh' || locale === 'zh-CN'
? `${DOCS_BASE_URL}/zh-CN/${path}`
: `${DOCS_BASE_URL}/${path}`
}
},
community: {
discord: `${WEBSITE_BASE_URL}/discord`,
forum: {
base: `${FORUM_BASE_URL}/`,
v1Feedback: `${FORUM_BASE_URL}/c/v1-feedback/`
}
}
}
export const GITHUB_REPOS = {
comfyui: 'https://github.com/comfyanonymous/ComfyUI',
comfyuiIssues: 'https://github.com/comfyanonymous/ComfyUI/issues',
frontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
electron: 'https://github.com/Comfy-Org/electron',
desktopPlatforms:
'https://github.com/Comfy-Org/desktop#currently-supported-platforms'
}
export const MODEL_SOURCES = {
repos: {
civitai: 'https://civitai.com/',
huggingface: 'https://huggingface.co/'
},
whitelistedUrls: [
'https://huggingface.co/stabilityai/stable-zero123/resolve/main/stable_zero123.ckpt',
'https://huggingface.co/TencentARC/T2I-Adapter/resolve/main/models/t2iadapter_depth_sd14v1.pth?download=true',
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
],
allowedDomains: [
'https://civitai.com/',
'https://huggingface.co/',
'http://localhost:' // TODO: Remove in production
]
}
export const DEVELOPER_TOOLS = {
git: 'https://git-scm.com/downloads/',
vcRedist: 'https://aka.ms/vs/17/release/vc_redist.x64.exe',
uv: 'https://docs.astral.sh/uv/getting-started/installation/'
}
// Platform and locale-aware desktop guide URL generator
export const getDesktopGuideUrl = (locale: string): string => {
const isChineseLocale = locale === 'zh'
const isMacOS =
typeof navigator !== 'undefined' &&
navigator.platform.toUpperCase().indexOf('MAC') >= 0
const platform = isMacOS ? 'macos' : 'windows'
const baseUrl = isChineseLocale
? `https://docs.${COMFY_BASE_DOMAIN}/zh-CN/installation/desktop`
: `https://docs.${COMFY_BASE_DOMAIN}/installation/desktop`
return `${baseUrl}/${platform}`
}

View File

@@ -1,8 +1,7 @@
import log from 'loglevel'
import { PYTHON_MIRROR } from '@/constants/mirrors'
import { getDesktopGuideUrl } from '@/constants/urls'
import { i18n, t } from '@/i18n'
import { PYTHON_MIRROR } from '@/constants/uvMirrors'
import { t } from '@/i18n'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useToastStore } from '@/stores/toastStore'
@@ -160,7 +159,7 @@ import { checkMirrorReachable } from '@/utils/networkUtil'
label: 'Desktop User Guide',
icon: 'pi pi-book',
function() {
window.open(getDesktopGuideUrl(i18n.global.locale.value), '_blank')
window.open('https://comfyorg.notion.site/', '_blank')
}
},
{

View File

@@ -113,8 +113,6 @@ const zLogRawResponse = z.object({
entries: z.array(zLogEntry)
})
const zFeatureFlagsWsMessage = z.record(z.string(), z.any())
export type StatusWsMessageStatus = z.infer<typeof zStatusWsMessageStatus>
export type StatusWsMessage = z.infer<typeof zStatusWsMessage>
export type ProgressWsMessage = z.infer<typeof zProgressWsMessage>
@@ -134,7 +132,6 @@ export type ProgressTextWsMessage = z.infer<typeof zProgressTextWsMessage>
export type DisplayComponentWsMessage = z.infer<
typeof zDisplayComponentWsMessage
>
export type FeatureFlagsWsMessage = z.infer<typeof zFeatureFlagsWsMessage>
// End of ws messages
const zPromptInputItem = z.object({

View File

@@ -1,6 +1,5 @@
import axios from 'axios'
import defaultClientFeatureFlags from '@/config/clientFeatureFlags.json'
import type {
DisplayComponentWsMessage,
EmbeddingsResponse,
@@ -12,7 +11,6 @@ import type {
ExecutionStartWsMessage,
ExecutionSuccessWsMessage,
ExtensionsResponse,
FeatureFlagsWsMessage,
HistoryTaskItem,
LogsRawResponse,
LogsWsMessage,
@@ -107,7 +105,6 @@ interface BackendApiCalls {
b_preview: Blob
progress_text: ProgressTextWsMessage
display_component: DisplayComponentWsMessage
feature_flags: FeatureFlagsWsMessage
}
/** Dictionary of all api calls */
@@ -237,19 +234,6 @@ export class ComfyApi extends EventTarget {
reportedUnknownMessageTypes = new Set<string>()
/**
* Get feature flags supported by this frontend client.
* Returns a copy to prevent external modification.
*/
getClientFeatureFlags(): Record<string, unknown> {
return { ...defaultClientFeatureFlags }
}
/**
* Feature flags received from the backend server.
*/
serverFeatureFlags: Record<string, unknown> = {}
/**
* The auth token for the comfy org account if the user is logged in.
* This is only used for {@link queuePrompt} now. It is not directly
@@ -391,15 +375,6 @@ export class ComfyApi extends EventTarget {
this.socket.addEventListener('open', () => {
opened = true
// Send feature flags as the first message
this.socket!.send(
JSON.stringify({
type: 'feature_flags',
data: this.getClientFeatureFlags()
})
)
if (isReconnect) {
this.dispatchCustomEvent('reconnected')
}
@@ -493,14 +468,6 @@ export class ComfyApi extends EventTarget {
case 'b_preview':
this.dispatchCustomEvent(msg.type, msg.data)
break
case 'feature_flags':
// Store server feature flags
this.serverFeatureFlags = msg.data
console.log(
'Server feature flags received:',
this.serverFeatureFlags
)
break
default:
if (this.#registered.has(msg.type)) {
// Fallback for custom types - calls super direct.
@@ -995,33 +962,6 @@ export class ComfyApi extends EventTarget {
async getCustomNodesI18n(): Promise<Record<string, any>> {
return (await axios.get(this.apiURL('/i18n'))).data
}
/**
* Checks if the server supports a specific feature.
* @param featureName The name of the feature to check
* @returns true if the feature is supported, false otherwise
*/
serverSupportsFeature(featureName: string): boolean {
return this.serverFeatureFlags[featureName] === true
}
/**
* Gets a server feature flag value.
* @param featureName The name of the feature to get
* @param defaultValue The default value if the feature is not found
* @returns The feature value or default
*/
getServerFeature<T = unknown>(featureName: string, defaultValue?: T): T {
return (this.serverFeatureFlags[featureName] ?? defaultValue) as T
}
/**
* Gets all server feature flags.
* @returns Copy of all server feature flags
*/
getServerFeatures(): Record<string, unknown> {
return { ...this.serverFeatureFlags }
}
}
export const api = new ComfyApi()

View File

@@ -1,7 +1,6 @@
import { defineStore } from 'pinia'
import { computed } from 'vue'
import { COMFY_URLS, GITHUB_REPOS } from '@/constants/urls'
import { AboutPageBadge } from '@/types/comfy'
import { electronAPI, isElectron } from '@/utils/envUtil'
@@ -25,20 +24,20 @@ export const useAboutPanelStore = defineStore('aboutPanel', () => {
? 'v' + electronAPI().getComfyUIVersion()
: coreVersion.value
}`,
url: GITHUB_REPOS.comfyui,
url: 'https://github.com/comfyanonymous/ComfyUI',
icon: 'pi pi-github'
},
{
label: `ComfyUI_frontend v${frontendVersion}`,
url: GITHUB_REPOS.frontend,
url: 'https://github.com/Comfy-Org/ComfyUI_frontend',
icon: 'pi pi-github'
},
{
label: 'Discord',
url: COMFY_URLS.community.discord,
url: 'https://www.comfy.org/discord',
icon: 'pi pi-discord'
},
{ label: 'ComfyOrg', url: COMFY_URLS.website.base, icon: 'pi pi-globe' }
{ label: 'ComfyOrg', url: 'https://www.comfy.org/', icon: 'pi pi-globe' }
])
const allBadges = computed<AboutPageBadge[]>(() => [

View File

@@ -1,4 +1,4 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { type LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import axios from 'axios'
import _ from 'lodash'
import { defineStore } from 'pinia'
@@ -304,6 +304,28 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
nodeDefsByName.value[nodeDef.name] = nodeDefImpl
nodeDefsByDisplayName.value[nodeDef.display_name] = nodeDefImpl
}
function updateNodeDefDisplayName(nodeType: string, newDisplayName: string) {
const existingNodeDef = nodeDefsByName.value[nodeType]
if (!existingNodeDef) return
// Remove old display name mapping
delete nodeDefsByDisplayName.value[existingNodeDef.display_name]
// Clone and mutate like bookmark store does to preserve references
const clonedNodeDef = _.clone(existingNodeDef) as any
clonedNodeDef.display_name = newDisplayName
// Update both mappings
nodeDefsByName.value[nodeType] = clonedNodeDef
nodeDefsByDisplayName.value[newDisplayName] = clonedNodeDef
// Also update the LiteGraph registered node type's static title
// This is necessary for new instances to use the updated display name
const RegisteredNodeClass = LiteGraph.registered_node_types[nodeType]
if (RegisteredNodeClass) {
RegisteredNodeClass.title = newDisplayName
}
}
function fromLGraphNode(node: LGraphNode): ComfyNodeDefImpl | null {
// Frontend-only nodes don't have nodeDef
// @ts-expect-error Optional chaining used in index
@@ -324,6 +346,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
updateNodeDefs,
addNodeDef,
updateNodeDefDisplayName,
fromLGraphNode
}
})

View File

@@ -699,27 +699,6 @@ export interface paths {
patch?: never
trace?: never
}
'/publishers/{publisherId}/nodes/{nodeId}/claim-my-node': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/**
* Claim nodeId into publisherId for the authenticated publisher
* @description This endpoint allows a publisher to claim an unclaimed node that they own the repo, which is identified by the nodeId. The unclaimed node's repository must be owned by the authenticated user.
*
*/
post: operations['claimMyNode']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/publishers/{publisherId}/nodes/v2': {
parameters: {
query?: never
@@ -1082,23 +1061,6 @@ export interface paths {
patch?: never
trace?: never
}
'/bulk/nodes/versions': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
put?: never
/** Retrieve multiple node versions in a single request */
post: operations['getBulkNodeVersions']
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/versions': {
parameters: {
query?: never
@@ -1133,26 +1095,6 @@ export interface paths {
patch?: never
trace?: never
}
'/admin/nodes/{nodeId}': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
get?: never
/**
* Admin Update Node
* @description Only admins can update a node with admin privileges.
*/
put: operations['adminUpdateNode']
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/admin/nodes/{nodeId}/versions/{versionNumber}': {
parameters: {
query?: never
@@ -3009,7 +2951,7 @@ export interface paths {
patch?: never
trace?: never
}
'/proxy/moonvalley/prompts/text-to-video': {
'/proxy/moonvalley/text-to-video': {
parameters: {
query?: never
header?: never
@@ -3026,7 +2968,7 @@ export interface paths {
patch?: never
trace?: never
}
'/proxy/moonvalley/prompts/text-to-image': {
'/proxy/moonvalley/text-to-image': {
parameters: {
query?: never
header?: never
@@ -3115,37 +3057,6 @@ export interface paths {
export type webhooks = Record<string, never>
export interface components {
schemas: {
ClaimMyNodeRequest: {
/** @description GitHub token to verify if the user owns the repo of the node */
GH_TOKEN: string
}
BulkNodeVersionsRequest: {
/** @description List of node ID and version pairs to retrieve */
node_versions: components['schemas']['NodeVersionIdentifier'][]
}
NodeVersionIdentifier: {
/** @description The unique identifier of the node */
node_id: string
/** @description The version of the node */
version: string
}
BulkNodeVersionsResponse: {
/** @description List of retrieved node versions with their status */
node_versions: components['schemas']['BulkNodeVersionResult'][]
}
BulkNodeVersionResult: {
/** @description The node and version identifier */
identifier: components['schemas']['NodeVersionIdentifier']
/**
* @description Status of the retrieval operation
* @enum {string}
*/
status: 'success' | 'not_found' | 'error'
/** @description The retrieved node version data (only present if status is success) */
node_version?: components['schemas']['NodeVersion']
/** @description Error message if retrieval failed (only present if status is error) */
error_message?: string
}
PersonalAccessToken: {
/**
* Format: uuid
@@ -8802,212 +8713,71 @@ export interface components {
| 'computer-use-preview'
| 'computer-use-preview-2025-03-11'
| 'chatgpt-4o-latest'
MoonvalleyTextToVideoInferenceParams: {
/**
* @description Height of the generated video in pixels
* @default 1080
*/
MoonvalleyInferenceParams: {
/** @default 1080 */
height: number
/**
* @description Width of the generated video in pixels
* @default 1920
*/
/** @default 1920 */
width: number
/**
* @description Number of frames to generate
* @default 64
*/
/** @default 64 */
num_frames: number
/**
* @description Frames per second of the generated video
* @default 24
*/
/** @default 24 */
fps: number
/**
* Format: float
* @description Guidance scale for generation control
* @default 10
* @default 12.5
*/
guidance_scale: number
/** @description Random seed for generation (default: random) */
seed?: number
/**
* @description Number of denoising steps
* @default 80
*/
/** @default 80 */
steps: number
/**
* @description Whether to use timestep transformation
* @default true
*/
/** @default true */
use_timestep_transform: boolean
/**
* Format: float
* @description Shift value for generation control
* @default 3
*/
shift_value: number
/**
* @description Whether to use guidance scheduling
* @default true
*/
/** @default true */
use_guidance_schedule: boolean
/**
* @description Whether to add quality guidance
* @default true
*/
/** @default true */
add_quality_guidance: boolean
/**
* Format: float
* @description CLIP value for generation control
* @default 3
*/
clip_value: number
/**
* @description Whether to use negative prompts
* @default false
*/
/** @default false */
use_negative_prompts: boolean
/** @description Negative prompt text */
negative_prompt?: string
/**
* @description Number of warmup steps (calculated based on num_frames)
* @default 0
*/
warmup_steps: number
/**
* @description Number of cooldown steps (calculated based on num_frames)
* @default 75
*/
cooldown_steps: number
warmup_steps?: number
cooldown_steps?: number
/**
* Format: float
* @description Caching coefficient for optimization
* @default 0.3
*/
caching_coefficient: number
/**
* @description Number of caching warmup steps
* @default 3
*/
/** @default 3 */
caching_warmup: number
/**
* @description Number of caching cooldown steps
* @default 3
*/
/** @default 3 */
caching_cooldown: number
/**
* @description Index of the conditioning frame
* @default 0
*/
conditioning_frame_index: number
}
MoonvalleyVideoToVideoInferenceParams: {
/**
* Format: float
* @description Guidance scale for generation control
* @default 15
*/
guidance_scale: number
/** @description Random seed for generation (default: random) */
seed?: number
/**
* @description Number of denoising steps
* @default 80
*/
steps: number
/**
* @description Whether to use timestep transformation
* @default true
*/
use_timestep_transform: boolean
/**
* Format: float
* @description Shift value for generation control
* @default 3
*/
shift_value: number
/**
* @description Whether to use guidance scheduling
* @default true
*/
use_guidance_schedule: boolean
/**
* @description Whether to add quality guidance
* @default true
*/
add_quality_guidance: boolean
/**
* Format: float
* @description CLIP value for generation control
* @default 3
*/
clip_value: number
/**
* @description Whether to use negative prompts
* @default false
*/
use_negative_prompts: boolean
/** @description Negative prompt text */
negative_prompt?: string
/**
* @description Number of warmup steps (calculated based on num_frames)
* @default 24
*/
warmup_steps: number
/**
* @description Number of cooldown steps (calculated based on num_frames)
* @default 36
*/
cooldown_steps: number
/**
* Format: float
* @description Caching coefficient for optimization
* @default 0.3
*/
caching_coefficient: number
/**
* @description Number of caching warmup steps
* @default 3
*/
caching_warmup: number
/**
* @description Number of caching cooldown steps
* @default 3
*/
caching_cooldown: number
/**
* @description Index of the conditioning frame
* @default 0
*/
/** @default 0 */
conditioning_frame_index: number
}
MoonvalleyTextToImageRequest: {
prompt_text?: string
image_url?: string
inference_params?: components['schemas']['MoonvalleyTextToVideoInferenceParams']
inference_params?: components['schemas']['MoonvalleyInferenceParams']
webhook_url?: string
}
MoonvalleyTextToVideoRequest: {
prompt_text?: string
image_url?: string
inference_params?: components['schemas']['MoonvalleyTextToVideoInferenceParams']
inference_params?: components['schemas']['MoonvalleyInferenceParams']
webhook_url?: string
}
MoonvalleyVideoToVideoRequest: {
/** @description Describes the video to generate */
prompt_text: string
/** @description Url to control video */
MoonvalleyVideoToVideoRequest: components['schemas']['MoonvalleyTextToVideoRequest'] & {
video_url: string
/**
* @description Supported types for video control
* @enum {string}
*/
control_type: 'motion_control' | 'pose_control'
/** @description Parameters for video-to-video generation inference */
inference_params?: components['schemas']['MoonvalleyVideoToVideoInferenceParams']
/** @description Optional webhook URL for notifications */
webhook_url?: string
control_type: string
}
MoonvalleyPromptResponse: {
id?: string
@@ -10651,89 +10421,6 @@ export interface operations {
}
}
}
claimMyNode: {
parameters: {
query?: never
header?: never
path: {
publisherId: string
nodeId: string
}
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['ClaimMyNodeRequest']
}
}
responses: {
/** @description Node claimed successfully */
204: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Bad request, invalid input data */
400: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Forbidden - various authorization and permission issues
* Includes:
* - The authenticated user does not have permission to claim the node
* - The node is already claimed by another publisher
* - The GH_TOKEN is invalid
* - The repository is not owned by the authenticated GitHub user
* */
403: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Too many requests - GitHub API rate limit exceeded */
429: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Service unavailable - GitHub API is currently unavailable */
503: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
listNodesForPublisherV2: {
parameters: {
query?: {
@@ -12022,48 +11709,6 @@ export interface operations {
}
}
}
getBulkNodeVersions: {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['BulkNodeVersionsRequest']
}
}
responses: {
/** @description Successfully retrieved node versions */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['BulkNodeVersionsResponse']
}
}
/** @description Bad request, invalid input */
400: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
listAllNodeVersions: {
parameters: {
query?: {
@@ -12189,75 +11834,6 @@ export interface operations {
}
}
}
adminUpdateNode: {
parameters: {
query?: never
header?: never
path: {
nodeId: string
}
cookie?: never
}
requestBody: {
content: {
'application/json': components['schemas']['Node']
}
}
responses: {
/** @description Node updated successfully */
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['Node']
}
}
/** @description Bad request, invalid input data. */
400: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown
}
content?: never
}
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Node not found */
404: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
/** @description Internal server error */
500: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['ErrorResponse']
}
}
}
}
adminUpdateNodeVersion: {
parameters: {
query?: never

View File

@@ -1,208 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
describe('API Feature Flags', () => {
let mockWebSocket: any
const wsEventHandlers: { [key: string]: (event: any) => void } = {}
beforeEach(() => {
// Use fake timers
vi.useFakeTimers()
// Mock WebSocket
mockWebSocket = {
readyState: 1, // WebSocket.OPEN
send: vi.fn(),
close: vi.fn(),
addEventListener: vi.fn(
(event: string, handler: (event: any) => void) => {
wsEventHandlers[event] = handler
}
),
removeEventListener: vi.fn()
}
// Mock WebSocket constructor
global.WebSocket = vi.fn().mockImplementation(() => mockWebSocket) as any
// Reset API state
api.serverFeatureFlags = {}
// Mock getClientFeatureFlags to return test feature flags
vi.spyOn(api, 'getClientFeatureFlags').mockReturnValue({
supports_preview_metadata: true,
api_version: '1.0.0',
capabilities: ['bulk_operations', 'async_nodes']
})
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('Feature flags negotiation', () => {
it('should send client feature flags as first message on connection', async () => {
// Initialize API connection
const initPromise = api.init()
// Simulate connection open
wsEventHandlers['open'](new Event('open'))
// Check that feature flags were sent as first message
expect(mockWebSocket.send).toHaveBeenCalledTimes(1)
const sentMessage = JSON.parse(mockWebSocket.send.mock.calls[0][0])
expect(sentMessage).toEqual({
type: 'feature_flags',
data: {
supports_preview_metadata: true,
api_version: '1.0.0',
capabilities: ['bulk_operations', 'async_nodes']
}
})
// Simulate server response with status message
wsEventHandlers['message']({
data: JSON.stringify({
type: 'status',
data: {
status: { exec_info: { queue_remaining: 0 } },
sid: 'test-sid'
}
})
})
// Simulate server feature flags response
wsEventHandlers['message']({
data: JSON.stringify({
type: 'feature_flags',
data: {
supports_preview_metadata: true,
async_execution: true,
supported_formats: ['webp', 'jpeg', 'png'],
api_version: '1.0.0',
max_upload_size: 104857600,
capabilities: ['isolated_nodes', 'dynamic_models']
}
})
})
await initPromise
// Check that server features were stored
expect(api.serverFeatureFlags).toEqual({
supports_preview_metadata: true,
async_execution: true,
supported_formats: ['webp', 'jpeg', 'png'],
api_version: '1.0.0',
max_upload_size: 104857600,
capabilities: ['isolated_nodes', 'dynamic_models']
})
})
it('should handle server without feature flags support', async () => {
// Initialize API connection
const initPromise = api.init()
// Simulate connection open
wsEventHandlers['open'](new Event('open'))
// Clear the send mock to reset
mockWebSocket.send.mockClear()
// Simulate server response with status but no feature flags
wsEventHandlers['message']({
data: JSON.stringify({
type: 'status',
data: {
status: { exec_info: { queue_remaining: 0 } },
sid: 'test-sid'
}
})
})
// Simulate some other message (not feature flags)
wsEventHandlers['message']({
data: JSON.stringify({
type: 'execution_start',
data: {}
})
})
await initPromise
// Server features should remain empty
expect(api.serverFeatureFlags).toEqual({})
})
})
describe('Feature checking methods', () => {
beforeEach(() => {
// Set up some test features
api.serverFeatureFlags = {
supports_preview_metadata: true,
async_execution: false,
capabilities: ['isolated_nodes', 'dynamic_models']
}
})
it('should check if server supports a boolean feature', () => {
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(true)
expect(api.serverSupportsFeature('async_execution')).toBe(false)
expect(api.serverSupportsFeature('non_existent_feature')).toBe(false)
})
it('should get server feature value', () => {
expect(api.getServerFeature('supports_preview_metadata')).toBe(true)
expect(api.getServerFeature('capabilities')).toEqual([
'isolated_nodes',
'dynamic_models'
])
expect(api.getServerFeature('non_existent_feature')).toBeUndefined()
})
})
describe('Client feature flags configuration', () => {
it('should use mocked client feature flags', () => {
// Verify mocked flags are returned
const clientFlags = api.getClientFeatureFlags()
expect(clientFlags).toEqual({
supports_preview_metadata: true,
api_version: '1.0.0',
capabilities: ['bulk_operations', 'async_nodes']
})
})
it('should return a copy of client feature flags', () => {
// Temporarily restore the real implementation for this test
vi.mocked(api.getClientFeatureFlags).mockRestore()
// Verify that modifications to returned object don't affect original
const clientFlags1 = api.getClientFeatureFlags()
const clientFlags2 = api.getClientFeatureFlags()
// Should be different objects
expect(clientFlags1).not.toBe(clientFlags2)
// But with same content
expect(clientFlags1).toEqual(clientFlags2)
// Modifying one should not affect the other
clientFlags1.test_flag = true
expect(api.getClientFeatureFlags()).not.toHaveProperty('test_flag')
})
})
describe('Integration with preview messages', () => {
it('should affect preview message handling based on feature support', () => {
// Test with metadata support
api.serverFeatureFlags = { supports_preview_metadata: true }
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(true)
// Test without metadata support
api.serverFeatureFlags = {}
expect(api.serverSupportsFeature('supports_preview_metadata')).toBe(false)
})
})
})

View File

@@ -1,192 +0,0 @@
import { describe, expect, it } from 'vitest'
import { COMFY_BASE_DOMAIN, COMFY_WEBSITE_URLS } from '@/config/comfyDomain'
import { PYPI_MIRROR, PYTHON_MIRROR } from '@/constants/mirrors'
import {
COMFY_URLS,
DEVELOPER_TOOLS,
GITHUB_REPOS,
MODEL_SOURCES,
getDesktopGuideUrl
} from '@/constants/urls'
describe('URL Constants', () => {
describe('URL Format Validation', () => {
it('should have valid HTTPS URLs throughout', () => {
const httpsPattern =
/^https:\/\/[a-z0-9]+([-.][a-z0-9]+)*\.[a-z]{2,}(:[0-9]{1,5})?(\/.*)?$/i
// Test COMFY_URLS
expect(COMFY_URLS.website.base).toMatch(httpsPattern)
expect(COMFY_URLS.docs.base).toMatch(httpsPattern)
expect(COMFY_URLS.community.discord).toMatch(httpsPattern)
// Test GITHUB_REPOS
Object.values(GITHUB_REPOS).forEach((url) => {
expect(url).toMatch(httpsPattern)
})
// Test MODEL_SOURCES
Object.values(MODEL_SOURCES.repos).forEach((url) => {
expect(url).toMatch(httpsPattern)
})
// Test DEVELOPER_TOOLS
Object.values(DEVELOPER_TOOLS).forEach((url) => {
expect(url).toMatch(httpsPattern)
})
})
it('should have proper GitHub URL format', () => {
const githubPattern = /^https:\/\/github\.com\/[\w-]+\/[\w-]+(\/[\w-]+)?$/
expect(GITHUB_REPOS.comfyui).toMatch(githubPattern)
expect(GITHUB_REPOS.comfyuiIssues).toMatch(githubPattern)
expect(GITHUB_REPOS.frontend).toMatch(githubPattern)
})
})
describe('Mirror Configuration', () => {
it('should have valid mirror URLs', () => {
const urlPattern =
/^https?:\/\/[a-z0-9]+([-.][a-z0-9]+)*\.[a-z]{2,}(:[0-9]{1,5})?(\/.*)?$/i
expect(PYTHON_MIRROR.mirror).toMatch(urlPattern)
expect(PYTHON_MIRROR.fallbackMirror).toMatch(urlPattern)
expect(PYPI_MIRROR.mirror).toMatch(urlPattern)
expect(PYPI_MIRROR.fallbackMirror).toMatch(urlPattern)
})
})
describe('Domain Configuration', () => {
it('should have valid domain format', () => {
const domainPattern = /^[a-z0-9]+([-.][a-z0-9]+)*\.[a-z]{2,}$/i
expect(COMFY_BASE_DOMAIN).toMatch(domainPattern)
})
it('should construct proper website URLs from base domain', () => {
const expectedBase = `https://www.${COMFY_BASE_DOMAIN}`
expect(COMFY_WEBSITE_URLS.base).toBe(expectedBase)
expect(COMFY_WEBSITE_URLS.termsOfService).toBe(
`${expectedBase}/terms-of-service`
)
expect(COMFY_WEBSITE_URLS.privacy).toBe(`${expectedBase}/privacy`)
})
})
describe('Localization', () => {
it('should handle valid language codes in getLocalized', () => {
const validLanguageCodes = ['en', 'zh', 'ja', 'ko', 'es', 'fr', 'de']
validLanguageCodes.forEach((lang) => {
const result = COMFY_URLS.docs.getLocalized('test-path', lang)
expect(result).toMatch(
/^https:\/\/docs\.comfy\.org\/(([a-z]{2}-[A-Z]{2}\/)?test-path)$/
)
})
})
it('should properly format localized paths', () => {
expect(COMFY_URLS.docs.getLocalized('test-path', 'en')).toBe(
'https://docs.comfy.org/test-path'
)
expect(COMFY_URLS.docs.getLocalized('test-path', 'zh')).toBe(
'https://docs.comfy.org/zh-CN/test-path'
)
expect(COMFY_URLS.docs.getLocalized('', 'en')).toBe(
'https://docs.comfy.org/'
)
expect(COMFY_URLS.docs.getLocalized('', 'zh')).toBe(
'https://docs.comfy.org/zh-CN/'
)
})
it('should generate platform and locale-aware desktop guide URLs', () => {
// Mock navigator for testing
const originalNavigator = global.navigator
// Test Windows platform
Object.defineProperty(global, 'navigator', {
value: { platform: 'Win32' },
writable: true
})
const winUrl = getDesktopGuideUrl('en')
expect(winUrl).toBe('https://docs.comfy.org/installation/desktop/windows')
// Test macOS platform
Object.defineProperty(global, 'navigator', {
value: { platform: 'MacIntel' },
writable: true
})
const macUrl = getDesktopGuideUrl('en')
expect(macUrl).toBe('https://docs.comfy.org/installation/desktop/macos')
// Test Chinese locale with macOS
const zhMacUrl = getDesktopGuideUrl('zh')
expect(zhMacUrl).toBe(
'https://docs.comfy.org/zh-CN/installation/desktop/macos'
)
// Test Chinese locale with Windows
Object.defineProperty(global, 'navigator', {
value: { platform: 'Win32' },
writable: true
})
const zhWinUrl = getDesktopGuideUrl('zh')
expect(zhWinUrl).toBe(
'https://docs.comfy.org/zh-CN/installation/desktop/windows'
)
// Test other locales default to English
const frUrl = getDesktopGuideUrl('fr')
expect(frUrl).toBe('https://docs.comfy.org/installation/desktop/windows')
// Test environment without navigator
Object.defineProperty(global, 'navigator', {
value: undefined,
writable: true
})
const noNavUrl = getDesktopGuideUrl('en')
expect(noNavUrl).toBe(
'https://docs.comfy.org/installation/desktop/windows'
)
// Restore original navigator
Object.defineProperty(global, 'navigator', {
value: originalNavigator,
writable: true
})
})
})
describe('Security', () => {
it('should only use secure HTTPS for external URLs', () => {
const allUrls = [
...Object.values(GITHUB_REPOS),
...Object.values(MODEL_SOURCES.repos),
...Object.values(DEVELOPER_TOOLS),
COMFY_URLS.website.base,
COMFY_URLS.docs.base,
COMFY_URLS.community.discord
]
allUrls.forEach((url) => {
expect(url.startsWith('https://')).toBe(true)
expect(url.startsWith('http://')).toBe(false)
})
})
it('should have valid URL allowlist for model sources', () => {
const urlPattern =
/^https?:\/\/([a-z0-9]+([-.][a-z0-9]+)*\.[a-z]{2,}|localhost)(:[0-9]+)?\/?.*$/i
MODEL_SOURCES.allowedDomains.forEach((url) => {
expect(url).toMatch(urlPattern)
})
})
})
})

View File

@@ -0,0 +1,142 @@
import { LiteGraph } from '@comfyorg/litegraph'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
// Mock LiteGraph
vi.mock('@comfyorg/litegraph', async () => {
const actual = await vi.importActual('@comfyorg/litegraph')
return {
...actual,
LiteGraph: {
registered_node_types: {}
}
}
})
describe('useNodeDefStore', () => {
let store: ReturnType<typeof useNodeDefStore>
beforeEach(() => {
// Set up Pinia
setActivePinia(createPinia())
// Create fresh store instance
store = useNodeDefStore()
// Clear registered node types
LiteGraph.registered_node_types = {}
})
describe('updateNodeDefDisplayName', () => {
const createTestNodeDef = (
name: string,
displayName: string
): ComfyNodeDefV1 => ({
name,
display_name: displayName,
category: 'test',
python_module: 'test_module',
description: 'Test node',
input: { required: {}, optional: {} },
output: [],
output_node: false
})
it('should update display name in nodeDefsByName mapping', () => {
const nodeDef = createTestNodeDef('TestNode', 'Original Display Name')
store.addNodeDef(nodeDef)
store.updateNodeDefDisplayName('TestNode', 'New Display Name')
const updatedNodeDef = store.nodeDefsByName['TestNode']
expect(updatedNodeDef.display_name).toBe('New Display Name')
})
it('should update nodeDefsByDisplayName mapping', () => {
const nodeDef = createTestNodeDef('TestNode', 'Original Display Name')
store.addNodeDef(nodeDef)
store.updateNodeDefDisplayName('TestNode', 'New Display Name')
// Old display name should be removed
expect(
store.nodeDefsByDisplayName['Original Display Name']
).toBeUndefined()
// New display name should be added
expect(store.nodeDefsByDisplayName['New Display Name']).toBeDefined()
expect(store.nodeDefsByDisplayName['New Display Name'].name).toBe(
'TestNode'
)
})
it('should update the object while preserving clone pattern', () => {
const nodeDef = createTestNodeDef('TestNode', 'Original Display Name')
store.addNodeDef(nodeDef)
const originalRef = store.nodeDefsByName['TestNode']
store.updateNodeDefDisplayName('TestNode', 'New Display Name')
const updatedRef = store.nodeDefsByName['TestNode']
// Clone creates a new reference but preserves properties
expect(updatedRef).not.toBe(originalRef)
expect(updatedRef.display_name).toBe('New Display Name')
expect(updatedRef.name).toBe('TestNode')
expect(updatedRef.category).toBe('test')
})
it('should update LiteGraph registered node type title', () => {
const nodeDef = createTestNodeDef('TestNode', 'Original Display Name')
store.addNodeDef(nodeDef)
// Simulate a registered LiteGraph node type
const mockNodeClass = { title: 'Original Display Name' }
LiteGraph.registered_node_types['TestNode'] = mockNodeClass as any
store.updateNodeDefDisplayName('TestNode', 'New Display Name')
expect(mockNodeClass.title).toBe('New Display Name')
})
it('should handle non-existent node type gracefully', () => {
// Should not throw when updating non-existent node
expect(() => {
store.updateNodeDefDisplayName('NonExistentNode', 'New Display Name')
}).not.toThrow()
})
it('should handle missing LiteGraph registered type gracefully', () => {
const nodeDef = createTestNodeDef('TestNode', 'Original Display Name')
store.addNodeDef(nodeDef)
// No registered LiteGraph type
expect(LiteGraph.registered_node_types['TestNode']).toBeUndefined()
// Should not throw
expect(() => {
store.updateNodeDefDisplayName('TestNode', 'New Display Name')
}).not.toThrow()
// Store should still be updated
expect(store.nodeDefsByName['TestNode'].display_name).toBe(
'New Display Name'
)
})
it('should work with ComfyNodeDefImpl instances', () => {
const nodeDef = createTestNodeDef('TestNode', 'Original Display Name')
const nodeDefImpl = new ComfyNodeDefImpl(nodeDef)
// Manually add to store mappings
store.nodeDefsByName['TestNode'] = nodeDefImpl
store.nodeDefsByDisplayName['Original Display Name'] = nodeDefImpl
store.updateNodeDefDisplayName('TestNode', 'New Display Name')
expect(store.nodeDefsByName['TestNode'].display_name).toBe(
'New Display Name'
)
expect(store.nodeDefsByDisplayName['New Display Name']).toBeDefined()
})
})
})