mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-18 01:38:03 +00:00
Compare commits
18 Commits
codex/part
...
drjkl/upda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b7216d935 | ||
|
|
d791351a1c | ||
|
|
922be31b74 | ||
|
|
b19d8d968f | ||
|
|
04c14d98c5 | ||
|
|
ba2d7c5774 | ||
|
|
99faedfa81 | ||
|
|
2182166da0 | ||
|
|
ecfd1f3816 | ||
|
|
f79f0e6066 | ||
|
|
c924d62f0c | ||
|
|
797fd0c78c | ||
|
|
5e1f0ddd03 | ||
|
|
4adf9bdda5 | ||
|
|
0e360677cf | ||
|
|
3f656030f7 | ||
|
|
08c34f2680 | ||
|
|
935142428d |
2
.github/workflows/ci-perf-report.yaml
vendored
2
.github/workflows/ci-perf-report.yaml
vendored
@@ -31,7 +31,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
4
.github/workflows/ci-tests-e2e.yaml
vendored
4
.github/workflows/ci-tests-e2e.yaml
vendored
@@ -63,7 +63,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/ci-website-e2e.yaml
vendored
2
.github/workflows/ci-website-e2e.yaml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
if: ${{ needs.changes.outputs.app-website-changes == 'true' || needs.changes.outputs.packages-changes == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17
|
||||
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.21
|
||||
credentials:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
update-screenshots:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.61.1-noble
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -128,6 +128,9 @@ jobs:
|
||||
|
||||
- name: Remove label
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
# Label cleanup is best-effort; the restricted pull_request token may
|
||||
# lack permission to remove labels and must not fail the run.
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@v8
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.pr-info.outputs.pr-number }}
|
||||
|
||||
@@ -83,8 +83,8 @@ const translations = {
|
||||
'如果您是 ComfyUI 新手,可以从 App 模式开始——这是工作流的简化视图。您随时可以切换回节点图视图以深入了解。'
|
||||
},
|
||||
'showcase.feature3.title': {
|
||||
en: 'Community Workflows on Comfy Hub',
|
||||
'zh-CN': 'Comfy Hub 上的社区工作流'
|
||||
en: 'Community Workflows on Comfy Workflows',
|
||||
'zh-CN': 'Comfy Workflows 上的社区工作流'
|
||||
},
|
||||
'showcase.feature3.description': {
|
||||
en: 'Browse and remix thousands of community-shared workflows. Start from a proven template and customize it to your needs.',
|
||||
@@ -2272,7 +2272,7 @@ const translations = {
|
||||
en: 'Comfy Enterprise',
|
||||
'zh-CN': 'Comfy 企业版'
|
||||
},
|
||||
'nav.comfyHub': { en: 'Comfy Hub', 'zh-CN': 'Comfy Hub' },
|
||||
'nav.comfyHub': { en: 'Comfy Workflows', 'zh-CN': 'Comfy Workflows' },
|
||||
'nav.gallery': { en: 'Gallery', 'zh-CN': '画廊' },
|
||||
'nav.learning': { en: 'Learning', 'zh-CN': '学习' },
|
||||
'nav.blogs': { en: 'Blog', 'zh-CN': '博客' },
|
||||
@@ -4302,8 +4302,8 @@ const translations = {
|
||||
'zh-CN': '{count} 个工作流'
|
||||
},
|
||||
'models.list.contact.label': {
|
||||
en: 'COMFY HUB',
|
||||
'zh-CN': 'COMFY HUB'
|
||||
en: 'COMFY WORKFLOWS',
|
||||
'zh-CN': 'COMFY WORKFLOWS'
|
||||
},
|
||||
'models.showcase.label': { en: 'AI MODELS', 'zh-CN': 'AI 模型' },
|
||||
'models.showcase.heading': {
|
||||
@@ -4339,9 +4339,9 @@ const translations = {
|
||||
'zh-CN': 'Wan 2.2\n文字转视频'
|
||||
},
|
||||
'models.list.contact.heading': {
|
||||
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Hub</a> for the newest workflows.',
|
||||
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Workflows</a> for the newest workflows.',
|
||||
'zh-CN':
|
||||
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Hub</a> 查看最新工作流。'
|
||||
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Workflows</a> 查看最新工作流。'
|
||||
},
|
||||
|
||||
// Payment status pages
|
||||
|
||||
@@ -44,7 +44,7 @@ const dirDescriptions: Record<string, string> = {
|
||||
}
|
||||
|
||||
const dirDesc = dirDescriptions[model.directory] ?? 'an AI model'
|
||||
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Hub, ready to load and customize.`
|
||||
const whatIsDescription = `${displayName} is ${dirDesc}. You can run it locally in ComfyUI with full control over every parameter, or access it through Comfy Cloud. ComfyUI's node-based workflow editor lets you connect ${displayName} with ControlNets, LoRAs, upscalers, and custom nodes to build any pipeline you need. There are ${model.workflowCount} community workflow templates using ${displayName} on Comfy Workflows, ready to load and customize.`
|
||||
|
||||
const pageTitle = `${displayName} in ComfyUI`
|
||||
const pageDescription = `Run ${displayName} in ComfyUI with full parameter control. ${model.workflowCount} community workflow templates, step-by-step tutorials, and free local inference.`
|
||||
@@ -87,7 +87,7 @@ const faqPage: JsonLdNode = {
|
||||
name: `How many ComfyUI workflows use ${displayName}?`,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Hub. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
|
||||
text: `There are ${model.workflowCount} community workflow templates that use ${displayName} on Comfy Workflows. Each template is ready to run in ComfyUI and can be customized to suit your project.`,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,9 +17,7 @@ import { mockSystemStats } from '../fixtures/data/systemStats'
|
||||
|
||||
// Extend the base set with test-specific nodes
|
||||
const nodeDefs = createMockNodeDefinitions({
|
||||
MyCustomNode: {
|
||||
/* ... */
|
||||
}
|
||||
MyCustomNode: {/* ... */}
|
||||
})
|
||||
|
||||
await page.route('**/api/object_info', (route) =>
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { PartnerNodePolicyResponse } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { PromptResponse } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as base } from '@e2e/fixtures/ComfyPage'
|
||||
import { WORKSPACE_FEATURE_FLAG } from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
const DISABLED_NODE = 'DisabledPartnerNode'
|
||||
|
||||
interface PartnerNodeGovernanceFixture {
|
||||
promptRequestCount: () => number
|
||||
}
|
||||
|
||||
function disabledPartnerNode(): ComfyNodeDef {
|
||||
return {
|
||||
name: DISABLED_NODE,
|
||||
display_name: 'Disabled Partner Node',
|
||||
category: 'partner/image/Acme',
|
||||
python_module: 'comfy_api_nodes.acme',
|
||||
description: '',
|
||||
input: {},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
output_node: true,
|
||||
api_node: true
|
||||
}
|
||||
}
|
||||
|
||||
async function setupGovernedWorkspace(page: Page) {
|
||||
await new CloudWorkspaceMockHelper(page).setup()
|
||||
await page.route('**/api/features', (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
...WORKSPACE_FEATURE_FLAG,
|
||||
partner_node_governance_enabled: true
|
||||
} satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/object_info', (route) =>
|
||||
route.fulfill(jsonRoute({ [DISABLED_NODE]: disabledPartnerNode() }))
|
||||
)
|
||||
await page.route(/\/api\/assets(?:\?.*)?$/, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
assets: [],
|
||||
total: 0,
|
||||
has_more: false
|
||||
} satisfies ListAssetsResponse)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/workspace/partner-node-policy', (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
enforcement_enabled: true,
|
||||
nodes: { [DISABLED_NODE]: false }
|
||||
} satisfies PartnerNodePolicyResponse)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export const partnerNodeGovernanceTest = base.extend<{
|
||||
partnerNodeGovernance: PartnerNodeGovernanceFixture
|
||||
}>({
|
||||
partnerNodeGovernance: async ({ page }, use) => {
|
||||
await setupGovernedWorkspace(page)
|
||||
let promptRequests = 0
|
||||
await page.route('**/api/prompt', (route) => {
|
||||
promptRequests++
|
||||
return route.fulfill(
|
||||
jsonRoute({
|
||||
prompt_id: 'unexpected-prompt',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
} satisfies PromptResponse)
|
||||
)
|
||||
})
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
await page.evaluate((nodeType) => {
|
||||
const node = window.LiteGraph!.createNode(nodeType)
|
||||
if (!node) throw new Error(`Failed to create ${nodeType}`)
|
||||
window.app!.rootGraph.add(node)
|
||||
}, DISABLED_NODE)
|
||||
|
||||
await use({ promptRequestCount: () => promptRequests })
|
||||
}
|
||||
})
|
||||
@@ -1,126 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { PartnerNodePolicyResponse } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { WORKSPACE_FEATURE_FLAG } from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
function partnerNode(
|
||||
name: string,
|
||||
displayName: string,
|
||||
provider: string
|
||||
): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: displayName,
|
||||
category: `partner/image/${provider}`,
|
||||
python_module: `comfy_api_nodes.${provider.toLocaleLowerCase()}`,
|
||||
description: '',
|
||||
input: {},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
output_node: false,
|
||||
api_node: true
|
||||
}
|
||||
}
|
||||
|
||||
async function openAllowlist(page: Page) {
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
await page
|
||||
.getByRole('button', { name: /^Settings/ })
|
||||
.first()
|
||||
.click()
|
||||
|
||||
const dialog = page.getByTestId('settings-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
|
||||
|
||||
const content = dialog.getByRole('main')
|
||||
await content.getByRole('tab', { name: 'Allowlist' }).click()
|
||||
await expect(
|
||||
content.getByRole('heading', { name: 'Partner nodes' })
|
||||
).toBeVisible()
|
||||
return content
|
||||
}
|
||||
|
||||
test.describe('Partner node allowlist', { tag: '@cloud' }, () => {
|
||||
test.describe.configure({ timeout: 60_000 })
|
||||
|
||||
test('saves enforcement and allowlist as one policy', async ({ page }) => {
|
||||
await new CloudWorkspaceMockHelper(page).setup()
|
||||
|
||||
await page.route('**/api/features', (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
...WORKSPACE_FEATURE_FLAG,
|
||||
partner_node_governance_enabled: true
|
||||
} satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/object_info', (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
FluxFill: partnerNode('FluxFill', 'Flux Fill', 'BFL'),
|
||||
FluxExpand: partnerNode('FluxExpand', 'Flux Expand', 'BFL'),
|
||||
VeoVideo: partnerNode('VeoVideo', 'Veo Video', 'Google')
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route(/\/api\/assets(?:\?.*)?$/, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
assets: [],
|
||||
total: 0,
|
||||
has_more: false
|
||||
} satisfies ListAssetsResponse)
|
||||
)
|
||||
)
|
||||
|
||||
let policy: PartnerNodePolicyResponse = {
|
||||
enforcement_enabled: false,
|
||||
nodes: { FluxFill: false, FluxExpand: true, VeoVideo: true }
|
||||
} satisfies PartnerNodePolicyResponse
|
||||
const updates: unknown[] = []
|
||||
await page.route('**/api/workspace/partner-node-policy', (route) => {
|
||||
if (route.request().method() === 'PUT') {
|
||||
policy = route.request().postDataJSON() as PartnerNodePolicyResponse
|
||||
updates.push(policy)
|
||||
}
|
||||
return route.fulfill(jsonRoute(policy))
|
||||
})
|
||||
|
||||
const content = await openAllowlist(page)
|
||||
const fluxFill = content.getByRole('switch', { name: 'Allow Flux Fill' })
|
||||
await expect(fluxFill).not.toBeChecked()
|
||||
await expect(
|
||||
content.getByRole('switch', { name: 'Allow Flux Expand' })
|
||||
).toBeChecked()
|
||||
|
||||
await content
|
||||
.getByRole('switch', { name: 'Enforce partner node allowlist' })
|
||||
.click()
|
||||
await fluxFill.click()
|
||||
await content.getByRole('button', { name: 'Save' }).click()
|
||||
|
||||
await expect(page.getByText('Partner node policy saved')).toBeVisible()
|
||||
expect(updates).toEqual([
|
||||
{
|
||||
enforcement_enabled: true,
|
||||
nodes: { FluxFill: true, FluxExpand: true, VeoVideo: true }
|
||||
}
|
||||
] satisfies PartnerNodePolicyResponse[])
|
||||
})
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { ListAssetsResponse } from '@comfyorg/ingest-types'
|
||||
|
||||
import type { PartnerNodePolicyResponse } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { WORKSPACE_FEATURE_FLAG } from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
function partnerNode(name: string): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: name,
|
||||
category: 'partner/image/Acme',
|
||||
python_module: 'comfy_api_nodes.acme',
|
||||
description: '',
|
||||
input: {},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
output_node: false,
|
||||
api_node: true
|
||||
}
|
||||
}
|
||||
|
||||
async function setupGovernedWorkspace(page: Page) {
|
||||
await new CloudWorkspaceMockHelper(page).setup()
|
||||
await page.route('**/api/features', (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
...WORKSPACE_FEATURE_FLAG,
|
||||
partner_node_governance_enabled: true
|
||||
} satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/object_info', (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
AllowedPartnerNode: partnerNode('AllowedPartnerNode'),
|
||||
DisabledPartnerNode: partnerNode('DisabledPartnerNode')
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route(/\/api\/assets(?:\?.*)?$/, (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
assets: [],
|
||||
total: 0,
|
||||
has_more: false
|
||||
} satisfies ListAssetsResponse)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/workspace/partner-node-policy', (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
enforcement_enabled: true,
|
||||
nodes: {
|
||||
AllowedPartnerNode: true,
|
||||
DisabledPartnerNode: false
|
||||
}
|
||||
} satisfies PartnerNodePolicyResponse)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Partner node governance discovery', { tag: '@cloud' }, () => {
|
||||
test('hides disabled nodes from search', async ({ page }) => {
|
||||
await setupGovernedWorkspace(page)
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
await page.evaluate(async () => {
|
||||
await window.app!.extensionManager.setting.set(
|
||||
'Comfy.NodeSearchBoxImpl',
|
||||
'default'
|
||||
)
|
||||
await window.app!.extensionManager.command.execute(
|
||||
'Workspace.SearchBox.Toggle'
|
||||
)
|
||||
})
|
||||
const search = page.getByRole('search')
|
||||
await expect(search).toBeVisible()
|
||||
await search.getByRole('combobox').fill('PartnerNode')
|
||||
|
||||
await expect(search.getByText('AllowedPartnerNode')).toBeVisible()
|
||||
await expect(search.getByText('DisabledPartnerNode')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { partnerNodeGovernanceTest as test } from '@e2e/fixtures/partnerNodeGovernanceFixture'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
test.describe('Partner node governance workbench', { tag: '@cloud' }, () => {
|
||||
test('blocks queueing a disabled partner node', async ({
|
||||
page,
|
||||
partnerNodeGovernance
|
||||
}) => {
|
||||
await page.getByTestId(TestIds.topbar.queueButton).click()
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText(
|
||||
'Workflow blocked by workspace policy'
|
||||
)
|
||||
expect(partnerNodeGovernance.promptRequestCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -31,9 +31,7 @@ export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
/* props */
|
||||
}
|
||||
args: {/* props */}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -37,10 +37,8 @@ const config: KnipConfig = {
|
||||
entry: ['src/scripts/**/*.ts']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3'],
|
||||
ignoreDependencies: [
|
||||
// Weird importmap things
|
||||
'@iconify-json/lucide',
|
||||
'@iconify/json',
|
||||
'@primeuix/forms',
|
||||
'@primeuix/styled',
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
"@vee-validate/zod": "catalog:",
|
||||
"@vueuse/core": "catalog:",
|
||||
"@vueuse/integrations": "catalog:",
|
||||
"@vueuse/router": "^14.2.0",
|
||||
"@vueuse/router": "^14.3.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-serialize": "^0.13.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
@@ -111,7 +111,7 @@
|
||||
"extendable-media-recorder": "^9.2.27",
|
||||
"extendable-media-recorder-wav-encoder": "^7.0.129",
|
||||
"firebase": "catalog:",
|
||||
"fuse.js": "^7.0.0",
|
||||
"fuse.js": "7.0.0",
|
||||
"glob": "catalog:",
|
||||
"jsonata": "catalog:",
|
||||
"loglevel": "^1.9.2",
|
||||
@@ -219,5 +219,5 @@
|
||||
"node": ">=25 <26",
|
||||
"pnpm": ">=11.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.3.0"
|
||||
"packageManager": "pnpm@11.13.1"
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
--color-gold-500: #fdab34;
|
||||
--color-gold-600: #fd9903;
|
||||
|
||||
--color-credit: #fabc25;
|
||||
--color-credit: #ddbd31;
|
||||
|
||||
--color-coral-500: #f75951;
|
||||
--color-coral-600: #e04e48;
|
||||
@@ -78,9 +78,9 @@
|
||||
--color-interface-panel-job-progress-border: var(--base-foreground);
|
||||
|
||||
--color-blue-selection: rgb(from var(--color-azure-600) r g b / 0.3);
|
||||
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b/ 0.15);
|
||||
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b/ 0.1);
|
||||
--color-modal-tag: rgb(from var(--color-smoke-400) r g b/ 0.4);
|
||||
--color-node-hover-100: rgb(from var(--color-charcoal-800) r g b / 0.15);
|
||||
--color-node-hover-200: rgb(from var(--color-charcoal-800) r g b / 0.1);
|
||||
--color-modal-tag: rgb(from var(--color-smoke-400) r g b / 0.4);
|
||||
--color-alpha-charcoal-600-30: color-mix(
|
||||
in srgb,
|
||||
var(--color-charcoal-600) 30%,
|
||||
@@ -107,7 +107,7 @@
|
||||
--color-highlight: var(--p-primary-color);
|
||||
|
||||
/* Special Colors (temporary) */
|
||||
--color-dark-elevation-1.5: rgba(from white r g b/ 0.015);
|
||||
--color-dark-elevation-1-5: rgba(from white r g b / 0.015);
|
||||
--color-dark-elevation-2: rgba(from white r g b / 0.03);
|
||||
}
|
||||
|
||||
@@ -140,26 +140,6 @@
|
||||
--button-surface: var(--color-white);
|
||||
--button-surface-contrast: var(--color-black);
|
||||
|
||||
--subscription-button-gradient:
|
||||
linear-gradient(
|
||||
315deg,
|
||||
rgb(105 230 255 / 0.15) 0%,
|
||||
rgb(99 73 233 / 0.5) 100%
|
||||
),
|
||||
radial-gradient(
|
||||
70.71% 70.71% at 50% 50%,
|
||||
rgb(62 99 222 / 0.15) 0.01%,
|
||||
rgb(66 0 123 / 0.5) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
92deg,
|
||||
#d000ff 0.38%,
|
||||
#b009fe 37.07%,
|
||||
#3e1ffc 65.17%,
|
||||
#009dff 103.86%
|
||||
),
|
||||
linear-gradient(var(--color-button-surface, #2d2e32));
|
||||
|
||||
/* Code styling colors for help menu*/
|
||||
--code-text-color: rgb(0 122 255 / 1);
|
||||
--code-bg-color: rgb(96 165 250 / 0.2);
|
||||
@@ -1873,9 +1853,9 @@ audio.comfy-audio.empty-audio-widget {
|
||||
/* Shadow Cover TOP */
|
||||
linear-gradient(--value(--color-*) 30%, transparent) center top,
|
||||
/* Shadow Cover BOTTOM */
|
||||
linear-gradient(transparent, --value(--color-*) 70%) center bottom,
|
||||
linear-gradient(transparent, --value(--color-*) 70%) center bottom,
|
||||
/* Shadow TOP */
|
||||
radial-gradient(
|
||||
radial-gradient(
|
||||
farthest-side at 50% 0,
|
||||
color-mix(in oklab, --value(--color-*), #777777 35%),
|
||||
60%,
|
||||
@@ -1883,7 +1863,7 @@ audio.comfy-audio.empty-audio-widget {
|
||||
)
|
||||
center top,
|
||||
/* Shadow BOTTOM */
|
||||
radial-gradient(
|
||||
radial-gradient(
|
||||
farthest-side at 50% 100%,
|
||||
color-mix(in oklab, --value(--color-*), #777777 35%),
|
||||
60%,
|
||||
|
||||
9447
pnpm-lock.yaml
generated
9447
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -24,26 +24,26 @@ catalog:
|
||||
'@iconify/tailwind4': ^1.2.3
|
||||
'@iconify/tools': ^5.0.3
|
||||
'@iconify/utils': ^3.1.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.5.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.5.1
|
||||
'@lobehub/i18n-cli': ^1.26.1
|
||||
'@lucide/vue': ^1.17.0
|
||||
'@pinia/testing': ^1.0.3
|
||||
'@playwright/test': ^1.58.1
|
||||
'@playwright/test': ^1.61.1
|
||||
'@primeuix/forms': 0.0.2
|
||||
'@primeuix/styled': 0.3.2
|
||||
'@primeuix/utils': ^0.3.2
|
||||
'@primevue/core': ^4.2.5
|
||||
'@primevue/forms': ^4.2.5
|
||||
'@primevue/core': 4.2.5
|
||||
'@primevue/forms': 4.2.5
|
||||
'@primevue/icons': 4.2.5
|
||||
'@primevue/themes': ^4.2.5
|
||||
'@primevue/themes': 4.2.5
|
||||
'@sentry/vite-plugin': ^4.6.0
|
||||
'@sentry/vue': ^10.32.1
|
||||
'@sparkjsdev/spark': ^2.1.0
|
||||
'@storybook/addon-docs': ^10.2.10
|
||||
'@storybook/addon-docs': ^10.5.0
|
||||
'@storybook/addon-mcp': 0.1.6
|
||||
'@storybook/vue3': ^10.2.10
|
||||
'@storybook/vue3-vite': ^10.2.10
|
||||
'@tailwindcss/vite': ^4.3.0
|
||||
'@storybook/vue3': ^10.5.0
|
||||
'@storybook/vue3-vite': ^10.5.0
|
||||
'@tailwindcss/vite': ^4.3.2
|
||||
'@tanstack/vue-virtual': ^3.13.12
|
||||
'@testing-library/jest-dom': ^6.9.1
|
||||
'@testing-library/user-event': ^14.6.1
|
||||
@@ -64,11 +64,11 @@ catalog:
|
||||
'@types/three': ^0.184.1
|
||||
'@vee-validate/zod': ^4.15.1
|
||||
'@vercel/analytics': ^2.0.1
|
||||
'@vitejs/plugin-vue': ^6.0.0
|
||||
'@vitest/coverage-v8': ^4.0.16
|
||||
'@vitest/ui': ^4.0.16
|
||||
'@vueuse/core': ^14.2.0
|
||||
'@vueuse/integrations': ^14.2.0
|
||||
'@vitejs/plugin-vue': ^6.0.8
|
||||
'@vitest/coverage-v8': ^4.1.10
|
||||
'@vitest/ui': ^4.1.10
|
||||
'@vueuse/core': ^14.3.0
|
||||
'@vueuse/integrations': ^14.3.0
|
||||
'@webgpu/types': ^0.1.66
|
||||
algoliasearch: ^5.21.0
|
||||
astro: ^6.4.2
|
||||
@@ -76,75 +76,75 @@ catalog:
|
||||
class-variance-authority: ^0.7.1
|
||||
cross-env: ^10.1.0
|
||||
cva: 1.0.0-beta.4
|
||||
dompurify: ^3.4.5
|
||||
dompurify: 3.4.7
|
||||
dotenv: ^16.4.5
|
||||
eslint: ^10.4.0
|
||||
eslint: ^10.7.0
|
||||
eslint-config-prettier: ^10.1.8
|
||||
eslint-import-resolver-typescript: ^4.4.4
|
||||
eslint-plugin-better-tailwindcss: ^4.3.1
|
||||
eslint-plugin-import-x: ^4.16.2
|
||||
eslint-plugin-oxlint: 1.69.0
|
||||
eslint-plugin-playwright: ^2.10.1
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
eslint-plugin-testing-library: ^7.16.1
|
||||
eslint-import-resolver-typescript: ^4.4.5
|
||||
eslint-plugin-better-tailwindcss: ^4.6.1
|
||||
eslint-plugin-import-x: ^4.17.1
|
||||
eslint-plugin-oxlint: 1.73.0
|
||||
eslint-plugin-playwright: ^2.10.5
|
||||
eslint-plugin-storybook: ^10.5.0
|
||||
eslint-plugin-testing-library: ^7.16.2
|
||||
eslint-plugin-unused-imports: ^4.4.1
|
||||
eslint-plugin-vue: ^10.9.1
|
||||
eslint-plugin-vue: ^10.9.2
|
||||
fallow: ^2.102.0
|
||||
fast-check: ^4.5.3
|
||||
firebase: ^11.6.0
|
||||
glob: ^13.0.6
|
||||
globals: ^16.5.0
|
||||
gsap: ^3.14.2
|
||||
happy-dom: ^20.8.9
|
||||
happy-dom: 20.9.0
|
||||
husky: ^9.1.7
|
||||
jiti: 2.6.1
|
||||
jiti: 2.7.0
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
knip: ^6.15.0
|
||||
knip: ^6.27.0
|
||||
lenis: ^1.3.21
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
minisearch: ^7.2.0
|
||||
mixpanel-browser: ^2.71.0
|
||||
monocart-coverage-reports: ^2.12.9
|
||||
oxfmt: ^0.54.0
|
||||
oxlint: ^1.69.0
|
||||
oxlint-tsgolint: ^0.23.0
|
||||
oxfmt: ^0.59.0
|
||||
oxlint: ^1.74.0
|
||||
oxlint-tsgolint: ^0.24.0
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^3.0.4
|
||||
postcss-html: ^1.8.0
|
||||
posthog-js: ^1.358.1
|
||||
pretty-bytes: ^7.1.0
|
||||
primeicons: ^7.0.0
|
||||
primevue: ^4.2.5
|
||||
primevue: 4.2.5
|
||||
reka-ui: 2.5.0
|
||||
rollup-plugin-visualizer: ^6.0.4
|
||||
storybook: ^10.2.10
|
||||
storybook: ^10.5.0
|
||||
stylelint: ^16.26.1
|
||||
tailwindcss: ^4.3.0
|
||||
tailwindcss: ^4.3.2
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
three: ^0.184.0
|
||||
tsx: ^4.15.6
|
||||
tw-animate-css: ^1.3.8
|
||||
typegpu: ^0.8.2
|
||||
typescript: ^5.9.3
|
||||
typescript-eslint: ^8.60.0
|
||||
typescript-eslint: ^8.64.0
|
||||
unplugin-icons: ^22.5.0
|
||||
unplugin-typegpu: 0.8.0
|
||||
unplugin-vue-components: ^30.0.0
|
||||
uuid: ^11.1.1
|
||||
vee-validate: ^4.15.1
|
||||
vite: ^8.0.13
|
||||
vite: ^8.1.4
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
vite-plugin-vue-devtools: ^8.0.0
|
||||
vitest: ^4.1.0
|
||||
vue: ^3.5.34
|
||||
vue-component-type-helpers: ^3.2.1
|
||||
vue-eslint-parser: ^10.4.0
|
||||
vite-plugin-vue-devtools: ^8.1.5
|
||||
vitest: ^4.1.10
|
||||
vue: ^3.5.39
|
||||
vue-component-type-helpers: ^3.3.7
|
||||
vue-eslint-parser: ^10.4.1
|
||||
vue-i18n: ^9.14.5
|
||||
vue-router: ^4.4.3
|
||||
vue-tsc: ^3.2.5
|
||||
vue-tsc: ^3.3.7
|
||||
vuefire: ^3.2.1
|
||||
wwobjloader2: ^6.2.1
|
||||
yjs: ^13.6.27
|
||||
|
||||
@@ -32,11 +32,11 @@ let _runWhenIdle: (
|
||||
* Falls back to setTimeout-based scheduling in browsers without native support.
|
||||
*/
|
||||
export let runWhenGlobalIdle: (
|
||||
callback: (idle: IdleDeadline) => void,
|
||||
timeout?: number
|
||||
) => IDisposable
|
||||
callback: (idle: IdleDeadline) => void,
|
||||
timeout?: number
|
||||
) => IDisposable
|
||||
|
||||
// Self-invoking function to set up the idle callback implementation
|
||||
// Self-invoking function to set up the idle callback implementation
|
||||
;(function () {
|
||||
const safeGlobal: GlobalWindow = globalThis as GlobalWindow
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[lucide--component] text-sm text-credit" />
|
||||
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
@@ -48,7 +48,7 @@
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCloud && isFreeTier"
|
||||
variant="gradient"
|
||||
variant="subscribe"
|
||||
size="sm"
|
||||
data-testid="upgrade-to-add-credits-button"
|
||||
@click="handleUpgradeToAddCredits"
|
||||
@@ -72,7 +72,7 @@
|
||||
:fluid="false"
|
||||
:label="$t('subscription.subscribeToComfyCloud')"
|
||||
size="sm"
|
||||
button-variant="gradient"
|
||||
button-variant="subscribe"
|
||||
@subscribed="handleSubscribed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Button
|
||||
v-if="isCloud && isFreeTier"
|
||||
class="mr-2 shrink-0 whitespace-nowrap"
|
||||
variant="gradient"
|
||||
variant="subscribe"
|
||||
size="sm"
|
||||
data-testid="topbar-subscribe-button"
|
||||
@click="handleClick"
|
||||
|
||||
@@ -24,8 +24,8 @@ export const buttonVariants = cva({
|
||||
base: 'bg-base-background text-base-foreground hover:bg-secondary-background-hover',
|
||||
tertiary:
|
||||
'bg-tertiary-background text-base-foreground hover:bg-tertiary-background-hover',
|
||||
gradient:
|
||||
'border-transparent bg-(image:--subscription-button-gradient) text-white hover:opacity-90'
|
||||
subscribe:
|
||||
'border-transparent bg-credit text-charcoal-800 hover:opacity-80'
|
||||
},
|
||||
size: {
|
||||
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
|
||||
@@ -58,7 +58,7 @@ const variants = [
|
||||
'base',
|
||||
'tertiary',
|
||||
'overlay-white',
|
||||
'gradient'
|
||||
'subscribe'
|
||||
] as const satisfies Array<ButtonVariants['variant']>
|
||||
const sizes = [
|
||||
'sm',
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mockCopy = vi.fn()
|
||||
const mockWriteText = vi.fn()
|
||||
const mockToastAdd = vi.fn()
|
||||
|
||||
vi.mock('@vueuse/core', () => ({
|
||||
useClipboard: vi.fn(() => ({
|
||||
copy: mockCopy,
|
||||
copied: ref(false),
|
||||
isSupported: computed(() => true)
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: vi.fn(() => ({
|
||||
add: mockToastAdd
|
||||
@@ -22,34 +13,31 @@ vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
|
||||
|
||||
describe('useCopyToClipboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.mocked(useClipboard).mockReturnValue({
|
||||
copy: mockCopy,
|
||||
copied: ref(false),
|
||||
isSupported: computed(() => true),
|
||||
text: ref('')
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: mockWriteText }
|
||||
})
|
||||
})
|
||||
|
||||
it('shows success toast when modern clipboard succeeds', async () => {
|
||||
mockCopy.mockResolvedValue(undefined)
|
||||
mockWriteText.mockResolvedValue(undefined)
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('hello')
|
||||
expect(mockWriteText).toHaveBeenCalledWith('hello')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to legacy when modern clipboard fails', async () => {
|
||||
mockCopy.mockRejectedValue(new Error('Not allowed'))
|
||||
mockWriteText.mockRejectedValue(new Error('Not allowed'))
|
||||
document.execCommand = vi.fn(() => true)
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
@@ -62,7 +50,7 @@ describe('useCopyToClipboard', () => {
|
||||
})
|
||||
|
||||
it('shows error toast when both modern and legacy fail', async () => {
|
||||
mockCopy.mockRejectedValue(new Error('Not allowed'))
|
||||
mockWriteText.mockRejectedValue(new Error('Not allowed'))
|
||||
document.execCommand = vi.fn(() => false)
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
@@ -73,19 +61,17 @@ describe('useCopyToClipboard', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('falls through to legacy when isSupported is false', async () => {
|
||||
vi.mocked(useClipboard).mockReturnValue({
|
||||
copy: mockCopy,
|
||||
copied: ref(false),
|
||||
isSupported: computed(() => false),
|
||||
text: ref('')
|
||||
it('falls through to legacy when clipboard API is unavailable', async () => {
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: undefined
|
||||
})
|
||||
document.execCommand = vi.fn(() => true)
|
||||
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
await copyToClipboard('hello')
|
||||
|
||||
expect(mockCopy).not.toHaveBeenCalled()
|
||||
expect(mockWriteText).not.toHaveBeenCalled()
|
||||
expect(document.execCommand).toHaveBeenCalledWith('copy')
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
@@ -20,15 +19,14 @@ function legacyCopy(text: string): boolean {
|
||||
}
|
||||
|
||||
export function useCopyToClipboard() {
|
||||
const { copy, isSupported } = useClipboard()
|
||||
const toast = useToast()
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
let success = false
|
||||
|
||||
try {
|
||||
if (isSupported.value) {
|
||||
await copy(text)
|
||||
if (navigator.clipboard) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
success = true
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -182,30 +182,6 @@ describe('useFeatureFlags', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('partnerNodeGovernanceEnabled', () => {
|
||||
afterEach(() => {
|
||||
remoteConfig.value = {}
|
||||
})
|
||||
|
||||
it('uses the workspace eligibility flag', () => {
|
||||
remoteConfig.value = { partner_node_governance_enabled: true }
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
expect(flags.partnerNodeGovernanceEnabled).toBe(true)
|
||||
})
|
||||
|
||||
it('defaults to false when the remote flag is unset', () => {
|
||||
vi.mocked(api.getServerFeature).mockImplementation(
|
||||
(_path, defaultValue) => defaultValue
|
||||
)
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
|
||||
expect(flags.partnerNodeGovernanceEnabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dev override via localStorage', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
|
||||
@@ -24,7 +24,6 @@ export enum ServerFeatureFlag {
|
||||
ONBOARDING_SURVEY_ENABLED = 'onboarding_survey_enabled',
|
||||
LINEAR_TOGGLE_ENABLED = 'linear_toggle_enabled',
|
||||
TEAM_WORKSPACES_ENABLED = 'team_workspaces_enabled',
|
||||
PARTNER_NODE_GOVERNANCE_ENABLED = 'partner_node_governance_enabled',
|
||||
USER_SECRETS_ENABLED = 'user_secrets_enabled',
|
||||
NODE_REPLACEMENTS = 'node_replacements',
|
||||
NODE_LIBRARY_ESSENTIALS_ENABLED = 'node_library_essentials_enabled',
|
||||
@@ -135,13 +134,6 @@ export function useFeatureFlags() {
|
||||
cachedTeamWorkspacesEnabled
|
||||
)
|
||||
},
|
||||
get partnerNodeGovernanceEnabled() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.PARTNER_NODE_GOVERNANCE_ENABLED,
|
||||
remoteConfig.value.partner_node_governance_enabled,
|
||||
false
|
||||
)
|
||||
},
|
||||
get userSecretsEnabled() {
|
||||
return resolveFlag(
|
||||
ServerFeatureFlag.USER_SECRETS_ENABLED,
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import type { QueuePromptGuard } from '@/services/queuePromptGuardService'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
|
||||
const {
|
||||
addToast,
|
||||
isNodeDisabled,
|
||||
registerQueuePromptGuard,
|
||||
registerExtension,
|
||||
usePartnerNodeGovernanceStore
|
||||
} = vi.hoisted(() => {
|
||||
const isNodeDisabled = vi.fn()
|
||||
return {
|
||||
addToast: vi.fn(),
|
||||
isNodeDisabled,
|
||||
registerQueuePromptGuard: vi.fn<
|
||||
(id: string, guard: QueuePromptGuard) => () => void
|
||||
>(() => () => {}),
|
||||
registerExtension: vi.fn<(extension: ComfyExtension) => void>(),
|
||||
usePartnerNodeGovernanceStore: vi.fn(() => ({ isNodeDisabled }))
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/partnerNodeGovernanceStore', () => ({
|
||||
usePartnerNodeGovernanceStore
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: addToast })
|
||||
}))
|
||||
|
||||
vi.mock('@/services/queuePromptGuardService', () => ({
|
||||
registerQueuePromptGuard
|
||||
}))
|
||||
|
||||
vi.mock('@/services/extensionService', () => ({
|
||||
useExtensionService: () => ({ registerExtension })
|
||||
}))
|
||||
|
||||
describe('cloudPartnerNodeGovernance', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
async function loadExtension(): Promise<ComfyExtension> {
|
||||
await import('./cloudPartnerNodeGovernance')
|
||||
const extension = registerExtension.mock.calls[0]?.[0]
|
||||
if (!extension)
|
||||
throw new Error('Expected governance extension registration')
|
||||
return extension
|
||||
}
|
||||
|
||||
it('initializes governance during Cloud setup', async () => {
|
||||
const extension = await loadExtension()
|
||||
expect(extension.name).toBe('Comfy.Cloud.PartnerNodeGovernance')
|
||||
|
||||
extension.setup?.(fromPartial<ComfyApp>({}))
|
||||
|
||||
expect(usePartnerNodeGovernanceStore).toHaveBeenCalledOnce()
|
||||
expect(registerQueuePromptGuard).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('blocks queueing when the graph contains a disabled partner node', async () => {
|
||||
const graph = new LGraph()
|
||||
graph.add(new LGraphNode('DisabledPartnerNode', 'DisabledPartnerNode'))
|
||||
isNodeDisabled.mockImplementation(
|
||||
(nodeType) => nodeType === 'DisabledPartnerNode'
|
||||
)
|
||||
const extension = await loadExtension()
|
||||
extension.setup?.(fromPartial<ComfyApp>({}))
|
||||
const guard = registerQueuePromptGuard.mock.calls[0]?.[1]
|
||||
if (!guard) throw new Error('Expected queue guard registration')
|
||||
|
||||
const result = await guard({ rootGraph: graph })
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(isNodeDisabled).toHaveBeenCalledWith('DisabledPartnerNode')
|
||||
expect(addToast).toHaveBeenCalledWith({
|
||||
severity: 'error',
|
||||
summary: 'Workflow blocked by workspace policy',
|
||||
detail:
|
||||
'Remove disabled partner nodes from this workflow or ask a workspace owner to update the policy.',
|
||||
life: 6000
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,35 +0,0 @@
|
||||
import { t } from '@/i18n'
|
||||
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { registerQueuePromptGuard } from '@/services/queuePromptGuardService'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
const QUEUE_GUARD_ID = 'workspace.partner-node-governance'
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Cloud.PartnerNodeGovernance',
|
||||
|
||||
setup: () => {
|
||||
usePartnerNodeGovernanceStore()
|
||||
registerQueuePromptGuard(QUEUE_GUARD_ID, (context) => {
|
||||
const governanceStore = usePartnerNodeGovernanceStore()
|
||||
let hasDisabledPartnerNode = false
|
||||
forEachNode(context.rootGraph, (node) => {
|
||||
if (node.type && governanceStore.isNodeDisabled(node.type)) {
|
||||
hasDisabledPartnerNode = true
|
||||
}
|
||||
})
|
||||
|
||||
if (!hasDisabledPartnerNode) return true
|
||||
|
||||
useToastStore().add({
|
||||
severity: 'error',
|
||||
summary: t('workspacePanel.allowlist.runBlocked.summary'),
|
||||
detail: t('workspacePanel.allowlist.runBlocked.detail'),
|
||||
life: 6000
|
||||
})
|
||||
return false
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -38,7 +38,6 @@ if (isCloud) {
|
||||
await import('./cloudRemoteConfig')
|
||||
await import('./cloudBadges')
|
||||
await import('./cloudSessionCookie')
|
||||
await import('./cloudPartnerNodeGovernance')
|
||||
}
|
||||
|
||||
// Feedback button for cloud and nightly builds
|
||||
|
||||
@@ -2919,31 +2919,7 @@
|
||||
"tabs": {
|
||||
"dashboard": "Dashboard",
|
||||
"planCredits": "Plan & Credits",
|
||||
"membersCount": "Members ({count})",
|
||||
"allowlist": "Allowlist"
|
||||
},
|
||||
"allowlist": {
|
||||
"title": "Partner nodes",
|
||||
"description": "Choose which partner nodes are allowed when workspace enforcement is enabled.",
|
||||
"searchPlaceholder": "Search partner nodes...",
|
||||
"loading": "Loading partner node policy...",
|
||||
"loadError": "Partner node policy could not be loaded.",
|
||||
"retry": "Try again",
|
||||
"empty": "No partner nodes are available.",
|
||||
"noMatches": "No partner nodes match your search.",
|
||||
"allowedCount": "{enabled} of {total} allowed",
|
||||
"nodeToggle": "Allow {name}",
|
||||
"saved": "Partner node policy saved",
|
||||
"saveError": "Partner node policy could not be saved.",
|
||||
"runBlocked": {
|
||||
"summary": "Workflow blocked by workspace policy",
|
||||
"detail": "Remove disabled partner nodes from this workflow or ask a workspace owner to update the policy."
|
||||
},
|
||||
"enforcement": {
|
||||
"title": "Enforce partner node allowlist",
|
||||
"description": "Block partner nodes that are not explicitly allowed.",
|
||||
"toggle": "Enforce partner node allowlist"
|
||||
}
|
||||
"membersCount": "Members ({count})"
|
||||
},
|
||||
"dashboard": {
|
||||
"placeholder": "Dashboard workspace settings"
|
||||
|
||||
@@ -19,7 +19,9 @@ export const hasNonEmptyValue = (
|
||||
return true
|
||||
}
|
||||
|
||||
export const isOtherValue = (current: string | string[] | undefined): boolean =>
|
||||
export const isOtherValue = (
|
||||
current: string | string[] | undefined
|
||||
): boolean =>
|
||||
Array.isArray(current) ? current.includes('other') : current === 'other'
|
||||
|
||||
const conditionMatches = (
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
<div v-if="showActionButton" class="flex flex-col gap-3">
|
||||
<Button
|
||||
v-if="isFreeTier"
|
||||
variant="gradient"
|
||||
variant="subscribe"
|
||||
size="lg"
|
||||
class="w-full font-normal"
|
||||
@click="handleUpgradeToAddCredits"
|
||||
|
||||
@@ -43,7 +43,7 @@ const label = computed(() =>
|
||||
v-if="hasInvalidNodes"
|
||||
class="flex w-full items-center justify-center gap-2"
|
||||
>
|
||||
<i class="icon-[comfy--credits] bg-amber-400" />
|
||||
<i class="icon-[comfy--credits] bg-credit" />
|
||||
{{ t('actionbar.freeTierPartner') }}
|
||||
</div>
|
||||
<div v-else class="flex w-full items-center justify-between">
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<i
|
||||
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
|
||||
class="icon-[comfy--credits] size-4 shrink-0 bg-credit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Button
|
||||
:size
|
||||
:disabled="disabled"
|
||||
:variant="buttonVariant === 'gradient' ? 'gradient' : 'primary'"
|
||||
:variant="buttonVariant === 'subscribe' ? 'subscribe' : 'primary'"
|
||||
:class="cn('font-bold', fluid && 'w-full')"
|
||||
@click="handleSubscribe"
|
||||
>
|
||||
@@ -28,7 +28,7 @@ const {
|
||||
} = defineProps<{
|
||||
label?: string
|
||||
size?: 'sm' | 'lg'
|
||||
buttonVariant?: 'default' | 'gradient'
|
||||
buttonVariant?: 'default' | 'subscribe'
|
||||
fluid?: boolean
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
showDelay: 600
|
||||
}"
|
||||
class="subscribe-to-run-button h-8 gap-1.5 rounded-lg px-4 whitespace-nowrap"
|
||||
variant="gradient"
|
||||
variant="subscribe"
|
||||
size="unset"
|
||||
data-testid="subscribe-to-run-button"
|
||||
@click="handleSubscribeToRun"
|
||||
|
||||
@@ -107,7 +107,6 @@ export type RemoteConfig = {
|
||||
manager_survey_url?: string
|
||||
linear_toggle_enabled?: boolean
|
||||
team_workspaces_enabled?: boolean
|
||||
partner_node_governance_enabled?: boolean
|
||||
user_secrets_enabled?: boolean
|
||||
node_library_essentials_enabled?: boolean
|
||||
free_tier_credits?: number
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getPartnerNodePolicy,
|
||||
PartnerNodePolicyApiError,
|
||||
updatePartnerNodePolicy
|
||||
} from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
|
||||
const mockFetchApi = vi.fn()
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fetchApi: (...args: unknown[]) => mockFetchApi(...args)
|
||||
}
|
||||
}))
|
||||
|
||||
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(body), init)
|
||||
}
|
||||
|
||||
describe('partnerNodePolicyApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('normalizes the configured policy response', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({
|
||||
enforcement_enabled: true,
|
||||
nodes: { AllowedNode: true, DisabledNode: false }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(getPartnerNodePolicy()).resolves.toEqual({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true, DisabledNode: false }
|
||||
})
|
||||
expect(mockFetchApi).toHaveBeenCalledWith(
|
||||
'/workspace/partner-node-policy',
|
||||
{ cache: 'no-store' }
|
||||
)
|
||||
})
|
||||
|
||||
it('maps 404 to an unconfigured policy', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({}, { status: 404, statusText: 'Not Found' })
|
||||
)
|
||||
|
||||
await expect(getPartnerNodePolicy()).resolves.toBeNull()
|
||||
})
|
||||
|
||||
it('preserves non-404 status codes for policy decisions', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({}, { status: 503, statusText: 'Service Unavailable' })
|
||||
)
|
||||
|
||||
await expect(getPartnerNodePolicy()).rejects.toEqual(
|
||||
new PartnerNodePolicyApiError(503, 'Service Unavailable')
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects malformed policy responses', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({ enforcement_enabled: 'yes', nodes: [] })
|
||||
)
|
||||
|
||||
await expect(getPartnerNodePolicy()).rejects.toMatchObject({
|
||||
name: 'ZodError'
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes and validates a whole-policy update', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({
|
||||
enforcement_enabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: true }
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
updatePartnerNodePolicy({
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: true }
|
||||
})
|
||||
).resolves.toEqual({
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: true }
|
||||
})
|
||||
expect(mockFetchApi).toHaveBeenCalledWith(
|
||||
'/workspace/partner-node-policy',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enforcement_enabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: true }
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves update failures for the editor', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({}, { status: 409, statusText: 'Conflict' })
|
||||
)
|
||||
|
||||
await expect(
|
||||
updatePartnerNodePolicy({ enforcementEnabled: false, nodes: {} })
|
||||
).rejects.toEqual(new PartnerNodePolicyApiError(409, 'Conflict'))
|
||||
})
|
||||
|
||||
it('rejects malformed update responses', async () => {
|
||||
mockFetchApi.mockResolvedValue(
|
||||
jsonResponse({ enforcement_enabled: false, nodes: [] })
|
||||
)
|
||||
|
||||
await expect(
|
||||
updatePartnerNodePolicy({ enforcementEnabled: false, nodes: {} })
|
||||
).rejects.toMatchObject({ name: 'ZodError' })
|
||||
})
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
const PARTNER_NODE_POLICY_PATH = '/workspace/partner-node-policy'
|
||||
|
||||
const partnerNodePolicyResponseSchema = z.object({
|
||||
enforcement_enabled: z.boolean(),
|
||||
nodes: z.record(z.string(), z.boolean())
|
||||
})
|
||||
|
||||
export type PartnerNodePolicyResponse = z.infer<
|
||||
typeof partnerNodePolicyResponseSchema
|
||||
>
|
||||
|
||||
export interface PartnerNodePolicy {
|
||||
enforcementEnabled: boolean
|
||||
nodes: Readonly<Record<string, boolean>>
|
||||
}
|
||||
|
||||
export class PartnerNodePolicyApiError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'PartnerNodePolicyApiError'
|
||||
}
|
||||
}
|
||||
|
||||
function parsePartnerNodePolicy(data: unknown): PartnerNodePolicy {
|
||||
const policy = partnerNodePolicyResponseSchema.parse(data)
|
||||
return {
|
||||
enforcementEnabled: policy.enforcement_enabled,
|
||||
nodes: policy.nodes
|
||||
}
|
||||
}
|
||||
|
||||
function throwResponseError(response: Response): never {
|
||||
throw new PartnerNodePolicyApiError(response.status, response.statusText)
|
||||
}
|
||||
|
||||
export async function getPartnerNodePolicy(): Promise<PartnerNodePolicy | null> {
|
||||
const response = await api.fetchApi(PARTNER_NODE_POLICY_PATH, {
|
||||
cache: 'no-store'
|
||||
})
|
||||
if (response.status === 404) return null
|
||||
if (!response.ok) throwResponseError(response)
|
||||
|
||||
return parsePartnerNodePolicy(await response.json())
|
||||
}
|
||||
|
||||
export async function updatePartnerNodePolicy(
|
||||
policy: PartnerNodePolicy
|
||||
): Promise<PartnerNodePolicy> {
|
||||
const response = await api.fetchApi(PARTNER_NODE_POLICY_PATH, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enforcement_enabled: policy.enforcementEnabled,
|
||||
nodes: policy.nodes
|
||||
})
|
||||
})
|
||||
if (!response.ok) throwResponseError(response)
|
||||
|
||||
return parsePartnerNodePolicy(await response.json())
|
||||
}
|
||||
@@ -59,7 +59,7 @@
|
||||
<!-- Credits Section -->
|
||||
|
||||
<div class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[lucide--component] text-sm text-credit" />
|
||||
<Skeleton
|
||||
v-if="isLoadingBalance"
|
||||
width="4rem"
|
||||
@@ -82,7 +82,7 @@
|
||||
<!-- Upgrade to add credits (free tier) -->
|
||||
<Button
|
||||
v-if="isActiveSubscription && permissions.canTopUp && isFreeTier"
|
||||
variant="gradient"
|
||||
variant="subscribe"
|
||||
size="sm"
|
||||
data-testid="upgrade-to-add-credits-button"
|
||||
@click="handleUpgradeToAddCredits"
|
||||
@@ -110,7 +110,7 @@
|
||||
: $t('workspaceSwitcher.subscribe')
|
||||
"
|
||||
size="sm"
|
||||
button-variant="gradient"
|
||||
button-variant="subscribe"
|
||||
/>
|
||||
<Button
|
||||
v-if="
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
{{ t('subscription.monthlyCreditsPerMemberLabel') }}
|
||||
</span>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[lucide--component] text-sm text-credit" />
|
||||
<span
|
||||
class="font-inter text-sm/normal font-bold text-base-foreground"
|
||||
>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
{{ $t(creditsRefillLabelKey) }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-credit" />
|
||||
<span class="font-bold text-base-foreground">
|
||||
{{ refillCredits }}
|
||||
</span>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-credit" />
|
||||
<span>{{ displayCredits }} {{ $t('subscription.perMonth') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-base-foreground">{{ refillLabel }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-credit" />
|
||||
<span class="font-bold text-base-foreground">{{
|
||||
refillCredits
|
||||
}}</span>
|
||||
@@ -89,7 +89,7 @@
|
||||
{{ $t('subscription.preview.creditsRefillMonthlyTo') }}
|
||||
</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400" />
|
||||
<i class="icon-[comfy--credits] size-4 shrink-0 bg-credit" />
|
||||
<span class="font-bold text-base-foreground">{{
|
||||
monthlyRefillCredits
|
||||
}}</span>
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
<div class="mt-auto flex flex-col gap-1">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<i
|
||||
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
|
||||
class="icon-[comfy--credits] size-4 shrink-0 bg-credit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
@@ -244,7 +244,7 @@
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<i
|
||||
class="icon-[comfy--credits] size-4 shrink-0 bg-amber-400"
|
||||
class="icon-[comfy--credits] size-4 shrink-0 bg-credit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import type { PartnerNodePolicyStatus } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
|
||||
|
||||
import PartnerNodeAllowlistPanel from './PartnerNodeAllowlistPanel.vue'
|
||||
|
||||
const mockSavePolicy = vi.fn()
|
||||
const mockLoadPolicy = vi.fn()
|
||||
const mockToastAdd = vi.fn()
|
||||
|
||||
const state = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
return {
|
||||
governedWorkspaceId: ref<string | null>('workspace-one'),
|
||||
partnerNodes: ref([
|
||||
{ id: 'FluxFill', name: 'Flux Fill', provider: 'BFL' },
|
||||
{ id: 'FluxExpand', name: 'Flux Expand', provider: 'BFL' },
|
||||
{ id: 'VeoVideo', name: 'Veo Video', provider: 'Google' }
|
||||
]),
|
||||
policy: ref<PartnerNodePolicy | null>({
|
||||
enforcementEnabled: true,
|
||||
nodes: {
|
||||
FluxFill: false,
|
||||
FluxExpand: true,
|
||||
VeoVideo: true,
|
||||
RetiredNode: false
|
||||
}
|
||||
}),
|
||||
status: ref<PartnerNodePolicyStatus>('configured')
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('pinia', async (importOriginal) => {
|
||||
const actual = await importOriginal()
|
||||
return {
|
||||
...(actual as object),
|
||||
storeToRefs: (store: Record<string, unknown>) => store
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/partnerNodeGovernanceStore', () => ({
|
||||
usePartnerNodeGovernanceStore: () => ({
|
||||
...state,
|
||||
savePolicy: mockSavePolicy,
|
||||
loadPolicy: mockLoadPolicy
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/toastStore', () => ({
|
||||
useToastStore: () => ({ add: mockToastAdd })
|
||||
}))
|
||||
|
||||
const ToggleSwitchStub = defineComponent({
|
||||
name: 'ToggleSwitch',
|
||||
props: {
|
||||
modelValue: Boolean,
|
||||
disabled: Boolean,
|
||||
ariaLabel: String
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<button
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:aria-label="ariaLabel"
|
||||
:disabled="disabled"
|
||||
@click="$emit('update:modelValue', !modelValue)"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
const SearchInputStub = defineComponent({
|
||||
name: 'SearchInput',
|
||||
props: {
|
||||
modelValue: { type: String, required: true },
|
||||
placeholder: String,
|
||||
disabled: Boolean
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: `
|
||||
<input
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
`
|
||||
})
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderPanel() {
|
||||
return render(PartnerNodeAllowlistPanel, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
ToggleSwitch: ToggleSwitchStub,
|
||||
SearchInput: SearchInputStub
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('PartnerNodeAllowlistPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
state.governedWorkspaceId.value = 'workspace-one'
|
||||
state.partnerNodes.value = [
|
||||
{ id: 'FluxFill', name: 'Flux Fill', provider: 'BFL' },
|
||||
{ id: 'FluxExpand', name: 'Flux Expand', provider: 'BFL' },
|
||||
{ id: 'VeoVideo', name: 'Veo Video', provider: 'Google' }
|
||||
]
|
||||
state.policy.value = {
|
||||
enforcementEnabled: true,
|
||||
nodes: {
|
||||
FluxFill: false,
|
||||
FluxExpand: true,
|
||||
VeoVideo: true,
|
||||
RetiredNode: false
|
||||
}
|
||||
}
|
||||
state.status.value = 'configured'
|
||||
mockSavePolicy.mockResolvedValue(true)
|
||||
})
|
||||
|
||||
it('groups catalog nodes and reflects the configured allowlist', () => {
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByText('BFL')).toBeInTheDocument()
|
||||
expect(screen.getByText('Google')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('switch', {
|
||||
name: 'Enforce partner node allowlist'
|
||||
})
|
||||
).toBeChecked()
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Allow Flux Fill' })
|
||||
).not.toBeChecked()
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Allow Flux Expand' })
|
||||
).toBeChecked()
|
||||
expect(screen.getByText('1 of 2 allowed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves one whole policy while preserving enforcement and hidden entries', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Allow Flux Fill' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(mockSavePolicy).toHaveBeenCalledWith({
|
||||
enforcementEnabled: true,
|
||||
nodes: {
|
||||
FluxFill: true,
|
||||
FluxExpand: true,
|
||||
VeoVideo: true,
|
||||
RetiredNode: false
|
||||
}
|
||||
})
|
||||
expect(mockToastAdd).toHaveBeenCalledWith({
|
||||
severity: 'success',
|
||||
summary: 'Partner node policy saved',
|
||||
life: 2000
|
||||
})
|
||||
})
|
||||
|
||||
it('saves enforcement with the existing allowlist', async () => {
|
||||
const user = userEvent.setup()
|
||||
state.policy.value = {
|
||||
enforcementEnabled: false,
|
||||
nodes: {
|
||||
FluxFill: false,
|
||||
FluxExpand: true,
|
||||
VeoVideo: true,
|
||||
RetiredNode: false
|
||||
}
|
||||
}
|
||||
renderPanel()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('switch', {
|
||||
name: 'Enforce partner node allowlist'
|
||||
})
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(mockSavePolicy).toHaveBeenCalledWith({
|
||||
enforcementEnabled: true,
|
||||
nodes: {
|
||||
FluxFill: false,
|
||||
FluxExpand: true,
|
||||
VeoVideo: true,
|
||||
RetiredNode: false
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('treats an unconfigured workspace as allow-all draft', async () => {
|
||||
state.policy.value = null
|
||||
state.status.value = 'unconfigured'
|
||||
renderPanel()
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
screen.getByRole('switch', {
|
||||
name: 'Enforce partner node allowlist'
|
||||
})
|
||||
).not.toBeChecked()
|
||||
const nodeToggles = screen.getAllByRole('switch', { name: /^Allow / })
|
||||
expect(nodeToggles).toHaveLength(3)
|
||||
for (const toggle of nodeToggles) {
|
||||
expect(toggle).toBeChecked()
|
||||
}
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('filters by provider and node identity', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await user.type(
|
||||
screen.getByPlaceholderText('Search partner nodes...'),
|
||||
'veo'
|
||||
)
|
||||
|
||||
expect(screen.getByText('Veo Video')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Flux Fill')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Google')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers retry when the policy is unavailable', async () => {
|
||||
const user = userEvent.setup()
|
||||
state.status.value = 'unavailable'
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Try again' }))
|
||||
|
||||
expect(mockLoadPolicy).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Partner node policy could not be loaded.'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps draft changes and surfaces a save failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockSavePolicy.mockRejectedValue(new Error('Conflict'))
|
||||
renderPanel()
|
||||
|
||||
const toggle = screen.getByRole('switch', { name: 'Allow Flux Fill' })
|
||||
await user.click(toggle)
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(toggle).toBeChecked()
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'Partner node policy could not be saved.'
|
||||
)
|
||||
})
|
||||
|
||||
it('releases the new workspace when a previous save fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
let rejectSave: (reason?: unknown) => void = () => undefined
|
||||
mockSavePolicy.mockImplementation(
|
||||
() =>
|
||||
new Promise<boolean>((_resolve, reject) => {
|
||||
rejectSave = reject
|
||||
})
|
||||
)
|
||||
renderPanel()
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: 'Allow Flux Fill' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Allow Flux Fill' })
|
||||
).toBeDisabled()
|
||||
|
||||
state.governedWorkspaceId.value = 'workspace-two'
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Allow Flux Fill' })
|
||||
).not.toBeDisabled()
|
||||
|
||||
rejectSave(new Error('Conflict'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(
|
||||
screen.queryByText('Allowlist could not be saved.')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,300 +0,0 @@
|
||||
<template>
|
||||
<div class="grow overflow-auto pt-6">
|
||||
<div
|
||||
class="flex size-full min-h-0 flex-col gap-5 rounded-2xl border border-interface-stroke p-6"
|
||||
>
|
||||
<div class="flex items-start gap-6">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="m-0 text-base font-semibold text-base-foreground">
|
||||
{{ $t('workspacePanel.allowlist.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 mb-0 text-sm text-muted-foreground">
|
||||
{{ $t('workspacePanel.allowlist.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('workspacePanel.allowlist.searchPlaceholder')"
|
||||
size="lg"
|
||||
class="w-64 shrink-0"
|
||||
:disabled="!isReady"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isReady"
|
||||
class="flex min-h-14 items-center gap-4 rounded-xl border border-interface-stroke/60 px-4 py-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="text-sm text-base-foreground">
|
||||
{{ $t('workspacePanel.allowlist.enforcement.title') }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-muted-foreground">
|
||||
{{ $t('workspacePanel.allowlist.enforcement.description') }}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
:model-value="draftEnforcementEnabled"
|
||||
:disabled="isSaving"
|
||||
:aria-label="$t('workspacePanel.allowlist.enforcement.toggle')"
|
||||
@update:model-value="setEnforcementEnabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status === 'loading'"
|
||||
role="status"
|
||||
class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--loader-circle] size-4 animate-spin" />
|
||||
{{ $t('workspacePanel.allowlist.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="status === 'error' || status === 'unavailable'"
|
||||
role="alert"
|
||||
class="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<span>{{ $t('workspacePanel.allowlist.loadError') }}</span>
|
||||
<Button variant="muted-textonly" @click="loadPolicy">
|
||||
{{ $t('workspacePanel.allowlist.retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="isReady && partnerNodes.length === 0"
|
||||
class="flex flex-1 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.allowlist.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="isReady" class="min-h-0 flex-1 overflow-y-auto">
|
||||
<div
|
||||
v-if="groups.length === 0"
|
||||
class="flex min-h-32 items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
{{ $t('workspacePanel.allowlist.noMatches') }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<section
|
||||
v-for="group in groups"
|
||||
:key="group.provider"
|
||||
class="mb-5 last:mb-0"
|
||||
>
|
||||
<div
|
||||
class="mb-2 flex items-center justify-between text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
<span>{{ group.provider }}</span>
|
||||
<span>
|
||||
{{
|
||||
$t('workspacePanel.allowlist.allowedCount', {
|
||||
enabled: group.enabledCount,
|
||||
total: group.nodes.length
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="overflow-hidden rounded-xl border border-interface-stroke/60"
|
||||
>
|
||||
<div
|
||||
v-for="node in group.nodes"
|
||||
:key="node.id"
|
||||
class="flex min-h-14 items-center gap-4 border-b border-interface-stroke/40 px-4 last:border-b-0"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm text-base-foreground">
|
||||
{{ node.name }}
|
||||
</div>
|
||||
<div
|
||||
v-if="node.name !== node.id"
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{{ node.id }}
|
||||
</div>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
:model-value="draftNodes[node.id]"
|
||||
:disabled="isSaving"
|
||||
:aria-label="
|
||||
$t('workspacePanel.allowlist.nodeToggle', {
|
||||
name: node.name
|
||||
})
|
||||
"
|
||||
@update:model-value="
|
||||
(enabled: boolean) => setNodeEnabled(node.id, enabled)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="isReady" class="flex min-h-10 items-center justify-end gap-3">
|
||||
<span v-if="saveError" role="alert" class="text-destructive text-sm">
|
||||
{{ $t('workspacePanel.allowlist.saveError') }}
|
||||
</span>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
:disabled="!hasChanges"
|
||||
:loading="isSaving"
|
||||
@click="save"
|
||||
>
|
||||
{{ $t('g.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
|
||||
import type { PartnerNodeCatalogItem } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
|
||||
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
interface PartnerNodeGroup {
|
||||
provider: string
|
||||
nodes: PartnerNodeCatalogItem[]
|
||||
enabledCount: number
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const governanceStore = usePartnerNodeGovernanceStore()
|
||||
const { governedWorkspaceId, partnerNodes, policy, status } =
|
||||
storeToRefs(governanceStore)
|
||||
const toastStore = useToastStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const draftNodes = ref<Record<string, boolean>>({})
|
||||
const draftEnforcementEnabled = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const saveError = ref(false)
|
||||
let saveGeneration = 0
|
||||
|
||||
const isReady = computed(
|
||||
() => status.value === 'configured' || status.value === 'unconfigured'
|
||||
)
|
||||
|
||||
function originalNodeValue(nodeId: string): boolean {
|
||||
return policy.value ? policy.value.nodes[nodeId] === true : true
|
||||
}
|
||||
|
||||
function resetDraft(): void {
|
||||
draftEnforcementEnabled.value = policy.value?.enforcementEnabled ?? false
|
||||
draftNodes.value = Object.fromEntries(
|
||||
partnerNodes.value.map((node) => [node.id, originalNodeValue(node.id)])
|
||||
)
|
||||
saveError.value = false
|
||||
}
|
||||
|
||||
watch([governedWorkspaceId, partnerNodes, policy], resetDraft, {
|
||||
immediate: true
|
||||
})
|
||||
|
||||
watch(
|
||||
governedWorkspaceId,
|
||||
() => {
|
||||
saveGeneration += 1
|
||||
isSaving.value = false
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
)
|
||||
|
||||
const hasChanges = computed(
|
||||
() =>
|
||||
draftEnforcementEnabled.value !==
|
||||
(policy.value?.enforcementEnabled ?? false) ||
|
||||
partnerNodes.value.some(
|
||||
(node) => draftNodes.value[node.id] !== originalNodeValue(node.id)
|
||||
)
|
||||
)
|
||||
|
||||
const groups = computed<PartnerNodeGroup[]>(() => {
|
||||
const query = searchQuery.value.trim().toLocaleLowerCase()
|
||||
const byProvider = new Map<string, PartnerNodeCatalogItem[]>()
|
||||
|
||||
for (const node of partnerNodes.value) {
|
||||
if (
|
||||
query &&
|
||||
![node.name, node.id, node.provider].some((value) =>
|
||||
value.toLocaleLowerCase().includes(query)
|
||||
)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const nodes = byProvider.get(node.provider) ?? []
|
||||
nodes.push(node)
|
||||
byProvider.set(node.provider, nodes)
|
||||
}
|
||||
|
||||
return [...byProvider.entries()]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([provider, nodes]) => {
|
||||
const sortedNodes = nodes.sort((left, right) =>
|
||||
left.name.localeCompare(right.name)
|
||||
)
|
||||
return {
|
||||
provider,
|
||||
nodes: sortedNodes,
|
||||
enabledCount: sortedNodes.filter(
|
||||
(node) => draftNodes.value[node.id] === true
|
||||
).length
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function setNodeEnabled(nodeId: string, enabled: boolean): void {
|
||||
draftNodes.value[nodeId] = enabled
|
||||
saveError.value = false
|
||||
}
|
||||
|
||||
function setEnforcementEnabled(enabled: boolean): void {
|
||||
draftEnforcementEnabled.value = enabled
|
||||
saveError.value = false
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
if (!hasChanges.value || isSaving.value) return
|
||||
|
||||
const generation = saveGeneration
|
||||
isSaving.value = true
|
||||
saveError.value = false
|
||||
try {
|
||||
const applied = await governanceStore.savePolicy({
|
||||
enforcementEnabled: draftEnforcementEnabled.value,
|
||||
nodes: {
|
||||
...(policy.value?.nodes ?? {}),
|
||||
...draftNodes.value
|
||||
}
|
||||
})
|
||||
if (!applied || generation !== saveGeneration) return
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: t('workspacePanel.allowlist.saved'),
|
||||
life: 2000
|
||||
})
|
||||
} catch {
|
||||
if (generation !== saveGeneration) return
|
||||
saveError.value = true
|
||||
} finally {
|
||||
if (generation === saveGeneration) {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadPolicy(): void {
|
||||
void governanceStore.loadPolicy()
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,4 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -10,19 +9,12 @@ import WorkspacePanelContent from './WorkspacePanelContent.vue'
|
||||
const mockFetchMembers = vi.fn()
|
||||
const mockFetchPendingInvites = vi.fn()
|
||||
|
||||
const {
|
||||
mockGovernedWorkspaceId,
|
||||
mockMembers,
|
||||
mockWorkspaceRole,
|
||||
mockWorkspaceType
|
||||
} = vi.hoisted(() => {
|
||||
const { mockMembers, mockWorkspaceType } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
|
||||
return {
|
||||
mockGovernedWorkspaceId: ref<string | null>('workspace-one'),
|
||||
mockMembers: ref<WorkspaceMember[]>([]),
|
||||
mockWorkspaceRole: ref<'owner' | 'member'>('owner'),
|
||||
mockWorkspaceType: ref<'personal' | 'team'>('team')
|
||||
}
|
||||
})
|
||||
@@ -49,20 +41,16 @@ vi.mock('@/platform/workspace/stores/teamWorkspaceStore', () => {
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/composables/useWorkspaceUI', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const { ref } = require('vue') as typeof import('vue')
|
||||
return {
|
||||
useWorkspaceUI: () => ({
|
||||
workspaceType: mockWorkspaceType,
|
||||
workspaceRole: mockWorkspaceRole
|
||||
workspaceRole: ref('owner')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/platform/workspace/stores/partnerNodeGovernanceStore', () => ({
|
||||
usePartnerNodeGovernanceStore: () => ({
|
||||
governedWorkspaceId: mockGovernedWorkspaceId
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue',
|
||||
() => ({
|
||||
@@ -87,11 +75,6 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/components/dialogs/settings/PartnerNodeAllowlistPanel.vue',
|
||||
() => ({ default: { template: '<div />' } })
|
||||
)
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -111,9 +94,8 @@ function createMember(id: string): WorkspaceMember {
|
||||
}
|
||||
}
|
||||
|
||||
function renderComponent(defaultTab?: string) {
|
||||
function renderComponent() {
|
||||
return render(WorkspacePanelContent, {
|
||||
props: { defaultTab },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: { WorkspaceProfilePic: true }
|
||||
@@ -125,8 +107,6 @@ describe('WorkspacePanelContent billing banner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMembers.value = []
|
||||
mockGovernedWorkspaceId.value = 'workspace-one'
|
||||
mockWorkspaceRole.value = 'owner'
|
||||
mockWorkspaceType.value = 'team'
|
||||
})
|
||||
|
||||
@@ -146,8 +126,6 @@ describe('WorkspacePanelContent members tab label', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockMembers.value = []
|
||||
mockGovernedWorkspaceId.value = 'workspace-one'
|
||||
mockWorkspaceRole.value = 'owner'
|
||||
mockWorkspaceType.value = 'team'
|
||||
})
|
||||
|
||||
@@ -178,56 +156,3 @@ describe('WorkspacePanelContent members tab label', () => {
|
||||
expect(mockFetchPendingInvites).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorkspacePanelContent allowlist tab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGovernedWorkspaceId.value = 'workspace-one'
|
||||
mockWorkspaceRole.value = 'owner'
|
||||
mockWorkspaceType.value = 'team'
|
||||
})
|
||||
|
||||
it('shows the allowlist for an eligible workspace owner', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the allowlist from workspace members', () => {
|
||||
mockWorkspaceRole.value = 'member'
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the allowlist when governance is ineligible', () => {
|
||||
mockGovernedWorkspaceId.value = null
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to a valid tab when allowlist access is lost', async () => {
|
||||
renderComponent('allowlist')
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
|
||||
mockWorkspaceRole.value = 'member'
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: 'workspacePanel.tabs.allowlist' })
|
||||
).toBeNull()
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'workspacePanel.tabs.planCredits' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,18 +39,6 @@
|
||||
: $t('workspacePanel.members.header')
|
||||
}}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
v-if="showAllowlistTab"
|
||||
value="allowlist"
|
||||
:class="
|
||||
cn(
|
||||
tabTriggerBase,
|
||||
activeTab === 'allowlist' ? tabTriggerActive : tabTriggerInactive
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ $t('workspacePanel.tabs.allowlist') }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<BillingStatusBanner class="mt-4" />
|
||||
@@ -61,26 +49,21 @@
|
||||
<TabsContent value="members" class="mt-4">
|
||||
<MembersPanelContent :key="workspaceRole" />
|
||||
</TabsContent>
|
||||
<TabsContent v-if="showAllowlistTab" value="allowlist" class="mt-4">
|
||||
<PartnerNodeAllowlistPanel />
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
|
||||
|
||||
import WorkspaceProfilePic from '@/platform/workspace/components/WorkspaceProfilePic.vue'
|
||||
import BillingStatusBanner from '@/platform/workspace/components/dialogs/settings/BillingStatusBanner.vue'
|
||||
import MembersPanelContent from '@/platform/workspace/components/dialogs/settings/MembersPanelContent.vue'
|
||||
import PartnerNodeAllowlistPanel from '@/platform/workspace/components/dialogs/settings/PartnerNodeAllowlistPanel.vue'
|
||||
import SubscriptionPanelContentWorkspace from '@/platform/workspace/components/SubscriptionPanelContentWorkspace.vue'
|
||||
import { useWorkspaceUI } from '@/platform/workspace/composables/useWorkspaceUI'
|
||||
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
@@ -100,19 +83,9 @@ const { workspaceName, members } = storeToRefs(workspaceStore)
|
||||
const { fetchMembers, fetchPendingInvites } = workspaceStore
|
||||
|
||||
const { workspaceType, workspaceRole } = useWorkspaceUI()
|
||||
const { governedWorkspaceId } = storeToRefs(usePartnerNodeGovernanceStore())
|
||||
const isPersonalWorkspace = computed(() => workspaceType.value === 'personal')
|
||||
const showAllowlistTab = computed(
|
||||
() => !!governedWorkspaceId.value && workspaceRole.value === 'owner'
|
||||
)
|
||||
const activeTab = ref(defaultTab)
|
||||
|
||||
watch(showAllowlistTab, (isVisible) => {
|
||||
if (!isVisible && activeTab.value === 'allowlist') {
|
||||
activeTab.value = defaultTab === 'allowlist' ? 'plan' : defaultTab
|
||||
}
|
||||
})
|
||||
|
||||
// Per design, the tab counts members only when there is more than the owner
|
||||
const showMembersTabCount = computed(
|
||||
() => !isPersonalWorkspace.value && members.value.length > 1
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphNode, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type * as PartnerNodePolicyApi from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import { PartnerNodePolicyApiError } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import { usePartnerNodeGovernanceStore } from '@/platform/workspace/stores/partnerNodeGovernanceStore'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
const mockGetPartnerNodePolicy = vi.hoisted(() => vi.fn())
|
||||
const mockUpdatePartnerNodePolicy = vi.hoisted(() => vi.fn())
|
||||
const mockFlags = vi.hoisted(() => ({
|
||||
teamWorkspacesEnabled: true,
|
||||
partnerNodeGovernanceEnabled: true
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/platform/workspace/api/partnerNodePolicyApi',
|
||||
async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof PartnerNodePolicyApi>()
|
||||
return {
|
||||
...actual,
|
||||
getPartnerNodePolicy: mockGetPartnerNodePolicy,
|
||||
updatePartnerNodePolicy: mockUpdatePartnerNodePolicy
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
function nodeDef(
|
||||
name: string,
|
||||
overrides: Partial<ComfyNodeDef> = {}
|
||||
): ComfyNodeDef {
|
||||
return {
|
||||
name,
|
||||
display_name: `Display ${name}`,
|
||||
category: 'partner/image/Provider',
|
||||
python_module: 'comfy_api_nodes.provider',
|
||||
description: '',
|
||||
input: {},
|
||||
output: [],
|
||||
output_is_list: [],
|
||||
output_name: [],
|
||||
output_node: false,
|
||||
api_node: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function activateWorkspace(id: string, type: 'personal' | 'team' = 'team') {
|
||||
const store = useTeamWorkspaceStore()
|
||||
store.workspaces = [
|
||||
{
|
||||
id,
|
||||
name: id,
|
||||
type,
|
||||
role: 'owner',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
isSubscribed: false,
|
||||
subscriptionPlan: null,
|
||||
subscriptionTier: null,
|
||||
members: [],
|
||||
pendingInvites: []
|
||||
}
|
||||
]
|
||||
store.activeWorkspaceId = id
|
||||
}
|
||||
|
||||
async function createLoadedStore() {
|
||||
const store = usePartnerNodeGovernanceStore()
|
||||
await vi.waitFor(() => expect(store.status).not.toBe('loading'))
|
||||
return store
|
||||
}
|
||||
|
||||
describe('partnerNodeGovernanceStore', () => {
|
||||
let store: ReturnType<typeof usePartnerNodeGovernanceStore> | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
vi.clearAllMocks()
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
mockFlags.partnerNodeGovernanceEnabled = true
|
||||
mockGetPartnerNodePolicy.mockResolvedValue(null)
|
||||
mockUpdatePartnerNodePolicy.mockImplementation(
|
||||
async (policy: PartnerNodePolicy) => policy
|
||||
)
|
||||
activateWorkspace('workspace-one')
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
nodeDef('AllowedNode'),
|
||||
nodeDef('DisabledNode'),
|
||||
nodeDef('UnreviewedNode'),
|
||||
nodeDef('CoreNode', {
|
||||
api_node: false,
|
||||
category: 'sampling',
|
||||
python_module: 'nodes'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete LiteGraph.registered_node_types.LegacyGovernedNode
|
||||
store?.$dispose()
|
||||
store = undefined
|
||||
})
|
||||
|
||||
it('composes partner-node catalog metadata from object info', async () => {
|
||||
useNodeDefStore().updateNodeDefs([
|
||||
nodeDef('PartnerNode', {
|
||||
display_name: 'Partner Node',
|
||||
category: 'partner/video/Acme'
|
||||
}),
|
||||
nodeDef('CoreNode', { api_node: false })
|
||||
])
|
||||
|
||||
store = await createLoadedStore()
|
||||
|
||||
expect(store.partnerNodes).toEqual([
|
||||
{ id: 'PartnerNode', name: 'Partner Node', provider: 'Acme' }
|
||||
])
|
||||
})
|
||||
|
||||
it('treats 404 as unconfigured and allows every node', async () => {
|
||||
store = await createLoadedStore()
|
||||
|
||||
expect(store.status).toBe('unconfigured')
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('does not block nodes while enforcement is off', async () => {
|
||||
mockGetPartnerNodePolicy.mockResolvedValue({
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: false }
|
||||
} satisfies PartnerNodePolicy)
|
||||
|
||||
store = await createLoadedStore()
|
||||
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows only explicit true rules while enforcement is on', async () => {
|
||||
mockGetPartnerNodePolicy.mockResolvedValue({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true, DisabledNode: false }
|
||||
} satisfies PartnerNodePolicy)
|
||||
|
||||
store = await createLoadedStore()
|
||||
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(true)
|
||||
expect(store.isNodeDisabled('UnreviewedNode')).toBe(true)
|
||||
expect(store.isNodeDisabled('CoreNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('filters disabled nodes out of discovery', async () => {
|
||||
mockGetPartnerNodePolicy.mockResolvedValue({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true, DisabledNode: false }
|
||||
} satisfies PartnerNodePolicy)
|
||||
|
||||
store = await createLoadedStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
expect(nodeDefStore.visibleNodeDefs.map((node) => node.name)).toEqual([
|
||||
'AllowedNode',
|
||||
'CoreNode'
|
||||
])
|
||||
expect(nodeDefStore.nodeSearchService.searchNode('DisabledNode')).toEqual(
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the litegraph legacy menu in sync with policy changes', async () => {
|
||||
class LegacyGovernedNode extends LGraphNode {}
|
||||
LiteGraph.registered_node_types.LegacyGovernedNode = LegacyGovernedNode
|
||||
useNodeDefStore().addNodeDef(nodeDef('LegacyGovernedNode'))
|
||||
mockGetPartnerNodePolicy.mockResolvedValue({
|
||||
enforcementEnabled: true,
|
||||
nodes: { LegacyGovernedNode: false }
|
||||
} satisfies PartnerNodePolicy)
|
||||
|
||||
store = await createLoadedStore()
|
||||
|
||||
expect(LegacyGovernedNode.skip_list).toBe(true)
|
||||
|
||||
mockGetPartnerNodePolicy.mockResolvedValue({
|
||||
enforcementEnabled: false,
|
||||
nodes: { LegacyGovernedNode: false }
|
||||
} satisfies PartnerNodePolicy)
|
||||
await store.loadPolicy()
|
||||
await nextTick()
|
||||
|
||||
expect(LegacyGovernedNode.skip_list).toBe(false)
|
||||
})
|
||||
|
||||
it('fails closed for a 503 from an enforcing workspace', async () => {
|
||||
mockGetPartnerNodePolicy.mockRejectedValue(
|
||||
new PartnerNodePolicyApiError(503, 'Service Unavailable')
|
||||
)
|
||||
|
||||
store = await createLoadedStore()
|
||||
|
||||
expect(store.status).toBe('unavailable')
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
|
||||
expect(store.isNodeDisabled('CoreNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('fails open during initial loading and a generic failure', async () => {
|
||||
let rejectLoad!: (error: Error) => void
|
||||
mockGetPartnerNodePolicy.mockReturnValue(
|
||||
new Promise((_, reject) => {
|
||||
rejectLoad = reject
|
||||
})
|
||||
)
|
||||
|
||||
store = usePartnerNodeGovernanceStore()
|
||||
|
||||
expect(store.status).toBe('loading')
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
|
||||
rejectLoad(new Error('Network error'))
|
||||
await vi.waitFor(() => expect(store?.status).toBe('error'))
|
||||
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('stays fail-closed while retrying an unavailable policy', async () => {
|
||||
mockGetPartnerNodePolicy.mockRejectedValueOnce(
|
||||
new PartnerNodePolicyApiError(503, 'Service Unavailable')
|
||||
)
|
||||
store = await createLoadedStore()
|
||||
let rejectRetry!: (error: Error) => void
|
||||
mockGetPartnerNodePolicy.mockReturnValueOnce(
|
||||
new Promise((_, reject) => {
|
||||
rejectRetry = reject
|
||||
})
|
||||
)
|
||||
|
||||
const retry = store.loadPolicy()
|
||||
|
||||
expect(store.status).toBe('unavailable')
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
|
||||
rejectRetry(new Error('Network error'))
|
||||
await retry
|
||||
expect(store.status).toBe('unavailable')
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves the last enforcing policy after a generic refresh error', async () => {
|
||||
mockGetPartnerNodePolicy.mockResolvedValue({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true, DisabledNode: false }
|
||||
} satisfies PartnerNodePolicy)
|
||||
store = await createLoadedStore()
|
||||
mockGetPartnerNodePolicy.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
await store.loadPolicy()
|
||||
|
||||
expect(store.status).toBe('error')
|
||||
expect(store.isNodeDisabled('AllowedNode')).toBe(false)
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(true)
|
||||
})
|
||||
|
||||
it('stays inactive when partner-node governance is disabled', async () => {
|
||||
mockFlags.partnerNodeGovernanceEnabled = false
|
||||
|
||||
store = usePartnerNodeGovernanceStore()
|
||||
await nextTick()
|
||||
|
||||
expect(mockGetPartnerNodePolicy).not.toHaveBeenCalled()
|
||||
expect(store.status).toBe('inactive')
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('stays inactive in a personal workspace', async () => {
|
||||
activateWorkspace('personal-workspace', 'personal')
|
||||
|
||||
store = usePartnerNodeGovernanceStore()
|
||||
await nextTick()
|
||||
|
||||
expect(mockGetPartnerNodePolicy).not.toHaveBeenCalled()
|
||||
expect(store.status).toBe('inactive')
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores a stale response after switching workspaces', async () => {
|
||||
let resolveFirst!: (policy: PartnerNodePolicy) => void
|
||||
mockGetPartnerNodePolicy
|
||||
.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
enforcementEnabled: true,
|
||||
nodes: { DisabledNode: true }
|
||||
} satisfies PartnerNodePolicy)
|
||||
store = usePartnerNodeGovernanceStore()
|
||||
await vi.waitFor(() =>
|
||||
expect(mockGetPartnerNodePolicy).toHaveBeenCalledTimes(1)
|
||||
)
|
||||
|
||||
activateWorkspace('workspace-two')
|
||||
await vi.waitFor(() => expect(store?.status).toBe('configured'))
|
||||
resolveFirst({
|
||||
enforcementEnabled: true,
|
||||
nodes: { DisabledNode: false }
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
expect(store.governedWorkspaceId).toBe('workspace-two')
|
||||
expect(store.isNodeDisabled('DisabledNode')).toBe(false)
|
||||
})
|
||||
|
||||
it('saves and installs the validated whole policy', async () => {
|
||||
store = await createLoadedStore()
|
||||
const nextPolicy = {
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true, DisabledNode: true }
|
||||
} satisfies PartnerNodePolicy
|
||||
|
||||
await expect(store.savePolicy(nextPolicy)).resolves.toBe(true)
|
||||
|
||||
expect(mockUpdatePartnerNodePolicy).toHaveBeenCalledWith(nextPolicy)
|
||||
expect(store.policy).toEqual(nextPolicy)
|
||||
expect(store.status).toBe('configured')
|
||||
})
|
||||
|
||||
it('does not install a save response after switching workspaces', async () => {
|
||||
store = await createLoadedStore()
|
||||
let resolveSave!: (policy: PartnerNodePolicy) => void
|
||||
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveSave = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const save = store.savePolicy({
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true }
|
||||
})
|
||||
activateWorkspace('workspace-two')
|
||||
await vi.waitFor(() =>
|
||||
expect(store?.governedWorkspaceId).toBe('workspace-two')
|
||||
)
|
||||
resolveSave({
|
||||
enforcementEnabled: false,
|
||||
nodes: { AllowedNode: true }
|
||||
})
|
||||
|
||||
await expect(save).resolves.toBe(false)
|
||||
await vi.waitFor(() => expect(store?.status).toBe('unconfigured'))
|
||||
expect(store.policy).toBeNull()
|
||||
})
|
||||
|
||||
it('does not install a save response after switching away and back', async () => {
|
||||
store = await createLoadedStore()
|
||||
let resolveSave!: (policy: PartnerNodePolicy) => void
|
||||
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveSave = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const save = store.savePolicy({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true }
|
||||
})
|
||||
activateWorkspace('workspace-two')
|
||||
await vi.waitFor(() =>
|
||||
expect(store?.governedWorkspaceId).toBe('workspace-two')
|
||||
)
|
||||
activateWorkspace('workspace-one')
|
||||
await vi.waitFor(() =>
|
||||
expect(store?.governedWorkspaceId).toBe('workspace-one')
|
||||
)
|
||||
resolveSave({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true }
|
||||
})
|
||||
|
||||
await expect(save).resolves.toBe(false)
|
||||
await vi.waitFor(() => expect(store?.status).toBe('unconfigured'))
|
||||
expect(store.policy).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores a stale save rejection after switching workspaces', async () => {
|
||||
store = await createLoadedStore()
|
||||
let rejectSave!: (error: Error) => void
|
||||
mockUpdatePartnerNodePolicy.mockReturnValueOnce(
|
||||
new Promise((_, reject) => {
|
||||
rejectSave = reject
|
||||
})
|
||||
)
|
||||
|
||||
const save = store.savePolicy({
|
||||
enforcementEnabled: true,
|
||||
nodes: { AllowedNode: true }
|
||||
})
|
||||
activateWorkspace('workspace-two')
|
||||
await vi.waitFor(() =>
|
||||
expect(store?.governedWorkspaceId).toBe('workspace-two')
|
||||
)
|
||||
rejectSave(new Error('Conflict'))
|
||||
|
||||
await expect(save).resolves.toBe(false)
|
||||
expect(store.error).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses to save before governance is ready', async () => {
|
||||
mockFlags.partnerNodeGovernanceEnabled = false
|
||||
store = usePartnerNodeGovernanceStore()
|
||||
await nextTick()
|
||||
|
||||
await expect(
|
||||
store.savePolicy({ enforcementEnabled: false, nodes: {} })
|
||||
).rejects.toThrow('Partner node governance is not ready')
|
||||
expect(mockUpdatePartnerNodePolicy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,213 +0,0 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, shallowRef, watch, watchEffect } from 'vue'
|
||||
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import {
|
||||
getPartnerNodePolicy,
|
||||
PartnerNodePolicyApiError,
|
||||
updatePartnerNodePolicy
|
||||
} from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import type { PartnerNodePolicy } from '@/platform/workspace/api/partnerNodePolicyApi'
|
||||
import { useTeamWorkspaceStore } from '@/platform/workspace/stores/teamWorkspaceStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
export interface PartnerNodeCatalogItem {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type PartnerNodePolicyStatus =
|
||||
| 'inactive'
|
||||
| 'loading'
|
||||
| 'unconfigured'
|
||||
| 'configured'
|
||||
| 'unavailable'
|
||||
| 'error'
|
||||
|
||||
const DISCOVERY_FILTER_ID = 'workspace.partner-node-governance'
|
||||
|
||||
export const usePartnerNodeGovernanceStore = defineStore(
|
||||
'partnerNodeGovernance',
|
||||
() => {
|
||||
const { flags } = useFeatureFlags()
|
||||
const workspaceStore = useTeamWorkspaceStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
|
||||
const policy = shallowRef<PartnerNodePolicy | null>(null)
|
||||
const policyWorkspaceId = ref<string | null>(null)
|
||||
const status = ref<PartnerNodePolicyStatus>('inactive')
|
||||
const error = shallowRef<Error | null>(null)
|
||||
let loadVersion = 0
|
||||
let workspaceVersion = 0
|
||||
|
||||
const governedWorkspaceId = computed(() => {
|
||||
const workspace = workspaceStore.activeWorkspace
|
||||
return flags.teamWorkspacesEnabled &&
|
||||
flags.partnerNodeGovernanceEnabled &&
|
||||
workspace?.type === 'team'
|
||||
? workspace.id
|
||||
: null
|
||||
})
|
||||
|
||||
const partnerNodes = computed<PartnerNodeCatalogItem[]>(() =>
|
||||
Object.values(nodeDefStore.nodeDefsByName)
|
||||
.filter((nodeDef) => nodeDef.api_node)
|
||||
.map((nodeDef) => ({
|
||||
id: nodeDef.name,
|
||||
name: nodeDef.display_name || nodeDef.name,
|
||||
provider:
|
||||
nodeDef.category.split('/')[2] ||
|
||||
nodeDef.category ||
|
||||
nodeDef.python_module
|
||||
}))
|
||||
)
|
||||
|
||||
function isNodeDisabled(nodeType: string): boolean {
|
||||
if (!nodeDefStore.nodeDefsByName[nodeType]?.api_node) return false
|
||||
const workspaceId = governedWorkspaceId.value
|
||||
if (!workspaceId || policyWorkspaceId.value !== workspaceId) return false
|
||||
if (status.value === 'unavailable') return true
|
||||
return (
|
||||
policy.value?.enforcementEnabled === true &&
|
||||
policy.value.nodes[nodeType] !== true
|
||||
)
|
||||
}
|
||||
|
||||
nodeDefStore.registerNodeDefFilter({
|
||||
id: DISCOVERY_FILTER_ID,
|
||||
name: 'Workspace partner-node governance',
|
||||
predicate: (nodeDef) => !isNodeDisabled(nodeDef.name)
|
||||
})
|
||||
|
||||
const legacyHiddenNodeTypes = new Set<string>()
|
||||
watchEffect(() => {
|
||||
const showDevOnly = nodeDefStore.showDevOnly
|
||||
for (const nodeDef of Object.values(nodeDefStore.nodeDefsByName)) {
|
||||
if (!nodeDef.api_node) continue
|
||||
const nodeType = LiteGraph.registered_node_types[nodeDef.name]
|
||||
if (!nodeType) continue
|
||||
|
||||
if (isNodeDisabled(nodeDef.name)) {
|
||||
if (!nodeType.skip_list) {
|
||||
nodeType.skip_list = true
|
||||
legacyHiddenNodeTypes.add(nodeDef.name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (legacyHiddenNodeTypes.delete(nodeDef.name)) {
|
||||
nodeType.skip_list = nodeDef.dev_only && !showDevOnly
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function loadPolicy(): Promise<void> {
|
||||
const workspaceId = governedWorkspaceId.value
|
||||
const version = ++loadVersion
|
||||
if (!workspaceId) {
|
||||
policy.value = null
|
||||
policyWorkspaceId.value = null
|
||||
status.value = 'inactive'
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const workspaceChanged = policyWorkspaceId.value !== workspaceId
|
||||
if (workspaceChanged) {
|
||||
policy.value = null
|
||||
policyWorkspaceId.value = workspaceId
|
||||
status.value = 'loading'
|
||||
} else if (status.value !== 'unavailable') {
|
||||
status.value = 'loading'
|
||||
}
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const nextPolicy = await getPartnerNodePolicy()
|
||||
if (
|
||||
version !== loadVersion ||
|
||||
governedWorkspaceId.value !== workspaceId
|
||||
) {
|
||||
return
|
||||
}
|
||||
policy.value = nextPolicy
|
||||
status.value = nextPolicy ? 'configured' : 'unconfigured'
|
||||
} catch (loadError) {
|
||||
if (
|
||||
version !== loadVersion ||
|
||||
governedWorkspaceId.value !== workspaceId
|
||||
) {
|
||||
return
|
||||
}
|
||||
error.value =
|
||||
loadError instanceof Error
|
||||
? loadError
|
||||
: new Error('Failed to load partner node policy')
|
||||
if (
|
||||
loadError instanceof PartnerNodePolicyApiError &&
|
||||
loadError.status === 503
|
||||
) {
|
||||
policy.value = null
|
||||
status.value = 'unavailable'
|
||||
return
|
||||
}
|
||||
if (status.value !== 'unavailable') status.value = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function savePolicy(nextPolicy: PartnerNodePolicy): Promise<boolean> {
|
||||
const workspaceId = governedWorkspaceId.value
|
||||
const version = workspaceVersion
|
||||
if (!workspaceId || policyWorkspaceId.value !== workspaceId) {
|
||||
throw new Error('Partner node governance is not ready')
|
||||
}
|
||||
|
||||
let savedPolicy: PartnerNodePolicy
|
||||
try {
|
||||
savedPolicy = await updatePartnerNodePolicy(nextPolicy)
|
||||
} catch (saveError) {
|
||||
if (
|
||||
workspaceVersion !== version ||
|
||||
governedWorkspaceId.value !== workspaceId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
throw saveError
|
||||
}
|
||||
if (
|
||||
workspaceVersion !== version ||
|
||||
governedWorkspaceId.value !== workspaceId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
policy.value = savedPolicy
|
||||
policyWorkspaceId.value = workspaceId
|
||||
status.value = 'configured'
|
||||
error.value = null
|
||||
return true
|
||||
}
|
||||
|
||||
watch(
|
||||
governedWorkspaceId,
|
||||
() => {
|
||||
workspaceVersion++
|
||||
void loadPolicy()
|
||||
},
|
||||
{ immediate: true, flush: 'sync' }
|
||||
)
|
||||
|
||||
return {
|
||||
policy,
|
||||
status,
|
||||
error,
|
||||
governedWorkspaceId,
|
||||
partnerNodes,
|
||||
isNodeDisabled,
|
||||
loadPolicy,
|
||||
savePolicy
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -7,7 +7,7 @@ defineProps<{ title: string; price: string }>()
|
||||
<span
|
||||
class="mt-2 flex h-5 max-w-max items-center rounded-full bg-component-node-widget-background p-2 py-3"
|
||||
>
|
||||
<i class="mr-1 icon-[lucide--component] h-4 bg-amber-400" />
|
||||
<i class="mr-1 icon-[lucide--component] h-4 bg-credit" />
|
||||
{{ price }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ const { t } = useI18n()
|
||||
<Popover v-if="mobile && creditsBadges.length" side="top">
|
||||
<template #button>
|
||||
<Button class="mr-2 size-10">
|
||||
<i class="icon-[comfy--credits] bg-amber-400" />
|
||||
<i class="icon-[comfy--credits] bg-credit" />
|
||||
</Button>
|
||||
</template>
|
||||
<section
|
||||
@@ -51,7 +51,7 @@ const { t } = useI18n()
|
||||
<div class="mb-1 w-full border-b border-border-subtle" />
|
||||
<CollapsibleTrigger as-child>
|
||||
<Button variant="textonly" class="w-full text-sm">
|
||||
<i class="icon-[comfy--credits] size-4 bg-amber-400" />
|
||||
<i class="icon-[comfy--credits] size-4 bg-credit" />
|
||||
{{ t('linearMode.hasCreditCost') }}
|
||||
<i v-if="open" class="ml-auto icon-[lucide--chevron-up]" />
|
||||
<i v-else class="ml-auto icon-[lucide--chevron-down]" />
|
||||
|
||||
@@ -224,9 +224,10 @@ function resolveValues(values: unknown): string[] {
|
||||
}
|
||||
|
||||
const modelValue = defineModel<string | undefined>({
|
||||
default(modelProps: Props) {
|
||||
default(modelProps: Record<string, unknown>) {
|
||||
try {
|
||||
const values = modelProps.widget.options?.values
|
||||
const modelWidget = modelProps.widget as Props['widget'] | undefined
|
||||
const values = modelWidget?.options?.values
|
||||
const resolved = typeof values === 'function' ? values() : values
|
||||
const firstValue = Array.isArray(resolved)
|
||||
? resolved.find((value) => value !== null && value !== undefined)
|
||||
|
||||
@@ -41,8 +41,9 @@ provide(
|
||||
)
|
||||
|
||||
const modelValue = defineModel<string | undefined>({
|
||||
default(props: Props) {
|
||||
const values = props.widget.options?.values
|
||||
default(modelProps: Record<string, unknown>) {
|
||||
const modelWidget = modelProps.widget as Props['widget'] | undefined
|
||||
const values = modelWidget?.options?.values
|
||||
return (Array.isArray(values) ? values[0] : undefined) ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
import { registerQueuePromptGuard } from '@/services/queuePromptGuardService'
|
||||
|
||||
const {
|
||||
mockApiKeyAuthStore,
|
||||
@@ -190,93 +189,6 @@ describe('ComfyApp', () => {
|
||||
})
|
||||
|
||||
describe('queuePrompt', () => {
|
||||
it('blocks before adding a disallowed prompt to the client queue', async () => {
|
||||
const unregisterGuard = registerQueuePromptGuard(
|
||||
'app.test.before-queue',
|
||||
() => false
|
||||
)
|
||||
const dispatchEvent = vi
|
||||
.spyOn(api, 'dispatchCustomEvent')
|
||||
.mockImplementation(() => true)
|
||||
const queuePrompt = vi.spyOn(api, 'queuePrompt')
|
||||
|
||||
try {
|
||||
await expect(app.queuePrompt(0)).resolves.toBe(false)
|
||||
} finally {
|
||||
unregisterGuard()
|
||||
}
|
||||
|
||||
expect(mockAuthStore.getAuthToken).not.toHaveBeenCalled()
|
||||
expect(dispatchEvent).not.toHaveBeenCalled()
|
||||
expect(queuePrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('checks again after deferred authentication resolves', async () => {
|
||||
const graph = new LGraph()
|
||||
Reflect.set(app, 'rootGraphInternal', graph)
|
||||
let resolveAuth: (token: string | undefined) => void = () => {}
|
||||
mockAuthStore.getAuthToken.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveAuth = resolve
|
||||
})
|
||||
)
|
||||
let guardCalls = 0
|
||||
const unregisterGuard = registerQueuePromptGuard(
|
||||
'app.test.after-auth',
|
||||
() => ++guardCalls === 1
|
||||
)
|
||||
const queuePrompt = vi.spyOn(api, 'queuePrompt')
|
||||
|
||||
try {
|
||||
const result = app.queuePrompt(0)
|
||||
await vi.waitFor(() => {
|
||||
expect(mockAuthStore.getAuthToken).toHaveBeenCalledOnce()
|
||||
})
|
||||
resolveAuth(undefined)
|
||||
|
||||
await expect(result).resolves.toBe(false)
|
||||
} finally {
|
||||
unregisterGuard()
|
||||
}
|
||||
expect(guardCalls).toBe(2)
|
||||
expect(queuePrompt).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dispatches queued progress when a later batch item is blocked', async () => {
|
||||
const graph = new LGraph()
|
||||
Reflect.set(app, 'rootGraphInternal', graph)
|
||||
vi.spyOn(app, 'graphToPrompt').mockResolvedValue({
|
||||
output: {},
|
||||
workflow: createWorkflowGraphData()
|
||||
})
|
||||
vi.spyOn(api, 'queuePrompt').mockResolvedValue({
|
||||
prompt_id: 'job-1',
|
||||
node_errors: {},
|
||||
error: ''
|
||||
})
|
||||
const dispatchEvent = vi
|
||||
.spyOn(api, 'dispatchCustomEvent')
|
||||
.mockImplementation(() => true)
|
||||
let guardCalls = 0
|
||||
const unregisterGuard = registerQueuePromptGuard(
|
||||
'app.test.partial-batch',
|
||||
() => ++guardCalls < 3
|
||||
)
|
||||
|
||||
try {
|
||||
await expect(app.queuePrompt(0, 2)).resolves.toBe(false)
|
||||
} finally {
|
||||
unregisterGuard()
|
||||
}
|
||||
|
||||
expect(api.queuePrompt).toHaveBeenCalledOnce()
|
||||
expect(dispatchEvent).toHaveBeenCalledWith('promptQueued', {
|
||||
number: 0,
|
||||
batchCount: 1,
|
||||
requestId: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the error overlay for successful prompt responses with node errors', async () => {
|
||||
const graph = new LGraph()
|
||||
const workflow = new ComfyWorkflow({
|
||||
|
||||
@@ -68,7 +68,6 @@ import { resolveAccountPrecondition } from '@/platform/errorCatalog/accountPreco
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { runQueuePromptGuards } from '@/services/queuePromptGuardService'
|
||||
import { useSubgraphService } from '@/services/subgraphService'
|
||||
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
@@ -1612,17 +1611,11 @@ export class ComfyApp {
|
||||
})
|
||||
}
|
||||
|
||||
private async isQueuePromptAllowed(): Promise<boolean> {
|
||||
return await runQueuePromptGuards(this)
|
||||
}
|
||||
|
||||
async queuePrompt(
|
||||
number: number,
|
||||
batchCount: number = 1,
|
||||
queueNodeIds?: NodeExecutionId[]
|
||||
): Promise<boolean> {
|
||||
if (!(await this.isQueuePromptAllowed())) return false
|
||||
|
||||
const requestId = this.nextQueueRequestId++
|
||||
this.queueItems.push({ number, batchCount, queueNodeIds, requestId })
|
||||
api.dispatchCustomEvent('promptQueueing', {
|
||||
@@ -1655,18 +1648,6 @@ export class ComfyApp {
|
||||
|
||||
const isPartialExecution = !!queueNodeIds?.length
|
||||
for (let i = 0; i < batchCount; i++) {
|
||||
if (!(await this.isQueuePromptAllowed())) {
|
||||
this.queueItems.length = 0
|
||||
if (queuedCount > 0) {
|
||||
api.dispatchCustomEvent('promptQueued', {
|
||||
number,
|
||||
batchCount: queuedCount,
|
||||
requestId
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Allow widgets to run callbacks before a prompt has been queued
|
||||
// e.g. random seed before every gen
|
||||
forEachNode(this.rootGraph, (node) => {
|
||||
|
||||
@@ -124,7 +124,7 @@ const findElementsInTag = (
|
||||
end: number,
|
||||
id: Uint8Array
|
||||
): EbmlElementRange | null => {
|
||||
for (let pos = start; pos < end - 1; ) {
|
||||
for (let pos = start; pos < end - 1;) {
|
||||
if (matchesId(data, pos, id)) {
|
||||
const size = readVint(data, pos + 2)
|
||||
if (size && size.value > 0) {
|
||||
@@ -307,7 +307,7 @@ const ebmlToString = (
|
||||
const parseMetadata = (data: Uint8Array): ComfyMetadata => {
|
||||
const meta: ComfyMetadata = {}
|
||||
|
||||
for (let pos = 0; pos < data.length - 2; ) {
|
||||
for (let pos = 0; pos < data.length - 2;) {
|
||||
const tagInfo = findNextTag(data, pos)
|
||||
if (!tagInfo) {
|
||||
pos++
|
||||
|
||||
@@ -306,13 +306,9 @@ export function useNodeFactory() {
|
||||
// Create node based on type and configuration
|
||||
switch (type) {
|
||||
case 'basic':
|
||||
return {
|
||||
/* basic node implementation */
|
||||
}
|
||||
return {/* basic node implementation */}
|
||||
case 'complex':
|
||||
return {
|
||||
/* complex node implementation */
|
||||
}
|
||||
return {/* complex node implementation */}
|
||||
default:
|
||||
throw new Error(`Unknown node type: ${type}`)
|
||||
}
|
||||
@@ -347,9 +343,7 @@ Services in ComfyUI can be tested effectively using different approaches dependi
|
||||
|
||||
```typescript
|
||||
// Manual instantiation required
|
||||
const mockData = [
|
||||
/* test data */
|
||||
]
|
||||
const mockData = [/* test data */]
|
||||
const service = new NodeSearchService(mockData)
|
||||
```
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
export interface QueuePromptGuardContext {
|
||||
readonly rootGraph: LGraph
|
||||
}
|
||||
|
||||
export type QueuePromptGuard = (
|
||||
context: QueuePromptGuardContext
|
||||
) => boolean | Promise<boolean>
|
||||
|
||||
const guards = new Map<string, QueuePromptGuard>()
|
||||
|
||||
export function registerQueuePromptGuard(
|
||||
id: string,
|
||||
guard: QueuePromptGuard
|
||||
): () => void {
|
||||
guards.set(id, guard)
|
||||
return () => {
|
||||
if (guards.get(id) === guard) guards.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
export async function runQueuePromptGuards(
|
||||
context: QueuePromptGuardContext
|
||||
): Promise<boolean> {
|
||||
const results = await Promise.all(
|
||||
[...guards.values()].map((guard) => guard(context))
|
||||
)
|
||||
return results.every((result) => result !== false)
|
||||
}
|
||||
@@ -147,9 +147,7 @@ describe('useAuthStore', () => {
|
||||
let authStateCallback: (user: User | null) => void
|
||||
let idTokenCallback: (user: User | null) => void
|
||||
|
||||
const mockAuth: MockAuth = {
|
||||
/* mock Auth object */
|
||||
}
|
||||
const mockAuth: MockAuth = {/* mock Auth object */}
|
||||
|
||||
const mockUser: MockUser = {
|
||||
uid: 'test-user-id',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
createTestSubgraphNode
|
||||
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { NodeDefFilter } from '@/stores/nodeDefStore'
|
||||
|
||||
describe('useNodeDefStore', () => {
|
||||
@@ -22,10 +21,6 @@ describe('useNodeDefStore', () => {
|
||||
store = useNodeDefStore()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const createMockNodeDef = (
|
||||
overrides: Partial<ComfyNodeDef> = {}
|
||||
): ComfyNodeDef => ({
|
||||
@@ -291,30 +286,6 @@ describe('useNodeDefStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('node frequency visibility', () => {
|
||||
it('excludes node definitions hidden by registered filters', async () => {
|
||||
vi.spyOn(axios, 'get').mockResolvedValue({
|
||||
data: { HiddenNode: 2, VisibleNode: 1 }
|
||||
})
|
||||
store.updateNodeDefs([
|
||||
createMockNodeDef({ name: 'HiddenNode' }),
|
||||
createMockNodeDef({ name: 'VisibleNode' })
|
||||
])
|
||||
store.registerNodeDefFilter({
|
||||
id: 'test.hide-node',
|
||||
name: 'Hide node',
|
||||
predicate: (nodeDef) => nodeDef.name !== 'HiddenNode'
|
||||
})
|
||||
|
||||
const frequencyStore = useNodeFrequencyStore()
|
||||
await frequencyStore.loadNodeFrequencies()
|
||||
|
||||
expect(frequencyStore.topNodeDefs.map((nodeDef) => nodeDef.name)).toEqual(
|
||||
['VisibleNode']
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('allNodeDefsByName', () => {
|
||||
it('should include all node defs by name', () => {
|
||||
const node1 = createMockNodeDef({ name: 'Node1' })
|
||||
|
||||
@@ -562,15 +562,9 @@ export const useNodeFrequencyStore = defineStore('nodeFrequency', () => {
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const topNodeDefs = computed<ComfyNodeDefImpl[]>(() => {
|
||||
const visibleNodeNames = new Set(
|
||||
nodeDefStore.visibleNodeDefs.map((nodeDef) => nodeDef.name)
|
||||
)
|
||||
return nodeNamesByFrequency.value
|
||||
.map((nodeName: string) => nodeDefStore.nodeDefsByName[nodeName])
|
||||
.filter(
|
||||
(nodeDef): nodeDef is ComfyNodeDefImpl =>
|
||||
nodeDef !== undefined && visibleNodeNames.has(nodeDef.name)
|
||||
)
|
||||
.filter((nodeDef: ComfyNodeDefImpl) => nodeDef !== undefined)
|
||||
.slice(0, topNodeDefLimit.value)
|
||||
})
|
||||
|
||||
|
||||
@@ -132,9 +132,9 @@ export function fixBadLinks(
|
||||
// We can set to null (delete), so undefined means we haven't set it at all.
|
||||
if (patchedNode['inputs']![slot] !== undefined) {
|
||||
logger.log(
|
||||
` > Already set ${node.id}.inputs[${slot}] to ${patchedNode[
|
||||
'inputs'
|
||||
]![slot]!} Skipping.`
|
||||
` > Already set ${node.id}.inputs[${slot}] to ${
|
||||
patchedNode['inputs']![slot]!
|
||||
} Skipping.`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user