mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-26 17:47:17 +00:00
Compare commits
69 Commits
fix/linear
...
DynamicGro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a16d488838 | ||
|
|
13b42d9b59 | ||
|
|
e604c85b88 | ||
|
|
7ae3ad936c | ||
|
|
7b83228cdd | ||
|
|
d26f578c81 | ||
|
|
cfbc378df3 | ||
|
|
db085eb7a1 | ||
|
|
48d429bd13 | ||
|
|
6eaad99502 | ||
|
|
2ced8a25d4 | ||
|
|
9209a4b923 | ||
|
|
90cb8021df | ||
|
|
5a01c5b3b4 | ||
|
|
e3049e7c31 | ||
|
|
87e84e7280 | ||
|
|
67009dcda2 | ||
|
|
026b2c4795 | ||
|
|
d60260ac3c | ||
|
|
0c89f5a3a7 | ||
|
|
f19597ce81 | ||
|
|
988dc71955 | ||
|
|
da55529d23 | ||
|
|
52d430d1b6 | ||
|
|
7ab6cb57c5 | ||
|
|
3c3a2ab4e2 | ||
|
|
a07854755f | ||
|
|
842e3d7541 | ||
|
|
2adef5d9f6 | ||
|
|
c406042215 | ||
|
|
395b0a1c89 | ||
|
|
6068571b35 | ||
|
|
e37f168eaa | ||
|
|
b165b3f999 | ||
|
|
d7f9754393 | ||
|
|
48a3ea0e92 | ||
|
|
a8f8ba7580 | ||
|
|
966659b303 | ||
|
|
a95dab2f59 | ||
|
|
5f90bacb73 | ||
|
|
84319bea13 | ||
|
|
f076106ca5 | ||
|
|
d7fa853c06 | ||
|
|
07f881fc14 | ||
|
|
e14b5c6f3f | ||
|
|
065650b3bf | ||
|
|
a58f927871 | ||
|
|
c304f206a6 | ||
|
|
75ea646090 | ||
|
|
a670944a05 | ||
|
|
282b8cf819 | ||
|
|
70bc8dc6e6 | ||
|
|
ac56ecf009 | ||
|
|
e97cca9e4a | ||
|
|
49a7b7b558 | ||
|
|
8d82944441 | ||
|
|
a0f4feb111 | ||
|
|
d4be483c03 | ||
|
|
8d0b21e9e8 | ||
|
|
69858538d0 | ||
|
|
001b132b0c | ||
|
|
56b05c0fd5 | ||
|
|
403353ac77 | ||
|
|
c4db198875 | ||
|
|
040e490f02 | ||
|
|
90c523b4a3 | ||
|
|
1f759a758c | ||
|
|
44557fd138 | ||
|
|
90210292d7 |
@@ -2,6 +2,7 @@ issue_enrichment:
|
||||
auto_enrich:
|
||||
enabled: true
|
||||
reviews:
|
||||
profile: assertive
|
||||
high_level_summary: false
|
||||
request_changes_workflow: true
|
||||
auto_review:
|
||||
@@ -15,6 +16,11 @@ reviews:
|
||||
- github-actions[bot]
|
||||
pre_merge_checks:
|
||||
override_requested_reviewers_only: true
|
||||
# Explicitly disable the built-in docstring coverage check, which is
|
||||
# enabled via organization-level settings. This repo opts out at the
|
||||
# repo level without affecting other org repos.
|
||||
docstrings:
|
||||
mode: 'off'
|
||||
custom_checks:
|
||||
- name: End-to-end regression coverage for fixes
|
||||
mode: error
|
||||
|
||||
21
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
21
.github/workflows/ci-dist-telemetry-scan.yaml
vendored
@@ -133,3 +133,24 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Customer.io references found'
|
||||
|
||||
- name: Scan dist for Cloudflare Turnstile sitekey references
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo '🔍 Scanning for Cloudflare Turnstile sitekeys...'
|
||||
if rg --no-ignore -n \
|
||||
-g '*.html' \
|
||||
-g '*.js' \
|
||||
-e '0x4AAAAAADnYZPVOpFCL_zeo' \
|
||||
-e '0x4AAAAAADnYY4_Q0qxHZ5a7' \
|
||||
-e '1x00000000000000000000AA' \
|
||||
dist; then
|
||||
echo '❌ ERROR: Cloudflare Turnstile sitekey found in dist assets!'
|
||||
echo 'The per-env Turnstile sitekeys are cloud-only and must be tree-shaken from OSS builds.'
|
||||
echo ''
|
||||
echo 'To fix this:'
|
||||
echo '1. Gate sitekey selection on the __DISTRIBUTION__ build define, not the runtime isCloud const'
|
||||
echo '2. See getTurnstileSiteKey() in src/config/turnstile.ts'
|
||||
exit 1
|
||||
fi
|
||||
echo '✅ No Turnstile sitekey references found'
|
||||
|
||||
10
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
10
.github/workflows/ci-tests-e2e-coverage.yaml
vendored
@@ -85,6 +85,16 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Strip non-source entries from coverage
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
run: |
|
||||
# Drop served bundle scripts (localhost-8188/assets/*.js) that V8 records but have no source file on disk, which would abort genhtml.
|
||||
lcov --remove coverage/playwright/coverage.lcov \
|
||||
'*localhost-8188*' \
|
||||
-o coverage/playwright/coverage.lcov \
|
||||
--ignore-errors unused
|
||||
wc -l coverage/playwright/coverage.lcov
|
||||
|
||||
- name: Upload merged coverage data
|
||||
if: steps.coverage-shards.outputs.has-coverage == 'true'
|
||||
uses: actions/upload-artifact@v6
|
||||
|
||||
63
.github/workflows/cla.yml
vendored
Normal file
63
.github/workflows/cla.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: CLA Assistant
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, closed]
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read # 'read' is enough because signatures live in a REMOTE repo
|
||||
pull-requests: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
cla-assistant:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: CLA Assistant
|
||||
# Run on PR events, on "recheck" comment, or when someone posts the exact signing phrase.
|
||||
# IMPORTANT: this phrase must match `custom-pr-sign-comment` below.
|
||||
if: >
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event.comment.body == 'recheck' ||
|
||||
github.event.comment.body == 'I have read and agree to the Contributor License Agreement'
|
||||
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# PAT required to write to the centralized signatures repo.
|
||||
PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
with:
|
||||
# Where the CLA document lives (shown to contributors)
|
||||
path-to-document: https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md
|
||||
|
||||
# Centralized signature storage
|
||||
remote-organization-name: comfy-org
|
||||
remote-repository-name: comfy-cla
|
||||
path-to-signatures: signatures/cla.json
|
||||
branch: main
|
||||
|
||||
# Allowlist bots so they don't need to sign (optional, comma-separated).
|
||||
# *[bot] is a catch-all for any GitHub App bot account.
|
||||
allowlist: actions-user,ampagent,claude,comfy-pr-bot,github-actions,*[bot],Glary Bot
|
||||
|
||||
# Custom PR comment messages
|
||||
custom-notsigned-prcomment: |
|
||||
🎉 Thank you for your contribution, we really appreciate it! 🎉
|
||||
|
||||
Like many open source projects, we require contributors to sign our [Contributor License Agreement (CLA)](https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md). A CLA makes the ownership of contributions explicit, so contributors and the project share a clear understanding of how the code can be used. By signing, you:
|
||||
|
||||
- Confirm that you own your contribution.
|
||||
- Keep the right to reuse your own code.
|
||||
- Grant us a copyright license to include and share it within our projects.
|
||||
|
||||
CLAs are standard practice across major open source projects including those under the Apache Software Foundation and the Linux Foundation. Ours is based on the Apache Software Foundation's CLA. Most importantly, it would enable us to relicense the project under a more permissive license in the future, giving the project and its community greater flexibility.
|
||||
|
||||
✍ **To sign, please post a new comment on this PR with exactly the following text:** ✍
|
||||
|
||||
custom-pr-sign-comment: I have read and agree to the Contributor License Agreement
|
||||
|
||||
custom-allsigned-prcomment: |
|
||||
✅ All contributors have signed the CLA. Thank you! This PR is ready to be merged.
|
||||
@@ -78,6 +78,21 @@ const config: StorybookConfig = {
|
||||
find: '@/composables/queue/useJobActions',
|
||||
replacement: process.cwd() + '/src/storybook/mocks/useJobActions.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/billing/useBillingContext',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useBillingContext.ts'
|
||||
},
|
||||
{
|
||||
find: '@/composables/useFeatureFlags',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/useFeatureFlags.ts'
|
||||
},
|
||||
{
|
||||
find: '@/platform/workspace/stores/teamWorkspaceStore',
|
||||
replacement:
|
||||
process.cwd() + '/src/storybook/mocks/teamWorkspaceStore.ts'
|
||||
},
|
||||
{
|
||||
find: '@/utils/formatUtil',
|
||||
replacement:
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { Preview, StoryContext, StoryFn } from '@storybook/vue3-vite'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'primeicons/primeicons.css'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ConfirmationService from 'primevue/confirmationservice'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
|
||||
@@ -42,7 +41,6 @@ setup((app) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
app.use(ConfirmationService)
|
||||
app.use(ToastService)
|
||||
})
|
||||
|
||||
|
||||
@@ -47,6 +47,11 @@ test.describe('Download page @smoke', () => {
|
||||
const downloadBtn = hero.getByRole('link', { name: /DOWNLOAD DESKTOP/i })
|
||||
await expect(downloadBtn).toBeVisible()
|
||||
await expect(downloadBtn).toHaveAttribute('target', '_blank')
|
||||
await expect(downloadBtn).toHaveAttribute(
|
||||
'href',
|
||||
'https://comfy.org/download/windows/nsis/x64'
|
||||
)
|
||||
await expect(downloadBtn).toHaveAttribute('data-astro-prefetch', 'false')
|
||||
|
||||
const githubBtn = hero.getByRole('link', { name: /INSTALL FROM GITHUB/i })
|
||||
await expect(githubBtn).toBeVisible()
|
||||
@@ -73,7 +78,7 @@ test.describe('Download page @smoke', () => {
|
||||
})
|
||||
|
||||
const windowsBtn = hero.locator(
|
||||
'a[href="https://download.comfy.org/windows/nsis/x64"]'
|
||||
'a[href="https://comfy.org/download/windows/nsis/x64"]'
|
||||
)
|
||||
await expect(windowsBtn).toBeVisible()
|
||||
await expect(windowsBtn).toHaveText(/DOWNLOAD DESKTOP/i)
|
||||
|
||||
@@ -30,9 +30,9 @@ function toggle(index: number) {
|
||||
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
|
||||
class="sticky top-20 z-10 w-full shrink-0 self-start bg-primary-comfy-ink py-4 md:top-28 md:w-80 md:py-0"
|
||||
>
|
||||
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
|
||||
<h2 class="text-4xl font-light text-primary-comfy-canvas md:text-5xl">
|
||||
{{ heading }}
|
||||
</h2>
|
||||
</div>
|
||||
@@ -42,7 +42,7 @@ function toggle(index: number) {
|
||||
<div
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="faq.id"
|
||||
class="border-primary-comfy-canvas/20 border-b"
|
||||
class="border-b border-primary-comfy-canvas/20"
|
||||
>
|
||||
<button
|
||||
:id="`faq-trigger-${faq.id}`"
|
||||
@@ -83,7 +83,7 @@ function toggle(index: number) {
|
||||
:aria-labelledby="`faq-trigger-${faq.id}`"
|
||||
class="pb-6"
|
||||
>
|
||||
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
|
||||
<p class="text-sm whitespace-pre-line text-primary-comfy-canvas/70">
|
||||
{{ faq.answer }}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -25,7 +25,7 @@ const {
|
||||
<section class="max-w-9xl mx-auto px-6 py-20 lg:py-32">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas max-w-5xl text-3xl font-light tracking-tight lg:text-5xl"
|
||||
class="max-w-5xl text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
|
||||
@@ -40,12 +40,12 @@ const {
|
||||
<div class="grid grid-cols-1 gap-12 lg:grid-cols-2 lg:gap-16">
|
||||
<div class="flex flex-col gap-8">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ t(headingKey, locale) }}
|
||||
</h2>
|
||||
<p
|
||||
class="text-primary-comfy-canvas max-w-sm text-sm/relaxed lg:text-base"
|
||||
class="max-w-sm text-sm/relaxed text-primary-comfy-canvas lg:text-base"
|
||||
>
|
||||
{{ t(descriptionKey, locale) }}
|
||||
</p>
|
||||
@@ -66,10 +66,10 @@ const {
|
||||
v-for="(event, i) in events"
|
||||
:key="i"
|
||||
:href="event.href"
|
||||
class="group border-primary-comfy-canvas/15 flex items-center gap-4 border-b py-6 lg:gap-8"
|
||||
class="group flex items-center gap-4 border-b border-primary-comfy-canvas/15 py-6 lg:gap-8"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-canvas shrink-0 text-sm font-medium"
|
||||
class="shrink-0 text-sm font-medium text-primary-comfy-canvas"
|
||||
>
|
||||
{{ event.label[locale] }}
|
||||
</span>
|
||||
|
||||
@@ -109,7 +109,7 @@ const contactColumn: { title: string; links: FooterLink[] } = {
|
||||
<template>
|
||||
<footer
|
||||
ref="footerRef"
|
||||
class="bg-primary-comfy-ink text-primary-comfy-canvas px-6 py-8 lg:px-20"
|
||||
class="bg-primary-comfy-ink px-6 py-8 text-primary-comfy-canvas lg:px-20"
|
||||
>
|
||||
<div
|
||||
class="border-primary-warm-gray grid gap-12 border-t pt-16 lg:grid-cols-2 lg:gap-4"
|
||||
|
||||
@@ -53,7 +53,7 @@ defineEmits<{ click: [] }>()
|
||||
<div class="flex w-full items-end justify-between p-4">
|
||||
<div class="gap-2">
|
||||
<p class="text-sm font-bold text-white">{{ item.title }}</p>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
<GalleryItemAttribution :item :locale />
|
||||
</p>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@ defineEmits<{ click: [] }>()
|
||||
<!-- Mobile metadata -->
|
||||
<div v-if="mobile" class="mt-2 gap-2">
|
||||
<p class="text-sm font-bold text-white">{{ item.title }}</p>
|
||||
<p class="text-primary-comfy-canvas text-xs">
|
||||
<p class="text-xs text-primary-comfy-canvas">
|
||||
<GalleryItemAttribution :item :locale />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 pt-24 pb-12 text-center"
|
||||
>
|
||||
<h1
|
||||
class="text-primary-comfy-canvas max-w-4xl text-3xl leading-[110%] font-light tracking-tight lg:text-5xl"
|
||||
class="max-w-4xl text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t('learning.heroTitle.before', locale) }}
|
||||
<span class="text-primary-comfy-yellow">ComfyUI</span
|
||||
|
||||
@@ -72,6 +72,7 @@ const buttons = computed<ButtonSpec[]>(() => {
|
||||
size="lg"
|
||||
:class="customClass"
|
||||
:aria-label="btn.ariaLabel"
|
||||
:data-astro-prefetch="btn.key === 'windows' ? 'false' : undefined"
|
||||
@click="captureDownloadClick(btn.key)"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { externalLinks } from '@/config/routes'
|
||||
|
||||
export const downloadUrls = {
|
||||
windows: 'https://download.comfy.org/windows/nsis/x64',
|
||||
windows: 'https://comfy.org/download/windows/nsis/x64',
|
||||
macArm: 'https://download.comfy.org/mac/dmg/arm64'
|
||||
} as const
|
||||
|
||||
|
||||
@@ -56,12 +56,16 @@ class ComfyPropertiesPanel {
|
||||
readonly panelTitle: Locator
|
||||
readonly searchBox: Locator
|
||||
readonly titleEditor: TitleEditor
|
||||
readonly toggleButton: Locator
|
||||
|
||||
constructor(readonly page: Page) {
|
||||
this.root = page.getByTestId(TestIds.propertiesPanel.root)
|
||||
this.panelTitle = this.root.locator('h3')
|
||||
this.searchBox = this.root.getByPlaceholder(/^Search/)
|
||||
this.titleEditor = new TitleEditor(this.root)
|
||||
this.toggleButton = page.getByRole('button', {
|
||||
name: 'Toggle properties panel'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export class BaseDialog {
|
||||
public readonly page: Page,
|
||||
testId?: string
|
||||
) {
|
||||
this.root = testId ? page.getByTestId(testId) : page.locator('.p-dialog')
|
||||
this.root = testId ? page.getByTestId(testId) : page.getByRole('dialog')
|
||||
this.closeButton = this.root.getByRole('button', { name: 'Close' })
|
||||
}
|
||||
|
||||
|
||||
@@ -352,20 +352,11 @@ export class AssetsSidebarTab extends SidebarTab {
|
||||
this.listViewItems = page.locator(
|
||||
'.sidebar-content-container [role="button"][tabindex="0"]'
|
||||
)
|
||||
this.selectionFooter = page
|
||||
.locator('.sidebar-content-container')
|
||||
.locator('..')
|
||||
.locator('[class*="h-18"]')
|
||||
this.selectionCountButton = page.getByText(/Assets Selected: \d+/)
|
||||
this.deselectAllButton = page.getByText('Deselect all')
|
||||
this.deleteSelectedButton = page
|
||||
.getByTestId('assets-delete-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--trash-2\\])').last())
|
||||
.first()
|
||||
this.downloadSelectedButton = page
|
||||
.getByTestId('assets-download-selected')
|
||||
.or(page.locator('button:has(.icon-\\[lucide--download\\])').last())
|
||||
.first()
|
||||
this.selectionFooter = page.getByTestId('assets-selection-bar')
|
||||
this.selectionCountButton = page.getByText(/\d+ selected/)
|
||||
this.deselectAllButton = page.getByTestId('assets-deselect-selected')
|
||||
this.deleteSelectedButton = page.getByTestId('assets-delete-selected')
|
||||
this.downloadSelectedButton = page.getByTestId('assets-download-selected')
|
||||
this.backToAssetsButton = page.getByText('Back to all assets')
|
||||
this.skeletonLoaders = page.locator(
|
||||
'.sidebar-content-container .animate-pulse'
|
||||
|
||||
96
browser_tests/fixtures/data/cloudWorkspace.ts
Normal file
96
browser_tests/fixtures/data/cloudWorkspace.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
Member,
|
||||
Plan,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
// `/api/features` is the remote-config source: production builds resolve the
|
||||
// workspaces flag from it (the `ff:` localStorage override is dev-only).
|
||||
export const WORKSPACE_FEATURE_FLAG: RemoteConfig = {
|
||||
team_workspaces_enabled: true
|
||||
}
|
||||
|
||||
export const TEAM_WORKSPACE: WorkspaceWithRole = {
|
||||
id: 'ws-team',
|
||||
name: 'Team Comfy',
|
||||
type: 'team',
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
joined_at: '2025-01-02T00:00:00Z',
|
||||
role: 'owner',
|
||||
subscription_tier: 'PRO'
|
||||
}
|
||||
|
||||
export const CREATOR: Member = {
|
||||
id: 'u-liz',
|
||||
name: 'Liz',
|
||||
email: 'liz@test.comfy.org',
|
||||
joined_at: '2025-01-01T00:00:00Z',
|
||||
role: 'owner',
|
||||
is_original_owner: true
|
||||
}
|
||||
|
||||
// Identity must match the CloudAuthHelper mock user so this row counts as
|
||||
// "(You)".
|
||||
export const VIEWER: Member = {
|
||||
id: 'u-me',
|
||||
name: 'E2E Test User',
|
||||
email: 'e2e@test.comfy.org',
|
||||
joined_at: '2025-01-02T00:00:00Z',
|
||||
role: 'owner',
|
||||
is_original_owner: false
|
||||
}
|
||||
|
||||
export const MEMBER_JANE: Member = {
|
||||
id: 'u-jane',
|
||||
name: 'Jane',
|
||||
email: 'jane@test.comfy.org',
|
||||
joined_at: '2025-01-03T00:00:00Z',
|
||||
role: 'member',
|
||||
is_original_owner: false
|
||||
}
|
||||
|
||||
export const MEMBER_JOHN: Member = {
|
||||
id: 'u-john',
|
||||
name: 'John',
|
||||
email: 'john@test.comfy.org',
|
||||
joined_at: '2025-01-04T00:00:00Z',
|
||||
role: 'member',
|
||||
is_original_owner: false
|
||||
}
|
||||
|
||||
export const DEFAULT_TEAM_MEMBERS: Member[] = [
|
||||
CREATOR,
|
||||
VIEWER,
|
||||
MEMBER_JANE,
|
||||
MEMBER_JOHN
|
||||
]
|
||||
|
||||
export const TEAM_BILLING_STATUS: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_status: 'active',
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
plan_slug: 'pro-monthly',
|
||||
billing_status: 'paid',
|
||||
has_funds: true,
|
||||
renewal_date: '2099-02-20T00:00:00Z'
|
||||
}
|
||||
|
||||
// `max_seats > 1` on the current plan is what flips `isOnTeamPlan`, which gates
|
||||
// the whole role-management UI.
|
||||
export const TEAM_PRO_PLAN: Plan = {
|
||||
slug: 'pro-monthly',
|
||||
tier: 'PRO',
|
||||
duration: 'MONTHLY',
|
||||
price_cents: 10000,
|
||||
credits_cents: 21100,
|
||||
max_seats: 30,
|
||||
availability: { available: true },
|
||||
seat_summary: {
|
||||
seat_count: 4,
|
||||
total_cost_cents: 40000,
|
||||
total_credits_cents: 0
|
||||
}
|
||||
}
|
||||
@@ -36,9 +36,11 @@ export class BuilderSaveAsHelper {
|
||||
this.closeButton = this.successDialog
|
||||
.getByRole('button', { name: 'Close', exact: true })
|
||||
.filter({ hasText: 'Close' })
|
||||
this.dismissButton = this.successDialog.locator(
|
||||
'button.p-dialog-close-button'
|
||||
)
|
||||
// The icon-only X carries an aria-label, while the footer Close button
|
||||
// is named by its text — getByLabel only matches the former.
|
||||
this.dismissButton = this.successDialog.getByLabel('Close', {
|
||||
exact: true
|
||||
})
|
||||
this.exitBuilderButton = this.successDialog.getByRole('button', {
|
||||
name: 'Exit builder'
|
||||
})
|
||||
|
||||
150
browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts
Normal file
150
browser_tests/fixtures/helpers/CloudWorkspaceMockHelper.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
import type { Member } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import {
|
||||
DEFAULT_TEAM_MEMBERS,
|
||||
TEAM_BILLING_STATUS,
|
||||
TEAM_PRO_PLAN,
|
||||
TEAM_WORKSPACE,
|
||||
WORKSPACE_FEATURE_FLAG
|
||||
} from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
interface RoleChangeRequest {
|
||||
url: string
|
||||
role: string
|
||||
}
|
||||
|
||||
interface MemberMockState {
|
||||
members: Member[]
|
||||
patches: RoleChangeRequest[]
|
||||
}
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
/**
|
||||
* Boots the cloud app against fully mocked workspace + billing endpoints so
|
||||
* member/role specs can drive a raw `page` (the `comfyPage` fixture would try
|
||||
* to reach the OSS devtools backend during setup).
|
||||
*
|
||||
* Returns the mutable mock state: `members` reflects PATCH-applied roles and
|
||||
* `patches` records every role-change request for assertion.
|
||||
*/
|
||||
export class CloudWorkspaceMockHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
async setup(
|
||||
members: Member[] = DEFAULT_TEAM_MEMBERS
|
||||
): Promise<MemberMockState> {
|
||||
const state = await this.mockBoot(members)
|
||||
await new CloudAuthHelper(this.page).mockAuth()
|
||||
await this.page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
localStorage.setItem('Comfy.Workspace.LastWorkspaceId', 'ws-team')
|
||||
})
|
||||
return state
|
||||
}
|
||||
|
||||
private async mockBoot(members: Member[]): Promise<MemberMockState> {
|
||||
const state: MemberMockState = {
|
||||
members: members.map((m) => ({ ...m })),
|
||||
patches: []
|
||||
}
|
||||
const { page } = this
|
||||
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(jsonRoute(WORKSPACE_FEATURE_FLAG))
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// A non-empty settings payload with TutorialCompleted marks the user as
|
||||
// returning, so the new-user Templates dialog never auto-opens to block the
|
||||
// Settings button. Errors tab off suppresses the model-folder 401 toast.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.RightSidePanel.ShowErrorsTab': false
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(jsonRoute({ workspaces: [TEAM_WORKSPACE] }))
|
||||
)
|
||||
|
||||
await page.route('**/api/workspace/members**', (route: Route) => {
|
||||
const request = route.request()
|
||||
if (request.method() === 'PATCH') {
|
||||
const url = request.url()
|
||||
const id = url.match(/\/api\/workspace\/members\/([^/?]+)/)?.[1]
|
||||
const { role } = request.postDataJSON() as { role: Member['role'] }
|
||||
state.patches.push({ url, role })
|
||||
const member = state.members.find((m) => m.id === id)
|
||||
if (member) member.role = role
|
||||
// Echo the updated row like the real BE; the store merges only the role
|
||||
// locally, so the response body shape is not load-bearing.
|
||||
return route.fulfill(jsonRoute(member))
|
||||
}
|
||||
return route.fulfill(
|
||||
jsonRoute({
|
||||
members: state.members,
|
||||
pagination: { offset: 0, limit: 50, total: state.members.length }
|
||||
})
|
||||
)
|
||||
})
|
||||
await page.route('**/api/workspace/invites', (r) =>
|
||||
r.fulfill(jsonRoute({ invites: [] }))
|
||||
)
|
||||
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(jsonRoute(TEAM_BILLING_STATUS))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
amount_micros: 6000,
|
||||
currency: 'usd',
|
||||
effective_balance_micros: 6000,
|
||||
cloud_credit_balance_micros: 5000,
|
||||
prepaid_balance_micros: 1000
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ current_plan_slug: 'pro-monthly', plans: [TEAM_PRO_PLAN] })
|
||||
)
|
||||
)
|
||||
|
||||
return state
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,22 @@ export class ExecutionHelper {
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `execution_interrupted` WS event (user-initiated stop). */
|
||||
executionInterrupted(jobId: string, nodeId: string): void {
|
||||
this.requireWs().send(
|
||||
JSON.stringify({
|
||||
type: 'execution_interrupted',
|
||||
data: {
|
||||
prompt_id: jobId,
|
||||
timestamp: Date.now(),
|
||||
node_id: nodeId,
|
||||
node_type: 'Unknown',
|
||||
executed: []
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/** Send `progress` WS event. */
|
||||
progress(jobId: string, nodeId: string, value: number, max: number): void {
|
||||
this.requireWs().send(
|
||||
|
||||
@@ -38,7 +38,6 @@ export const TestIds = {
|
||||
settings: 'settings-dialog',
|
||||
settingsContainer: 'settings-container',
|
||||
settingsTabAbout: 'settings-tab-about',
|
||||
confirm: 'confirm-dialog',
|
||||
errorOverlay: 'error-overlay',
|
||||
errorOverlaySeeErrors: 'error-overlay-see-errors',
|
||||
errorOverlayDismiss: 'error-overlay-dismiss',
|
||||
@@ -113,6 +112,10 @@ export const TestIds = {
|
||||
root: 'properties-panel',
|
||||
errorsTab: 'panel-tab-errors'
|
||||
},
|
||||
assets: {
|
||||
browserModal: 'asset-browser-modal',
|
||||
card: 'asset-card'
|
||||
},
|
||||
subgraphEditor: {
|
||||
hiddenSection: 'subgraph-editor-hidden-section',
|
||||
iconEye: 'icon-eye',
|
||||
|
||||
34
browser_tests/fixtures/utils/cloudBillingMocks.ts
Normal file
34
browser_tests/fixtures/utils/cloudBillingMocks.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
/**
|
||||
* Minimal valid billing shapes so the billing facade resolves while a
|
||||
* subscription dialog mounts. Active personal sub with zero balance.
|
||||
*/
|
||||
export async function mockBilling(page: Page) {
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
is_active: true,
|
||||
has_funds: true,
|
||||
subscription_status: 'active',
|
||||
subscription_tier: 'pro',
|
||||
subscription_duration: 'MONTHLY',
|
||||
billing_status: 'paid'
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(jsonRoute({ plans: [] }))
|
||||
)
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(jsonRoute({ is_active: false }))
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(jsonRoute({ amount_micros: 0, currency: 'usd' }))
|
||||
)
|
||||
}
|
||||
64
browser_tests/fixtures/utils/cloudBootMocks.ts
Normal file
64
browser_tests/fixtures/utils/cloudBootMocks.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
interface CloudBootOptions {
|
||||
/** Remote-config payload for `/api/features` (enables the flags under test). */
|
||||
features: RemoteConfig
|
||||
/** Body for `/api/settings` (defaults to `{}`). */
|
||||
settings?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the core endpoints the cloud app hits on boot so a raw `page` reaches the
|
||||
* working app without falling through to the OSS devtools backend. Specs layer
|
||||
* their own feature- or flow-specific routes on top.
|
||||
*/
|
||||
export async function mockCloudBoot(
|
||||
page: Page,
|
||||
{ features, settings = {} }: CloudBootOptions
|
||||
) {
|
||||
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(features)))
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/user', (r) =>
|
||||
r.fulfill(jsonRoute({ status: 'active' }))
|
||||
)
|
||||
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute(settings)))
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock Firebase auth and pre-select the e2e user so the cloud app boots
|
||||
* signed-in. The signed-in email (`e2e@test.comfy.org`) is what the
|
||||
* original-owner gate matches against the members self-row.
|
||||
*/
|
||||
export async function bootCloud(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
}
|
||||
12
browser_tests/fixtures/utils/jsonRoute.ts
Normal file
12
browser_tests/fixtures/utils/jsonRoute.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Build a 200 JSON body for `route.fulfill()`. Generic so callers can type the
|
||||
* payload (e.g. `jsonRoute({ ... } satisfies RemoteConfig)`) and catch contract
|
||||
* drift against the real API shape.
|
||||
*/
|
||||
export function jsonRoute<T>(body: T) {
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
68
browser_tests/fixtures/utils/workspaceMocks.ts
Normal file
68
browser_tests/fixtures/utils/workspaceMocks.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type {
|
||||
Member,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
|
||||
export function workspace(
|
||||
type: 'personal' | 'team',
|
||||
role: 'owner' | 'member'
|
||||
): WorkspaceWithRole {
|
||||
return {
|
||||
id: `ws-${type}`,
|
||||
name: type === 'team' ? 'My Team' : 'Personal Workspace',
|
||||
type,
|
||||
role,
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z'
|
||||
}
|
||||
}
|
||||
|
||||
export function member(
|
||||
overrides: Partial<Member> & Pick<Member, 'email' | 'role'>
|
||||
): Member {
|
||||
return {
|
||||
id: `user-${overrides.email}`,
|
||||
name: overrides.email,
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
is_original_owner: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub the workspace resolution + members list so the cloud app boots into the
|
||||
* given workspace with the given roster (drives the original-owner gate).
|
||||
*/
|
||||
export async function mockWorkspace(
|
||||
page: Page,
|
||||
ws: WorkspaceWithRole,
|
||||
members: Member[]
|
||||
) {
|
||||
await page.route('**/api/workspaces', async (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
await route.fulfill(jsonRoute({ workspaces: [ws] }))
|
||||
})
|
||||
await page.route('**/api/auth/token', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
workspace: { id: ws.id, name: ws.name, type: ws.type },
|
||||
role: ws.role,
|
||||
permissions: []
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/api/workspace/members**', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
members,
|
||||
pagination: { offset: 0, limit: 50, total: members.length }
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
194
browser_tests/tests/billingFacadeConsumers.spec.ts
Normal file
194
browser_tests/tests/billingFacadeConsumers.spec.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
BillingBalanceResponse,
|
||||
BillingStatusResponse
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
/**
|
||||
* Billing facade consumers — FE-933 (B3) regression.
|
||||
*
|
||||
* The repointed surfaces (avatar popover tier badge / balance, free-tier
|
||||
* dialog renewal date) must keep rendering from `useBillingContext`. The facade
|
||||
* selects its backend by flag: `team_workspaces_enabled: false` routes through
|
||||
* the legacy `/customers/*` endpoints, while `true` routes a personal workspace
|
||||
* through the workspace `/api/billing/*` endpoints. Both shapes are mocked here.
|
||||
* Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
* against fully mocked endpoints — same pattern as creditsTile.spec.ts.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
// The workspace `/api/billing/status` shape mirrors the legacy subscription
|
||||
// status; map the fields so a single test fixture drives both backends.
|
||||
const toWorkspaceStatus = (
|
||||
s: CloudSubscriptionStatusResponse
|
||||
): BillingStatusResponse => ({
|
||||
is_active: s.is_active ?? false,
|
||||
subscription_tier: s.subscription_tier ?? undefined,
|
||||
subscription_duration: s.subscription_duration ?? undefined,
|
||||
renewal_date: s.renewal_date ?? undefined,
|
||||
cancel_at: s.end_date ?? undefined,
|
||||
has_funds: s.has_fund ?? true
|
||||
})
|
||||
|
||||
const mockBalance: BillingBalanceResponse = {
|
||||
amount_micros: 6000, // -> 12,660 credits
|
||||
currency: 'usd',
|
||||
effective_balance_micros: 6000
|
||||
}
|
||||
|
||||
async function mockCloudBoot(
|
||||
page: Page,
|
||||
subscriptionStatus: CloudSubscriptionStatusResponse,
|
||||
remoteConfig: RemoteConfig = { team_workspaces_enabled: false }
|
||||
) {
|
||||
await page.route('**/api/features', (r) => r.fulfill(jsonRoute(remoteConfig)))
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// TutorialCompleted suppresses the new-user template browser, whose modal
|
||||
// overlay would otherwise intercept clicks on the topbar.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: 'Personal Workspace',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Legacy backend (team_workspaces_enabled: false).
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(jsonRoute(subscriptionStatus))
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(jsonRoute(mockBalance))
|
||||
)
|
||||
|
||||
// Workspace backend (team_workspaces_enabled: true) — a personal workspace
|
||||
// now routes through `/api/billing/*`.
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(jsonRoute(toWorkspaceStatus(subscriptionStatus)))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(jsonRoute(mockBalance))
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(jsonRoute({ plans: [] }))
|
||||
)
|
||||
}
|
||||
|
||||
async function bootApp(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Billing facade consumers (FE-933)', { tag: '@cloud' }, () => {
|
||||
test('avatar popover renders tier badge and balance from the facade', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page, {
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-02-20T10:00:00Z',
|
||||
end_date: null
|
||||
})
|
||||
await bootApp(page)
|
||||
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
const popover = page.locator('.current-user-popover')
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
await expect(popover.getByText('Pro', { exact: true })).toBeVisible()
|
||||
await expect(popover.getByText('12,660')).toBeVisible()
|
||||
await expect(popover.getByTestId('add-credits-button')).toBeVisible()
|
||||
})
|
||||
|
||||
test('free-tier dialog shows the renewal date from the facade', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
// Boots with team workspaces enabled (production shape); the facade routes a
|
||||
// personal workspace through the workspace `/api/billing/*` endpoints. With
|
||||
// subscription gating on, an inactive FREE user gets the "Subscribe to run"
|
||||
// button, which opens the free-tier dialog on click. (refreshRemoteConfig
|
||||
// overwrites window.__CONFIG__ from /api/features, so the flags must come
|
||||
// from the features mock, not an init script.)
|
||||
await mockCloudBoot(
|
||||
page,
|
||||
{
|
||||
is_active: false,
|
||||
subscription_tier: 'FREE',
|
||||
subscription_duration: 'MONTHLY',
|
||||
// 10:00Z keeps the en-US calendar date stable across CI timezones.
|
||||
renewal_date: '2099-02-20T10:00:00Z',
|
||||
end_date: null
|
||||
},
|
||||
{ team_workspaces_enabled: true, subscription_required: true }
|
||||
)
|
||||
await bootApp(page)
|
||||
|
||||
await page.getByTestId('subscribe-to-run-button').click()
|
||||
|
||||
// T5: the dialog must source the date from facade renewalDate — when this
|
||||
// line read the legacy store it silently vanished for team users.
|
||||
await expect(
|
||||
page.getByText('Your credits refresh on Feb 20, 2099.')
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -223,4 +223,23 @@ test.describe('Bottom Panel Shortcuts', { tag: '@ui' }, () => {
|
||||
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
|
||||
})
|
||||
|
||||
test('should focus keybindings search when opening manage shortcuts', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { bottomPanel } = comfyPage
|
||||
|
||||
await bottomPanel.keyboardShortcutsButton.click()
|
||||
await bottomPanel.shortcuts.manageButton.click()
|
||||
|
||||
await expect(comfyPage.settingDialog.root).toBeVisible()
|
||||
await expect(comfyPage.settingDialog.category('Keybinding')).toBeVisible()
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByPlaceholder('Search Keybindings...')
|
||||
).toBeFocused()
|
||||
await expect(
|
||||
comfyPage.page.getByPlaceholder('Search Settings...')
|
||||
).not.toBeFocused()
|
||||
})
|
||||
})
|
||||
|
||||
61
browser_tests/tests/browseModelAssets.spec.ts
Normal file
61
browser_tests/tests/browseModelAssets.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { Asset } from '@comfyorg/ingest-types'
|
||||
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
|
||||
import { STABLE_CHECKPOINT } from '@e2e/fixtures/data/assetFixtures'
|
||||
|
||||
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT]
|
||||
|
||||
const test = createCloudAssetsFixture(CLOUD_ASSETS)
|
||||
|
||||
test.describe('Browse Model Assets - Use button', { tag: '@cloud' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test('Use button ghost-places a loader populated with the model', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseModelAssets')
|
||||
|
||||
const modal = comfyPage.page.locator(
|
||||
'[data-component-id="AssetBrowserModal"]'
|
||||
)
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
const card = comfyPage.page.locator(
|
||||
`[data-component-id="AssetCard"][data-asset-id="${STABLE_CHECKPOINT.id}"]`
|
||||
)
|
||||
await expect(card).toBeVisible()
|
||||
await card.getByRole('button', { name: 'Use' }).click()
|
||||
|
||||
// Dialog closes and the ghost is armed; the node is not placed until the
|
||||
// user clicks the canvas.
|
||||
await expect(modal).toBeHidden()
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
|
||||
.toBe(0)
|
||||
|
||||
const canvasBox = (await comfyPage.canvas.boundingBox())!
|
||||
await comfyPage.canvas.click({
|
||||
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(loader).toBeDefined()
|
||||
const widget = await loader.getWidgetByName('ckpt_name')
|
||||
expect(await widget.getValue()).toBe(STABLE_CHECKPOINT.name)
|
||||
})
|
||||
})
|
||||
99
browser_tests/tests/cloud-asset-promoted-widget.spec.ts
Normal file
99
browser_tests/tests/cloud-asset-promoted-widget.spec.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
assetRequestIncludesTag,
|
||||
createCloudAssetsFixture
|
||||
} from '@e2e/fixtures/assetApiFixture'
|
||||
import {
|
||||
STABLE_CHECKPOINT,
|
||||
STABLE_CHECKPOINT_2
|
||||
} from '@e2e/fixtures/data/assetFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const WORKFLOW = 'missing/missing_model_promoted_widget'
|
||||
const HOST_NODE_ID = 2
|
||||
const WIDGET_NAME = 'ckpt_name'
|
||||
const SELECTED_MODEL = STABLE_CHECKPOINT_2.name
|
||||
|
||||
const test = createCloudAssetsFixture([STABLE_CHECKPOINT, STABLE_CHECKPOINT_2])
|
||||
|
||||
interface WidgetSnapshot {
|
||||
type: string
|
||||
value: string
|
||||
hasLayout: boolean
|
||||
}
|
||||
|
||||
async function getHostWidgetSnapshot(page: Page): Promise<WidgetSnapshot> {
|
||||
return await page.evaluate(
|
||||
({ nodeId, widgetName }) => {
|
||||
const node = window.app!.graph.getNodeById(nodeId)
|
||||
const widget = node?.widgets?.find((widget) => widget.name === widgetName)
|
||||
|
||||
return {
|
||||
type: widget?.type ?? '',
|
||||
value: String(widget?.value ?? ''),
|
||||
hasLayout: widget?.last_y != null
|
||||
}
|
||||
},
|
||||
{ nodeId: HOST_NODE_ID, widgetName: WIDGET_NAME }
|
||||
)
|
||||
}
|
||||
|
||||
test.describe(
|
||||
'Promoted subgraph asset widgets',
|
||||
{ tag: ['@cloud', '@canvas', '@widget'] },
|
||||
() => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test('legacy asset browser selection updates the promoted host widget value', async ({
|
||||
cloudAssetRequests,
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
|
||||
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
cloudAssetRequests.some((url) =>
|
||||
assetRequestIncludesTag(url, 'checkpoints')
|
||||
),
|
||||
{ timeout: 10_000 }
|
||||
)
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(() => getHostWidgetSnapshot(comfyPage.page))
|
||||
.toMatchObject({
|
||||
type: 'asset',
|
||||
hasLayout: true
|
||||
})
|
||||
const initialWidget = await getHostWidgetSnapshot(comfyPage.page)
|
||||
expect(initialWidget.value).not.toBe(SELECTED_MODEL)
|
||||
|
||||
const hostNode = await comfyPage.nodeOps.getNodeRefById(HOST_NODE_ID)
|
||||
await hostNode.centerOnNode()
|
||||
const promotedWidget = await hostNode.getWidgetByName(WIDGET_NAME)
|
||||
await promotedWidget.click()
|
||||
|
||||
const modal = comfyPage.page.getByTestId(TestIds.assets.browserModal)
|
||||
await expect(modal).toBeVisible()
|
||||
|
||||
const assetCard = modal
|
||||
.getByTestId(TestIds.assets.card)
|
||||
.filter({ hasText: SELECTED_MODEL })
|
||||
.first()
|
||||
await expect(assetCard).toBeVisible()
|
||||
await assetCard.getByRole('button', { name: 'Use' }).click()
|
||||
|
||||
await expect(modal).toBeHidden()
|
||||
await expect
|
||||
.poll(() =>
|
||||
getHostWidgetSnapshot(comfyPage.page).then((widget) => widget.value)
|
||||
)
|
||||
.toBe(SELECTED_MODEL)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
const SHARE_AUTH_STORAGE_KEY = 'Comfy.PreservedQuery.share_auth'
|
||||
|
||||
/**
|
||||
* Cloud distribution E2E tests.
|
||||
*
|
||||
@@ -14,15 +17,31 @@ test.describe('Cloud distribution UI', { tag: '@cloud' }, () => {
|
||||
test('cloud build redirects unauthenticated users to login', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto('http://localhost:8188')
|
||||
await page.goto(APP_URL)
|
||||
// Cloud build has an auth guard that redirects to /cloud/login.
|
||||
// This route only exists in the cloud distribution — it's tree-shaken
|
||||
// in the OSS build. Its presence confirms the cloud build is active.
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('preserves share auth attribution before redirecting logged-out users', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(new URL('/?share=abc', APP_URL).toString())
|
||||
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(key) => sessionStorage.getItem(key),
|
||||
SHARE_AUTH_STORAGE_KEY
|
||||
)
|
||||
)
|
||||
.toBe(JSON.stringify({ share: 'abc' }))
|
||||
})
|
||||
|
||||
test('cloud login page renders sign-in options', async ({ page }) => {
|
||||
await page.goto('http://localhost:8188')
|
||||
await page.goto(APP_URL)
|
||||
await expect(page).toHaveURL(/\/cloud\/login/, { timeout: 10_000 })
|
||||
// Verify cloud-specific login UI is rendered
|
||||
await expect(page.getByRole('button', { name: /google/i })).toBeVisible()
|
||||
|
||||
@@ -4,8 +4,7 @@ import type { Page } from '@playwright/test'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
|
||||
/**
|
||||
* getSurveyCompletedStatus fails safe: a transient 401 on `/` must not bounce a
|
||||
@@ -16,51 +15,12 @@ import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
function jsonRoute(body: unknown) {
|
||||
return {
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
|
||||
async function mockCloudBoot(page: Page) {
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only). Enable the survey so the gate is actually live.
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ onboarding_survey_enabled: true } satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// Cloud user status (getUserCloudStatus) — an active account so the gate
|
||||
// proceeds to the survey check instead of bouncing back to login.
|
||||
await page.route('**/api/user', (r) =>
|
||||
r.fulfill(jsonRoute({ status: 'active' }))
|
||||
)
|
||||
await page.route('**/api/settings', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
}
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `onboardingSurveyEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only). Enable the survey so the gate is actually live.
|
||||
const BOOT_FEATURES = {
|
||||
onboarding_survey_enabled: true
|
||||
} satisfies RemoteConfig
|
||||
|
||||
// Genuine "not completed": the cloud backend returns 404 for a survey key that
|
||||
// was never stored. This is the response that must still route to the survey.
|
||||
@@ -89,22 +49,13 @@ async function mockSurveyTransient401(page: Page) {
|
||||
)
|
||||
}
|
||||
|
||||
async function bootCloud(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
// Pre-select the mock user to skip the user-select screen.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
|
||||
test('a transient 401 on the survey check does not bounce a working user to the survey', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page)
|
||||
await mockCloudBoot(page, { features: BOOT_FEATURES })
|
||||
await mockSurveyTransient401(page)
|
||||
await bootCloud(page)
|
||||
|
||||
@@ -122,9 +73,9 @@ test.describe('Cloud onboarding survey gate', { tag: '@cloud' }, () => {
|
||||
test('a not-completed (404) user landing on / is routed to the survey', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
test.slow()
|
||||
|
||||
await mockCloudBoot(page)
|
||||
await mockCloudBoot(page, { features: BOOT_FEATURES })
|
||||
await mockSurveyNotCompleted(page)
|
||||
await bootCloud(page)
|
||||
|
||||
|
||||
181
browser_tests/tests/currentUserPopoverCredits.spec.ts
Normal file
181
browser_tests/tests/currentUserPopoverCredits.spec.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { CloudSubscriptionStatusResponse } from '@/platform/cloud/subscription/composables/useSubscription'
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
BillingStatusResponse,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
|
||||
import type { operations } from '@/types/comfyRegistryTypes'
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
type CustomerBalanceResponse = NonNullable<
|
||||
operations['GetCustomerBalance']['responses']['200']['content']['application/json']
|
||||
>
|
||||
|
||||
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
|
||||
const FUTURE_DATE = '2099-01-01T00:00:00Z'
|
||||
|
||||
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
|
||||
|
||||
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: PERSONAL_WORKSPACE_NAME,
|
||||
type: 'personal',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
joined_at: '2026-01-01T00:00:00Z',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const mockTokenResponse: WorkspaceTokenResponse = {
|
||||
token: 'mock-workspace-token',
|
||||
expires_at: FUTURE_DATE,
|
||||
workspace: {
|
||||
id: 'ws-personal',
|
||||
name: PERSONAL_WORKSPACE_NAME,
|
||||
type: 'personal'
|
||||
},
|
||||
role: 'owner',
|
||||
permissions: []
|
||||
}
|
||||
|
||||
// Cancelled but still active: `end_date` set (cancelled) while `is_active` is
|
||||
// true. A personal owner in this state sees BOTH "Add credits" and "Resubscribe"
|
||||
// in the credits row.
|
||||
const mockSubscriptionStatus: CloudSubscriptionStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_id: 'sub_e2e',
|
||||
renewal_date: FUTURE_DATE,
|
||||
end_date: FUTURE_DATE
|
||||
}
|
||||
|
||||
// With team workspaces enabled, the facade routes a personal workspace through
|
||||
// `/api/billing/*`. The cancelled-but-active state maps to `is_active: true`
|
||||
// with `subscription_status: 'canceled'`; a paid tier keeps "Add credits"
|
||||
// visible (free tier would swap it for "Upgrade to add credits").
|
||||
const mockBillingStatus: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_status: 'canceled',
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
has_funds: true,
|
||||
cancel_at: FUTURE_DATE,
|
||||
renewal_date: FUTURE_DATE
|
||||
}
|
||||
|
||||
// ~6.3M credits — a 7-digit balance is what pushes the second action button out
|
||||
// of the popover before the fix.
|
||||
const mockBalance: CustomerBalanceResponse = {
|
||||
amount_micros: 3_000_000,
|
||||
effective_balance_micros: 3_000_000,
|
||||
currency: 'usd'
|
||||
}
|
||||
|
||||
const test = comfyPageFixture.extend({
|
||||
page: async ({ page }, use) => {
|
||||
await page.route('**/api/features', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockRemoteConfig)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/workspaces', async (route) => {
|
||||
if (route.request().method() !== 'GET') {
|
||||
await route.fallback()
|
||||
return
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockListWorkspacesResponse)
|
||||
})
|
||||
})
|
||||
|
||||
await page.route('**/api/auth/token', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockTokenResponse)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/customers/cloud-subscription-status', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockSubscriptionStatus)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/customers/balance', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBalance)
|
||||
})
|
||||
)
|
||||
|
||||
// Flag-on (team workspaces enabled) routes a personal workspace through the
|
||||
// workspace billing endpoints, so the popover sources its data from here.
|
||||
await page.route('**/api/billing/status', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBillingStatus)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/billing/balance', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(mockBalance)
|
||||
})
|
||||
)
|
||||
|
||||
await page.route('**/api/billing/plans', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ plans: [] })
|
||||
})
|
||||
)
|
||||
|
||||
await use(page)
|
||||
}
|
||||
})
|
||||
|
||||
test.describe('Current user popover credits row', { tag: '@cloud' }, () => {
|
||||
test('keeps both action buttons inside the popover when cancelled but active', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const page = comfyPage.page
|
||||
|
||||
await comfyPage.toast.closeToasts()
|
||||
await page.getByRole('button', { name: 'Current user' }).click()
|
||||
|
||||
const popover = page.locator('.current-user-popover')
|
||||
await expect(popover).toBeVisible()
|
||||
|
||||
const addCredits = page.getByTestId('add-credits-button')
|
||||
const resubscribe = page.getByRole('button', { name: 'Resubscribe' })
|
||||
await expect(addCredits).toBeVisible()
|
||||
await expect(resubscribe).toBeVisible()
|
||||
|
||||
const popoverBox = await popover.boundingBox()
|
||||
const resubscribeBox = await resubscribe.boundingBox()
|
||||
expect(popoverBox).not.toBeNull()
|
||||
expect(resubscribeBox).not.toBeNull()
|
||||
|
||||
const popoverRight = popoverBox!.x + popoverBox!.width
|
||||
const resubscribeRight = resubscribeBox!.x + resubscribeBox!.width
|
||||
expect(resubscribeRight).toBeLessThanOrEqual(popoverRight)
|
||||
})
|
||||
})
|
||||
264
browser_tests/tests/dialogs/creditsTile.spec.ts
Normal file
264
browser_tests/tests/dialogs/creditsTile.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type { BillingStatusResponse } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockSystemStats } from '@e2e/fixtures/data/systemStats'
|
||||
import { CloudAuthHelper } from '@e2e/fixtures/helpers/CloudAuthHelper'
|
||||
|
||||
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
|
||||
// devtools backend during setup.
|
||||
|
||||
/**
|
||||
* Credits tile (Settings ▸ Workspace ▸ Plan & Credits) — DES-247 / FE-964.
|
||||
*
|
||||
* The credits tile only lives inside the authenticated cloud app, which the
|
||||
* shared `comfyPage` fixture can't boot (it expects the OSS devtools backend).
|
||||
* Instead this drives a raw page: mock Firebase auth + every boot endpoint so
|
||||
* the cloud app initializes against fully stubbed data. With team workspaces
|
||||
* enabled the facade routes a personal workspace through the workspace
|
||||
* `/api/billing/*` endpoints (mocked with an active Pro subscription); the
|
||||
* legacy `/customers/*` shapes are mocked too for the flag-off path. The tile
|
||||
* should then render its total / progress bar / monthly+additional breakdown /
|
||||
* add-credits.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
const jsonRoute = (body: unknown) => ({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
// Legacy `/customers/balance` and workspace `/api/billing/balance` share the
|
||||
// same response shape, so one body fulfills both endpoints.
|
||||
const balanceRoute = (balance: {
|
||||
amount: number
|
||||
monthly: number
|
||||
prepaid: number
|
||||
}) =>
|
||||
jsonRoute({
|
||||
amount_micros: balance.amount,
|
||||
currency: 'usd',
|
||||
effective_balance_micros: balance.amount,
|
||||
cloud_credit_balance_micros: balance.monthly,
|
||||
prepaid_balance_micros: balance.prepaid
|
||||
})
|
||||
|
||||
// 6000 -> 12,660 total; 5000 -> 10,550 monthly remaining; 1000 -> 2,110 extra.
|
||||
const DEFAULT_BALANCE = { amount: 6000, monthly: 5000, prepaid: 1000 }
|
||||
|
||||
const mockBillingStatus: BillingStatusResponse = {
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-02-20T12:00:00Z',
|
||||
has_funds: true
|
||||
}
|
||||
|
||||
async function mockCloudBoot(page: Page) {
|
||||
// Frontend-origin boot endpoints (proxied to the backend in production).
|
||||
// `/api/features` is the remote-config source: production builds resolve
|
||||
// `teamWorkspacesEnabled` from it (the `ff:` localStorage override is
|
||||
// dev-only), and the flag gates the Workspace settings panel.
|
||||
await page.route('**/api/features', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({ team_workspaces_enabled: true } satisfies RemoteConfig)
|
||||
)
|
||||
)
|
||||
await page.route('**/api/system_stats', (r) =>
|
||||
r.fulfill(jsonRoute(mockSystemStats))
|
||||
)
|
||||
// Include the mock user so the multi-user select screen auto-selects it
|
||||
// (paired with the `Comfy.userId` localStorage seed below).
|
||||
await page.route('**/api/users', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
storage: 'server',
|
||||
migrated: true,
|
||||
users: { 'test-user-e2e': 'E2E Test User' }
|
||||
})
|
||||
)
|
||||
)
|
||||
// Non-empty settings with a completed tutorial keep the cloud app from
|
||||
// booting as a new user, whose Workflow Templates dialog would otherwise
|
||||
// auto-open and intercept the Settings click behind its modal backdrop.
|
||||
await page.route('**/api/settings', (r) =>
|
||||
r.fulfill(jsonRoute({ 'Comfy.TutorialCompleted': true }))
|
||||
)
|
||||
await page.route('**/api/userdata**', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/extensions', (r) => r.fulfill(jsonRoute([])))
|
||||
await page.route('**/api/object_info', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/global_subgraphs', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/i18n', (r) => r.fulfill(jsonRoute({})))
|
||||
await page.route('**/api/auth/session', (r) =>
|
||||
r.fulfill(jsonRoute({ token: 'mock-workspace-token' }))
|
||||
)
|
||||
await page.route('**/releases**', (r) => r.fulfill(jsonRoute([])))
|
||||
|
||||
// Single personal workspace.
|
||||
await page.route('**/api/workspaces', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
workspaces: [
|
||||
{
|
||||
id: 'ws-personal',
|
||||
name: 'Personal Workspace',
|
||||
type: 'personal',
|
||||
role: 'owner'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Legacy billing (flag-off path, api.comfy.org/customers/*).
|
||||
await page.route('**/customers/cloud-subscription-status', (r) =>
|
||||
r.fulfill(
|
||||
jsonRoute({
|
||||
is_active: true,
|
||||
subscription_tier: 'PRO',
|
||||
subscription_duration: 'MONTHLY',
|
||||
renewal_date: '2099-02-20T12:00:00Z',
|
||||
end_date: null
|
||||
})
|
||||
)
|
||||
)
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(balanceRoute(DEFAULT_BALANCE))
|
||||
)
|
||||
|
||||
// Workspace billing (flag-on path) — a personal workspace now routes through
|
||||
// `/api/billing/*`.
|
||||
await page.route('**/api/billing/status', (r) =>
|
||||
r.fulfill(jsonRoute(mockBillingStatus))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(balanceRoute(DEFAULT_BALANCE))
|
||||
)
|
||||
await page.route('**/api/billing/plans', (r) =>
|
||||
r.fulfill(jsonRoute({ plans: [] }))
|
||||
)
|
||||
}
|
||||
|
||||
async function mockBalance(
|
||||
page: Page,
|
||||
balance: { amount: number; monthly: number; prepaid: number }
|
||||
) {
|
||||
await page.unroute('**/customers/balance')
|
||||
await page.unroute('**/api/billing/balance')
|
||||
await page.route('**/customers/balance', (r) =>
|
||||
r.fulfill(balanceRoute(balance))
|
||||
)
|
||||
await page.route('**/api/billing/balance', (r) =>
|
||||
r.fulfill(balanceRoute(balance))
|
||||
)
|
||||
}
|
||||
|
||||
/** Boots the mocked cloud app and opens Settings ▸ Workspace ▸ Plan & Credits. */
|
||||
async function openPlanAndCredits(page: Page) {
|
||||
const auth = new CloudAuthHelper(page)
|
||||
await auth.mockAuth()
|
||||
|
||||
// Pre-select the mock user to skip the user-select screen.
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('Comfy.userId', 'test-user-e2e')
|
||||
})
|
||||
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
// Open Settings ▸ Workspace.
|
||||
await page
|
||||
.getByRole('button', { name: /^Settings/ })
|
||||
.first()
|
||||
.click()
|
||||
const dialog = page.getByTestId('settings-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
|
||||
|
||||
return dialog.getByRole('main')
|
||||
}
|
||||
|
||||
test.describe('Credits tile (Plan & Credits)', { tag: '@cloud' }, () => {
|
||||
test('renders the unified tile with breakdown and add-credits', async ({
|
||||
page
|
||||
}) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page)
|
||||
|
||||
const content = await openPlanAndCredits(page)
|
||||
|
||||
// Total + remaining suffix (Pro monthly allowance = 21,100; remaining
|
||||
// 10,550 -> used 10,550).
|
||||
await expect(content.getByText('Total credits')).toBeVisible()
|
||||
await expect(content.getByText('12,660')).toBeVisible()
|
||||
|
||||
// Monthly usage bar header + used / left-of-total labels.
|
||||
await expect(content.getByText('Monthly', { exact: true })).toBeVisible()
|
||||
await expect(content.getByText(/Refills Feb/)).toBeVisible()
|
||||
await expect(content.getByText('10,550 used')).toBeVisible()
|
||||
await expect(content.getByText('10,550 left of 21,100')).toBeVisible()
|
||||
|
||||
// Additional credits row + subtitle.
|
||||
await expect(content.getByText('Additional credits')).toBeVisible()
|
||||
await expect(content.getByText('2,110')).toBeVisible()
|
||||
await expect(content.getByText('Used after monthly runs out')).toBeVisible()
|
||||
|
||||
// Permission-gated add-credits action (personal owner can top up).
|
||||
await expect(
|
||||
content.getByRole('button', { name: 'Add credits' })
|
||||
).toBeVisible()
|
||||
|
||||
// Narrow container (DES-247 responsive variants): drop the used/remaining
|
||||
// labels and the breakdown subtitle, compact the monthly summary numbers.
|
||||
await page.setViewportSize({ width: 360, height: 800 })
|
||||
await expect(content.getByText('10,550 used')).toBeHidden()
|
||||
await expect(content.getByText('remaining', { exact: true })).toBeHidden()
|
||||
await expect(content.getByText('Used after monthly runs out')).toBeHidden()
|
||||
await expect(content.getByText('10,550 left of 21,100')).toBeHidden()
|
||||
await expect(content.getByText('11K left of 21K')).toBeVisible()
|
||||
})
|
||||
|
||||
test('renders the depleted-credit empty states', async ({ page }) => {
|
||||
test.setTimeout(60_000)
|
||||
|
||||
await mockCloudBoot(page)
|
||||
// Monthly allowance fully spent; additional credits keep generation going.
|
||||
await mockBalance(page, { amount: 1000, monthly: 0, prepaid: 1000 })
|
||||
|
||||
const content = await openPlanAndCredits(page)
|
||||
|
||||
// 0-monthly state: depletion notice + IN USE badge on additional credits.
|
||||
await expect(
|
||||
content.getByText('Monthly credits are used up. Refills Feb 20')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
content.getByText("You're now spending additional credits.")
|
||||
).toBeVisible()
|
||||
await expect(content.getByText('In use')).toBeVisible()
|
||||
await expect(content.getByText('0 left of 21,100')).toBeVisible()
|
||||
|
||||
// Drain the remaining additional credits and refresh the tile: the
|
||||
// out-of-credits notice takes over and the badge drops.
|
||||
await mockBalance(page, { amount: 0, monthly: 0, prepaid: 0 })
|
||||
await content.getByRole('button', { name: 'Refresh credits' }).click()
|
||||
|
||||
await expect(
|
||||
content.getByText("You're out of credits. Credits refill Feb 20")
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
content.getByText('Add more credits to continue generating.')
|
||||
).toBeVisible()
|
||||
await expect(content.getByText('In use')).toBeHidden()
|
||||
await expect(
|
||||
content.getByRole('button', { name: 'Add credits' })
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
264
browser_tests/tests/dialogs/memberRoleChange.spec.ts
Normal file
264
browser_tests/tests/dialogs/memberRoleChange.spec.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { Member } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
CREATOR,
|
||||
MEMBER_JANE,
|
||||
MEMBER_JOHN,
|
||||
VIEWER
|
||||
} from '@e2e/fixtures/data/cloudWorkspace'
|
||||
import { CloudWorkspaceMockHelper } from '@e2e/fixtures/helpers/CloudWorkspaceMockHelper'
|
||||
|
||||
// Drives a raw `page` (not the `comfyPage` fixture) so the cloud app boots
|
||||
// against fully mocked endpoints; `comfyPage` would try to reach the OSS
|
||||
// devtools backend during setup.
|
||||
|
||||
/**
|
||||
* Member role change (Settings ▸ Workspace ▸ Members) — Figma 2993-15512.
|
||||
*
|
||||
* The viewer is a promoted owner (not the workspace creator), so the spec can
|
||||
* distinguish the creator guard from the self guard: the creator row and the
|
||||
* viewer's own row hide the row menu, every other row exposes
|
||||
* "Change role ›" (Owner / Member) plus "Remove member". Promoting a member
|
||||
* sends PATCH /api/workspace/members/:id {role}, flips the Role column,
|
||||
* re-sorts the row under the creator, and the promoted owner stays demotable.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
async function openMembersTab(page: Page): Promise<Locator> {
|
||||
await page.goto(APP_URL)
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: /^Settings/ })
|
||||
.first()
|
||||
.click()
|
||||
const dialog = page.getByTestId('settings-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.locator('nav').getByRole('button', { name: 'Workspace' }).click()
|
||||
|
||||
const content = dialog.getByRole('main')
|
||||
await content.getByRole('tab', { name: /Members/ }).click()
|
||||
await expect(content.getByText('4 of 30 members')).toBeVisible()
|
||||
return content
|
||||
}
|
||||
|
||||
function memberRow(content: Locator, email: string): Locator {
|
||||
return content
|
||||
.locator('div.grid')
|
||||
.filter({ has: content.page().getByText(email, { exact: true }) })
|
||||
}
|
||||
|
||||
function menuButton(row: Locator): Locator {
|
||||
return row.getByRole('button', { name: 'More Options' })
|
||||
}
|
||||
|
||||
// Reka submenus open on real pointer travel or keyboard; Playwright's
|
||||
// synthetic hover doesn't trigger the pointermove handler, so drive the
|
||||
// subtrigger with ArrowRight instead.
|
||||
async function openChangeRoleSubmenu(page: Page) {
|
||||
const trigger = page.getByRole('menuitem', { name: 'Change role' })
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.press('ArrowRight')
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
|
||||
test.describe('Member role change (Members tab)', { tag: '@cloud' }, () => {
|
||||
test.describe.configure({ timeout: 60_000 })
|
||||
|
||||
test('row menus respect creator and self guards', async ({ page }) => {
|
||||
await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
// US8/US9 — no row actions on the creator row (Liz) nor on the viewer's
|
||||
// own row; the two plain members each expose a menu.
|
||||
await expect(
|
||||
menuButton(memberRow(content, MEMBER_JOHN.email))
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
menuButton(memberRow(content, MEMBER_JANE.email))
|
||||
).toBeVisible()
|
||||
await expect(menuButton(memberRow(content, CREATOR.email))).toHaveCount(0)
|
||||
await expect(menuButton(memberRow(content, VIEWER.email))).toHaveCount(0)
|
||||
|
||||
// US1/US12 — the row menu exposes Change role and the FE-768 remove flow.
|
||||
await menuButton(memberRow(content, MEMBER_JANE.email)).click()
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Change role' })
|
||||
).toBeVisible()
|
||||
await page.getByRole('menuitem', { name: 'Remove member' }).click()
|
||||
await expect(page.getByText('Remove this member?')).toBeVisible()
|
||||
})
|
||||
|
||||
test('selecting the current role is a no-op', async ({ page }) => {
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
|
||||
// The current role is a checked radio item so assistive tech can announce
|
||||
// which role is active.
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(
|
||||
page.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
).toHaveAttribute('aria-checked', 'false')
|
||||
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(page.getByRole('heading', { name: /an owner\?/ })).toHaveCount(
|
||||
0
|
||||
)
|
||||
expect(state.patches).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('promote dialog shows the Figma copy and cancelling keeps the role', async ({
|
||||
page
|
||||
}) => {
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText("They'll be able to:")).toBeVisible()
|
||||
await expect(page.getByText('Add additional credits')).toBeVisible()
|
||||
await expect(
|
||||
page.getByText('Manage members, payment methods, and workspace settings')
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Promote and demote other owners (except the workspace creator).'
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toHaveCount(0)
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
expect(state.patches).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('promoting a member re-sorts the row under the creator and stays demotable', async ({
|
||||
page
|
||||
}) => {
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup()
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const emails = content.getByText(/@test\.comfy\.org/)
|
||||
await expect(emails).toHaveText([
|
||||
CREATOR.email,
|
||||
VIEWER.email,
|
||||
MEMBER_JOHN.email,
|
||||
MEMBER_JANE.email
|
||||
])
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Make owner' }).click()
|
||||
|
||||
await expect(page.getByText('Role updated')).toBeVisible()
|
||||
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
|
||||
await expect(emails).toHaveText([
|
||||
CREATOR.email,
|
||||
VIEWER.email,
|
||||
MEMBER_JANE.email,
|
||||
MEMBER_JOHN.email
|
||||
])
|
||||
expect(state.patches).toEqual([
|
||||
{
|
||||
url: expect.stringContaining('/api/workspace/members/u-jane'),
|
||||
role: 'owner'
|
||||
}
|
||||
])
|
||||
|
||||
// The promoted owner keeps its row menu (still demotable).
|
||||
await expect(menuButton(janeRow)).toBeVisible()
|
||||
})
|
||||
|
||||
test('demoting an owner returns them to member', async ({ page }) => {
|
||||
const ownerJane: Member = { ...MEMBER_JANE, role: 'owner' }
|
||||
const state = await new CloudWorkspaceMockHelper(page).setup([
|
||||
CREATOR,
|
||||
VIEWER,
|
||||
ownerJane,
|
||||
MEMBER_JOHN
|
||||
])
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await expect(janeRow.getByText('Owner', { exact: true })).toBeVisible()
|
||||
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Member', exact: true })
|
||||
.click()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Demote Jane to member?' })
|
||||
).toBeVisible()
|
||||
await expect(page.getByText("They'll lose admin access.")).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Demote to member' }).click()
|
||||
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
expect(state.patches).toEqual([
|
||||
{
|
||||
url: expect.stringContaining('/api/workspace/members/u-jane'),
|
||||
role: 'member'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
test('failed role change keeps the dialog open with an error toast', async ({
|
||||
page
|
||||
}) => {
|
||||
await new CloudWorkspaceMockHelper(page).setup()
|
||||
// Override the member route so PATCH fails after boot succeeds.
|
||||
await page.route('**/api/workspace/members/**', (route) =>
|
||||
route.request().method() === 'PATCH'
|
||||
? route.fulfill({ status: 500, body: '{}' })
|
||||
: route.fallback()
|
||||
)
|
||||
const content = await openMembersTab(page)
|
||||
|
||||
const janeRow = memberRow(content, MEMBER_JANE.email)
|
||||
await menuButton(janeRow).click()
|
||||
await openChangeRoleSubmenu(page)
|
||||
await page
|
||||
.getByRole('menuitemradio', { name: 'Owner', exact: true })
|
||||
.click()
|
||||
await page.getByRole('button', { name: 'Make owner' }).click()
|
||||
|
||||
// US10 — error toast, dialog stays open, role unchanged.
|
||||
await expect(page.getByText('Failed to update role')).toBeVisible()
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Make Jane an owner?' })
|
||||
).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click()
|
||||
await expect(janeRow.getByText('Member', { exact: true })).toBeVisible()
|
||||
})
|
||||
})
|
||||
128
browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Normal file
128
browser_tests/tests/dialogs/pricingTableDeepLink.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Page } from '@playwright/test'
|
||||
|
||||
import type { RemoteConfig } from '@/platform/remoteConfig/types'
|
||||
import type {
|
||||
Member,
|
||||
WorkspaceWithRole
|
||||
} from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { mockBilling } from '@e2e/fixtures/utils/cloudBillingMocks'
|
||||
import { bootCloud, mockCloudBoot } from '@e2e/fixtures/utils/cloudBootMocks'
|
||||
import { jsonRoute } from '@e2e/fixtures/utils/jsonRoute'
|
||||
import {
|
||||
member,
|
||||
mockWorkspace,
|
||||
workspace
|
||||
} from '@e2e/fixtures/utils/workspaceMocks'
|
||||
|
||||
/**
|
||||
* The `?pricing=` deep link opens the pricing table on app load, gated to the
|
||||
* original owner (canManageSubscriptionLifecycle). Drives a raw `page` so the
|
||||
* cloud app boots against fully mocked endpoints, like the survey-gate spec.
|
||||
*/
|
||||
const APP_URL = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
|
||||
|
||||
// CloudAuthHelper.mockAuth() signs in as this email; the original-owner gate
|
||||
// matches it against the members self-row.
|
||||
const SELF_EMAIL = 'e2e@test.comfy.org'
|
||||
|
||||
const BOOT_FEATURES = { team_workspaces_enabled: true } satisfies RemoteConfig
|
||||
// Disable the experimental Asset API: with it on (cloud default) the unmocked
|
||||
// asset endpoints 403 and workflow restore throws uncaught, aborting the
|
||||
// GraphCanvas onMounted chain before the deep-link loader.
|
||||
const BOOT_SETTINGS = { 'Comfy.Assets.UseAssetAPI': false }
|
||||
|
||||
// The deep-link loader runs at the tail of GraphCanvas onMounted, so the boot
|
||||
// chain must not throw before it: a missing settings subpath, prompt exec_info,
|
||||
// or queue status each abort that chain.
|
||||
async function mockGraphBootExtras(page: Page) {
|
||||
// Boot only reads these; fall back on any write so an unexpected POST/PUT
|
||||
// surfaces instead of being masked by a blanket 200.
|
||||
await page.route('**/api/settings/**', (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
return route.fulfill(jsonRoute({}))
|
||||
})
|
||||
await page.route('**/api/prompt', (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
return route.fulfill(jsonRoute({ exec_info: { queue_remaining: 0 } }))
|
||||
})
|
||||
await page.route('**/api/queue', (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
return route.fulfill(jsonRoute({ queue_running: [], queue_pending: [] }))
|
||||
})
|
||||
}
|
||||
|
||||
async function setupCloudApp(
|
||||
page: Page,
|
||||
ws: WorkspaceWithRole,
|
||||
members: Member[]
|
||||
) {
|
||||
await mockCloudBoot(page, {
|
||||
features: BOOT_FEATURES,
|
||||
settings: BOOT_SETTINGS
|
||||
})
|
||||
await mockGraphBootExtras(page)
|
||||
await mockBilling(page)
|
||||
await mockWorkspace(page, ws, members)
|
||||
await bootCloud(page)
|
||||
}
|
||||
|
||||
const pricingHeading = (page: Page) =>
|
||||
page.getByRole('heading', { name: 'Choose a Plan' })
|
||||
|
||||
test.describe('Pricing table deep link', { tag: '@cloud' }, () => {
|
||||
test('opens the pricing table for a personal owner', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('personal', 'owner'), [])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
await expect(page).not.toHaveURL(/[?&]pricing=/)
|
||||
})
|
||||
|
||||
test('opens on the Team tab for ?pricing=team', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('personal', 'owner'), [])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=team`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'For Teams' })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
|
||||
test('opens for a team original owner', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('team', 'owner'), [
|
||||
member({ email: SELF_EMAIL, role: 'owner', is_original_owner: true })
|
||||
])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await expect(pricingHeading(page)).toBeVisible({ timeout: 45_000 })
|
||||
})
|
||||
|
||||
test('is a silent no-op for a team member', async ({ page }) => {
|
||||
test.slow()
|
||||
await setupCloudApp(page, workspace('team', 'member'), [
|
||||
member({
|
||||
email: 'creator@test.comfy.org',
|
||||
role: 'owner',
|
||||
is_original_owner: true
|
||||
}),
|
||||
member({ email: SELF_EMAIL, role: 'member' })
|
||||
])
|
||||
|
||||
await page.goto(`${APP_URL}/?pricing=1`)
|
||||
|
||||
await page.waitForFunction(() => !!window.app?.extensionManager, null, {
|
||||
timeout: 45_000
|
||||
})
|
||||
await expect(page).not.toHaveURL(/[?&]pricing=/)
|
||||
await expect(pricingHeading(page)).toBeHidden()
|
||||
})
|
||||
})
|
||||
@@ -99,15 +99,15 @@ async function mockShareableAssets(
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss stale PrimeVue dialog masks left by cloud-mode's onboarding flow
|
||||
* or auth-triggered modals by pressing Escape until they clear.
|
||||
* Dismiss stale dialogs left by cloud-mode's onboarding flow or
|
||||
* auth-triggered modals by pressing Escape until they clear.
|
||||
*/
|
||||
async function dismissOverlays(page: Page): Promise<void> {
|
||||
const mask = page.locator('.p-dialog-mask')
|
||||
const dialogs = page.getByRole('dialog')
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if ((await mask.count()) === 0) break
|
||||
if ((await dialogs.count()) === 0) break
|
||||
await page.keyboard.press('Escape')
|
||||
await mask
|
||||
await dialogs
|
||||
.first()
|
||||
.waitFor({ state: 'hidden', timeout: 2000 })
|
||||
.catch(() => {})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
@@ -612,18 +612,23 @@ test.describe('Canvas Interaction', { tag: '@screenshot' }, () => {
|
||||
test('Can zoom in/out with ctrl+shift+vertical-drag', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 })
|
||||
// Use ctrlShiftDrag so the Control+Shift modifiers are pressed and released
|
||||
// around each individual gesture. Holding the modifiers down across all
|
||||
// three drags plus the intervening screenshot assertions could saturate the
|
||||
// main thread and stall a single mouse.move step past the test timeout, and
|
||||
// a mid-test failure would leave the modifiers stuck down. Releasing per
|
||||
// gesture matches the robust pattern used in canvasSettings.spec.ts.
|
||||
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 100 }, { x: 10, y: 40 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 })
|
||||
await comfyPage.canvasOps.ctrlShiftDrag({ x: 10, y: 40 }, { x: 10, y: 160 })
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out-ctrl-shift.png')
|
||||
await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 })
|
||||
await comfyPage.canvasOps.ctrlShiftDrag(
|
||||
{ x: 10, y: 280 },
|
||||
{ x: 10, y: 220 }
|
||||
)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'zoomed-default-ctrl-shift.png'
|
||||
)
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
await comfyPage.page.keyboard.up('Shift')
|
||||
})
|
||||
|
||||
test('Can zoom in/out after decreasing canvas zoom speed setting', async ({
|
||||
|
||||
@@ -254,21 +254,8 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
let maskUploadCount = 0
|
||||
let imageUploadCount = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadCount++
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
name: `test-mask-${maskUploadCount}.png`,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadCount++
|
||||
return route.fulfill({
|
||||
@@ -288,20 +275,17 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
// The save pipeline uploads multiple layers (mask + image variants)
|
||||
// The save pipeline uploads four layers (masked, paint, painted, paintedMasked)
|
||||
// through the unified /upload/image endpoint.
|
||||
expect(
|
||||
maskUploadCount + imageUploadCount,
|
||||
'save should trigger upload calls'
|
||||
).toBeGreaterThan(0)
|
||||
imageUploadCount,
|
||||
'save should upload all four layers via /upload/image'
|
||||
).toBe(4)
|
||||
})
|
||||
|
||||
test('save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
|
||||
// Fail all upload routes
|
||||
await comfyPage.page.route('**/upload/mask', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill({ status: 500 })
|
||||
)
|
||||
|
||||
@@ -34,19 +34,17 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
let observedContentType = ''
|
||||
let observedBodyLength = 0
|
||||
|
||||
await comfyPage.page.route('**/upload/mask', async (route) => {
|
||||
await comfyPage.page.route('**/upload/image', async (route) => {
|
||||
const request = route.request()
|
||||
observedContentType = (await request.headerValue('content-type')) ?? ''
|
||||
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
|
||||
if (!observedContentType) {
|
||||
observedContentType = (await request.headerValue('content-type')) ?? ''
|
||||
observedBodyLength = request.postDataBuffer()?.byteLength ?? 0
|
||||
}
|
||||
await route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-123.png'))
|
||||
)
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/upload/image', (route) =>
|
||||
route.fulfill(fulfillJson(successResponse('clipspace-painted-123.png')))
|
||||
)
|
||||
|
||||
await dialog.getByRole('button', { name: 'Save' }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
expect(observedContentType).toContain('multipart/form-data')
|
||||
@@ -69,24 +67,11 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test('Save failure on partial upload keeps dialog open', async ({
|
||||
comfyPage,
|
||||
maskEditor
|
||||
}) => {
|
||||
test('Save failure keeps dialog open', async ({ comfyPage, maskEditor }) => {
|
||||
const dialog = await maskEditor.openDialog()
|
||||
await maskEditor.drawStrokeAndExpectPixels(dialog)
|
||||
|
||||
// The saver uploads sequentially: mask layer first, then image layers.
|
||||
// Let the mask upload succeed and the image upload fail to exercise both
|
||||
// endpoints and verify the dialog stays open after a partial failure.
|
||||
let maskUploadHit = false
|
||||
let imageUploadHit = false
|
||||
await comfyPage.page.route('**/upload/mask', (route) => {
|
||||
maskUploadHit = true
|
||||
return route.fulfill(
|
||||
fulfillJson(successResponse('clipspace-mask-999.png'))
|
||||
)
|
||||
})
|
||||
await comfyPage.page.route('**/upload/image', (route) => {
|
||||
imageUploadHit = true
|
||||
return route.fulfill({ status: 500 })
|
||||
@@ -95,7 +80,6 @@ test.describe('Mask Editor load/save', { tag: '@vue-nodes' }, () => {
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.click()
|
||||
|
||||
await expect.poll(() => maskUploadHit).toBe(true)
|
||||
await expect.poll(() => imageUploadHit).toBe(true)
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(saveButton).toBeVisible()
|
||||
|
||||
@@ -143,7 +143,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const objectInfo = await response.json()
|
||||
const ckptName =
|
||||
objectInfo.CheckpointLoaderSimple.input.required.ckpt_name
|
||||
ckptName[0] = [...ckptName[0], 'fake_model.safetensors']
|
||||
ckptName[0] = [...ckptName[0], FAKE_MODEL_NAME]
|
||||
await route.fulfill({ response, json: objectInfo })
|
||||
})
|
||||
|
||||
@@ -151,21 +151,11 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/object_info') && response.ok()
|
||||
})
|
||||
const modelFoldersResponse = comfyPage.page.waitForResponse(
|
||||
(response) => {
|
||||
const url = new URL(response.url())
|
||||
return url.pathname.endsWith('/experiment/models') && response.ok()
|
||||
}
|
||||
)
|
||||
const refreshButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelRefresh
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
objectInfoResponse,
|
||||
modelFoldersResponse,
|
||||
refreshButton.click()
|
||||
])
|
||||
await Promise.all([objectInfoResponse, refreshButton.click()])
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
).toBeHidden()
|
||||
|
||||
@@ -13,10 +13,6 @@ import type {
|
||||
// Legacy coverage backed by AssetsHelper's shadow backend. New assets-sidebar
|
||||
// browser coverage should use typed route mocks in assetsSidebarTab.spec.ts.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SAMPLE_JOBS: RawJobListItem[] = [
|
||||
createMockJob({
|
||||
id: 'job-alpha',
|
||||
@@ -180,12 +176,10 @@ test.describe('Assets sidebar - tab navigation', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to Imported
|
||||
await tab.switchToImported()
|
||||
await expect(tab.importedTab).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'false')
|
||||
|
||||
// Switch back to Generated
|
||||
await tab.switchToGenerated()
|
||||
await expect(tab.generatedTab).toHaveAttribute('aria-selected', 'true')
|
||||
})
|
||||
@@ -194,11 +188,9 @@ test.describe('Assets sidebar - tab navigation', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Type search in Generated tab
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect(tab.searchInput).toHaveValue('landscape')
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
await expect(tab.searchInput).toHaveValue('')
|
||||
})
|
||||
@@ -235,10 +227,8 @@ test.describe('Assets sidebar - grid view display', () => {
|
||||
await tab.open()
|
||||
await tab.switchToImported()
|
||||
|
||||
// Wait for imported assets to render
|
||||
await expect(tab.assetCards.first()).toBeVisible()
|
||||
|
||||
// Imported tab should show the mocked files
|
||||
await expect.poll(() => tab.assetCards.count()).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
@@ -286,11 +276,9 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Open settings menu and select list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
|
||||
// List view items should now be visible
|
||||
await expect(tab.listViewItems.first()).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -298,16 +286,13 @@ test.describe('Assets sidebar - view mode toggle', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Switch to list view
|
||||
await tab.openSettingsMenu()
|
||||
await tab.listViewOption.click()
|
||||
await expect(tab.listViewItems.first()).toBeVisible()
|
||||
|
||||
// Switch back to grid view (settings popover is still open)
|
||||
await tab.gridViewOption.click()
|
||||
await tab.waitForAssets()
|
||||
|
||||
// Grid cards (with data-selected attribute) should be visible again
|
||||
await expect(tab.assetCards.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -342,10 +327,8 @@ test.describe('Assets sidebar - search', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Search for a specific filename that matches only one asset
|
||||
await tab.searchInput.fill('landscape')
|
||||
|
||||
// Wait for filter to reduce the count
|
||||
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
})
|
||||
|
||||
@@ -355,7 +338,6 @@ test.describe('Assets sidebar - search', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Filter then clear
|
||||
await tab.searchInput.fill('landscape')
|
||||
await expect.poll(() => tab.assetCards.count()).toBeLessThan(initialCount)
|
||||
|
||||
@@ -391,10 +373,8 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Click first asset card
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Should have data-selected="true"
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
})
|
||||
|
||||
@@ -405,11 +385,9 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Click first card
|
||||
await cards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Ctrl+click second card
|
||||
await cards.nth(1).click({ modifiers: ['ControlOrMeta'] })
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
})
|
||||
@@ -420,10 +398,8 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Footer should show selection count
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -431,15 +407,10 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Hover over the selection count button to reveal "Deselect all"
|
||||
await tab.selectionCountButton.hover()
|
||||
await expect(tab.deselectAllButton).toBeVisible()
|
||||
|
||||
// Click "Deselect all"
|
||||
await tab.deselectAllButton.click()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
})
|
||||
@@ -448,14 +419,11 @@ test.describe('Assets sidebar - selection', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select an asset
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectedCards).toHaveCount(1)
|
||||
|
||||
// Switch to Imported tab
|
||||
await tab.switchToImported()
|
||||
|
||||
// Switch back - selection should be cleared
|
||||
await tab.switchToGenerated()
|
||||
await tab.waitForAssets()
|
||||
await expect(tab.selectedCards).toHaveCount(0)
|
||||
@@ -481,10 +449,8 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Right-click first asset
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
|
||||
// Context menu should appear with standard items
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
})
|
||||
@@ -565,8 +531,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
test('Cancelling export-workflow filename prompt does not show an error toast', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// job-gamma is the first card; its detail carries a valid workflow so
|
||||
// extraction succeeds and the filename prompt opens.
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', JOB_GAMMA_DETAIL)
|
||||
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
@@ -614,8 +578,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
test('Export-workflow shows a warning toast when the asset has no workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Strip the workflow field so extraction yields null and the export
|
||||
// action returns { success: false, error: 'No workflow…' }.
|
||||
const { workflow: _, ...detailWithoutWorkflow } = JOB_GAMMA_DETAIL
|
||||
await comfyPage.assets.mockJobDetail('job-gamma', detailWithoutWorkflow)
|
||||
|
||||
@@ -625,7 +587,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
await tab.assetCards.first().click({ button: 'right' })
|
||||
await tab.contextMenuItem('Export workflow').click()
|
||||
|
||||
// Filename prompt should be skipped: extraction fails before the prompt.
|
||||
await expect(comfyPage.toast.toastWarnings).toBeVisible()
|
||||
await expect(comfyPage.toast.toastSuccesses).toBeHidden({ timeout: 1500 })
|
||||
})
|
||||
@@ -639,23 +600,18 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(2)
|
||||
|
||||
// Dismiss any toasts that appeared after asset loading
|
||||
await tab.dismissToasts()
|
||||
|
||||
// Multi-select: use keyboard.down/up so useKeyModifier('Control') detects
|
||||
// the modifier — click({ modifiers }) only sets the mouse event flag and
|
||||
// does not fire a keydown event that VueUse tracks.
|
||||
// useKeyModifier('Control') needs keyboard events, not click modifiers.
|
||||
await cards.first().click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Verify multi-selection took effect and footer is stable before right-clicking
|
||||
await expect(tab.selectedCards).toHaveCount(2)
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
|
||||
// Use dispatchEvent instead of click({ button: 'right' }) to avoid any
|
||||
// overlay intercepting the event, and assert directly without toPass.
|
||||
// dispatchEvent avoids the selection footer intercepting a right click.
|
||||
const contextMenu = comfyPage.page.locator('.p-contextmenu')
|
||||
await cards.first().dispatchEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
@@ -664,7 +620,6 @@ test.describe('Assets sidebar - context menu', () => {
|
||||
})
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Bulk menu should show bulk download action
|
||||
await expect(tab.contextMenuItem('Download all')).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -692,7 +647,6 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Download button in footer should be visible
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -704,7 +658,6 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
// Delete button in footer should be visible
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -712,21 +665,67 @@ test.describe('Assets sidebar - bulk actions', () => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
// Select the two single-output assets (job-alpha, job-beta).
|
||||
// The count reflects total outputs, not cards — job-gamma has
|
||||
// outputs_count: 2 which would inflate the total.
|
||||
const cards = tab.assetCards
|
||||
await expect.poll(() => cards.count()).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Cards are sorted newest-first: gamma (idx 0), beta (1), alpha (2)
|
||||
await cards.nth(1).click()
|
||||
await comfyPage.page.keyboard.down('Control')
|
||||
await cards.nth(2).click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
// Selection count should show the count
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
|
||||
})
|
||||
|
||||
test('Selection count sums the outputs of a stacked asset', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
|
||||
await expect(tab.selectionCountButton).toBeVisible()
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
|
||||
})
|
||||
|
||||
test('Selection bar stays capped, not stretched, on a wide panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.setViewportSize({ width: 1600, height: 900 })
|
||||
const tab = comfyPage.menu.assetsTab
|
||||
await tab.open()
|
||||
|
||||
const gutter = comfyPage.page.locator('.p-splitter-gutter').first()
|
||||
await expect(gutter).toBeVisible()
|
||||
const gutterBox = await gutter.boundingBox()
|
||||
if (!gutterBox) {
|
||||
throw new Error('sidebar splitter gutter has no bounding box')
|
||||
}
|
||||
await comfyPage.page.mouse.move(
|
||||
gutterBox.x + gutterBox.width / 2,
|
||||
gutterBox.y + gutterBox.height / 2
|
||||
)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(900, gutterBox.y + gutterBox.height / 2, {
|
||||
steps: 12
|
||||
})
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
await tab.assetCards.first().click()
|
||||
await expect(tab.selectionFooter).toBeVisible()
|
||||
|
||||
const sidebar = comfyPage.page.locator('.side-bar-panel').first()
|
||||
await expect
|
||||
.poll(async () => (await sidebar.boundingBox())?.width ?? 0)
|
||||
.toBeGreaterThan(520)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bar = await tab.selectionFooter.boundingBox()
|
||||
const side = await sidebar.boundingBox()
|
||||
return bar && side ? side.width - bar.width : 0
|
||||
})
|
||||
.toBeGreaterThan(100)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -833,8 +832,7 @@ test.describe('Assets sidebar - pagination', () => {
|
||||
await comfyPage.assets.mockOutputHistory(manyJobs)
|
||||
await comfyPage.setup()
|
||||
|
||||
// Capture the first history fetch (terminal statuses only).
|
||||
// Queue polling also hits /jobs but with status=in_progress,pending.
|
||||
// Queue polling also calls /jobs, so wait for completed history only.
|
||||
const firstRequest = comfyPage.page.waitForRequest((req) => {
|
||||
if (!/\/api\/jobs\?/.test(req.url())) return false
|
||||
const url = new URL(req.url())
|
||||
@@ -1002,9 +1000,7 @@ const MIXED_MEDIA_JOBS: RawJobListItem[] = [
|
||||
})
|
||||
]
|
||||
|
||||
// Filter button is guarded by isCloud (compile-time). The cloud CI project
|
||||
// cannot use comfyPageFixture (auth required). Enable once cloud E2E infra
|
||||
// supports authenticated comfyPage setup.
|
||||
// Filter button is guarded by isCloud; cloud CI needs authenticated setup.
|
||||
test.describe('Assets sidebar - media type filter', () => {
|
||||
test.fixme(true, 'Requires DISTRIBUTION=cloud build with auth bypass')
|
||||
|
||||
@@ -1040,12 +1036,9 @@ test.describe('Assets sidebar - media type filter', () => {
|
||||
'All three mixed-media jobs should render'
|
||||
).toHaveCount(3)
|
||||
|
||||
// Open filter menu and enable only image filter (selecting a filter
|
||||
// restricts to that type only, hiding unselected types)
|
||||
await tab.openFilterMenu()
|
||||
await tab.filterCheckbox('Image').click()
|
||||
|
||||
// Only the image asset should remain
|
||||
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
|
||||
await expect(tab.getAssetCardByName('photo.png')).toBeVisible()
|
||||
})
|
||||
@@ -1056,12 +1049,10 @@ test.describe('Assets sidebar - media type filter', () => {
|
||||
|
||||
const initialCount = await tab.assetCards.count()
|
||||
|
||||
// Enable image filter to restrict to images only
|
||||
await tab.openFilterMenu()
|
||||
await tab.filterCheckbox('Image').click()
|
||||
await expect(tab.assetCards).toHaveCount(1, { timeout: 5000 })
|
||||
|
||||
// Uncheck image filter to remove all filters (restores all assets)
|
||||
await tab.filterCheckbox('Image').click()
|
||||
await expect(tab.assetCards).toHaveCount(initialCount, { timeout: 5000 })
|
||||
})
|
||||
|
||||
@@ -214,7 +214,7 @@ test.describe('FE-130 assets sidebar route mocks', () => {
|
||||
await tab.open()
|
||||
|
||||
await tab.getAssetCardByName('alpha').click()
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*1\b/)
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b1 selected\b/)
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
|
||||
@@ -222,7 +222,7 @@ test.describe('FE-130 assets sidebar route mocks', () => {
|
||||
await tab.getAssetCardByName('beta').click()
|
||||
await comfyPage.page.keyboard.up('Control')
|
||||
|
||||
await expect(tab.selectionCountButton).toHaveText(/Assets Selected:\s*2\b/)
|
||||
await expect(tab.selectionCountButton).toHaveText(/\b2 selected\b/)
|
||||
await expect(tab.deleteSelectedButton).toBeVisible()
|
||||
await expect(tab.downloadSelectedButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -233,4 +233,64 @@ test.describe('Model library sidebar - empty state', () => {
|
||||
await expect(tab.folderNodes).toHaveCount(0)
|
||||
await expect(tab.leafNodes).toHaveCount(0)
|
||||
})
|
||||
|
||||
test.describe('Model library sidebar - add node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.modelLibrary.clearMocks()
|
||||
})
|
||||
|
||||
test('Clicking a model defers creation until placed on the canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
|
||||
await tab.getLeafByLabel('sd_xl_base_1.0').click()
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
|
||||
.toBe(0)
|
||||
|
||||
const canvasBox = (await comfyPage.canvas.boundingBox())!
|
||||
await comfyPage.canvas.click({
|
||||
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
|
||||
.toBe(1)
|
||||
|
||||
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
expect(loader).toBeDefined()
|
||||
const widget = await loader.getWidgetByName('ckpt_name')
|
||||
expect(await widget.getValue()).toBe('sd_xl_base_1.0.safetensors')
|
||||
})
|
||||
|
||||
test('Ghost preview shows the model in the loader widget before placing', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const tab = comfyPage.menu.modelLibraryTab
|
||||
await tab.open()
|
||||
await tab.getFolderByLabel('checkpoints').click()
|
||||
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
|
||||
|
||||
await tab.getLeafByLabel('sd_xl_base_1.0').click()
|
||||
|
||||
const ghost = comfyPage.page.locator(
|
||||
'[data-node-id="preview-CheckpointLoaderSimple"]'
|
||||
)
|
||||
await expect(ghost).toContainText('sd_xl_base_1.0.safetensors')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ConsoleMessage } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
||||
|
||||
const domPreviewSelector = '.image-preview'
|
||||
@@ -95,4 +98,225 @@ test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
|
||||
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Detach Race Repro', { tag: ['@vue-nodes'] }, () => {
|
||||
const SUBGRAPH_NODE_TITLE = 'New Subgraph'
|
||||
|
||||
// Queues legacy onNodeRemoved/onSelectionChange so unpack completes first,
|
||||
// widening the race window so a guard regression deterministically surfaces.
|
||||
async function deferLegacyHandlers(comfyPage: ComfyPage) {
|
||||
return await comfyPage.page.evaluateHandle(() => {
|
||||
const graph = window.app!.graph!
|
||||
const canvas = window.app!.canvas!
|
||||
const queue: Array<() => void> = []
|
||||
const originalNodeRemoved = graph.onNodeRemoved
|
||||
const originalSelectionChange = canvas.onSelectionChange
|
||||
graph.onNodeRemoved = function (node) {
|
||||
queue.push(() => originalNodeRemoved?.call(this, node))
|
||||
}
|
||||
canvas.onSelectionChange = function (selected) {
|
||||
queue.push(() => originalSelectionChange?.call(this, selected))
|
||||
}
|
||||
return {
|
||||
drain: () => {
|
||||
for (const fn of queue.splice(0)) fn()
|
||||
},
|
||||
restore: () => {
|
||||
graph.onNodeRemoved = originalNodeRemoved
|
||||
canvas.onSelectionChange = originalSelectionChange
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type DeferredHandlers = Awaited<ReturnType<typeof deferLegacyHandlers>>
|
||||
|
||||
// Defers only the legacy selection-change callback, so the detached host
|
||||
// node lingers in the reactive selection while onNodeRemoved still runs
|
||||
// normally and clears it from the canvas. This isolates the panel render
|
||||
// path: a panel mounted during this window reads the stale selection.
|
||||
async function deferSelectionChange(
|
||||
comfyPage: ComfyPage
|
||||
): Promise<DeferredHandlers> {
|
||||
return await comfyPage.page.evaluateHandle(() => {
|
||||
const canvas = window.app!.canvas!
|
||||
const queue: Array<() => void> = []
|
||||
const original = canvas.onSelectionChange
|
||||
canvas.onSelectionChange = function (selected) {
|
||||
queue.push(() => original?.call(this, selected))
|
||||
}
|
||||
return {
|
||||
drain: () => {
|
||||
for (const fn of queue.splice(0)) fn()
|
||||
},
|
||||
restore: () => {
|
||||
canvas.onSelectionChange = original
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function isNullGraphErrorText(text: string): boolean {
|
||||
return text.includes('NullGraphError') || text.endsWith('has no graph')
|
||||
}
|
||||
|
||||
// Vue's default errorHandler routes render throws to console.error,
|
||||
// not pageerror - listen to both.
|
||||
function captureNullGraphErrors(comfyPage: ComfyPage) {
|
||||
const captured: string[] = []
|
||||
const onPageError = (err: Error) => {
|
||||
if (
|
||||
err.name === 'NullGraphError' ||
|
||||
isNullGraphErrorText(err.message ?? '')
|
||||
) {
|
||||
captured.push(`pageerror ${err.name}: ${err.message}`)
|
||||
}
|
||||
}
|
||||
const onConsoleMessage = (msg: ConsoleMessage) => {
|
||||
if (msg.type() !== 'error') return
|
||||
const text = msg.text()
|
||||
if (isNullGraphErrorText(text)) {
|
||||
captured.push(`console.error: ${text}`)
|
||||
}
|
||||
}
|
||||
comfyPage.page.on('pageerror', onPageError)
|
||||
comfyPage.page.on('console', onConsoleMessage)
|
||||
return {
|
||||
getErrors: () => [...captured],
|
||||
stop: () => {
|
||||
comfyPage.page.off('pageerror', onPageError)
|
||||
comfyPage.page.off('console', onConsoleMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function unpackViaContextMenu(comfyPage: ComfyPage, title: string) {
|
||||
const fixture = await comfyPage.vueNodes.getFixtureByTitle(title)
|
||||
await comfyPage.contextMenu.openForVueNode(fixture.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
|
||||
}
|
||||
|
||||
async function reopenRightSidePanel(comfyPage: ComfyPage) {
|
||||
const { propertiesPanel } = comfyPage.menu
|
||||
await propertiesPanel.toggleButton.click()
|
||||
await expect(propertiesPanel.root).toBeHidden()
|
||||
await propertiesPanel.toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
// Unpacks the subgraph behind deferred teardown, runs an optional
|
||||
// interaction while the node is detached but not yet cleaned up, then
|
||||
// drains the deferred handlers and reports any NullGraphErrors seen.
|
||||
async function unpackAndCaptureNullGraphErrors(
|
||||
comfyPage: ComfyPage,
|
||||
options: {
|
||||
defer: (comfyPage: ComfyPage) => Promise<DeferredHandlers>
|
||||
duringWindow?: (comfyPage: ComfyPage) => Promise<void>
|
||||
}
|
||||
): Promise<string[]> {
|
||||
const subgraphNode =
|
||||
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
|
||||
const errors = captureNullGraphErrors(comfyPage)
|
||||
const deferred = await options.defer(comfyPage)
|
||||
try {
|
||||
await unpackViaContextMenu(comfyPage, SUBGRAPH_NODE_TITLE)
|
||||
await expect(subgraphNode).toHaveCount(0)
|
||||
await options.duringWindow?.(comfyPage)
|
||||
await deferred.evaluate((handlers) => handlers.drain())
|
||||
// Let drained-handler reactive flushes settle before stop().
|
||||
await comfyPage.nextFrame()
|
||||
return errors.getErrors()
|
||||
} finally {
|
||||
await deferred.evaluate((handlers) => handlers.restore())
|
||||
await deferred.dispose()
|
||||
errors.stop()
|
||||
}
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.RightSidePanel.IsOpen', true)
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-promoted-text-widget'
|
||||
)
|
||||
const subgraphNode =
|
||||
comfyPage.vueNodes.getNodeByTitle(SUBGRAPH_NODE_TITLE)
|
||||
await expect(subgraphNode).toBeVisible()
|
||||
|
||||
const fixture =
|
||||
await comfyPage.vueNodes.getFixtureByTitle(SUBGRAPH_NODE_TITLE)
|
||||
await fixture.header.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.root)
|
||||
).toBeVisible()
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
test('unpack does not surface NullGraphError on the LGraphNode render path', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferLegacyHandlers
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'LGraphNode render path: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('unpack does not surface NullGraphError from the TabSubgraphInputs panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferLegacyHandlers
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'TabSubgraphInputs panel: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('unpack with subgraph editor open does not surface NullGraphError from the SubgraphEditor panel', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferLegacyHandlers
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'SubgraphEditor panel: detach race must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('reopening the right side panel after unpack does not surface NullGraphError', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferSelectionChange,
|
||||
duringWindow: reopenRightSidePanel
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'TabSubgraphInputs remount: stale selection must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('reopening the right side panel with the subgraph editor open does not surface NullGraphError', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByTestId(TestIds.subgraphEditor.toggle).click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const nullGraphErrors = await unpackAndCaptureNullGraphErrors(comfyPage, {
|
||||
defer: deferSelectionChange,
|
||||
duringWindow: reopenRightSidePanel
|
||||
})
|
||||
expect(
|
||||
nullGraphErrors,
|
||||
'SubgraphEditor remount: stale selection must not surface NullGraphError'
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
139
browser_tests/tests/topbar/workflowTabStatus.spec.ts
Normal file
139
browser_tests/tests/topbar/workflowTabStatus.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { Locator, WebSocketRoute } from '@playwright/test'
|
||||
import { mergeTests } from '@playwright/test'
|
||||
|
||||
import {
|
||||
comfyPageFixture,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
|
||||
import { webSocketFixture } from '@e2e/fixtures/ws'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
|
||||
const KSAMPLER_NODE = '3'
|
||||
|
||||
async function runOnBackgroundTab(
|
||||
comfyPage: ComfyPage,
|
||||
ws: WebSocketRoute
|
||||
): Promise<{ exec: ExecutionHelper; jobId: string; backgroundTab: Locator }> {
|
||||
const topbar = comfyPage.menu.topbar
|
||||
|
||||
await comfyPage.workflow.waitForActiveWorkflow()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
|
||||
const exec = new ExecutionHelper(comfyPage, ws)
|
||||
const jobId = await exec.run()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await topbar.newWorkflowButton.click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect(topbar.getActiveTab()).toContainText('(2)')
|
||||
|
||||
const backgroundTab = topbar.getTab(0)
|
||||
exec.executionStart(jobId)
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Running' })
|
||||
).toBeVisible()
|
||||
|
||||
return { exec, jobId, backgroundTab }
|
||||
}
|
||||
|
||||
test.describe('Workflow tab status indicator', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Topbar'
|
||||
)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('replaces the running indicator with completed when the job finishes', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
|
||||
comfyPage,
|
||||
ws
|
||||
)
|
||||
|
||||
exec.executionSuccess(jobId)
|
||||
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Completed' })
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Running' })
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('shows failed when the background job errors', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
|
||||
comfyPage,
|
||||
ws
|
||||
)
|
||||
|
||||
exec.executionError(jobId, KSAMPLER_NODE, 'boom')
|
||||
|
||||
// The error opens a modal dialog that aria-hides the rest of the app
|
||||
// (focus trap), taking the tab out of the accessibility tree. Dismiss it
|
||||
// so the badge is reachable by role.
|
||||
const errorDialog = comfyPage.page.getByTestId(TestIds.dialogs.errorDialog)
|
||||
await expect(errorDialog).toBeVisible()
|
||||
await comfyPage.page.keyboard.press('Escape')
|
||||
await expect(errorDialog).toBeHidden()
|
||||
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Failed' })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('drops the indicator on user interrupt rather than showing an error', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
|
||||
comfyPage,
|
||||
ws
|
||||
)
|
||||
|
||||
exec.executionInterrupted(jobId, KSAMPLER_NODE)
|
||||
|
||||
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('clears the indicator once the tab is activated', async ({
|
||||
comfyPage,
|
||||
getWebSocket
|
||||
}) => {
|
||||
const ws = await getWebSocket()
|
||||
const { exec, jobId, backgroundTab } = await runOnBackgroundTab(
|
||||
comfyPage,
|
||||
ws
|
||||
)
|
||||
|
||||
exec.executionSuccess(jobId)
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Completed' })
|
||||
).toBeVisible()
|
||||
|
||||
const currentTab = comfyPage.menu.topbar.getActiveTab()
|
||||
|
||||
await expect(
|
||||
backgroundTab.getByRole('img', { name: 'Completed' })
|
||||
).toBeVisible()
|
||||
await backgroundTab.click()
|
||||
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
|
||||
|
||||
await currentTab.click()
|
||||
await comfyPage.workflow.waitForWorkflowIdle()
|
||||
await expect(backgroundTab.getByRole('img')).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
@@ -280,3 +280,36 @@ test.describe('Vue Node Groups', { tag: ['@screenshot', '@vue-nodes'] }, () => {
|
||||
await expect.poll(bypassCount, "won't toggle double selected node").toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Vue Node Group Context Menu',
|
||||
{ tag: ['@vue-nodes', '@canvas'] },
|
||||
() => {
|
||||
test('right-clicking a group opens the Vue context menu instead of the legacy menu', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Deselect so the right-click selects the group itself.
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press(CREATE_GROUP_HOTKEY)
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => graph!.groups.length))
|
||||
.toBe(1)
|
||||
await comfyPage.page.mouse.click(100, 100)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const groupPos = await getGroupTitlePosition(comfyPage, 'Group')
|
||||
await comfyPage.page.mouse.click(groupPos.x, groupPos.y, {
|
||||
button: 'right'
|
||||
})
|
||||
|
||||
await expect(comfyPage.contextMenu.primeVueMenu).toBeVisible()
|
||||
await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden()
|
||||
await expect(comfyPage.contextMenu.litegraphMenu).toBeHidden()
|
||||
|
||||
// Group-only action confirms it is the group menu.
|
||||
await expect(
|
||||
comfyPage.contextMenu.primeVueMenu.getByText('Fit Group To Nodes')
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import type { Position } from '@e2e/fixtures/types'
|
||||
import { VueNodeFixture } from '@e2e/fixtures/utils/vueNodeFixtures'
|
||||
|
||||
test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
const getHeaderPos = async (
|
||||
@@ -335,6 +336,79 @@ test.describe('Vue Node Moving', { tag: '@vue-nodes' }, () => {
|
||||
await comfyPage.canvasOps.moveMouseToEmptyArea()
|
||||
})
|
||||
|
||||
test('pointerCancel stops autopan', async ({ comfyPage }) => {
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
await ksampler.header.click({ trial: true })
|
||||
await comfyPage.page.mouse.down()
|
||||
|
||||
const getOffset = () => comfyPage.canvasOps.getOffset()
|
||||
const initialOffset = await getOffset()
|
||||
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
|
||||
await expect.poll(getOffset, 'drag with autopan').not.toEqual(initialOffset)
|
||||
|
||||
await test.step('move outside pan range and cancel drag', async () => {
|
||||
await comfyPage.page.mouse.move(400, 400, { steps: 20 })
|
||||
await ksampler.header.evaluate((node) =>
|
||||
node.dispatchEvent(new PointerEvent('pointercancel', { bubbles: true }))
|
||||
)
|
||||
})
|
||||
|
||||
const secondaryOffset = await getOffset()
|
||||
|
||||
await comfyPage.page.mouse.move(10, 10, { steps: 20 })
|
||||
await comfyPage.nextFrame()
|
||||
expect(await getOffset(), 'drag canceled').toEqual(secondaryOffset)
|
||||
})
|
||||
|
||||
test('dragging a node moves all selected items', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
const samplerLocator = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
const ksampler = new VueNodeFixture(samplerLocator)
|
||||
const loaderLocator = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const loader = new VueNodeFixture(loaderLocator)
|
||||
|
||||
await test.step('create graph with group and reroute', async () => {
|
||||
await comfyPage.nodeOps.clearGraph()
|
||||
await comfyPage.searchBoxV2.addNode('Load Checkpoint')
|
||||
const samplerOptions = { position: { x: 800, y: 200 } }
|
||||
await comfyPage.searchBoxV2.addNode('KSampler', samplerOptions)
|
||||
await ksampler.getSlot('model').dragTo(loader.getSlot('MODEL'))
|
||||
|
||||
await test.step('add reroute', async () => {
|
||||
const b1 = await ksampler.getSlot('model').boundingBox()
|
||||
const b2 = await loader.getSlot('MODEL').boundingBox()
|
||||
if (!b1 || !b2) throw new Error('Failed to get bounds')
|
||||
|
||||
const x = (b1.x + b2.x + (b1.width + b2.width) / 2) / 2
|
||||
const y = (b1.y + b2.y + (b1.height + b2.height) / 2) / 2
|
||||
await comfyPage.page.keyboard.down('Alt')
|
||||
await comfyPage.page.mouse.click(x, y)
|
||||
await comfyPage.page.keyboard.up('Alt')
|
||||
|
||||
const rerouteCount = () =>
|
||||
comfyPage.page.evaluate(() => graph!.reroutes.size)
|
||||
await expect.poll(rerouteCount).toBe(1)
|
||||
})
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press('Control+G')
|
||||
await comfyPage.keyboard.selectAll()
|
||||
})
|
||||
|
||||
const getReroutePos = () =>
|
||||
comfyPage.page.evaluate(() => [...graph!.reroutes.values()][0])
|
||||
const getGroupPos = () =>
|
||||
comfyPage.page.evaluate(() => graph!.groups[0].pos)
|
||||
const initialReroutePos = await getReroutePos()
|
||||
const initialGroupPos = await getGroupPos()
|
||||
await comfyMouse.dragElementBy(ksampler.title, { x: 100 })
|
||||
|
||||
await expect.poll(getReroutePos).not.toEqual(initialReroutePos)
|
||||
await expect.poll(getGroupPos).not.toEqual(initialGroupPos)
|
||||
})
|
||||
|
||||
test(
|
||||
'@mobile should allow moving nodes by dragging on touch devices',
|
||||
{ tag: '@screenshot' },
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('tooltips', { tag: '@vue-nodes' }, async () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.settings.setSetting('LiteGraph.Node.TooltipDelay', 0)
|
||||
})
|
||||
|
||||
test('widget value tooltips', async ({ comfyPage }) => {
|
||||
const tooltip = comfyPage.page.locator('.p-tooltip-text')
|
||||
await comfyPage.vueNodes.getWidgetByName('load check', 'ckpt_name').hover()
|
||||
await expect(tooltip, 'displays for combos').toContainText('v1-5-pruned')
|
||||
|
||||
await comfyPage.vueNodes.getWidgetByName('ksampler', 'seed').hover()
|
||||
await expect(tooltip, 'displays for numbers').toContainText('15668')
|
||||
|
||||
await comfyPage.vueNodes.getNodeLocator('6').getByLabel('text').hover()
|
||||
await expect(tooltip).toBeVisible()
|
||||
await expect(tooltip, "doesn't display for prompts").not.toContainText(
|
||||
'purple galaxy bottle'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -73,4 +73,16 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
|
||||
|
||||
await expect(widget, 'Widget has restored value').toHaveText('scale width')
|
||||
})
|
||||
|
||||
test('Dynamic children have separate state', async ({ comfyPage }) => {
|
||||
const nodeName = 'Node With Dynamic Combo'
|
||||
await comfyPage.searchBoxV2.addNode(nodeName, {
|
||||
position: { x: 200, y: 150 }
|
||||
})
|
||||
const child = comfyPage.vueNodes.getWidgetByName(nodeName, 'suboption')
|
||||
await expect(child, 'initial state').toHaveText('1x')
|
||||
|
||||
await comfyPage.vueNodes.selectComboOption(nodeName, 'combo', 'option2')
|
||||
await expect(child, 'child of same name has new state').toHaveText('2x')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.47.2",
|
||||
"version": "1.47.5",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -19,7 +19,10 @@
|
||||
"size:collect": "node scripts/size-collect.js",
|
||||
"size:report": "node scripts/size-report.js",
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"dev:cloud": "cross-env DEV_SERVER_COMFYUI_URL='https://testcloud.comfy.org/' vite --config vite.config.mts",
|
||||
"dev:cloud": "pnpm dev:cloud:test",
|
||||
"dev:cloud:test": "cross-env DEV_SERVER_COMFYUI_URL=https://testcloud.comfy.org/ vite --config vite.config.mts",
|
||||
"dev:cloud:staging": "cross-env DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/ vite --config vite.config.mts",
|
||||
"dev:cloud:prod": "cross-env DEV_SERVER_COMFYUI_URL=https://cloud.comfy.org/ vite --config vite.config.mts",
|
||||
"dev:desktop": "pnpm --filter @comfyorg/desktop-ui run dev",
|
||||
"dev:electron": "cross-env DISTRIBUTION=desktop vite --config vite.electron.config.mts",
|
||||
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true vite --config vite.config.mts",
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
--color-gold-500: #fdab34;
|
||||
--color-gold-600: #fd9903;
|
||||
|
||||
--color-credit: #fabc25;
|
||||
|
||||
--color-coral-500: #f75951;
|
||||
--color-coral-600: #e04e48;
|
||||
--color-coral-700: #b33a3a;
|
||||
@@ -236,6 +238,8 @@
|
||||
--secondary-background: var(--color-smoke-200);
|
||||
--secondary-background-hover: var(--color-smoke-400);
|
||||
--secondary-background-selected: var(--color-smoke-600);
|
||||
--tertiary-background: var(--color-smoke-400);
|
||||
--tertiary-background-hover: var(--color-smoke-500);
|
||||
--base-background: var(--color-white);
|
||||
--primary-background: var(--color-azure-400);
|
||||
--primary-background-hover: var(--color-cobalt-800);
|
||||
@@ -384,6 +388,8 @@
|
||||
--secondary-background: var(--color-charcoal-600);
|
||||
--secondary-background-hover: var(--color-charcoal-400);
|
||||
--secondary-background-selected: var(--color-charcoal-200);
|
||||
--tertiary-background: var(--color-charcoal-400);
|
||||
--tertiary-background-hover: var(--color-charcoal-300);
|
||||
--base-background: var(--color-charcoal-800);
|
||||
--primary-background: var(--color-azure-600);
|
||||
--primary-background-hover: var(--color-azure-400);
|
||||
@@ -554,6 +560,8 @@
|
||||
--color-secondary-background: var(--secondary-background);
|
||||
--color-secondary-background-hover: var(--secondary-background-hover);
|
||||
--color-secondary-background-selected: var(--secondary-background-selected);
|
||||
--color-tertiary-background: var(--tertiary-background);
|
||||
--color-tertiary-background-hover: var(--tertiary-background-hover);
|
||||
--color-primary-background: var(--primary-background);
|
||||
--color-primary-background-hover: var(--primary-background-hover);
|
||||
--color-destructive-background: var(--destructive-background);
|
||||
|
||||
@@ -344,6 +344,15 @@ export const zDynamicComboInputSpec = z.tuple([
|
||||
})
|
||||
])
|
||||
|
||||
export const zDynamicGroupInputSpec = z.tuple([
|
||||
z.literal('COMFY_DYNAMICGROUP_V3'),
|
||||
zBaseInputOptions.extend({
|
||||
template: zComfyInputsSpec,
|
||||
min: z.number().int().nonnegative().optional().default(0),
|
||||
max: z.number().int().positive().max(100).optional().default(50)
|
||||
})
|
||||
])
|
||||
|
||||
export const zMatchTypeOptions = z.object({
|
||||
...zBaseInputOptions.shape,
|
||||
type: z.literal('COMFY_MATCHTYPE_V3'),
|
||||
|
||||
5347
packages/registry-types/src/comfyRegistryTypes.ts
generated
5347
packages/registry-types/src/comfyRegistryTypes.ts
generated
File diff suppressed because it is too large
Load Diff
224
src/components/boundingBoxes/WidgetBoundingBoxes.test.ts
Normal file
224
src/components/boundingBoxes/WidgetBoundingBoxes.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import WidgetBoundingBoxes from './WidgetBoundingBoxes.vue'
|
||||
import boundingBoxes from '@/locales/en/main.json'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
|
||||
const { appState } = vi.hoisted(() => ({ appState: { node: null as unknown } }))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph: { getNodeById: () => appState.node } } }
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
boundingBoxes: boundingBoxes.boundingBoxes,
|
||||
palette: { swatchTitle: 'Edit', addColor: 'Add' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
|
||||
x: 51,
|
||||
y: 51,
|
||||
width: 256,
|
||||
height: 256,
|
||||
metadata: { type: 'obj', text: '', desc: '', palette: ['#ff0000'] },
|
||||
...over
|
||||
})
|
||||
|
||||
const fakeCtx = {
|
||||
measureText: (s: string) => ({ width: s.length * 7 }),
|
||||
setTransform: () => {},
|
||||
clearRect: () => {},
|
||||
fillRect: () => {},
|
||||
strokeRect: () => {},
|
||||
fillText: () => {},
|
||||
drawImage: () => {},
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
beginPath: () => {},
|
||||
rect: () => {},
|
||||
clip: () => {},
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 0
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
function prepCanvas(canvas: HTMLCanvasElement) {
|
||||
Object.defineProperty(canvas, 'clientWidth', {
|
||||
value: 100,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(canvas, 'clientHeight', {
|
||||
value: 100,
|
||||
configurable: true
|
||||
})
|
||||
canvas.getContext = (() =>
|
||||
fakeCtx) as unknown as HTMLCanvasElement['getContext']
|
||||
canvas.getBoundingClientRect = () =>
|
||||
({
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({})
|
||||
}) as DOMRect
|
||||
canvas.setPointerCapture = () => {}
|
||||
canvas.releasePointerCapture = () => {}
|
||||
}
|
||||
|
||||
function renderWidget(modelValue: BoundingBox[]) {
|
||||
const result = render(WidgetBoundingBoxes, {
|
||||
props: { nodeId: '1', modelValue },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
const canvas = screen.getByTestId('bounding-boxes').querySelector('canvas')!
|
||||
prepCanvas(canvas)
|
||||
return { ...result, canvas }
|
||||
}
|
||||
|
||||
const lastBoxes = (emitted: () => Record<string, unknown[][]>) => {
|
||||
const calls = emitted()['update:modelValue']
|
||||
return calls[calls.length - 1][0] as BoundingBox[]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
appState.node = {
|
||||
widgets: [
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 }
|
||||
],
|
||||
findInputSlot: () => -1,
|
||||
getInputNode: () => null
|
||||
}
|
||||
vi.stubGlobal('requestAnimationFrame', () => 1)
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('WidgetBoundingBoxes', () => {
|
||||
it('renders the canvas and editor shell', () => {
|
||||
renderWidget([])
|
||||
expect(
|
||||
screen.getByTestId('bounding-boxes').querySelector('canvas')
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows the region editor panel when a region is active', () => {
|
||||
renderWidget([box()])
|
||||
expect(screen.getByText('obj')).toBeTruthy()
|
||||
expect(screen.getByText('text')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reveals the text field after switching the region to text', async () => {
|
||||
renderWidget([box()])
|
||||
expect(
|
||||
screen.queryByPlaceholderText('text to render (verbatim)')
|
||||
).toBeNull()
|
||||
await userEvent.click(screen.getByText('text'))
|
||||
expect(
|
||||
screen.getByPlaceholderText('text to render (verbatim)')
|
||||
).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clears all regions via the clear button', async () => {
|
||||
const { emitted } = renderWidget([box()])
|
||||
await userEvent.click(screen.getByText('Clear all'))
|
||||
expect(lastBoxes(emitted)).toEqual([])
|
||||
})
|
||||
|
||||
it('draws a region through canvas pointer events', async () => {
|
||||
const { canvas, emitted } = renderWidget([])
|
||||
await fireEvent.pointerDown(canvas, {
|
||||
button: 0,
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
pointerId: 1
|
||||
})
|
||||
await fireEvent.pointerMove(canvas, {
|
||||
clientX: 60,
|
||||
clientY: 60,
|
||||
pointerId: 1
|
||||
})
|
||||
await fireEvent.pointerUp(canvas, {
|
||||
clientX: 60,
|
||||
clientY: 60,
|
||||
pointerId: 1
|
||||
})
|
||||
expect(lastBoxes(emitted)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('tracks focus and blur on the canvas', async () => {
|
||||
const { canvas } = renderWidget([box()])
|
||||
await fireEvent.focus(canvas)
|
||||
await fireEvent.blur(canvas)
|
||||
expect(canvas).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens an inline editor on double click', async () => {
|
||||
const { canvas, container } = renderWidget([box()])
|
||||
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
|
||||
expect(container.querySelector('textarea')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('syncs description edits back to the model', async () => {
|
||||
const { emitted } = renderWidget([box()])
|
||||
await fireEvent.update(
|
||||
screen.getByPlaceholderText('description of this region'),
|
||||
'a caption'
|
||||
)
|
||||
expect(lastBoxes(emitted)[0].metadata.desc).toBe('a caption')
|
||||
})
|
||||
|
||||
it('edits the text field once the region is a text region', async () => {
|
||||
const { emitted } = renderWidget([box()])
|
||||
await userEvent.click(screen.getByText('text'))
|
||||
await fireEvent.update(
|
||||
screen.getByPlaceholderText('text to render (verbatim)'),
|
||||
'hello'
|
||||
)
|
||||
expect(lastBoxes(emitted)[0].metadata.text).toBe('hello')
|
||||
})
|
||||
|
||||
it('deletes the active region with the Delete key', async () => {
|
||||
const { canvas, emitted } = renderWidget([box()])
|
||||
await fireEvent.keyDown(canvas, { key: 'Delete' })
|
||||
expect(lastBoxes(emitted)).toEqual([])
|
||||
})
|
||||
|
||||
it('clears hover state on pointer leave', async () => {
|
||||
const { canvas } = renderWidget([
|
||||
box({ x: 10, y: 10, width: 256, height: 256 })
|
||||
])
|
||||
await fireEvent.pointerMove(canvas, { clientX: 15, clientY: 15 })
|
||||
await fireEvent.pointerLeave(canvas)
|
||||
expect(canvas).toBeTruthy()
|
||||
})
|
||||
|
||||
it('commits the inline editor on blur', async () => {
|
||||
const { canvas, container, emitted } = renderWidget([box()])
|
||||
await fireEvent.dblClick(canvas, { clientX: 30, clientY: 30 })
|
||||
const editor = container.querySelector('textarea')!
|
||||
await fireEvent.update(editor, 'committed')
|
||||
await fireEvent.blur(editor)
|
||||
expect(lastBoxes(emitted)[0].metadata.desc).toBe('committed')
|
||||
})
|
||||
})
|
||||
181
src/components/boundingBoxes/WidgetBoundingBoxes.vue
Normal file
181
src/components/boundingBoxes/WidgetBoundingBoxes.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div
|
||||
class="widget-expands flex size-full flex-col gap-1 select-none"
|
||||
data-testid="bounding-boxes"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeRegion"
|
||||
class="flex flex-col gap-2 rounded-sm bg-node-component-surface p-2 text-xs"
|
||||
>
|
||||
<div
|
||||
class="flex h-8 items-center gap-1 rounded-sm bg-component-node-widget-background p-1"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 self-stretch px-2 text-xs transition-colors',
|
||||
activeRegion.type === 'obj'
|
||||
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
|
||||
: 'text-node-text-muted hover:text-node-text'
|
||||
)
|
||||
"
|
||||
@click="setActiveType('obj')"
|
||||
>
|
||||
{{ $t('boundingBoxes.typeObj') }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="
|
||||
cn(
|
||||
'flex-1 self-stretch px-2 text-xs transition-colors',
|
||||
activeRegion.type === 'text'
|
||||
? 'rounded-sm bg-component-node-widget-background-selected text-base-foreground'
|
||||
: 'text-node-text-muted hover:text-node-text'
|
||||
)
|
||||
"
|
||||
@click="setActiveType('text')"
|
||||
>
|
||||
{{ $t('boundingBoxes.typeText') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
v-if="activeRegion.type === 'text'"
|
||||
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('boundingBoxes.textLabel') }}
|
||||
</span>
|
||||
<Textarea
|
||||
v-model="activeRegion.text"
|
||||
:placeholder="$t('boundingBoxes.textPlaceholder')"
|
||||
class="min-h-14 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
|
||||
data-capture-wheel="true"
|
||||
@update:model-value="syncState"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="group relative rounded-lg transition-all focus-within:ring focus-within:ring-component-node-widget-background-highlighted hover:bg-component-node-widget-background-hovered"
|
||||
>
|
||||
<span
|
||||
class="pointer-events-none absolute top-1.5 left-3 z-10 text-2xs text-muted-foreground"
|
||||
>
|
||||
{{ $t('boundingBoxes.descLabel') }}
|
||||
</span>
|
||||
<Textarea
|
||||
v-model="activeRegion.desc"
|
||||
:placeholder="$t('boundingBoxes.descPlaceholder')"
|
||||
class="min-h-20 resize-none overflow-hidden pt-5 text-(length:--comfy-textarea-font-size) leading-normal not-disabled:bg-component-node-widget-background not-disabled:text-component-node-foreground hover:overflow-auto focus:overflow-auto"
|
||||
data-capture-wheel="true"
|
||||
@update:model-value="syncState"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="shrink-0 truncate text-sm text-muted-foreground">
|
||||
{{ $t('boundingBoxes.colors') }}
|
||||
</span>
|
||||
<PaletteSwatchRow
|
||||
v-model="activeRegion.palette"
|
||||
:max="maxColors"
|
||||
@update:model-value="syncState"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
|
||||
{{ $t('boundingBoxes.clickRegionToEdit') }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
{{ $t('boundingBoxes.clearAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import PaletteSwatchRow from '@/components/palette/PaletteSwatchRow.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Textarea from '@/components/ui/textarea/Textarea.vue'
|
||||
import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
|
||||
const { nodeId } = defineProps<{ nodeId: string }>()
|
||||
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
|
||||
|
||||
const canvasEl = useTemplateRef<HTMLCanvasElement>('canvasEl')
|
||||
const canvasContainer = useTemplateRef<HTMLDivElement>('canvasContainer')
|
||||
const inlineEditorEl = useTemplateRef<HTMLTextAreaElement>('inlineEditorEl')
|
||||
|
||||
const {
|
||||
canvasStyle,
|
||||
canvasCursor,
|
||||
focused,
|
||||
activeRegion,
|
||||
hasRegions,
|
||||
inlineEditor,
|
||||
maxColors,
|
||||
onPointerDown,
|
||||
onCanvasPointerMove,
|
||||
onDocPointerUp,
|
||||
onPointerLeave,
|
||||
onDoubleClick,
|
||||
onCanvasKeyDown,
|
||||
onInlineKeyDown,
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState
|
||||
} = useBoundingBoxes(nodeId, {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
inlineEditorEl,
|
||||
modelValue
|
||||
})
|
||||
</script>
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
defineOptions({
|
||||
@@ -50,11 +52,27 @@ defineProps<{ itemClass: string; contentClass: string; item: MenuItem }>()
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuItem
|
||||
v-else
|
||||
:class="itemClass"
|
||||
v-tooltip="
|
||||
item.tooltip ? { value: String(item.tooltip), showDelay: 0 } : undefined
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
itemClass,
|
||||
String(item.class ?? ''),
|
||||
Boolean(item.tooltip) && toValue(item.disabled) && 'pointer-events-auto'
|
||||
)
|
||||
"
|
||||
v-bind="
|
||||
'checked' in item
|
||||
? { role: 'menuitemradio', 'aria-checked': Boolean(item.checked) }
|
||||
: {}
|
||||
"
|
||||
:disabled="toValue(item.disabled) ?? !item.command"
|
||||
@select="item.command?.({ originalEvent: $event, item })"
|
||||
>
|
||||
<i class="size-5 shrink-0" :class="item.icon" />
|
||||
<!-- Items declaring an icon key (even empty) keep the slot so labels align
|
||||
within icon-bearing menus; icon-less menus render labels flush-left. -->
|
||||
<i v-if="'icon' in item" class="size-5 shrink-0" :class="item.icon" />
|
||||
<div class="mr-auto truncate" v-text="item.label" />
|
||||
<i v-if="item.checked" class="icon-[lucide--check] shrink-0" />
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
DropdownMenuArrow,
|
||||
@@ -7,13 +8,16 @@ import {
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuTrigger
|
||||
} from 'reka-ui'
|
||||
import { computed, toValue } from 'vue'
|
||||
import { computed, ref, toValue } from 'vue'
|
||||
|
||||
import DropdownItem from '@/components/common/DropdownItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { ButtonVariants } from '../ui/button/button.variants'
|
||||
|
||||
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
|
||||
const MODAL_BASE_Z_INDEX = 1700
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
@@ -41,10 +45,20 @@ const contentClass = computed(() =>
|
||||
contentProp
|
||||
)
|
||||
)
|
||||
|
||||
// Body-portaled content keeps its static z-1700 unless a dialog that joined
|
||||
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
|
||||
// past that dialog so the menu isn't hidden behind it.
|
||||
const open = ref(false)
|
||||
const contentStyle = computed(() => {
|
||||
if (!open.value) return undefined
|
||||
const topZIndex = ZIndex.getCurrent('modal')
|
||||
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuRoot v-model:open="open">
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
|
||||
@@ -60,6 +74,7 @@ const contentClass = computed(() =>
|
||||
:collision-padding="10"
|
||||
v-bind="$attrs"
|
||||
:class="contentClass"
|
||||
:style="contentStyle"
|
||||
>
|
||||
<slot :item-class>
|
||||
<DropdownItem
|
||||
|
||||
56
src/components/common/DropdownMenu.zindex.test.ts
Normal file
56
src/components/common/DropdownMenu.zindex.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json'
|
||||
|
||||
import DropdownMenu from './DropdownMenu.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderMenu() {
|
||||
return render(DropdownMenu, {
|
||||
props: { entries: [{ label: 'Item A' }] },
|
||||
global: { plugins: [i18n], directives: { tooltip: {} } }
|
||||
})
|
||||
}
|
||||
|
||||
let openModal: HTMLElement | undefined
|
||||
|
||||
afterEach(() => {
|
||||
if (openModal) {
|
||||
ZIndex.clear(openModal)
|
||||
openModal = undefined
|
||||
}
|
||||
})
|
||||
|
||||
describe('DropdownMenu z-index', () => {
|
||||
it('opens above a dialog registered with the modal z-index counter', async () => {
|
||||
openModal = document.createElement('div')
|
||||
ZIndex.set('modal', openModal, 1700)
|
||||
const dialogZ = Number(openModal.style.zIndex)
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderMenu()
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
const menu = await screen.findByRole('menu')
|
||||
expect(Number(menu.style.zIndex)).toBeGreaterThan(dialogZ)
|
||||
})
|
||||
|
||||
it('leaves the static z-index untouched when no dialog is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderMenu()
|
||||
await user.click(screen.getByRole('button'))
|
||||
|
||||
const menu = await screen.findByRole('menu')
|
||||
expect(menu.style.zIndex).toBe('')
|
||||
expect(menu.className).toContain('z-1700')
|
||||
})
|
||||
})
|
||||
@@ -355,7 +355,7 @@ describe('TreeExplorerV2Node', () => {
|
||||
const nodeDiv = getTreeNode(container)
|
||||
await fireEvent.dragStart(nodeDiv)
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native')
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockData, { mode: 'native' })
|
||||
})
|
||||
|
||||
it('does not call startDrag for folder items on dragstart', async () => {
|
||||
|
||||
@@ -427,7 +427,6 @@ import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useLazyPagination } from '@/composables/useLazyPagination'
|
||||
import { usePrimeVueOverlayChildStyle } from '@/composables/usePopoverSizing'
|
||||
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
@@ -453,16 +452,14 @@ onMounted(() => {
|
||||
|
||||
// Wrap onClose to track session end
|
||||
const onClose = () => {
|
||||
if (isCloud) {
|
||||
const timeSpentSeconds = Math.floor(
|
||||
(Date.now() - sessionStartTime.value) / 1000
|
||||
)
|
||||
const timeSpentSeconds = Math.floor(
|
||||
(Date.now() - sessionStartTime.value) / 1000
|
||||
)
|
||||
|
||||
useTelemetry()?.trackTemplateLibraryClosed({
|
||||
template_selected: templateWasSelected.value,
|
||||
time_spent_seconds: timeSpentSeconds
|
||||
})
|
||||
}
|
||||
useTelemetry()?.trackTemplateLibraryClosed({
|
||||
template_selected: templateWasSelected.value,
|
||||
time_spent_seconds: timeSpentSeconds
|
||||
})
|
||||
|
||||
originalOnClose()
|
||||
}
|
||||
|
||||
@@ -44,16 +44,32 @@ describe('GlobalDialog renderer branching', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('renders the PrimeVue branch when renderer is omitted', async () => {
|
||||
it('renders the Reka branch when renderer is omitted (default)', async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'primevue-default',
|
||||
title: 'PrimeVue dialog',
|
||||
key: 'renderer-default',
|
||||
title: 'Default renderer dialog',
|
||||
component: Body
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.length).toBeGreaterThan(0)
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(false)
|
||||
})
|
||||
|
||||
it("renders the legacy PrimeVue branch when renderer is 'primevue'", async () => {
|
||||
mountDialog()
|
||||
const store = useDialogStore()
|
||||
|
||||
store.showDialog({
|
||||
key: 'primevue-escape-hatch',
|
||||
title: 'PrimeVue dialog',
|
||||
component: Body,
|
||||
dialogComponentProps: { renderer: 'primevue' }
|
||||
})
|
||||
|
||||
const dialogs = await screen.findAllByRole('dialog')
|
||||
expect(dialogs.some((el) => el.classList.contains('p-dialog'))).toBe(true)
|
||||
})
|
||||
|
||||
54
src/components/dialog/confirm/confirmDialog.test.ts
Normal file
54
src/components/dialog/confirm/confirmDialog.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Dialog migration regression net: the showConfirmDialog helper must open
|
||||
* its dialog through the Reka renderer with zeroed section padding (the
|
||||
* Confirm* sections carry their own). Catches accidental reverts of the
|
||||
* Phase 6 renderer flip.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const showDialog = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({ showDialog })
|
||||
}))
|
||||
|
||||
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
|
||||
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
|
||||
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
|
||||
import { showConfirmDialog } from '@/components/dialog/confirm/confirmDialog'
|
||||
|
||||
describe('showConfirmDialog Reka renderer opt-in', () => {
|
||||
beforeEach(() => {
|
||||
showDialog.mockReset()
|
||||
})
|
||||
|
||||
it("sets renderer 'reka' with size 'md' and zeroed section padding", () => {
|
||||
showConfirmDialog()
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.dialogComponentProps.renderer).toBe('reka')
|
||||
expect(args.dialogComponentProps.size).toBe('md')
|
||||
expect(args.dialogComponentProps.headerClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.bodyClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.footerClass).toBe('p-0')
|
||||
expect(args.dialogComponentProps.pt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('forwards the confirm section components and caller props', () => {
|
||||
showConfirmDialog({
|
||||
key: 'confirm-test',
|
||||
headerProps: { title: 'Title' },
|
||||
props: { promptText: 'Prompt' },
|
||||
footerProps: { confirmText: 'Delete' }
|
||||
})
|
||||
|
||||
const [args] = showDialog.mock.calls[0]
|
||||
expect(args.key).toBe('confirm-test')
|
||||
expect(args.headerComponent).toBe(ConfirmHeader)
|
||||
expect(args.component).toBe(ConfirmBody)
|
||||
expect(args.footerComponent).toBe(ConfirmFooter)
|
||||
expect(args.headerProps).toEqual({ title: 'Title' })
|
||||
expect(args.props).toEqual({ promptText: 'Prompt' })
|
||||
expect(args.footerProps).toEqual({ confirmText: 'Delete' })
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import ConfirmBody from '@/components/dialog/confirm/ConfirmBody.vue'
|
||||
import ConfirmFooter from '@/components/dialog/confirm/ConfirmFooter.vue'
|
||||
import ConfirmHeader from '@/components/dialog/confirm/ConfirmHeader.vue'
|
||||
import type { DialogInstance } from '@/stores/dialogStore'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComponentAttrs } from 'vue-component-type-helpers'
|
||||
|
||||
@@ -11,7 +12,9 @@ interface ConfirmDialogOptions {
|
||||
footerProps?: ComponentAttrs<typeof ConfirmFooter>
|
||||
}
|
||||
|
||||
export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
export function showConfirmDialog(
|
||||
options: ConfirmDialogOptions = {}
|
||||
): DialogInstance {
|
||||
const dialogStore = useDialogStore()
|
||||
const { key, headerProps, props, footerProps } = options
|
||||
return dialogStore.showDialog({
|
||||
@@ -23,11 +26,13 @@ export function showConfirmDialog(options: ConfirmDialogOptions = {}) {
|
||||
props,
|
||||
footerProps,
|
||||
dialogComponentProps: {
|
||||
pt: {
|
||||
header: 'py-0! px-0!',
|
||||
content: 'p-0!',
|
||||
footer: 'p-0!'
|
||||
}
|
||||
renderer: 'reka',
|
||||
size: 'md',
|
||||
// Confirm sections carry their own padding — zero out the dialog
|
||||
// chrome padding, like the PrimeVue `pt` overrides did.
|
||||
headerClass: 'p-0',
|
||||
bodyClass: 'p-0',
|
||||
footerClass: 'p-0'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<Message v-if="userIsInChina" severity="warn" class="mb-4">
|
||||
{{ t('auth.signup.regionRestrictionChina') }}
|
||||
</Message>
|
||||
<SignUpForm v-else @submit="signUpWithEmail" />
|
||||
<SignUpForm v-else ref="signUpForm" @submit="signUpWithEmail" />
|
||||
</template>
|
||||
|
||||
<!-- Divider -->
|
||||
@@ -206,9 +206,21 @@ const signInWithEmail = async (values: SignInData) => {
|
||||
}
|
||||
}
|
||||
|
||||
const signUpWithEmail = async (values: SignUpData) => {
|
||||
if (await authActions.signUpWithEmail(values.email, values.password)) {
|
||||
const signUpForm = ref<InstanceType<typeof SignUpForm> | null>(null)
|
||||
|
||||
const signUpWithEmail = async (values: SignUpData, turnstileToken?: string) => {
|
||||
if (
|
||||
await authActions.signUpWithEmail(
|
||||
values.email,
|
||||
values.password,
|
||||
turnstileToken
|
||||
)
|
||||
) {
|
||||
onSuccess()
|
||||
} else {
|
||||
// Signup failed while the form is still mounted: re-arm the single-use
|
||||
// Turnstile token so the next attempt sends a fresh one.
|
||||
signUpForm.value?.resetTurnstile()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
97
src/components/dialog/content/setting/CreditsPanel.vue
Normal file
97
src/components/dialog/content/setting/CreditsPanel.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="credits-container flex h-full flex-col gap-4">
|
||||
<div>
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('credits.credits') }}
|
||||
</h2>
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
<CreditsTile />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="m-0">{{ $t('credits.activity') }}</h3>
|
||||
<Button variant="muted-textonly" @click="handleCreditsHistoryClick">
|
||||
<i class="pi pi-arrow-up-right" />
|
||||
{{ $t('credits.invoiceHistory') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<UsageLogsTable ref="usageLogsTableRef" />
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button variant="muted-textonly" @click="handleFaqClick">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('credits.faqs') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('subscription.partnerNodesCredits') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleMessageSupport">
|
||||
<i class="pi pi-comments" />
|
||||
{{ $t('credits.messageSupport') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Divider from 'primevue/divider'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import CreditsTile from '@/platform/cloud/subscription/components/CreditsTile.vue'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const authStore = useAuthStore()
|
||||
const authActions = useAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
|
||||
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
|
||||
|
||||
watch(
|
||||
() => authStore.lastBalanceUpdateTime,
|
||||
(newTime, oldTime) => {
|
||||
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
|
||||
void usageLogsTableRef.value.refresh()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleCreditsHistoryClick = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const handleMessageSupport = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'credits_panel'
|
||||
})
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
const handleFaqClick = () => {
|
||||
window.open(
|
||||
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
'_blank',
|
||||
'noopener,noreferrer'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -8,6 +8,7 @@
|
||||
v-model="filters['global'].value"
|
||||
class="max-w-96"
|
||||
size="lg"
|
||||
autofocus
|
||||
:placeholder="
|
||||
$t('g.searchPlaceholder', { subject: $t('g.keybindings') })
|
||||
"
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
<template>
|
||||
<div class="credits-container h-full">
|
||||
<!-- Legacy Design -->
|
||||
<div class="flex h-full flex-col">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('credits.credits') }}
|
||||
</h2>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-sm font-medium text-muted">
|
||||
{{ $t('credits.yourCreditBalance') }}
|
||||
</h3>
|
||||
<div class="flex items-center justify-between">
|
||||
<UserCredit text-class="text-3xl font-bold" />
|
||||
<Skeleton v-if="loading" width="2rem" height="2rem" />
|
||||
<Button
|
||||
v-else-if="isActiveSubscription"
|
||||
:loading="loading"
|
||||
@click="handlePurchaseCreditsClick"
|
||||
>
|
||||
{{ $t('credits.purchaseCredits') }}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex flex-row items-center">
|
||||
<Skeleton
|
||||
v-if="balanceLoading"
|
||||
width="12rem"
|
||||
height="1rem"
|
||||
class="text-xs"
|
||||
/>
|
||||
<div v-else-if="formattedLastUpdateTime" class="text-xs text-muted">
|
||||
{{ $t('credits.lastUpdated') }}: {{ formattedLastUpdateTime }}
|
||||
</div>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('g.refresh')"
|
||||
@click="() => authActions.fetchBalance()"
|
||||
>
|
||||
<i class="pi pi-refresh" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<h3>{{ $t('credits.activity') }}</h3>
|
||||
<Button
|
||||
variant="muted-textonly"
|
||||
:loading="loading"
|
||||
@click="handleCreditsHistoryClick"
|
||||
>
|
||||
<i class="pi pi-arrow-up-right" />
|
||||
{{ $t('credits.invoiceHistory') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<template v-if="creditHistory.length > 0">
|
||||
<div class="grow">
|
||||
<DataTable :value="creditHistory" :show-headers="false">
|
||||
<Column field="title" :header="$t('g.name')">
|
||||
<template #body="{ data }">
|
||||
<div class="text-sm font-medium">{{ data.title }}</div>
|
||||
<div class="text-xs text-muted">{{ data.timestamp }}</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="amount" :header="$t('g.amount')">
|
||||
<template #body="{ data }">
|
||||
<div
|
||||
:class="[
|
||||
'text-center text-base font-medium',
|
||||
data.isPositive ? 'text-sky-500' : 'text-red-400'
|
||||
]"
|
||||
>
|
||||
{{ data.isPositive ? '+' : '-' }}${{
|
||||
formatMetronomeCurrency(data.amount, 'usd')
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Divider />
|
||||
|
||||
<UsageLogsTable ref="usageLogsTableRef" />
|
||||
|
||||
<div class="flex flex-row gap-2">
|
||||
<Button variant="muted-textonly" @click="handleFaqClick">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('credits.faqs') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleOpenPartnerNodesInfo">
|
||||
<i class="pi pi-question-circle" />
|
||||
{{ $t('subscription.partnerNodesCredits') }}
|
||||
</Button>
|
||||
<Button variant="muted-textonly" @click="handleMessageSupport">
|
||||
<i class="pi pi-comments" />
|
||||
{{ $t('credits.messageSupport') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Divider from 'primevue/divider'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import UserCredit from '@/components/common/UserCredit.vue'
|
||||
import UsageLogsTable from '@/components/dialog/content/setting/UsageLogsTable.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useBillingContext } from '@/composables/billing/useBillingContext'
|
||||
import { useAuthActions } from '@/composables/auth/useAuthActions'
|
||||
import { useExternalLink } from '@/composables/useExternalLink'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
import { formatMetronomeCurrency } from '@/utils/formatUtil'
|
||||
|
||||
interface CreditHistoryItemData {
|
||||
title: string
|
||||
timestamp: string
|
||||
amount: number
|
||||
isPositive: boolean
|
||||
}
|
||||
|
||||
const { buildDocsUrl, docsPaths } = useExternalLink()
|
||||
const dialogService = useDialogService()
|
||||
const authStore = useAuthStore()
|
||||
const authActions = useAuthActions()
|
||||
const commandStore = useCommandStore()
|
||||
const telemetry = useTelemetry()
|
||||
const { isActiveSubscription } = useBillingContext()
|
||||
const loading = computed(() => authStore.loading)
|
||||
const balanceLoading = computed(() => authStore.isFetchingBalance)
|
||||
|
||||
const usageLogsTableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
|
||||
|
||||
const formattedLastUpdateTime = computed(() =>
|
||||
authStore.lastBalanceUpdateTime
|
||||
? authStore.lastBalanceUpdateTime.toLocaleString()
|
||||
: ''
|
||||
)
|
||||
|
||||
watch(
|
||||
() => authStore.lastBalanceUpdateTime,
|
||||
(newTime, oldTime) => {
|
||||
if (newTime && newTime !== oldTime && usageLogsTableRef.value) {
|
||||
usageLogsTableRef.value.refresh()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handlePurchaseCreditsClick = () => {
|
||||
// Track purchase credits entry from Settings > Credits panel
|
||||
useTelemetry()?.trackAddApiCreditButtonClicked()
|
||||
dialogService.showTopUpCreditsDialog()
|
||||
}
|
||||
|
||||
const handleCreditsHistoryClick = async () => {
|
||||
await authActions.accessBillingPortal()
|
||||
}
|
||||
|
||||
const handleMessageSupport = async () => {
|
||||
telemetry?.trackHelpResourceClicked({
|
||||
resource_type: 'help_feedback',
|
||||
is_external: true,
|
||||
source: 'credits_panel'
|
||||
})
|
||||
await commandStore.execute('Comfy.ContactSupport')
|
||||
}
|
||||
|
||||
const handleFaqClick = () => {
|
||||
window.open(
|
||||
buildDocsUrl('/tutorials/api-nodes/faq', { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const handleOpenPartnerNodesInfo = () => {
|
||||
window.open(
|
||||
buildDocsUrl(docsPaths.partnerNodesPricing, { includeLocale: true }),
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
|
||||
const creditHistory = ref<CreditHistoryItemData[]>([])
|
||||
</script>
|
||||
@@ -7,6 +7,7 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
|
||||
import type * as DistributionTypes from '@/platform/distribution/types'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import { EventType } from '@/services/customerEventsService'
|
||||
|
||||
@@ -38,6 +39,23 @@ vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
}))
|
||||
|
||||
const mockFlags = vi.hoisted(() => ({ teamWorkspacesEnabled: false }))
|
||||
vi.mock('@/composables/useFeatureFlags', () => ({
|
||||
useFeatureFlags: () => ({ flags: mockFlags })
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof DistributionTypes>()),
|
||||
isCloud: true
|
||||
}))
|
||||
|
||||
const mockWorkspaceApi = vi.hoisted(() => ({
|
||||
getBillingEvents: vi.fn()
|
||||
}))
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: mockWorkspaceApi
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -118,6 +136,8 @@ describe('UsageLogsTable', () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockWorkspaceApi.getBillingEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockFlags.teamWorkspacesEnabled = false
|
||||
mockCustomerEventsService.formatEventType.mockImplementation(
|
||||
(type: string) => {
|
||||
switch (type) {
|
||||
@@ -320,6 +340,20 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('billing events source', () => {
|
||||
it('uses workspaceApi.getBillingEvents when teamWorkspacesEnabled is on', async () => {
|
||||
mockFlags.teamWorkspacesEnabled = true
|
||||
|
||||
await renderLoaded()
|
||||
|
||||
expect(mockWorkspaceApi.getBillingEvents).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 7
|
||||
})
|
||||
expect(mockCustomerEventsService.getMyEvents).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventType integration', () => {
|
||||
it('renders credit_added event with correct detail template', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(
|
||||
|
||||
@@ -99,7 +99,10 @@ import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { workspaceApi } from '@/platform/workspace/api/workspaceApi'
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import {
|
||||
EventType,
|
||||
@@ -112,6 +115,9 @@ const error = ref<string | null>(null)
|
||||
|
||||
const customerEventService = useCustomerEventsService()
|
||||
|
||||
const { flags } = useFeatureFlags()
|
||||
const useBillingApi = computed(() => isCloud && flags.teamWorkspacesEnabled)
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
limit: 7,
|
||||
@@ -138,10 +144,13 @@ const loadEvents = async () => {
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await customerEventService.getMyEvents({
|
||||
const params = {
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.limit
|
||||
})
|
||||
}
|
||||
const response = useBillingApi.value
|
||||
? await workspaceApi.getBillingEvents(params)
|
||||
: await customerEventService.getMyEvents(params)
|
||||
|
||||
if (response) {
|
||||
if (response.events) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Form, FormField } from '@primevue/forms'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Password from 'primevue/password'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
@@ -36,34 +37,116 @@ vi.mock('@/stores/authStore', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockTurnstileEnabled = ref(false)
|
||||
const mockTurnstileEnforced = ref(false)
|
||||
const mockReset = vi.fn()
|
||||
let emitTurnstileToken: ((token: string) => void) | undefined
|
||||
|
||||
vi.mock('@/composables/auth/useTurnstile', () => ({
|
||||
useTurnstile: () => ({
|
||||
enabled: mockTurnstileEnabled,
|
||||
enforced: mockTurnstileEnforced
|
||||
})
|
||||
}))
|
||||
|
||||
// Stub the real widget (which loads the external Turnstile script) with one that
|
||||
// exposes a spyable reset() and lets a test drive the v-model token the way a
|
||||
// solved challenge would.
|
||||
vi.mock('./TurnstileWidget.vue', async () => {
|
||||
const { defineComponent: defineMock } = await import('vue')
|
||||
return {
|
||||
default: defineMock({
|
||||
name: 'TurnstileWidget',
|
||||
emits: ['update:token'],
|
||||
setup(_, { expose, emit }) {
|
||||
expose({ reset: mockReset })
|
||||
emitTurnstileToken = (token: string) => emit('update:token', token)
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const signUpButton = enMessages.auth.signup.signUpButton
|
||||
|
||||
function globalOptions() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
return {
|
||||
plugins: [PrimeVue, i18n],
|
||||
components: {
|
||||
Form,
|
||||
FormField,
|
||||
Button,
|
||||
InputText,
|
||||
Password,
|
||||
ProgressSpinner
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('SignUpForm', () => {
|
||||
beforeEach(() => {
|
||||
mockLoadingRef.value = false
|
||||
mockTurnstileEnabled.value = false
|
||||
mockTurnstileEnforced.value = false
|
||||
mockReset.mockClear()
|
||||
emitTurnstileToken = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function renderComponent() {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
return render(SignUpForm, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
components: {
|
||||
Form,
|
||||
FormField,
|
||||
Button,
|
||||
InputText,
|
||||
Password,
|
||||
ProgressSpinner
|
||||
}
|
||||
function renderComponent(props: Record<string, unknown> = {}) {
|
||||
const user = userEvent.setup()
|
||||
const utils = render(SignUpForm, { global: globalOptions(), props })
|
||||
return { ...utils, user }
|
||||
}
|
||||
|
||||
/** Render through a host that keeps a ref, so the parent-facing exposed
|
||||
* `resetTurnstile()` can be invoked the way SignInContent would. */
|
||||
function renderWithRef() {
|
||||
const formRef = ref<{ resetTurnstile: () => void } | null>(null)
|
||||
const Host = defineComponent({
|
||||
setup() {
|
||||
return () => h(SignUpForm, { ref: formRef })
|
||||
}
|
||||
})
|
||||
const utils = render(Host, { global: globalOptions() })
|
||||
return {
|
||||
...utils,
|
||||
form: () => {
|
||||
if (!formRef.value) throw new Error('form not mounted')
|
||||
return formRef.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const expectedValues = {
|
||||
email: 'new@example.com',
|
||||
password: 'Password1!',
|
||||
confirmPassword: 'Password1!'
|
||||
}
|
||||
|
||||
async function fillValidSignup(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(enMessages.auth.signup.emailPlaceholder),
|
||||
expectedValues.email
|
||||
)
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(enMessages.auth.signup.passwordPlaceholder),
|
||||
expectedValues.password
|
||||
)
|
||||
await user.type(
|
||||
screen.getByPlaceholderText(
|
||||
enMessages.auth.login.confirmPasswordPlaceholder
|
||||
),
|
||||
expectedValues.confirmPassword
|
||||
)
|
||||
}
|
||||
|
||||
describe('Password manager autofill attributes', () => {
|
||||
@@ -107,4 +190,97 @@ describe('SignUpForm', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Turnstile single-use token reset', () => {
|
||||
it('exposes resetTurnstile() that resets the rendered widget', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
const { form } = renderWithRef()
|
||||
await nextTick()
|
||||
|
||||
form().resetTurnstile()
|
||||
|
||||
expect(mockReset).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not reset the widget on the initial render', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
renderWithRef()
|
||||
await nextTick()
|
||||
|
||||
expect(mockReset).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Turnstile token hygiene', () => {
|
||||
it('clears the stale token when Turnstile becomes disabled', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const { user } = renderComponent()
|
||||
await fillValidSignup(user)
|
||||
|
||||
emitTurnstileToken!('stale-token')
|
||||
await nextTick()
|
||||
expect(
|
||||
screen.getByRole('button', { name: signUpButton })
|
||||
).not.toBeDisabled()
|
||||
|
||||
mockTurnstileEnabled.value = false
|
||||
await nextTick()
|
||||
|
||||
// re-enable: the stale token must have been cleared so submit is blocked again
|
||||
mockTurnstileEnabled.value = true
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Turnstile submit gating', () => {
|
||||
it('disables the submit button in enforce mode until a token is present', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
renderComponent()
|
||||
await nextTick()
|
||||
|
||||
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('does not emit submit in enforce mode while the token is empty', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: signUpButton }))
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits submit with the token in enforce mode once the challenge is solved', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = true
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
|
||||
emitTurnstileToken!('token-xyz')
|
||||
await nextTick()
|
||||
await user.click(screen.getByRole('button', { name: signUpButton }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedValues, 'token-xyz')
|
||||
})
|
||||
|
||||
it('emits submit without a token in shadow mode (never blocks)', async () => {
|
||||
mockTurnstileEnabled.value = true
|
||||
mockTurnstileEnforced.value = false
|
||||
const onSubmit = vi.fn()
|
||||
const { user } = renderComponent({ onSubmit })
|
||||
await fillValidSignup(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: signUpButton }))
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedValues, undefined)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,13 +29,34 @@
|
||||
|
||||
<PasswordFields />
|
||||
|
||||
<TurnstileWidget
|
||||
v-if="turnstileEnabled"
|
||||
ref="turnstileWidget"
|
||||
v-model:token="turnstileToken"
|
||||
/>
|
||||
|
||||
<small
|
||||
v-show="submitBlockedByTurnstile"
|
||||
id="comfy-org-sign-up-turnstile-hint"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
class="opacity-80"
|
||||
>
|
||||
{{ t('auth.turnstile.submitBlockedHint') }}
|
||||
</small>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<ProgressSpinner v-if="loading" class="mx-auto size-8" />
|
||||
<Button
|
||||
v-else
|
||||
type="submit"
|
||||
class="mt-4 h-10 font-medium"
|
||||
:disabled="!$form.valid"
|
||||
:disabled="!$form.valid || submitBlockedByTurnstile"
|
||||
:aria-describedby="
|
||||
submitBlockedByTurnstile
|
||||
? 'comfy-org-sign-up-turnstile-hint'
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
{{ t('auth.signup.signUpButton') }}
|
||||
</Button>
|
||||
@@ -49,27 +70,58 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useTurnstile } from '@/composables/auth/useTurnstile'
|
||||
import { signUpSchema } from '@/schemas/signInSchema'
|
||||
import type { SignUpData } from '@/schemas/signInSchema'
|
||||
import { useAuthStore } from '@/stores/authStore'
|
||||
|
||||
import PasswordFields from './PasswordFields.vue'
|
||||
import TurnstileWidget from './TurnstileWidget.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const loading = computed(() => authStore.loading)
|
||||
|
||||
const { enabled: turnstileEnabled, enforced: turnstileEnforced } =
|
||||
useTurnstile()
|
||||
const turnstileToken = ref('')
|
||||
const turnstileWidget =
|
||||
useTemplateRef<InstanceType<typeof TurnstileWidget>>('turnstileWidget')
|
||||
const submitBlockedByTurnstile = computed(
|
||||
() => turnstileEnforced.value && !turnstileToken.value
|
||||
)
|
||||
|
||||
watch(turnstileEnabled, (on) => {
|
||||
if (!on) turnstileToken.value = ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [values: SignUpData]
|
||||
submit: [values: SignUpData, turnstileToken?: string]
|
||||
}>()
|
||||
|
||||
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
|
||||
if (event.valid) {
|
||||
emit('submit', event.values as SignUpData)
|
||||
if (event.valid && !submitBlockedByTurnstile.value) {
|
||||
emit(
|
||||
'submit',
|
||||
event.values as SignUpData,
|
||||
turnstileToken.value || undefined
|
||||
)
|
||||
}
|
||||
}, 1_500)
|
||||
|
||||
// Turnstile tokens are single-use. The parent calls this after a FAILED signup
|
||||
// (the form can't observe the submit outcome itself) to discard the spent token
|
||||
// and request a fresh challenge. Driving it from the actual result — instead of
|
||||
// watching the store-global loading flag — keeps an unrelated auth action from
|
||||
// wiping a freshly-solved token, and avoids resetting a widget that is about to
|
||||
// unmount on success.
|
||||
function resetTurnstile() {
|
||||
turnstileWidget.value?.reset()
|
||||
}
|
||||
|
||||
defineExpose({ resetTurnstile })
|
||||
</script>
|
||||
|
||||
264
src/components/dialog/content/signin/TurnstileWidget.test.ts
Normal file
264
src/components/dialog/content/signin/TurnstileWidget.test.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render } from '@testing-library/vue'
|
||||
|
||||
import type { TurnstileRenderOptions } from '@/composables/auth/turnstileScript'
|
||||
|
||||
import TurnstileWidget from './TurnstileWidget.vue'
|
||||
|
||||
const { mockLoadTurnstile, mockGetSiteKey, mockLightTheme } = vi.hoisted(
|
||||
() => ({
|
||||
mockLoadTurnstile: vi.fn(),
|
||||
mockGetSiteKey: vi.fn(() => 'site-key'),
|
||||
mockLightTheme: { value: true }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('@/composables/auth/turnstileScript', () => ({
|
||||
loadTurnstile: mockLoadTurnstile
|
||||
}))
|
||||
vi.mock('@/config/turnstile', () => ({
|
||||
getTurnstileSiteKey: mockGetSiteKey
|
||||
}))
|
||||
vi.mock('@/stores/workspace/colorPaletteStore', () => ({
|
||||
useColorPaletteStore: () => ({
|
||||
completedActivePalette: {
|
||||
get light_theme() {
|
||||
return mockLightTheme.value
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
auth: {
|
||||
turnstile: {
|
||||
expired: 'Challenge expired',
|
||||
failed: 'Verification failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** A controllable Cloudflare Turnstile global whose render() captures options. */
|
||||
function fakeTurnstile() {
|
||||
let captured: TurnstileRenderOptions | undefined
|
||||
const api = {
|
||||
render: vi.fn((_el: unknown, options: TurnstileRenderOptions) => {
|
||||
captured = options
|
||||
return 'widget-id'
|
||||
}),
|
||||
reset: vi.fn(),
|
||||
remove: vi.fn()
|
||||
}
|
||||
return { api, options: () => captured }
|
||||
}
|
||||
|
||||
/** Drain the onMounted async (loadTurnstile) plus any follow-up microtasks. */
|
||||
const flush = async () => {
|
||||
await Promise.resolve()
|
||||
await new Promise((resolve) => setTimeout(resolve))
|
||||
}
|
||||
|
||||
const renderWidget = () =>
|
||||
render(TurnstileWidget, { global: { plugins: [i18n] } })
|
||||
|
||||
/**
|
||||
* Render TurnstileWidget through a thin host that keeps a ref to it, so the
|
||||
* exposed `reset()` method can be invoked the way a parent (SignUpForm) would.
|
||||
*/
|
||||
const renderWidgetWithExpose = () => {
|
||||
const widgetRef = ref<{ reset: () => void } | null>(null)
|
||||
const Host = defineComponent({
|
||||
setup(_, { emit }) {
|
||||
return () =>
|
||||
h(TurnstileWidget, {
|
||||
ref: widgetRef,
|
||||
'onUpdate:token': (value: string) => emit('update:token', value)
|
||||
})
|
||||
}
|
||||
})
|
||||
const utils = render(Host, { global: { plugins: [i18n] } })
|
||||
return {
|
||||
...utils,
|
||||
getCurrentInstance: () => {
|
||||
if (!widgetRef.value) throw new Error('widget not mounted')
|
||||
return widgetRef.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('TurnstileWidget', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetSiteKey.mockReturnValue('site-key')
|
||||
mockLightTheme.value = true
|
||||
delete window.turnstile
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete window.turnstile
|
||||
})
|
||||
|
||||
it('renders the widget with the configured sitekey and light theme', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
renderWidget()
|
||||
await flush()
|
||||
|
||||
expect(mockLoadTurnstile).toHaveBeenCalledOnce()
|
||||
expect(api.render).toHaveBeenCalledOnce()
|
||||
expect(options()?.sitekey).toBe('site-key')
|
||||
expect(options()?.theme).toBe('light')
|
||||
})
|
||||
|
||||
it('uses the dark theme when the active palette is not light', async () => {
|
||||
mockLightTheme.value = false
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
renderWidget()
|
||||
await flush()
|
||||
|
||||
expect(options()?.theme).toBe('dark')
|
||||
})
|
||||
|
||||
it('emits the solved token via v-model and shows no error', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted, container } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:token'].at(-1)).toEqual(['token-abc'])
|
||||
expect(container.textContent).not.toContain('Challenge expired')
|
||||
expect(container.textContent).not.toContain('Verification failed')
|
||||
})
|
||||
|
||||
it('clears the token and surfaces the expired message on expiry', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted, container } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
options()!['expired-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:token'].at(-1)).toEqual([''])
|
||||
expect(container.textContent).toContain('Challenge expired')
|
||||
})
|
||||
|
||||
it('clears the token and surfaces the failure message on widget error', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
|
||||
const { emitted, container } = renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(emitted()['update:token'].at(-1)).toEqual([''])
|
||||
expect(container.textContent).toContain('Verification failed')
|
||||
})
|
||||
|
||||
it('resets the widget on a challenge error to fetch a fresh challenge', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
renderWidget()
|
||||
await flush()
|
||||
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
|
||||
expect(api.reset).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
|
||||
it('shows the failure message when the Turnstile script fails to load', async () => {
|
||||
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
|
||||
|
||||
const { container } = renderWidget()
|
||||
await flush()
|
||||
|
||||
expect(container.textContent).toContain('Verification failed')
|
||||
})
|
||||
|
||||
it('reset() clears the token model and resets the rendered widget', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
const { emitted, getCurrentInstance } = renderWidgetWithExpose()
|
||||
await flush()
|
||||
|
||||
options()!.callback!('token-abc')
|
||||
await flush()
|
||||
expect(emitted()['update:token'].at(-1)).toEqual(['token-abc'])
|
||||
|
||||
getCurrentInstance().reset()
|
||||
await flush()
|
||||
|
||||
expect(api.reset).toHaveBeenCalledWith('widget-id')
|
||||
expect(emitted()['update:token'].at(-1)).toEqual([''])
|
||||
})
|
||||
|
||||
it('reset() clears a stale error so it does not linger over a fresh challenge', async () => {
|
||||
const { api, options } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
const { container, getCurrentInstance } = renderWidgetWithExpose()
|
||||
await flush()
|
||||
|
||||
options()!['error-callback']!()
|
||||
await flush()
|
||||
expect(container.textContent).toContain('Verification failed')
|
||||
|
||||
getCurrentInstance().reset()
|
||||
await flush()
|
||||
expect(container.textContent).not.toContain('Verification failed')
|
||||
})
|
||||
|
||||
it('reset() clears the token even when the widget never rendered', async () => {
|
||||
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
|
||||
|
||||
const { emitted, getCurrentInstance } = renderWidgetWithExpose()
|
||||
await flush()
|
||||
|
||||
getCurrentInstance().reset()
|
||||
await flush()
|
||||
|
||||
// No widget id was captured, so window.turnstile.reset is never called,
|
||||
// but the token model is still cleared.
|
||||
expect(emitted()['update:token']?.at(-1) ?? ['']).toEqual([''])
|
||||
})
|
||||
|
||||
it('removes the widget on unmount when one was rendered', async () => {
|
||||
const { api } = fakeTurnstile()
|
||||
mockLoadTurnstile.mockResolvedValue(api)
|
||||
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
|
||||
|
||||
const { unmount } = renderWidget()
|
||||
await flush()
|
||||
|
||||
unmount()
|
||||
|
||||
expect(api.remove).toHaveBeenCalledWith('widget-id')
|
||||
})
|
||||
})
|
||||
92
src/components/dialog/content/signin/TurnstileWidget.vue
Normal file
92
src/components/dialog/content/signin/TurnstileWidget.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div ref="containerRef"></div>
|
||||
<small
|
||||
v-if="errorMessage"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
class="text-red-500"
|
||||
>{{ errorMessage }}</small
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { loadTurnstile } from '@/composables/auth/turnstileScript'
|
||||
import { getTurnstileSiteKey } from '@/config/turnstile'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
|
||||
const token = defineModel<string>('token', { default: '' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const errorMessage = ref('')
|
||||
let widgetId: string | undefined
|
||||
|
||||
const clearToken = () => {
|
||||
token.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a fresh challenge and clear the current token.
|
||||
*
|
||||
* Turnstile tokens are single-use, so after a token is consumed by a submit
|
||||
* attempt that did not succeed, the spent token must be discarded and a new
|
||||
* challenge requested. Clearing the model re-blocks submission until the user
|
||||
* solves the fresh challenge; clearing the error drops any stale failure text
|
||||
* so it can't linger over the new challenge.
|
||||
*/
|
||||
const reset = () => {
|
||||
clearToken()
|
||||
errorMessage.value = ''
|
||||
if (widgetId && window.turnstile) {
|
||||
window.turnstile.reset(widgetId)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reset })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const turnstile = await loadTurnstile()
|
||||
if (!containerRef.value) return
|
||||
|
||||
const theme = colorPaletteStore.completedActivePalette.light_theme
|
||||
? 'light'
|
||||
: 'dark'
|
||||
|
||||
widgetId = turnstile.render(containerRef.value, {
|
||||
sitekey: getTurnstileSiteKey(),
|
||||
theme,
|
||||
callback: (newToken: string) => {
|
||||
errorMessage.value = ''
|
||||
token.value = newToken
|
||||
},
|
||||
'expired-callback': () => {
|
||||
clearToken()
|
||||
errorMessage.value = t('auth.turnstile.expired')
|
||||
},
|
||||
'error-callback': () => {
|
||||
clearToken()
|
||||
console.warn('Turnstile challenge failed')
|
||||
errorMessage.value = t('auth.turnstile.failed')
|
||||
if (widgetId && window.turnstile) window.turnstile.reset(widgetId)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Turnstile failed to load', error)
|
||||
errorMessage.value = t('auth.turnstile.failed')
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (widgetId && window.turnstile) {
|
||||
window.turnstile.remove(widgetId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -93,6 +93,7 @@
|
||||
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
|
||||
<NodeDragPreview />
|
||||
<VueNodeSwitchPopup />
|
||||
|
||||
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
|
||||
@@ -136,6 +137,7 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
|
||||
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
|
||||
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
|
||||
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'
|
||||
@@ -145,6 +147,7 @@ import TopbarBadges from '@/components/topbar/TopbarBadges.vue'
|
||||
import TopbarSubscribeButton from '@/components/topbar/TopbarSubscribeButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useGroupContextMenu } from '@/composables/graph/useGroupContextMenu'
|
||||
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
@@ -192,10 +195,7 @@ import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
import { forEachNode } from '@/utils/graphTraversalUtil'
|
||||
|
||||
import SelectionRectangle from './SelectionRectangle.vue'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useFeatureFlags } from '@/composables/useFeatureFlags'
|
||||
import { useCreateWorkspaceUrlLoader } from '@/platform/workspace/composables/useCreateWorkspaceUrlLoader'
|
||||
import { useInviteUrlLoader } from '@/platform/workspace/composables/useInviteUrlLoader'
|
||||
import { useUrlActionLoaders } from '@/composables/useUrlActionLoaders'
|
||||
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{
|
||||
@@ -454,16 +454,14 @@ useEventListener(
|
||||
|
||||
const comfyAppReady = ref(false)
|
||||
const workflowPersistence = useWorkflowPersistence()
|
||||
const { flags } = useFeatureFlags()
|
||||
// Set up URL loaders during setup phase so useRoute/useRouter work correctly
|
||||
const inviteUrlLoader = isCloud ? useInviteUrlLoader() : null
|
||||
const createWorkspaceUrlLoader = isCloud ? useCreateWorkspaceUrlLoader() : null
|
||||
const { runUrlActionLoaders } = useUrlActionLoaders()
|
||||
useCanvasDrop(canvasRef)
|
||||
useLitegraphSettings()
|
||||
useNodeBadge()
|
||||
|
||||
useGlobalLitegraph()
|
||||
useContextMenuTranslation()
|
||||
useGroupContextMenu()
|
||||
useCopy()
|
||||
usePaste()
|
||||
useWorkflowAutoSave()
|
||||
@@ -565,23 +563,8 @@ onMounted(async () => {
|
||||
() => canvasStore.updateSelectedItems()
|
||||
)
|
||||
|
||||
// Accept workspace invite from URL if present (e.g., ?invite=TOKEN)
|
||||
// WorkspaceAuthGate ensures flag state is resolved before GraphCanvas mounts
|
||||
if (inviteUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
await inviteUrlLoader.loadInviteFromUrl()
|
||||
}
|
||||
|
||||
// Open create workspace dialog from URL if present (e.g., ?create_workspace=1)
|
||||
if (createWorkspaceUrlLoader && flags.teamWorkspacesEnabled) {
|
||||
try {
|
||||
await createWorkspaceUrlLoader.loadCreateWorkspaceFromUrl()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[GraphCanvas] Failed to load create workspace from URL:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
// Run query-param deep-link loaders (?invite, ?create_workspace, ?pricing)
|
||||
await runUrlActionLoaders()
|
||||
|
||||
// Initialize release store to fetch releases from comfy-api (fire-and-forget)
|
||||
const { useReleaseStore } =
|
||||
|
||||
97
src/components/graph/NodeDragPreview.test.ts
Normal file
97
src/components/graph/NodeDragPreview.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue',
|
||||
() => ({
|
||||
default: { template: '<div data-testid="node-preview" />' }
|
||||
})
|
||||
)
|
||||
|
||||
const nodeDef = fromPartial<ComfyNodeDefImpl>({ name: 'TestNode' })
|
||||
|
||||
function moveMouse(clientX: number, clientY: number) {
|
||||
window.dispatchEvent(new MouseEvent('mousemove', { clientX, clientY }))
|
||||
}
|
||||
|
||||
function ghostElement() {
|
||||
return document.querySelector('[data-testid="node-preview"]')?.parentElement
|
||||
?.parentElement
|
||||
}
|
||||
|
||||
describe('NodeDragPreview', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useNodeDragToCanvas().cancelDrag()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('shows no ghost when nothing is being dragged', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
moveMouse(100, 200)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('keeps the ghost hidden until the mouse position is known', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
useNodeDragToCanvas().startDrag(nodeDef)
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('follows the mouse with an offset while dragging', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
useNodeDragToCanvas().startDrag(nodeDef)
|
||||
await nextTick()
|
||||
moveMouse(100, 200)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()?.style.transform).toBe('translate(112px, 212px)')
|
||||
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()?.style.transform).toBe('translate(112px, 212px)')
|
||||
|
||||
moveMouse(300, 400)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()?.style.transform).toBe('translate(312px, 412px)')
|
||||
})
|
||||
|
||||
it('removes the ghost when the drag is cancelled', async () => {
|
||||
render(NodeDragPreview)
|
||||
|
||||
useNodeDragToCanvas().startDrag(nodeDef)
|
||||
await nextTick()
|
||||
moveMouse(100, 200)
|
||||
vi.advanceTimersByTime(16)
|
||||
await nextTick()
|
||||
expect(ghostElement()).toBeTruthy()
|
||||
|
||||
useNodeDragToCanvas().cancelDrag()
|
||||
await nextTick()
|
||||
|
||||
expect(ghostElement()).toBeFalsy()
|
||||
})
|
||||
})
|
||||
57
src/components/graph/NodeDragPreview.vue
Normal file
57
src/components/graph/NodeDragPreview.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showGhost && rafPosition"
|
||||
class="pointer-events-none fixed top-0 left-0 z-10000 will-change-transform"
|
||||
:style="{
|
||||
transform: `translate(${rafPosition.x + 12}px, ${rafPosition.y + 12}px)`
|
||||
}"
|
||||
>
|
||||
<div class="origin-top-left scale-50 opacity-80">
|
||||
<LGraphNodePreview
|
||||
:node-def="draggedNode!"
|
||||
:widget-values="pendingWidgetValues"
|
||||
position="relative"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMouse, useRafFn } from '@vueuse/core'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
|
||||
const { isDragging, draggedNode, pendingWidgetValues } = useNodeDragToCanvas()
|
||||
|
||||
const { x, y, sourceType } = useMouse({ type: 'client' })
|
||||
|
||||
const showGhost = computed(() => Boolean(isDragging.value && draggedNode.value))
|
||||
const rafPosition = shallowRef<{ x: number; y: number }>()
|
||||
|
||||
const { pause, resume } = useRafFn(
|
||||
() => {
|
||||
if (sourceType.value === null) return
|
||||
const pos = rafPosition.value
|
||||
if (pos && pos.x === x.value && pos.y === y.value) return
|
||||
rafPosition.value = { x: x.value, y: y.value }
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
watch(
|
||||
showGhost,
|
||||
(show) => {
|
||||
if (show) {
|
||||
resume()
|
||||
} else {
|
||||
pause()
|
||||
rafPosition.value = undefined
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
126
src/components/hdr/HdrViewerContent.test.ts
Normal file
126
src/components/hdr/HdrViewerContent.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import HdrViewerContent from './HdrViewerContent.vue'
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({ downloadFile: vi.fn() }))
|
||||
|
||||
const holder = vi.hoisted(() => ({ viewer: undefined as unknown }))
|
||||
vi.mock('@/composables/useHdrViewer', () => ({
|
||||
useHdrViewer: () => holder.viewer,
|
||||
CHANNEL_MODES: ['rgb', 'r', 'g', 'b', 'a', 'luminance']
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
g: { loading: 'Loading', downloadImage: 'Download' },
|
||||
hdrViewer: {
|
||||
failedToLoad: 'Failed',
|
||||
exposure: 'Exposure',
|
||||
normalizeExposure: 'Auto exposure',
|
||||
channel: 'Channel',
|
||||
channels: {
|
||||
rgb: 'RGB',
|
||||
r: 'R',
|
||||
g: 'G',
|
||||
b: 'B',
|
||||
a: 'Alpha',
|
||||
luminance: 'Luminance'
|
||||
},
|
||||
sourceGamut: 'Source gamut',
|
||||
dither: 'Dither',
|
||||
clipWarnings: 'Clip warnings',
|
||||
fitView: 'Fit',
|
||||
histogram: 'Histogram',
|
||||
resolution: 'Resolution',
|
||||
min: 'Min',
|
||||
max: 'Max',
|
||||
mean: 'Mean',
|
||||
stdDev: 'Std dev',
|
||||
nan: 'NaN',
|
||||
inf: 'Inf'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function makeViewer(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
exposureStops: ref(0),
|
||||
dither: ref(true),
|
||||
clipWarnings: ref(false),
|
||||
gamut: ref('sRGB'),
|
||||
channel: ref('r'),
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
dimensions: ref('512 x 512'),
|
||||
stats: ref({
|
||||
min: 0,
|
||||
max: 4,
|
||||
mean: 0.5,
|
||||
stdDev: 0.2,
|
||||
nanCount: 2,
|
||||
infCount: 1
|
||||
}),
|
||||
histogram: ref(new Uint32Array([1, 2, 3, 4])),
|
||||
pixel: ref({ x: 1, y: 2, r: 0.1, g: 0.2, b: 0.3, a: 1 }),
|
||||
mount: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
fitView: vi.fn(),
|
||||
normalizeExposure: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderViewer() {
|
||||
return render(HdrViewerContent, {
|
||||
props: { imageUrl: '/api/view?filename=out.exr' },
|
||||
global: { plugins: [i18n], stubs: { Button: true } }
|
||||
})
|
||||
}
|
||||
|
||||
describe('HdrViewerContent', () => {
|
||||
beforeEach(() => {
|
||||
holder.viewer = makeViewer()
|
||||
})
|
||||
|
||||
it('renders the full statistics set including NaN/Inf', () => {
|
||||
renderViewer()
|
||||
for (const label of [
|
||||
'Resolution',
|
||||
'Min',
|
||||
'Max',
|
||||
'Mean',
|
||||
'Std dev',
|
||||
'NaN',
|
||||
'Inf'
|
||||
]) {
|
||||
screen.getByText(label)
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the pixel readout when a pixel is hovered', () => {
|
||||
renderViewer()
|
||||
expect(screen.getByTestId('hdr-pixel-readout')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('colors the histogram according to the selected channel', () => {
|
||||
holder.viewer = makeViewer({ channel: ref('g') })
|
||||
const { container } = renderViewer()
|
||||
const path = container.querySelector('svg path')
|
||||
expect(path?.getAttribute('class')).toContain('text-green-500')
|
||||
})
|
||||
|
||||
it('renders an option for each channel mode', () => {
|
||||
renderViewer()
|
||||
expect(
|
||||
screen.getByRole('option', { name: 'Luminance' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
258
src/components/hdr/HdrViewerContent.vue
Normal file
258
src/components/hdr/HdrViewerContent.vue
Normal file
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div class="flex size-full bg-base-background">
|
||||
<div class="relative flex-1">
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="absolute size-full"
|
||||
data-testid="hdr-viewer-canvas"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="viewer.loading.value"
|
||||
class="absolute inset-0 flex items-center justify-center text-base-foreground"
|
||||
>
|
||||
{{ $t('g.loading') }}...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="viewer.error.value"
|
||||
role="alert"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-base-foreground"
|
||||
>
|
||||
<i class="icon-[lucide--image-off] size-12" />
|
||||
<p class="text-sm">{{ $t('hdrViewer.failedToLoad') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="viewer.pixel.value"
|
||||
class="absolute top-2 left-2 rounded-sm bg-base-background/80 px-2 py-1 font-mono text-xs text-base-foreground"
|
||||
data-testid="hdr-pixel-readout"
|
||||
>
|
||||
<div>{{ viewer.pixel.value.x }}, {{ viewer.pixel.value.y }}</div>
|
||||
<div>
|
||||
{{ formatNum(viewer.pixel.value.r) }}
|
||||
{{ formatNum(viewer.pixel.value.g) }}
|
||||
{{ formatNum(viewer.pixel.value.b) }}
|
||||
<template v-if="viewer.pixel.value.a !== null">
|
||||
{{ formatNum(viewer.pixel.value.a) }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-72 flex-col" data-testid="hdr-viewer-sidebar">
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.exposure') }}: {{ exposureLabel }}</label>
|
||||
<input
|
||||
v-model.number="viewer.exposureStops.value"
|
||||
type="range"
|
||||
min="-10"
|
||||
max="10"
|
||||
step="0.1"
|
||||
class="w-full"
|
||||
:aria-label="$t('hdrViewer.exposure')"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="w-full"
|
||||
@click="viewer.normalizeExposure"
|
||||
>
|
||||
{{ $t('hdrViewer.normalizeExposure') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.channel') }}</label>
|
||||
<select
|
||||
v-model="viewer.channel.value"
|
||||
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
|
||||
:aria-label="$t('hdrViewer.channel')"
|
||||
>
|
||||
<option v-for="mode in channelModes" :key="mode" :value="mode">
|
||||
{{ channelLabels[mode] }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label>{{ $t('hdrViewer.sourceGamut') }}</label>
|
||||
<select
|
||||
v-model="viewer.gamut.value"
|
||||
class="bg-base-component-surface w-full rounded-sm px-2 py-1"
|
||||
:aria-label="$t('hdrViewer.sourceGamut')"
|
||||
>
|
||||
<option v-for="name in gamutNames" :key="name" :value="name">
|
||||
{{ name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="hdr-dither"
|
||||
v-model="viewer.dither.value"
|
||||
type="checkbox"
|
||||
class="size-4 cursor-pointer accent-node-component-surface-highlight"
|
||||
/>
|
||||
<label for="hdr-dither" class="cursor-pointer">
|
||||
{{ $t('hdrViewer.dither') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="hdr-clip"
|
||||
v-model="viewer.clipWarnings.value"
|
||||
type="checkbox"
|
||||
class="size-4 cursor-pointer accent-node-component-surface-highlight"
|
||||
/>
|
||||
<label for="hdr-clip" class="cursor-pointer">
|
||||
{{ $t('hdrViewer.clipWarnings') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="histogramPath" class="space-y-2 p-2">
|
||||
<label>{{ $t('hdrViewer.histogram') }}</label>
|
||||
<svg
|
||||
viewBox="0 0 1 1"
|
||||
preserveAspectRatio="none"
|
||||
class="bg-base-component-surface aspect-3/2 w-full rounded-sm"
|
||||
>
|
||||
<path
|
||||
:d="histogramPath"
|
||||
:class="histogramColorClass"
|
||||
fill="currentColor"
|
||||
fill-opacity="0.5"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="viewer.stats.value"
|
||||
class="space-y-1 p-2 text-xs tabular-nums"
|
||||
>
|
||||
<div v-if="viewer.dimensions.value" class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.resolution') }}</span>
|
||||
<span>{{ viewer.dimensions.value }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.min') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.min) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.max') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.max) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.mean') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.mean) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>{{ $t('hdrViewer.stdDev') }}</span>
|
||||
<span>{{ formatNum(viewer.stats.value.stdDev) }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="viewer.stats.value.nanCount"
|
||||
class="flex justify-between text-error"
|
||||
>
|
||||
<span>{{ $t('hdrViewer.nan') }}</span>
|
||||
<span>{{ viewer.stats.value.nanCount }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="viewer.stats.value.infCount"
|
||||
class="flex justify-between text-error"
|
||||
>
|
||||
<span>{{ $t('hdrViewer.inf') }}</span>
|
||||
<span>{{ viewer.stats.value.infCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="flex gap-2">
|
||||
<Button variant="secondary" class="flex-1" @click="viewer.fitView">
|
||||
{{ $t('hdrViewer.fitView') }}
|
||||
</Button>
|
||||
<Button variant="secondary" class="flex-1" @click="handleDownload">
|
||||
{{ $t('g.downloadImage') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import type { ChannelMode } from '@/composables/useHdrViewer'
|
||||
import { CHANNEL_MODES, useHdrViewer } from '@/composables/useHdrViewer'
|
||||
import { GAMUT_NAMES } from '@/renderer/hdr/colorGamut'
|
||||
import { toFullResolutionUrl } from '@/utils/hdrFormatUtil'
|
||||
import { histogramToPath } from '@/utils/histogramUtil'
|
||||
|
||||
const { imageUrl } = defineProps<{ imageUrl: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const viewer = useHdrViewer()
|
||||
const gamutNames = GAMUT_NAMES
|
||||
const channelModes = CHANNEL_MODES
|
||||
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
|
||||
|
||||
const exposureLabel = computed(() => {
|
||||
const value = viewer.exposureStops.value
|
||||
return `${value > 0 ? '+' : ''}${value.toFixed(1)}`
|
||||
})
|
||||
|
||||
const histogramPath = computed(() =>
|
||||
viewer.histogram.value ? histogramToPath(viewer.histogram.value) : ''
|
||||
)
|
||||
|
||||
const histogramColorClass = computed(() => {
|
||||
switch (viewer.channel.value) {
|
||||
case 'r':
|
||||
return 'text-red-500'
|
||||
case 'g':
|
||||
return 'text-green-500'
|
||||
case 'b':
|
||||
return 'text-blue-500'
|
||||
default:
|
||||
return 'text-base-foreground'
|
||||
}
|
||||
})
|
||||
|
||||
const channelLabels = computed<Record<ChannelMode, string>>(() => ({
|
||||
rgb: t('hdrViewer.channels.rgb'),
|
||||
r: t('hdrViewer.channels.r'),
|
||||
g: t('hdrViewer.channels.g'),
|
||||
b: t('hdrViewer.channels.b'),
|
||||
a: t('hdrViewer.channels.a'),
|
||||
luminance: t('hdrViewer.channels.luminance')
|
||||
}))
|
||||
|
||||
function formatNum(value: number): string {
|
||||
if (!Number.isFinite(value)) return String(value)
|
||||
return Math.abs(value) >= 1000 || (value !== 0 && Math.abs(value) < 0.001)
|
||||
? value.toExponential(3)
|
||||
: value.toFixed(4)
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
downloadFile(toFullResolutionUrl(imageUrl))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) void viewer.mount(containerRef.value, imageUrl)
|
||||
})
|
||||
</script>
|
||||
70
src/components/palette/PaletteSwatchRow.test.ts
Normal file
70
src/components/palette/PaletteSwatchRow.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import PaletteSwatchRow from './PaletteSwatchRow.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
|
||||
})
|
||||
|
||||
function renderRow(modelValue: string[], max = 5) {
|
||||
return render(PaletteSwatchRow, {
|
||||
props: { modelValue, max },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
const lastEmit = (emitted: () => Record<string, unknown[][]>) => {
|
||||
const calls = emitted()['update:modelValue']
|
||||
return calls[calls.length - 1][0]
|
||||
}
|
||||
|
||||
describe('PaletteSwatchRow', () => {
|
||||
it('renders one swatch per color', () => {
|
||||
const { container } = renderRow(['#ff0000', '#00ff00'])
|
||||
expect(container.querySelectorAll('[data-index]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('appends a color when the add button is clicked', async () => {
|
||||
const { emitted } = renderRow(['#ff0000'])
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
|
||||
})
|
||||
|
||||
it('removes a color on right click', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.contextMenu(container.querySelector('[data-index="0"]')!)
|
||||
expect(lastEmit(emitted)).toEqual(['#00ff00'])
|
||||
})
|
||||
|
||||
it('hides the add button once the max is reached', () => {
|
||||
renderRow(['#a', '#b'], 2)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('writes a picked color back through the hidden color input', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.click(container.querySelector('[data-index="1"]')!)
|
||||
const input = container.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
input.value = '#0000ff'
|
||||
await fireEvent.input(input)
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
|
||||
})
|
||||
|
||||
it('starts a drag on pointer down without emitting', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.pointerDown(container.querySelector('[data-index="0"]')!, {
|
||||
button: 0,
|
||||
clientX: 5,
|
||||
clientY: 5
|
||||
})
|
||||
expect(emitted()['update:modelValue']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
48
src/components/palette/PaletteSwatchRow.vue
Normal file
48
src/components/palette/PaletteSwatchRow.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div ref="container" class="flex flex-wrap items-center gap-1">
|
||||
<div
|
||||
v-for="(hex, i) in modelValue"
|
||||
:key="`${i}-${hex}`"
|
||||
:data-index="i"
|
||||
:data-hex="hex"
|
||||
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
|
||||
:style="{ background: hex }"
|
||||
:title="t('palette.swatchTitle')"
|
||||
@click="openPicker(i, $event)"
|
||||
@contextmenu.prevent.stop="remove(i)"
|
||||
@pointerdown="onPointerDown(i, $event)"
|
||||
/>
|
||||
<button
|
||||
v-if="modelValue.length < max"
|
||||
type="button"
|
||||
class="h-5 rounded-sm border border-component-node-border bg-component-node-widget-background px-2 text-xs leading-none"
|
||||
:title="t('palette.addColor')"
|
||||
@click="addColor"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<input
|
||||
ref="picker"
|
||||
type="color"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
@input="onPickerInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
|
||||
|
||||
const { max = 5 } = defineProps<{ max?: number }>()
|
||||
const modelValue = defineModel<string[]>({ required: true })
|
||||
const { t } = useI18n()
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const picker = useTemplateRef<HTMLInputElement>('picker')
|
||||
|
||||
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
</script>
|
||||
54
src/components/palette/WidgetColors.test.ts
Normal file
54
src/components/palette/WidgetColors.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/* eslint-disable testing-library/no-node-access, testing-library/no-container, testing-library/prefer-user-event */
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import WidgetColors from './WidgetColors.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { palette: { swatchTitle: 'Edit', addColor: 'Add' } } }
|
||||
})
|
||||
|
||||
function renderWidget(modelValue: string[], widget?: { name: string }) {
|
||||
return render(WidgetColors, {
|
||||
props: { modelValue, widget },
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
}
|
||||
|
||||
const cleanups: Array<() => void> = []
|
||||
afterEach(() => {
|
||||
while (cleanups.length) cleanups.pop()?.()
|
||||
})
|
||||
|
||||
describe('WidgetColors', () => {
|
||||
it('renders the palette swatch row for each color', () => {
|
||||
renderWidget(['#ff0000', '#00ff00'])
|
||||
const root = screen.getByTestId('colors')
|
||||
expect(root.querySelectorAll('[data-index]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('shows the widget name as an inline label', () => {
|
||||
renderWidget(['#ff0000'], { name: 'color_palette' })
|
||||
expect(screen.getByText('color_palette')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits an updated palette when a color is added', async () => {
|
||||
const { emitted } = renderWidget([])
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
const calls = emitted()['update:modelValue'] as unknown[][]
|
||||
expect(calls[calls.length - 1][0]).toEqual(['#ffffff'])
|
||||
})
|
||||
|
||||
it('does not stop swatch pointer moves from reaching document drag handlers', async () => {
|
||||
const { container } = renderWidget(['#ff0000'])
|
||||
const onDocMove = vi.fn()
|
||||
document.addEventListener('pointermove', onDocMove)
|
||||
cleanups.push(() => document.removeEventListener('pointermove', onDocMove))
|
||||
await fireEvent.pointerMove(container.querySelector('[data-index="0"]')!)
|
||||
expect(onDocMove).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
29
src/components/palette/WidgetColors.vue
Normal file
29
src/components/palette/WidgetColors.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex size-full items-center gap-2"
|
||||
data-testid="colors"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<span
|
||||
v-if="widget?.name"
|
||||
class="shrink-0 truncate text-node-component-slot-text"
|
||||
>
|
||||
{{ widget.label || widget.name }}
|
||||
</span>
|
||||
<PaletteSwatchRow v-model="modelValue" :max="MAX_COLORS" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import PaletteSwatchRow from './PaletteSwatchRow.vue'
|
||||
|
||||
const MAX_COLORS = 16
|
||||
|
||||
const { widget } = defineProps<{
|
||||
widget?: Pick<SimplifiedWidget<string[]>, 'name' | 'label'>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string[]>({ default: () => [] })
|
||||
</script>
|
||||
@@ -66,7 +66,6 @@ import { useQueueProgress } from '@/composables/queue/useQueueProgress'
|
||||
import { useResultGallery } from '@/composables/queue/useResultGallery'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useAssetSelectionStore } from '@/platform/assets/composables/useAssetSelectionStore'
|
||||
import { isCloud } from '@/platform/distribution/types'
|
||||
import { useSurveyFeatureTracking } from '@/platform/surveys/useSurveyFeatureTracking'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
@@ -195,20 +194,15 @@ const onCancelItem = wrapWithErrorHandlingAsync(async (item: JobListItem) => {
|
||||
const jobId = item.taskRef?.jobId
|
||||
if (!jobId) return
|
||||
|
||||
if (item.state === 'running' || item.state === 'initialization') {
|
||||
// Running/initializing jobs: interrupt execution
|
||||
// Cloud backend uses deleteItem, local uses interrupt
|
||||
if (isCloud) {
|
||||
await api.deleteItem('queue', jobId)
|
||||
} else {
|
||||
await api.interrupt(jobId)
|
||||
}
|
||||
if (
|
||||
item.state === 'running' ||
|
||||
item.state === 'initialization' ||
|
||||
item.state === 'pending'
|
||||
) {
|
||||
// State-agnostic cancel (see api.ts cancelJob for the runtime-parity caveat).
|
||||
await api.cancelJob(jobId)
|
||||
executionStore.clearInitializationByJobId(jobId)
|
||||
await queueStore.update()
|
||||
} else if (item.state === 'pending') {
|
||||
// Pending jobs: remove from queue
|
||||
await api.deleteItem('queue', jobId)
|
||||
await queueStore.update()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -292,17 +286,8 @@ const interruptAll = wrapWithErrorHandlingAsync(async () => {
|
||||
|
||||
if (!jobIds.length) return
|
||||
|
||||
// Cloud backend supports cancelling specific jobs via /queue delete,
|
||||
// while /interrupt always targets the "first" job. Use the targeted API
|
||||
// on cloud to ensure we cancel the workflow the user clicked.
|
||||
if (isCloud) {
|
||||
await Promise.all(jobIds.map((id) => api.deleteItem('queue', id)))
|
||||
executionStore.clearInitializationByJobIds(jobIds)
|
||||
await queueStore.update()
|
||||
return
|
||||
}
|
||||
|
||||
await Promise.all(jobIds.map((id) => api.interrupt(id)))
|
||||
// State-agnostic batch cancel (see api.ts cancelJobs for the runtime-parity caveat).
|
||||
await api.cancelJobs(jobIds)
|
||||
executionStore.clearInitializationByJobIds(jobIds)
|
||||
await queueStore.update()
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, ref } from 'vue'
|
||||
@@ -165,7 +166,9 @@ describe('WidgetRange', () => {
|
||||
outputsHolder.nodeOutputs = {
|
||||
loc1: { histogram_range_w: [1, 2, 3, 4] }
|
||||
}
|
||||
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
|
||||
renderWidget(
|
||||
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
|
||||
)
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'true'
|
||||
)
|
||||
@@ -175,7 +178,9 @@ describe('WidgetRange', () => {
|
||||
outputsHolder.nodeOutputs = {
|
||||
loc1: { histogram_range_w: [] }
|
||||
}
|
||||
renderWidget(makeWidget({}, { nodeLocatorId: 'loc1' }))
|
||||
renderWidget(
|
||||
makeWidget({}, { nodeLocatorId: createNodeLocatorId(null, 'loc1') })
|
||||
)
|
||||
expect(screen.getByTestId('range-editor').dataset.hasHistogram).toBe(
|
||||
'false'
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
const meta: Meta<typeof ErrorNodeCard> = {
|
||||
title: 'RightSidePanel/Errors/ErrorNodeCard',
|
||||
@@ -23,9 +24,8 @@ type Story = StoryObj<typeof meta>
|
||||
const singleErrorCard: ErrorCardData = {
|
||||
id: 'node-10',
|
||||
title: 'CLIPTextEncode',
|
||||
nodeId: '10',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeTitle: 'CLIP Text Encode (Prompt)',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input "text" is missing.',
|
||||
@@ -37,9 +37,8 @@ const singleErrorCard: ErrorCardData = {
|
||||
const multipleErrorsCard: ErrorCardData = {
|
||||
id: 'node-24',
|
||||
title: 'VAEDecode',
|
||||
nodeId: '24',
|
||||
nodeId: createNodeExecutionId([24]),
|
||||
nodeTitle: 'VAE Decode',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'Required input "samples" is missing.',
|
||||
@@ -55,9 +54,8 @@ const multipleErrorsCard: ErrorCardData = {
|
||||
const runtimeErrorCard: ErrorCardData = {
|
||||
id: 'exec-45',
|
||||
title: 'KSampler',
|
||||
nodeId: '45',
|
||||
nodeId: createNodeExecutionId([45]),
|
||||
nodeTitle: 'KSampler',
|
||||
isSubgraphNode: false,
|
||||
errors: [
|
||||
{
|
||||
message: 'OutOfMemoryError: CUDA out of memory. Tried to allocate 1.2GB.',
|
||||
@@ -72,20 +70,6 @@ const runtimeErrorCard: ErrorCardData = {
|
||||
]
|
||||
}
|
||||
|
||||
const subgraphErrorCard: ErrorCardData = {
|
||||
id: 'node-3:15',
|
||||
title: 'KSampler',
|
||||
nodeId: '3:15',
|
||||
nodeTitle: 'Nested KSampler',
|
||||
isSubgraphNode: true,
|
||||
errors: [
|
||||
{
|
||||
message: 'Latent input is required.',
|
||||
details: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const promptOnlyCard: ErrorCardData = {
|
||||
id: '__prompt__',
|
||||
title: 'Prompt has no outputs.',
|
||||
@@ -103,13 +87,6 @@ export const SingleValidationError: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
/** Subgraph node error — shows "Enter subgraph" button */
|
||||
export const WithEnterSubgraphButton: Story = {
|
||||
args: {
|
||||
card: subgraphErrorCard
|
||||
}
|
||||
}
|
||||
|
||||
/** Multiple validation errors on one node */
|
||||
export const MultipleErrors: Story = {
|
||||
args: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import ErrorNodeCard from './ErrorNodeCard.vue'
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
const mockGetLogs = vi.fn(() => Promise.resolve('mock server logs'))
|
||||
const mockSerialize = vi.fn(() => ({ nodes: [] }))
|
||||
@@ -78,7 +79,6 @@ describe('ErrorNodeCard.vue', () => {
|
||||
},
|
||||
rightSidePanel: {
|
||||
locateNode: 'Locate Node',
|
||||
enterSubgraph: 'Enter Subgraph',
|
||||
errorLog: 'Error log',
|
||||
findOnGithubTooltip: 'Search GitHub issues for related problems',
|
||||
getHelpTooltip:
|
||||
@@ -156,7 +156,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
return {
|
||||
id: `exec-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: '10',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
@@ -249,7 +249,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
renderCard({
|
||||
id: `node-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: '10',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
@@ -387,7 +387,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
const card: ErrorCardData = {
|
||||
id: `exec-${++cardIdCounter}`,
|
||||
title: 'KSampler',
|
||||
nodeId: '10',
|
||||
nodeId: createNodeExecutionId([10]),
|
||||
nodeTitle: 'KSampler',
|
||||
errors: [
|
||||
{
|
||||
|
||||
@@ -21,15 +21,6 @@
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<Button
|
||||
v-if="card.isSubgraphNode"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="shrink-0 focus-visible:ring-inset"
|
||||
@click.stop="handleEnterSubgraph"
|
||||
>
|
||||
{{ t('rightSidePanel.enterSubgraph') }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="hasRuntimeError"
|
||||
variant="textonly"
|
||||
@@ -202,7 +193,6 @@ const { card, compact = false } = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
locateNode: [nodeId: string]
|
||||
enterSubgraph: [nodeId: string]
|
||||
copyToClipboard: [text: string]
|
||||
}>()
|
||||
|
||||
@@ -233,12 +223,6 @@ function handleLocateNode() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnterSubgraph() {
|
||||
if (card.nodeId) {
|
||||
emit('enterSubgraph', card.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyError(idx: number) {
|
||||
const details = displayedDetailsMap.value[idx]
|
||||
const message = getCopyMessage(card.errors[idx])
|
||||
|
||||
@@ -11,7 +11,6 @@ import type { MissingModelCandidate } from '@/platform/missingModel/types'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
const mockFocusNode = vi.hoisted(() => vi.fn())
|
||||
const mockEnterSubgraph = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
@@ -35,16 +34,9 @@ vi.mock('@/composables/useCopyToClipboard', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: vi.fn(() => ({
|
||||
fitView: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/canvas/useFocusNode', () => ({
|
||||
useFocusNode: vi.fn(() => ({
|
||||
focusNode: mockFocusNode,
|
||||
enterSubgraph: mockEnterSubgraph
|
||||
focusNode: mockFocusNode
|
||||
}))
|
||||
}))
|
||||
|
||||
|
||||
@@ -249,7 +249,6 @@
|
||||
:card="card"
|
||||
:compact="isSingleNodeSelected"
|
||||
@locate-node="handleLocateNode"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
@copy-to-clipboard="copyToClipboard"
|
||||
/>
|
||||
</div>
|
||||
@@ -357,7 +356,7 @@ const ErrorPanelSurveyCta =
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useCopyToClipboard()
|
||||
const { focusNode, enterSubgraph } = useFocusNode()
|
||||
const { focusNode } = useFocusNode()
|
||||
const { openGitHubIssues, contactSupport } = useErrorActions()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
@@ -523,8 +522,4 @@ function handleReplaceGroup(group: SwapNodeGroup) {
|
||||
function handleReplaceAll() {
|
||||
replaceAllGroups(swapNodeGroups.value)
|
||||
}
|
||||
|
||||
function handleEnterSubgraph(nodeId: string) {
|
||||
enterSubgraph(nodeId, errorNodeCache.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ResolvedErrorMessage } from '@/platform/errorCatalog/types'
|
||||
import type { NodeExecutionId } from '@/types/nodeIdentification'
|
||||
|
||||
export interface ErrorItem extends ResolvedErrorMessage {
|
||||
/** Raw source/API-compatible message. */
|
||||
@@ -12,10 +13,9 @@ export interface ErrorItem extends ResolvedErrorMessage {
|
||||
export interface ErrorCardData {
|
||||
id: string
|
||||
title: string
|
||||
nodeId?: string
|
||||
nodeId?: NodeExecutionId
|
||||
nodeTitle?: string
|
||||
graphNodeId?: string
|
||||
isSubgraphNode?: boolean
|
||||
errors: ErrorItem[]
|
||||
}
|
||||
|
||||
|
||||
@@ -39,8 +39,8 @@ import {
|
||||
resolveRunErrorMessage
|
||||
} from '@/platform/errorCatalog/errorMessageResolver'
|
||||
import {
|
||||
isNodeExecutionId,
|
||||
compareExecutionId
|
||||
compareExecutionId,
|
||||
tryNormalizeNodeExecutionId
|
||||
} from '@/types/nodeIdentification'
|
||||
|
||||
const PROMPT_CARD_ID = '__prompt__'
|
||||
@@ -82,7 +82,7 @@ interface ErrorSearchItem {
|
||||
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
|
||||
|
||||
/** Resolve display info for a node by its execution ID. */
|
||||
function resolveNodeInfo(nodeId: string) {
|
||||
function resolveNodeInfo(nodeId: NodeExecutionId) {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, nodeId)
|
||||
|
||||
return {
|
||||
@@ -119,7 +119,7 @@ function getOrCreateGroup(
|
||||
}
|
||||
|
||||
function createErrorCard(
|
||||
nodeId: string,
|
||||
nodeId: NodeExecutionId,
|
||||
classType: string,
|
||||
idPrefix: string
|
||||
): ErrorCardData {
|
||||
@@ -130,7 +130,6 @@ function createErrorCard(
|
||||
nodeId,
|
||||
nodeTitle: nodeInfo.title,
|
||||
graphNodeId: nodeInfo.graphNodeId,
|
||||
isSubgraphNode: isNodeExecutionId(nodeId),
|
||||
errors: []
|
||||
}
|
||||
}
|
||||
@@ -288,7 +287,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return map
|
||||
})
|
||||
|
||||
function isErrorInSelection(executionNodeId: string): boolean {
|
||||
function isErrorInSelection(executionNodeId: NodeExecutionId): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
|
||||
@@ -305,7 +304,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
|
||||
function addNodeErrorToGroup(
|
||||
groupsMap: Map<string, GroupEntry>,
|
||||
nodeId: string,
|
||||
nodeId: NodeExecutionId,
|
||||
classType: string,
|
||||
idPrefix: string,
|
||||
error: CataloguedErrorItem,
|
||||
@@ -371,9 +370,11 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
) {
|
||||
if (!executionErrorStore.lastNodeErrors) return
|
||||
|
||||
for (const [nodeId, nodeError] of Object.entries(
|
||||
for (const [rawNodeId, nodeError] of Object.entries(
|
||||
executionErrorStore.lastNodeErrors
|
||||
)) {
|
||||
const nodeId = tryNormalizeNodeExecutionId(rawNodeId)
|
||||
if (!nodeId) continue
|
||||
const nodeDisplayName =
|
||||
resolveNodeInfo(nodeId).title || nodeError.class_type
|
||||
for (const e of nodeError.errors) {
|
||||
@@ -404,9 +405,12 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
if (!executionErrorStore.lastExecutionError) return
|
||||
|
||||
const e = executionErrorStore.lastExecutionError
|
||||
const nodeId = tryNormalizeNodeExecutionId(e.node_id)
|
||||
if (!nodeId) return
|
||||
|
||||
addNodeErrorToGroup(
|
||||
groupsMap,
|
||||
String(e.node_id),
|
||||
nodeId,
|
||||
e.node_type,
|
||||
'exec',
|
||||
{
|
||||
@@ -417,8 +421,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
...resolveRunErrorMessage({
|
||||
kind: 'execution',
|
||||
error: e,
|
||||
nodeDisplayName:
|
||||
resolveNodeInfo(String(e.node_id)).title || e.node_type
|
||||
nodeDisplayName: resolveNodeInfo(nodeId).title || e.node_type
|
||||
})
|
||||
},
|
||||
filterBySelection
|
||||
@@ -669,7 +672,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
]
|
||||
}
|
||||
|
||||
function isAssetErrorInSelection(executionNodeId: string): boolean {
|
||||
function isAssetErrorInSelection(executionNodeId: NodeExecutionId): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
|
||||
@@ -691,12 +694,17 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
return false
|
||||
}
|
||||
|
||||
function isAssetCandidateInSelection(nodeId: string | number): boolean {
|
||||
const executionNodeId = tryNormalizeNodeExecutionId(nodeId)
|
||||
return executionNodeId ? isAssetErrorInSelection(executionNodeId) : false
|
||||
}
|
||||
|
||||
const filteredMissingModelGroups = computed(() => {
|
||||
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
|
||||
const candidates = missingModelStore.missingModelCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
|
||||
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
return groupMissingModelCandidates(filtered, isCloud)
|
||||
@@ -707,7 +715,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
|
||||
const candidates = missingMediaStore.missingMediaCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
|
||||
(c) => c.nodeId != null && isAssetCandidateInSelection(c.nodeId)
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
return groupCandidatesByMediaType(filtered)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { nextTick, ref } from 'vue'
|
||||
import type { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
|
||||
import type { ErrorCardData } from './types'
|
||||
import { createNodeExecutionId } from '@/types/nodeIdentification'
|
||||
import { useErrorReport } from './useErrorReport'
|
||||
|
||||
async function flushPromises() {
|
||||
@@ -103,7 +104,7 @@ function makeCard(overrides: Partial<ErrorCardData> = {}): ErrorCardData {
|
||||
return {
|
||||
id: 'card-1',
|
||||
title: 'KSampler',
|
||||
nodeId: '42',
|
||||
nodeId: createNodeExecutionId([42]),
|
||||
errors: [],
|
||||
...overrides
|
||||
}
|
||||
@@ -181,7 +182,7 @@ describe('useErrorReport', () => {
|
||||
exceptionType: 'RuntimeError',
|
||||
exceptionMessage: 'CUDA oom',
|
||||
traceback: 'trace-0',
|
||||
nodeId: '42',
|
||||
nodeId: createNodeExecutionId([42]),
|
||||
nodeType: 'KSampler',
|
||||
systemStats: sampleSystemStats,
|
||||
serverLogs: 'server logs',
|
||||
|
||||
@@ -3,17 +3,12 @@ import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
|
||||
type SidebarIconProps = {
|
||||
icon: string
|
||||
selected: boolean
|
||||
tooltip?: string
|
||||
class?: string
|
||||
iconBadge?: string | (() => string | null)
|
||||
}
|
||||
type SidebarIconProps = ComponentProps<typeof SidebarIcon>
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
@@ -84,4 +79,20 @@ describe('SidebarIcon', () => {
|
||||
tooltipText
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to label for tooltip when no tooltip is provided', async () => {
|
||||
const labelText = 'WASNodeSuitePreprocessors'
|
||||
const { user } = renderSidebarIcon({ label: labelText })
|
||||
|
||||
expect(screen.getByRole('button')).toHaveAttribute('aria-label', labelText)
|
||||
|
||||
await user.hover(screen.getByRole('button'))
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(labelText)
|
||||
},
|
||||
{ timeout: 1000 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -40,9 +40,11 @@
|
||||
</span>
|
||||
</div>
|
||||
</slot>
|
||||
<!-- w-max sizes the label to the rail instead of the padding-inset
|
||||
button content box, which is too narrow for one-line labels -->
|
||||
<span
|
||||
v-if="label && !isSmall"
|
||||
class="side-bar-button-label text-center text-2xs"
|
||||
class="side-bar-button-label line-clamp-2 w-max max-w-[calc(var(--sidebar-width)-var(--sidebar-padding))] text-center text-2xs wrap-break-word whitespace-normal"
|
||||
>{{ st(label, label) }}</span
|
||||
>
|
||||
</div>
|
||||
@@ -83,7 +85,14 @@ const overlayValue = computed(() =>
|
||||
typeof iconBadge === 'function' ? (iconBadge() ?? '') : iconBadge
|
||||
)
|
||||
const shouldShowBadge = computed(() => !!overlayValue.value)
|
||||
const computedTooltip = computed(() => st(tooltip, tooltip) + tooltipSuffix)
|
||||
/**
|
||||
* Falls back to the label when no tooltip is provided, so labels clamped
|
||||
* to two lines can always be recovered in full on hover.
|
||||
*/
|
||||
const computedTooltip = computed(() => {
|
||||
const text = tooltip || label
|
||||
return st(text, text) + tooltipSuffix
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -115,69 +115,14 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div
|
||||
<MediaAssetSelectionBar
|
||||
v-if="hasSelection"
|
||||
ref="footerRef"
|
||||
class="flex h-18 w-full items-center justify-between gap-1"
|
||||
>
|
||||
<div class="flex-1 pl-4">
|
||||
<div ref="selectionCountButtonRef" class="inline-flex w-48">
|
||||
<Button
|
||||
variant="secondary"
|
||||
:class="cn(isCompact && 'text-left')"
|
||||
@click="handleDeselectAll"
|
||||
>
|
||||
{{
|
||||
isHoveringSelectionCount
|
||||
? $t('mediaAsset.selection.deselectAll')
|
||||
: $t('mediaAsset.selection.selectedCount', {
|
||||
count: totalOutputCount
|
||||
})
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink items-center-safe justify-end-safe gap-2 pr-4">
|
||||
<template v-if="isCompact">
|
||||
<!-- Compact mode: Icon only -->
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
size="icon"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- Normal mode: Icon + Text -->
|
||||
<Button
|
||||
v-if="shouldShowDeleteButton"
|
||||
variant="secondary"
|
||||
data-testid="assets-delete-selected"
|
||||
@click="handleDeleteSelected"
|
||||
>
|
||||
<span>{{ $t('mediaAsset.selection.deleteSelected') }}</span>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
data-testid="assets-download-selected"
|
||||
@click="handleDownloadSelected"
|
||||
>
|
||||
<span>{{ $t('mediaAsset.selection.downloadSelected') }}</span>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
:count="totalOutputCount"
|
||||
:show-delete="shouldShowDeleteButton"
|
||||
@deselect="handleDeselectAll"
|
||||
@download="handleDownloadSelected"
|
||||
@delete="handleDeleteSelected"
|
||||
/>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<MediaLightbox
|
||||
@@ -208,8 +153,6 @@
|
||||
import {
|
||||
useAsyncState,
|
||||
useDebounceFn,
|
||||
useElementHover,
|
||||
useResizeObserver,
|
||||
useStorage,
|
||||
useTimeoutFn
|
||||
} from '@vueuse/core'
|
||||
@@ -236,6 +179,7 @@ import TabList from '@/components/tab/TabList.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import MediaAssetContextMenu from '@/platform/assets/components/MediaAssetContextMenu.vue'
|
||||
import MediaAssetFilterBar from '@/platform/assets/components/MediaAssetFilterBar.vue'
|
||||
import MediaAssetSelectionBar from '@/platform/assets/components/MediaAssetSelectionBar.vue'
|
||||
import { getAssetType } from '@/platform/assets/composables/media/assetMappers'
|
||||
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
|
||||
import { useAssetSelection } from '@/platform/assets/composables/useAssetSelection'
|
||||
@@ -257,7 +201,6 @@ import {
|
||||
getMediaTypeFromFilename,
|
||||
isPreviewableMediaType
|
||||
} from '@/utils/formatUtil'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const Load3dViewerContent = defineAsyncComponent(
|
||||
() => import('@/components/load3d/Load3dViewerContent.vue')
|
||||
@@ -335,33 +278,6 @@ const {
|
||||
exportMultipleWorkflows
|
||||
} = useMediaAssetActions()
|
||||
|
||||
// Footer responsive behavior
|
||||
const footerRef = ref<HTMLElement | null>(null)
|
||||
const footerWidth = ref(0)
|
||||
|
||||
// Track footer width changes
|
||||
useResizeObserver(footerRef, (entries) => {
|
||||
const entry = entries[0]
|
||||
footerWidth.value = entry.contentRect.width
|
||||
})
|
||||
|
||||
// Determine if we should show compact mode (icon only)
|
||||
// Threshold matches when grid switches from 2 columns to 1 column
|
||||
// 2 columns need about ~430px
|
||||
const COMPACT_MODE_THRESHOLD_PX = 430
|
||||
const isCompact = computed(
|
||||
() => footerWidth.value > 0 && footerWidth.value <= COMPACT_MODE_THRESHOLD_PX
|
||||
)
|
||||
|
||||
// Hover state for selection count button
|
||||
const selectionCountButtonRef = ref<HTMLElement | null>(null)
|
||||
const isHoveringSelectionCount = useElementHover(selectionCountButtonRef)
|
||||
|
||||
// Total output count for all selected assets
|
||||
const totalOutputCount = computed(() => {
|
||||
return getTotalOutputCount(selectedAssets.value)
|
||||
})
|
||||
|
||||
const currentAssets = computed(() =>
|
||||
activeTab.value === 'input' ? inputAssets : outputAssets
|
||||
)
|
||||
@@ -429,6 +345,10 @@ const previewableVisibleAssets = computed(() =>
|
||||
|
||||
const selectedAssets = computed(() => getSelectedAssets(visibleAssets.value))
|
||||
|
||||
const totalOutputCount = computed(() =>
|
||||
getTotalOutputCount(selectedAssets.value)
|
||||
)
|
||||
|
||||
const isBulkMode = computed(
|
||||
() => hasSelection.value && selectedAssets.value.length > 1
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user