Compare commits
1 Commits
feat/surve
...
feat/9079-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
feaa05e20e |
@@ -1,183 +0,0 @@
|
||||
{
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-aaaa-bbbb-cccc-ddddeeee0001",
|
||||
"pos": [600, 400],
|
||||
"size": [200, 100],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-aaaa-bbbb-cccc-ddddeeee0001",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 5,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Subgraph With Duplicate Links",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [200, 400, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [900, 400, 120, 60]
|
||||
},
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "out-latent-1",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"pos": [920, 420]
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [400, 100],
|
||||
"size": [270, 262],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [100, 200],
|
||||
"size": [200, 106],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [1, 3, 4, 5]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [512, 512, 1]
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": 2,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"origin_id": 2,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"origin_id": 2,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"origin_id": 2,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 3,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"frontendVersion": "1.38.14"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -375,45 +375,6 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Unpacking', () => {
|
||||
test('Unpacking subgraph with duplicate links does not create extra links', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-duplicate-links'
|
||||
)
|
||||
|
||||
const result = await comfyPage.page.evaluate(() => {
|
||||
const graph = window.app!.graph!
|
||||
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
|
||||
if (!subgraphNode || !subgraphNode.isSubgraphNode()) {
|
||||
return { error: 'No subgraph node found' }
|
||||
}
|
||||
|
||||
graph.unpackSubgraph(subgraphNode)
|
||||
|
||||
const linkCount = graph.links.size
|
||||
const nodes = graph.nodes
|
||||
const ksampler = nodes.find((n) => n.type === 'KSampler')
|
||||
if (!ksampler) return { error: 'No KSampler found after unpack' }
|
||||
|
||||
const linkedInputCount = ksampler.inputs.filter(
|
||||
(i) => i.link != null
|
||||
).length
|
||||
|
||||
return { linkCount, linkedInputCount, nodeCount: nodes.length }
|
||||
})
|
||||
|
||||
expect(result).not.toHaveProperty('error')
|
||||
// Should have exactly 1 link (EmptyLatentImage→KSampler)
|
||||
// not 4 (with 3 duplicates). The KSampler→output link is dropped
|
||||
// because the subgraph output has no downstream connection.
|
||||
expect(result.linkCount).toBe(1)
|
||||
// KSampler should have exactly 1 linked input (latent_image)
|
||||
expect(result.linkedInputCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph Creation and Deletion', () => {
|
||||
test('Can create subgraph from selected nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('default')
|
||||
|
||||
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 103 KiB |
@@ -1,54 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Textarea from './Textarea.vue'
|
||||
|
||||
const meta: Meta<typeof Textarea> = {
|
||||
title: 'UI/Textarea',
|
||||
component: Textarea,
|
||||
tags: ['autodocs']
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof Textarea>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { Textarea },
|
||||
setup() {
|
||||
const value = ref('Hello world')
|
||||
return { value }
|
||||
},
|
||||
template:
|
||||
'<Textarea v-model="value" placeholder="Type something..." class="max-w-sm" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: () => ({
|
||||
components: { Textarea },
|
||||
template:
|
||||
'<Textarea model-value="Disabled textarea" disabled class="max-w-sm" />'
|
||||
})
|
||||
}
|
||||
|
||||
export const WithLabel: Story = {
|
||||
render: () => ({
|
||||
components: { Textarea },
|
||||
setup() {
|
||||
const value = ref('Content that sits below the label')
|
||||
return { value }
|
||||
},
|
||||
template: `
|
||||
<div class="relative max-w-sm rounded-lg bg-component-node-widget-background">
|
||||
<label class="pointer-events-none absolute left-3 top-1.5 text-xxs text-muted-foreground z-10">
|
||||
Prompt
|
||||
</label>
|
||||
<Textarea
|
||||
v-model="value"
|
||||
class="size-full resize-none border-none bg-transparent pt-5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { class: className, ...restAttrs } = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string | number>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
v-bind="restAttrs"
|
||||
v-model="modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'flex min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
@@ -484,110 +484,3 @@ describe('ensureGlobalIdUniqueness', () => {
|
||||
expect(subNode.id).toBe(subId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Subgraph Unpacking', () => {
|
||||
class TestNode extends LGraphNode {
|
||||
constructor(title?: string) {
|
||||
super(title ?? 'TestNode')
|
||||
this.addInput('input_0', 'number')
|
||||
this.addOutput('output_0', 'number')
|
||||
}
|
||||
}
|
||||
|
||||
class MultiInputNode extends LGraphNode {
|
||||
constructor(title?: string) {
|
||||
super(title ?? 'MultiInputNode')
|
||||
this.addInput('input_0', 'number')
|
||||
this.addInput('input_1', 'number')
|
||||
this.addOutput('output_0', 'number')
|
||||
}
|
||||
}
|
||||
|
||||
function registerTestNodes() {
|
||||
LiteGraph.registerNodeType('test/TestNode', TestNode)
|
||||
LiteGraph.registerNodeType('test/MultiInputNode', MultiInputNode)
|
||||
}
|
||||
|
||||
function createSubgraphOnGraph(rootGraph: LGraph) {
|
||||
return rootGraph.createSubgraph(createTestSubgraphData())
|
||||
}
|
||||
|
||||
it('deduplicates links when unpacking subgraph with duplicate links', () => {
|
||||
registerTestNodes()
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createSubgraphOnGraph(rootGraph)
|
||||
|
||||
const sourceNode = LiteGraph.createNode('test/TestNode', 'Source')!
|
||||
const targetNode = LiteGraph.createNode('test/TestNode', 'Target')!
|
||||
subgraph.add(sourceNode)
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Create a legitimate link
|
||||
sourceNode.connect(0, targetNode, 0)
|
||||
expect(subgraph._links.size).toBe(1)
|
||||
|
||||
// Manually add duplicate links (simulating the bug)
|
||||
const existingLink = subgraph._links.values().next().value!
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const dupLink = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
subgraph._links.set(dupLink.id, dupLink)
|
||||
sourceNode.outputs[0].links!.push(dupLink.id)
|
||||
}
|
||||
expect(subgraph._links.size).toBe(4)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
rootGraph.unpackSubgraph(subgraphNode)
|
||||
|
||||
// After unpacking, there should be exactly 1 link (not 4)
|
||||
expect(rootGraph.links.size).toBe(1)
|
||||
})
|
||||
|
||||
it('preserves correct link connections when unpacking with duplicate links', () => {
|
||||
registerTestNodes()
|
||||
const rootGraph = new LGraph()
|
||||
const subgraph = createSubgraphOnGraph(rootGraph)
|
||||
|
||||
const sourceNode = LiteGraph.createNode('test/MultiInputNode', 'Source')!
|
||||
const targetNode = LiteGraph.createNode('test/MultiInputNode', 'Target')!
|
||||
subgraph.add(sourceNode)
|
||||
subgraph.add(targetNode)
|
||||
|
||||
// Connect source output 0 → target input 0
|
||||
sourceNode.connect(0, targetNode, 0)
|
||||
|
||||
// Add duplicate links to the same connection
|
||||
const existingLink = subgraph._links.values().next().value!
|
||||
const dupLink = new LLink(
|
||||
++subgraph.state.lastLinkId,
|
||||
existingLink.type,
|
||||
existingLink.origin_id,
|
||||
existingLink.origin_slot,
|
||||
existingLink.target_id,
|
||||
existingLink.target_slot
|
||||
)
|
||||
subgraph._links.set(dupLink.id, dupLink)
|
||||
sourceNode.outputs[0].links!.push(dupLink.id)
|
||||
|
||||
const subgraphNode = createTestSubgraphNode(subgraph, { pos: [100, 100] })
|
||||
rootGraph.add(subgraphNode)
|
||||
|
||||
rootGraph.unpackSubgraph(subgraphNode)
|
||||
|
||||
// Verify only 1 link exists
|
||||
expect(rootGraph.links.size).toBe(1)
|
||||
|
||||
// Verify target input 1 does NOT have a link (no spurious connection)
|
||||
const unpackedTarget = rootGraph.nodes.find((n) => n.title === 'Target')!
|
||||
expect(unpackedTarget.inputs[0].link).not.toBeNull()
|
||||
expect(unpackedTarget.inputs[1].link).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1929,20 +1929,15 @@ export class LGraph
|
||||
node.id = this.last_node_id
|
||||
n_info.id = this.last_node_id
|
||||
|
||||
// Strip links from serialized data before configure to prevent
|
||||
// onConnectionsChange from resolving subgraph-internal link IDs
|
||||
// against the parent graph's link map (which may contain unrelated
|
||||
// links with the same numeric IDs).
|
||||
for (const input of n_info.inputs ?? []) {
|
||||
input.link = null
|
||||
}
|
||||
for (const output of n_info.outputs ?? []) {
|
||||
output.links = []
|
||||
}
|
||||
|
||||
this.add(node, true)
|
||||
node.configure(n_info)
|
||||
node.setPos(node.pos[0] + offsetX, node.pos[1] + offsetY)
|
||||
for (const input of node.inputs) {
|
||||
input.link = null
|
||||
}
|
||||
for (const output of node.outputs) {
|
||||
output.links = []
|
||||
}
|
||||
toSelect.push(node)
|
||||
}
|
||||
const groups = structuredClone(
|
||||
@@ -2048,19 +2043,8 @@ export class LGraph
|
||||
}
|
||||
this.remove(subgraphNode)
|
||||
this.subgraphs.delete(subgraphNode.subgraph.id)
|
||||
|
||||
// Deduplicate links by (oid, oslot, tid, tslot) to prevent repeated
|
||||
// disconnect/reconnect cycles on widget inputs that can shift slot indices.
|
||||
const seenLinks = new Set<string>()
|
||||
const dedupedNewLinks = newLinks.filter((link) => {
|
||||
const key = `${link.oid}:${link.oslot}:${link.tid}:${link.tslot}`
|
||||
if (seenLinks.has(key)) return false
|
||||
seenLinks.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
const linkIdMap = new Map<LinkId, LinkId[]>()
|
||||
for (const newLink of dedupedNewLinks) {
|
||||
for (const newLink of newLinks) {
|
||||
let created: LLink | null | undefined
|
||||
if (newLink.oid == SUBGRAPH_INPUT_ID) {
|
||||
if (!(this instanceof Subgraph)) {
|
||||
@@ -2118,7 +2102,7 @@ export class LGraph
|
||||
toSelect.push(migratedReroute)
|
||||
}
|
||||
//iterate over newly created links to update reroute parentIds
|
||||
for (const newLink of dedupedNewLinks) {
|
||||
for (const newLink of newLinks) {
|
||||
const linkInstance = this.links.get(newLink.id)
|
||||
if (!linkInstance) {
|
||||
continue
|
||||
@@ -2673,8 +2657,6 @@ export class Subgraph
|
||||
|
||||
/** The display name of the subgraph. */
|
||||
name: string = 'Unnamed Subgraph'
|
||||
/** Optional description shown as tooltip when hovering over the subgraph node. */
|
||||
description?: string
|
||||
|
||||
readonly inputNode = new SubgraphInputNode(this)
|
||||
readonly outputNode = new SubgraphOutputNode(this)
|
||||
@@ -2725,10 +2707,9 @@ export class Subgraph
|
||||
| (ISerialisedGraph & ExportedSubgraph)
|
||||
| (SerialisableGraph & ExportedSubgraph)
|
||||
): void {
|
||||
const { name, description, inputs, outputs, widgets } = data
|
||||
const { name, inputs, outputs, widgets } = data
|
||||
|
||||
this.name = name
|
||||
this.description = description
|
||||
if (inputs) {
|
||||
this.inputs.length = 0
|
||||
for (const input of inputs) {
|
||||
@@ -2939,7 +2920,6 @@ export class Subgraph
|
||||
revision: this.revision,
|
||||
config: this.config,
|
||||
name: this.name,
|
||||
...(this.description && { description: this.description }),
|
||||
inputNode: this.inputNode.asSerialisable(),
|
||||
outputNode: this.outputNode.asSerialisable(),
|
||||
inputs: this.inputs.map((x) => x.asSerialisable()),
|
||||
|
||||
@@ -76,6 +76,7 @@ describe.skip('SubgraphSerialization - Basic Serialization', () => {
|
||||
// Verify core properties
|
||||
expect(restored.id).toBe(original.id)
|
||||
expect(restored.name).toBe(original.name)
|
||||
// @ts-expect-error description property not in type definition
|
||||
expect(restored.description).toBe(original.description)
|
||||
|
||||
// Verify I/O structure
|
||||
|
||||
@@ -139,8 +139,6 @@ export interface ExportedSubgraph extends SerialisableGraph {
|
||||
name: string
|
||||
/** Optional category for organizing subgraph blueprints in the node library. */
|
||||
category?: string
|
||||
/** Optional description shown as tooltip when hovering over the subgraph node. */
|
||||
description?: string
|
||||
inputNode: ExportedSubgraphIONode
|
||||
outputNode: ExportedSubgraphIONode
|
||||
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
|
||||
|
||||
@@ -2529,14 +2529,6 @@
|
||||
"renderBypassState": "Render Bypass State",
|
||||
"renderErrorState": "Render Error State"
|
||||
},
|
||||
"nightlySurvey": {
|
||||
"title": "Help Us Improve",
|
||||
"description": "You've been using this feature. Would you take a moment to share your feedback?",
|
||||
"accept": "Sure, I'll help!",
|
||||
"notNow": "Not Now",
|
||||
"dontAskAgain": "Don't Ask Again",
|
||||
"loadError": "Failed to load survey. Please try again later."
|
||||
},
|
||||
"cloudOnboarding": {
|
||||
"skipToCloudApp": "Skip to the cloud app",
|
||||
"survey": {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { VueFire, VueFireAuth } from 'vuefire'
|
||||
import { getFirebaseConfig } from '@/config/firebase'
|
||||
import '@/lib/litegraph/public/css/litegraph.css'
|
||||
import router from '@/router'
|
||||
import { initServerCapabilities } from '@/services/serverCapabilities'
|
||||
import { useBootstrapStore } from '@/stores/bootstrapStore'
|
||||
|
||||
import App from './App.vue'
|
||||
@@ -21,6 +22,8 @@ import App from './App.vue'
|
||||
import './assets/css/style.css'
|
||||
import { i18n } from './i18n'
|
||||
|
||||
await initServerCapabilities()
|
||||
|
||||
/**
|
||||
* CRITICAL: Load remote config FIRST for cloud builds to ensure
|
||||
* window.__CONFIG__is available for all modules during initialization
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const FEATURE_USAGE_KEY = 'Comfy.FeatureUsage'
|
||||
const POPOVER_SELECTOR = '[data-testid="nightly-survey-popover"]'
|
||||
|
||||
const mockIsNightly = vi.hoisted(() => ({ value: true }))
|
||||
const mockIsCloud = vi.hoisted(() => ({ value: false }))
|
||||
const mockIsDesktop = vi.hoisted(() => ({ value: false }))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isNightly() {
|
||||
return mockIsNightly.value
|
||||
},
|
||||
get isCloud() {
|
||||
return mockIsCloud.value
|
||||
},
|
||||
get isDesktop() {
|
||||
return mockIsDesktop.value
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: vi.fn(() => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
}))
|
||||
|
||||
describe('NightlySurveyPopover', () => {
|
||||
const defaultConfig = {
|
||||
featureId: 'test-feature',
|
||||
typeformId: 'abc123',
|
||||
triggerThreshold: 3,
|
||||
delayMs: 100,
|
||||
enabled: true
|
||||
}
|
||||
|
||||
function setFeatureUsage(featureId: string, useCount: number) {
|
||||
const existing = JSON.parse(localStorage.getItem(FEATURE_USAGE_KEY) ?? '{}')
|
||||
existing[featureId] = {
|
||||
useCount,
|
||||
firstUsed: Date.now() - 1000,
|
||||
lastUsed: Date.now()
|
||||
}
|
||||
localStorage.setItem(FEATURE_USAGE_KEY, JSON.stringify(existing))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2024-06-15T12:00:00Z'))
|
||||
|
||||
mockIsNightly.value = true
|
||||
mockIsCloud.value = false
|
||||
mockIsDesktop.value = false
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
vi.useRealTimers()
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
async function mountComponent(config = defaultConfig) {
|
||||
const { default: NightlySurveyPopover } =
|
||||
await import('./NightlySurveyPopover.vue')
|
||||
return mount(NightlySurveyPopover, {
|
||||
props: { config },
|
||||
global: {
|
||||
stubs: {
|
||||
Teleport: true
|
||||
}
|
||||
},
|
||||
attachTo: document.body
|
||||
})
|
||||
}
|
||||
|
||||
describe('visibility', () => {
|
||||
it('shows popover after delay when eligible', async () => {
|
||||
setFeatureUsage('test-feature', 5)
|
||||
|
||||
const wrapper = await mountComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not show when not eligible', async () => {
|
||||
setFeatureUsage('test-feature', 1)
|
||||
|
||||
const wrapper = await mountComponent()
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not show on cloud', async () => {
|
||||
mockIsCloud.value = true
|
||||
setFeatureUsage('test-feature', 5)
|
||||
|
||||
const wrapper = await mountComponent()
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('user actions', () => {
|
||||
it('emits shown event when displayed', async () => {
|
||||
setFeatureUsage('test-feature', 5)
|
||||
|
||||
const wrapper = await mountComponent()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('shown')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits dismissed when close button clicked', async () => {
|
||||
setFeatureUsage('test-feature', 5)
|
||||
|
||||
const wrapper = await mountComponent()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
|
||||
const closeButton = wrapper.find('[aria-label="g.close"]')
|
||||
await closeButton.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('dismissed')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('emits optedOut when opt out button clicked', async () => {
|
||||
setFeatureUsage('test-feature', 5)
|
||||
|
||||
const wrapper = await mountComponent()
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const optOutButton = buttons.find((b) =>
|
||||
b.text().includes('nightlySurvey.dontAskAgain')
|
||||
)
|
||||
expect(optOutButton).toBeDefined()
|
||||
await optOutButton!.trigger('click')
|
||||
|
||||
expect(wrapper.emitted('optedOut')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('config', () => {
|
||||
it('uses custom delay from config', async () => {
|
||||
setFeatureUsage('test-feature', 5)
|
||||
|
||||
const wrapper = await mountComponent({
|
||||
...defaultConfig,
|
||||
delayMs: 500
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(400)
|
||||
await nextTick()
|
||||
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await nextTick()
|
||||
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('does not show when config is disabled', async () => {
|
||||
setFeatureUsage('test-feature', 5)
|
||||
|
||||
const wrapper = await mountComponent({
|
||||
...defaultConfig,
|
||||
enabled: false
|
||||
})
|
||||
await nextTick()
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find(POPOVER_SELECTOR).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,166 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed, onUnmounted, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
import type { FeatureSurveyConfig } from './useSurveyEligibility'
|
||||
import { useSurveyEligibility } from './useSurveyEligibility'
|
||||
|
||||
const { config } = defineProps<{
|
||||
config: FeatureSurveyConfig
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
shown: []
|
||||
dismissed: []
|
||||
optedOut: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const { isEligible, delayMs, markSurveyShown, optOut } = useSurveyEligibility(
|
||||
() => config
|
||||
)
|
||||
|
||||
const TYPEFORM_SRC = 'https://embed.typeform.com/next/embed.js'
|
||||
|
||||
const isVisible = ref(false)
|
||||
const typeformError = ref(false)
|
||||
const typeformRef = useTemplateRef<HTMLDivElement>('typeformRef')
|
||||
|
||||
let showTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const isValidTypeformId = computed(() =>
|
||||
/^[A-Za-z0-9]+$/.test(config.typeformId)
|
||||
)
|
||||
const typeformId = computed(() =>
|
||||
isValidTypeformId.value ? config.typeformId : ''
|
||||
)
|
||||
|
||||
watch(
|
||||
isEligible,
|
||||
(eligible) => {
|
||||
if (!eligible) {
|
||||
if (showTimeout) {
|
||||
clearTimeout(showTimeout)
|
||||
showTimeout = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (isVisible.value || showTimeout) return
|
||||
|
||||
showTimeout = setTimeout(() => {
|
||||
showTimeout = null
|
||||
isVisible.value = true
|
||||
emit('shown')
|
||||
}, delayMs.value)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
if (showTimeout) {
|
||||
clearTimeout(showTimeout)
|
||||
}
|
||||
})
|
||||
|
||||
whenever(typeformRef, () => {
|
||||
if (document.querySelector(`script[src="${TYPEFORM_SRC}"]`)) return
|
||||
|
||||
const scriptEl = document.createElement('script')
|
||||
scriptEl.src = TYPEFORM_SRC
|
||||
scriptEl.async = true
|
||||
scriptEl.onerror = () => {
|
||||
typeformError.value = true
|
||||
}
|
||||
document.head.appendChild(scriptEl)
|
||||
})
|
||||
|
||||
function handleAccept() {
|
||||
markSurveyShown()
|
||||
}
|
||||
|
||||
function handleDismiss() {
|
||||
isVisible.value = false
|
||||
emit('dismissed')
|
||||
}
|
||||
|
||||
function handleOptOut() {
|
||||
optOut()
|
||||
isVisible.value = false
|
||||
emit('optedOut')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="translate-x-full opacity-0"
|
||||
enter-to-class="translate-x-0 opacity-100"
|
||||
leave-active-class="transition duration-300 ease-in"
|
||||
leave-from-class="translate-x-0 opacity-100"
|
||||
leave-to-class="translate-x-full opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isVisible"
|
||||
data-testid="nightly-survey-popover"
|
||||
class="fixed bottom-4 right-4 z-[10000] w-80 rounded-lg border border-border-subtle bg-base-background p-4 shadow-lg"
|
||||
>
|
||||
<div class="mb-3 flex items-start justify-between">
|
||||
<h3 class="text-sm font-medium text-text-primary">
|
||||
{{ t('nightlySurvey.title') }}
|
||||
</h3>
|
||||
<button
|
||||
class="text-text-muted hover:text-text-primary"
|
||||
:aria-label="t('g.close')"
|
||||
@click="handleDismiss"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mb-4 text-sm text-text-secondary">
|
||||
{{ t('nightlySurvey.description') }}
|
||||
</p>
|
||||
|
||||
<div v-if="typeformError" class="mb-4 text-sm text-danger">
|
||||
{{ t('nightlySurvey.loadError') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-show="!typeformError && isValidTypeformId"
|
||||
ref="typeformRef"
|
||||
data-tf-auto-resize
|
||||
:data-tf-widget="typeformId"
|
||||
class="min-h-[300px]"
|
||||
/>
|
||||
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<Button variant="primary" class="w-full" @click="handleAccept">
|
||||
{{ t('nightlySurvey.accept') }}
|
||||
</Button>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="textonly"
|
||||
class="flex-1 text-xs"
|
||||
@click="handleDismiss"
|
||||
>
|
||||
{{ t('nightlySurvey.notNow') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
class="flex-1 text-xs"
|
||||
@click="handleOptOut"
|
||||
>
|
||||
{{ t('nightlySurvey.dontAskAgain') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -396,8 +396,6 @@ interface SubgraphDefinitionBase<
|
||||
id: string
|
||||
revision: number
|
||||
name: string
|
||||
/** Optional description shown as tooltip when hovering over the subgraph node. */
|
||||
description?: string
|
||||
category?: string
|
||||
/** Custom metadata for the subgraph (description, searchAliases, etc.) */
|
||||
extra?: T extends ComfyWorkflow1BaseInput
|
||||
@@ -434,8 +432,6 @@ const zSubgraphDefinition = zComfyWorkflow1
|
||||
id: z.string().uuid(),
|
||||
revision: z.number(),
|
||||
name: z.string(),
|
||||
/** Optional description shown as tooltip when hovering over the subgraph node. */
|
||||
description: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
inputNode: zExportedSubgraphIONode,
|
||||
outputNode: zExportedSubgraphIONode,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
@@ -26,6 +28,10 @@ function mountComponent(
|
||||
placeholder?: string
|
||||
) {
|
||||
return mount(WidgetTextarea, {
|
||||
global: {
|
||||
plugins: [PrimeVue],
|
||||
components: { Textarea }
|
||||
},
|
||||
props: {
|
||||
widget,
|
||||
modelValue,
|
||||
|
||||
@@ -1,45 +1,37 @@
|
||||
<template>
|
||||
<div
|
||||
<FloatLabel
|
||||
variant="in"
|
||||
:unstyled="hideLayoutField"
|
||||
:class="
|
||||
cn(
|
||||
'relative rounded-lg focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
|
||||
'rounded-lg space-y-1 focus-within:ring focus-within:ring-component-node-widget-background-highlighted transition-all',
|
||||
widget.borderStyle
|
||||
)
|
||||
"
|
||||
>
|
||||
<label
|
||||
v-if="!hideLayoutField"
|
||||
:for="id"
|
||||
class="pointer-events-none absolute left-3 top-1.5 z-10 text-xxs text-muted-foreground"
|
||||
>
|
||||
{{ displayName }}
|
||||
</label>
|
||||
<Textarea
|
||||
v-bind="filteredProps"
|
||||
:id
|
||||
v-model="modelValue"
|
||||
:class="
|
||||
cn(
|
||||
WidgetInputBaseClass,
|
||||
'size-full text-xs resize-none',
|
||||
!hideLayoutField && 'pt-5'
|
||||
)
|
||||
"
|
||||
:class="cn(WidgetInputBaseClass, 'size-full text-xs resize-none')"
|
||||
:placeholder
|
||||
:readonly="isReadOnly"
|
||||
fluid
|
||||
data-capture-wheel="true"
|
||||
@pointerdown.capture.stop
|
||||
@pointermove.capture.stop
|
||||
@pointerup.capture.stop
|
||||
@contextmenu.capture.stop
|
||||
/>
|
||||
</div>
|
||||
<label v-if="!hideLayoutField" :for="id">{{ displayName }}</label>
|
||||
</FloatLabel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import { computed, useId } from 'vue'
|
||||
|
||||
import Textarea from '@/components/ui/textarea/Textarea.vue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { useHideLayoutField } from '@/types/widgetTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
131
src/services/serverCapabilities.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
getServerCapability,
|
||||
initServerCapabilities
|
||||
} from '@/services/serverCapabilities'
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
isCloud: false
|
||||
}))
|
||||
|
||||
describe('serverCapabilities', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
supports_preview_metadata: true,
|
||||
max_upload_size: 104857600,
|
||||
node_replacements: false,
|
||||
extension: { manager: { supports_v4: true } }
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
describe('initServerCapabilities', () => {
|
||||
it('fetches and freezes capabilities on success', async () => {
|
||||
await initServerCapabilities()
|
||||
|
||||
expect(getServerCapability('supports_preview_metadata')).toBe(true)
|
||||
expect(getServerCapability('max_upload_size')).toBe(104857600)
|
||||
})
|
||||
|
||||
it('retries and falls back to empty object on persistent failure', async () => {
|
||||
vi.mocked(fetch).mockRejectedValue(new Error('Network error'))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
await initServerCapabilities()
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(3)
|
||||
expect(getServerCapability('supports_preview_metadata')).toBeUndefined()
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Failed to fetch server capabilities after retries'
|
||||
)
|
||||
})
|
||||
|
||||
it('succeeds on retry after initial failure', async () => {
|
||||
vi.mocked(fetch)
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ supports_preview_metadata: true })
|
||||
} as Response)
|
||||
|
||||
await initServerCapabilities()
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(2)
|
||||
expect(getServerCapability('supports_preview_metadata')).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to empty object on persistent non-ok response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: false,
|
||||
json: () => Promise.resolve({})
|
||||
} as Response)
|
||||
|
||||
await initServerCapabilities()
|
||||
|
||||
expect(fetch).toHaveBeenCalledTimes(3)
|
||||
expect(getServerCapability('supports_preview_metadata')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getServerCapability', () => {
|
||||
it('returns default value when called before init', () => {
|
||||
expect(getServerCapability('some_key', 'fallback')).toBe('fallback')
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await initServerCapabilities()
|
||||
})
|
||||
|
||||
it('returns value for existing key', () => {
|
||||
expect(getServerCapability('supports_preview_metadata')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns default value for missing key', () => {
|
||||
expect(getServerCapability('non_existent', 'fallback')).toBe('fallback')
|
||||
})
|
||||
|
||||
it('supports dot notation for nested values', () => {
|
||||
expect(getServerCapability('extension.manager.supports_v4')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns undefined for missing key with no default', () => {
|
||||
expect(getServerCapability('missing_key')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dev override via localStorage', () => {
|
||||
beforeEach(async () => {
|
||||
await initServerCapabilities()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
it('returns localStorage override over server value', () => {
|
||||
localStorage.setItem('ff:supports_preview_metadata', 'false')
|
||||
expect(getServerCapability('supports_preview_metadata')).toBe(false)
|
||||
})
|
||||
|
||||
it('falls through to server value when no override is set', () => {
|
||||
expect(getServerCapability('supports_preview_metadata')).toBe(true)
|
||||
})
|
||||
|
||||
it('override works with numeric values', () => {
|
||||
localStorage.setItem('ff:max_upload_size', '999')
|
||||
expect(getServerCapability('max_upload_size')).toBe(999)
|
||||
})
|
||||
})
|
||||
})
|
||||
41
src/services/serverCapabilities.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { get } from 'es-toolkit/compat'
|
||||
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
|
||||
|
||||
const EMPTY: Readonly<Record<string, unknown>> = Object.freeze({})
|
||||
const MAX_RETRIES = 2
|
||||
|
||||
let capabilities: Readonly<Record<string, unknown>> = EMPTY
|
||||
|
||||
function getApiBase(): string {
|
||||
return isCloud ? '' : location.pathname.split('/').slice(0, -1).join('/')
|
||||
}
|
||||
|
||||
export async function initServerCapabilities(): Promise<void> {
|
||||
const url = `${getApiBase()}/api/features`
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, { cache: 'no-store' })
|
||||
if (res.ok) {
|
||||
capabilities = Object.freeze(await res.json())
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Retry on network errors
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('Failed to fetch server capabilities after retries')
|
||||
capabilities = EMPTY
|
||||
}
|
||||
|
||||
export function getServerCapability<T = unknown>(
|
||||
key: string,
|
||||
defaultValue?: T
|
||||
): T {
|
||||
const override = getDevOverride<T>(key)
|
||||
if (override !== undefined) return override
|
||||
return get(capabilities, key, defaultValue) as T
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export const useSubgraphService = () => {
|
||||
output_tooltips: [],
|
||||
name: id,
|
||||
display_name: name,
|
||||
description: exportedSubgraph.description || `Subgraph node for ${name}`,
|
||||
description: `Subgraph node for ${name}`,
|
||||
category: 'subgraph',
|
||||
output_node: false,
|
||||
python_module: 'nodes'
|
||||
|
||||