Compare commits

..

8 Commits

Author SHA1 Message Date
Alexander Brown
f6c415d101 Merge branch 'main' into fix/collapsed-node-link-positions 2026-03-28 17:09:34 -07:00
Alexander Brown
81e6282599 Chore: pnpm build ignores and version centralization (#10687)
## Summary

Just pnpm pieces. Centralize the pnpm version for corepack/actions.
Ignore builds from some recent deps.
2026-03-28 16:38:02 -07:00
Dante
b8480f889e feat: add Tag component from design system and rename SquareChip (#10650)
## Summary
- Add `Tag` component based on Figma design system with CVA variants
  - `square` (rounded-sm) and `rounded` (pill) shapes
- `overlay` shape for tags on image thumbnails (pending Figma
confirmation)
  - `default`, `unselected`, `selected` states matching Figma
  - `removable` prop with X close button and `remove` event
  - Icon slot support
- Rename `SquareChip` → `Tag` across all consumers
(WorkflowTemplateSelectorDialog, SampleModelSelector)
- Update all Storybook stories (Tag, Card, BaseModalLayout)
- Delete old `SquareChip.vue` and `SquareChip.stories.ts`
- Add E2E screenshot test for template card overlay tags

Foundation for migrating PrimeVue `Chip` and `Tag` components in
follow-up PRs.

## Test plan
- [x] Unit tests pass (5 tests: rendering, removable, icon slot)
- [x] E2E screenshot test: template cards with overlay tags
- [x] Typecheck passes
- [x] Lint passes
- [ ] Verify Tag stories render correctly in Storybook
- [ ] Verify WorkflowTemplateSelectorDialog tags display correctly
- [ ] Verify SampleModelSelector chips display correctly

## Follow-up work
- **PR 4** (#10673): Migrate PrimeVue `Chip` → custom `Tag`
(SearchFilterChip, NodeSearchItem, DownloadItem)
- **PR 5** (planned): Migrate PrimeVue `Tag` → custom `Tag` (~14 files)
2026-03-29 08:27:53 +09:00
Christian Byrne
b49ea9fabd feat: add getNodesByTitle and getNodeByTitleNth helpers to VueNodeHelpers (#10666)
## Summary

Add helpers for safely interacting with nodes that share the same title
without hitting Playwright strict mode.

## Changes

- **What**: Added `getNodesByTitle(title)` and `getNodeByTitleNth(title,
index)` to `VueNodeHelpers`. Updated `docs/guidance/playwright.md` with
a gotcha note about duplicate node names.

## Review Focus

These are purely additive helpers — no existing behavior changes.
`getNodesByTitle` returns all matching nodes (callers use `.nth()` to
pick), and `getNodeByTitleNth` is a convenience wrapper. The existing
`selectNodes(nodeIds)` by-ID method is unchanged.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10666-feat-add-getNodesByTitle-and-getNodeByTitleNth-helpers-to-VueNodeHelpers-3316d73d3650812eabe6e56a768a34d2)
by [Unito](https://www.unito.io)
2026-03-28 16:09:18 -07:00
Christian Byrne
8da4640a76 docs: add assertion best practices to Playwright guide (#10663)
## Summary

Document custom expect messages and soft assertions as Playwright best
practices.

## Changes

- **What**: Added "Assertion Best Practices" section to
`docs/guidance/playwright.md` covering custom messages, `expect.soft()`,
and guidelines for when to use each.

## Review Focus

Documentation-only change — no code impact.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10663-docs-add-assertion-best-practices-to-Playwright-guide-3316d73d365081309d83f95bb9b86fe1)
by [Unito](https://www.unito.io)

---------

Co-authored-by: GitHub Action <action@github.com>
2026-03-28 16:00:07 -07:00
jaeone94
3a0c718a17 refactor: use existing fixture helpers for collapsed node link tests
Replace local helpers with VueNodeFixture.toggleCollapse and
Playwright mouse drag. Only assertSlotsWithinNodeBounds remains
as a local helper.
2026-03-28 18:42:02 +09:00
jaeone94
5dfcc59f13 fix: increase test asset node size to minimum [400, 200] 2026-03-28 12:37:24 +09:00
jaeone94
6823097bc1 fix: collapsed node connection link positions
Fall back to clientPosToCanvasPos for collapsed node slot positioning
since the DOM-relative scale derivation is invalid when layout store
preserves expanded size. Clear stale cachedOffset on collapse and
defer sync when canvas is not yet initialized.

Regression from #9121 (DOM-relative scale) combined with #9680
(collapsed node ResizeObserver skip).
2026-03-28 12:19:55 +09:00
41 changed files with 785 additions and 532 deletions

View File

@@ -13,8 +13,6 @@ runs:
# Install pnpm, Node.js, build frontend
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -17,8 +17,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -22,8 +22,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -21,8 +21,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -20,8 +20,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0

View File

@@ -21,8 +21,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
@@ -76,8 +74,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Use Node.js
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0

View File

@@ -30,8 +30,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -85,8 +85,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -76,8 +76,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6
@@ -203,8 +201,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- uses: actions/setup-node@v6
with:

View File

@@ -20,10 +20,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'

View File

@@ -76,8 +76,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -16,10 +16,10 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- uses: actions/setup-node@v6
with:
node-version-file: '.nvmrc'

View File

@@ -144,8 +144,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -52,8 +52,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -30,8 +30,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v6

View File

@@ -0,0 +1,116 @@
{
"id": "selection-bbox-test",
"revision": 0,
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [300, 200],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [1]
}
],
"properties": {},
"widgets_values": []
},
{
"id": 3,
"type": "EmptyLatentImage",
"pos": [800, 200],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "latent",
"type": "LATENT",
"link": 1
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": [512, 512, 1]
}
],
"links": [[1, 2, 0, 3, 0, "LATENT"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [],
"pos": { "0": 200, "1": 220 }
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -31,10 +31,14 @@ export class VueNodeHelpers {
}
/**
* Get locator for a Vue node by the node's title (displayed name in the header)
* Get locator for Vue nodes by the node's title (displayed name in the header).
* Matches against the actual title element, not the full node body.
* Use `.first()` for unique titles, `.nth(n)` for duplicates.
*/
getNodeByTitle(title: string): Locator {
return this.page.locator(`[data-node-id]`).filter({ hasText: title })
return this.page.locator('[data-node-id]').filter({
has: this.page.locator('[data-testid="node-title"]', { hasText: title })
})
}
/**

View File

@@ -0,0 +1,108 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
const SLOT_BOUNDS_MARGIN = 20
const SUBGRAPH_ID = '2'
const WORKFLOW = 'selection/subgraph-with-regular-node'
async function assertSlotsWithinNodeBounds(page: Page, nodeId: string) {
await page
.locator(`[data-node-id="${nodeId}"] [data-slot-key]`)
.first()
.waitFor()
const result = await page.evaluate(
({ nodeId, margin }) => {
const nodeEl = document.querySelector(
`[data-node-id="${nodeId}"]`
) as HTMLElement | null
if (!nodeEl) return { ok: false, violations: ['node element not found'] }
const nodeRect = nodeEl.getBoundingClientRect()
const slotEls = nodeEl.querySelectorAll('[data-slot-key]')
const violations: string[] = []
for (const slotEl of slotEls) {
const slotRect = slotEl.getBoundingClientRect()
const cx = slotRect.left + slotRect.width / 2 - nodeRect.left
const cy = slotRect.top + slotRect.height / 2 - nodeRect.top
if (cx < -margin || cx > nodeRect.width + margin)
violations.push(`slot X=${cx} outside width=${nodeRect.width}`)
if (cy < -margin || cy > nodeRect.height + margin)
violations.push(`slot Y=${cy} outside height=${nodeRect.height}`)
}
return { ok: violations.length === 0, violations }
},
{ nodeId, margin: SLOT_BOUNDS_MARGIN }
)
expect(
result.ok,
`Slot positions out of bounds: ${result.violations?.join(', ')}`
).toBe(true)
}
test.describe(
'Collapsed node link positions',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
test('link endpoints stay within collapsed node bounds', async ({
comfyPage
}) => {
const node = await comfyPage.vueNodes.getFixtureByTitle('Test Subgraph')
await node.toggleCollapse()
await comfyPage.nextFrame()
await assertSlotsWithinNodeBounds(comfyPage.page, SUBGRAPH_ID)
})
test('links follow collapsed node after drag', async ({ comfyPage }) => {
const node = await comfyPage.vueNodes.getFixtureByTitle('Test Subgraph')
await node.toggleCollapse()
await comfyPage.nextFrame()
const box = await node.boundingBox()
expect(box).not.toBeNull()
await comfyPage.page.mouse.move(
box!.x + box!.width / 2,
box!.y + box!.height / 2
)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(
box!.x + box!.width / 2 + 200,
box!.y + box!.height / 2 + 100,
{ steps: 10 }
)
await comfyPage.page.mouse.up()
await comfyPage.nextFrame()
await assertSlotsWithinNodeBounds(comfyPage.page, SUBGRAPH_ID)
})
test('links recover correct positions after expand', async ({
comfyPage
}) => {
const node = await comfyPage.vueNodes.getFixtureByTitle('Test Subgraph')
await node.toggleCollapse()
await comfyPage.nextFrame()
await node.toggleCollapse()
await comfyPage.nextFrame()
await assertSlotsWithinNodeBounds(comfyPage.page, SUBGRAPH_ID)
})
}
)

View File

@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test'
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
async function checkTemplateFileExists(
page: Page,
@@ -345,4 +346,71 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
)
}
)
test(
'template cards display overlay tags correctly',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.page.route('**/templates/index.json', async (route) => {
const response = [
{
moduleName: 'default',
title: 'Test Templates',
type: 'image',
templates: [
{
name: 'tagged-template',
title: 'Tagged Template',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'A template with tags.',
tags: ['Relight', 'Image Edit']
},
{
name: 'no-tags',
title: 'No Tags',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'A template without tags.'
}
]
}
]
await route.fulfill({
status: 200,
body: JSON.stringify(response),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.page.route('**/templates/**.webp', async (route) => {
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'no-store'
}
})
})
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
const taggedCard = comfyPage.page.getByTestId(
TestIds.templates.workflowCard('tagged-template')
)
await expect(taggedCard).toBeVisible({ timeout: 5000 })
await expect(taggedCard.getByText('Relight')).toBeVisible()
await expect(taggedCard.getByText('Image Edit')).toBeVisible()
const templateGrid = comfyPage.page.getByTestId(TestIds.templates.content)
await expect(templateGrid).toHaveScreenshot(
'template-cards-with-overlay-tags.png'
)
}
)
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -75,6 +75,30 @@ await page.evaluate(() => {
// Keep app.extensionManager typed as ExtensionManager, not WorkspaceStore
```
## Assertion Best Practices
When a test depends on an invariant unrelated to what it's actually testing (e.g. asserting a node has 4 widgets before testing node movement), always assert that invariant explicitly — don't leave it unchecked. Use a custom message or `expect.soft()` rather than a bare `expect`, so failures point to the broken assumption instead of producing a confusing error downstream.
```typescript
// ✅ Custom message on an unrelated precondition — clear signal when the invariant breaks
expect(node.widgets, 'Widget count changed — update test fixture').toHaveLength(
4
)
await node.move(100, 200)
// ✅ Soft assertion — verifies multiple invariants without stopping the test early
expect.soft(menuItem1).toBeVisible()
expect.soft(menuItem2).toBeVisible()
expect.soft(menuItem3).toBeVisible()
// ❌ Bare expect on a precondition — no context when it fails
expect(node.widgets).toHaveLength(4)
```
- Use custom messages (`expect(x, 'reason')`) for precondition checks unrelated to the test's purpose
- Use `expect.soft()` when you want to verify multiple invariants without aborting on the first failure
- Prefer Playwright's built-in message parameter over custom error classes
## Test Tags
Tags are respected by config:
@@ -86,6 +110,7 @@ Tags are respected by config:
- Check `browser_tests/assets/` for test data and fixtures
- Use realistic ComfyUI workflows for E2E tests
- When multiple nodes share the same title (e.g. two "CLIP Text Encode" nodes), use `vueNodes.getNodeByTitle(name).nth(n)` to pick a specific one. Never interact with the bare locator when titles are non-unique — Playwright strict mode will fail.
## Running Tests

View File

@@ -36,7 +36,6 @@
"lint:desktop": "nx run @comfyorg/desktop-ui:lint",
"locale": "lobe-i18n locale",
"oxlint": "oxlint src --type-aware",
"preinstall": "pnpm dlx only-allow pnpm",
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
"preview": "nx preview",
"storybook": "nx storybook",
@@ -200,11 +199,21 @@
"zod-to-json-schema": "catalog:"
},
"engines": {
"node": "24.x"
"node": "24.x",
"pnpm": ">=10"
},
"packageManager": "pnpm@10.33.0",
"pnpm": {
"overrides": {
"vite": "catalog:"
}
},
"ignoredBuiltDependencies": [
"@firebase/util",
"core-js",
"protobufjs",
"sharp",
"unrs-resolver",
"vue-demi"
]
}
}

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import SquareChip from '../chip/SquareChip.vue'
import Tag from '@/components/chip/Tag.vue'
import CardBottom from './CardBottom.vue'
import CardContainer from './CardContainer.vue'
import CardDescription from './CardDescription.vue'
@@ -174,7 +174,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
CardTitle,
CardDescription,
Button,
SquareChip
Tag
},
setup() {
const favorited = ref(false)
@@ -218,7 +218,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
</template>
<template v-if="args.showTopLeft" #top-left>
<SquareChip label="Featured" />
<Tag label="Featured" />
</template>
<template v-if="args.showTopRight" #top-right>
@@ -238,17 +238,17 @@ const createCardTemplate = (args: CardStoryArgs) => ({
</template>
<template v-if="args.showBottomLeft" #bottom-left>
<SquareChip label="New" />
<Tag label="New" />
</template>
<template v-if="args.showBottomRight" #bottom-right>
<SquareChip v-if="args.showFileType" :label="args.fileType" />
<SquareChip v-if="args.showFileSize" :label="args.fileSize" />
<SquareChip v-for="tag in args.tags" :key="tag" :label="tag">
<Tag v-if="args.showFileType" :label="args.fileType" />
<Tag v-if="args.showFileSize" :label="args.fileSize" />
<Tag v-for="tag in args.tags" :key="tag" :label="tag">
<template v-if="tag === 'LoRA'" #icon>
<i class="icon-[lucide--folder] size-3" />
</template>
</SquareChip>
</Tag>
</template>
</CardTop>
</template>

View File

@@ -1,36 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SquareChip from './SquareChip.vue'
const meta: Meta<typeof SquareChip> = {
title: 'Components/SquareChip',
component: SquareChip,
tags: ['autodocs'],
argTypes: {
label: {
control: 'text',
defaultValue: 'Tag'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const TagList: Story = {
render: () => ({
components: { SquareChip },
template: `
<div class="flex flex-wrap gap-2">
<SquareChip label="JavaScript" />
<SquareChip label="TypeScript" />
<SquareChip label="Vue.js" />
<SquareChip label="React" />
<SquareChip label="Node.js" />
<SquareChip label="Python" />
<SquareChip label="Docker" />
<SquareChip label="Kubernetes" />
</div>
`
})
}

View File

@@ -1,31 +0,0 @@
<template>
<div :class="chipClasses">
<slot name="icon"></slot>
<span>{{ label }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { label, variant = 'dark' } = defineProps<{
label: string
variant?: 'dark' | 'light' | 'gray'
}>()
const baseClasses =
'inline-flex shrink-0 items-center justify-center gap-1 rounded px-2 py-1 text-xs font-bold'
const variantStyles = {
dark: 'bg-zinc-500/40 text-white/90',
light: cn('bg-base-background/50 text-base-foreground backdrop-blur-[2px]'),
gray: cn(
'bg-modal-card-tag-background text-base-foreground backdrop-blur-[2px]'
)
}
const chipClasses = computed(() => {
return cn(baseClasses, variantStyles[variant])
})
</script>

View File

@@ -0,0 +1,104 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Tag from './Tag.vue'
const meta: Meta<typeof Tag> = {
title: 'Components/Tag',
component: Tag,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
shape: {
control: 'select',
options: ['square', 'rounded', 'overlay']
},
state: {
control: 'select',
options: ['default', 'unselected', 'selected']
},
removable: { control: 'boolean' }
},
args: {
label: 'Tag',
shape: 'square',
state: 'default',
removable: false
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Rounded: Story = {
args: {
label: 'Tag',
shape: 'rounded'
}
}
export const Unselected: Story = {
args: {
label: 'Tag',
state: 'unselected'
}
}
export const Removable: Story = {
args: {
label: 'Tag',
removable: true
}
}
export const AllStates: Story = {
render: () => ({
components: { Tag },
template: `
<div class="flex flex-col gap-4">
<div>
<p class="mb-2 text-xs text-muted-foreground">Square</p>
<div class="flex items-center gap-2">
<Tag label="Default" />
<Tag label="Unselected" state="unselected" />
<Tag label="Removable" removable />
</div>
</div>
<div>
<p class="mb-2 text-xs text-muted-foreground">Rounded</p>
<div class="flex items-center gap-2">
<Tag label="Default" shape="rounded" />
<Tag label="Unselected" shape="rounded" state="unselected" />
<Tag label="Removable" shape="rounded" removable />
</div>
</div>
<div class="bg-zinc-800 p-2 rounded">
<p class="mb-2 text-xs text-muted-foreground">Overlay (on images)</p>
<div class="flex items-center gap-2">
<Tag label="png" shape="overlay" />
<Tag label="1.2 MB" shape="overlay" />
</div>
</div>
</div>
`
})
}
export const TagList: Story = {
render: () => ({
components: { Tag },
template: `
<div class="flex flex-wrap gap-2">
<Tag label="JavaScript" />
<Tag label="TypeScript" />
<Tag label="Vue.js" />
<Tag label="React" />
<Tag label="Node.js" />
<Tag label="Python" />
<Tag label="Docker" />
<Tag label="Kubernetes" />
</div>
`
})
}

View File

@@ -0,0 +1,66 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import Tag from './Tag.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { remove: 'Remove' } } }
})
function renderTag(
props: {
label: string
shape?: 'square' | 'rounded'
removable?: boolean
onRemove?: (...args: unknown[]) => void
},
options?: { slots?: Record<string, string> }
) {
return render(Tag, {
props,
global: { plugins: [i18n] },
...options
})
}
describe('Tag', () => {
it('renders label text', () => {
renderTag({ label: 'JavaScript' })
expect(screen.getByText('JavaScript')).toBeInTheDocument()
})
it('does not show remove button by default', () => {
renderTag({ label: 'Test' })
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('shows remove button when removable', () => {
renderTag({ label: 'Test', removable: true })
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument()
})
it('emits remove event when remove button is clicked', async () => {
const user = userEvent.setup()
const onRemove = vi.fn()
renderTag({ label: 'Test', removable: true, onRemove })
await user.click(screen.getByRole('button', { name: 'Remove' }))
expect(onRemove).toHaveBeenCalledOnce()
})
it('renders icon slot content', () => {
renderTag(
{ label: 'LoRA' },
{
slots: {
icon: '<i data-testid="tag-icon" class="icon-[lucide--folder]" />'
}
}
)
expect(screen.getByTestId('tag-icon')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { tagVariants } from './tag.variants'
import type { TagVariants } from './tag.variants'
const {
label,
shape = 'square',
state = 'default',
removable = false,
class: className
} = defineProps<{
label: string
shape?: TagVariants['shape']
state?: TagVariants['state']
removable?: boolean
class?: string
}>()
const emit = defineEmits<{
remove: [event: Event]
}>()
const tagClass = computed(() =>
cn(tagVariants({ shape, state, removable }), className)
)
</script>
<template>
<span :class="tagClass">
<slot name="icon" />
<span class="truncate">{{ label }}</span>
<button
v-if="removable"
type="button"
:aria-label="$t('g.remove')"
class="inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full p-0.5 hover:bg-white/10"
@click.stop="emit('remove', $event)"
>
<i class="icon-[lucide--x] size-3" aria-hidden="true" />
</button>
</span>
</template>

View File

@@ -0,0 +1,29 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const tagVariants = cva({
base: 'inline-flex h-6 shrink-0 items-center justify-center gap-1 text-xs',
variants: {
shape: {
square: 'rounded-sm bg-modal-card-tag-background',
rounded: 'rounded-full bg-secondary-background',
overlay: 'rounded-sm bg-zinc-500/40 text-white/90'
},
state: {
default: 'text-modal-card-tag-foreground',
unselected: 'text-muted-foreground opacity-70',
selected: 'text-modal-card-tag-foreground'
},
removable: {
true: 'py-1 pr-1 pl-2',
false: 'px-2 py-1'
}
},
defaultVariants: {
shape: 'square',
state: 'default',
removable: false
}
})
export type TagVariants = VariantProps<typeof tagVariants>

View File

@@ -265,10 +265,11 @@
</template>
<template #bottom-right>
<template v-if="template.tags && template.tags.length > 0">
<SquareChip
<Tag
v-for="tag in template.tags"
:key="tag"
:label="tag"
shape="overlay"
/>
</template>
</template>
@@ -402,7 +403,7 @@ import { useI18n } from 'vue-i18n'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import Tag from '@/components/chip/Tag.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'

View File

@@ -99,13 +99,13 @@
</Button>
</template>
<template #bottom-right>
<SquareChip label="png" />
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<Tag label="png" shape="overlay" />
<Tag label="1.2 MB" shape="overlay" />
<Tag label="LoRA" shape="overlay">
<template #icon>
<i class="icon-[lucide--folder]" />
</template>
</SquareChip>
</Tag>
</template>
</CardTop>
</template>
@@ -129,7 +129,7 @@ import MoreButton from '@/components/button/MoreButton.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import Tag from '@/components/chip/Tag.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'

View File

@@ -6,7 +6,7 @@ import MoreButton from '@/components/button/MoreButton.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import Tag from '@/components/chip/Tag.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
@@ -76,7 +76,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
CardContainer,
CardTop,
CardBottom,
SquareChip
Tag
},
setup() {
const t = (k: string) => k
@@ -276,13 +276,13 @@ const createStoryTemplate = (args: StoryArgs) => ({
</Button>
</template>
<template #bottom-right>
<SquareChip label="png" />
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<Tag label="png" />
<Tag label="1.2 MB" />
<Tag label="LoRA">
<template #icon>
<i class="icon-[lucide--folder] size-3" />
</template>
</SquareChip>
</Tag>
</template>
</CardTop>
</template>
@@ -392,13 +392,13 @@ const createStoryTemplate = (args: StoryArgs) => ({
</Button>
</template>
<template #bottom-right>
<SquareChip label="png" />
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<Tag label="png" />
<Tag label="1.2 MB" />
<Tag label="LoRA">
<template #icon>
<i class="icon-[lucide--folder] size-3" />
</template>
</SquareChip>
</Tag>
</template>
</CardTop>
</template>

View File

@@ -1,5 +1,6 @@
import axios from 'axios'
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { useAuthStore } from '@/stores/authStore'
@@ -287,7 +288,27 @@ const workspaceApiClient = axios.create({
})
async function getAuthHeaderOrThrow() {
return useAuthStore().getAuthHeaderOrThrow()
const authHeader = await useAuthStore().getAuthHeader()
if (!authHeader) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),
401,
'NOT_AUTHENTICATED'
)
}
return authHeader
}
async function getFirebaseHeaderOrThrow() {
const authHeader = await useAuthStore().getFirebaseAuthHeader()
if (!authHeader) {
throw new WorkspaceApiError(
t('toastMessages.userNotAuthenticated'),
401,
'NOT_AUTHENTICATED'
)
}
return authHeader
}
function handleAxiosError(err: unknown): never {
@@ -479,7 +500,7 @@ export const workspaceApi = {
* Uses Firebase auth (user identity) since the user isn't yet a workspace member.
*/
async acceptInvite(token: string): Promise<AcceptInviteResponse> {
const headers = await useAuthStore().getFirebaseAuthHeaderOrThrow()
const headers = await getFirebaseHeaderOrThrow()
try {
const response = await workspaceApiClient.post<AcceptInviteResponse>(
api.apiURL(`/invites/${token}/accept`),

View File

@@ -343,10 +343,6 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
}
}
function getWorkspaceToken(): string | undefined {
return workspaceToken.value ?? undefined
}
function clearWorkspaceContext(): void {
// Increment request ID to invalidate any in-flight stale refresh operations
refreshRequestId++
@@ -374,7 +370,6 @@ export const useWorkspaceAuthStore = defineStore('workspaceAuth', () => {
switchWorkspace,
refreshToken,
getWorkspaceAuthHeader,
getWorkspaceToken,
clearWorkspaceContext
}
})

View File

@@ -19,11 +19,27 @@ import {
} from './useSlotElementTracking'
const mockGraph = vi.hoisted(() => ({ _nodes: [] as unknown[] }))
const mockCanvasState = vi.hoisted(() => ({
canvas: {} as object | null
}))
const mockClientPosToCanvasPos = vi.hoisted(() =>
vi.fn(([x, y]: [number, number]) => [x * 0.5, y * 0.5] as [number, number])
)
vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: mockGraph, setDirty: vi.fn() } }
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => mockCanvasState
}))
vi.mock('@/composables/element/useCanvasPositionConversion', () => ({
useSharedCanvasPositionConversion: () => ({
clientPosToCanvasPos: mockClientPosToCanvasPos
})
}))
const NODE_ID = 'test-node'
const SLOT_INDEX = 0
@@ -45,9 +61,10 @@ function createWrapperComponent(type: 'input' | 'output') {
})
}
function createSlotElement(): HTMLElement {
function createSlotElement(collapsed = false): HTMLElement {
const container = document.createElement('div')
container.dataset.nodeId = NODE_ID
if (collapsed) container.dataset.collapsed = ''
container.getBoundingClientRect = () =>
({
left: 0,
@@ -113,6 +130,8 @@ describe('useSlotElementTracking', () => {
actor: 'test'
})
mockGraph._nodes = [{ id: 1 }]
mockCanvasState.canvas = {}
mockClientPosToCanvasPos.mockClear()
})
it.each([
@@ -251,4 +270,57 @@ describe('useSlotElementTracking', () => {
expect(batchUpdateSpy).not.toHaveBeenCalled()
})
describe('collapsed node slot sync', () => {
function registerCollapsedSlot() {
const slotKey = getSlotKey(NODE_ID, SLOT_INDEX, true)
const slotEl = createSlotElement(true)
const registryStore = useNodeSlotRegistryStore()
const node = registryStore.ensureNode(NODE_ID)
node.slots.set(slotKey, {
el: slotEl,
index: SLOT_INDEX,
type: 'input',
cachedOffset: { x: 50, y: 60 }
})
return { slotKey, node }
}
it('uses clientPosToCanvasPos for collapsed nodes', () => {
const { slotKey } = registerCollapsedSlot()
syncNodeSlotLayoutsFromDOM(NODE_ID)
// Slot element center: (10 + 10/2, 30 + 10/2) = (15, 35)
const screenCenter: [number, number] = [15, 35]
expect(mockClientPosToCanvasPos).toHaveBeenCalledWith(screenCenter)
// Mock returns x*0.5, y*0.5
const layout = layoutStore.getSlotLayout(slotKey)
expect(layout).not.toBeNull()
expect(layout!.position.x).toBe(screenCenter[0] * 0.5)
expect(layout!.position.y).toBe(screenCenter[1] * 0.5)
})
it('clears cachedOffset for collapsed nodes', () => {
const { slotKey, node } = registerCollapsedSlot()
const entry = node.slots.get(slotKey)!
expect(entry.cachedOffset).toBeDefined()
syncNodeSlotLayoutsFromDOM(NODE_ID)
expect(entry.cachedOffset).toBeUndefined()
})
it('defers sync when canvas is not initialized', () => {
mockCanvasState.canvas = null
registerCollapsedSlot()
syncNodeSlotLayoutsFromDOM(NODE_ID)
expect(mockClientPosToCanvasPos).not.toHaveBeenCalled()
})
})
})

View File

@@ -8,7 +8,9 @@
import { onMounted, onUnmounted, watch } from 'vue'
import type { Ref } from 'vue'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { getSlotKey } from '@/renderer/core/layout/slots/slotIdentifier'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { app } from '@/scripts/app'
@@ -134,11 +136,26 @@ export function syncNodeSlotLayoutsFromDOM(nodeId: string) {
.value?.el.closest('[data-node-id]')
const nodeEl = closestNode instanceof HTMLElement ? closestNode : null
const nodeRect = nodeEl?.getBoundingClientRect()
// Collapsed nodes preserve expanded size in layoutStore, so DOM-relative
// scale derivation breaks. Fall back to clientPosToCanvasPos instead.
const isCollapsed = nodeEl?.dataset.collapsed != null
const effectiveScale =
nodeRect && nodeLayout.size.width > 0
!isCollapsed && nodeRect && nodeLayout.size.width > 0
? nodeRect.width / nodeLayout.size.width
: 0
const canvasStore = useCanvasStore()
const conv =
isCollapsed && canvasStore.canvas
? useSharedCanvasPositionConversion()
: null
if (isCollapsed && !conv) {
scheduleSlotLayoutSync(nodeId)
return
}
const batch: Array<{ key: string; layout: SlotLayout }> = []
for (const [slotKey, entry] of node.slots) {
@@ -155,22 +172,30 @@ export function syncNodeSlotLayoutsFromDOM(nodeId: string) {
rect.top + rect.height / 2
]
if (!nodeRect || effectiveScale <= 0) continue
let centerCanvas: { x: number; y: number }
// DOM-relative measurement: compute offset from the node element's
// top-left corner in canvas units. The node element is rendered at
// (position.x, position.y - NODE_TITLE_HEIGHT), so the Y offset
// must subtract NODE_TITLE_HEIGHT to be relative to position.y.
entry.cachedOffset = {
x: (screenCenter[0] - nodeRect.left) / effectiveScale,
y:
(screenCenter[1] - nodeRect.top) / effectiveScale -
LiteGraph.NODE_TITLE_HEIGHT
}
if (conv) {
const [cx, cy] = conv.clientPosToCanvasPos(screenCenter)
centerCanvas = { x: cx, y: cy }
entry.cachedOffset = undefined
} else {
if (!nodeRect || effectiveScale <= 0) continue
const centerCanvas = {
x: nodeLayout.position.x + entry.cachedOffset.x,
y: nodeLayout.position.y + entry.cachedOffset.y
// DOM-relative measurement: compute offset from the node element's
// top-left corner in canvas units. The node element is rendered at
// (position.x, position.y - NODE_TITLE_HEIGHT), so the Y offset
// must subtract NODE_TITLE_HEIGHT to be relative to position.y.
entry.cachedOffset = {
x: (screenCenter[0] - nodeRect.left) / effectiveScale,
y:
(screenCenter[1] - nodeRect.top) / effectiveScale -
LiteGraph.NODE_TITLE_HEIGHT
}
centerCanvas = {
x: nodeLayout.position.x + entry.cachedOffset.x,
y: nodeLayout.position.y + entry.cachedOffset.y
}
}
const nextLayout = createSlotLayout({

View File

@@ -15,8 +15,8 @@ import { useDocumentVisibility } from '@vueuse/core'
import { useSharedCanvasPositionConversion } from '@/composables/element/useCanvasPositionConversion'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import type { Bounds, NodeId } from '@/renderer/core/layout/types'
import { LayoutSource } from '@/renderer/core/layout/types'
import {

View File

@@ -1,111 +0,0 @@
import { vi } from 'vitest'
import type { Mock } from 'vitest'
import { computed, reactive, ref } from 'vue'
/**
* Shared mock factory for useAuthStore.
*
* Usage in test files:
* import { createAuthStoreMock, mockAuthStoreModule } from '@/stores/__tests__/authStoreMock'
*
* const { mock, controls } = createAuthStoreMock()
* vi.mock('@/stores/authStore', () => mockAuthStoreModule(mock))
*
* // Per-test customization:
* controls.currentUser.value = { uid: 'test-123', email: 'a@b.com' }
* controls.getAuthHeader.mockResolvedValue({ Authorization: 'Bearer tok' })
*/
export interface AuthStoreMockControls {
currentUser: ReturnType<typeof ref<Record<string, unknown> | null>>
isInitialized: ReturnType<typeof ref<boolean>>
loading: ReturnType<typeof ref<boolean>>
balance: ReturnType<typeof ref<Record<string, unknown> | null>>
isFetchingBalance: ReturnType<typeof ref<boolean>>
tokenRefreshTrigger: ReturnType<typeof ref<number>>
login: Mock
register: Mock
logout: Mock
getIdToken: Mock
getAuthHeader: Mock
getAuthHeaderOrThrow: Mock
getFirebaseAuthHeader: Mock
getFirebaseAuthHeaderOrThrow: Mock
getAuthToken: Mock
createCustomer: Mock
fetchBalance: Mock
accessBillingPortal: Mock
loginWithGoogle: Mock
loginWithGithub: Mock
sendPasswordReset: Mock
updatePassword: Mock
initiateCreditPurchase: Mock
}
export function createAuthStoreMock(): {
mock: Record<string, unknown>
controls: AuthStoreMockControls
} {
const currentUser = ref<Record<string, unknown> | null>(null)
const isInitialized = ref(false)
const loading = ref(false)
const balance = ref<Record<string, unknown> | null>(null)
const isFetchingBalance = ref(false)
const tokenRefreshTrigger = ref(0)
const controls: AuthStoreMockControls = {
currentUser,
isInitialized,
loading,
balance,
isFetchingBalance,
tokenRefreshTrigger,
login: vi.fn(),
register: vi.fn(),
logout: vi.fn(),
getIdToken: vi.fn().mockResolvedValue('mock-id-token'),
getAuthHeader: vi.fn().mockResolvedValue(null),
getAuthHeaderOrThrow: vi.fn().mockResolvedValue({
Authorization: 'Bearer mock-id-token'
}),
getFirebaseAuthHeader: vi.fn().mockResolvedValue(null),
getFirebaseAuthHeaderOrThrow: vi.fn().mockResolvedValue({
Authorization: 'Bearer mock-id-token'
}),
getAuthToken: vi.fn().mockResolvedValue(undefined),
createCustomer: vi.fn(),
fetchBalance: vi.fn(),
accessBillingPortal: vi.fn(),
loginWithGoogle: vi.fn(),
loginWithGithub: vi.fn(),
sendPasswordReset: vi.fn(),
updatePassword: vi.fn(),
initiateCreditPurchase: vi.fn()
}
const mock = reactive({
...controls,
isAuthenticated: computed(() => !!currentUser.value),
userEmail: computed(
() => (currentUser.value as Record<string, unknown> | null)?.email ?? null
),
userId: computed(
() => (currentUser.value as Record<string, unknown> | null)?.uid ?? null
)
})
return { mock, controls }
}
export function mockAuthStoreModule(mock: Record<string, unknown>) {
return {
useAuthStore: () => mock,
AuthStoreError: class extends Error {
constructor(message: string) {
super(message)
this.name = 'AuthStoreError'
}
}
}
}

View File

@@ -1,211 +0,0 @@
import type { User } from 'firebase/auth'
import * as firebaseAuth from 'firebase/auth'
import { setActivePinia } from 'pinia'
import type { Mock } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as vuefire from 'vuefire'
import { useAuthStore } from '@/stores/authStore'
import { createTestingPinia } from '@pinia/testing'
const { mockFeatureFlags } = vi.hoisted(() => ({
mockFeatureFlags: {
teamWorkspacesEnabled: false
}
}))
const { mockDistributionTypes } = vi.hoisted(() => ({
mockDistributionTypes: {
isCloud: true,
isDesktop: true
}
}))
const mockWorkspaceAuthHeader = vi.fn().mockReturnValue(null)
const mockGetWorkspaceToken = vi.fn().mockReturnValue(undefined)
const mockClearWorkspaceContext = vi.fn()
vi.mock('@/platform/workspace/stores/workspaceAuthStore', () => ({
useWorkspaceAuthStore: () => ({
getWorkspaceAuthHeader: mockWorkspaceAuthHeader,
getWorkspaceToken: mockGetWorkspaceToken,
clearWorkspaceContext: mockClearWorkspaceContext
})
}))
vi.mock('@/composables/useFeatureFlags', () => ({
useFeatureFlags: () => ({
flags: mockFeatureFlags
})
}))
vi.mock('vuefire', () => ({
useFirebaseAuth: vi.fn()
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
createI18n: () => ({ global: { t: (key: string) => key } })
}))
vi.mock('firebase/auth', async (importOriginal) => {
const actual = await importOriginal<typeof firebaseAuth>()
return {
...actual,
signInWithEmailAndPassword: vi.fn(),
createUserWithEmailAndPassword: vi.fn(),
signOut: vi.fn(),
onAuthStateChanged: vi.fn(),
onIdTokenChanged: vi.fn(),
signInWithPopup: vi.fn(),
GoogleAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
},
GithubAuthProvider: class {
addScope = vi.fn()
setCustomParameters = vi.fn()
},
getAdditionalUserInfo: vi.fn(),
setPersistence: vi.fn().mockResolvedValue(undefined)
}
})
vi.mock('@/platform/telemetry', () => ({
useTelemetry: () => ({ trackAuth: vi.fn() })
}))
vi.mock('@/stores/toastStore', () => ({
useToastStore: () => ({ add: vi.fn() })
}))
vi.mock('@/services/dialogService')
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
const mockApiKeyGetAuthHeader = vi.fn().mockReturnValue(null)
vi.mock('@/stores/apiKeyAuthStore', () => ({
useApiKeyAuthStore: () => ({
getAuthHeader: mockApiKeyGetAuthHeader,
getApiKey: vi.fn(),
currentUser: null,
isAuthenticated: false,
storeApiKey: vi.fn(),
clearStoredApiKey: vi.fn()
})
}))
type MockUser = Omit<User, 'getIdToken'> & { getIdToken: Mock }
describe('auth token priority chain', () => {
let store: ReturnType<typeof useAuthStore>
let authStateCallback: (user: User | null) => void
const mockAuth: Record<string, unknown> = {}
const mockUser: MockUser = {
uid: 'test-user-id',
email: 'test@example.com',
getIdToken: vi.fn().mockResolvedValue('firebase-token')
} as Partial<User> as MockUser
beforeEach(() => {
vi.resetAllMocks()
mockFeatureFlags.teamWorkspacesEnabled = false
mockWorkspaceAuthHeader.mockReturnValue(null)
mockGetWorkspaceToken.mockReturnValue(undefined)
mockApiKeyGetAuthHeader.mockReturnValue(null)
mockUser.getIdToken.mockResolvedValue('firebase-token')
vi.mocked(vuefire.useFirebaseAuth).mockReturnValue(
mockAuth as unknown as ReturnType<typeof vuefire.useFirebaseAuth>
)
vi.mocked(firebaseAuth.onAuthStateChanged).mockImplementation(
(_, callback) => {
authStateCallback = callback as (user: User | null) => void
;(callback as (user: User | null) => void)(mockUser)
return vi.fn()
}
)
setActivePinia(createTestingPinia({ stubActions: false }))
store = useAuthStore()
})
describe('getAuthHeader priority', () => {
it('returns workspace auth header when workspace is active and feature enabled', async () => {
mockFeatureFlags.teamWorkspacesEnabled = true
mockWorkspaceAuthHeader.mockReturnValue({
Authorization: 'Bearer workspace-token'
})
const header = await store.getAuthHeader()
expect(header).toEqual({
Authorization: 'Bearer workspace-token'
})
})
it('returns Firebase token when workspace is not active but user is authenticated', async () => {
mockFeatureFlags.teamWorkspacesEnabled = true
mockWorkspaceAuthHeader.mockReturnValue(null)
const header = await store.getAuthHeader()
expect(header).toEqual({
Authorization: 'Bearer firebase-token'
})
})
it('returns API key when neither workspace nor Firebase are available', async () => {
authStateCallback(null)
mockApiKeyGetAuthHeader.mockReturnValue({ 'X-API-KEY': 'test-key' })
const header = await store.getAuthHeader()
expect(header).toEqual({ 'X-API-KEY': 'test-key' })
})
it('returns null when no auth method is available', async () => {
authStateCallback(null)
const header = await store.getAuthHeader()
expect(header).toBeNull()
})
it('skips workspace header when team_workspaces feature is disabled', async () => {
mockFeatureFlags.teamWorkspacesEnabled = false
mockWorkspaceAuthHeader.mockReturnValue({
Authorization: 'Bearer workspace-token'
})
const header = await store.getAuthHeader()
expect(header).toEqual({
Authorization: 'Bearer firebase-token'
})
})
})
describe('getAuthToken priority', () => {
it('returns workspace token when workspace is active and feature enabled', async () => {
mockFeatureFlags.teamWorkspacesEnabled = true
mockGetWorkspaceToken.mockReturnValue('workspace-raw-token')
const token = await store.getAuthToken()
expect(token).toBe('workspace-raw-token')
})
it('returns Firebase token when workspace token is not available', async () => {
mockFeatureFlags.teamWorkspacesEnabled = true
mockGetWorkspaceToken.mockReturnValue(undefined)
const token = await store.getAuthToken()
expect(token).toBe('firebase-token')
})
})
})

View File

@@ -730,37 +730,6 @@ describe('useAuthStore', () => {
})
})
describe('getAuthHeaderOrThrow', () => {
it('returns auth header when authenticated', async () => {
const header = await store.getAuthHeaderOrThrow()
expect(header).toEqual({ Authorization: 'Bearer mock-id-token' })
})
it('throws AuthStoreError when not authenticated', async () => {
authStateCallback(null)
mockApiKeyGetAuthHeader.mockReturnValue(null)
await expect(store.getAuthHeaderOrThrow()).rejects.toThrow(
'toastMessages.userNotAuthenticated'
)
})
})
describe('getFirebaseAuthHeaderOrThrow', () => {
it('returns Firebase auth header when authenticated', async () => {
const header = await store.getFirebaseAuthHeaderOrThrow()
expect(header).toEqual({ Authorization: 'Bearer mock-id-token' })
})
it('throws AuthStoreError when not authenticated', async () => {
authStateCallback(null)
await expect(store.getFirebaseAuthHeaderOrThrow()).rejects.toThrow(
'toastMessages.userNotAuthenticated'
)
})
})
describe('createCustomer', () => {
it('should succeed with API key auth when no Firebase user is present', async () => {
authStateCallback(null)

View File

@@ -22,10 +22,10 @@ import { useFirebaseAuth } from 'vuefire'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { WORKSPACE_STORAGE_KEYS } from '@/platform/workspace/workspaceConstants'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { useDialogService } from '@/services/dialogService'
import { useWorkspaceAuthStore } from '@/platform/workspace/stores/workspaceAuthStore'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import type { AuthHeader } from '@/types/authTypes'
import type { operations } from '@/types/comfyRegistryTypes'
@@ -110,7 +110,15 @@ export const useAuthStore = defineStore('auth', () => {
isInitialized.value = true
if (user === null) {
lastTokenUserId.value = null
useWorkspaceAuthStore().clearWorkspaceContext()
// Clear workspace sessionStorage on logout to prevent stale tokens
try {
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.CURRENT_WORKSPACE)
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.TOKEN)
sessionStorage.removeItem(WORKSPACE_STORAGE_KEYS.EXPIRES_AT)
} catch {
// Ignore sessionStorage errors (e.g., in private browsing mode)
}
}
// Reset balance when auth state changes
@@ -167,8 +175,21 @@ export const useAuthStore = defineStore('auth', () => {
*/
const getAuthHeader = async (): Promise<AuthHeader | null> => {
if (flags.teamWorkspacesEnabled) {
const wsHeader = useWorkspaceAuthStore().getWorkspaceAuthHeader()
if (wsHeader) return wsHeader
const workspaceToken = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.TOKEN
)
const expiresAt = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT
)
if (workspaceToken && expiresAt) {
const expiryTime = parseInt(expiresAt, 10)
if (Date.now() < expiryTime) {
return {
Authorization: `Bearer ${workspaceToken}`
}
}
}
}
const token = await getIdToken()
@@ -197,29 +218,24 @@ export const useAuthStore = defineStore('auth', () => {
*/
const getAuthToken = async (): Promise<string | undefined> => {
if (flags.teamWorkspacesEnabled) {
const wsToken = useWorkspaceAuthStore().getWorkspaceToken()
if (wsToken) return wsToken
const workspaceToken = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.TOKEN
)
const expiresAt = sessionStorage.getItem(
WORKSPACE_STORAGE_KEYS.EXPIRES_AT
)
if (workspaceToken && expiresAt) {
const expiryTime = parseInt(expiresAt, 10)
if (Date.now() < expiryTime) {
return workspaceToken
}
}
}
return await getIdToken()
}
const getAuthHeaderOrThrow = async (): Promise<AuthHeader> => {
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
return authHeader
}
const getFirebaseAuthHeaderOrThrow = async (): Promise<AuthHeader> => {
const authHeader = await getFirebaseAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
return authHeader
}
const fetchBalance = async (): Promise<GetCustomerBalanceResponse | null> => {
isFetchingBalance.value = true
try {
@@ -522,9 +538,7 @@ export const useAuthStore = defineStore('auth', () => {
sendPasswordReset,
updatePassword: _updatePassword,
getAuthHeader,
getAuthHeaderOrThrow,
getFirebaseAuthHeader,
getFirebaseAuthHeaderOrThrow,
getAuthToken
}
})