mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-02 11:25:08 +00:00
Compare commits
15 Commits
drjkl/widg
...
cloud/1.45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75fb11785a | ||
|
|
5ff0b33295 | ||
|
|
e89778fc89 | ||
|
|
6f3ef2ed70 | ||
|
|
cd216d4db8 | ||
|
|
bccfc41f5d | ||
|
|
2bf1eb6e19 | ||
|
|
d2ad47634a | ||
|
|
daeae316b1 | ||
|
|
20cf8074a9 | ||
|
|
c87cb03024 | ||
|
|
28c4080134 | ||
|
|
1a6e77e955 | ||
|
|
e06d7a7b34 | ||
|
|
c76b7280af |
55
apps/website/src/scripts/posthog.test.ts
Normal file
55
apps/website/src/scripts/posthog.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
mockInit: vi.fn(),
|
||||
mockCapture: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
init: hoisted.mockInit,
|
||||
capture: hoisted.mockCapture
|
||||
}
|
||||
}))
|
||||
|
||||
describe('initPostHog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('passes a before_send hook to posthog.init that strips PII end-to-end', async () => {
|
||||
const { initPostHog } = await import('./posthog')
|
||||
initPostHog()
|
||||
|
||||
expect(hoisted.mockInit).toHaveBeenCalledOnce()
|
||||
const initOptions = hoisted.mockInit.mock.calls[0][1]
|
||||
expect(initOptions.person_profiles).toBe('identified_only')
|
||||
expect(typeof initOptions.before_send).toBe('function')
|
||||
|
||||
const event = {
|
||||
properties: {
|
||||
email: 'a@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'b@example.com',
|
||||
$email: 'c@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: { email: 'd@example.com', name: 'keep me' },
|
||||
$set_once: { $email: 'e@example.com', plan: 'free' }
|
||||
}
|
||||
|
||||
const result = initOptions.before_send(event)
|
||||
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
expect(result.$set_once).not.toHaveProperty('$email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
const POSTHOG_KEY =
|
||||
import.meta.env.PUBLIC_POSTHOG_KEY ??
|
||||
'phc_iKfK86id4xVYws9LybMje0h44eGtfwFgRPIBehmy8rO'
|
||||
@@ -18,7 +20,9 @@ export function initPostHog() {
|
||||
ui_host: POSTHOG_UI_HOST,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: true,
|
||||
person_profiles: 'identified_only'
|
||||
person_profiles: 'identified_only',
|
||||
// cookie_domain omitted — see PostHogTelemetryProvider.ts note + posthog-js#3578
|
||||
before_send: createPostHogBeforeSend()
|
||||
})
|
||||
initialized = true
|
||||
} catch (error) {
|
||||
|
||||
@@ -213,7 +213,8 @@ export class VueNodeHelpers {
|
||||
return {
|
||||
input: widget.locator('input'),
|
||||
decrementButton: widget.getByTestId(TestIds.widgets.decrement),
|
||||
incrementButton: widget.getByTestId(TestIds.widgets.increment)
|
||||
incrementButton: widget.getByTestId(TestIds.widgets.increment),
|
||||
valueControl: widget.getByTestId(TestIds.widgets.valueControl)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ export class ContextMenu {
|
||||
await this.waitForHidden()
|
||||
}
|
||||
|
||||
menuItem(name: string): Locator {
|
||||
return this.anyMenu.getByRole('menuitem', { name, exact: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Click a litegraph menu entry. Selects the most recently opened matching
|
||||
* entry so nested submenu items can be reached without being shadowed by
|
||||
|
||||
@@ -11,6 +11,11 @@ import { createMockJob } from '@e2e/fixtures/helpers/AssetsHelper'
|
||||
|
||||
const PROMPT_ROUTE_PATTERN = /\/api\/prompt$/
|
||||
|
||||
type RunOptions = {
|
||||
nodeErrors?: Record<string, NodeError>
|
||||
onPromptRequest?: (requestBody: unknown) => void | Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `NodeError` describing a single failed input on a KSampler node.
|
||||
* Shared between specs that surface validation rings via 400 responses.
|
||||
@@ -70,8 +75,9 @@ export class ExecutionHelper {
|
||||
* The app receives a valid PromptResponse so storeJob() fires
|
||||
* and registers the job against the active workflow path.
|
||||
*/
|
||||
async run(): Promise<string> {
|
||||
async run(options: RunOptions = {}): Promise<string> {
|
||||
const jobId = `test-job-${++this.jobCounter}`
|
||||
const { nodeErrors = {}, onPromptRequest } = options
|
||||
|
||||
let fulfilled!: () => void
|
||||
const prompted = new Promise<void>((r) => {
|
||||
@@ -81,12 +87,13 @@ export class ExecutionHelper {
|
||||
await this.page.route(
|
||||
PROMPT_ROUTE_PATTERN,
|
||||
async (route) => {
|
||||
await onPromptRequest?.(route.request().postDataJSON())
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
prompt_id: jobId,
|
||||
node_errors: {}
|
||||
node_errors: nodeErrors
|
||||
})
|
||||
})
|
||||
fulfilled()
|
||||
|
||||
@@ -135,7 +135,8 @@ export const TestIds = {
|
||||
colorPickerButton: 'color-picker-button',
|
||||
colorPickerCurrentColor: 'color-picker-current-color',
|
||||
colorBlue: 'blue',
|
||||
colorRed: 'red'
|
||||
colorRed: 'red',
|
||||
convertSubgraph: 'convert-to-subgraph-button'
|
||||
},
|
||||
menu: {
|
||||
moreMenuContent: 'more-menu-content'
|
||||
@@ -152,6 +153,7 @@ export const TestIds = {
|
||||
widget: 'node-widget',
|
||||
decrement: 'decrement',
|
||||
increment: 'increment',
|
||||
valueControl: 'value-control',
|
||||
domWidgetTextarea: 'dom-widget-textarea',
|
||||
subgraphEnterButton: 'subgraph-enter-button',
|
||||
selectDefaultSearchInput: 'widget-select-default-search-input',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
type ChangeTrackerDebugState = {
|
||||
changeCount: number
|
||||
@@ -310,4 +311,28 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'Tracks convert to subgraph as undo step',
|
||||
{ tag: ['@vue-nodes', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Empty Latent')
|
||||
const width = comfyPage.vueNodes.getWidgetByName('Empty Latent', 'width')
|
||||
const { input } = comfyPage.vueNodes.getInputNumberControls(width)
|
||||
|
||||
await input.fill('40')
|
||||
await node.title.click()
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.selectionToolbox.convertSubgraph)
|
||||
.click()
|
||||
await expect(input).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(input).toHaveValue('40')
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(input).toHaveValue('512')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,60 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const VALIDATION_ERROR_NODE_ID = '1'
|
||||
const VALIDATION_ERROR_MESSAGE = 'Required input is missing: source'
|
||||
const PARTIAL_EXECUTION_ROOT_NODE_IDS = ['1', '4']
|
||||
|
||||
type PromptRequestNode = {
|
||||
class_type?: string
|
||||
}
|
||||
|
||||
type PromptRequestBody = {
|
||||
prompt?: Record<string, PromptRequestNode>
|
||||
}
|
||||
|
||||
function buildPreviewAnyValidationError(): NodeError {
|
||||
return {
|
||||
class_type: 'PreviewAny',
|
||||
dependent_outputs: [VALIDATION_ERROR_NODE_ID],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: VALIDATION_ERROR_MESSAGE,
|
||||
details: '',
|
||||
extra_info: { input_name: 'source' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function expectPartialExecutionRootNodes(requestBody: unknown): void {
|
||||
const prompt = (requestBody as PromptRequestBody).prompt ?? {}
|
||||
|
||||
for (const nodeId of PARTIAL_EXECUTION_ROOT_NODE_IDS) {
|
||||
expect(prompt[nodeId]).toMatchObject({ class_type: 'PreviewAny' })
|
||||
}
|
||||
}
|
||||
|
||||
async function getValidationErrorMessage(comfyPage: ComfyPage) {
|
||||
return await comfyPage.page.evaluate(
|
||||
(nodeId) =>
|
||||
window.app!.extensionManager.lastNodeErrors?.[nodeId]?.errors[0]
|
||||
?.message ?? null,
|
||||
VALIDATION_ERROR_NODE_ID
|
||||
)
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
@@ -74,3 +127,48 @@ test.describe(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe('Execution validation errors', { tag: '@workflow' }, () => {
|
||||
test('preserves validation errors when another active root starts execution', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.workflow.loadWorkflow('execution/partial_execution')
|
||||
|
||||
const ws = await getWebSocket()
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
const nodeErrors = {
|
||||
[VALIDATION_ERROR_NODE_ID]: buildPreviewAnyValidationError()
|
||||
}
|
||||
let promptRequestBody: unknown
|
||||
|
||||
const jobId = await exec.run({
|
||||
nodeErrors,
|
||||
onPromptRequest: (requestBody) => {
|
||||
promptRequestBody = requestBody
|
||||
}
|
||||
})
|
||||
expectPartialExecutionRootNodes(promptRequestBody)
|
||||
await expect
|
||||
.poll(() => getValidationErrorMessage(comfyPage))
|
||||
.toBe(VALIDATION_ERROR_MESSAGE)
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
exec.executionStart(jobId)
|
||||
|
||||
await expect
|
||||
.poll(() => getValidationErrorMessage(comfyPage))
|
||||
.toBe(VALIDATION_ERROR_MESSAGE)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -166,15 +166,6 @@ test.describe('Node Interaction', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.dragTextEncodeNode2()
|
||||
// Move mouse away to avoid hover highlight on the node at the drop position.
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
await comfyPage.expectScreenshot(comfyPage.canvas, 'dragged-node1.png', {
|
||||
maxDiffPixels: 50
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node Duplication', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Pin this suite to the legacy canvas path so Alt+drag exercises
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 93 KiB |
@@ -1,6 +1,10 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { maskEditorTest as test } from '@e2e/fixtures/helpers/MaskEditorHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
|
||||
const wstest = mergeTests(test, webSocketFixture)
|
||||
|
||||
test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
test(
|
||||
@@ -301,3 +305,39 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
wstest(
|
||||
'Will not use stale litegraph previews',
|
||||
async ({ comfyPage, getWebSocket }) => {
|
||||
const executionHelper = new ExecutionHelper(comfyPage, await getWebSocket())
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.searchBoxV2.addNode('Preview Image')
|
||||
|
||||
async function getNodeOutput() {
|
||||
return await comfyPage.page.evaluate(
|
||||
() => graph!.getNodeById('1')!.images?.[0]?.filename
|
||||
)
|
||||
}
|
||||
|
||||
executionHelper.executed('', '1', { images: [{ filename: 'test1.png' }] })
|
||||
await comfyPage.page.evaluate(() => app!.canvas.setDirty(true))
|
||||
await expect.poll(getNodeOutput).toBe('test1.png')
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
|
||||
const resolvableFile = { filename: 'example.png', type: 'input' }
|
||||
executionHelper.executed('', '1', { images: [resolvableFile] })
|
||||
await expect.poll(getNodeOutput).toBe('example.png')
|
||||
|
||||
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
|
||||
await node.imagePreview.hover()
|
||||
await node.imagePreview
|
||||
.getByRole('button', { name: 'Edit or mask image' })
|
||||
.click()
|
||||
|
||||
// On previous versions, attempting to open the mask editor here would
|
||||
// incorrectly reference the non-existant test1.png
|
||||
// This causes the mask editor to throw in setup and not display
|
||||
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve } from 'path'
|
||||
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -651,6 +650,12 @@ test(
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
})
|
||||
|
||||
const rawClip = await comfyPage.subgraph.getInputBounds()
|
||||
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
|
||||
const clip = { ...rawClip, ...absolutePos }
|
||||
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
|
||||
const twoLinkScreenshot = await comfyPage.page.screenshot({ clip })
|
||||
|
||||
const stepsSlot = ksampler.getSlot('steps')
|
||||
|
||||
await test.step('Node -> I/O hover effect', async () => {
|
||||
@@ -659,9 +664,6 @@ test(
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
|
||||
|
||||
const rawClip = await comfyPage.subgraph.getInputBounds()
|
||||
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
|
||||
const clip = { ...rawClip, ...absolutePos }
|
||||
await expect(comfyPage.page).toHaveScreenshot('vue-io-highlight.png', {
|
||||
clip
|
||||
})
|
||||
@@ -699,5 +701,18 @@ test(
|
||||
'opacity',
|
||||
'0'
|
||||
)
|
||||
|
||||
await test.step('Can disconnect link by right click', async () => {
|
||||
const stepsIOSlot = await comfyPage.subgraph.getInputSlot('steps')
|
||||
const { x, y } = await stepsIOSlot.getPosition()
|
||||
await comfyPage.page.mouse.click(x, y, { button: 'right' })
|
||||
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
|
||||
|
||||
await expect(slotParent).toHaveCSS('opacity', '0')
|
||||
|
||||
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
|
||||
const postScreenshot = await comfyPage.page.screenshot({ clip })
|
||||
expect(postScreenshot).toStrictEqual(twoLinkScreenshot)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -166,7 +166,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Bypass')
|
||||
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
await expect(nodeRef).toBeBypassed()
|
||||
await expect(getNodeWrapper(comfyPage, nodeTitle)).toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
@@ -174,12 +174,33 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await clickExactMenuItem(comfyPage, 'Remove Bypass')
|
||||
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
|
||||
await expect(nodeRef).not.toBeBypassed()
|
||||
await expect(getNodeWrapper(comfyPage, nodeTitle)).not.toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
})
|
||||
|
||||
test('shows exactly one bypass menu item per state (FE-720 regression)', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nodeTitle = 'Load Checkpoint'
|
||||
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
|
||||
const bypassItem = comfyPage.contextMenu.menuItem('Bypass')
|
||||
const removeBypassItem = comfyPage.contextMenu.menuItem('Remove Bypass')
|
||||
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await expect(bypassItem).toHaveCount(1)
|
||||
await expect(removeBypassItem).toHaveCount(0)
|
||||
await clickExactMenuItem(comfyPage, 'Bypass')
|
||||
await expect(nodeRef).toBeBypassed()
|
||||
|
||||
await openContextMenu(comfyPage, nodeTitle)
|
||||
await expect(removeBypassItem).toHaveCount(1)
|
||||
await expect(bypassItem).toHaveCount(0)
|
||||
await clickExactMenuItem(comfyPage, 'Remove Bypass')
|
||||
await expect(nodeRef).not.toBeBypassed()
|
||||
})
|
||||
|
||||
test('should minimize and expand node via context menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
@@ -451,7 +472,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const nodeRef = await getNodeRef(comfyPage, title)
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(true)
|
||||
await expect(nodeRef).toBeBypassed()
|
||||
await expect(getNodeWrapper(comfyPage, title)).toHaveClass(BYPASS_CLASS)
|
||||
}
|
||||
|
||||
@@ -460,7 +481,7 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
for (const title of nodeTitles) {
|
||||
const nodeRef = await getNodeRef(comfyPage, title)
|
||||
await expect.poll(() => nodeRef.isBypassed()).toBe(false)
|
||||
await expect(nodeRef).not.toBeBypassed()
|
||||
await expect(getNodeWrapper(comfyPage, title)).not.toHaveClass(
|
||||
BYPASS_CLASS
|
||||
)
|
||||
|
||||
@@ -38,4 +38,15 @@ test.describe('Vue Integer Widget', { tag: '@vue-nodes' }, () => {
|
||||
await controls.decrementButton.click()
|
||||
await expect(controls.input).toHaveValue(initialValue.toString())
|
||||
})
|
||||
|
||||
test('displays control widgets with default state', async ({ comfyPage }) => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.searchBoxV2.addNode('Int')
|
||||
const widget = comfyPage.vueNodes.getWidgetByName('Int', 'value')
|
||||
await expect(widget).toBeVisible()
|
||||
|
||||
const { valueControl } = comfyPage.vueNodes.getInputNumberControls(widget)
|
||||
await expect(valueControl).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,19 +12,22 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets!.push(node.widgets![0])
|
||||
node.widgets!.push({ ...node.widgets![0], name: 'added_widget_1' })
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(2)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets![2] = node.widgets![0]
|
||||
node.widgets![2] = { ...node.widgets![0], name: 'added_widget_2' }
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const graph = window.graph as TestGraphAccess
|
||||
const node = graph._nodes_by_id['4']
|
||||
node.widgets!.splice(0, 0, node.widgets![0])
|
||||
node.widgets!.splice(0, 0, {
|
||||
...node.widgets![0],
|
||||
name: 'added_widget_3'
|
||||
})
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(4)
|
||||
})
|
||||
@@ -52,4 +55,24 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
})
|
||||
|
||||
test('Can load dynamic combos', async ({ comfyPage }) => {
|
||||
await comfyPage.searchBoxV2.addNode('Resize Image/Mask')
|
||||
const widgetTuple = ['Resize Image/Mask', 'resize_type'] as const
|
||||
const widget = comfyPage.vueNodes.getWidgetByName(...widgetTuple)
|
||||
|
||||
await test.step('Update value of the dynamic combo widget', async () => {
|
||||
await comfyPage.vueNodes.selectComboOption(...widgetTuple, 'scale width')
|
||||
await expect(widget).toHaveText('scale width')
|
||||
})
|
||||
|
||||
await test.step('Swap to a different workflow and back', async () => {
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await expect(widget).toBeHidden()
|
||||
await comfyPage.menu.topbar.getTab(0).click()
|
||||
await expect(widget).toBeVisible()
|
||||
})
|
||||
|
||||
await expect(widget, 'Widget has restored value').toHaveText('scale width')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./formatUtil": "./src/formatUtil.ts",
|
||||
"./networkUtil": "./src/networkUtil.ts"
|
||||
"./networkUtil": "./src/networkUtil.ts",
|
||||
"./piiUtil": "./src/piiUtil.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
||||
58
packages/shared-frontend-utils/src/piiUtil.test.ts
Normal file
58
packages/shared-frontend-utils/src/piiUtil.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createPostHogBeforeSend } from './piiUtil'
|
||||
|
||||
describe('createPostHogBeforeSend', () => {
|
||||
const beforeSend = createPostHogBeforeSend()
|
||||
|
||||
it('returns null for null input', () => {
|
||||
expect(beforeSend(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('strips all PII keys from properties, $set, and $set_once', () => {
|
||||
const event = {
|
||||
properties: {
|
||||
email: 'a@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'b@example.com',
|
||||
$email: 'c@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: {
|
||||
email: 'd@example.com',
|
||||
user_email: 'e@example.com',
|
||||
$email: 'f@example.com',
|
||||
name: 'keep me'
|
||||
},
|
||||
$set_once: {
|
||||
email: 'g@example.com',
|
||||
plan: 'free'
|
||||
}
|
||||
}
|
||||
|
||||
const result = beforeSend(event)!
|
||||
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).not.toHaveProperty('user_email')
|
||||
expect(result.$set).not.toHaveProperty('$email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
|
||||
expect(result.$set_once).not.toHaveProperty('email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
|
||||
it('handles missing property bags gracefully', () => {
|
||||
const event = { properties: { email: 'a@example.com', safe: true } }
|
||||
const result = beforeSend(event)!
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).toHaveProperty('safe', true)
|
||||
expect(result.$set).toBeUndefined()
|
||||
expect(result.$set_once).toBeUndefined()
|
||||
})
|
||||
})
|
||||
35
packages/shared-frontend-utils/src/piiUtil.ts
Normal file
35
packages/shared-frontend-utils/src/piiUtil.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const PII_KEYS = ['email', 'prompt', 'user_email', '$email'] as const
|
||||
|
||||
function stripPiiKeys(obj?: Record<string, unknown>): void {
|
||||
if (!obj) return
|
||||
for (const key of PII_KEYS) {
|
||||
delete obj[key]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PostHog before_send hook that strips PII from all three property bags
|
||||
* an event can carry: properties, $set, and $set_once.
|
||||
*
|
||||
* posthog.identify(id, { email }) lands in $set, not properties, so all
|
||||
* three bags must be sanitized.
|
||||
*
|
||||
* Ref: posthog.com/tutorials/web-redact-properties
|
||||
*/
|
||||
interface PostHogEventLike {
|
||||
properties?: Record<string, unknown>
|
||||
$set?: Record<string, unknown>
|
||||
$set_once?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function createPostHogBeforeSend() {
|
||||
return function beforeSend<E extends PostHogEventLike>(
|
||||
event: E | null
|
||||
): E | null {
|
||||
if (!event) return null
|
||||
stripPiiKeys(event.properties)
|
||||
stripPiiKeys(event.$set)
|
||||
stripPiiKeys(event.$set_once)
|
||||
return event
|
||||
}
|
||||
}
|
||||
@@ -159,7 +159,7 @@
|
||||
|
||||
<audio
|
||||
:ref="(el) => (audioRef = el as HTMLAudioElement)"
|
||||
:src="audioSrc"
|
||||
:src
|
||||
preload="metadata"
|
||||
class="hidden"
|
||||
/>
|
||||
@@ -192,7 +192,6 @@ const progressRef = ref<HTMLElement>()
|
||||
const {
|
||||
audioRef,
|
||||
waveformRef,
|
||||
audioSrc,
|
||||
bars,
|
||||
loading,
|
||||
isPlaying,
|
||||
|
||||
222
src/components/graph/NodeTooltip.test.ts
Normal file
222
src/components/graph/NodeTooltip.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { cleanup, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { i18n, te } from '@/i18n'
|
||||
import type * as LiteGraphModule from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import NodeTooltip from './NodeTooltip.vue'
|
||||
|
||||
type HitTest = (
|
||||
node: MockNode,
|
||||
x: number,
|
||||
y: number,
|
||||
offset: [number, number]
|
||||
) => number
|
||||
|
||||
interface MockWidget {
|
||||
name: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface MockNode {
|
||||
type: string
|
||||
flags: {
|
||||
collapsed?: boolean
|
||||
ghost?: boolean
|
||||
}
|
||||
pos: [number, number]
|
||||
inputs: Array<{ name: string }>
|
||||
constructor: {
|
||||
title_mode?: 0 | 1 | 2 | 3
|
||||
}
|
||||
}
|
||||
|
||||
interface MockCanvas {
|
||||
mouse: [number, number]
|
||||
graph_mouse: [number, number]
|
||||
node_over: MockNode | null
|
||||
getWidgetAtCursor: () => MockWidget | null
|
||||
}
|
||||
|
||||
const mockIsOverNodeInput = vi.hoisted(() => vi.fn<HitTest>())
|
||||
const mockIsOverNodeOutput = vi.hoisted(() => vi.fn<HitTest>())
|
||||
const mockIsDOMWidget = vi.hoisted(() =>
|
||||
vi.fn<(widget: MockWidget) => boolean>()
|
||||
)
|
||||
const mockCanvas = vi.hoisted(
|
||||
(): MockCanvas => ({
|
||||
mouse: [100, 80],
|
||||
graph_mouse: [10, 10],
|
||||
node_over: null,
|
||||
getWidgetAtCursor: vi.fn<() => MockWidget | null>()
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof LiteGraphModule>()
|
||||
return {
|
||||
...actual,
|
||||
isOverNodeInput: mockIsOverNodeInput,
|
||||
isOverNodeOutput: mockIsOverNodeOutput
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
canvas: mockCanvas
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/domWidget', () => ({
|
||||
isDOMWidget: mockIsDOMWidget
|
||||
}))
|
||||
|
||||
const jsonTooltip =
|
||||
'Positive point prompts as JSON [{"x": int, "y": int}, ...] (pixel coords)'
|
||||
|
||||
const positiveCoordsTooltipKey =
|
||||
'nodeDefs.SAM3_Detect.inputs.positive_coords.tooltip'
|
||||
|
||||
const outputTooltipKey = 'nodeDefs.SAM3_Detect.outputs.0.tooltip'
|
||||
|
||||
const sam3DetectNodeDef: ComfyNodeDef = {
|
||||
name: 'SAM3_Detect',
|
||||
display_name: 'SAM3 Detect',
|
||||
category: 'detection/',
|
||||
python_module: 'comfy_extras.nodes_sam3',
|
||||
description: '',
|
||||
input: {
|
||||
required: {},
|
||||
optional: {
|
||||
positive_coords: [
|
||||
'STRING',
|
||||
{
|
||||
tooltip: jsonTooltip,
|
||||
forceInput: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
output: ['MASK'],
|
||||
output_name: ['masks'],
|
||||
output_tooltips: [jsonTooltip],
|
||||
output_node: false,
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
}
|
||||
|
||||
function createSam3Node(): MockNode {
|
||||
return {
|
||||
type: 'SAM3_Detect',
|
||||
flags: {},
|
||||
pos: [0, 0],
|
||||
inputs: [{ name: 'positive_coords' }],
|
||||
constructor: {}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeOutputTooltipMessage(tooltip: string | null) {
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
nodeDefs: {
|
||||
SAM3_Detect: {
|
||||
outputs: {
|
||||
0: {
|
||||
tooltip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function renderAndHoverCanvas() {
|
||||
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
|
||||
render(NodeTooltip)
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
document.body.appendChild(canvas)
|
||||
await user.hover(canvas)
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
describe('NodeTooltip', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetAllMocks()
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
vi.spyOn(useSettingStore(), 'get').mockImplementation(
|
||||
<K extends keyof Settings>(key: K): Settings[K] => {
|
||||
switch (key) {
|
||||
case 'LiteGraph.Node.TooltipDelay':
|
||||
return 0 as Settings[K]
|
||||
default:
|
||||
return undefined as Settings[K]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mockCanvas.mouse = [100, 80]
|
||||
mockCanvas.graph_mouse = [10, 10]
|
||||
mockCanvas.node_over = createSam3Node()
|
||||
vi.mocked(mockCanvas.getWidgetAtCursor).mockReturnValue(null)
|
||||
vi.mocked(mockIsOverNodeInput).mockReturnValue(-1)
|
||||
vi.mocked(mockIsOverNodeOutput).mockReturnValue(-1)
|
||||
vi.mocked(mockIsDOMWidget).mockReturnValue(false)
|
||||
|
||||
useNodeDefStore().addNodeDef(sam3DetectNodeDef)
|
||||
mergeOutputTooltipMessage(jsonTooltip)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mergeOutputTooltipMessage(null)
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('shows input slot JSON tooltips without i18n placeholder errors', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(mockIsOverNodeInput).mockReturnValue(0)
|
||||
|
||||
await renderAndHoverCanvas()
|
||||
|
||||
expect(te(positiveCoordsTooltipKey)).toBe(true)
|
||||
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows output slot JSON tooltips without i18n placeholder errors', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(mockIsOverNodeOutput).mockReturnValue(0)
|
||||
|
||||
await renderAndHoverCanvas()
|
||||
|
||||
expect(te(outputTooltipKey)).toBe(true)
|
||||
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows widget JSON tooltips without i18n placeholder errors', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.mocked(mockCanvas.getWidgetAtCursor).mockReturnValue({
|
||||
name: 'positive_coords'
|
||||
})
|
||||
|
||||
await renderAndHoverCanvas()
|
||||
|
||||
expect(te(positiveCoordsTooltipKey)).toBe(true)
|
||||
expect(screen.getByText(jsonTooltip)).toBeInTheDocument()
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,7 @@
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import { st } from '@/i18n'
|
||||
import { stRaw } from '@/i18n'
|
||||
import {
|
||||
LiteGraph,
|
||||
isOverNodeInput,
|
||||
@@ -84,7 +84,7 @@ function onIdle() {
|
||||
)
|
||||
if (inputSlot !== -1) {
|
||||
const inputName = node.inputs[inputSlot].name
|
||||
const translatedTooltip = st(
|
||||
const translatedTooltip = stRaw(
|
||||
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(inputName)}.tooltip`,
|
||||
nodeDef?.inputs[inputName]?.tooltip ?? ''
|
||||
)
|
||||
@@ -98,7 +98,7 @@ function onIdle() {
|
||||
[0, 0]
|
||||
)
|
||||
if (outputSlot !== -1) {
|
||||
const translatedTooltip = st(
|
||||
const translatedTooltip = stRaw(
|
||||
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.outputs.${outputSlot}.tooltip`,
|
||||
nodeDef?.outputs[outputSlot]?.tooltip ?? ''
|
||||
)
|
||||
@@ -108,7 +108,7 @@ function onIdle() {
|
||||
const widget = comfyApp.canvas.getWidgetAtCursor()
|
||||
// Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these
|
||||
if (widget && !isDOMWidget(widget)) {
|
||||
const translatedTooltip = st(
|
||||
const translatedTooltip = stRaw(
|
||||
`nodeDefs.${normalizeI18nKey(node.type ?? '')}.inputs.${normalizeI18nKey(widget.name)}.tooltip`,
|
||||
nodeDef?.inputs[widget.name]?.tooltip ?? ''
|
||||
)
|
||||
|
||||
@@ -258,6 +258,34 @@ describe('useSelectedLiteGraphItems', () => {
|
||||
expect(node.mode).toBe(LGraphEventMode.ALWAYS)
|
||||
})
|
||||
|
||||
it('areAllSelectedNodesInMode returns true when every selected node matches', () => {
|
||||
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
|
||||
const node1 = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
const node2 = { id: 2, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
|
||||
app.canvas.selected_nodes = { '0': node1, '1': node2 }
|
||||
|
||||
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(true)
|
||||
})
|
||||
|
||||
it('areAllSelectedNodesInMode returns false on mixed selection', () => {
|
||||
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
|
||||
const bypassed = { id: 1, mode: LGraphEventMode.BYPASS } as LGraphNode
|
||||
const active = { id: 2, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
|
||||
app.canvas.selected_nodes = { '0': bypassed, '1': active }
|
||||
|
||||
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
|
||||
})
|
||||
|
||||
it('areAllSelectedNodesInMode returns false for empty selection', () => {
|
||||
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
|
||||
|
||||
app.canvas.selected_nodes = {}
|
||||
|
||||
expect(areAllSelectedNodesInMode(LGraphEventMode.BYPASS)).toBe(false)
|
||||
})
|
||||
|
||||
it('getSelectedNodes should include nodes from subgraphs', () => {
|
||||
const { getSelectedNodes } = useSelectedLiteGraphItems()
|
||||
const subNode1 = { id: 11, mode: LGraphEventMode.ALWAYS } as LGraphNode
|
||||
|
||||
@@ -93,6 +93,22 @@ export function useSelectedLiteGraphItems() {
|
||||
return collectFromNodes(nodeArray)
|
||||
}
|
||||
|
||||
const getSelectedNodesShallow = (): LGraphNode[] =>
|
||||
Object.values(app.canvas.selected_nodes ?? {})
|
||||
|
||||
/**
|
||||
* True iff every selected node is in `mode`. Mirrors the predicate used by
|
||||
* {@link toggleSelectedNodesMode} so labels match the toggle's effect.
|
||||
* An empty selection returns `false` (no node is in the mode).
|
||||
*/
|
||||
const areAllSelectedNodesInMode = (mode: LGraphEventMode): boolean => {
|
||||
const selectedNodeArray = getSelectedNodesShallow()
|
||||
return (
|
||||
selectedNodeArray.length > 0 &&
|
||||
selectedNodeArray.every((node) => node.mode === mode)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the execution mode of all selected nodes
|
||||
*
|
||||
@@ -102,18 +118,10 @@ export function useSelectedLiteGraphItems() {
|
||||
* @param mode - The LGraphEventMode to toggle to (e.g., NEVER for mute, BYPASS for bypass)
|
||||
*/
|
||||
const toggleSelectedNodesMode = (mode: LGraphEventMode): void => {
|
||||
const selectedNodes = app.canvas.selected_nodes
|
||||
if (!selectedNodes) return
|
||||
|
||||
// Convert selected_nodes object to array
|
||||
const selectedNodeArray: LGraphNode[] = []
|
||||
for (const i in selectedNodes) {
|
||||
selectedNodeArray.push(selectedNodes[i])
|
||||
}
|
||||
const allNodesMatch = !selectedNodeArray.some(
|
||||
(selectedNode) => selectedNode.mode !== mode
|
||||
)
|
||||
const newModeForSelectedNode = allNodesMatch ? LGraphEventMode.ALWAYS : mode
|
||||
const selectedNodeArray = getSelectedNodesShallow()
|
||||
const newModeForSelectedNode = areAllSelectedNodesInMode(mode)
|
||||
? LGraphEventMode.ALWAYS
|
||||
: mode
|
||||
|
||||
for (const selectedNode of selectedNodeArray)
|
||||
selectedNode.mode = newModeForSelectedNode
|
||||
@@ -126,6 +134,7 @@ export function useSelectedLiteGraphItems() {
|
||||
hasSelectableItems,
|
||||
hasMultipleSelectableItems,
|
||||
getSelectedNodes,
|
||||
areAllSelectedNodesInMode,
|
||||
toggleSelectedNodesMode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,51 @@ describe('contextMenuConverter', () => {
|
||||
expect(getIndex('Node Info')).toBeLessThanOrEqual(getIndex('Color'))
|
||||
})
|
||||
|
||||
it('blacklists the legacy Bypass push so Vue supplies the only item', () => {
|
||||
const legacyOptions = convertContextMenuToOptions(
|
||||
[{ content: 'Bypass', callback: () => {} }],
|
||||
undefined,
|
||||
false
|
||||
)
|
||||
expect(
|
||||
legacyOptions.find(
|
||||
(opt) => opt.label === 'Bypass' || opt.label === 'Remove Bypass'
|
||||
)
|
||||
).toBeUndefined()
|
||||
|
||||
const vueBypass: MenuOption = {
|
||||
label: 'Remove Bypass',
|
||||
icon: 'icon-[lucide--redo-dot]',
|
||||
shortcut: 'Ctrl+B',
|
||||
action: () => {},
|
||||
source: 'vue'
|
||||
}
|
||||
const result = buildStructuredMenu([...legacyOptions, vueBypass])
|
||||
|
||||
const bypassItems = result.filter(
|
||||
(opt) => opt.label === 'Bypass' || opt.label === 'Remove Bypass'
|
||||
)
|
||||
expect(bypassItems).toHaveLength(1)
|
||||
expect(bypassItems[0].source).toBe('vue')
|
||||
expect(bypassItems[0].shortcut).toBe('Ctrl+B')
|
||||
})
|
||||
|
||||
it('does not treat Bypass and Remove Bypass as label equivalents', () => {
|
||||
const options: MenuOption[] = [
|
||||
{ label: 'Bypass', action: () => {}, source: 'vue' },
|
||||
{ label: 'Remove Bypass', action: () => {}, source: 'litegraph' }
|
||||
]
|
||||
|
||||
const result = buildStructuredMenu(options)
|
||||
|
||||
const labels = result
|
||||
.map((opt) => opt.label)
|
||||
.filter((l) => l === 'Bypass' || l === 'Remove Bypass')
|
||||
expect(labels).toEqual(
|
||||
expect.arrayContaining(['Bypass', 'Remove Bypass'])
|
||||
)
|
||||
})
|
||||
|
||||
it('should recognize Frame Nodes as a core menu item', () => {
|
||||
const options: MenuOption[] = [
|
||||
{ label: 'Rename', source: 'vue' },
|
||||
|
||||
@@ -21,7 +21,10 @@ const HARD_BLACKLIST = new Set([
|
||||
'Title',
|
||||
'Mode',
|
||||
'Properties Panel',
|
||||
'Copy (Clipspace)'
|
||||
'Copy (Clipspace)',
|
||||
// Vue getBypassOption supplies the single state-aware Bypass/Remove Bypass item
|
||||
'Bypass',
|
||||
'Remove Bypass'
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -23,6 +23,7 @@ import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import type { NodeId } from '@/renderer/core/layout/types'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { isDOMWidget } from '@/scripts/domWidget'
|
||||
import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
|
||||
import { normalizeControlOption } from '@/types/simplifiedWidget'
|
||||
@@ -154,9 +155,7 @@ function isPromotedDOMWidget(widget: IBaseWidget): boolean {
|
||||
export function getControlWidget(
|
||||
widget: IBaseWidget
|
||||
): SafeControlWidget | undefined {
|
||||
const cagWidget = widget.linkedWidgets?.find(
|
||||
(w) => w.name == 'control_after_generate'
|
||||
)
|
||||
const cagWidget = widget.linkedWidgets?.find((w) => w[IS_CONTROL_WIDGET])
|
||||
if (!cagWidget) return
|
||||
return {
|
||||
value: normalizeControlOption(cagWidget.value),
|
||||
|
||||
@@ -212,7 +212,7 @@ export function useMoreOptionsMenu() {
|
||||
}
|
||||
if (!groupContext) {
|
||||
const pin = getPinOption(states, bump)
|
||||
const bypass = getBypassOption(states, bump)
|
||||
const bypass = getBypassOption(bump)
|
||||
options.push(pin)
|
||||
options.push(bypass)
|
||||
}
|
||||
|
||||
97
src/composables/graph/useNodeMenuOptions.test.ts
Normal file
97
src/composables/graph/useNodeMenuOptions.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { defineComponent } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useNodeMenuOptions } from '@/composables/graph/useNodeMenuOptions'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
const mockApp = vi.hoisted(() => ({
|
||||
canvas: {
|
||||
selected_nodes: null as Record<string, LGraphNode> | null
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({ app: mockApp }))
|
||||
|
||||
vi.mock('@/composables/graph/useNodeCustomization', () => ({
|
||||
useNodeCustomization: () => ({
|
||||
shapeOptions: [],
|
||||
applyShape: vi.fn(),
|
||||
applyColor: vi.fn(),
|
||||
colorOptions: [],
|
||||
isLightTheme: { value: false }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/graph/useSelectedNodeActions', () => ({
|
||||
useSelectedNodeActions: () => ({
|
||||
adjustNodeSize: vi.fn(),
|
||||
toggleNodeCollapse: vi.fn(),
|
||||
toggleNodePin: vi.fn(),
|
||||
toggleNodeBypass: vi.fn(),
|
||||
runBranch: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: {} },
|
||||
missingWarn: false,
|
||||
fallbackWarn: false
|
||||
})
|
||||
|
||||
const setSelectedNodes = (nodes: LGraphNode[]) => {
|
||||
const dict: Record<string, LGraphNode> = {}
|
||||
nodes.forEach((n, i) => {
|
||||
dict[String(i)] = n
|
||||
})
|
||||
mockApp.canvas.selected_nodes = dict
|
||||
}
|
||||
|
||||
const nodeWithMode = (mode: LGraphEventMode, id = 1): LGraphNode =>
|
||||
({ id, mode }) as LGraphNode
|
||||
|
||||
const getBypassLabel = (): string => {
|
||||
let label = ''
|
||||
const Wrapper = defineComponent({
|
||||
setup() {
|
||||
const { getBypassOption } = useNodeMenuOptions()
|
||||
label = getBypassOption(() => {}).label ?? ''
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
render(Wrapper, { global: { plugins: [i18n] } })
|
||||
return label
|
||||
}
|
||||
|
||||
describe('useNodeMenuOptions.getBypassOption', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
mockApp.canvas.selected_nodes = null
|
||||
})
|
||||
|
||||
it('labels as "Bypass" when no node is bypassed', () => {
|
||||
setSelectedNodes([nodeWithMode(LGraphEventMode.ALWAYS, 1)])
|
||||
expect(getBypassLabel()).toBe('contextMenu.Bypass')
|
||||
})
|
||||
|
||||
it('labels as "Remove Bypass" when every selected node is bypassed', () => {
|
||||
setSelectedNodes([
|
||||
nodeWithMode(LGraphEventMode.BYPASS, 1),
|
||||
nodeWithMode(LGraphEventMode.BYPASS, 2)
|
||||
])
|
||||
expect(getBypassLabel()).toBe('contextMenu.Remove Bypass')
|
||||
})
|
||||
|
||||
it('labels as "Bypass" on mixed selection so it matches the toggle action', () => {
|
||||
setSelectedNodes([
|
||||
nodeWithMode(LGraphEventMode.BYPASS, 1),
|
||||
nodeWithMode(LGraphEventMode.ALWAYS, 2)
|
||||
])
|
||||
expect(getBypassLabel()).toBe('contextMenu.Bypass')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,9 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { LGraphEventMode } from '@/lib/litegraph/src/litegraph'
|
||||
|
||||
import type { MenuOption } from './useMoreOptionsMenu'
|
||||
import { useNodeCustomization } from './useNodeCustomization'
|
||||
import { useSelectedNodeActions } from './useSelectedNodeActions'
|
||||
@@ -20,6 +23,7 @@ export function useNodeMenuOptions() {
|
||||
toggleNodeBypass,
|
||||
runBranch
|
||||
} = useSelectedNodeActions()
|
||||
const { areAllSelectedNodesInMode } = useSelectedLiteGraphItems()
|
||||
|
||||
const shapeSubmenu = computed(() =>
|
||||
shapeOptions.map((shape) => ({
|
||||
@@ -91,11 +95,8 @@ export function useNodeMenuOptions() {
|
||||
}
|
||||
})
|
||||
|
||||
const getBypassOption = (
|
||||
states: NodeSelectionState,
|
||||
bump: () => void
|
||||
): MenuOption => ({
|
||||
label: states.bypassed
|
||||
const getBypassOption = (bump: () => void): MenuOption => ({
|
||||
label: areAllSelectedNodesInMode(LGraphEventMode.BYPASS)
|
||||
? t('contextMenu.Remove Bypass')
|
||||
: t('contextMenu.Bypass'),
|
||||
icon: 'icon-[lucide--redo-dot]',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphEventMode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
@@ -13,7 +13,6 @@ import { filterOutputNodes } from '@/utils/nodeFilterUtil'
|
||||
export interface NodeSelectionState {
|
||||
collapsed: boolean
|
||||
pinned: boolean
|
||||
bypassed: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,12 +77,10 @@ export function useSelectionState() {
|
||||
const computeSelectionStatesFromNodes = (
|
||||
nodes: LGraphNode[]
|
||||
): NodeSelectionState => {
|
||||
if (!nodes.length)
|
||||
return { collapsed: false, pinned: false, bypassed: false }
|
||||
if (!nodes.length) return { collapsed: false, pinned: false }
|
||||
return {
|
||||
collapsed: nodes.some((n) => n.flags?.collapsed),
|
||||
pinned: nodes.some((n) => n.pinned),
|
||||
bypassed: nodes.some((n) => n.mode === LGraphEventMode.BYPASS)
|
||||
pinned: nodes.some((n) => n.pinned)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, reactive, readonly } from 'vue'
|
||||
|
||||
import { isCloud, isNightly } from '@/platform/distribution/types'
|
||||
import {
|
||||
cachedTeamWorkspacesEnabled,
|
||||
isAuthenticatedConfigLoaded,
|
||||
remoteConfig
|
||||
} from '@/platform/remoteConfig/remoteConfig'
|
||||
@@ -107,7 +108,8 @@ export function useFeatureFlags() {
|
||||
if (override !== undefined) return override
|
||||
|
||||
if (!isCloud) return false
|
||||
if (!isAuthenticatedConfigLoaded.value) return false
|
||||
if (!isAuthenticatedConfigLoaded.value)
|
||||
return cachedTeamWorkspacesEnabled.value ?? false
|
||||
|
||||
return (
|
||||
remoteConfig.value.team_workspaces_enabled ??
|
||||
|
||||
@@ -108,23 +108,6 @@ describe('useWaveAudioPlayer', () => {
|
||||
expect(bars.value).toHaveLength(10)
|
||||
})
|
||||
|
||||
it('clears blobUrl and shows placeholder bars when fetch fails', async () => {
|
||||
mockFetchApi.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const src = ref('/api/view?filename=audio.wav&type=output')
|
||||
const { bars, loading, audioSrc } = useWaveAudioPlayer({
|
||||
src,
|
||||
barCount: 10
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(loading.value).toBe(false)
|
||||
})
|
||||
|
||||
expect(bars.value).toHaveLength(10)
|
||||
expect(audioSrc.value).toBe('/api/view?filename=audio.wav&type=output')
|
||||
})
|
||||
|
||||
it('does not call decodeAudioSource when src is empty', () => {
|
||||
const src = ref('')
|
||||
useWaveAudioPlayer({ src })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMediaControls, whenever } from '@vueuse/core'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
@@ -19,7 +19,6 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
|
||||
|
||||
const audioRef = ref<HTMLAudioElement>()
|
||||
const waveformRef = ref<HTMLElement>()
|
||||
const blobUrl = ref<string>()
|
||||
const loading = ref(false)
|
||||
let decodeRequestId = 0
|
||||
const bars = ref<WaveformBar[]>(generatePlaceholderBars())
|
||||
@@ -35,10 +34,6 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
|
||||
const formattedCurrentTime = computed(() => formatTime(currentTime.value))
|
||||
const formattedDuration = computed(() => formatTime(duration.value))
|
||||
|
||||
const audioSrc = computed(() =>
|
||||
src.value ? (blobUrl.value ?? src.value) : ''
|
||||
)
|
||||
|
||||
function generatePlaceholderBars(): WaveformBar[] {
|
||||
return Array.from({ length: barCount }, () => ({
|
||||
height: Math.random() * 60 + 10
|
||||
@@ -90,22 +85,12 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
|
||||
|
||||
if (requestId !== decodeRequestId) return
|
||||
|
||||
const blob = new Blob([arrayBuffer.slice(0)], {
|
||||
type: response.headers.get('content-type') ?? 'audio/wav'
|
||||
})
|
||||
if (blobUrl.value) URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = URL.createObjectURL(blob)
|
||||
|
||||
ctx = new AudioContext()
|
||||
const audioBuffer = await ctx.decodeAudioData(arrayBuffer)
|
||||
if (requestId !== decodeRequestId) return
|
||||
generateBarsFromBuffer(audioBuffer)
|
||||
} catch {
|
||||
if (requestId === decodeRequestId) {
|
||||
if (blobUrl.value) {
|
||||
URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = undefined
|
||||
}
|
||||
bars.value = generatePlaceholderBars()
|
||||
}
|
||||
} finally {
|
||||
@@ -173,19 +158,9 @@ export function useWaveAudioPlayer(options: UseWaveAudioPlayerOptions) {
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
decodeRequestId += 1
|
||||
audioRef.value?.pause()
|
||||
if (blobUrl.value) {
|
||||
URL.revokeObjectURL(blobUrl.value)
|
||||
blobUrl.value = undefined
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
audioRef,
|
||||
waveformRef,
|
||||
audioSrc,
|
||||
bars,
|
||||
loading,
|
||||
isPlaying: playing,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LLink } from '@/lib/litegraph/src/LLink'
|
||||
import { commonType } from '@/lib/litegraph/src/utils/type'
|
||||
import { resolveNodeRootGraphId } from '@/lib/litegraph/src/utils/widget'
|
||||
import { transformInputSpecV1ToV2 } from '@/schemas/nodeDef/migration'
|
||||
import type { ComboInputSpec, InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
const INLINE_INPUTS = false
|
||||
|
||||
@@ -185,11 +187,18 @@ function dynamicComboWidget(
|
||||
//A little hacky, but onConfigure won't work.
|
||||
//It fires too late and is overly disruptive
|
||||
let widgetValue = widget.value
|
||||
const getState = () => {
|
||||
const graphId = resolveNodeRootGraphId(node)
|
||||
if (!graphId) return undefined
|
||||
return useWidgetValueStore().getWidget(graphId, node.id, widget.name)
|
||||
}
|
||||
Object.defineProperty(widget, 'value', {
|
||||
get() {
|
||||
return widgetValue
|
||||
return getState()?.value ?? widgetValue
|
||||
},
|
||||
set(value) {
|
||||
const state = getState()
|
||||
if (state) state.value = value
|
||||
widgetValue = value
|
||||
updateWidgets(value)
|
||||
}
|
||||
|
||||
15
src/i18n.ts
15
src/i18n.ts
@@ -155,6 +155,7 @@ export const i18n = createI18n({
|
||||
|
||||
/** Convenience shorthand: i18n.global */
|
||||
export const { t, te, d } = i18n.global
|
||||
const { tm } = i18n.global
|
||||
|
||||
/**
|
||||
* Safe translation function that returns the fallback message if the key is not found.
|
||||
@@ -166,3 +167,17 @@ export function st(key: string, fallbackMessage: string) {
|
||||
// The normal defaultMsg overload fails in some cases for custom nodes
|
||||
return te(key) ? t(key) : fallbackMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* Safe raw translation function for strings that may contain i18n syntax.
|
||||
*
|
||||
* @param key - The key for the raw locale message.
|
||||
* @param fallbackMessage - The fallback message to use if the key is not found
|
||||
* or the locale message is not a string.
|
||||
*/
|
||||
export function stRaw(key: string, fallbackMessage: string) {
|
||||
if (!te(key)) return fallbackMessage
|
||||
|
||||
const message = tm(key)
|
||||
return typeof message === 'string' ? message : fallbackMessage
|
||||
}
|
||||
|
||||
@@ -1670,6 +1670,7 @@ export class LGraph
|
||||
|
||||
// Record state before conversion for proper undo support
|
||||
this.beforeChange()
|
||||
this.canvasAction((c) => c.emitBeforeChange())
|
||||
|
||||
try {
|
||||
function extractNodes(item: Positionable): Positionable[] {
|
||||
@@ -1684,6 +1685,7 @@ export class LGraph
|
||||
} finally {
|
||||
// Mark state change complete for proper undo support
|
||||
this.afterChange()
|
||||
this.canvasAction((c) => c.emitAfterChange())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ export abstract class SubgraphIONodeBase<
|
||||
break
|
||||
}
|
||||
|
||||
this.subgraph.setDirtyCanvas(true)
|
||||
this.subgraph.setDirtyCanvas(true, true)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
65
src/platform/cloud/onboarding/onboardingCloudRoutes.test.ts
Normal file
65
src/platform/cloud/onboarding/onboardingCloudRoutes.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { oauthConsentRedirect } from '@/platform/cloud/onboarding/onboardingCloudRoutes'
|
||||
import {
|
||||
captureOAuthRequestId,
|
||||
clearOAuthRequestId
|
||||
} from '@/platform/cloud/oauth/oauthState'
|
||||
|
||||
const VALID_REQUEST_ID = '550e8400-e29b-41d4-a716-446655440000'
|
||||
|
||||
const createSessionOrThrow = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
vi.mock('@/platform/auth/session/useSessionCookie', () => ({
|
||||
useSessionCookie: () => ({ createSessionOrThrow })
|
||||
}))
|
||||
|
||||
describe('oauthConsentRedirect', () => {
|
||||
beforeEach(() => {
|
||||
clearOAuthRequestId()
|
||||
createSessionOrThrow.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('routes to user-check and mints no session when no OAuth flow is pending', async () => {
|
||||
const target = await oauthConsentRedirect()
|
||||
|
||||
expect(target).toEqual({ name: 'cloud-user-check' })
|
||||
expect(createSessionOrThrow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('mints the Cloud session cookie before redirecting to consent when resuming OAuth', async () => {
|
||||
// Regression: an already-signed-in user (Firebase) carries no Cloud session
|
||||
// cookie, so the consent challenge fetch fails unless the cookie is minted
|
||||
// here, mirroring the post-login resume path.
|
||||
captureOAuthRequestId({ oauth_request_id: VALID_REQUEST_ID })
|
||||
|
||||
const target = await oauthConsentRedirect()
|
||||
|
||||
expect(createSessionOrThrow).toHaveBeenCalledOnce()
|
||||
expect(target).toEqual({
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: VALID_REQUEST_ID }
|
||||
})
|
||||
})
|
||||
|
||||
it('still lands on consent when session minting fails so the view can surface the error', async () => {
|
||||
captureOAuthRequestId({ oauth_request_id: VALID_REQUEST_ID })
|
||||
createSessionOrThrow.mockRejectedValue(new Error('Unauthorized'))
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
try {
|
||||
const target = await oauthConsentRedirect()
|
||||
|
||||
expect(target).toEqual({
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: VALID_REQUEST_ID }
|
||||
})
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Failed to establish Cloud session cookie before OAuth consent:',
|
||||
expect.any(Error)
|
||||
)
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -5,14 +5,39 @@ import { getOAuthRequestId } from '@/platform/cloud/oauth/oauthState'
|
||||
// `oauth_request_id` capture lives in the global router.beforeEach guard
|
||||
// (src/router.ts), which runs before any per-route beforeEnter. Per-route
|
||||
// guards read it back via getOAuthRequestId().
|
||||
function oauthConsentRedirect() {
|
||||
//
|
||||
// When an already-signed-in user is bounced to login/signup mid-OAuth, we skip
|
||||
// the sign-in step and jump straight to consent. The consent challenge fetch
|
||||
// (GET /oauth/authorize) is authenticated by the Cloud *session cookie*, which
|
||||
// is a separate credential from the Firebase client login that `isLoggedIn`
|
||||
// reflects. The post-login resume path mints that cookie via
|
||||
// `createSessionOrThrow` (see useOAuthPostLoginRedirect); the already-signed-in
|
||||
// path must do the same. Without it the consent fetch is unauthenticated, the
|
||||
// backend 302s it to login, and the consent view fails with
|
||||
// "OAuth request failed. Please restart from the client app."
|
||||
export async function oauthConsentRedirect() {
|
||||
const oauthRequestId = getOAuthRequestId()
|
||||
return oauthRequestId
|
||||
? {
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: oauthRequestId }
|
||||
}
|
||||
: { name: 'cloud-user-check' }
|
||||
if (!oauthRequestId) return { name: 'cloud-user-check' }
|
||||
|
||||
try {
|
||||
const { useSessionCookie } =
|
||||
await import('@/platform/auth/session/useSessionCookie')
|
||||
await useSessionCookie().createSessionOrThrow()
|
||||
} catch (error) {
|
||||
// Best effort: if the cookie can't be minted (e.g. an expired Firebase
|
||||
// token), still land on the consent view so it can surface the failure and
|
||||
// prompt the user to restart from the client app, rather than silently
|
||||
// dropping the OAuth flow.
|
||||
console.warn(
|
||||
'Failed to establish Cloud session cookie before OAuth consent:',
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'cloud-oauth-consent',
|
||||
query: { oauth_request_id: oauthRequestId }
|
||||
}
|
||||
}
|
||||
|
||||
export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
@@ -34,7 +59,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
if (isLoggedIn.value) {
|
||||
return next(oauthConsentRedirect())
|
||||
return next(await oauthConsentRedirect())
|
||||
}
|
||||
}
|
||||
next()
|
||||
@@ -52,7 +77,7 @@ export const cloudOnboardingRoutes: RouteRecordRaw[] = [
|
||||
const { isLoggedIn } = useCurrentUser()
|
||||
|
||||
if (isLoggedIn.value) {
|
||||
return next(oauthConsentRedirect())
|
||||
return next(await oauthConsentRedirect())
|
||||
}
|
||||
}
|
||||
next()
|
||||
|
||||
@@ -1,22 +1,46 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
downloadModel,
|
||||
fetchModelMetadata,
|
||||
isModelDownloadable,
|
||||
toBrowsableUrl
|
||||
} from './missingModelDownload'
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
const { fetchMock, mockIsDesktop, mockSidebarTabStore, mockStartDownload } =
|
||||
vi.hoisted(() => ({
|
||||
fetchMock: vi.fn(),
|
||||
mockIsDesktop: { value: false },
|
||||
mockSidebarTabStore: { activeSidebarTabId: null as string | null },
|
||||
mockStartDownload: vi.fn()
|
||||
}))
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({ isDesktop: false }))
|
||||
vi.mock('@/stores/electronDownloadStore', () => ({}))
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isDesktop() {
|
||||
return mockIsDesktop.value
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/electronDownloadStore', () => ({
|
||||
useElectronDownloadStore: () => ({
|
||||
start: mockStartDownload
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||
useSidebarTabStore: () => mockSidebarTabStore
|
||||
}))
|
||||
|
||||
let testId = 0
|
||||
|
||||
describe('fetchModelMetadata', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset()
|
||||
mockIsDesktop.value = false
|
||||
mockSidebarTabStore.activeSidebarTabId = null
|
||||
mockStartDownload.mockReset()
|
||||
testId++
|
||||
})
|
||||
|
||||
@@ -213,3 +237,31 @@ describe('isModelDownloadable', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadModel', () => {
|
||||
beforeEach(() => {
|
||||
mockIsDesktop.value = false
|
||||
mockSidebarTabStore.activeSidebarTabId = null
|
||||
mockStartDownload.mockReset()
|
||||
})
|
||||
|
||||
it('opens the model library sidebar before starting a desktop download', () => {
|
||||
mockIsDesktop.value = true
|
||||
|
||||
downloadModel(
|
||||
{
|
||||
name: 'model.safetensors',
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
directory: 'checkpoints'
|
||||
},
|
||||
{ checkpoints: ['/models/checkpoints'] }
|
||||
)
|
||||
|
||||
expect(mockSidebarTabStore.activeSidebarTabId).toBe('model-library')
|
||||
expect(mockStartDownload).toHaveBeenCalledWith({
|
||||
url: 'https://huggingface.co/org/model/resolve/main/model.safetensors',
|
||||
savePath: '/models/checkpoints',
|
||||
filename: 'model.safetensors'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { downloadUrlToHfRepoUrl, isCivitaiModelUrl } from '@/utils/formatUtil'
|
||||
import { isDesktop } from '@/platform/distribution/types'
|
||||
import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
|
||||
const ALLOWED_SOURCES = [
|
||||
'https://civitai.com/',
|
||||
@@ -26,6 +27,8 @@ const WHITE_LISTED_URLS: ReadonlySet<string> = new Set([
|
||||
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
|
||||
])
|
||||
|
||||
const MODEL_LIBRARY_TAB_ID = 'model-library'
|
||||
|
||||
export interface ModelWithUrl {
|
||||
name: string
|
||||
url: string
|
||||
@@ -72,6 +75,7 @@ export function downloadModel(
|
||||
|
||||
const modelPaths = paths[model.directory]
|
||||
if (modelPaths?.[0]) {
|
||||
useSidebarTabStore().activeSidebarTabId = MODEL_LIBRARY_TAB_ID
|
||||
void useElectronDownloadStore().start({
|
||||
url: model.url,
|
||||
savePath: modelPaths[0],
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
import { remoteConfig, remoteConfigState } from './remoteConfig'
|
||||
import {
|
||||
cachedTeamWorkspacesEnabled,
|
||||
remoteConfig,
|
||||
remoteConfigState
|
||||
} from './remoteConfig'
|
||||
|
||||
interface RefreshRemoteConfigOptions {
|
||||
/**
|
||||
@@ -34,6 +38,10 @@ export async function refreshRemoteConfig(
|
||||
window.__CONFIG__ = config
|
||||
remoteConfig.value = config
|
||||
remoteConfigState.value = useAuth ? 'authenticated' : 'anonymous'
|
||||
if (useAuth)
|
||||
cachedTeamWorkspacesEnabled.value = Boolean(
|
||||
config.team_workspaces_enabled
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { useStorage } from '@vueuse/core'
|
||||
|
||||
import type { ServerFeatureFlag } from '@/composables/useFeatureFlags'
|
||||
|
||||
/**
|
||||
* Remote configuration service
|
||||
*
|
||||
@@ -50,3 +54,8 @@ export function configValueOrDefault<K extends keyof RemoteConfig>(
|
||||
const configValue = remoteConfig[key]
|
||||
return configValue || defaultValue
|
||||
}
|
||||
|
||||
export const cachedTeamWorkspacesEnabled = useStorage<boolean | undefined>(
|
||||
'team_workspaces_enabled' satisfies `${ServerFeatureFlag.TEAM_WORKSPACES_ENABLED}`,
|
||||
undefined
|
||||
)
|
||||
|
||||
@@ -263,4 +263,72 @@ describe('PostHogTelemetryProvider', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('before_send', () => {
|
||||
it('strips PII keys from event properties, $set, and $set_once', async () => {
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const { before_send } = hoisted.mockInit.mock.calls[0][1]
|
||||
|
||||
const event = {
|
||||
event: 'test',
|
||||
properties: {
|
||||
email: 'props@example.com',
|
||||
prompt: 'hello',
|
||||
user_email: 'props_user@example.com',
|
||||
$email: 'props_posthog@example.com',
|
||||
method: 'google'
|
||||
},
|
||||
$set: {
|
||||
email: 'set@example.com',
|
||||
user_email: 'set_user@example.com',
|
||||
$email: 'set_posthog@example.com',
|
||||
name: 'keep me'
|
||||
},
|
||||
$set_once: {
|
||||
email: 'set_once@example.com',
|
||||
plan: 'free'
|
||||
}
|
||||
}
|
||||
|
||||
const result = before_send(event)
|
||||
|
||||
// event.properties — all four PII keys stripped, non-PII preserved
|
||||
expect(result.properties).not.toHaveProperty('email')
|
||||
expect(result.properties).not.toHaveProperty('prompt')
|
||||
expect(result.properties).not.toHaveProperty('user_email')
|
||||
expect(result.properties).not.toHaveProperty('$email')
|
||||
expect(result.properties).toHaveProperty('method', 'google')
|
||||
|
||||
// event.$set — PII stripped, non-PII preserved
|
||||
// posthog.identify(id, { email }) lands here, not in properties
|
||||
expect(result.$set).not.toHaveProperty('email')
|
||||
expect(result.$set).not.toHaveProperty('user_email')
|
||||
expect(result.$set).not.toHaveProperty('$email')
|
||||
expect(result.$set).toHaveProperty('name', 'keep me')
|
||||
|
||||
// event.$set_once — PII stripped, non-PII preserved
|
||||
expect(result.$set_once).not.toHaveProperty('email')
|
||||
expect(result.$set_once).toHaveProperty('plan', 'free')
|
||||
})
|
||||
|
||||
it('remoteConfig.posthog_config cannot override before_send or person_profiles', async () => {
|
||||
const remoteBefore_send = vi.fn()
|
||||
mockRemoteConfig.value = {
|
||||
posthog_config: {
|
||||
before_send: remoteBefore_send,
|
||||
person_profiles: 'always'
|
||||
}
|
||||
}
|
||||
|
||||
createProvider()
|
||||
await vi.dynamicImportSettled()
|
||||
|
||||
const initConfig = hoisted.mockInit.mock.calls[0][1]
|
||||
|
||||
expect(initConfig.before_send).not.toBe(remoteBefore_send)
|
||||
expect(initConfig.person_profiles).toBe('identified_only')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import { createPostHogBeforeSend } from '@comfyorg/shared-frontend-utils/piiUtil'
|
||||
|
||||
import { useAppMode } from '@/composables/useAppMode'
|
||||
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
|
||||
import { useSubscription } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
@@ -114,7 +116,13 @@ export class PostHogTelemetryProvider implements TelemetryProvider {
|
||||
capture_pageleave: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
debug: import.meta.env.VITE_POSTHOG_DEBUG === 'true',
|
||||
...serverConfig
|
||||
...serverConfig,
|
||||
person_profiles: 'identified_only',
|
||||
// cookie_domain omitted: posthog-js sets a first-party cross-subdomain cookie
|
||||
// automatically when persistence includes 'cookie' (the default).
|
||||
// Explicit override interacts badly with posthog-js#3578 where reset() fails
|
||||
// to clear localStorage on other subdomains, causing identity bleed on logout.
|
||||
before_send: createPostHogBeforeSend()
|
||||
})
|
||||
this.isInitialized = true
|
||||
this.flushEventQueue()
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { i18n, te } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import type { Settings } from '@/schemas/apiSchema'
|
||||
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
import { useNodeTooltips } from './useNodeTooltips'
|
||||
|
||||
const jsonTooltip =
|
||||
'Positive point prompts as JSON [{"x": int, "y": int}, ...] (pixel coords)'
|
||||
|
||||
const positiveCoordsTooltipKey =
|
||||
'nodeDefs.SAM3_Detect.inputs.positive_coords.tooltip'
|
||||
|
||||
const outputTooltipKey = 'nodeDefs.SAM3_Detect.outputs.0.tooltip'
|
||||
|
||||
const positiveCoordsWidget: SafeWidgetData = {
|
||||
name: 'positive_coords',
|
||||
type: 'STRING'
|
||||
}
|
||||
|
||||
function mergeOutputTooltipMessage(tooltip: string | null) {
|
||||
i18n.global.mergeLocaleMessage('en', {
|
||||
nodeDefs: {
|
||||
SAM3_Detect: {
|
||||
outputs: {
|
||||
0: {
|
||||
tooltip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const sam3DetectNodeDef: ComfyNodeDef = {
|
||||
name: 'SAM3_Detect',
|
||||
display_name: 'SAM3 Detect',
|
||||
category: 'detection/',
|
||||
python_module: 'comfy_extras.nodes_sam3',
|
||||
description: '',
|
||||
input: {
|
||||
required: {},
|
||||
optional: {
|
||||
positive_coords: [
|
||||
'STRING',
|
||||
{
|
||||
tooltip: jsonTooltip,
|
||||
forceInput: true
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
output: ['MASK'],
|
||||
output_name: ['masks'],
|
||||
output_tooltips: [jsonTooltip],
|
||||
output_node: false,
|
||||
deprecated: false,
|
||||
experimental: false
|
||||
}
|
||||
|
||||
describe('useNodeTooltips', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
|
||||
vi.spyOn(useSettingStore(), 'get').mockImplementation(
|
||||
<K extends keyof Settings>(key: K): Settings[K] => {
|
||||
switch (key) {
|
||||
case 'Comfy.EnableTooltips':
|
||||
return true as Settings[K]
|
||||
case 'LiteGraph.Node.TooltipDelay':
|
||||
return 500 as Settings[K]
|
||||
default:
|
||||
return undefined as Settings[K]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
useNodeDefStore().addNodeDef(sam3DetectNodeDef)
|
||||
mergeOutputTooltipMessage(jsonTooltip)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mergeOutputTooltipMessage(null)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('reads JSON examples in node metadata without i18n placeholder errors', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { getInputSlotTooltip } = useNodeTooltips('SAM3_Detect')
|
||||
|
||||
// Ensure this exercises the bundled i18n path, not only metadata fallback.
|
||||
expect(te(positiveCoordsTooltipKey)).toBe(true)
|
||||
expect(getInputSlotTooltip('positive_coords')).toBe(jsonTooltip)
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads input-based widget tooltips without i18n placeholder errors', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { getWidgetTooltip } = useNodeTooltips('SAM3_Detect')
|
||||
|
||||
expect(te(positiveCoordsTooltipKey)).toBe(true)
|
||||
expect(getWidgetTooltip(positiveCoordsWidget)).toBe(jsonTooltip)
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reads output slot tooltips without i18n placeholder errors', () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { getOutputSlotTooltip } = useNodeTooltips('SAM3_Detect')
|
||||
|
||||
expect(te(outputTooltipKey)).toBe(true)
|
||||
expect(getOutputSlotTooltip(0)).toBe(jsonTooltip)
|
||||
expect(consoleError).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import { computed, ref, unref } from 'vue'
|
||||
import type { MaybeRef } from 'vue'
|
||||
|
||||
import type { SafeWidgetData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { st } from '@/i18n'
|
||||
import { st, stRaw } from '@/i18n'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
@@ -119,7 +119,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
|
||||
|
||||
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.inputs.${normalizeI18nKey(slotName)}.tooltip`
|
||||
const inputTooltip = nodeDef.value.inputs?.[slotName]?.tooltip ?? ''
|
||||
return st(key, inputTooltip)
|
||||
return stRaw(key, inputTooltip)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,7 +130,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
|
||||
|
||||
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.outputs.${slotIndex}.tooltip`
|
||||
const outputTooltip = nodeDef.value.outputs?.[slotIndex]?.tooltip ?? ''
|
||||
return st(key, outputTooltip)
|
||||
return stRaw(key, outputTooltip)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +146,7 @@ export function useNodeTooltips(nodeType: MaybeRef<string>) {
|
||||
// Then try input-based tooltip lookup
|
||||
const key = `nodeDefs.${normalizeI18nKey(unref(nodeType))}.inputs.${normalizeI18nKey(widget.name)}.tooltip`
|
||||
const inputTooltip = nodeDef.value.inputs?.[widget.name]?.tooltip ?? ''
|
||||
return st(key, inputTooltip)
|
||||
return stRaw(key, inputTooltip)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,15 +54,30 @@ describe('getWidgetIdentity', () => {
|
||||
expect(renderKey).toBe(dedupeIdentity)
|
||||
})
|
||||
|
||||
it('returns transient renderKey for widgets without stable identity', () => {
|
||||
it('falls back to host nodeId so duplicate normal widgets dedupe', () => {
|
||||
const widget = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(widget, '5', 3)
|
||||
expect(dedupeIdentity).toBe('node:5:test_widget:test_widget:combo')
|
||||
expect(renderKey).toBe(dedupeIdentity)
|
||||
})
|
||||
|
||||
it('returns transient renderKey when no nodeId is available at all', () => {
|
||||
const widget = createMockWidget({
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const { dedupeIdentity, renderKey } = getWidgetIdentity(
|
||||
widget,
|
||||
undefined,
|
||||
3
|
||||
)
|
||||
expect(dedupeIdentity).toBeUndefined()
|
||||
expect(renderKey).toBe('transient:5:test_widget:test_widget:combo:3')
|
||||
expect(renderKey).toBe('transient::test_widget:test_widget:combo:3')
|
||||
})
|
||||
|
||||
it('uses sourceExecutionId for identity when no nodeId', () => {
|
||||
@@ -360,6 +375,46 @@ describe('computeProcessedWidgets borderStyle', () => {
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].hidden).toBe(false)
|
||||
})
|
||||
|
||||
it('collapses duplicate normal widgets on the same node to one render', () => {
|
||||
const colorA = createMockWidget({
|
||||
name: 'color',
|
||||
type: 'color',
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
const colorB = createMockWidget({
|
||||
name: 'color',
|
||||
type: 'color',
|
||||
nodeId: undefined,
|
||||
storeNodeId: undefined,
|
||||
sourceExecutionId: undefined
|
||||
})
|
||||
|
||||
const result = computeProcessedWidgets({
|
||||
nodeData: {
|
||||
id: '1',
|
||||
type: 'ColorToRGBInt',
|
||||
widgets: [colorA, colorB],
|
||||
title: 'Color to RGB Int',
|
||||
mode: 0,
|
||||
selected: false,
|
||||
executing: false,
|
||||
inputs: [],
|
||||
outputs: []
|
||||
},
|
||||
graphId: 'graph-test',
|
||||
showAdvanced: false,
|
||||
isGraphReady: false,
|
||||
rootGraph: null,
|
||||
ui: noopUi
|
||||
})
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].name).toBe('color')
|
||||
expect(result[0].renderKey).toBe('node:1:color:color:color')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWidgetUpdateHandler (via computeProcessedWidgets)', () => {
|
||||
|
||||
@@ -129,11 +129,15 @@ export function getWidgetIdentity(
|
||||
const rawWidgetId = widget.storeNodeId ?? widget.nodeId
|
||||
const storeWidgetName = widget.storeName ?? widget.name
|
||||
const slotNameForIdentity = widget.slotName ?? widget.name
|
||||
const hostNodeIdRoot =
|
||||
nodeId !== undefined && nodeId !== ''
|
||||
? `node:${String(stripGraphPrefix(nodeId))}`
|
||||
: undefined
|
||||
const stableIdentityRoot = rawWidgetId
|
||||
? `node:${String(stripGraphPrefix(rawWidgetId))}`
|
||||
: widget.sourceExecutionId
|
||||
? `exec:${widget.sourceExecutionId}`
|
||||
: undefined
|
||||
: hostNodeIdRoot
|
||||
|
||||
const dedupeIdentity = stableIdentityRoot
|
||||
? `${stableIdentityRoot}:${storeWidgetName}:${slotNameForIdentity}:${widget.type}`
|
||||
|
||||
@@ -28,6 +28,7 @@ const textMap: Record<ControlOptions, string | null> = {
|
||||
|
||||
<template>
|
||||
<button
|
||||
data-testid="value-control"
|
||||
type="button"
|
||||
:aria-label="t('widgets.valueControl.' + mode)"
|
||||
:class="
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
IColorWidget,
|
||||
IWidgetOptions
|
||||
} from '@/lib/litegraph/src/types/widgets'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useColorWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useColorWidget'
|
||||
|
||||
function createMockNode(): LGraphNode {
|
||||
const widgets: IColorWidget[] = []
|
||||
const addWidget = vi.fn(
|
||||
(
|
||||
type: string,
|
||||
name: string,
|
||||
value: string,
|
||||
_callback: () => void,
|
||||
options: IWidgetOptions
|
||||
) => {
|
||||
const widget = {
|
||||
type,
|
||||
name,
|
||||
value,
|
||||
options,
|
||||
callback: _callback
|
||||
} as unknown as IColorWidget
|
||||
widgets.push(widget)
|
||||
return widget
|
||||
}
|
||||
)
|
||||
|
||||
return { widgets, addWidget } as unknown as LGraphNode
|
||||
}
|
||||
|
||||
const colorSpec: InputSpec = {
|
||||
type: 'COLOR',
|
||||
name: 'color',
|
||||
default: '#ffffff',
|
||||
socketless: true
|
||||
}
|
||||
|
||||
describe('useColorWidget', () => {
|
||||
it('reads the top-level default from the V2 spec', () => {
|
||||
const node = createMockNode()
|
||||
const widget = useColorWidget()(node, colorSpec)
|
||||
expect(widget.value).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('falls back to nested options.default when top-level default is absent', () => {
|
||||
const node = createMockNode()
|
||||
const widget = useColorWidget()(node, {
|
||||
type: 'COLOR',
|
||||
name: 'color',
|
||||
options: { default: '#abcdef' }
|
||||
} as InputSpec)
|
||||
expect(widget.value).toBe('#abcdef')
|
||||
})
|
||||
|
||||
it('falls back to #000000 when no default is declared', () => {
|
||||
const node = createMockNode()
|
||||
const widget = useColorWidget()(node, {
|
||||
type: 'COLOR',
|
||||
name: 'color'
|
||||
} as InputSpec)
|
||||
expect(widget.value).toBe('#000000')
|
||||
})
|
||||
|
||||
it('returns the existing widget instead of creating a duplicate', () => {
|
||||
const node = createMockNode()
|
||||
const first = useColorWidget()(node, colorSpec)
|
||||
const second = useColorWidget()(node, colorSpec)
|
||||
expect(second).toBe(first)
|
||||
expect(node.widgets).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -8,8 +8,14 @@ import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
|
||||
|
||||
export const useColorWidget = (): ComfyWidgetConstructorV2 => {
|
||||
return (node: LGraphNode, inputSpec: InputSpecV2): IColorWidget => {
|
||||
const { name, options } = inputSpec as ColorInputSpec
|
||||
const defaultValue = options?.default || '#000000'
|
||||
const colorSpec = inputSpec as ColorInputSpec
|
||||
const { name, options } = colorSpec
|
||||
const defaultValue = colorSpec.default ?? options?.default ?? '#000000'
|
||||
|
||||
const existing = node.widgets?.find(
|
||||
(w): w is IColorWidget => w.name === name && w.type === 'color'
|
||||
)
|
||||
if (existing) return existing
|
||||
|
||||
const widget = node.addWidget('color', name, defaultValue, () => {}, {
|
||||
serialize: true
|
||||
|
||||
@@ -2,12 +2,13 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type {
|
||||
LGraph,
|
||||
LGraphCanvas,
|
||||
LGraphNode
|
||||
} from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON
|
||||
} from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { ComfyApp } from './app'
|
||||
import { createNode } from '@/utils/litegraphUtil'
|
||||
import {
|
||||
@@ -20,14 +21,30 @@ import {
|
||||
} from '@/composables/usePaste'
|
||||
import { getWorkflowDataFromFile } from '@/scripts/metadata/parser'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
import type { NodeError } from '@/schemas/apiSchema'
|
||||
|
||||
const {
|
||||
mockApiKeyAuthStore,
|
||||
mockAuthStore,
|
||||
mockSettingStore,
|
||||
mockToastStore,
|
||||
mockExtensionService,
|
||||
mockNodeOutputStore,
|
||||
mockWorkspaceWorkflow,
|
||||
mockRefreshMissingModelPipeline
|
||||
} = vi.hoisted(() => ({
|
||||
mockApiKeyAuthStore: {
|
||||
getApiKey: vi.fn()
|
||||
},
|
||||
mockAuthStore: {
|
||||
getAuthToken: vi.fn()
|
||||
},
|
||||
mockSettingStore: {
|
||||
get: vi.fn()
|
||||
},
|
||||
mockToastStore: {
|
||||
addAlert: vi.fn(),
|
||||
add: vi.fn(),
|
||||
@@ -41,7 +58,7 @@ const {
|
||||
refreshNodeOutputs: vi.fn()
|
||||
},
|
||||
mockWorkspaceWorkflow: {
|
||||
activeWorkflow: null
|
||||
activeWorkflow: null as ComfyWorkflow | null
|
||||
},
|
||||
mockRefreshMissingModelPipeline: vi.fn()
|
||||
}))
|
||||
@@ -55,6 +72,18 @@ vi.mock('@/utils/litegraphUtil', () => ({
|
||||
fixLinkInputSlots: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/apiKeyAuthStore', () => ({
|
||||
useApiKeyAuthStore: vi.fn(() => mockApiKeyAuthStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => mockAuthStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => mockSettingStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/usePaste', () => ({
|
||||
pasteAudioNode: vi.fn(),
|
||||
pasteAudioNodes: vi.fn(),
|
||||
@@ -110,6 +139,7 @@ function createMockCanvas(): Partial<LGraphCanvas> {
|
||||
|
||||
return {
|
||||
graph: mockGraph as LGraph,
|
||||
draw: vi.fn(),
|
||||
selectItems: vi.fn()
|
||||
}
|
||||
}
|
||||
@@ -141,8 +171,70 @@ describe('ComfyApp', () => {
|
||||
app = new ComfyApp()
|
||||
mockCanvas = createMockCanvas() as LGraphCanvas
|
||||
app.canvas = mockCanvas as LGraphCanvas
|
||||
mockWorkspaceWorkflow.activeWorkflow = null
|
||||
mockApiKeyAuthStore.getApiKey.mockReturnValue(undefined)
|
||||
mockAuthStore.getAuthToken.mockResolvedValue(undefined)
|
||||
mockExtensionService.invokeExtensions.mockReturnValue([])
|
||||
mockExtensionService.invokeExtensionsAsync.mockResolvedValue(undefined)
|
||||
mockSettingStore.get.mockImplementation((key: string) =>
|
||||
key === 'Comfy.RightSidePanel.ShowErrorsTab' ? true : undefined
|
||||
)
|
||||
})
|
||||
|
||||
describe('queuePrompt', () => {
|
||||
it('shows the error overlay for successful prompt responses with node errors', async () => {
|
||||
const graph = new LGraph()
|
||||
const workflow = new ComfyWorkflow({
|
||||
path: 'workflows/review.json',
|
||||
modified: 0,
|
||||
size: 0
|
||||
})
|
||||
const promptOutput: ComfyApiWorkflow = {
|
||||
'1': {
|
||||
class_type: 'PreviewAny',
|
||||
inputs: {},
|
||||
_meta: { title: 'PreviewAny' }
|
||||
}
|
||||
}
|
||||
const nodeErrors: Record<string, NodeError> = {
|
||||
'1': {
|
||||
class_type: 'PreviewAny',
|
||||
dependent_outputs: ['1'],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Required input is missing: source',
|
||||
details: '',
|
||||
extra_info: { input_name: 'source' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reflect.set(app, 'rootGraphInternal', graph)
|
||||
mockWorkspaceWorkflow.activeWorkflow = workflow
|
||||
vi.spyOn(app, 'graphToPrompt').mockResolvedValue({
|
||||
output: promptOutput,
|
||||
workflow: createWorkflowGraphData()
|
||||
})
|
||||
vi.spyOn(api, 'dispatchCustomEvent').mockImplementation(() => true)
|
||||
vi.spyOn(api, 'queuePrompt').mockResolvedValue({
|
||||
prompt_id: 'job-1',
|
||||
node_errors: nodeErrors,
|
||||
error: ''
|
||||
})
|
||||
|
||||
await expect(app.queuePrompt(0)).resolves.toBe(false)
|
||||
|
||||
const errorStore = useExecutionErrorStore()
|
||||
const executionStore = useExecutionStore()
|
||||
expect(errorStore.lastNodeErrors).toEqual(nodeErrors)
|
||||
expect(errorStore.isErrorOverlayOpen).toBe(true)
|
||||
expect(executionStore.queuedJobs['job-1']?.nodes).toEqual({ '1': false })
|
||||
expect(executionStore.jobIdToSessionWorkflowPath.get('job-1')).toBe(
|
||||
'workflows/review.json'
|
||||
)
|
||||
expect(mockCanvas.draw).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshComboInNodes', () => {
|
||||
|
||||
@@ -1623,20 +1623,32 @@ export class ComfyApp {
|
||||
})
|
||||
delete api.authToken
|
||||
delete api.apiKey
|
||||
executionErrorStore.lastNodeErrors = res.node_errors ?? null
|
||||
if (executionErrorStore.lastNodeErrors?.length) {
|
||||
const nodeErrors = res.node_errors
|
||||
const hasNodeErrors =
|
||||
nodeErrors && Object.keys(nodeErrors).length > 0
|
||||
executionErrorStore.lastNodeErrors = hasNodeErrors
|
||||
? nodeErrors
|
||||
: null
|
||||
try {
|
||||
if (res.prompt_id) {
|
||||
executionStore.storeJob({
|
||||
id: res.prompt_id,
|
||||
nodes: Object.keys(p.output),
|
||||
promptOutput: p.output,
|
||||
workflow: queuedWorkflow
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to store queued job metadata', {
|
||||
promptId: res.prompt_id,
|
||||
error
|
||||
})
|
||||
}
|
||||
if (hasNodeErrors) {
|
||||
if (useSettingStore().get('Comfy.RightSidePanel.ShowErrorsTab')) {
|
||||
executionErrorStore.showErrorOverlay()
|
||||
}
|
||||
this.canvas.draw(true, true)
|
||||
} else {
|
||||
try {
|
||||
if (res.prompt_id) {
|
||||
executionStore.storeJob({
|
||||
id: res.prompt_id,
|
||||
nodes: Object.keys(p.output),
|
||||
promptOutput: p.output,
|
||||
workflow: queuedWorkflow
|
||||
})
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
|
||||
@@ -52,7 +52,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
isErrorOverlayOpen.value = false
|
||||
}
|
||||
|
||||
/** Clear all error state. Called at execution start and workflow changes.
|
||||
/** Clear all error state.
|
||||
* Missing model state is intentionally preserved here to avoid wiping
|
||||
* in-progress model repairs (importTaskIds, URL inputs, etc.).
|
||||
* Missing models are cleared separately during workflow load/clean paths. */
|
||||
@@ -64,6 +64,17 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
isErrorOverlayOpen.value = false
|
||||
}
|
||||
|
||||
function clearExecutionStartErrors() {
|
||||
lastExecutionError.value = null
|
||||
lastPromptError.value = null
|
||||
if (
|
||||
!lastNodeErrors.value ||
|
||||
Object.keys(lastNodeErrors.value).length === 0
|
||||
) {
|
||||
isErrorOverlayOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear only prompt-level errors. Called during resetExecutionState. */
|
||||
function clearPromptError() {
|
||||
lastPromptError.value = null
|
||||
@@ -361,6 +372,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
|
||||
|
||||
// Clearing
|
||||
clearAllErrors,
|
||||
clearExecutionStartErrors,
|
||||
clearPromptError,
|
||||
|
||||
// Overlay UI
|
||||
|
||||
@@ -948,6 +948,48 @@ describe('useExecutionStore - WebSocket event handlers', () => {
|
||||
expect(store.queuedJobs['job-1']).toEqual({ nodes: {} })
|
||||
})
|
||||
|
||||
it('clears transient errors while preserving validation errors', () => {
|
||||
const errorStore = useExecutionErrorStore()
|
||||
const nodeErrors = {
|
||||
'1': {
|
||||
class_type: 'Test',
|
||||
dependent_outputs: [],
|
||||
errors: [
|
||||
{
|
||||
type: 'required_input_missing',
|
||||
message: 'Missing',
|
||||
details: '',
|
||||
extra_info: { input_name: 'x' }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
errorStore.lastExecutionError = {
|
||||
prompt_id: 'old-job',
|
||||
timestamp: 0,
|
||||
node_id: '1',
|
||||
node_type: 'Test',
|
||||
executed: [],
|
||||
exception_message: 'boom',
|
||||
exception_type: 'RuntimeError',
|
||||
traceback: []
|
||||
}
|
||||
errorStore.lastPromptError = {
|
||||
type: 'old-error',
|
||||
message: 'old prompt error',
|
||||
details: ''
|
||||
}
|
||||
errorStore.lastNodeErrors = nodeErrors
|
||||
errorStore.showErrorOverlay()
|
||||
|
||||
fire('execution_start', { prompt_id: 'job-1', timestamp: 0 })
|
||||
|
||||
expect(errorStore.lastExecutionError).toBeNull()
|
||||
expect(errorStore.lastPromptError).toBeNull()
|
||||
expect(errorStore.lastNodeErrors).toEqual(nodeErrors)
|
||||
expect(errorStore.isErrorOverlayOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('clears initializing state for the starting job', () => {
|
||||
store.initializingJobIds = new Set([
|
||||
'job-1',
|
||||
|
||||
@@ -261,7 +261,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
|
||||
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
|
||||
executionIdToLocatorCache.clear()
|
||||
executionErrorStore.clearAllErrors()
|
||||
executionErrorStore.clearExecutionStartErrors()
|
||||
activeJobId.value = e.detail.prompt_id
|
||||
queuedJobs.value[activeJobId.value] ??= { nodes: {} }
|
||||
clearInitializationByJobId(activeJobId.value)
|
||||
|
||||
@@ -473,6 +473,9 @@ export const useNodeOutputStore = defineStore('nodeOutput', () => {
|
||||
|
||||
node.imgs = [element]
|
||||
node.imageIndex = activeIndex
|
||||
|
||||
const outputs = getNodeOutputs(node)
|
||||
if (outputs?.images) node.images = outputs.images
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user