mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
Compare commits
2 Commits
pysssss/te
...
test/edit-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b084fc8cff | ||
|
|
21e7163e9e |
90
.github/workflows/ci-vercel-website-preview.yaml
vendored
90
.github/workflows/ci-vercel-website-preview.yaml
vendored
@@ -1,90 +0,0 @@
|
||||
---
|
||||
name: 'CI: Vercel Website Preview'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
|
||||
- name: Build project artifacts
|
||||
run: vercel build --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
id: deploy
|
||||
run: |
|
||||
URL=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add deployment URL to summary
|
||||
run: echo "**Preview:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Save PR metadata
|
||||
run: |
|
||||
mkdir -p temp/vercel-preview
|
||||
echo "${{ steps.deploy.outputs.url }}" > temp/vercel-preview/url.txt
|
||||
|
||||
- name: Upload preview metadata
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: vercel-preview
|
||||
path: temp/vercel-preview
|
||||
|
||||
deploy-production:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
|
||||
- name: Build project artifacts
|
||||
run: vercel build --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
id: deploy
|
||||
run: |
|
||||
URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add deployment URL to summary
|
||||
run: echo "**Production:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
74
.github/workflows/pr-vercel-website-preview.yaml
vendored
74
.github/workflows/pr-vercel-website-preview.yaml
vendored
@@ -1,74 +0,0 @@
|
||||
---
|
||||
name: 'PR: Vercel Website Preview'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Vercel Website Preview']
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download preview metadata
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: vercel-preview
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
path: temp/vercel-preview
|
||||
|
||||
- name: Resolve PR number from workflow_run context
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.info('No open PR found for this workflow run — skipping.');
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('skip', 'false');
|
||||
core.setOutput('number', String(pr.number));
|
||||
|
||||
- name: Read preview URL
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: meta
|
||||
run: |
|
||||
echo "url=$(cat temp/vercel-preview/url.txt)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Write report
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
run: |
|
||||
echo "**Website Preview:** ${{ steps.meta.outputs.url }}" > preview-report.md
|
||||
|
||||
- name: Post PR comment
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
report-file: ./preview-report.md
|
||||
comment-marker: '<!-- VERCEL_WEBSITE_PREVIEW -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -5,7 +5,6 @@
|
||||
"scripts": {
|
||||
"lint": "nx run @comfyorg/desktop-ui:lint",
|
||||
"typecheck": "nx run @comfyorg/desktop-ui:typecheck",
|
||||
"test:unit": "vitest run --config vitest.config.mts",
|
||||
"storybook": "storybook dev -p 6007",
|
||||
"build-storybook": "storybook build -o dist/storybook"
|
||||
},
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import StartupDisplay from '@/components/common/StartupDisplay.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: { g: { logoAlt: 'ComfyUI' } } }
|
||||
})
|
||||
|
||||
const ProgressBarStub = {
|
||||
props: ['mode', 'value', 'showValue'],
|
||||
template:
|
||||
'<div data-testid="progress-bar" :data-mode="mode" :data-value="value" />'
|
||||
}
|
||||
|
||||
function renderDisplay(
|
||||
props: {
|
||||
progressPercentage?: number
|
||||
title?: string
|
||||
statusText?: string
|
||||
hideProgress?: boolean
|
||||
fullScreen?: boolean
|
||||
} = {}
|
||||
) {
|
||||
return render(StartupDisplay, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }], i18n],
|
||||
stubs: { ProgressBar: ProgressBarStub }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('StartupDisplay', () => {
|
||||
describe('progressMode', () => {
|
||||
it('renders indeterminate mode when progressPercentage is undefined', () => {
|
||||
renderDisplay()
|
||||
expect(screen.getByTestId('progress-bar').dataset.mode).toBe(
|
||||
'indeterminate'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders determinate mode when progressPercentage is provided', () => {
|
||||
renderDisplay({ progressPercentage: 50 })
|
||||
expect(screen.getByTestId('progress-bar').dataset.mode).toBe(
|
||||
'determinate'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes progressPercentage as value to the progress bar', () => {
|
||||
renderDisplay({ progressPercentage: 75 })
|
||||
expect(screen.getByTestId('progress-bar').dataset.value).toBe('75')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hideProgress', () => {
|
||||
it('hides the progress bar when hideProgress is true', () => {
|
||||
renderDisplay({ hideProgress: true })
|
||||
expect(screen.queryByTestId('progress-bar')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the progress bar by default', () => {
|
||||
renderDisplay()
|
||||
expect(screen.getByTestId('progress-bar')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('title', () => {
|
||||
it('renders the title text when provided', () => {
|
||||
renderDisplay({ title: 'Loading...' })
|
||||
expect(screen.getByText('Loading...')).toBeDefined()
|
||||
})
|
||||
|
||||
it('does not render h1 when title is not provided', () => {
|
||||
renderDisplay()
|
||||
expect(screen.queryByRole('heading', { level: 1 })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('statusText', () => {
|
||||
it('renders statusText with data-testid attribute', () => {
|
||||
renderDisplay({ statusText: 'Starting server' })
|
||||
expect(screen.getByTestId('startup-status-text').textContent).toContain(
|
||||
'Starting server'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not render statusText element when not provided', () => {
|
||||
renderDisplay()
|
||||
expect(screen.queryByTestId('startup-status-text')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,208 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('@comfyorg/shared-frontend-utils/networkUtil', () => ({
|
||||
checkUrlReachable: vi.fn()
|
||||
}))
|
||||
|
||||
import { checkUrlReachable } from '@comfyorg/shared-frontend-utils/networkUtil'
|
||||
import UrlInput from '@/components/common/UrlInput.vue'
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
const InputTextStub = {
|
||||
props: ['modelValue', 'invalid'],
|
||||
emits: ['update:modelValue', 'blur'],
|
||||
template: `<input
|
||||
data-testid="url-input"
|
||||
:value="modelValue"
|
||||
:data-invalid="invalid"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
@blur="$emit('blur')"
|
||||
/>`
|
||||
}
|
||||
|
||||
const InputIconStub = {
|
||||
template: '<span data-testid="input-icon" />'
|
||||
}
|
||||
|
||||
const IconFieldStub = {
|
||||
template: '<div><slot /></div>'
|
||||
}
|
||||
|
||||
function renderUrlInput(
|
||||
modelValue = '',
|
||||
validateUrlFn?: (url: string) => Promise<boolean>
|
||||
) {
|
||||
return render(UrlInput, {
|
||||
props: { modelValue, ...(validateUrlFn ? { validateUrlFn } : {}) },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
InputIcon: InputIconStub,
|
||||
IconField: IconFieldStub
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('UrlInput', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('initial validation on mount', () => {
|
||||
it('stays IDLE when modelValue is empty on mount', async () => {
|
||||
renderUrlInput('')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('sets VALID state when modelValue is a reachable URL on mount', async () => {
|
||||
vi.mocked(checkUrlReachable).mockResolvedValue(true)
|
||||
renderUrlInput('https://example.com')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
it('sets INVALID state when URL is not reachable on mount', async () => {
|
||||
vi.mocked(checkUrlReachable).mockResolvedValue(false)
|
||||
renderUrlInput('https://unreachable.example')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('url-input').dataset.invalid).toBe('true')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('input handling', () => {
|
||||
it('resets validation state to IDLE on user input', async () => {
|
||||
vi.mocked(checkUrlReachable).mockResolvedValue(false)
|
||||
renderUrlInput('https://bad.example')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('url-input').dataset.invalid).toBe('true')
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.type(screen.getByTestId('url-input'), 'x')
|
||||
expect(screen.getByTestId('url-input').dataset.invalid).toBe('false')
|
||||
})
|
||||
|
||||
it('strips whitespace from typed input', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(UrlInput, {
|
||||
props: {
|
||||
modelValue: '',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
InputIcon: InputIconStub,
|
||||
IconField: IconFieldStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const input = screen.getByTestId('url-input')
|
||||
await user.type(input, 'htt ps')
|
||||
expect((input as HTMLInputElement).value).not.toContain(' ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('blur handling', () => {
|
||||
it('emits update:modelValue on blur', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(UrlInput, {
|
||||
props: {
|
||||
modelValue: 'https://example.com',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
InputIcon: InputIconStub,
|
||||
IconField: IconFieldStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('url-input'))
|
||||
await user.tab()
|
||||
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('normalizes URL on blur', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(UrlInput, {
|
||||
props: {
|
||||
modelValue: 'https://example.com',
|
||||
'onUpdate:modelValue': onUpdate
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
InputIcon: InputIconStub,
|
||||
IconField: IconFieldStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('url-input'))
|
||||
await user.tab()
|
||||
|
||||
const emittedUrl = onUpdate.mock.calls[0]?.[0]
|
||||
expect(emittedUrl).toBe('https://example.com/')
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom validateUrlFn', () => {
|
||||
it('uses custom validateUrlFn when provided', async () => {
|
||||
const customValidator = vi.fn().mockResolvedValue(true)
|
||||
renderUrlInput('https://custom.example', customValidator)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(customValidator).toHaveBeenCalledWith('https://custom.example')
|
||||
})
|
||||
|
||||
expect(checkUrlReachable).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('state-change emission', () => {
|
||||
it('emits state-change when validation state changes', async () => {
|
||||
const onStateChange = vi.fn()
|
||||
vi.mocked(checkUrlReachable).mockResolvedValue(true)
|
||||
|
||||
render(UrlInput, {
|
||||
props: {
|
||||
modelValue: 'https://example.com',
|
||||
'onState-change': onStateChange
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
InputIcon: InputIconStub,
|
||||
IconField: IconFieldStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onStateChange).toHaveBeenCalledWith(ValidationState.VALID)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,112 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => ({
|
||||
getPlatform: vi.fn().mockReturnValue('win32')
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key,
|
||||
te: () => false,
|
||||
st: (_key: string, fallback: string) => fallback
|
||||
}))
|
||||
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
import GpuPicker from '@/components/install/GpuPicker.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
missingWarn: false,
|
||||
fallbackWarn: false,
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
const HardwareOptionStub = {
|
||||
props: ['imagePath', 'placeholderText', 'subtitle', 'selected'],
|
||||
emits: ['click'],
|
||||
template:
|
||||
'<button :data-testid="placeholderText" :data-selected="selected" @click="$emit(\'click\')" >{{ placeholderText }}</button>'
|
||||
}
|
||||
|
||||
function renderPicker(device: TorchDeviceType | null = null) {
|
||||
return render(GpuPicker, {
|
||||
props: { device },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }], i18n],
|
||||
stubs: {
|
||||
HardwareOption: HardwareOptionStub,
|
||||
Tag: {
|
||||
props: ['value'],
|
||||
template: '<span data-testid="recommended-tag">{{ value }}</span>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('GpuPicker', () => {
|
||||
describe('recommended badge', () => {
|
||||
it('shows recommended badge for nvidia', () => {
|
||||
renderPicker('nvidia')
|
||||
expect(screen.getByTestId('recommended-tag')).toBeVisible()
|
||||
})
|
||||
|
||||
it('shows recommended badge for amd', () => {
|
||||
renderPicker('amd')
|
||||
expect(screen.getByTestId('recommended-tag')).toBeVisible()
|
||||
})
|
||||
|
||||
it('does not show recommended badge for cpu', () => {
|
||||
renderPicker('cpu')
|
||||
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('does not show recommended badge for unsupported', () => {
|
||||
renderPicker('unsupported')
|
||||
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('does not show recommended badge when no device is selected', () => {
|
||||
renderPicker(null)
|
||||
expect(screen.getByTestId('recommended-tag')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
describe('selection state', () => {
|
||||
it('marks nvidia as selected when device is nvidia', () => {
|
||||
renderPicker('nvidia')
|
||||
expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('true')
|
||||
})
|
||||
|
||||
it('marks cpu as selected when device is cpu', () => {
|
||||
renderPicker('cpu')
|
||||
expect(screen.getByTestId('CPU').dataset.selected).toBe('true')
|
||||
})
|
||||
|
||||
it('marks unsupported as selected when device is unsupported', () => {
|
||||
renderPicker('unsupported')
|
||||
expect(screen.getByTestId('Manual Install').dataset.selected).toBe('true')
|
||||
})
|
||||
|
||||
it('no option is selected when device is null', () => {
|
||||
renderPicker(null)
|
||||
expect(screen.getByTestId('CPU').dataset.selected).toBe('false')
|
||||
expect(screen.getByTestId('NVIDIA').dataset.selected).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('gpu options on non-darwin platform', () => {
|
||||
it('shows NVIDIA, AMD, CPU, and Manual Install options', () => {
|
||||
renderPicker(null)
|
||||
expect(screen.getByTestId('NVIDIA')).toBeDefined()
|
||||
expect(screen.getByTestId('AMD')).toBeDefined()
|
||||
expect(screen.getByTestId('CPU')).toBeDefined()
|
||||
expect(screen.getByTestId('Manual Install')).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,223 +0,0 @@
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
const mockValidateComfyUISource = vi.fn()
|
||||
const mockShowDirectoryPicker = vi.fn()
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => ({
|
||||
validateComfyUISource: mockValidateComfyUISource,
|
||||
showDirectoryPicker: mockShowDirectoryPicker
|
||||
}))
|
||||
}))
|
||||
|
||||
import MigrationPicker from '@/components/install/MigrationPicker.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
install: {
|
||||
migrationSourcePathDescription: 'Source path description',
|
||||
migrationOptional: 'Migration is optional',
|
||||
selectItemsToMigrate: 'Select items to migrate',
|
||||
pathValidationFailed: 'Validation failed',
|
||||
failedToSelectDirectory: 'Failed to select directory',
|
||||
locationPicker: {
|
||||
migrationPathPlaceholder: 'Enter path'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const InputTextStub = {
|
||||
props: ['modelValue', 'invalid'],
|
||||
emits: ['update:modelValue'],
|
||||
template: `<input
|
||||
data-testid="source-input"
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>`
|
||||
}
|
||||
|
||||
const CheckboxStub = {
|
||||
props: ['modelValue', 'inputId', 'binary'],
|
||||
emits: ['update:modelValue', 'click'],
|
||||
template: `<input
|
||||
type="checkbox"
|
||||
:data-testid="'checkbox-' + inputId"
|
||||
:checked="modelValue"
|
||||
@change="$emit('update:modelValue', $event.target.checked)"
|
||||
@click.stop="$emit('click')"
|
||||
/>`
|
||||
}
|
||||
|
||||
function renderPicker(sourcePath = '', migrationItemIds: string[] = []) {
|
||||
return render(MigrationPicker, {
|
||||
props: { sourcePath, migrationItemIds },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }], i18n],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
Checkbox: CheckboxStub,
|
||||
Button: { template: '<button data-testid="browse-btn" />' },
|
||||
Message: {
|
||||
props: ['severity'],
|
||||
template: '<div data-testid="error-msg"><slot /></div>'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('MigrationPicker', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
describe('isValidSource', () => {
|
||||
it('hides migration options when source path is empty', () => {
|
||||
renderPicker('')
|
||||
expect(screen.queryByText('Select items to migrate')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows migration options when source path is valid', async () => {
|
||||
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
|
||||
const { rerender } = renderPicker('')
|
||||
|
||||
await rerender({ sourcePath: '/valid/path' })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select items to migrate')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows optional message when no valid source', () => {
|
||||
renderPicker('')
|
||||
expect(screen.getByText('Migration is optional')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateSource', () => {
|
||||
it('clears error when source path becomes empty', async () => {
|
||||
mockValidateComfyUISource.mockResolvedValue({
|
||||
isValid: false,
|
||||
error: 'Not found'
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderPicker()
|
||||
|
||||
await user.type(screen.getByTestId('source-input'), '/bad/path')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-msg')).toBeDefined()
|
||||
})
|
||||
|
||||
await user.clear(screen.getByTestId('source-input'))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('error-msg')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows error message when validation fails', async () => {
|
||||
mockValidateComfyUISource.mockResolvedValue({
|
||||
isValid: false,
|
||||
error: 'Path not found'
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderPicker()
|
||||
|
||||
await user.type(screen.getByTestId('source-input'), '/bad/path')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error-msg')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows no error when validation passes', async () => {
|
||||
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
|
||||
|
||||
const user = userEvent.setup()
|
||||
renderPicker()
|
||||
|
||||
await user.type(screen.getByTestId('source-input'), '/valid/path')
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('error-msg')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrationItemIds watchEffect', () => {
|
||||
it('emits all item IDs by default (all items start selected)', async () => {
|
||||
const onUpdate = vi.fn()
|
||||
render(MigrationPicker, {
|
||||
props: {
|
||||
sourcePath: '',
|
||||
migrationItemIds: [],
|
||||
'onUpdate:migrationItemIds': onUpdate
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }], i18n],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
Checkbox: CheckboxStub,
|
||||
Button: { template: '<button />' },
|
||||
Message: { template: '<div />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalled()
|
||||
const emittedIds = onUpdate.mock.calls[0][0]
|
||||
expect(Array.isArray(emittedIds)).toBe(true)
|
||||
expect(emittedIds.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('browse path', () => {
|
||||
it('opens directory picker on browse click', async () => {
|
||||
mockShowDirectoryPicker.mockResolvedValue(null)
|
||||
renderPicker()
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('browse-btn'))
|
||||
|
||||
expect(mockShowDirectoryPicker).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('updates source path when directory is selected', async () => {
|
||||
mockShowDirectoryPicker.mockResolvedValue('/selected/path')
|
||||
mockValidateComfyUISource.mockResolvedValue({ isValid: true })
|
||||
|
||||
const onUpdate = vi.fn()
|
||||
render(MigrationPicker, {
|
||||
props: {
|
||||
sourcePath: '',
|
||||
'onUpdate:sourcePath': onUpdate
|
||||
},
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }], i18n],
|
||||
stubs: {
|
||||
InputText: InputTextStub,
|
||||
Checkbox: CheckboxStub,
|
||||
Button: { template: '<button data-testid="browse-btn" />' },
|
||||
Message: { template: '<div />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByTestId('browse-btn'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdate).toHaveBeenCalledWith('/selected/path')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
import StatusTag from '@/components/maintenance/StatusTag.vue'
|
||||
|
||||
const TagStub = defineComponent({
|
||||
name: 'Tag',
|
||||
props: {
|
||||
icon: String,
|
||||
severity: String,
|
||||
value: String
|
||||
},
|
||||
template: `<span data-testid="tag" :data-icon="icon" :data-severity="severity" :data-value="value">{{ value }}</span>`
|
||||
})
|
||||
|
||||
function renderStatusTag(props: { error: boolean; refreshing?: boolean }) {
|
||||
return render(StatusTag, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: { Tag: TagStub }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('StatusTag', () => {
|
||||
describe('refreshing state', () => {
|
||||
it('shows info severity when refreshing', () => {
|
||||
renderStatusTag({ error: false, refreshing: true })
|
||||
expect(screen.getByTestId('tag').dataset.severity).toBe('info')
|
||||
})
|
||||
|
||||
it('shows refreshing translation key when refreshing', () => {
|
||||
renderStatusTag({ error: false, refreshing: true })
|
||||
expect(screen.getByTestId('tag').dataset.value).toBe(
|
||||
'maintenance.refreshing'
|
||||
)
|
||||
})
|
||||
|
||||
it('shows question icon when refreshing', () => {
|
||||
renderStatusTag({ error: false, refreshing: true })
|
||||
expect(screen.getByTestId('tag').dataset.icon).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('error state', () => {
|
||||
it('shows danger severity when error is true', () => {
|
||||
renderStatusTag({ error: true })
|
||||
expect(screen.getByTestId('tag').dataset.severity).toBe('danger')
|
||||
})
|
||||
|
||||
it('shows error translation key when error is true', () => {
|
||||
renderStatusTag({ error: true })
|
||||
expect(screen.getByTestId('tag').dataset.value).toBe('g.error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('OK state', () => {
|
||||
it('shows success severity when not refreshing and not error', () => {
|
||||
renderStatusTag({ error: false })
|
||||
expect(screen.getByTestId('tag').dataset.severity).toBe('success')
|
||||
})
|
||||
|
||||
it('shows OK translation key when not refreshing and not error', () => {
|
||||
renderStatusTag({ error: false })
|
||||
expect(screen.getByTestId('tag').dataset.value).toBe('maintenance.OK')
|
||||
})
|
||||
})
|
||||
|
||||
describe('precedence', () => {
|
||||
it('shows refreshing state when both refreshing and error are true', () => {
|
||||
renderStatusTag({ error: true, refreshing: true })
|
||||
expect(screen.getByTestId('tag').dataset.severity).toBe('info')
|
||||
expect(screen.getByTestId('tag').dataset.value).toBe(
|
||||
'maintenance.refreshing'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,89 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => ({
|
||||
Validation: { validateInstallation: vi.fn() }
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
|
||||
DESKTOP_MAINTENANCE_TASKS: []
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/refUtil', () => ({
|
||||
useMinLoadingDurationRef: (source: { value: boolean }) => source
|
||||
}))
|
||||
|
||||
const mockGetRunner = vi.fn()
|
||||
vi.mock('@/stores/maintenanceTaskStore', () => ({
|
||||
useMaintenanceTaskStore: vi.fn(() => ({
|
||||
getRunner: mockGetRunner
|
||||
}))
|
||||
}))
|
||||
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
import TaskCard from '@/components/maintenance/TaskCard.vue'
|
||||
|
||||
const baseTask: MaintenanceTask = {
|
||||
id: 'testTask',
|
||||
name: 'Test Task',
|
||||
shortDescription: 'Short description',
|
||||
errorDescription: 'Error occurred',
|
||||
execute: vi.fn().mockResolvedValue(true)
|
||||
}
|
||||
|
||||
const cardStubs = {
|
||||
Card: {
|
||||
template: '<div data-testid="card"><slot name="content"></slot></div>'
|
||||
},
|
||||
Button: { template: '<button />' }
|
||||
}
|
||||
|
||||
function renderCard(
|
||||
state: 'OK' | 'error' | 'warning' | 'skipped',
|
||||
task: MaintenanceTask = baseTask
|
||||
) {
|
||||
mockGetRunner.mockReturnValue({
|
||||
state,
|
||||
executing: false,
|
||||
refreshing: false,
|
||||
resolved: false
|
||||
})
|
||||
|
||||
return render(TaskCard, {
|
||||
props: { task },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: cardStubs
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TaskCard', () => {
|
||||
describe('description computed', () => {
|
||||
it('shows errorDescription when task state is error', () => {
|
||||
renderCard('error')
|
||||
expect(screen.getByText('Error occurred')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows shortDescription when task state is OK', () => {
|
||||
renderCard('OK')
|
||||
expect(screen.getByText('Short description')).toBeDefined()
|
||||
})
|
||||
|
||||
it('shows shortDescription when task state is warning', () => {
|
||||
renderCard('warning')
|
||||
expect(screen.getByText('Short description')).toBeDefined()
|
||||
})
|
||||
|
||||
it('falls back to shortDescription when errorDescription is absent and state is error', () => {
|
||||
const taskWithoutErrorDesc: MaintenanceTask = {
|
||||
...baseTask,
|
||||
errorDescription: undefined
|
||||
}
|
||||
renderCard('error', taskWithoutErrorDesc)
|
||||
expect(screen.getByText('Short description')).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => ({
|
||||
Validation: { validateInstallation: vi.fn() }
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
|
||||
DESKTOP_MAINTENANCE_TASKS: []
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/refUtil', () => ({
|
||||
useMinLoadingDurationRef: (source: { value: boolean }) => source
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
const mockGetRunner = vi.fn()
|
||||
vi.mock('@/stores/maintenanceTaskStore', () => ({
|
||||
useMaintenanceTaskStore: vi.fn(() => ({
|
||||
getRunner: mockGetRunner
|
||||
}))
|
||||
}))
|
||||
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
import TaskListItem from '@/components/maintenance/TaskListItem.vue'
|
||||
|
||||
const baseTask: MaintenanceTask = {
|
||||
id: 'testTask',
|
||||
name: 'Test Task',
|
||||
button: { text: 'Fix', icon: 'pi pi-check' },
|
||||
execute: vi.fn().mockResolvedValue(true)
|
||||
}
|
||||
|
||||
const ButtonStub = {
|
||||
props: ['severity', 'label', 'icon', 'loading'],
|
||||
template:
|
||||
'<button :data-severity="severity" :data-label="label" :data-testid="label ? \'action-button\' : \'icon-button\'" />'
|
||||
}
|
||||
|
||||
function renderItem(state: 'OK' | 'error' | 'warning' | 'skipped') {
|
||||
mockGetRunner.mockReturnValue({
|
||||
state,
|
||||
executing: false,
|
||||
refreshing: false,
|
||||
resolved: false
|
||||
})
|
||||
|
||||
return render(TaskListItem, {
|
||||
props: { task: baseTask },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
Button: ButtonStub,
|
||||
Popover: { template: '<div />' },
|
||||
TaskListStatusIcon: { template: '<span />' }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TaskListItem', () => {
|
||||
describe('severity computed', () => {
|
||||
it('uses primary severity for error state', () => {
|
||||
renderItem('error')
|
||||
expect(screen.getByTestId('action-button').dataset.severity).toBe(
|
||||
'primary'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses primary severity for warning state', () => {
|
||||
renderItem('warning')
|
||||
expect(screen.getByTestId('action-button').dataset.severity).toBe(
|
||||
'primary'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses secondary severity for OK state', () => {
|
||||
renderItem('OK')
|
||||
expect(screen.getByTestId('action-button').dataset.severity).toBe(
|
||||
'secondary'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses secondary severity for skipped state', () => {
|
||||
renderItem('skipped')
|
||||
expect(screen.getByTestId('action-button').dataset.severity).toBe(
|
||||
'secondary'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,44 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
t: (key: string) => key
|
||||
}))
|
||||
|
||||
import TaskListStatusIcon from '@/components/maintenance/TaskListStatusIcon.vue'
|
||||
|
||||
type TaskState = 'warning' | 'error' | 'resolved' | 'OK' | 'skipped' | undefined
|
||||
|
||||
function renderIcon(state: TaskState, loading?: boolean) {
|
||||
return render(TaskListStatusIcon, {
|
||||
props: { state, loading },
|
||||
global: {
|
||||
plugins: [[PrimeVue, { unstyled: true }]],
|
||||
stubs: {
|
||||
ProgressSpinner: {
|
||||
template: '<div data-testid="spinner" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('TaskListStatusIcon', () => {
|
||||
describe('loading / no state', () => {
|
||||
it('renders spinner when state is undefined', () => {
|
||||
renderIcon(undefined)
|
||||
expect(screen.getByTestId('spinner')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders spinner when loading is true', () => {
|
||||
renderIcon('OK', true)
|
||||
expect(screen.getByTestId('spinner')).toBeDefined()
|
||||
})
|
||||
|
||||
it('hides spinner when state is defined and not loading', () => {
|
||||
renderIcon('OK', false)
|
||||
expect(screen.queryByTestId('spinner')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,124 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { mockTerminal, MockTerminal, mockFitAddon, MockFitAddon } = vi.hoisted(
|
||||
() => {
|
||||
const mockTerminal = {
|
||||
loadAddon: vi.fn(),
|
||||
attachCustomKeyEventHandler: vi.fn(),
|
||||
open: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
hasSelection: vi.fn<[], boolean>(),
|
||||
resize: vi.fn(),
|
||||
cols: 80,
|
||||
rows: 24
|
||||
}
|
||||
const MockTerminal = vi.fn(function () {
|
||||
return mockTerminal
|
||||
})
|
||||
|
||||
const mockFitAddon = {
|
||||
proposeDimensions: vi.fn().mockReturnValue({ cols: 80, rows: 24 })
|
||||
}
|
||||
const MockFitAddon = vi.fn(function () {
|
||||
return mockFitAddon
|
||||
})
|
||||
|
||||
return { mockTerminal, MockTerminal, mockFitAddon, MockFitAddon }
|
||||
}
|
||||
)
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({ Terminal: MockTerminal }))
|
||||
vi.mock('@xterm/addon-fit', () => ({ FitAddon: MockFitAddon }))
|
||||
vi.mock('@xterm/xterm/css/xterm.css', () => ({}))
|
||||
|
||||
import { withSetup } from '@/test/withSetup'
|
||||
import { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
|
||||
|
||||
function getKeyHandler(): (event: KeyboardEvent) => boolean {
|
||||
return mockTerminal.attachCustomKeyEventHandler.mock.calls[0][0]
|
||||
}
|
||||
|
||||
describe('useTerminal key event handler', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockTerminal.hasSelection.mockReturnValue(false)
|
||||
|
||||
const element = ref<HTMLElement | undefined>(undefined)
|
||||
withSetup(() => useTerminal(element))
|
||||
})
|
||||
|
||||
it('allows browser to handle copy when text is selected (Ctrl+C)', () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(true)
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
key: 'c'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('allows browser to handle copy when text is selected (Meta+C)', () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(true)
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
key: 'c'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not pass copy to browser when no text is selected', () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(false)
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
key: 'c'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('allows browser to handle paste (Ctrl+V)', () => {
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
key: 'v'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('allows browser to handle paste (Meta+V)', () => {
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: false,
|
||||
metaKey: true,
|
||||
key: 'v'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(false)
|
||||
})
|
||||
|
||||
it('does not intercept non-keydown events', () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(true)
|
||||
const event = {
|
||||
type: 'keyup',
|
||||
ctrlKey: true,
|
||||
metaKey: false,
|
||||
key: 'c'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(true)
|
||||
})
|
||||
|
||||
it('passes through unrelated key combinations', () => {
|
||||
const event = {
|
||||
type: 'keydown',
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
key: 'Enter'
|
||||
} as KeyboardEvent
|
||||
expect(getKeyHandler()(event)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,48 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockSerialize, MockSerializeAddon } = vi.hoisted(() => {
|
||||
const mockSerialize = vi.fn<[], string>()
|
||||
const MockSerializeAddon = vi.fn(function () {
|
||||
return { serialize: mockSerialize }
|
||||
})
|
||||
return { mockSerialize, MockSerializeAddon }
|
||||
})
|
||||
|
||||
vi.mock('@xterm/xterm', () => ({
|
||||
Terminal: vi.fn(function () {
|
||||
return { loadAddon: vi.fn(), dispose: vi.fn(), write: vi.fn() }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@xterm/addon-serialize', () => ({
|
||||
SerializeAddon: MockSerializeAddon
|
||||
}))
|
||||
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import { withSetup } from '@/test/withSetup'
|
||||
import { useTerminalBuffer } from '@/composables/bottomPanelTabs/useTerminalBuffer'
|
||||
|
||||
describe('useTerminalBuffer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockSerialize.mockReturnValue('')
|
||||
})
|
||||
|
||||
describe('copyTo', () => {
|
||||
it('writes serialized buffer content to the destination terminal', () => {
|
||||
mockSerialize.mockReturnValue('hello world')
|
||||
const { copyTo } = withSetup(() => useTerminalBuffer())
|
||||
const mockWrite = vi.fn()
|
||||
copyTo({ write: mockWrite } as Pick<Terminal, 'write'>)
|
||||
expect(mockWrite).toHaveBeenCalledWith('hello world')
|
||||
})
|
||||
|
||||
it('writes empty string when buffer is empty', () => {
|
||||
mockSerialize.mockReturnValue('')
|
||||
const { copyTo } = withSetup(() => useTerminalBuffer())
|
||||
const mockWrite = vi.fn()
|
||||
copyTo({ write: mockWrite } as Pick<Terminal, 'write'>)
|
||||
expect(mockWrite).toHaveBeenCalledWith('')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,50 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { DESKTOP_DIALOGS, getDialog } from '@/constants/desktopDialogs'
|
||||
|
||||
describe('getDialog', () => {
|
||||
it('returns the matching dialog for a valid ID', () => {
|
||||
const result = getDialog('reinstallVenv')
|
||||
expect(result.id).toBe('reinstallVenv')
|
||||
expect(result.title).toBe(DESKTOP_DIALOGS.reinstallVenv.title)
|
||||
expect(result.message).toBe(DESKTOP_DIALOGS.reinstallVenv.message)
|
||||
})
|
||||
|
||||
it('returns invalidDialog for an unknown string ID', () => {
|
||||
const result = getDialog('unknownDialog')
|
||||
expect(result.id).toBe('invalidDialog')
|
||||
})
|
||||
|
||||
it('returns invalidDialog when given an array of strings', () => {
|
||||
const result = getDialog(['reinstallVenv', 'other'])
|
||||
expect(result.id).toBe('invalidDialog')
|
||||
})
|
||||
|
||||
it('returns invalidDialog for empty string', () => {
|
||||
const result = getDialog('')
|
||||
expect(result.id).toBe('invalidDialog')
|
||||
})
|
||||
|
||||
it('returns a deep clone — mutations do not affect the original', () => {
|
||||
const result = getDialog('reinstallVenv')
|
||||
const originalFirstLabel = DESKTOP_DIALOGS.reinstallVenv.buttons[0].label
|
||||
result.buttons[0].label = 'Mutated'
|
||||
expect(DESKTOP_DIALOGS.reinstallVenv.buttons[0].label).toBe(
|
||||
originalFirstLabel
|
||||
)
|
||||
})
|
||||
|
||||
it('every button has a returnValue', () => {
|
||||
for (const id of Object.keys(DESKTOP_DIALOGS)) {
|
||||
const result = getDialog(id)
|
||||
for (const button of result.buttons) {
|
||||
expect(button.returnValue).toBeDefined()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('invalidDialog has a close/cancel button', () => {
|
||||
const result = getDialog('invalidDialog')
|
||||
expect(result.buttons.some((b) => b.action === 'cancel')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockElectron } = vi.hoisted(() => ({
|
||||
mockElectron: {
|
||||
setBasePath: vi.fn(),
|
||||
reinstall: vi.fn<[], Promise<void>>().mockResolvedValue(undefined),
|
||||
uv: {
|
||||
installRequirements: vi.fn<[], Promise<void>>(),
|
||||
clearCache: vi.fn<[], Promise<void>>().mockResolvedValue(undefined),
|
||||
resetVenv: vi.fn<[], Promise<void>>().mockResolvedValue(undefined)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => mockElectron)
|
||||
}))
|
||||
|
||||
import { DESKTOP_MAINTENANCE_TASKS } from '@/constants/desktopMaintenanceTasks'
|
||||
|
||||
function findTask(id: string) {
|
||||
const task = DESKTOP_MAINTENANCE_TASKS.find((t) => t.id === id)
|
||||
if (!task) throw new Error(`Task not found: ${id}`)
|
||||
return task
|
||||
}
|
||||
|
||||
describe('desktopMaintenanceTasks', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
mockElectron.reinstall.mockResolvedValue(undefined)
|
||||
mockElectron.uv.clearCache.mockResolvedValue(undefined)
|
||||
mockElectron.uv.resetVenv.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
describe('pythonPackages', () => {
|
||||
it('returns true when installation succeeds', async () => {
|
||||
mockElectron.uv.installRequirements.mockResolvedValue(undefined)
|
||||
expect(await findTask('pythonPackages').execute()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when installation throws', async () => {
|
||||
mockElectron.uv.installRequirements.mockRejectedValue(
|
||||
new Error('install failed')
|
||||
)
|
||||
expect(await findTask('pythonPackages').execute()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('URL-opening tasks', () => {
|
||||
it('git execute opens the git download page', () => {
|
||||
findTask('git').execute()
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://git-scm.com/downloads/',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('uv execute opens the uv installation page', () => {
|
||||
findTask('uv').execute()
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://docs.astral.sh/uv/getting-started/installation/',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
|
||||
it('vcRedist execute opens the VC++ redistributable download', () => {
|
||||
findTask('vcRedist').execute()
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
'https://aka.ms/vs/17/release/vc_redist.x64.exe',
|
||||
'_blank'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,288 +0,0 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import type { InstallValidation } from '@comfyorg/comfyui-electron-types'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockElectron, testTasks } = vi.hoisted(() => {
|
||||
const terminalTaskExecute = vi.fn().mockResolvedValue(true)
|
||||
const basicTaskExecute = vi.fn().mockResolvedValue(true)
|
||||
|
||||
return {
|
||||
mockElectron: {
|
||||
Validation: {
|
||||
validateInstallation: vi.fn()
|
||||
}
|
||||
},
|
||||
testTasks: [
|
||||
{
|
||||
id: 'basicTask',
|
||||
name: 'Basic Task',
|
||||
execute: basicTaskExecute
|
||||
},
|
||||
{
|
||||
id: 'terminalTask',
|
||||
name: 'Terminal Task',
|
||||
execute: terminalTaskExecute,
|
||||
usesTerminal: true,
|
||||
isInstallationFix: true
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => mockElectron)
|
||||
}))
|
||||
|
||||
vi.mock('@/constants/desktopMaintenanceTasks', () => ({
|
||||
DESKTOP_MAINTENANCE_TASKS: testTasks
|
||||
}))
|
||||
|
||||
import { useMaintenanceTaskStore } from '@/stores/maintenanceTaskStore'
|
||||
import type { MaintenanceTask } from '@/types/desktop/maintenanceTypes'
|
||||
|
||||
type PartialInstallValidation = Partial<InstallValidation> &
|
||||
Record<string, unknown>
|
||||
|
||||
function makeUpdate(
|
||||
overrides: PartialInstallValidation = {}
|
||||
): InstallValidation {
|
||||
return {
|
||||
inProgress: false,
|
||||
installState: 'installed',
|
||||
...overrides
|
||||
} as InstallValidation
|
||||
}
|
||||
|
||||
function createStore() {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
return useMaintenanceTaskStore()
|
||||
}
|
||||
|
||||
describe('useMaintenanceTaskStore', () => {
|
||||
let store: ReturnType<typeof useMaintenanceTaskStore>
|
||||
const [basicTask, terminalTask] = testTasks as MaintenanceTask[]
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
store = createStore()
|
||||
})
|
||||
|
||||
describe('processUpdate', () => {
|
||||
it('sets isRefreshing to true during in-progress update', () => {
|
||||
store.processUpdate(makeUpdate({ inProgress: true }))
|
||||
expect(store.isRefreshing).toBe(true)
|
||||
})
|
||||
|
||||
it('sets isRefreshing to false when update is complete', () => {
|
||||
store.processUpdate(makeUpdate({ inProgress: false, basicTask: 'OK' }))
|
||||
expect(store.isRefreshing).toBe(false)
|
||||
})
|
||||
|
||||
it('updates runner state for tasks present in the final update', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
expect(store.getRunner(basicTask).state).toBe('error')
|
||||
})
|
||||
|
||||
it('sets task state to warning from update', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'warning' }))
|
||||
expect(store.getRunner(basicTask).state).toBe('warning')
|
||||
})
|
||||
|
||||
it('marks runners as refreshing when task id is absent from in-progress update', () => {
|
||||
store.processUpdate(makeUpdate({ inProgress: true }))
|
||||
expect(store.getRunner(basicTask).refreshing).toBe(true)
|
||||
})
|
||||
|
||||
it('marks task as skipped when absent from final update', () => {
|
||||
store.processUpdate(makeUpdate({ inProgress: false }))
|
||||
expect(store.getRunner(basicTask).state).toBe('skipped')
|
||||
})
|
||||
|
||||
it('clears refreshing flag after final update', () => {
|
||||
store.processUpdate(makeUpdate({ inProgress: true }))
|
||||
store.processUpdate(makeUpdate({ inProgress: false }))
|
||||
expect(store.getRunner(basicTask).refreshing).toBe(false)
|
||||
})
|
||||
|
||||
it('stores lastUpdate and exposes unsafeBasePath', () => {
|
||||
store.processUpdate(makeUpdate({ unsafeBasePath: true }))
|
||||
expect(store.unsafeBasePath).toBe(true)
|
||||
})
|
||||
|
||||
it('exposes unsafeBasePathReason from the update', () => {
|
||||
store.processUpdate(
|
||||
makeUpdate({ unsafeBasePath: true, unsafeBasePathReason: 'oneDrive' })
|
||||
)
|
||||
expect(store.unsafeBasePathReason).toBe('oneDrive')
|
||||
})
|
||||
})
|
||||
|
||||
describe('anyErrors', () => {
|
||||
it('returns true when any task has error state', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
expect(store.anyErrors).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when all tasks are OK', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'OK', terminalTask: 'OK' }))
|
||||
expect(store.anyErrors).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when all tasks are warning', () => {
|
||||
store.processUpdate(
|
||||
makeUpdate({ basicTask: 'warning', terminalTask: 'warning' })
|
||||
)
|
||||
expect(store.anyErrors).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('runner state transitions', () => {
|
||||
it('marks runner as resolved when transitioning from error to OK', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
|
||||
expect(store.getRunner(basicTask).resolved).toBe(true)
|
||||
})
|
||||
|
||||
it('does not mark resolved for warning to OK transition', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'warning' }))
|
||||
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
|
||||
expect(store.getRunner(basicTask).resolved).toBeFalsy()
|
||||
})
|
||||
|
||||
it('clears resolved flag when task returns to error', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
expect(store.getRunner(basicTask).resolved).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearResolved', () => {
|
||||
it('clears resolved flags on all runners', () => {
|
||||
store.processUpdate(makeUpdate({ basicTask: 'error' }))
|
||||
store.processUpdate(makeUpdate({ basicTask: 'OK' }))
|
||||
expect(store.getRunner(basicTask).resolved).toBe(true)
|
||||
|
||||
store.clearResolved()
|
||||
expect(store.getRunner(basicTask).resolved).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('execute', () => {
|
||||
it('returns true when task execution succeeds', async () => {
|
||||
vi.mocked(basicTask.execute).mockResolvedValue(true)
|
||||
const result = await store.execute(basicTask)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when task execution fails', async () => {
|
||||
vi.mocked(basicTask.execute).mockResolvedValue(false)
|
||||
const result = await store.execute(basicTask)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('calls refreshDesktopTasks after successful installation-fix task', async () => {
|
||||
vi.mocked(terminalTask.execute).mockResolvedValue(true)
|
||||
await store.execute(terminalTask)
|
||||
expect(
|
||||
mockElectron.Validation.validateInstallation
|
||||
).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not call refreshDesktopTasks when task is not an installation fix', async () => {
|
||||
vi.mocked(basicTask.execute).mockResolvedValue(true)
|
||||
await store.execute(basicTask)
|
||||
expect(
|
||||
mockElectron.Validation.validateInstallation
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call refreshDesktopTasks when installation-fix task fails', async () => {
|
||||
vi.mocked(terminalTask.execute).mockResolvedValue(false)
|
||||
await store.execute(terminalTask)
|
||||
expect(
|
||||
mockElectron.Validation.validateInstallation
|
||||
).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets runner executing to true during task execution', async () => {
|
||||
let resolveTask!: (value: boolean) => void
|
||||
vi.mocked(basicTask.execute).mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveTask = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const executePromise = store.execute(basicTask)
|
||||
expect(store.getRunner(basicTask).executing).toBe(true)
|
||||
|
||||
resolveTask(true)
|
||||
await executePromise
|
||||
expect(store.getRunner(basicTask).executing).toBe(false)
|
||||
})
|
||||
|
||||
it('clears executing flag when task throws', async () => {
|
||||
vi.mocked(basicTask.execute).mockRejectedValue(new Error('fail'))
|
||||
await expect(store.execute(basicTask)).rejects.toThrow('fail')
|
||||
expect(store.getRunner(basicTask).executing).toBe(false)
|
||||
})
|
||||
|
||||
it('sets runner error message when task throws', async () => {
|
||||
vi.mocked(basicTask.execute).mockRejectedValue(
|
||||
new Error('something broke')
|
||||
)
|
||||
await expect(store.execute(basicTask)).rejects.toThrow()
|
||||
expect(store.getRunner(basicTask).error).toBe('something broke')
|
||||
})
|
||||
|
||||
it('clears runner error on successful execution', async () => {
|
||||
vi.mocked(basicTask.execute).mockRejectedValue(new Error('fail'))
|
||||
await expect(store.execute(basicTask)).rejects.toThrow()
|
||||
|
||||
vi.mocked(basicTask.execute).mockResolvedValue(true)
|
||||
await store.execute(basicTask)
|
||||
expect(store.getRunner(basicTask).error).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRunningTerminalCommand', () => {
|
||||
it('returns true while a terminal task is executing', async () => {
|
||||
let resolveTask!: (value: boolean) => void
|
||||
vi.mocked(terminalTask.execute).mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveTask = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const executePromise = store.execute(terminalTask)
|
||||
expect(store.isRunningTerminalCommand).toBe(true)
|
||||
|
||||
resolveTask(true)
|
||||
await executePromise
|
||||
expect(store.isRunningTerminalCommand).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when no terminal tasks are executing', () => {
|
||||
expect(store.isRunningTerminalCommand).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isRunningInstallationFix', () => {
|
||||
it('returns true while an installation-fix task is executing', async () => {
|
||||
let resolveTask!: (value: boolean) => void
|
||||
vi.mocked(terminalTask.execute).mockReturnValue(
|
||||
new Promise<boolean>((resolve) => {
|
||||
resolveTask = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const executePromise = store.execute(terminalTask)
|
||||
expect(store.isRunningInstallationFix).toBe(true)
|
||||
|
||||
resolveTask(true)
|
||||
await executePromise
|
||||
expect(store.isRunningInstallationFix).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
@@ -1,16 +0,0 @@
|
||||
import { render } from '@testing-library/vue'
|
||||
import { defineComponent } from 'vue'
|
||||
|
||||
export function withSetup<T>(composable: () => T): T {
|
||||
let result!: T
|
||||
render(
|
||||
defineComponent({
|
||||
setup() {
|
||||
result = composable()
|
||||
return {}
|
||||
},
|
||||
template: '<div />'
|
||||
})
|
||||
)
|
||||
return result
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { mockElectron } = vi.hoisted(() => ({
|
||||
mockElectron: {
|
||||
NetWork: {
|
||||
canAccessUrl: vi.fn<[url: string], Promise<boolean>>()
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/envUtil', () => ({
|
||||
electronAPI: vi.fn(() => mockElectron)
|
||||
}))
|
||||
|
||||
import { checkMirrorReachable } from '@/utils/electronMirrorCheck'
|
||||
|
||||
describe('checkMirrorReachable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('returns false for an invalid URL without calling canAccessUrl', async () => {
|
||||
const result = await checkMirrorReachable('not-a-url')
|
||||
expect(result).toBe(false)
|
||||
expect(mockElectron.NetWork.canAccessUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns false when canAccessUrl returns false', async () => {
|
||||
mockElectron.NetWork.canAccessUrl.mockResolvedValue(false)
|
||||
const result = await checkMirrorReachable('https://example.com')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true when URL is valid and canAccessUrl returns true', async () => {
|
||||
mockElectron.NetWork.canAccessUrl.mockResolvedValue(true)
|
||||
const result = await checkMirrorReachable('https://example.com')
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it('passes the mirror URL to canAccessUrl', async () => {
|
||||
const url = 'https://pypi.org/simple/'
|
||||
mockElectron.NetWork.canAccessUrl.mockResolvedValue(true)
|
||||
await checkMirrorReachable(url)
|
||||
expect(mockElectron.NetWork.canAccessUrl).toHaveBeenCalledWith(url)
|
||||
})
|
||||
|
||||
it('returns false for empty string', async () => {
|
||||
const result = await checkMirrorReachable('')
|
||||
expect(result).toBe(false)
|
||||
expect(mockElectron.NetWork.canAccessUrl).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { isElectron, isNativeWindow } from '@/utils/envUtil'
|
||||
|
||||
describe('isElectron', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns true when window.electronAPI is an object', () => {
|
||||
vi.stubGlobal('window', { ...window, electronAPI: {} })
|
||||
expect(isElectron()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when window.electronAPI is undefined', () => {
|
||||
vi.stubGlobal('window', { ...window, electronAPI: undefined })
|
||||
expect(isElectron()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when window.electronAPI is absent', () => {
|
||||
const copy = { ...window } as Record<string, unknown>
|
||||
delete copy['electronAPI']
|
||||
vi.stubGlobal('window', copy)
|
||||
expect(isElectron()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNativeWindow', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('returns true when Electron and windowControlsOverlay.visible is true', () => {
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
electronAPI: {},
|
||||
navigator: {
|
||||
...window.navigator,
|
||||
windowControlsOverlay: { visible: true }
|
||||
}
|
||||
})
|
||||
expect(isNativeWindow()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when not in Electron', () => {
|
||||
const copy = { ...window } as Record<string, unknown>
|
||||
delete copy['electronAPI']
|
||||
vi.stubGlobal('window', copy)
|
||||
expect(isNativeWindow()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when windowControlsOverlay.visible is false', () => {
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
electronAPI: {},
|
||||
navigator: {
|
||||
...window.navigator,
|
||||
windowControlsOverlay: { visible: false }
|
||||
}
|
||||
})
|
||||
expect(isNativeWindow()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when windowControlsOverlay is absent', () => {
|
||||
vi.stubGlobal('window', {
|
||||
...window,
|
||||
electronAPI: {},
|
||||
navigator: { ...window.navigator, windowControlsOverlay: undefined }
|
||||
})
|
||||
expect(isNativeWindow()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { withSetup } from '@/test/withSetup'
|
||||
import { useMinLoadingDurationRef } from '@/utils/refUtil'
|
||||
|
||||
describe('useMinLoadingDurationRef', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reflects false when source is initially false', () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source))
|
||||
expect(result.value).toBe(false)
|
||||
})
|
||||
|
||||
it('reflects true when source is initially true', () => {
|
||||
const source = ref(true)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source))
|
||||
expect(result.value).toBe(true)
|
||||
})
|
||||
|
||||
it('becomes true immediately when source transitions to true', async () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source))
|
||||
source.value = true
|
||||
await nextTick()
|
||||
expect(result.value).toBe(true)
|
||||
})
|
||||
|
||||
it('stays true within minDuration after source returns to false', async () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
|
||||
|
||||
source.value = true
|
||||
await nextTick()
|
||||
source.value = false
|
||||
await nextTick()
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
await nextTick()
|
||||
expect(result.value).toBe(true)
|
||||
})
|
||||
|
||||
it('becomes false after minDuration has elapsed', async () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
|
||||
|
||||
source.value = true
|
||||
await nextTick()
|
||||
source.value = false
|
||||
await nextTick()
|
||||
|
||||
vi.advanceTimersByTime(250)
|
||||
await nextTick()
|
||||
expect(result.value).toBe(false)
|
||||
})
|
||||
|
||||
it('remains true while source is true even after minDuration elapses', async () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source, 250))
|
||||
|
||||
source.value = true
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(500)
|
||||
await nextTick()
|
||||
expect(result.value).toBe(true)
|
||||
})
|
||||
|
||||
it('works with a computed ref as input', async () => {
|
||||
const raw = ref(false)
|
||||
const source = computed(() => raw.value)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source))
|
||||
|
||||
raw.value = true
|
||||
await nextTick()
|
||||
expect(result.value).toBe(true)
|
||||
})
|
||||
|
||||
it('uses 250ms as default minDuration', async () => {
|
||||
const source = ref(false)
|
||||
const result = withSetup(() => useMinLoadingDurationRef(source))
|
||||
|
||||
source.value = true
|
||||
await nextTick()
|
||||
source.value = false
|
||||
await nextTick()
|
||||
|
||||
vi.advanceTimersByTime(249)
|
||||
await nextTick()
|
||||
expect(result.value).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await nextTick()
|
||||
expect(result.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -13,8 +13,7 @@
|
||||
"src/**/*.ts",
|
||||
"src/**/*.vue",
|
||||
"src/**/*.d.ts",
|
||||
"vite.config.mts",
|
||||
"vitest.config.mts"
|
||||
"vite.config.mts"
|
||||
],
|
||||
"references": []
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(projectRoot, 'src'),
|
||||
'@frontend-locales': path.resolve(projectRoot, '../../src/locales')
|
||||
}
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'happy-dom',
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
setupFiles: ['./src/test/setup.ts']
|
||||
}
|
||||
})
|
||||
@@ -4,9 +4,6 @@
|
||||
"outputDirectory": "dist",
|
||||
"installCommand": "pnpm install --frozen-lockfile",
|
||||
"framework": null,
|
||||
"github": {
|
||||
"enabled": false
|
||||
},
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/pricing",
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Load3D",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 650],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "mesh_path",
|
||||
"type": "STRING",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "normal",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "camera_info",
|
||||
"type": "LOAD3D_CAMERA",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "recording_video",
|
||||
"type": "VIDEO",
|
||||
"links": null
|
||||
},
|
||||
{
|
||||
"name": "model_3d",
|
||||
"type": "FILE_3D",
|
||||
"links": [1]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Load3D"
|
||||
},
|
||||
"widgets_values": ["", 1024, 1024, "#000000"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "Preview3D",
|
||||
"pos": [520, 50],
|
||||
"size": [450, 600],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model_file",
|
||||
"type": "FILE_3D",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "Preview3D"
|
||||
},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [[1, 1, 6, 2, 0, "*"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
{
|
||||
"id": "fe4562c0-3a0b-4614-bdec-7039a58d75b8",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 2,
|
||||
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"pos": [627.5973510742188, 423.0972900390625],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 2,
|
||||
"lastLinkId": 4,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "New Subgraph",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [347.90441582814213, 417.3822440655296, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [892.5973510742188, 416.0972900390625, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"linkIds": [1],
|
||||
"pos": {
|
||||
"0": 447.9044189453125,
|
||||
"1": 437.3822326660156
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"linkIds": [2],
|
||||
"pos": {
|
||||
"0": 912.5973510742188,
|
||||
"1": 436.0972900390625
|
||||
}
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [554.8743286132812, 100.95539093017578],
|
||||
"size": [270, 262],
|
||||
"flags": { "collapsed": true },
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "model",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "positive",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "negative",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "latent_image",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [2]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "VAEEncode",
|
||||
"pos": [685.1265869140625, 439.1734619140625],
|
||||
"size": [140, 46],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "pixels",
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "LATENT",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [4]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncode"
|
||||
}
|
||||
}
|
||||
],
|
||||
"groups": [],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 1,
|
||||
"type": "CONDITIONING"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "LATENT"
|
||||
}
|
||||
],
|
||||
"extra": {}
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 0.8894351682943402,
|
||||
"offset": [58.7671207025881, 137.7124650620126]
|
||||
},
|
||||
"frontendVersion": "1.24.1"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"id": 1,
|
||||
"type": "ImageCropV2",
|
||||
"pos": [50, 50],
|
||||
"size": [400, 550],
|
||||
"size": [400, 500],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
@@ -27,7 +27,14 @@
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageCropV2"
|
||||
},
|
||||
"widgets_values": [{ "x": 0, "y": 0, "width": 512, "height": 512 }]
|
||||
"widgets_values": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 512,
|
||||
"height": 512
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
|
||||
@@ -10,7 +10,6 @@ export const DefaultGraphPositions = {
|
||||
textEncodeNode2: { x: 622, y: 400 },
|
||||
textEncodeNodeToggler: { x: 430, y: 171 },
|
||||
emptySpaceClick: { x: 35, y: 31 },
|
||||
emptyCanvasClick: { x: 50, y: 500 },
|
||||
|
||||
// Slot positions
|
||||
clipTextEncodeNode1InputSlot: { x: 427, y: 198 },
|
||||
@@ -40,7 +39,6 @@ export const DefaultGraphPositions = {
|
||||
textEncodeNode2: Position
|
||||
textEncodeNodeToggler: Position
|
||||
emptySpaceClick: Position
|
||||
emptyCanvasClick: Position
|
||||
clipTextEncodeNode1InputSlot: Position
|
||||
clipTextEncodeNode2InputSlot: Position
|
||||
clipTextEncodeNode2InputLinkPath: Position
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { assetPath } from '@e2e/fixtures/utils/paths'
|
||||
import {
|
||||
PREVIEW3D_CAMERA_AXIS_RESTORE_EPS,
|
||||
PREVIEW3D_CAMERA_ZOOM_RESTORE_EPS,
|
||||
preview3dCameraStatesDiffer as cameraStatesDiffer,
|
||||
preview3dRestoreCameraStatesMatch
|
||||
} from '@e2e/fixtures/utils/preview3dCameraState'
|
||||
import { Load3DHelper } from '@e2e/tests/load3d/Load3DHelper'
|
||||
|
||||
async function orbitDragFromCanvasCenter(
|
||||
page: Page,
|
||||
canvas: Locator,
|
||||
delta: { dx: number; dy: number },
|
||||
steps: number
|
||||
): Promise<void> {
|
||||
await canvas.scrollIntoViewIfNeeded()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const b = await canvas.boundingBox()
|
||||
return b !== null && b.width > 0 && b.height > 0
|
||||
},
|
||||
{
|
||||
timeout: 15_000,
|
||||
message:
|
||||
'3D canvas should have non-zero bounding box before orbit drag (layout / WebGL surface ready)'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const box = await canvas.boundingBox()
|
||||
expect(box, 'canvas bounding box should exist').not.toBeNull()
|
||||
const cx = box!.x + box!.width / 2
|
||||
const cy = box!.y + box!.height / 2
|
||||
await page.mouse.move(cx, cy)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(cx + delta.dx, cy + delta.dy, { steps })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
export class Preview3DPipelineContext {
|
||||
/** Matches node ids in `browser_tests/assets/3d/preview3d_pipeline.json`. */
|
||||
static readonly loadNodeId = '1'
|
||||
/** Matches node ids in `browser_tests/assets/3d/preview3d_pipeline.json`. */
|
||||
static readonly previewNodeId = '2'
|
||||
|
||||
readonly load3d: Load3DHelper
|
||||
readonly preview3d: Load3DHelper
|
||||
|
||||
constructor(readonly comfyPage: ComfyPage) {
|
||||
this.load3d = new Load3DHelper(
|
||||
comfyPage.vueNodes.getNodeLocator(Preview3DPipelineContext.loadNodeId)
|
||||
)
|
||||
this.preview3d = new Load3DHelper(
|
||||
comfyPage.vueNodes.getNodeLocator(Preview3DPipelineContext.previewNodeId)
|
||||
)
|
||||
}
|
||||
|
||||
async getModelFileWidgetValue(nodeId: string): Promise<string> {
|
||||
return this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(Number(id))
|
||||
if (!node?.widgets) return ''
|
||||
const w = node.widgets.find((x) => x.name === 'model_file')
|
||||
const v = w?.value
|
||||
return typeof v === 'string' ? v : ''
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
async getLastTimeModelFile(nodeId: string): Promise<string> {
|
||||
return this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(Number(id))
|
||||
if (!node?.properties) return ''
|
||||
const v = (node.properties as Record<string, unknown>)[
|
||||
'Last Time Model File'
|
||||
]
|
||||
return typeof v === 'string' ? v : ''
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
async getCameraStateFromProperties(nodeId: string): Promise<unknown> {
|
||||
return this.comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph.getNodeById(Number(id))
|
||||
if (!node?.properties) return null
|
||||
const cfg = (node.properties as Record<string, unknown>)['Camera Config']
|
||||
if (cfg === null || typeof cfg !== 'object') return null
|
||||
if (!('state' in cfg)) return null
|
||||
const rec = cfg as Record<string, unknown>
|
||||
return rec.state ?? null
|
||||
}, nodeId)
|
||||
}
|
||||
|
||||
async seedLoad3dWithCubeObj(): Promise<void> {
|
||||
const fileChooserPromise = this.comfyPage.page.waitForEvent('filechooser')
|
||||
await this.load3d.getUploadButton('upload 3d model').click()
|
||||
const fileChooser = await fileChooserPromise
|
||||
await fileChooser.setFiles(assetPath('cube.obj'))
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
this.getModelFileWidgetValue(Preview3DPipelineContext.loadNodeId)
|
||||
)
|
||||
.toContain('cube.obj')
|
||||
|
||||
await this.load3d.waitForModelLoaded()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async setNonDefaultLoad3dCameraState(): Promise<void> {
|
||||
const initialCamera = await this.getCameraStateFromProperties(
|
||||
Preview3DPipelineContext.loadNodeId
|
||||
)
|
||||
await orbitDragFromCanvasCenter(
|
||||
this.comfyPage.page,
|
||||
this.load3d.canvas,
|
||||
{ dx: 80, dy: 20 },
|
||||
10
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const current = await this.getCameraStateFromProperties(
|
||||
Preview3DPipelineContext.loadNodeId
|
||||
)
|
||||
if (current === null) return false
|
||||
if (initialCamera === null) return true
|
||||
return cameraStatesDiffer(current, initialCamera, 1e-4)
|
||||
},
|
||||
{
|
||||
timeout: 10_000,
|
||||
message:
|
||||
'Load3D camera state should change after orbit drag (see cameraStatesDiffer)'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async nudgePreview3dCameraIntoProperties(): Promise<void> {
|
||||
await orbitDragFromCanvasCenter(
|
||||
this.comfyPage.page,
|
||||
this.preview3d.canvas,
|
||||
{ dx: -60, dy: 20 },
|
||||
10
|
||||
)
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async alignPreview3dWorkflowUiSettings(): Promise<void> {
|
||||
await this.comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await this.comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
}
|
||||
|
||||
async queuePromptAndWaitIdle(timeoutMs: number): Promise<void> {
|
||||
await this.comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
||||
await this.comfyPage.workflow.waitForWorkflowIdle(timeoutMs)
|
||||
}
|
||||
|
||||
async assertPreview3dExecutionOutputSettled(): Promise<void> {
|
||||
const previewId = Preview3DPipelineContext.previewNodeId
|
||||
await expect
|
||||
.poll(() => this.getModelFileWidgetValue(previewId))
|
||||
.not.toBe('')
|
||||
const modelPath = await this.getModelFileWidgetValue(previewId)
|
||||
expect(modelPath.length, 'Preview3D model path populated').toBeGreaterThan(
|
||||
4
|
||||
)
|
||||
await expect
|
||||
.poll(() => this.getLastTimeModelFile(previewId))
|
||||
.toBe(modelPath)
|
||||
await this.preview3d.waitForModelLoaded()
|
||||
}
|
||||
|
||||
async assertPreview3dCanvasNonEmpty(): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const b = await this.preview3d.canvas.boundingBox()
|
||||
return (b?.width ?? 0) > 0 && (b?.height ?? 0) > 0
|
||||
})
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async getPreview3dCameraStateWhenReady(): Promise<unknown> {
|
||||
let last: unknown = null
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
last = await this.getCameraStateFromProperties(
|
||||
Preview3DPipelineContext.previewNodeId
|
||||
)
|
||||
return last !== null
|
||||
},
|
||||
{
|
||||
message:
|
||||
'Preview3D Camera Config.state should exist after orbit (cameraChanged)'
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
return last
|
||||
}
|
||||
|
||||
async saveNamedWorkflowToSidebar(prefix: string): Promise<string> {
|
||||
const workflowName = `${prefix}-${Date.now().toString(36)}`
|
||||
await this.comfyPage.menu.workflowsTab.open()
|
||||
await this.comfyPage.menu.topbar.saveWorkflow(workflowName)
|
||||
return workflowName
|
||||
}
|
||||
|
||||
async reloadPageAndWaitForAppShell(): Promise<void> {
|
||||
await this.comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await this.comfyPage.page.waitForFunction(
|
||||
() => window.app && window.app.extensionManager,
|
||||
{ timeout: 30_000 }
|
||||
)
|
||||
await this.comfyPage.page.locator('.p-blockui-mask').waitFor({
|
||||
state: 'hidden',
|
||||
timeout: 30_000
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
|
||||
async openPersistedWorkflowFromSidebar(workflowName: string): Promise<void> {
|
||||
await this.alignPreview3dWorkflowUiSettings()
|
||||
const tab = this.comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
await tab.getPersistedItem(workflowName).click()
|
||||
await this.comfyPage.workflow.waitForWorkflowIdle(30_000)
|
||||
await this.comfyPage.vueNodes.waitForNodes()
|
||||
}
|
||||
|
||||
async assertPreview3dModelPathAndLastTime(path: string): Promise<void> {
|
||||
const previewId = Preview3DPipelineContext.previewNodeId
|
||||
await expect.poll(() => this.getModelFileWidgetValue(previewId)).toBe(path)
|
||||
await expect.poll(() => this.getLastTimeModelFile(previewId)).toBe(path)
|
||||
await this.preview3d.waitForModelLoaded()
|
||||
}
|
||||
|
||||
async assertPreview3dCameraRestored(savedCamera: unknown): Promise<void> {
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
preview3dRestoreCameraStatesMatch(
|
||||
await this.getCameraStateFromProperties(
|
||||
Preview3DPipelineContext.previewNodeId
|
||||
),
|
||||
savedCamera
|
||||
),
|
||||
{
|
||||
timeout: 15_000,
|
||||
message: `Preview3D camera after reload should match saved state (axis max delta ≤ ${PREVIEW3D_CAMERA_AXIS_RESTORE_EPS}, zoom delta ≤ ${PREVIEW3D_CAMERA_ZOOM_RESTORE_EPS}; see browser_tests/fixtures/utils/preview3dCameraState.ts)`
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
export const preview3dPipelineTest = comfyPageFixture.extend<{
|
||||
preview3dPipeline: Preview3DPipelineContext
|
||||
}>({
|
||||
preview3dPipeline: async ({ comfyPage }, use) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('3d/preview3d_pipeline')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const pipeline = new Preview3DPipelineContext(comfyPage)
|
||||
await use(pipeline)
|
||||
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
}
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
interface Preview3dCameraStatePayload {
|
||||
position: { x: number; y: number; z: number }
|
||||
target: { x: number; y: number; z: number }
|
||||
zoom?: number
|
||||
cameraType?: string
|
||||
}
|
||||
|
||||
type Vec3 = { x: number; y: number; z: number }
|
||||
|
||||
function isVec3(v: unknown): v is Vec3 {
|
||||
if (v === null || typeof v !== 'object') return false
|
||||
const r = v as Record<string, unknown>
|
||||
return (
|
||||
'x' in v &&
|
||||
typeof r.x === 'number' &&
|
||||
'y' in v &&
|
||||
typeof r.y === 'number' &&
|
||||
'z' in v &&
|
||||
typeof r.z === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function isPreview3dCameraStatePayload(
|
||||
v: unknown
|
||||
): v is Preview3dCameraStatePayload {
|
||||
if (v === null || typeof v !== 'object') return false
|
||||
if (!('position' in v) || !('target' in v)) return false
|
||||
const r = v as Record<string, unknown>
|
||||
return isVec3(r.position) && isVec3(r.target)
|
||||
}
|
||||
|
||||
function vecMaxAbsDelta(a: Vec3, b: Vec3): number {
|
||||
return Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y), Math.abs(a.z - b.z))
|
||||
}
|
||||
|
||||
function vecWithinEps(a: Vec3, b: Vec3, eps: number): boolean {
|
||||
return vecMaxAbsDelta(a, b) <= eps
|
||||
}
|
||||
|
||||
/**
|
||||
* Max abs error per position/target axis when comparing restored Preview3D
|
||||
* camera state (same order of magnitude as the former 2e-2 poll tolerance).
|
||||
*/
|
||||
export const PREVIEW3D_CAMERA_AXIS_RESTORE_EPS = 0.02
|
||||
|
||||
/**
|
||||
* Max abs zoom error when comparing restored Preview3D state (aligned with
|
||||
* Playwright `toBeCloseTo(..., 5)`-style checks on typical zoom magnitudes).
|
||||
*/
|
||||
export const PREVIEW3D_CAMERA_ZOOM_RESTORE_EPS = 1e-4
|
||||
|
||||
export function preview3dRestoreCameraStatesMatch(
|
||||
a: unknown,
|
||||
b: unknown
|
||||
): boolean {
|
||||
if (!isPreview3dCameraStatePayload(a) || !isPreview3dCameraStatePayload(b)) {
|
||||
return false
|
||||
}
|
||||
if (a.cameraType !== b.cameraType) return false
|
||||
const zoomA = typeof a.zoom === 'number' ? a.zoom : 0
|
||||
const zoomB = typeof b.zoom === 'number' ? b.zoom : 0
|
||||
if (Math.abs(zoomA - zoomB) > PREVIEW3D_CAMERA_ZOOM_RESTORE_EPS) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
vecWithinEps(a.position, b.position, PREVIEW3D_CAMERA_AXIS_RESTORE_EPS) &&
|
||||
vecWithinEps(a.target, b.target, PREVIEW3D_CAMERA_AXIS_RESTORE_EPS)
|
||||
)
|
||||
}
|
||||
|
||||
export function preview3dCameraStatesDiffer(
|
||||
a: unknown,
|
||||
b: unknown,
|
||||
eps: number
|
||||
): boolean {
|
||||
if (!isPreview3dCameraStatePayload(a) || !isPreview3dCameraStatePayload(b)) {
|
||||
return true
|
||||
}
|
||||
if (a.cameraType !== b.cameraType) return true
|
||||
const zoomA = typeof a.zoom === 'number' ? a.zoom : 0
|
||||
const zoomB = typeof b.zoom === 'number' ? b.zoom : 0
|
||||
if (Math.abs(zoomA - zoomB) > eps) return true
|
||||
return !(
|
||||
vecWithinEps(a.position, b.position, eps) &&
|
||||
vecWithinEps(a.target, b.target, eps)
|
||||
)
|
||||
}
|
||||
@@ -35,13 +35,6 @@ export async function hasCanvasContent(canvas: Locator): Promise<boolean> {
|
||||
}
|
||||
|
||||
export async function triggerSerialization(page: Page): Promise<void> {
|
||||
await page.waitForFunction(() => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
const node = graph?._nodes_by_id?.['1']
|
||||
const widget = node?.widgets?.find((w) => w.name === 'mask')
|
||||
return typeof widget?.serializeValue === 'function'
|
||||
})
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const graph = window.graph as TestGraphAccess | undefined
|
||||
if (!graph) {
|
||||
@@ -57,14 +50,9 @@ export async function triggerSerialization(page: Page): Promise<void> {
|
||||
)
|
||||
}
|
||||
|
||||
const widgetIndex = node.widgets?.findIndex((w) => w.name === 'mask') ?? -1
|
||||
if (widgetIndex === -1) {
|
||||
throw new Error('Widget "mask" not found on target node 1.')
|
||||
}
|
||||
|
||||
const widget = node.widgets?.[widgetIndex]
|
||||
const widget = node.widgets?.find((w) => w.name === 'mask')
|
||||
if (!widget) {
|
||||
throw new Error(`Widget index ${widgetIndex} not found on target node 1.`)
|
||||
throw new Error('Widget "mask" not found on target node 1.')
|
||||
}
|
||||
|
||||
if (typeof widget.serializeValue !== 'function') {
|
||||
@@ -73,6 +61,6 @@ export async function triggerSerialization(page: Page): Promise<void> {
|
||||
)
|
||||
}
|
||||
|
||||
await widget.serializeValue(node, widgetIndex)
|
||||
await widget.serializeValue(node, 0)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ test.describe('Actionbar', { tag: '@ui' }, () => {
|
||||
|
||||
;(
|
||||
window.app!.extensionManager as WorkspaceStore
|
||||
).workflow.activeWorkflow?.changeTracker.captureCanvasState()
|
||||
).workflow.activeWorkflow?.changeTracker.checkState()
|
||||
}, value)
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
|
||||
window.app!.graph!.setDirtyCanvas(true, true)
|
||||
;(
|
||||
window.app!.extensionManager as WorkspaceStore
|
||||
).workflow.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
).workflow.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
await expect
|
||||
.poll(() => comfyPage.page.title())
|
||||
|
||||
@@ -71,7 +71,7 @@ async function waitForChangeTrackerSettled(
|
||||
) {
|
||||
// Visible node flags can flip before undo finishes loadGraphData() and
|
||||
// updates the tracker. Poll the tracker's own settled state so we do not
|
||||
// start the next transaction while captureCanvasState() is still gated.
|
||||
// start the next transaction while checkState() is still gated.
|
||||
await expect
|
||||
.poll(() => getChangeTrackerDebugState(comfyPage))
|
||||
.toMatchObject({
|
||||
@@ -272,42 +272,4 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
|
||||
await comfyPage.canvasOps.pan({ x: 10, y: 10 })
|
||||
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
|
||||
})
|
||||
|
||||
test('Undo preserves viewport offset', async ({ comfyPage }) => {
|
||||
// Pan to a distinct offset so we can detect drift
|
||||
await comfyPage.canvasOps.pan({ x: 200, y: 150 })
|
||||
|
||||
const viewportBefore = await comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
})
|
||||
|
||||
// Make a graph change so we have something to undo
|
||||
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
|
||||
await node.click('title')
|
||||
await node.click('collapse')
|
||||
await expect(node).toBeCollapsed()
|
||||
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
|
||||
|
||||
// Undo the collapse — viewport should be preserved
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(node).not.toBeCollapsed()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return { scale: ds.scale, offset: [...ds.offset] }
|
||||
}),
|
||||
{ timeout: 2_000 }
|
||||
)
|
||||
.toEqual({
|
||||
scale: expect.closeTo(viewportBefore.scale, 2),
|
||||
offset: [
|
||||
expect.closeTo(viewportBefore.offset[0], 0),
|
||||
expect.closeTo(viewportBefore.offset[1], 0)
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ test.describe(
|
||||
await comfyPage.workflow.setupWorkflowsDirectory({})
|
||||
})
|
||||
|
||||
test('Prevents captureCanvasState from corrupting workflow state during tab switch', async ({
|
||||
test('Prevents checkState from corrupting workflow state during tab switch', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Tab 0: default workflow (7 nodes)
|
||||
@@ -21,9 +21,9 @@ test.describe(
|
||||
// Save tab 0 so it has a unique name for tab switching
|
||||
await comfyPage.menu.topbar.saveWorkflow('workflow-a')
|
||||
|
||||
// Register an extension that forces captureCanvasState during graph loading.
|
||||
// Register an extension that forces checkState during graph loading.
|
||||
// This simulates the bug scenario where a user clicks during graph loading
|
||||
// which triggers a captureCanvasState call on the wrong graph, corrupting the activeState.
|
||||
// which triggers a checkState call on the wrong graph, corrupting the activeState.
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.registerExtension({
|
||||
name: 'TestCheckStateDuringLoad',
|
||||
@@ -35,7 +35,7 @@ test.describe(
|
||||
// ; (workflow.changeTracker.constructor as unknown as { isLoadingGraph: boolean }).isLoadingGraph = false
|
||||
|
||||
// Simulate the user clicking during graph loading
|
||||
workflow.changeTracker.captureCanvasState()
|
||||
workflow.changeTracker.checkState()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -64,29 +64,3 @@ test.describe(
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'Collapsed node links inside subgraph on first entry',
|
||||
{ tag: ['@canvas', '@node', '@vue-nodes', '@subgraph', '@screenshot'] },
|
||||
() => {
|
||||
test('renders collapsed node links correctly after fitView on first subgraph entry', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'subgraphs/subgraph-with-collapsed-node'
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
|
||||
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
||||
|
||||
// fitView runs on first entry and re-syncs slot layouts for the
|
||||
// pre-collapsed KSampler. Screenshot captures the rendered canvas
|
||||
// links to guard against regressing the stale-coordinate bug.
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'subgraph-entry-collapsed-node-links.png'
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB |
@@ -146,9 +146,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByType('KSampler')
|
||||
await ksamplerNodes[0].copy()
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.emptyCanvasClick
|
||||
})
|
||||
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
|
||||
await comfyPage.nextFrame()
|
||||
await comfyPage.clipboard.paste()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
|
||||
@@ -176,9 +174,7 @@ test.describe('Copy Paste', { tag: ['@screenshot', '@workflow'] }, () => {
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(3)
|
||||
|
||||
// Step 3: Click empty canvas area, paste image → creates new LoadImage
|
||||
await comfyPage.canvas.click({
|
||||
position: DefaultGraphPositions.emptyCanvasClick
|
||||
})
|
||||
await comfyPage.canvas.click({ position: { x: 50, y: 500 } })
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const uploadPromise2 = comfyPage.page.waitForResponse(
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Image Crop', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.workflow.loadWorkflow('widgets/image_crop_widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test(
|
||||
'Shows empty state when no input image is connected',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node.getByText('No input image connected')).toBeVisible()
|
||||
await expect(node.locator('img[alt="Crop preview"]')).toHaveCount(0)
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Renders bounding box coordinate inputs',
|
||||
{ tag: '@smoke' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node.getByText('X')).toBeVisible()
|
||||
await expect(node.getByText('Y')).toBeVisible()
|
||||
await expect(node.getByText('Width')).toBeVisible()
|
||||
await expect(node.getByText('Height')).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Renders ratio selector and lock button',
|
||||
{ tag: '@ui' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
await expect(node).toBeVisible()
|
||||
|
||||
await expect(node.getByText('Ratio')).toBeVisible()
|
||||
await expect(node.getByRole('button', { name: /lock/i })).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Lock button toggles aspect ratio lock',
|
||||
{ tag: '@ui' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
|
||||
const lockButton = node.getByRole('button', {
|
||||
name: 'Lock aspect ratio'
|
||||
})
|
||||
await expect(lockButton).toBeVisible()
|
||||
|
||||
await lockButton.click()
|
||||
await expect(
|
||||
node.getByRole('button', { name: 'Unlock aspect ratio' })
|
||||
).toBeVisible()
|
||||
|
||||
await node.getByRole('button', { name: 'Unlock aspect ratio' }).click()
|
||||
await expect(
|
||||
node.getByRole('button', { name: 'Lock aspect ratio' })
|
||||
).toBeVisible()
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Ratio selector offers expected presets',
|
||||
{ tag: '@ui' },
|
||||
async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
|
||||
const trigger = node.getByRole('combobox')
|
||||
await trigger.click()
|
||||
|
||||
const expectedRatios = ['1:1', '3:4', '4:3', '16:9', '9:16', 'Custom']
|
||||
for (const label of expectedRatios) {
|
||||
await expect(
|
||||
comfyPage.page.getByRole('option', { name: label, exact: true })
|
||||
).toBeVisible()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Programmatically setting widget value updates bounding box inputs',
|
||||
{ tag: '@ui' },
|
||||
async ({ comfyPage }) => {
|
||||
const newBounds = { x: 50, y: 100, width: 200, height: 300 }
|
||||
|
||||
await comfyPage.page.evaluate(
|
||||
({ bounds }) => {
|
||||
const node = window.app!.graph.getNodeById(1)
|
||||
const widget = node?.widgets?.find((w) => w.type === 'imagecrop')
|
||||
if (widget) {
|
||||
widget.value = bounds
|
||||
widget.callback?.(bounds)
|
||||
}
|
||||
},
|
||||
{ bounds: newBounds }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const inputs = node.locator('input[inputmode="decimal"]')
|
||||
|
||||
await expect.poll(() => inputs.nth(0).inputValue()).toBe('50')
|
||||
|
||||
await expect.poll(() => inputs.nth(1).inputValue()).toBe('100')
|
||||
|
||||
await expect.poll(() => inputs.nth(2).inputValue()).toBe('200')
|
||||
|
||||
await expect.poll(() => inputs.nth(3).inputValue()).toBe('300')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,41 +0,0 @@
|
||||
import {
|
||||
preview3dPipelineTest as test,
|
||||
Preview3DPipelineContext
|
||||
} from '@e2e/fixtures/helpers/Preview3DPipelineFixture'
|
||||
|
||||
test.describe('Preview3D execution flow', { tag: ['@slow', '@node'] }, () => {
|
||||
test('Preview3D loads model from execution output', async ({
|
||||
preview3dPipeline: pipeline
|
||||
}) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await pipeline.seedLoad3dWithCubeObj()
|
||||
await pipeline.queuePromptAndWaitIdle(90_000)
|
||||
await pipeline.assertPreview3dExecutionOutputSettled()
|
||||
await pipeline.assertPreview3dCanvasNonEmpty()
|
||||
})
|
||||
|
||||
test('Preview3D restores last model and camera after save and full reload', async ({
|
||||
preview3dPipeline: pipeline
|
||||
}) => {
|
||||
test.setTimeout(180_000)
|
||||
|
||||
await pipeline.seedLoad3dWithCubeObj()
|
||||
await pipeline.setNonDefaultLoad3dCameraState()
|
||||
await pipeline.queuePromptAndWaitIdle(90_000)
|
||||
await pipeline.assertPreview3dExecutionOutputSettled()
|
||||
await pipeline.nudgePreview3dCameraIntoProperties()
|
||||
|
||||
const savedPath = await pipeline.getModelFileWidgetValue(
|
||||
Preview3DPipelineContext.previewNodeId
|
||||
)
|
||||
const savedCamera = await pipeline.getPreview3dCameraStateWhenReady()
|
||||
const workflowName =
|
||||
await pipeline.saveNamedWorkflowToSidebar('p3d-restore')
|
||||
await pipeline.reloadPageAndWaitForAppShell()
|
||||
await pipeline.openPersistedWorkflowFromSidebar(workflowName)
|
||||
await pipeline.assertPreview3dModelPathAndLastTime(savedPath)
|
||||
await pipeline.assertPreview3dCanvasNonEmpty()
|
||||
await pipeline.assertPreview3dCameraRestored(savedCamera)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
@@ -28,85 +27,6 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function openMaskEditorDialog(comfyPage: ComfyPage) {
|
||||
const { imagePreview } = await loadImageOnNode(comfyPage)
|
||||
|
||||
await imagePreview.getByRole('region').hover()
|
||||
await comfyPage.page.getByLabel('Edit or mask image').click()
|
||||
|
||||
const dialog = comfyPage.page.locator('.mask-editor-dialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
const canvasContainer = dialog.locator('#maskEditorCanvasContainer')
|
||||
await expect(canvasContainer).toBeVisible()
|
||||
await expect(canvasContainer.locator('canvas')).toHaveCount(4)
|
||||
|
||||
return dialog
|
||||
}
|
||||
|
||||
async function getMaskCanvasPixelData(page: Page) {
|
||||
return page.evaluate(() => {
|
||||
const canvases = document.querySelectorAll(
|
||||
'#maskEditorCanvasContainer canvas'
|
||||
)
|
||||
// The mask canvas is the 3rd canvas (index 2, z-30)
|
||||
const maskCanvas = canvases[2] as HTMLCanvasElement
|
||||
if (!maskCanvas) return null
|
||||
const ctx = maskCanvas.getContext('2d')
|
||||
if (!ctx) return null
|
||||
const data = ctx.getImageData(0, 0, maskCanvas.width, maskCanvas.height)
|
||||
let nonTransparentPixels = 0
|
||||
for (let i = 3; i < data.data.length; i += 4) {
|
||||
if (data.data[i] > 0) nonTransparentPixels++
|
||||
}
|
||||
return { nonTransparentPixels, totalPixels: data.data.length / 4 }
|
||||
})
|
||||
}
|
||||
|
||||
function pollMaskPixelCount(page: Page): Promise<number> {
|
||||
return getMaskCanvasPixelData(page).then(
|
||||
(d) => d?.nonTransparentPixels ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
async function drawStrokeOnPointerZone(
|
||||
page: Page,
|
||||
dialog: ReturnType<typeof page.locator>
|
||||
) {
|
||||
const pointerZone = dialog.locator(
|
||||
'.maskEditor-ui-container [class*="w-[calc"]'
|
||||
)
|
||||
await expect(pointerZone).toBeVisible()
|
||||
|
||||
const box = await pointerZone.boundingBox()
|
||||
if (!box) throw new Error('Pointer zone bounding box not found')
|
||||
|
||||
const startX = box.x + box.width * 0.3
|
||||
const startY = box.y + box.height * 0.5
|
||||
const endX = box.x + box.width * 0.7
|
||||
const endY = box.y + box.height * 0.5
|
||||
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(endX, endY, { steps: 10 })
|
||||
await page.mouse.up()
|
||||
|
||||
return { startX, startY, endX, endY, box }
|
||||
}
|
||||
|
||||
async function drawStrokeAndExpectPixels(
|
||||
comfyPage: ComfyPage,
|
||||
dialog: ReturnType<typeof comfyPage.page.locator>
|
||||
) {
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test(
|
||||
'opens mask editor from image preview button',
|
||||
{ tag: ['@smoke', '@screenshot'] },
|
||||
@@ -132,7 +52,7 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
await expect(dialog.getByText('Save')).toBeVisible()
|
||||
await expect(dialog.getByText('Cancel')).toBeVisible()
|
||||
|
||||
await comfyPage.expectScreenshot(dialog, 'mask-editor-dialog-open.png')
|
||||
await expect(dialog).toHaveScreenshot('mask-editor-dialog-open.png')
|
||||
}
|
||||
)
|
||||
|
||||
@@ -159,245 +79,9 @@ test.describe('Mask Editor', { tag: '@vue-nodes' }, () => {
|
||||
dialog.getByRole('heading', { name: 'Mask Editor' })
|
||||
).toBeVisible()
|
||||
|
||||
await comfyPage.expectScreenshot(
|
||||
dialog,
|
||||
await expect(dialog).toHaveScreenshot(
|
||||
'mask-editor-dialog-from-context-menu.png'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
test('draws a brush stroke on the mask canvas', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
|
||||
expect(dataBefore).not.toBeNull()
|
||||
expect(dataBefore!.nonTransparentPixels).toBe(0)
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
})
|
||||
|
||||
test('undo reverts a brush stroke', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
|
||||
const undoButton = dialog.locator('button[title="Undo"]')
|
||||
await expect(undoButton).toBeVisible()
|
||||
await undoButton.click()
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
})
|
||||
|
||||
test('redo restores an undone stroke', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
|
||||
const undoButton = dialog.locator('button[title="Undo"]')
|
||||
await undoButton.click()
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
|
||||
const redoButton = dialog.locator('button[title="Redo"]')
|
||||
await expect(redoButton).toBeVisible()
|
||||
await redoButton.click()
|
||||
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
.toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('clear button removes all mask content', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
|
||||
const clearButton = dialog.getByRole('button', { name: 'Clear' })
|
||||
await expect(clearButton).toBeVisible()
|
||||
await clearButton.click()
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
})
|
||||
|
||||
test('cancel closes the dialog without saving', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
|
||||
const cancelButton = dialog.getByRole('button', { name: 'Cancel' })
|
||||
await cancelButton.click()
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
})
|
||||
|
||||
test('invert button inverts the mask', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
const dataBefore = await getMaskCanvasPixelData(comfyPage.page)
|
||||
expect(dataBefore).not.toBeNull()
|
||||
const pixelsBefore = dataBefore!.nonTransparentPixels
|
||||
|
||||
const invertButton = dialog.getByRole('button', { name: 'Invert' })
|
||||
await expect(invertButton).toBeVisible()
|
||||
await invertButton.click()
|
||||
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
.toBeGreaterThan(pixelsBefore)
|
||||
})
|
||||
|
||||
test('keyboard shortcut Ctrl+Z triggers undo', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
|
||||
const modifier = process.platform === 'darwin' ? 'Meta+z' : 'Control+z'
|
||||
await comfyPage.page.keyboard.press(modifier)
|
||||
|
||||
await expect.poll(() => pollMaskPixelCount(comfyPage.page)).toBe(0)
|
||||
})
|
||||
|
||||
test(
|
||||
'tool panel shows all five tools',
|
||||
{ tag: ['@smoke'] },
|
||||
async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
const toolPanel = dialog.locator('.maskEditor-ui-container')
|
||||
await expect(toolPanel).toBeVisible()
|
||||
|
||||
// The tool panel should contain exactly 5 tool entries
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await expect(toolEntries).toHaveCount(5)
|
||||
|
||||
// First tool (MaskPen) should be selected by default
|
||||
const selectedTool = dialog.locator(
|
||||
'.maskEditor_toolPanelContainerSelected'
|
||||
)
|
||||
await expect(selectedTool).toHaveCount(1)
|
||||
}
|
||||
)
|
||||
|
||||
test('switching tools updates the selected indicator', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await expect(toolEntries).toHaveCount(5)
|
||||
|
||||
// Click the third tool (Eraser, index 2)
|
||||
await toolEntries.nth(2).click()
|
||||
|
||||
// The third tool should now be selected
|
||||
const selectedTool = dialog.locator(
|
||||
'.maskEditor_toolPanelContainerSelected'
|
||||
)
|
||||
await expect(selectedTool).toHaveCount(1)
|
||||
|
||||
// Verify it's the eraser (3rd entry)
|
||||
await expect(toolEntries.nth(2)).toHaveClass(/Selected/)
|
||||
})
|
||||
|
||||
test('brush settings panel is visible with thickness controls', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
// The side panel should show brush settings by default
|
||||
const thicknessLabel = dialog.getByText('Thickness')
|
||||
await expect(thicknessLabel).toBeVisible()
|
||||
|
||||
const opacityLabel = dialog.getByText('Opacity').first()
|
||||
await expect(opacityLabel).toBeVisible()
|
||||
|
||||
const hardnessLabel = dialog.getByText('Hardness')
|
||||
await expect(hardnessLabel).toBeVisible()
|
||||
})
|
||||
|
||||
test('save uploads all layers and closes dialog', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
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({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
name: `test-image-${imageUploadCount}.png`,
|
||||
subfolder: 'clipspace',
|
||||
type: 'input'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await expect(saveButton).toBeVisible()
|
||||
await saveButton.click()
|
||||
|
||||
await expect(dialog).toBeHidden()
|
||||
|
||||
// The save pipeline uploads multiple layers (mask + image variants)
|
||||
expect(
|
||||
maskUploadCount + imageUploadCount,
|
||||
'save should trigger upload calls'
|
||||
).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('save failure keeps dialog open', async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
// 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 })
|
||||
)
|
||||
|
||||
const saveButton = dialog.getByRole('button', { name: 'Save' })
|
||||
await saveButton.click()
|
||||
|
||||
// Dialog should remain open when save fails
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
test(
|
||||
'eraser tool removes mask content',
|
||||
{ tag: ['@screenshot'] },
|
||||
async ({ comfyPage }) => {
|
||||
const dialog = await openMaskEditorDialog(comfyPage)
|
||||
|
||||
// Draw a stroke with the mask pen (default tool)
|
||||
await drawStrokeAndExpectPixels(comfyPage, dialog)
|
||||
|
||||
const pixelsAfterDraw = await getMaskCanvasPixelData(comfyPage.page)
|
||||
|
||||
// Switch to eraser tool (3rd tool, index 2)
|
||||
const toolEntries = dialog.locator('.maskEditor_toolPanelContainer')
|
||||
await toolEntries.nth(2).click()
|
||||
|
||||
// Draw over the same area with the eraser
|
||||
await drawStrokeOnPointerZone(comfyPage.page, dialog)
|
||||
|
||||
await expect
|
||||
.poll(() => pollMaskPixelCount(comfyPage.page))
|
||||
.toBeLessThan(pixelsAfterDraw!.nonTransparentPixels)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
@@ -17,24 +16,21 @@ function hasCanvasContent(canvas: Locator): Promise<boolean> {
|
||||
})
|
||||
}
|
||||
|
||||
function getMinimapLocators(comfyPage: ComfyPage) {
|
||||
const container = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
|
||||
return {
|
||||
container,
|
||||
canvas: comfyPage.page.getByTestId(TestIds.canvas.minimapCanvas),
|
||||
viewport: comfyPage.page.getByTestId(TestIds.canvas.minimapViewport),
|
||||
toggleButton: comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
),
|
||||
closeButton: comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton)
|
||||
}
|
||||
}
|
||||
async function clickMinimapAt(
|
||||
overlay: Locator,
|
||||
page: Page,
|
||||
relX: number,
|
||||
relY: number
|
||||
) {
|
||||
const box = await overlay.boundingBox()
|
||||
expect(box, 'Minimap interaction overlay not found').toBeTruthy()
|
||||
|
||||
function getCanvasOffset(page: Page): Promise<[number, number]> {
|
||||
return page.evaluate(() => {
|
||||
const ds = window.app!.canvas.ds
|
||||
return [ds.offset[0], ds.offset[1]] as [number, number]
|
||||
})
|
||||
// Click area — avoiding the settings button (top-left, 32×32px)
|
||||
// and close button (top-right, 32×32px)
|
||||
await page.mouse.click(
|
||||
box!.x + box!.width * relX,
|
||||
box!.y + box!.height * relY
|
||||
)
|
||||
}
|
||||
|
||||
test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
@@ -46,13 +42,23 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
})
|
||||
|
||||
test('Validate minimap is visible by default', async ({ comfyPage }) => {
|
||||
const { container, canvas, viewport } = getMinimapLocators(comfyPage)
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
|
||||
await expect(container).toBeVisible()
|
||||
await expect(canvas).toBeVisible()
|
||||
await expect(viewport).toBeVisible()
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
await expect(container).toHaveCSS('position', 'relative')
|
||||
const minimapCanvas = minimapContainer.getByTestId(
|
||||
TestIds.canvas.minimapCanvas
|
||||
)
|
||||
await expect(minimapCanvas).toBeVisible()
|
||||
|
||||
const minimapViewport = minimapContainer.getByTestId(
|
||||
TestIds.canvas.minimapViewport
|
||||
)
|
||||
await expect(minimapViewport).toBeVisible()
|
||||
|
||||
await expect(minimapContainer).toHaveCSS('position', 'relative')
|
||||
|
||||
// position and z-index validation moved to the parent container of the minimap
|
||||
const minimapMainContainer = comfyPage.page.locator(
|
||||
@@ -63,53 +69,59 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
})
|
||||
|
||||
test('Validate minimap toggle button state', async ({ comfyPage }) => {
|
||||
const { container, toggleButton } = getMinimapLocators(comfyPage)
|
||||
const toggleButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
|
||||
await expect(toggleButton).toBeVisible()
|
||||
await expect(container).toBeVisible()
|
||||
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('Validate minimap can be toggled off and on', async ({ comfyPage }) => {
|
||||
const { container, toggleButton } = getMinimapLocators(comfyPage)
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
const toggleButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
|
||||
await expect(container).toBeVisible()
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
await toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(container).toBeHidden()
|
||||
await expect(minimapContainer).toBeHidden()
|
||||
|
||||
await toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(container).toBeVisible()
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('Validate minimap keyboard shortcut Alt+M', async ({ comfyPage }) => {
|
||||
const { container } = getMinimapLocators(comfyPage)
|
||||
const minimapContainer = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
|
||||
await expect(container).toBeVisible()
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(container).toBeHidden()
|
||||
await expect(minimapContainer).toBeHidden()
|
||||
|
||||
await comfyPage.page.keyboard.press('Alt+KeyM')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(container).toBeVisible()
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
})
|
||||
|
||||
test('Close button hides minimap', async ({ comfyPage }) => {
|
||||
const { container, toggleButton, closeButton } =
|
||||
getMinimapLocators(comfyPage)
|
||||
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await expect(container).toBeVisible()
|
||||
|
||||
await closeButton.click()
|
||||
await expect(container).toBeHidden()
|
||||
await comfyPage.page.getByTestId(TestIds.canvas.closeMinimapButton).click()
|
||||
await expect(minimap).toBeHidden()
|
||||
|
||||
const toggleButton = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.toggleMinimapButton
|
||||
)
|
||||
await expect(toggleButton).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -117,10 +129,12 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
'Panning canvas moves minimap viewport',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const { container } = getMinimapLocators(comfyPage)
|
||||
await expect(container).toBeVisible()
|
||||
const minimap = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
await comfyPage.expectScreenshot(container, 'minimap-before-pan.png')
|
||||
await expect(minimap).toHaveScreenshot('minimap-before-pan.png')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
@@ -129,192 +143,155 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
canvas.ds.offset[1] = -600
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await comfyPage.expectScreenshot(container, 'minimap-after-pan.png')
|
||||
await comfyPage.expectScreenshot(minimap, 'minimap-after-pan.png')
|
||||
}
|
||||
)
|
||||
|
||||
test(
|
||||
'Viewport rectangle is visible and positioned within minimap',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const { container, viewport } = getMinimapLocators(comfyPage)
|
||||
await expect(container).toBeVisible()
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
const minimapBox = await container.boundingBox()
|
||||
const viewportBox = await viewport.boundingBox()
|
||||
|
||||
expect(minimapBox).toBeTruthy()
|
||||
expect(viewportBox).toBeTruthy()
|
||||
expect(viewportBox!.width).toBeGreaterThan(0)
|
||||
expect(viewportBox!.height).toBeGreaterThan(0)
|
||||
|
||||
expect(viewportBox!.x + viewportBox!.width).toBeGreaterThan(minimapBox!.x)
|
||||
expect(viewportBox!.y + viewportBox!.height).toBeGreaterThan(
|
||||
minimapBox!.y
|
||||
)
|
||||
expect(viewportBox!.x).toBeLessThan(minimapBox!.x + minimapBox!.width)
|
||||
expect(viewportBox!.y).toBeLessThan(minimapBox!.y + minimapBox!.height)
|
||||
|
||||
await comfyPage.expectScreenshot(container, 'minimap-with-viewport.png')
|
||||
}
|
||||
)
|
||||
|
||||
test('Clicking on minimap pans the canvas to that position', async ({
|
||||
test('Minimap canvas is non-empty for a workflow with nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { container } = getMinimapLocators(comfyPage)
|
||||
await expect(container).toBeVisible()
|
||||
|
||||
const offsetBefore = await getCanvasOffset(comfyPage.page)
|
||||
|
||||
const minimapBox = await container.boundingBox()
|
||||
expect(minimapBox).toBeTruthy()
|
||||
|
||||
// Click the top-left quadrant — canvas should pan so that region
|
||||
// becomes centered, meaning offset increases (moves right/down)
|
||||
await comfyPage.page.mouse.click(
|
||||
minimapBox!.x + minimapBox!.width * 0.2,
|
||||
minimapBox!.y + minimapBox!.height * 0.2
|
||||
const minimapCanvas = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapCanvas
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
await expect(minimapCanvas).toBeVisible()
|
||||
|
||||
await expect
|
||||
.poll(() => getCanvasOffset(comfyPage.page))
|
||||
.not.toEqual(offsetBefore)
|
||||
})
|
||||
|
||||
test('Dragging on minimap continuously pans the canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { container } = getMinimapLocators(comfyPage)
|
||||
await expect(container).toBeVisible()
|
||||
|
||||
const minimapBox = await container.boundingBox()
|
||||
expect(minimapBox).toBeTruthy()
|
||||
|
||||
const startX = minimapBox!.x + minimapBox!.width * 0.3
|
||||
const startY = minimapBox!.y + minimapBox!.height * 0.3
|
||||
const endX = minimapBox!.x + minimapBox!.width * 0.7
|
||||
const endY = minimapBox!.y + minimapBox!.height * 0.7
|
||||
|
||||
const offsetBefore = await getCanvasOffset(comfyPage.page)
|
||||
|
||||
// Drag from top-left toward bottom-right on the minimap
|
||||
await comfyPage.page.mouse.move(startX, startY)
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.page.mouse.move(endX, endY, { steps: 10 })
|
||||
|
||||
// Mid-drag: offset should already differ from initial state
|
||||
const offsetMidDrag = await getCanvasOffset(comfyPage.page)
|
||||
expect(
|
||||
offsetMidDrag[0] !== offsetBefore[0] ||
|
||||
offsetMidDrag[1] !== offsetBefore[1]
|
||||
).toBe(true)
|
||||
|
||||
await comfyPage.page.mouse.up()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Final offset should also differ (drag was not discarded on mouseup)
|
||||
await expect
|
||||
.poll(() => getCanvasOffset(comfyPage.page))
|
||||
.not.toEqual(offsetBefore)
|
||||
})
|
||||
|
||||
test('Minimap viewport updates when canvas is zoomed', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { container, viewport } = getMinimapLocators(comfyPage)
|
||||
await expect(container).toBeVisible()
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
const viewportBefore = await viewport.boundingBox()
|
||||
expect(viewportBefore).toBeTruthy()
|
||||
|
||||
// Zoom in significantly
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.scale = 3
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Viewport rectangle should shrink when zoomed in
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await viewport.boundingBox()
|
||||
return box?.width ?? 0
|
||||
})
|
||||
.toBeLessThan(viewportBefore!.width)
|
||||
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(true)
|
||||
})
|
||||
|
||||
test('Minimap canvas is empty after all nodes are deleted', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { canvas } = getMinimapLocators(comfyPage)
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
// Minimap should have content before deletion
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
|
||||
// Remove all nodes
|
||||
await comfyPage.canvas.press('Control+a')
|
||||
await comfyPage.canvas.press('Delete')
|
||||
await comfyPage.nextFrame()
|
||||
const minimapCanvas = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapCanvas
|
||||
)
|
||||
await expect(minimapCanvas).toBeVisible()
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.vueNodes.deleteSelected()
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(0)
|
||||
|
||||
// Minimap canvas should be empty — no nodes means nothing to render
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), { timeout: 5000 })
|
||||
.toBe(false)
|
||||
await expect.poll(() => hasCanvasContent(minimapCanvas)).toBe(false)
|
||||
})
|
||||
|
||||
test('Minimap re-renders after loading a different workflow', async ({
|
||||
test('Clicking minimap corner pans the main canvas', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { canvas } = getMinimapLocators(comfyPage)
|
||||
await expect(canvas).toBeVisible()
|
||||
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
|
||||
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
|
||||
const overlay = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapInteractionOverlay
|
||||
)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
// Default workflow has content
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
const before = await comfyPage.page.evaluate(() => ({
|
||||
x: window.app!.canvas.ds.offset[0],
|
||||
y: window.app!.canvas.ds.offset[1]
|
||||
}))
|
||||
|
||||
// Load a very different workflow
|
||||
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
|
||||
await comfyPage.nextFrame()
|
||||
const transformBefore = await viewport.evaluate(
|
||||
(el: HTMLElement) => el.style.transform
|
||||
)
|
||||
|
||||
await clickMinimapAt(overlay, comfyPage.page, 0.15, 0.85)
|
||||
|
||||
// Minimap should still have content (different workflow, still has nodes)
|
||||
await expect
|
||||
.poll(() => hasCanvasContent(canvas), { timeout: 5000 })
|
||||
.toBe(true)
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => ({
|
||||
x: window.app!.canvas.ds.offset[0],
|
||||
y: window.app!.canvas.ds.offset[1]
|
||||
}))
|
||||
)
|
||||
.not.toStrictEqual(before)
|
||||
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform))
|
||||
.not.toBe(transformBefore)
|
||||
})
|
||||
|
||||
test('Minimap viewport position reflects canvas pan state', async ({
|
||||
test('Clicking minimap center after FitView causes minimal canvas movement', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const { container, viewport } = getMinimapLocators(comfyPage)
|
||||
await expect(container).toBeVisible()
|
||||
await expect(viewport).toBeVisible()
|
||||
const minimap = comfyPage.page.getByTestId(TestIds.canvas.minimapContainer)
|
||||
const overlay = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapInteractionOverlay
|
||||
)
|
||||
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const positionBefore = await viewport.boundingBox()
|
||||
expect(positionBefore).toBeTruthy()
|
||||
|
||||
// Pan the canvas by a large amount to the right and down
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const canvas = window.app!.canvas
|
||||
canvas.ds.offset[0] -= 500
|
||||
canvas.ds.offset[1] -= 500
|
||||
canvas.ds.offset[0] -= 1000
|
||||
canvas.setDirty(true, true)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The viewport indicator should have moved within the minimap
|
||||
const transformBefore = await viewport.evaluate(
|
||||
(el: HTMLElement) => el.style.transform
|
||||
)
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.app!.canvas.fitViewToSelectionAnimated({ duration: 1 })
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const box = await viewport.boundingBox()
|
||||
if (!box || !positionBefore) return false
|
||||
return box.x !== positionBefore.x || box.y !== positionBefore.y
|
||||
.poll(() => viewport.evaluate((el: HTMLElement) => el.style.transform), {
|
||||
timeout: 2000
|
||||
})
|
||||
.toBe(true)
|
||||
.not.toBe(transformBefore)
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const before = await comfyPage.page.evaluate(() => ({
|
||||
x: window.app!.canvas.ds.offset[0],
|
||||
y: window.app!.canvas.ds.offset[1]
|
||||
}))
|
||||
|
||||
await clickMinimapAt(overlay, comfyPage.page, 0.5, 0.5)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const after = await comfyPage.page.evaluate(() => ({
|
||||
x: window.app!.canvas.ds.offset[0],
|
||||
y: window.app!.canvas.ds.offset[1]
|
||||
}))
|
||||
|
||||
// ~3px overlay error × ~15 canvas/minimap scale ≈ 45, rounded up
|
||||
const TOLERANCE = 50
|
||||
expect(
|
||||
Math.abs(after.x - before.x),
|
||||
`offset.x changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
|
||||
).toBeLessThan(TOLERANCE)
|
||||
expect(
|
||||
Math.abs(after.y - before.y),
|
||||
`offset.y changed by more than ${TOLERANCE} after clicking minimap center post-FitView`
|
||||
).toBeLessThan(TOLERANCE)
|
||||
})
|
||||
|
||||
test(
|
||||
'Viewport rectangle is visible and positioned within minimap',
|
||||
{ tag: '@screenshot' },
|
||||
async ({ comfyPage }) => {
|
||||
const minimap = comfyPage.page.getByTestId(
|
||||
TestIds.canvas.minimapContainer
|
||||
)
|
||||
await expect(minimap).toBeVisible()
|
||||
|
||||
const viewport = minimap.getByTestId(TestIds.canvas.minimapViewport)
|
||||
await expect(viewport).toBeVisible()
|
||||
|
||||
await expect(async () => {
|
||||
const vb = await viewport.boundingBox()
|
||||
const mb = await minimap.boundingBox()
|
||||
expect(vb).toBeTruthy()
|
||||
expect(mb).toBeTruthy()
|
||||
expect(vb!.width).toBeGreaterThan(0)
|
||||
expect(vb!.height).toBeGreaterThan(0)
|
||||
expect(vb!.x).toBeGreaterThanOrEqual(mb!.x)
|
||||
expect(vb!.y).toBeGreaterThanOrEqual(mb!.y)
|
||||
expect(vb!.x + vb!.width).toBeLessThanOrEqual(mb!.x + mb!.width)
|
||||
expect(vb!.y + vb!.height).toBeLessThanOrEqual(mb!.y + mb!.height)
|
||||
}).toPass({ timeout: 5000 })
|
||||
|
||||
await expect(minimap).toHaveScreenshot('minimap-with-viewport.png')
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -370,64 +370,4 @@ test.describe('Painter', { tag: ['@widget', '@vue-nodes'] }, () => {
|
||||
await expect(comfyPage.toast.visibleToasts.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Eraser', () => {
|
||||
test('Eraser removes previously drawn content', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
canvas.evaluate((el: HTMLCanvasElement) => {
|
||||
const ctx = el.getContext('2d')
|
||||
if (!ctx) return false
|
||||
const cx = Math.floor(el.width / 2)
|
||||
const cy = Math.floor(el.height / 2)
|
||||
const { data } = ctx.getImageData(cx - 5, cy - 5, 10, 10)
|
||||
return data.every((v, i) => i % 4 !== 3 || v === 0)
|
||||
}),
|
||||
{ message: 'erased area should be transparent' }
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Eraser on empty canvas adds no content', async ({ comfyPage }) => {
|
||||
const node = comfyPage.vueNodes.getNodeLocator('1')
|
||||
const painterWidget = node.locator('.widget-expands')
|
||||
const canvas = painterWidget.locator('canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await painterWidget.getByRole('button', { name: 'Eraser' }).click()
|
||||
await drawStroke(comfyPage.page, canvas)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test('Multiple strokes accumulate on the canvas', async ({ comfyPage }) => {
|
||||
const canvas = comfyPage.vueNodes
|
||||
.getNodeLocator('1')
|
||||
.locator('.widget-expands canvas')
|
||||
await expect(canvas).toBeVisible()
|
||||
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.3 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
|
||||
await drawStroke(comfyPage.page, canvas, { yPct: 0.7 })
|
||||
await comfyPage.nextFrame()
|
||||
await expect.poll(() => hasCanvasContent(canvas)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -121,9 +121,9 @@ test.describe('Workflow Persistence', () => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
await expect.poll(() => getNodeOutputImageCount(comfyPage, nodeId)).toBe(1)
|
||||
@@ -388,7 +388,7 @@ test.describe('Workflow Persistence', () => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflow called captureCanvasState on inactive tab, serializing the active graph instead'
|
||||
'PR #10745 — saveWorkflow called checkState on inactive tab, serializing the active graph instead'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
@@ -419,13 +419,13 @@ test.describe('Workflow Persistence', () => {
|
||||
.toBe(nodeCountA + 1)
|
||||
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
|
||||
|
||||
// Trigger captureCanvasState so isModified is set
|
||||
// Trigger checkState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
// Switch to A via topbar tab (making B inactive)
|
||||
@@ -464,7 +464,7 @@ test.describe('Workflow Persistence', () => {
|
||||
test.info().annotations.push({
|
||||
type: 'regression',
|
||||
description:
|
||||
'PR #10745 — saveWorkflowAs called captureCanvasState on inactive temp tab, serializing the active graph'
|
||||
'PR #10745 — saveWorkflowAs called checkState on inactive temp tab, serializing the active graph'
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting(
|
||||
@@ -488,13 +488,13 @@ test.describe('Workflow Persistence', () => {
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// Trigger captureCanvasState so isModified is set
|
||||
// Trigger checkState so isModified is set
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const em = window.app!.extensionManager as unknown as Record<
|
||||
string,
|
||||
{ activeWorkflow?: { changeTracker?: { captureCanvasState(): void } } }
|
||||
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
|
||||
>
|
||||
em.workflow?.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
em.workflow?.activeWorkflow?.changeTracker?.checkState()
|
||||
})
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(1)
|
||||
|
||||
@@ -5,18 +5,14 @@ history by comparing serialized graph snapshots.
|
||||
|
||||
## How It Works
|
||||
|
||||
`captureCanvasState()` is the core method. It:
|
||||
`checkState()` is the core method. It:
|
||||
|
||||
1. Serializes the current graph via `app.rootGraph.serialize()`
|
||||
2. Deep-compares the result against the last known `activeState`
|
||||
3. If different, pushes `activeState` onto `undoQueue` and replaces it
|
||||
|
||||
**It is not reactive.** Changes to the graph (widget values, node positions,
|
||||
links, etc.) are only captured when `captureCanvasState()` is explicitly triggered.
|
||||
|
||||
**INVARIANT:** `captureCanvasState()` asserts that it is called on the active
|
||||
workflow's tracker. Calling it on an inactive tracker logs a warning and
|
||||
returns early, preventing cross-workflow data corruption.
|
||||
links, etc.) are only captured when `checkState()` is explicitly triggered.
|
||||
|
||||
## Automatic Triggers
|
||||
|
||||
@@ -35,7 +31,7 @@ These are set up once in `ChangeTracker.init()`:
|
||||
| Graph cleared | `api` `graphCleared` event | Full graph clear |
|
||||
| Transaction end | `litegraph:canvas` `after-change` event | Batched operations via `beforeChange`/`afterChange` |
|
||||
|
||||
## When You Must Call `captureCanvasState()` Manually
|
||||
## When You Must Call `checkState()` Manually
|
||||
|
||||
The automatic triggers above are designed around LiteGraph's native DOM
|
||||
rendering. They **do not cover**:
|
||||
@@ -54,42 +50,24 @@ rendering. They **do not cover**:
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
|
||||
// After mutating the graph:
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
```
|
||||
|
||||
### Existing Manual Call Sites
|
||||
|
||||
These locations call `captureCanvasState()` directly:
|
||||
These locations already call `checkState()` explicitly:
|
||||
|
||||
- `WidgetSelectDropdown.vue` — After dropdown selection and file upload
|
||||
- `ColorPickerButton.vue` — After changing node colors
|
||||
- `NodeSearchBoxPopover.vue` — After adding a node from search
|
||||
- `builderViewOptions.ts` — After setting default view
|
||||
- `useAppSetDefaultView.ts` — After setting default view
|
||||
- `useSelectionOperations.ts` — After align, copy, paste, duplicate, group
|
||||
- `useSelectedNodeActions.ts` — After pin, bypass, collapse
|
||||
- `useGroupMenuOptions.ts` — After group operations
|
||||
- `useSubgraphOperations.ts` — After subgraph enter/exit
|
||||
- `useCanvasRefresh.ts` — After canvas refresh
|
||||
- `useCoreCommands.ts` — After metadata/subgraph commands
|
||||
- `appModeStore.ts` — After app mode transitions
|
||||
|
||||
`workflowService.ts` calls `captureCanvasState()` indirectly via
|
||||
`deactivate()` and `prepareForSave()` (see Lifecycle Methods below).
|
||||
|
||||
> **Deprecated:** `checkState()` is an alias for `captureCanvasState()` kept
|
||||
> for extension compatibility. Extension authors should migrate to
|
||||
> `captureCanvasState()`. See the `@deprecated` JSDoc on the method.
|
||||
|
||||
## Lifecycle Methods
|
||||
|
||||
| Method | Caller | Purpose |
|
||||
| ---------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `captureCanvasState()` | Event handlers, UI interactions | Snapshots canvas into activeState, pushes undo. Asserts active tracker. |
|
||||
| `deactivate()` | `beforeLoadNewGraph` only | `captureCanvasState()` (skipped during undo/redo) + `store()`. Freezes state for tab switch. Must be called while this workflow is still active. |
|
||||
| `prepareForSave()` | Save paths only | Active: calls `captureCanvasState()`. Inactive: no-op (state was frozen by `deactivate()`). |
|
||||
| `store()` | Internal to `deactivate()` | Saves viewport scale/offset, node outputs, subgraph navigation. |
|
||||
| `restore()` | `afterLoadNewGraph` | Restores viewport, outputs, subgraph navigation. |
|
||||
| `reset()` | `afterLoadNewGraph`, save | Resets initial state (marks workflow as "clean"). |
|
||||
- `workflowService.ts` — After workflow service operations
|
||||
|
||||
## Transaction Guards
|
||||
|
||||
@@ -98,7 +76,7 @@ For operations that make multiple changes that should be a single undo entry:
|
||||
```typescript
|
||||
changeTracker.beforeChange()
|
||||
// ... multiple graph mutations ...
|
||||
changeTracker.afterChange() // calls captureCanvasState() when nesting count hits 0
|
||||
changeTracker.afterChange() // calls checkState() when nesting count hits 0
|
||||
```
|
||||
|
||||
The `litegraph:canvas` custom event also supports this with `before-change` /
|
||||
@@ -106,12 +84,8 @@ The `litegraph:canvas` custom event also supports this with `before-change` /
|
||||
|
||||
## Key Invariants
|
||||
|
||||
- `captureCanvasState()` asserts it is called on the active workflow's tracker;
|
||||
inactive trackers get an early return (and a warning log)
|
||||
- `captureCanvasState()` is a no-op during `loadGraphData` (guarded by
|
||||
- `checkState()` is a no-op during `loadGraphData` (guarded by
|
||||
`isLoadingGraph`) to prevent cross-workflow corruption
|
||||
- `captureCanvasState()` is a no-op during undo/redo (guarded by
|
||||
`_restoringState`) to prevent undo history corruption
|
||||
- `captureCanvasState()` is a no-op when `changeCount > 0` (inside a transaction)
|
||||
- `checkState()` is a no-op when `changeCount > 0` (inside a transaction)
|
||||
- `undoQueue` is capped at 50 entries (`MAX_HISTORY`)
|
||||
- `graphEqual` ignores node order and `ds` (pan/zoom) when comparing
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate test fixture files for metadata parser tests.
|
||||
|
||||
Each fixture embeds the same workflow and prompt JSON, matching the
|
||||
format the ComfyUI backend uses to write metadata.
|
||||
|
||||
Prerequisites:
|
||||
source ~/ComfyUI/.venv/bin/activate
|
||||
python3 scripts/generate-embedded-metadata-test-files.py
|
||||
|
||||
Output: src/scripts/metadata/__fixtures__/
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
|
||||
import av
|
||||
from PIL import Image
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
FIXTURES_DIR = os.path.join(REPO_ROOT, 'src', 'scripts', 'metadata', '__fixtures__')
|
||||
|
||||
WORKFLOW = {
|
||||
'nodes': [
|
||||
{
|
||||
'id': 1,
|
||||
'type': 'KSampler',
|
||||
'pos': [100, 100],
|
||||
'size': [200, 200],
|
||||
}
|
||||
]
|
||||
}
|
||||
PROMPT = {'1': {'class_type': 'KSampler', 'inputs': {}}}
|
||||
|
||||
WORKFLOW_JSON = json.dumps(WORKFLOW, separators=(',', ':'))
|
||||
PROMPT_JSON = json.dumps(PROMPT, separators=(',', ':'))
|
||||
|
||||
|
||||
def out(name: str) -> str:
|
||||
return os.path.join(FIXTURES_DIR, name)
|
||||
|
||||
|
||||
def report(name: str):
|
||||
size = os.path.getsize(out(name))
|
||||
print(f' {name} ({size} bytes)')
|
||||
|
||||
|
||||
def make_1x1_image() -> Image.Image:
|
||||
return Image.new('RGB', (1, 1), (255, 0, 0))
|
||||
|
||||
|
||||
def build_exif_bytes() -> bytes:
|
||||
"""Build EXIF bytes matching the backend's tag assignments.
|
||||
|
||||
Backend: 0x010F (Make) = "workflow:<json>", 0x0110 (Model) = "prompt:<json>"
|
||||
"""
|
||||
img = make_1x1_image()
|
||||
exif = img.getexif()
|
||||
exif[0x010F] = f'workflow:{WORKFLOW_JSON}'
|
||||
exif[0x0110] = f'prompt:{PROMPT_JSON}'
|
||||
return exif.tobytes()
|
||||
|
||||
|
||||
def inject_exif_prefix_in_webp(path: str):
|
||||
"""Prepend Exif\\0\\0 to the EXIF chunk in a WEBP file.
|
||||
|
||||
PIL always strips this prefix, so we re-inject it to test that code path.
|
||||
"""
|
||||
data = bytearray(open(path, 'rb').read())
|
||||
off = 12
|
||||
while off < len(data):
|
||||
chunk_type = data[off:off + 4]
|
||||
chunk_len = struct.unpack_from('<I', data, off + 4)[0]
|
||||
if chunk_type == b'EXIF':
|
||||
prefix = b'Exif\x00\x00'
|
||||
data[off + 8:off + 8] = prefix
|
||||
struct.pack_into('<I', data, off + 4, chunk_len + len(prefix))
|
||||
riff_size = struct.unpack_from('<I', data, 4)[0]
|
||||
struct.pack_into('<I', data, 4, riff_size + len(prefix))
|
||||
break
|
||||
off += 8 + chunk_len + (chunk_len % 2)
|
||||
with open(path, 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
|
||||
def generate_av_fixture(
|
||||
name: str,
|
||||
fmt: str,
|
||||
codec: str,
|
||||
rate: int = 44100,
|
||||
options: dict | None = None,
|
||||
):
|
||||
"""Generate an audio fixture via PyAV container.metadata[], matching the backend."""
|
||||
path = out(name)
|
||||
container = av.open(path, mode='w', format=fmt, options=options or {})
|
||||
stream = container.add_stream(codec, rate=rate)
|
||||
stream.layout = 'mono'
|
||||
|
||||
container.metadata['prompt'] = PROMPT_JSON
|
||||
container.metadata['workflow'] = WORKFLOW_JSON
|
||||
|
||||
sample_fmt = stream.codec_context.codec.audio_formats[0].name
|
||||
samples = stream.codec_context.frame_size or 1024
|
||||
frame = av.AudioFrame(format=sample_fmt, layout='mono', samples=samples)
|
||||
frame.rate = rate
|
||||
frame.pts = 0
|
||||
for packet in stream.encode(frame):
|
||||
container.mux(packet)
|
||||
for packet in stream.encode():
|
||||
container.mux(packet)
|
||||
container.close()
|
||||
report(name)
|
||||
|
||||
|
||||
def generate_webp():
|
||||
img = make_1x1_image()
|
||||
exif = build_exif_bytes()
|
||||
|
||||
img.save(out('with_metadata.webp'), 'WEBP', exif=exif)
|
||||
report('with_metadata.webp')
|
||||
|
||||
img.save(out('with_metadata_exif_prefix.webp'), 'WEBP', exif=exif)
|
||||
inject_exif_prefix_in_webp(out('with_metadata_exif_prefix.webp'))
|
||||
report('with_metadata_exif_prefix.webp')
|
||||
|
||||
|
||||
def generate_avif():
|
||||
img = make_1x1_image()
|
||||
exif = build_exif_bytes()
|
||||
img.save(out('with_metadata.avif'), 'AVIF', exif=exif)
|
||||
report('with_metadata.avif')
|
||||
|
||||
|
||||
def generate_flac():
|
||||
generate_av_fixture('with_metadata.flac', 'flac', 'flac')
|
||||
|
||||
|
||||
def generate_opus():
|
||||
generate_av_fixture('with_metadata.opus', 'opus', 'libopus', rate=48000)
|
||||
|
||||
|
||||
def generate_mp3():
|
||||
generate_av_fixture('with_metadata.mp3', 'mp3', 'libmp3lame')
|
||||
|
||||
|
||||
def generate_mp4():
|
||||
"""Generate MP4 via ffmpeg CLI with QuickTime keys/ilst metadata."""
|
||||
path = out('with_metadata.mp4')
|
||||
subprocess.run([
|
||||
'ffmpeg', '-y', '-loglevel', 'error',
|
||||
'-f', 'lavfi', '-i', 'anullsrc=r=44100:cl=mono',
|
||||
'-t', '0.01', '-c:a', 'aac', '-b:a', '32k',
|
||||
'-movflags', 'use_metadata_tags',
|
||||
'-metadata', f'prompt={PROMPT_JSON}',
|
||||
'-metadata', f'workflow={WORKFLOW_JSON}',
|
||||
path,
|
||||
], check=True)
|
||||
report('with_metadata.mp4')
|
||||
|
||||
|
||||
def generate_webm():
|
||||
generate_av_fixture('with_metadata.webm', 'webm', 'libvorbis')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('Generating fixtures...')
|
||||
generate_webp()
|
||||
generate_avif()
|
||||
generate_flac()
|
||||
generate_opus()
|
||||
generate_mp3()
|
||||
generate_mp4()
|
||||
generate_webm()
|
||||
print('Done.')
|
||||
@@ -46,7 +46,7 @@ const mockActiveWorkflow = ref<{
|
||||
isTemporary: boolean
|
||||
initialMode?: string
|
||||
isModified?: boolean
|
||||
changeTracker?: { captureCanvasState: () => void }
|
||||
changeTracker?: { checkState: () => void }
|
||||
} | null>({
|
||||
isTemporary: true,
|
||||
initialMode: 'app'
|
||||
|
||||
@@ -49,10 +49,10 @@ describe('setWorkflowDefaultView', () => {
|
||||
expect(app.rootGraph.extra.linearMode).toBe(false)
|
||||
})
|
||||
|
||||
it('calls changeTracker.captureCanvasState', () => {
|
||||
it('calls changeTracker.checkState', () => {
|
||||
const workflow = createMockLoadedWorkflow()
|
||||
setWorkflowDefaultView(workflow, true)
|
||||
expect(workflow.changeTracker.captureCanvasState).toHaveBeenCalledOnce()
|
||||
expect(workflow.changeTracker.checkState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('tracks telemetry with correct default_view', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ export function setWorkflowDefaultView(
|
||||
workflow.initialMode = openAsApp ? 'app' : 'graph'
|
||||
const extra = (app.rootGraph.extra ??= {})
|
||||
extra.linearMode = openAsApp
|
||||
workflow.changeTracker?.captureCanvasState()
|
||||
workflow.changeTracker?.checkState()
|
||||
useTelemetry()?.trackDefaultViewSet({
|
||||
default_view: openAsApp ? 'app' : 'graph'
|
||||
})
|
||||
|
||||
@@ -31,7 +31,7 @@ function createMockWorkflow(
|
||||
const changeTracker = Object.assign(
|
||||
new ChangeTracker(workflow, structuredClone(defaultGraph)),
|
||||
{
|
||||
captureCanvasState: vi.fn() as Mock
|
||||
checkState: vi.fn() as Mock
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ const applyColor = (colorOption: ColorOption | null) => {
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
currentColorOption.value = canvasColorOption
|
||||
showColorPicker.value = false
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker.checkState()
|
||||
}
|
||||
|
||||
const currentColorOption = ref<CanvasColorOption | null>(null)
|
||||
|
||||
@@ -143,7 +143,7 @@ function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) {
|
||||
disconnectOnReset = false
|
||||
|
||||
// Notify changeTracker - new step should be added
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
window.requestAnimationFrame(closeDialog)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ export function useCanvasRefresh() {
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
canvasStore.canvas?.graph?.afterChange()
|
||||
canvasStore.canvas?.emitAfterChange()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -36,7 +36,7 @@ export function useGroupMenuOptions() {
|
||||
groupContext.resizeTo(groupContext.children, padding)
|
||||
groupContext.graph?.change()
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -119,7 +119,7 @@ export function useGroupMenuOptions() {
|
||||
})
|
||||
canvasStore.canvas?.setDirty(true, true)
|
||||
groupContext.graph?.change()
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
bump()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,7 +23,7 @@ export function useSelectedNodeActions() {
|
||||
})
|
||||
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const toggleNodeCollapse = () => {
|
||||
@@ -33,7 +33,7 @@ export function useSelectedNodeActions() {
|
||||
})
|
||||
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const toggleNodePin = () => {
|
||||
@@ -43,7 +43,7 @@ export function useSelectedNodeActions() {
|
||||
})
|
||||
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const toggleNodeBypass = () => {
|
||||
|
||||
@@ -47,7 +47,7 @@ export function useSelectionOperations() {
|
||||
canvas.pasteFromClipboard({ connectInputs: false })
|
||||
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const duplicateSelection = () => {
|
||||
@@ -73,7 +73,7 @@ export function useSelectionOperations() {
|
||||
canvas.pasteFromClipboard({ connectInputs: false })
|
||||
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const deleteSelection = () => {
|
||||
@@ -92,7 +92,7 @@ export function useSelectionOperations() {
|
||||
canvas.setDirty(true, true)
|
||||
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const renameSelection = async () => {
|
||||
@@ -122,7 +122,7 @@ export function useSelectionOperations() {
|
||||
const titledItem = item as { title: string }
|
||||
titledItem.title = newTitle
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
}
|
||||
return
|
||||
@@ -145,7 +145,7 @@ export function useSelectionOperations() {
|
||||
}
|
||||
})
|
||||
app.canvas.setDirty(true, true)
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function useSubgraphOperations() {
|
||||
canvas.select(node)
|
||||
canvasStore.updateSelectedItems()
|
||||
// Trigger change tracking
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const doUnpack = (
|
||||
@@ -46,7 +46,7 @@ export function useSubgraphOperations() {
|
||||
nodeOutputStore.revokeSubgraphPreviews(subgraphNode)
|
||||
graph.unpackSubgraph(subgraphNode, { skipMissingNodes })
|
||||
}
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
const unpackSubgraph = () => {
|
||||
|
||||
@@ -94,7 +94,7 @@ vi.mock('@/stores/toastStore', () => ({
|
||||
}))
|
||||
|
||||
const mockChangeTracker = vi.hoisted(() => ({
|
||||
captureCanvasState: vi.fn()
|
||||
checkState: vi.fn()
|
||||
}))
|
||||
const mockWorkflowStore = vi.hoisted(() => ({
|
||||
activeWorkflow: {
|
||||
@@ -382,7 +382,7 @@ describe('useCoreCommands', () => {
|
||||
|
||||
expect(mockDialogService.prompt).toHaveBeenCalled()
|
||||
expect(mockSubgraph.extra.BlueprintDescription).toBe('Test description')
|
||||
expect(mockChangeTracker.captureCanvasState).toHaveBeenCalled()
|
||||
expect(mockChangeTracker.checkState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not set description when user cancels', async () => {
|
||||
@@ -397,7 +397,7 @@ describe('useCoreCommands', () => {
|
||||
await setDescCommand.function()
|
||||
|
||||
expect(mockSubgraph.extra.BlueprintDescription).toBeUndefined()
|
||||
expect(mockChangeTracker.captureCanvasState).not.toHaveBeenCalled()
|
||||
expect(mockChangeTracker.checkState).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -432,7 +432,7 @@ describe('useCoreCommands', () => {
|
||||
'alias2',
|
||||
'alias3'
|
||||
])
|
||||
expect(mockChangeTracker.captureCanvasState).toHaveBeenCalled()
|
||||
expect(mockChangeTracker.checkState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should trim whitespace and filter empty strings', async () => {
|
||||
@@ -478,7 +478,7 @@ describe('useCoreCommands', () => {
|
||||
await setAliasesCommand.function()
|
||||
|
||||
expect(mockSubgraph.extra.BlueprintSearchAliases).toBeUndefined()
|
||||
expect(mockChangeTracker.captureCanvasState).not.toHaveBeenCalled()
|
||||
expect(mockChangeTracker.checkState).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1164,7 +1164,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
if (description === null) return
|
||||
|
||||
extra.BlueprintDescription = description.trim() || undefined
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1201,7 +1201,7 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
}
|
||||
|
||||
extra.BlueprintSearchAliases = aliases.length > 0 ? aliases : undefined
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
102
src/extensions/core/editAttention.test.ts
Normal file
102
src/extensions/core/editAttention.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
registerExtension: vi.fn(),
|
||||
ui: { settings: { addSetting: vi.fn() } }
|
||||
}
|
||||
}))
|
||||
|
||||
import {
|
||||
addWeightToParentheses,
|
||||
findNearestEnclosure,
|
||||
incrementWeight
|
||||
} from './editAttention'
|
||||
|
||||
describe('incrementWeight', () => {
|
||||
it('increments a weight by the given delta', () => {
|
||||
expect(incrementWeight('1.0', 0.05)).toBe('1.05')
|
||||
})
|
||||
|
||||
it('decrements a weight by the given delta', () => {
|
||||
expect(incrementWeight('1.05', -0.05)).toBe('1')
|
||||
})
|
||||
|
||||
it('returns the original string when weight is not a number', () => {
|
||||
expect(incrementWeight('abc', 0.05)).toBe('abc')
|
||||
})
|
||||
|
||||
it('rounds correctly and avoids floating point accumulation', () => {
|
||||
expect(incrementWeight('1.1', 0.1)).toBe('1.2')
|
||||
})
|
||||
|
||||
it('can produce a weight of zero', () => {
|
||||
expect(incrementWeight('0.05', -0.05)).toBe('0')
|
||||
})
|
||||
|
||||
it('produces negative weights', () => {
|
||||
expect(incrementWeight('0.0', -0.05)).toBe('-0.05')
|
||||
})
|
||||
})
|
||||
|
||||
describe('findNearestEnclosure', () => {
|
||||
it('returns start and end of a simple parenthesized expression', () => {
|
||||
expect(findNearestEnclosure('(cat)', 2)).toEqual({ start: 1, end: 4 })
|
||||
})
|
||||
|
||||
it('returns null when there are no parentheses', () => {
|
||||
expect(findNearestEnclosure('cat dog', 3)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when cursor is outside any enclosure', () => {
|
||||
expect(findNearestEnclosure('(cat) dog', 7)).toBeNull()
|
||||
})
|
||||
|
||||
it('finds the inner enclosure when cursor is on nested content', () => {
|
||||
expect(findNearestEnclosure('(outer (inner) end)', 9)).toEqual({
|
||||
start: 8,
|
||||
end: 13
|
||||
})
|
||||
})
|
||||
|
||||
it('finds the outer enclosure when cursor is on outer content', () => {
|
||||
expect(findNearestEnclosure('(outer (inner) end)', 2)).toEqual({
|
||||
start: 1,
|
||||
end: 18
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for empty string', () => {
|
||||
expect(findNearestEnclosure('', 0)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when opening paren has no matching closing paren', () => {
|
||||
expect(findNearestEnclosure('(cat', 2)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('addWeightToParentheses', () => {
|
||||
it('adds weight 1.0 to a bare parenthesized token', () => {
|
||||
expect(addWeightToParentheses('(cat)')).toBe('(cat:1.0)')
|
||||
})
|
||||
|
||||
it('leaves a token that already has a weight unchanged', () => {
|
||||
expect(addWeightToParentheses('(cat:1.5)')).toBe('(cat:1.5)')
|
||||
})
|
||||
|
||||
it('leaves a token without parentheses unchanged', () => {
|
||||
expect(addWeightToParentheses('cat')).toBe('cat')
|
||||
})
|
||||
|
||||
it('leaves a token with scientific notation weight unchanged', () => {
|
||||
expect(addWeightToParentheses('(cat:1e-3)')).toBe('(cat:1e-3)')
|
||||
})
|
||||
|
||||
it('leaves a token with a negative weight unchanged', () => {
|
||||
expect(addWeightToParentheses('(cat:-0.5)')).toBe('(cat:-0.5)')
|
||||
})
|
||||
|
||||
it('adds weight to a multi-word parenthesized token', () => {
|
||||
expect(addWeightToParentheses('(cat dog)')).toBe('(cat dog:1.0)')
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,61 @@
|
||||
import { app } from '../../scripts/app'
|
||||
|
||||
// Allows you to edit the attention weight by holding ctrl (or cmd) and using the up/down arrow keys
|
||||
type Enclosure = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export function incrementWeight(weight: string, delta: number): string {
|
||||
const floatWeight = parseFloat(weight)
|
||||
if (isNaN(floatWeight)) return weight
|
||||
const newWeight = floatWeight + delta
|
||||
return String(Number(newWeight.toFixed(10)))
|
||||
}
|
||||
|
||||
export function findNearestEnclosure(
|
||||
text: string,
|
||||
cursorPos: number
|
||||
): Enclosure | null {
|
||||
let start = cursorPos,
|
||||
end = cursorPos
|
||||
let openCount = 0,
|
||||
closeCount = 0
|
||||
|
||||
while (start >= 0) {
|
||||
start--
|
||||
if (text[start] === '(' && openCount === closeCount) break
|
||||
if (text[start] === '(') openCount++
|
||||
if (text[start] === ')') closeCount++
|
||||
}
|
||||
if (start < 0) return null
|
||||
|
||||
openCount = 0
|
||||
closeCount = 0
|
||||
|
||||
while (end < text.length) {
|
||||
if (text[end] === ')' && openCount === closeCount) break
|
||||
if (text[end] === '(') openCount++
|
||||
if (text[end] === ')') closeCount++
|
||||
end++
|
||||
}
|
||||
if (end === text.length) return null
|
||||
|
||||
return { start: start + 1, end: end }
|
||||
}
|
||||
|
||||
export function addWeightToParentheses(text: string): string {
|
||||
const parenRegex = /^\((.*)\)$/
|
||||
const parenMatch = text.match(parenRegex)
|
||||
|
||||
const floatRegex = /:([+-]?(\d*\.)?\d+([eE][+-]?\d+)?)/
|
||||
const floatMatch = text.match(floatRegex)
|
||||
|
||||
if (parenMatch && !floatMatch) {
|
||||
return `(${parenMatch[1]}:1.0)`
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.EditAttention',
|
||||
@@ -18,65 +73,6 @@ app.registerExtension({
|
||||
defaultValue: 0.05
|
||||
})
|
||||
|
||||
function incrementWeight(weight: string, delta: number): string {
|
||||
const floatWeight = parseFloat(weight)
|
||||
if (isNaN(floatWeight)) return weight
|
||||
const newWeight = floatWeight + delta
|
||||
return String(Number(newWeight.toFixed(10)))
|
||||
}
|
||||
|
||||
type Enclosure = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
function findNearestEnclosure(
|
||||
text: string,
|
||||
cursorPos: number
|
||||
): Enclosure | null {
|
||||
let start = cursorPos,
|
||||
end = cursorPos
|
||||
let openCount = 0,
|
||||
closeCount = 0
|
||||
|
||||
// Find opening parenthesis before cursor
|
||||
while (start >= 0) {
|
||||
start--
|
||||
if (text[start] === '(' && openCount === closeCount) break
|
||||
if (text[start] === '(') openCount++
|
||||
if (text[start] === ')') closeCount++
|
||||
}
|
||||
if (start < 0) return null
|
||||
|
||||
openCount = 0
|
||||
closeCount = 0
|
||||
|
||||
// Find closing parenthesis after cursor
|
||||
while (end < text.length) {
|
||||
if (text[end] === ')' && openCount === closeCount) break
|
||||
if (text[end] === '(') openCount++
|
||||
if (text[end] === ')') closeCount++
|
||||
end++
|
||||
}
|
||||
if (end === text.length) return null
|
||||
|
||||
return { start: start + 1, end: end }
|
||||
}
|
||||
|
||||
function addWeightToParentheses(text: string): string {
|
||||
const parenRegex = /^\((.*)\)$/
|
||||
const parenMatch = text.match(parenRegex)
|
||||
|
||||
const floatRegex = /:([+-]?(\d*\.)?\d+([eE][+-]?\d+)?)/
|
||||
const floatMatch = text.match(floatRegex)
|
||||
|
||||
if (parenMatch && !floatMatch) {
|
||||
return `(${parenMatch[1]}:1.0)`
|
||||
} else {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
function editAttention(event: KeyboardEvent) {
|
||||
// @ts-expect-error Runtime narrowing not impl.
|
||||
const inputField: HTMLTextAreaElement = event.composedPath()[0]
|
||||
@@ -92,7 +88,6 @@ app.registerExtension({
|
||||
let end = inputField.selectionEnd
|
||||
let selectedText = inputField.value.substring(start, end)
|
||||
|
||||
// If there is no selection, attempt to find the nearest enclosure, or select the current word
|
||||
if (!selectedText) {
|
||||
const nearestEnclosure = findNearestEnclosure(inputField.value, start)
|
||||
if (nearestEnclosure) {
|
||||
@@ -100,7 +95,6 @@ app.registerExtension({
|
||||
end = nearestEnclosure.end
|
||||
selectedText = inputField.value.substring(start, end)
|
||||
} else {
|
||||
// Select the current word, find the start and end of the word
|
||||
const delimiters = ' .,\\/!?%^*;:{}=-_`~()\r\n\t'
|
||||
|
||||
while (
|
||||
@@ -122,13 +116,11 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
|
||||
// If the selection ends with a space, remove it
|
||||
if (selectedText[selectedText.length - 1] === ' ') {
|
||||
selectedText = selectedText.substring(0, selectedText.length - 1)
|
||||
end -= 1
|
||||
}
|
||||
|
||||
// If there are parentheses left and right of the selection, select them
|
||||
if (
|
||||
inputField.value[start - 1] === '(' &&
|
||||
inputField.value[end] === ')'
|
||||
@@ -138,7 +130,6 @@ app.registerExtension({
|
||||
selectedText = inputField.value.substring(start, end)
|
||||
}
|
||||
|
||||
// If the selection is not enclosed in parentheses, add them
|
||||
if (
|
||||
selectedText[0] !== '(' ||
|
||||
selectedText[selectedText.length - 1] !== ')'
|
||||
@@ -146,10 +137,8 @@ app.registerExtension({
|
||||
selectedText = `(${selectedText})`
|
||||
}
|
||||
|
||||
// If the selection does not have a weight, add a weight of 1.0
|
||||
selectedText = addWeightToParentheses(selectedText)
|
||||
|
||||
// Increment the weight
|
||||
const weightDelta = event.key === 'ArrowUp' ? delta : -delta
|
||||
const updatedText = selectedText.replace(
|
||||
/\((.*):([+-]?\d+(?:\.\d+)?)\)/,
|
||||
|
||||
@@ -3225,9 +3225,7 @@
|
||||
"createProfileToPublish": "Create a profile to publish to ComfyHub",
|
||||
"createProfileCta": "Create a profile",
|
||||
"publishFailedTitle": "Publish failed",
|
||||
"publishFailedDescription": "Something went wrong while publishing your workflow. Please try again.",
|
||||
"publishSuccessTitle": "Published successfully",
|
||||
"publishSuccessDescription": "Your workflow is now live on ComfyHub."
|
||||
"publishFailedDescription": "Something went wrong while publishing your workflow. Please try again."
|
||||
},
|
||||
"comfyHubProfile": {
|
||||
"checkingAccess": "Checking your publishing access...",
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { Plan } from '@/platform/workspace/api/workspaceApi'
|
||||
|
||||
const { mockGetBillingPlans } = vi.hoisted(() => ({
|
||||
mockGetBillingPlans: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workspace/api/workspaceApi', () => ({
|
||||
workspaceApi: {
|
||||
getBillingPlans: mockGetBillingPlans
|
||||
}
|
||||
}))
|
||||
|
||||
const buildPlan = (overrides: Partial<Plan> = {}): Plan => ({
|
||||
slug: 'standard-monthly',
|
||||
tier: 'STANDARD',
|
||||
duration: 'MONTHLY',
|
||||
price_cents: 2000,
|
||||
credits_cents: 4200,
|
||||
max_seats: 1,
|
||||
availability: { available: true },
|
||||
seat_summary: {
|
||||
seat_count: 1,
|
||||
total_cost_cents: 2000,
|
||||
total_credits_cents: 4200
|
||||
},
|
||||
...overrides
|
||||
})
|
||||
|
||||
const importUseBillingPlans = async () => {
|
||||
const mod =
|
||||
await import('@/platform/cloud/subscription/composables/useBillingPlans')
|
||||
return mod.useBillingPlans
|
||||
}
|
||||
|
||||
describe('useBillingPlans', () => {
|
||||
let consoleErrorSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
mockGetBillingPlans.mockReset()
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
describe('fetchPlans', () => {
|
||||
it('populates plans and currentPlanSlug on success', async () => {
|
||||
const apiPlans = [
|
||||
buildPlan({ slug: 'standard-monthly', duration: 'MONTHLY' }),
|
||||
buildPlan({ slug: 'creator-annual', duration: 'ANNUAL' })
|
||||
]
|
||||
mockGetBillingPlans.mockResolvedValue({
|
||||
current_plan_slug: 'standard-monthly',
|
||||
plans: apiPlans
|
||||
})
|
||||
|
||||
const useBillingPlans = await importUseBillingPlans()
|
||||
const { fetchPlans, plans, currentPlanSlug, error, isLoading } =
|
||||
useBillingPlans()
|
||||
|
||||
await fetchPlans()
|
||||
|
||||
expect(plans.value).toEqual(apiPlans)
|
||||
expect(currentPlanSlug.value).toBe('standard-monthly')
|
||||
expect(error.value).toBeNull()
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('normalizes missing current_plan_slug to null', async () => {
|
||||
mockGetBillingPlans.mockResolvedValue({ plans: [buildPlan()] })
|
||||
|
||||
const useBillingPlans = await importUseBillingPlans()
|
||||
const { fetchPlans, currentPlanSlug } = useBillingPlans()
|
||||
|
||||
await fetchPlans()
|
||||
|
||||
expect(currentPlanSlug.value).toBeNull()
|
||||
})
|
||||
|
||||
it('dedupes concurrent calls while a fetch is in flight', async () => {
|
||||
let resolveFetch: (value: { plans: Plan[] }) => void = () => {}
|
||||
mockGetBillingPlans.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFetch = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const useBillingPlans = await importUseBillingPlans()
|
||||
const { fetchPlans, isLoading } = useBillingPlans()
|
||||
|
||||
const first = fetchPlans()
|
||||
expect(isLoading.value).toBe(true)
|
||||
const second = fetchPlans()
|
||||
|
||||
resolveFetch({ plans: [buildPlan()] })
|
||||
await Promise.all([first, second])
|
||||
|
||||
expect(mockGetBillingPlans).toHaveBeenCalledTimes(1)
|
||||
expect(isLoading.value).toBe(false)
|
||||
})
|
||||
|
||||
it('captures Error messages into error.value and logs to console', async () => {
|
||||
mockGetBillingPlans.mockRejectedValue(new Error('network down'))
|
||||
|
||||
const useBillingPlans = await importUseBillingPlans()
|
||||
const { fetchPlans, error, isLoading, plans } = useBillingPlans()
|
||||
|
||||
await fetchPlans()
|
||||
|
||||
expect(error.value).toBe('network down')
|
||||
expect(isLoading.value).toBe(false)
|
||||
expect(plans.value).toEqual([])
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'[useBillingPlans] Failed to fetch plans:',
|
||||
expect.any(Error)
|
||||
)
|
||||
})
|
||||
|
||||
it('uses a fallback message when rejection is not an Error instance', async () => {
|
||||
mockGetBillingPlans.mockRejectedValue('boom')
|
||||
|
||||
const useBillingPlans = await importUseBillingPlans()
|
||||
const { fetchPlans, error } = useBillingPlans()
|
||||
|
||||
await fetchPlans()
|
||||
|
||||
expect(error.value).toBe('Failed to fetch plans')
|
||||
})
|
||||
|
||||
it('clears previous error state when a new fetch succeeds', async () => {
|
||||
mockGetBillingPlans.mockRejectedValueOnce(new Error('first failure'))
|
||||
mockGetBillingPlans.mockResolvedValueOnce({ plans: [buildPlan()] })
|
||||
|
||||
const useBillingPlans = await importUseBillingPlans()
|
||||
const { fetchPlans, error } = useBillingPlans()
|
||||
|
||||
await fetchPlans()
|
||||
expect(error.value).toBe('first failure')
|
||||
|
||||
await fetchPlans()
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed plan lists', () => {
|
||||
it('partitions plans into monthly and annual by duration', async () => {
|
||||
const plans = [
|
||||
buildPlan({ slug: 'a-monthly', duration: 'MONTHLY' }),
|
||||
buildPlan({ slug: 'b-annual', duration: 'ANNUAL' }),
|
||||
buildPlan({ slug: 'c-monthly', duration: 'MONTHLY' })
|
||||
]
|
||||
mockGetBillingPlans.mockResolvedValue({ plans })
|
||||
|
||||
const useBillingPlans = await importUseBillingPlans()
|
||||
const { fetchPlans, monthlyPlans, annualPlans } = useBillingPlans()
|
||||
|
||||
await fetchPlans()
|
||||
|
||||
expect(monthlyPlans.value.map((p) => p.slug)).toEqual([
|
||||
'a-monthly',
|
||||
'c-monthly'
|
||||
])
|
||||
expect(annualPlans.value.map((p) => p.slug)).toEqual(['b-annual'])
|
||||
})
|
||||
|
||||
it('returns empty arrays when no plans are loaded', async () => {
|
||||
const useBillingPlans = await importUseBillingPlans()
|
||||
const { monthlyPlans, annualPlans } = useBillingPlans()
|
||||
|
||||
expect(monthlyPlans.value).toEqual([])
|
||||
expect(annualPlans.value).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('lookup helpers', () => {
|
||||
it('getPlanBySlug finds an existing plan and returns undefined otherwise', async () => {
|
||||
const plan = buildPlan({ slug: 'creator-annual' })
|
||||
mockGetBillingPlans.mockResolvedValue({ plans: [plan] })
|
||||
|
||||
const useBillingPlans = await importUseBillingPlans()
|
||||
const { fetchPlans, getPlanBySlug } = useBillingPlans()
|
||||
|
||||
await fetchPlans()
|
||||
|
||||
expect(getPlanBySlug('creator-annual')).toEqual(plan)
|
||||
expect(getPlanBySlug('missing')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('getPlansForTier filters plans by tier', async () => {
|
||||
mockGetBillingPlans.mockResolvedValue({
|
||||
plans: [
|
||||
buildPlan({ slug: 'standard-monthly', tier: 'STANDARD' }),
|
||||
buildPlan({ slug: 'creator-monthly', tier: 'CREATOR' }),
|
||||
buildPlan({ slug: 'creator-annual', tier: 'CREATOR' })
|
||||
]
|
||||
})
|
||||
|
||||
const useBillingPlans = await importUseBillingPlans()
|
||||
const { fetchPlans, getPlansForTier } = useBillingPlans()
|
||||
|
||||
await fetchPlans()
|
||||
|
||||
expect(getPlansForTier('CREATOR').map((p) => p.slug)).toEqual([
|
||||
'creator-monthly',
|
||||
'creator-annual'
|
||||
])
|
||||
expect(getPlansForTier('PRO')).toEqual([])
|
||||
})
|
||||
|
||||
it('isCurrentPlan reflects the loaded currentPlanSlug', async () => {
|
||||
mockGetBillingPlans.mockResolvedValue({
|
||||
current_plan_slug: 'standard-monthly',
|
||||
plans: [buildPlan()]
|
||||
})
|
||||
|
||||
const useBillingPlans = await importUseBillingPlans()
|
||||
const { fetchPlans, isCurrentPlan } = useBillingPlans()
|
||||
|
||||
expect(isCurrentPlan('standard-monthly')).toBe(false)
|
||||
|
||||
await fetchPlans()
|
||||
|
||||
expect(isCurrentPlan('standard-monthly')).toBe(true)
|
||||
expect(isCurrentPlan('creator-annual')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('shared module state', () => {
|
||||
it('shares refs across separate useBillingPlans() invocations', async () => {
|
||||
mockGetBillingPlans.mockResolvedValue({
|
||||
current_plan_slug: 'standard-monthly',
|
||||
plans: [buildPlan()]
|
||||
})
|
||||
|
||||
const useBillingPlans = await importUseBillingPlans()
|
||||
const first = useBillingPlans()
|
||||
await first.fetchPlans()
|
||||
|
||||
const second = useBillingPlans()
|
||||
expect(second.plans.value).toEqual(first.plans.value)
|
||||
expect(second.currentPlanSlug.value).toBe('standard-monthly')
|
||||
expect(second.isCurrentPlan('standard-monthly')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,109 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getCommonTierBenefits } from '@/platform/cloud/subscription/utils/tierBenefits'
|
||||
|
||||
const mockRemoteConfig = vi.hoisted(() => ({
|
||||
value: { free_tier_credits: 120 } as Record<string, unknown>
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/remoteConfig/remoteConfig', () => ({
|
||||
remoteConfig: mockRemoteConfig
|
||||
}))
|
||||
|
||||
const translate = (key: string) => `t:${key}`
|
||||
const formatNumber = (value: number) => `n:${value}`
|
||||
|
||||
describe('getCommonTierBenefits', () => {
|
||||
beforeEach(() => {
|
||||
mockRemoteConfig.value = { free_tier_credits: 120 }
|
||||
})
|
||||
|
||||
it('includes monthlyCredits only for the free tier when credits are configured', () => {
|
||||
const freeBenefits = getCommonTierBenefits('free', translate, formatNumber)
|
||||
|
||||
const monthlyCredits = freeBenefits.find((b) => b.key === 'monthlyCredits')
|
||||
expect(monthlyCredits).toEqual({
|
||||
key: 'monthlyCredits',
|
||||
type: 'metric',
|
||||
value: 'n:120',
|
||||
label: 't:subscription.monthlyCreditsLabel'
|
||||
})
|
||||
|
||||
const paidBenefits = getCommonTierBenefits(
|
||||
'standard',
|
||||
translate,
|
||||
formatNumber
|
||||
)
|
||||
expect(paidBenefits.some((b) => b.key === 'monthlyCredits')).toBe(false)
|
||||
})
|
||||
|
||||
it('omits monthlyCredits for free tier when remoteConfig has no credits', () => {
|
||||
mockRemoteConfig.value = {}
|
||||
|
||||
const benefits = getCommonTierBenefits('free', translate, formatNumber)
|
||||
|
||||
expect(benefits.some((b) => b.key === 'monthlyCredits')).toBe(false)
|
||||
})
|
||||
|
||||
it('includes a tier-scoped maxDuration metric for every tier', () => {
|
||||
const tiers = ['free', 'standard', 'creator', 'pro', 'founder'] as const
|
||||
|
||||
for (const tier of tiers) {
|
||||
const benefits = getCommonTierBenefits(tier, translate, formatNumber)
|
||||
const maxDuration = benefits.find((b) => b.key === 'maxDuration')
|
||||
|
||||
expect(maxDuration).toEqual({
|
||||
key: 'maxDuration',
|
||||
type: 'metric',
|
||||
value: `t:subscription.maxDuration.${tier}`,
|
||||
label: 't:subscription.maxDurationLabel'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('always includes the gpu feature benefit', () => {
|
||||
const benefits = getCommonTierBenefits('creator', translate, formatNumber)
|
||||
|
||||
expect(benefits).toContainEqual({
|
||||
key: 'gpu',
|
||||
type: 'feature',
|
||||
label: 't:subscription.gpuLabel'
|
||||
})
|
||||
})
|
||||
|
||||
it('adds the addCredits benefit for every tier except free', () => {
|
||||
const paidTiers = ['standard', 'creator', 'pro', 'founder'] as const
|
||||
|
||||
for (const tier of paidTiers) {
|
||||
const benefits = getCommonTierBenefits(tier, translate, formatNumber)
|
||||
expect(benefits.some((b) => b.key === 'addCredits')).toBe(true)
|
||||
}
|
||||
|
||||
const freeBenefits = getCommonTierBenefits('free', translate, formatNumber)
|
||||
expect(freeBenefits.some((b) => b.key === 'addCredits')).toBe(false)
|
||||
})
|
||||
|
||||
it('includes customLoRAs only when the tier has it enabled', () => {
|
||||
const creator = getCommonTierBenefits('creator', translate, formatNumber)
|
||||
const pro = getCommonTierBenefits('pro', translate, formatNumber)
|
||||
expect(creator.some((b) => b.key === 'customLoRAs')).toBe(true)
|
||||
expect(pro.some((b) => b.key === 'customLoRAs')).toBe(true)
|
||||
|
||||
const tiersWithoutLoRAs = ['free', 'standard', 'founder'] as const
|
||||
for (const tier of tiersWithoutLoRAs) {
|
||||
const benefits = getCommonTierBenefits(tier, translate, formatNumber)
|
||||
expect(benefits.some((b) => b.key === 'customLoRAs')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards translation params via the provided helpers', () => {
|
||||
const tSpy = vi.fn((key: string) => key)
|
||||
const nSpy = vi.fn((value: number) => String(value))
|
||||
|
||||
getCommonTierBenefits('free', tSpy, nSpy)
|
||||
|
||||
expect(nSpy).toHaveBeenCalledWith(120)
|
||||
expect(tSpy).toHaveBeenCalledWith('subscription.monthlyCreditsLabel')
|
||||
expect(tSpy).toHaveBeenCalledWith('subscription.maxDuration.free')
|
||||
})
|
||||
})
|
||||
@@ -60,9 +60,8 @@ function makeWorkflowData(
|
||||
}
|
||||
}
|
||||
|
||||
const { mockConfirm, mockTrackWorkflowSaved } = vi.hoisted(() => ({
|
||||
mockConfirm: vi.fn(),
|
||||
mockTrackWorkflowSaved: vi.fn()
|
||||
const { mockConfirm } = vi.hoisted(() => ({
|
||||
mockConfirm: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/dialogService', () => ({
|
||||
@@ -99,7 +98,7 @@ vi.mock('@/renderer/core/thumbnail/useWorkflowThumbnail', () => ({
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackDefaultViewSet: vi.fn(),
|
||||
trackWorkflowSaved: mockTrackWorkflowSaved,
|
||||
trackWorkflowSaved: vi.fn(),
|
||||
trackEnterLinear: vi.fn()
|
||||
})
|
||||
}))
|
||||
@@ -766,7 +765,6 @@ describe('useWorkflowService', () => {
|
||||
service = useWorkflowService()
|
||||
vi.spyOn(workflowStore, 'saveWorkflow').mockResolvedValue()
|
||||
vi.spyOn(workflowStore, 'renameWorkflow').mockResolvedValue()
|
||||
mockTrackWorkflowSaved.mockClear()
|
||||
app.rootGraph.extra = {}
|
||||
})
|
||||
|
||||
@@ -903,11 +901,6 @@ describe('useWorkflowService', () => {
|
||||
'workflows/test.app.json'
|
||||
)
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(copy)
|
||||
expect(mockTrackWorkflowSaved).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackWorkflowSaved).toHaveBeenCalledWith({
|
||||
is_app: true,
|
||||
is_new: true
|
||||
})
|
||||
})
|
||||
|
||||
it('self-overwrites when saving same name with same mode', async () => {
|
||||
@@ -928,42 +921,6 @@ describe('useWorkflowService', () => {
|
||||
expect(workflowStore.saveWorkflow).toHaveBeenCalledWith(source)
|
||||
})
|
||||
|
||||
it('emits a single is_new:true telemetry event on self-overwrite', async () => {
|
||||
const source = createModeTestWorkflow({
|
||||
path: 'workflows/test.app.json',
|
||||
initialMode: 'app'
|
||||
})
|
||||
vi.spyOn(workflowStore, 'getWorkflowByPath').mockReturnValue(source)
|
||||
mockConfirm.mockResolvedValue(true)
|
||||
|
||||
await service.saveWorkflowAs(source, {
|
||||
filename: 'test',
|
||||
isApp: true
|
||||
})
|
||||
|
||||
expect(mockTrackWorkflowSaved).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackWorkflowSaved).toHaveBeenCalledWith({
|
||||
is_app: true,
|
||||
is_new: true
|
||||
})
|
||||
})
|
||||
|
||||
it('calls prepareForSave once on self-overwrite', async () => {
|
||||
const source = createModeTestWorkflow({
|
||||
path: 'workflows/test.app.json',
|
||||
initialMode: 'app'
|
||||
})
|
||||
vi.spyOn(workflowStore, 'getWorkflowByPath').mockReturnValue(source)
|
||||
mockConfirm.mockResolvedValue(true)
|
||||
|
||||
await service.saveWorkflowAs(source, {
|
||||
filename: 'test',
|
||||
isApp: true
|
||||
})
|
||||
|
||||
expect(source.changeTracker!.prepareForSave).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not modify source workflow mode when saving persisted workflow as different mode', async () => {
|
||||
const source = createModeTestWorkflow({
|
||||
path: 'workflows/original.json',
|
||||
|
||||
@@ -140,9 +140,8 @@ export const useWorkflowService = () => {
|
||||
}
|
||||
|
||||
if (isSelfOverwrite) {
|
||||
workflow.changeTracker?.prepareForSave()
|
||||
// Call workflowStore.saveWorkflow directly: saveWorkflowAs emits its own is_new:true event below, so delegating to saveWorkflow() would also fire is_new:false and run prepareForSave a second time.
|
||||
await workflowStore.saveWorkflow(workflow)
|
||||
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
|
||||
await saveWorkflow(workflow)
|
||||
} else {
|
||||
let target: ComfyWorkflow
|
||||
if (workflow.isTemporary) {
|
||||
@@ -158,7 +157,8 @@ export const useWorkflowService = () => {
|
||||
app.rootGraph.extra.linearMode = isApp
|
||||
target.initialMode = isApp ? 'app' : 'graph'
|
||||
}
|
||||
target.changeTracker?.prepareForSave()
|
||||
if (workflowStore.isActive(target)) target.changeTracker?.checkState()
|
||||
|
||||
await workflowStore.saveWorkflow(target)
|
||||
}
|
||||
|
||||
@@ -174,7 +174,8 @@ export const useWorkflowService = () => {
|
||||
if (workflow.isTemporary) {
|
||||
await saveWorkflowAs(workflow)
|
||||
} else {
|
||||
workflow.changeTracker?.prepareForSave()
|
||||
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
|
||||
|
||||
const isApp = workflow.initialMode === 'app'
|
||||
const expectedPath =
|
||||
workflow.directory +
|
||||
@@ -369,7 +370,7 @@ export const useWorkflowService = () => {
|
||||
const workflowStore = useWorkspaceStore().workflow
|
||||
const activeWorkflow = workflowStore.activeWorkflow
|
||||
if (activeWorkflow) {
|
||||
activeWorkflow.changeTracker?.deactivate()
|
||||
activeWorkflow.changeTracker.store()
|
||||
if (settingStore.get('Comfy.Workflow.Persist') && activeWorkflow.path) {
|
||||
const activeState = activeWorkflow.activeState
|
||||
if (activeState) {
|
||||
|
||||
@@ -11,10 +11,8 @@ vi.mock('vue-i18n', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const mockToastAdd = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('primevue/usetoast', () => ({
|
||||
useToast: () => ({ add: mockToastAdd })
|
||||
useToast: () => ({ add: vi.fn() })
|
||||
}))
|
||||
|
||||
import ComfyHubPublishDialog from '@/platform/workflow/sharing/components/publish/ComfyHubPublishDialog.vue'
|
||||
@@ -220,9 +218,6 @@ describe('ComfyHubPublishDialog', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(mockSubmitToComfyHub).toHaveBeenCalledOnce()
|
||||
expect(mockToastAdd).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'success' })
|
||||
)
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
|
||||
@@ -213,12 +213,6 @@ async function handlePublish(): Promise<void> {
|
||||
if (path) {
|
||||
cachePublishPrefill(path, formData.value)
|
||||
}
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('comfyHubPublish.publishSuccessTitle'),
|
||||
detail: t('comfyHubPublish.publishSuccessDescription'),
|
||||
life: 5000
|
||||
})
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Failed to publish workflow:', error)
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useWidgetSelectActions } from '@/renderer/extensions/vueNodes/widgets/c
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
const mockCaptureCanvasState = vi.hoisted(() => vi.fn())
|
||||
const mockCheckState = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
const actual = await vi.importActual(
|
||||
@@ -20,7 +20,7 @@ vi.mock('@/platform/workflow/management/stores/workflowStore', async () => {
|
||||
useWorkflowStore: () => ({
|
||||
activeWorkflow: {
|
||||
changeTracker: {
|
||||
captureCanvasState: mockCaptureCanvasState
|
||||
checkState: mockCheckState
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -48,7 +48,7 @@ function createItems(...names: string[]): FormDropdownItem[] {
|
||||
describe('useWidgetSelectActions', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
mockCaptureCanvasState.mockClear()
|
||||
mockCheckState.mockClear()
|
||||
})
|
||||
|
||||
describe('updateSelectedItems', () => {
|
||||
@@ -71,7 +71,7 @@ describe('useWidgetSelectActions', () => {
|
||||
updateSelectedItems(new Set(['input-1']))
|
||||
|
||||
expect(modelValue.value).toBe('photo_abc.jpg')
|
||||
expect(mockCaptureCanvasState).toHaveBeenCalledOnce()
|
||||
expect(mockCheckState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('clears modelValue when empty set', () => {
|
||||
@@ -93,7 +93,7 @@ describe('useWidgetSelectActions', () => {
|
||||
updateSelectedItems(new Set())
|
||||
|
||||
expect(modelValue.value).toBeUndefined()
|
||||
expect(mockCaptureCanvasState).toHaveBeenCalledOnce()
|
||||
expect(mockCheckState).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('useWidgetSelectActions', () => {
|
||||
await handleFilesUpdate([file])
|
||||
|
||||
expect(modelValue.value).toBe('uploaded.png')
|
||||
expect(mockCaptureCanvasState).toHaveBeenCalledOnce()
|
||||
expect(mockCheckState).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('adds uploaded path to widget values array', async () => {
|
||||
|
||||
@@ -23,8 +23,8 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
const toastStore = useToastStore()
|
||||
const { wrapWithErrorHandlingAsync } = useErrorHandling()
|
||||
|
||||
function captureWorkflowState() {
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
function checkWorkflowState() {
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()
|
||||
}
|
||||
|
||||
function updateSelectedItems(selectedItems: Set<string>) {
|
||||
@@ -36,7 +36,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
: dropdownItems.value.find((item) => item.id === id)?.name
|
||||
|
||||
modelValue.value = name
|
||||
captureWorkflowState()
|
||||
checkWorkflowState()
|
||||
}
|
||||
|
||||
async function uploadFile(
|
||||
@@ -109,7 +109,7 @@ export function useWidgetSelectActions(options: UseWidgetSelectActionsOptions) {
|
||||
widget.callback(uploadedPaths[0])
|
||||
}
|
||||
|
||||
captureWorkflowState()
|
||||
checkWorkflowState()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
|
||||
const mockNodeOutputStore = vi.hoisted(() => ({
|
||||
snapshotOutputs: vi.fn(() => ({})),
|
||||
restoreOutputs: vi.fn()
|
||||
}))
|
||||
|
||||
const mockSubgraphNavigationStore = vi.hoisted(() => ({
|
||||
exportState: vi.fn(() => []),
|
||||
restoreState: vi.fn()
|
||||
}))
|
||||
|
||||
const mockWorkflowStore = vi.hoisted(() => ({
|
||||
activeWorkflow: null as { changeTracker: unknown } | null,
|
||||
getWorkflowByPath: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
graph: {},
|
||||
rootGraph: {
|
||||
serialize: vi.fn(() => ({
|
||||
nodes: [],
|
||||
links: [],
|
||||
groups: [],
|
||||
extra: {},
|
||||
config: {},
|
||||
version: 0.4,
|
||||
last_node_id: 0,
|
||||
last_link_id: 0
|
||||
}))
|
||||
},
|
||||
canvas: {
|
||||
ds: { scale: 1, offset: [0, 0] }
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
dispatchCustomEvent: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', () => ({
|
||||
useNodeOutputStore: vi.fn(() => mockNodeOutputStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/subgraphNavigationStore', () => ({
|
||||
useSubgraphNavigationStore: vi.fn(() => mockSubgraphNavigationStore)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({
|
||||
ComfyWorkflow: class {},
|
||||
useWorkflowStore: vi.fn(() => mockWorkflowStore)
|
||||
}))
|
||||
|
||||
import { app } from '@/scripts/app'
|
||||
import { api } from '@/scripts/api'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
|
||||
let nodeIdCounter = 0
|
||||
|
||||
function createState(nodeCount = 0): ComfyWorkflowJSON {
|
||||
const nodes = Array.from({ length: nodeCount }, () => ({
|
||||
id: ++nodeIdCounter,
|
||||
type: 'TestNode',
|
||||
pos: [0, 0],
|
||||
size: [100, 50],
|
||||
flags: {},
|
||||
order: 0,
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: {}
|
||||
}))
|
||||
return {
|
||||
nodes,
|
||||
links: [],
|
||||
groups: [],
|
||||
extra: {},
|
||||
config: {},
|
||||
version: 0.4,
|
||||
last_node_id: nodeIdCounter,
|
||||
last_link_id: 0
|
||||
} as unknown as ComfyWorkflowJSON
|
||||
}
|
||||
|
||||
function createTracker(initialState?: ComfyWorkflowJSON): ChangeTracker {
|
||||
const state = initialState ?? createState()
|
||||
const workflow = { path: '/test/workflow.json' } as never
|
||||
const tracker = new ChangeTracker(workflow, state)
|
||||
mockWorkflowStore.activeWorkflow = { changeTracker: tracker }
|
||||
return tracker
|
||||
}
|
||||
|
||||
function mockCanvasState(state: ComfyWorkflowJSON) {
|
||||
vi.mocked(app.rootGraph.serialize).mockReturnValue(state as never)
|
||||
}
|
||||
|
||||
describe('ChangeTracker', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
nodeIdCounter = 0
|
||||
ChangeTracker.isLoadingGraph = false
|
||||
mockWorkflowStore.activeWorkflow = null
|
||||
mockWorkflowStore.getWorkflowByPath.mockReturnValue(null)
|
||||
})
|
||||
|
||||
describe('captureCanvasState', () => {
|
||||
describe('guards', () => {
|
||||
it('is a no-op when app.graph is falsy', () => {
|
||||
const tracker = createTracker()
|
||||
const original = tracker.activeState
|
||||
|
||||
const spy = vi.spyOn(app, 'graph', 'get').mockReturnValue(null as never)
|
||||
tracker.captureCanvasState()
|
||||
spy.mockRestore()
|
||||
|
||||
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
|
||||
expect(tracker.activeState).toBe(original)
|
||||
})
|
||||
|
||||
it('is a no-op when changeCount > 0', () => {
|
||||
const tracker = createTracker()
|
||||
tracker.beforeChange()
|
||||
|
||||
tracker.captureCanvasState()
|
||||
|
||||
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a no-op when isLoadingGraph is true', () => {
|
||||
const tracker = createTracker()
|
||||
ChangeTracker.isLoadingGraph = true
|
||||
|
||||
tracker.captureCanvasState()
|
||||
|
||||
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a no-op when _restoringState is true', () => {
|
||||
const tracker = createTracker()
|
||||
tracker._restoringState = true
|
||||
|
||||
tracker.captureCanvasState()
|
||||
|
||||
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a no-op and logs error when called on inactive tracker', () => {
|
||||
const tracker = createTracker()
|
||||
mockWorkflowStore.activeWorkflow = { changeTracker: {} }
|
||||
|
||||
tracker.captureCanvasState()
|
||||
|
||||
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('state capture', () => {
|
||||
it('pushes to undoQueue, updates activeState, and calls updateModified', () => {
|
||||
const initial = createState(1)
|
||||
const tracker = createTracker(initial)
|
||||
const changed = createState(2)
|
||||
mockCanvasState(changed)
|
||||
|
||||
tracker.captureCanvasState()
|
||||
|
||||
expect(tracker.undoQueue).toHaveLength(1)
|
||||
expect(tracker.undoQueue[0]).toEqual(initial)
|
||||
expect(tracker.activeState).toEqual(changed)
|
||||
expect(api.dispatchCustomEvent).toHaveBeenCalledWith(
|
||||
'graphChanged',
|
||||
changed
|
||||
)
|
||||
})
|
||||
|
||||
it('does not push when state is identical', () => {
|
||||
const state = createState()
|
||||
const tracker = createTracker(state)
|
||||
mockCanvasState(state)
|
||||
|
||||
tracker.captureCanvasState()
|
||||
|
||||
expect(tracker.undoQueue).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('clears redoQueue on new change', () => {
|
||||
const tracker = createTracker(createState(1))
|
||||
tracker.redoQueue.push(createState(3))
|
||||
mockCanvasState(createState(2))
|
||||
|
||||
tracker.captureCanvasState()
|
||||
|
||||
expect(tracker.redoQueue).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('produces a single undo entry for a beforeChange/afterChange transaction', () => {
|
||||
const tracker = createTracker(createState(1))
|
||||
const intermediate = createState(2)
|
||||
const final = createState(3)
|
||||
|
||||
tracker.beforeChange()
|
||||
mockCanvasState(intermediate)
|
||||
tracker.captureCanvasState()
|
||||
expect(tracker.undoQueue).toHaveLength(0)
|
||||
|
||||
mockCanvasState(final)
|
||||
tracker.afterChange()
|
||||
|
||||
expect(tracker.undoQueue).toHaveLength(1)
|
||||
expect(tracker.activeState).toEqual(final)
|
||||
})
|
||||
|
||||
it('caps undoQueue at MAX_HISTORY', () => {
|
||||
const tracker = createTracker(createState(1))
|
||||
for (let i = 0; i < ChangeTracker.MAX_HISTORY; i++) {
|
||||
tracker.undoQueue.push(createState(1))
|
||||
}
|
||||
expect(tracker.undoQueue).toHaveLength(ChangeTracker.MAX_HISTORY)
|
||||
|
||||
mockCanvasState(createState(2))
|
||||
tracker.captureCanvasState()
|
||||
|
||||
expect(tracker.undoQueue).toHaveLength(ChangeTracker.MAX_HISTORY)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deactivate', () => {
|
||||
it('captures canvas state then stores viewport/outputs', () => {
|
||||
const tracker = createTracker(createState(1))
|
||||
const changed = createState(2)
|
||||
mockCanvasState(changed)
|
||||
|
||||
tracker.deactivate()
|
||||
|
||||
expect(tracker.activeState).toEqual(changed)
|
||||
expect(mockNodeOutputStore.snapshotOutputs).toHaveBeenCalled()
|
||||
expect(mockSubgraphNavigationStore.exportState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips captureCanvasState but still calls store during undo/redo', () => {
|
||||
const tracker = createTracker(createState(1))
|
||||
tracker._restoringState = true
|
||||
|
||||
tracker.deactivate()
|
||||
|
||||
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
|
||||
expect(mockNodeOutputStore.snapshotOutputs).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a full no-op when called on inactive tracker', () => {
|
||||
const tracker = createTracker()
|
||||
mockWorkflowStore.activeWorkflow = { changeTracker: {} }
|
||||
|
||||
tracker.deactivate()
|
||||
|
||||
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
|
||||
expect(mockNodeOutputStore.snapshotOutputs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepareForSave', () => {
|
||||
it('captures canvas state when tracker is active', () => {
|
||||
const tracker = createTracker(createState(1))
|
||||
const changed = createState(2)
|
||||
mockCanvasState(changed)
|
||||
|
||||
tracker.prepareForSave()
|
||||
|
||||
expect(tracker.activeState).toEqual(changed)
|
||||
})
|
||||
|
||||
it('is a no-op when tracker is inactive', () => {
|
||||
const tracker = createTracker()
|
||||
const original = tracker.activeState
|
||||
mockWorkflowStore.activeWorkflow = { changeTracker: {} }
|
||||
|
||||
tracker.prepareForSave()
|
||||
|
||||
expect(app.rootGraph.serialize).not.toHaveBeenCalled()
|
||||
expect(tracker.activeState).toBe(original)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkState (deprecated)', () => {
|
||||
it('delegates to captureCanvasState', () => {
|
||||
const tracker = createTracker(createState(1))
|
||||
const changed = createState(2)
|
||||
mockCanvasState(changed)
|
||||
|
||||
tracker.checkState()
|
||||
|
||||
expect(tracker.activeState).toEqual(changed)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,8 +4,10 @@ import log from 'loglevel'
|
||||
|
||||
import type { CanvasPointerEvent } from '@/lib/litegraph/src/litegraph'
|
||||
import { LGraphCanvas, LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import {
|
||||
ComfyWorkflow,
|
||||
useWorkflowStore
|
||||
} from '@/platform/workflow/management/stores/workflowStore'
|
||||
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
|
||||
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
@@ -24,18 +26,14 @@ const logger = log.getLogger('ChangeTracker')
|
||||
// Change to debug for more verbose logging
|
||||
logger.setLevel('info')
|
||||
|
||||
function isActiveTracker(tracker: ChangeTracker): boolean {
|
||||
return useWorkflowStore().activeWorkflow?.changeTracker === tracker
|
||||
}
|
||||
|
||||
export class ChangeTracker {
|
||||
static MAX_HISTORY = 50
|
||||
/**
|
||||
* Guard flag to prevent captureCanvasState from running during loadGraphData.
|
||||
* Guard flag to prevent checkState from running during loadGraphData.
|
||||
* Between rootGraph.configure() and afterLoadNewGraph(), the rootGraph
|
||||
* contains the NEW workflow's data while activeWorkflow still points to
|
||||
* the OLD workflow. Any captureCanvasState call in that window would
|
||||
* serialize the wrong graph into the old workflow's activeState, corrupting it.
|
||||
* the OLD workflow. Any checkState call in that window would serialize
|
||||
* the wrong graph into the old workflow's activeState, corrupting it.
|
||||
*/
|
||||
static isLoadingGraph = false
|
||||
/**
|
||||
@@ -93,41 +91,6 @@ export class ChangeTracker {
|
||||
this.subgraphState = { navigation }
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze this tracker's state before the workflow goes inactive.
|
||||
* Always calls store() to preserve viewport/outputs. Calls
|
||||
* captureCanvasState() only when not in undo/redo (to avoid
|
||||
* corrupting undo history with intermediate graph state).
|
||||
*
|
||||
* PRECONDITION: must be called while this workflow is still the active one
|
||||
* (before the activeWorkflow pointer is moved). If called after the pointer
|
||||
* has already moved, this is a no-op to avoid freezing wrong viewport data.
|
||||
*
|
||||
* @internal Not part of the public extension API.
|
||||
*/
|
||||
deactivate() {
|
||||
if (!isActiveTracker(this)) {
|
||||
logger.warn(
|
||||
'deactivate() called on inactive tracker for:',
|
||||
this.workflow.path
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!this._restoringState) this.captureCanvasState()
|
||||
this.store()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure activeState is up-to-date for persistence.
|
||||
* Active workflow: flushes canvas → activeState.
|
||||
* Inactive workflow: no-op (activeState was frozen by deactivate()).
|
||||
*
|
||||
* @internal Not part of the public extension API.
|
||||
*/
|
||||
prepareForSave() {
|
||||
if (isActiveTracker(this)) this.captureCanvasState()
|
||||
}
|
||||
|
||||
restore() {
|
||||
if (this.ds) {
|
||||
app.canvas.ds.scale = this.ds.scale
|
||||
@@ -175,28 +138,8 @@ export class ChangeTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the current canvas state into activeState and push undo.
|
||||
* INVARIANT: only the active workflow's tracker may read from the canvas.
|
||||
* Calling this on an inactive tracker would capture the wrong graph.
|
||||
*/
|
||||
captureCanvasState() {
|
||||
if (
|
||||
!app.graph ||
|
||||
this.changeCount ||
|
||||
this._restoringState ||
|
||||
ChangeTracker.isLoadingGraph
|
||||
)
|
||||
return
|
||||
|
||||
if (!isActiveTracker(this)) {
|
||||
logger.warn(
|
||||
'captureCanvasState called on inactive tracker for:',
|
||||
this.workflow.path
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
checkState() {
|
||||
if (!app.graph || this.changeCount || ChangeTracker.isLoadingGraph) return
|
||||
const currentState = clone(app.rootGraph.serialize()) as ComfyWorkflowJSON
|
||||
if (!this.activeState) {
|
||||
this.activeState = currentState
|
||||
@@ -215,19 +158,6 @@ export class ChangeTracker {
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link captureCanvasState} instead. */
|
||||
checkState() {
|
||||
if (!ChangeTracker._checkStateWarned) {
|
||||
ChangeTracker._checkStateWarned = true
|
||||
logger.warn(
|
||||
'checkState() is deprecated — use captureCanvasState() instead.'
|
||||
)
|
||||
}
|
||||
this.captureCanvasState()
|
||||
}
|
||||
|
||||
private static _checkStateWarned = false
|
||||
|
||||
async updateState(source: ComfyWorkflowJSON[], target: ComfyWorkflowJSON[]) {
|
||||
const prevState = source.pop()
|
||||
if (prevState) {
|
||||
@@ -286,14 +216,14 @@ export class ChangeTracker {
|
||||
|
||||
afterChange() {
|
||||
if (!--this.changeCount) {
|
||||
this.captureCanvasState()
|
||||
this.checkState()
|
||||
}
|
||||
}
|
||||
|
||||
static init() {
|
||||
const getCurrentChangeTracker = () =>
|
||||
useWorkflowStore().activeWorkflow?.changeTracker
|
||||
const captureState = () => getCurrentChangeTracker()?.captureCanvasState()
|
||||
const checkState = () => getCurrentChangeTracker()?.checkState()
|
||||
|
||||
let keyIgnored = false
|
||||
window.addEventListener(
|
||||
@@ -337,8 +267,8 @@ export class ChangeTracker {
|
||||
|
||||
// If our active element is some type of input then handle changes after they're done
|
||||
if (ChangeTracker.bindInput(bindInputEl)) return
|
||||
logger.debug('captureCanvasState on keydown')
|
||||
changeTracker.captureCanvasState()
|
||||
logger.debug('checkState on keydown')
|
||||
changeTracker.checkState()
|
||||
})
|
||||
},
|
||||
true
|
||||
@@ -347,34 +277,34 @@ export class ChangeTracker {
|
||||
window.addEventListener('keyup', () => {
|
||||
if (keyIgnored) {
|
||||
keyIgnored = false
|
||||
logger.debug('captureCanvasState on keyup')
|
||||
captureState()
|
||||
logger.debug('checkState on keyup')
|
||||
checkState()
|
||||
}
|
||||
})
|
||||
|
||||
// Handle clicking DOM elements (e.g. widgets)
|
||||
window.addEventListener('mouseup', () => {
|
||||
logger.debug('captureCanvasState on mouseup')
|
||||
captureState()
|
||||
logger.debug('checkState on mouseup')
|
||||
checkState()
|
||||
})
|
||||
|
||||
// Handle prompt queue event for dynamic widget changes
|
||||
api.addEventListener('promptQueued', () => {
|
||||
logger.debug('captureCanvasState on promptQueued')
|
||||
captureState()
|
||||
logger.debug('checkState on promptQueued')
|
||||
checkState()
|
||||
})
|
||||
|
||||
api.addEventListener('graphCleared', () => {
|
||||
logger.debug('captureCanvasState on graphCleared')
|
||||
captureState()
|
||||
logger.debug('checkState on graphCleared')
|
||||
checkState()
|
||||
})
|
||||
|
||||
// Handle litegraph clicks
|
||||
const processMouseUp = LGraphCanvas.prototype.processMouseUp
|
||||
LGraphCanvas.prototype.processMouseUp = function (e) {
|
||||
const v = processMouseUp.apply(this, [e])
|
||||
logger.debug('captureCanvasState on processMouseUp')
|
||||
captureState()
|
||||
logger.debug('checkState on processMouseUp')
|
||||
checkState()
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -388,9 +318,9 @@ export class ChangeTracker {
|
||||
) {
|
||||
const extendedCallback = (v: string) => {
|
||||
callback(v)
|
||||
captureState()
|
||||
checkState()
|
||||
}
|
||||
logger.debug('captureCanvasState on prompt')
|
||||
logger.debug('checkState on prompt')
|
||||
return prompt.apply(this, [title, value, extendedCallback, event])
|
||||
}
|
||||
|
||||
@@ -398,8 +328,8 @@ export class ChangeTracker {
|
||||
const close = LiteGraph.ContextMenu.prototype.close
|
||||
LiteGraph.ContextMenu.prototype.close = function (e: MouseEvent) {
|
||||
const v = close.apply(this, [e])
|
||||
logger.debug('captureCanvasState on contextMenuClose')
|
||||
captureState()
|
||||
logger.debug('checkState on contextMenuClose')
|
||||
checkState()
|
||||
return v
|
||||
}
|
||||
|
||||
@@ -451,7 +381,7 @@ export class ChangeTracker {
|
||||
const htmlElement = activeEl as HTMLElement
|
||||
if (`on${evt}` in htmlElement) {
|
||||
const listener = () => {
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.captureCanvasState?.()
|
||||
useWorkflowStore().activeWorkflow?.changeTracker?.checkState?.()
|
||||
htmlElement.removeEventListener(evt, listener)
|
||||
}
|
||||
htmlElement.addEventListener(evt, listener)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 552 B |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 266 B |
Binary file not shown.
|
Before Width: | Height: | Size: 272 B |
@@ -1,55 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getFromAvifFile } from './avif'
|
||||
|
||||
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.avif')
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('AVIF metadata', () => {
|
||||
it('extracts workflow and prompt from EXIF data in ISOBMFF boxes', async () => {
|
||||
const bytes = fs.readFileSync(fixturePath)
|
||||
const file = new File([bytes], 'test.avif', { type: 'image/avif' })
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(JSON.parse(result.workflow)).toEqual({
|
||||
nodes: [{ id: 1, type: 'KSampler', pos: [100, 100], size: [200, 200] }]
|
||||
})
|
||||
expect(JSON.parse(result.prompt)).toEqual({
|
||||
'1': { class_type: 'KSampler', inputs: {} }
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty for non-AVIF data', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const file = new File([new Uint8Array(16)], 'fake.avif')
|
||||
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result).toEqual({})
|
||||
expect(console.error).toHaveBeenCalledWith('Not a valid AVIF file')
|
||||
})
|
||||
|
||||
it('returns empty when AVIF has valid ftyp but corrupt internal boxes', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
const buf = new Uint8Array(40)
|
||||
const dv = new DataView(buf.buffer)
|
||||
dv.setUint32(0, 16)
|
||||
buf.set(new TextEncoder().encode('ftypavif'), 4)
|
||||
dv.setUint32(16, 24)
|
||||
buf.set(new TextEncoder().encode('meta'), 20)
|
||||
|
||||
const file = new File([buf], 'corrupt.avif', { type: 'image/avif' })
|
||||
const result = await getFromAvifFile(file)
|
||||
|
||||
expect(result).toEqual({})
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Error parsing AVIF metadata'),
|
||||
expect.anything()
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getFromWebmFile } from './ebml'
|
||||
|
||||
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.webm')
|
||||
|
||||
describe('WebM/EBML metadata', () => {
|
||||
it('extracts workflow and prompt from EBML SimpleTag elements', async () => {
|
||||
const bytes = fs.readFileSync(fixturePath)
|
||||
const file = new File([bytes], 'test.webm', { type: 'video/webm' })
|
||||
|
||||
const result = await getFromWebmFile(file)
|
||||
|
||||
expect(result.workflow).toEqual({
|
||||
nodes: [{ id: 1, type: 'KSampler', pos: [100, 100], size: [200, 200] }]
|
||||
})
|
||||
expect(result.prompt).toEqual({
|
||||
'1': { class_type: 'KSampler', inputs: {} }
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty for non-WebM data', async () => {
|
||||
const file = new File([new Uint8Array(16)], 'fake.webm')
|
||||
|
||||
const result = await getFromWebmFile(file)
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -1,30 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getFromFlacBuffer } from './flac'
|
||||
|
||||
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.flac')
|
||||
|
||||
describe('FLAC metadata', () => {
|
||||
it('extracts workflow and prompt from Vorbis comments', () => {
|
||||
const bytes = fs.readFileSync(fixturePath)
|
||||
const buffer = bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength
|
||||
)
|
||||
|
||||
const result = getFromFlacBuffer(buffer)
|
||||
|
||||
expect(result.workflow).toBe(
|
||||
'{"nodes":[{"id":1,"type":"KSampler","pos":[100,100],"size":[200,200]}]}'
|
||||
)
|
||||
expect(result.prompt).toBe('{"1":{"class_type":"KSampler","inputs":{}}}')
|
||||
})
|
||||
|
||||
it('returns undefined for non-FLAC data', () => {
|
||||
const buf = new ArrayBuffer(16)
|
||||
const result = getFromFlacBuffer(buf)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,33 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getFromIsobmffFile } from './isobmff'
|
||||
|
||||
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.mp4')
|
||||
|
||||
describe('ISOBMFF (MP4) metadata', () => {
|
||||
it('extracts workflow and prompt from QuickTime keys/ilst boxes', async () => {
|
||||
const bytes = fs.readFileSync(fixturePath)
|
||||
const file = new File([bytes], 'test.mp4', { type: 'video/mp4' })
|
||||
|
||||
const result = await getFromIsobmffFile(file)
|
||||
|
||||
expect(result.workflow).toEqual({
|
||||
nodes: [{ id: 1, type: 'KSampler', pos: [100, 100], size: [200, 200] }]
|
||||
})
|
||||
expect(result.prompt).toEqual({
|
||||
'1': { class_type: 'KSampler', inputs: {} }
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty for non-ISOBMFF data', async () => {
|
||||
const file = new File([new Uint8Array(16)], 'fake.mp4', {
|
||||
type: 'video/mp4'
|
||||
})
|
||||
|
||||
const result = await getFromIsobmffFile(file)
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getDataFromJSON } from './json'
|
||||
|
||||
function jsonFile(content: object): File {
|
||||
return new File([JSON.stringify(content)], 'test.json', {
|
||||
type: 'application/json'
|
||||
})
|
||||
}
|
||||
|
||||
describe('getDataFromJSON', () => {
|
||||
it('detects API-format workflows by class_type on every value', async () => {
|
||||
const apiData = {
|
||||
'1': { class_type: 'KSampler', inputs: {} },
|
||||
'2': { class_type: 'EmptyLatentImage', inputs: {} }
|
||||
}
|
||||
|
||||
const result = await getDataFromJSON(jsonFile(apiData))
|
||||
|
||||
expect(result).toEqual({ prompt: apiData })
|
||||
})
|
||||
|
||||
it('treats objects without universal class_type as a workflow', async () => {
|
||||
const workflow = { nodes: [], links: [], version: 1 }
|
||||
|
||||
const result = await getDataFromJSON(jsonFile(workflow))
|
||||
|
||||
expect(result).toEqual({ workflow })
|
||||
})
|
||||
|
||||
it('extracts templates when the root object has a templates key', async () => {
|
||||
const templates = [{ name: 'basic' }]
|
||||
|
||||
const result = await getDataFromJSON(jsonFile({ templates }))
|
||||
|
||||
expect(result).toEqual({ templates })
|
||||
})
|
||||
|
||||
it('returns undefined for non-JSON content', async () => {
|
||||
const file = new File(['not valid json'], 'bad.json', {
|
||||
type: 'application/json'
|
||||
})
|
||||
|
||||
const result = await getDataFromJSON(file)
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('FileReader failure modes', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('resolves undefined when the FileReader fires error', async () => {
|
||||
vi.spyOn(FileReader.prototype, 'readAsText').mockImplementation(
|
||||
function (this: FileReader) {
|
||||
queueMicrotask(() =>
|
||||
this.onerror?.(
|
||||
new ProgressEvent('error') as ProgressEvent<FileReader>
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const result = await getDataFromJSON(jsonFile({ nodes: [] }))
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves undefined when the FileReader fires abort', async () => {
|
||||
vi.spyOn(FileReader.prototype, 'readAsText').mockImplementation(
|
||||
function (this: FileReader) {
|
||||
queueMicrotask(() =>
|
||||
this.onabort?.(
|
||||
new ProgressEvent('abort') as ProgressEvent<FileReader>
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
const result = await getDataFromJSON(jsonFile({ nodes: [] }))
|
||||
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -6,24 +6,19 @@ export function getDataFromJSON(
|
||||
return new Promise<Record<string, object> | undefined>((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const readerResult = reader.result as string
|
||||
const jsonContent = JSON.parse(readerResult)
|
||||
if (jsonContent?.templates) {
|
||||
resolve({ templates: jsonContent.templates })
|
||||
return
|
||||
}
|
||||
if (isApiJson(jsonContent)) {
|
||||
resolve({ prompt: jsonContent })
|
||||
return
|
||||
}
|
||||
resolve({ workflow: jsonContent })
|
||||
} catch {
|
||||
resolve(undefined)
|
||||
const readerResult = reader.result as string
|
||||
const jsonContent = JSON.parse(readerResult)
|
||||
if (jsonContent?.templates) {
|
||||
resolve({ templates: jsonContent.templates })
|
||||
return
|
||||
}
|
||||
if (isApiJson(jsonContent)) {
|
||||
resolve({ prompt: jsonContent })
|
||||
return
|
||||
}
|
||||
resolve({ workflow: jsonContent })
|
||||
return
|
||||
}
|
||||
reader.onerror = () => resolve(undefined)
|
||||
reader.onabort = () => resolve(undefined)
|
||||
reader.readAsText(file)
|
||||
return
|
||||
})
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getMp3Metadata } from './mp3'
|
||||
|
||||
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.mp3')
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('MP3 metadata', () => {
|
||||
it('extracts workflow and prompt from ID3 tags', async () => {
|
||||
const bytes = fs.readFileSync(fixturePath)
|
||||
const file = new File([bytes], 'test.mp3', { type: 'audio/mpeg' })
|
||||
|
||||
const result = await getMp3Metadata(file)
|
||||
|
||||
expect(result.workflow).toEqual({
|
||||
nodes: [{ id: 1, type: 'KSampler', pos: [100, 100], size: [200, 200] }]
|
||||
})
|
||||
expect(result.prompt).toEqual({
|
||||
'1': { class_type: 'KSampler', inputs: {} }
|
||||
})
|
||||
})
|
||||
|
||||
it('returns undefined fields when file has no embedded metadata', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const file = new File([new Uint8Array(16)], 'empty.mp3', {
|
||||
type: 'audio/mpeg'
|
||||
})
|
||||
|
||||
const result = await getMp3Metadata(file)
|
||||
|
||||
expect(result.workflow).toBeUndefined()
|
||||
expect(result.prompt).toBeUndefined()
|
||||
expect(console.error).toHaveBeenCalledWith('Invalid file signature.')
|
||||
})
|
||||
|
||||
it('handles files larger than 4096 bytes without RangeError', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const size = 5000
|
||||
const buf = new Uint8Array(size)
|
||||
buf[4500] = 0xff
|
||||
buf[4501] = 0xfb
|
||||
const file = new File([buf], 'large.mp3', { type: 'audio/mpeg' })
|
||||
|
||||
const result = await getMp3Metadata(file)
|
||||
|
||||
expect(result.workflow).toBeUndefined()
|
||||
expect(result.prompt).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -15,11 +15,7 @@ export async function getMp3Metadata(file: File) {
|
||||
let header = ''
|
||||
while (header.length < arrayBuffer.byteLength) {
|
||||
const page = String.fromCharCode(
|
||||
...new Uint8Array(
|
||||
arrayBuffer,
|
||||
header.length,
|
||||
Math.min(4096, arrayBuffer.byteLength - header.length)
|
||||
)
|
||||
...new Uint8Array(arrayBuffer, header.length, header.length + 4096)
|
||||
)
|
||||
header += page
|
||||
if (page.match('\u00ff\u00fb')) break
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getOggMetadata } from './ogg'
|
||||
|
||||
const fixturePath = path.resolve(__dirname, '__fixtures__/with_metadata.opus')
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('OGG/Opus metadata', () => {
|
||||
it('extracts workflow and prompt from an Opus file', async () => {
|
||||
const bytes = fs.readFileSync(fixturePath)
|
||||
const file = new File([bytes], 'test.opus', { type: 'audio/ogg' })
|
||||
|
||||
const result = await getOggMetadata(file)
|
||||
|
||||
expect(result.workflow).toEqual({
|
||||
nodes: [{ id: 1, type: 'KSampler', pos: [100, 100], size: [200, 200] }]
|
||||
})
|
||||
expect(result.prompt).toEqual({
|
||||
'1': { class_type: 'KSampler', inputs: {} }
|
||||
})
|
||||
})
|
||||
|
||||
it('returns undefined fields for non-OGG data', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const file = new File([new Uint8Array(16)], 'fake.ogg', {
|
||||
type: 'audio/ogg'
|
||||
})
|
||||
|
||||
const result = await getOggMetadata(file)
|
||||
|
||||
expect(result.workflow).toBeUndefined()
|
||||
expect(result.prompt).toBeUndefined()
|
||||
expect(console.error).toHaveBeenCalledWith('Invalid file signature.')
|
||||
})
|
||||
|
||||
it('handles files larger than 4096 bytes without RangeError', async () => {
|
||||
const size = 5000
|
||||
const buf = new Uint8Array(size)
|
||||
const oggs = new TextEncoder().encode('OggS\0')
|
||||
buf.set(oggs, 0)
|
||||
buf.set(oggs, 4500)
|
||||
const file = new File([buf], 'large.ogg', { type: 'audio/ogg' })
|
||||
|
||||
const result = await getOggMetadata(file)
|
||||
|
||||
expect(result.workflow).toBeUndefined()
|
||||
expect(result.prompt).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -11,11 +11,7 @@ export async function getOggMetadata(file: File) {
|
||||
let header = ''
|
||||
while (header.length < arrayBuffer.byteLength) {
|
||||
const page = String.fromCharCode(
|
||||
...new Uint8Array(
|
||||
arrayBuffer,
|
||||
header.length,
|
||||
Math.min(4096, arrayBuffer.byteLength - header.length)
|
||||
)
|
||||
...new Uint8Array(arrayBuffer, header.length, header.length + 4096)
|
||||
)
|
||||
if (page.match('OggS\u0000')) oggs++
|
||||
header += page
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getFromPngBuffer, getFromPngFile } from './png'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
||||
import { getFromPngBuffer } from './png'
|
||||
|
||||
function createPngWithChunk(
|
||||
chunkType: string,
|
||||
keyword: string,
|
||||
content: string | Uint8Array,
|
||||
content: string,
|
||||
options: {
|
||||
compressionFlag?: number
|
||||
compressionMethod?: number
|
||||
@@ -24,11 +20,12 @@ function createPngWithChunk(
|
||||
translatedKeyword = ''
|
||||
} = options
|
||||
|
||||
const signature = new Uint8Array(PNG_SIGNATURE)
|
||||
const signature = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
||||
])
|
||||
const typeBytes = new TextEncoder().encode(chunkType)
|
||||
const keywordBytes = new TextEncoder().encode(keyword)
|
||||
const contentBytes =
|
||||
content instanceof Uint8Array ? content : new TextEncoder().encode(content)
|
||||
const contentBytes = new TextEncoder().encode(content)
|
||||
|
||||
let chunkData: Uint8Array
|
||||
if (chunkType === 'iTXt') {
|
||||
@@ -69,11 +66,12 @@ function createPngWithChunk(
|
||||
new DataView(lengthBytes.buffer).setUint32(0, chunkData.length, false)
|
||||
|
||||
const crc = new Uint8Array(4)
|
||||
|
||||
const iendType = new TextEncoder().encode('IEND')
|
||||
const iendLength = new Uint8Array(4)
|
||||
const iendCrc = new Uint8Array(4)
|
||||
|
||||
const total = signature.length + (4 + 4 + chunkData.length + 4) + (4 + 4 + 4)
|
||||
const total = signature.length + 4 + 4 + chunkData.length + 4 + 4 + 4 + 0 + 4
|
||||
const result = new Uint8Array(total)
|
||||
|
||||
let offset = 0
|
||||
@@ -140,21 +138,6 @@ describe('getFromPngBuffer', () => {
|
||||
expect(result['workflow']).toBe(workflow)
|
||||
})
|
||||
|
||||
it('logs warning and skips iTXt chunk with unsupported compression method', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const buffer = createPngWithChunk('iTXt', 'workflow', 'data', {
|
||||
compressionFlag: 1,
|
||||
compressionMethod: 99
|
||||
})
|
||||
|
||||
const result = await getFromPngBuffer(buffer)
|
||||
|
||||
expect(result['workflow']).toBeUndefined()
|
||||
expect(console.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Unsupported compression method 99')
|
||||
)
|
||||
})
|
||||
|
||||
it('parses compressed iTXt chunk', async () => {
|
||||
const workflow = '{"nodes":[{"id":1,"type":"KSampler"}]}'
|
||||
const contentBytes = new TextEncoder().encode(workflow)
|
||||
@@ -180,35 +163,83 @@ describe('getFromPngBuffer', () => {
|
||||
pos += chunk.length
|
||||
}
|
||||
|
||||
const buffer = createPngWithChunk('iTXt', 'workflow', compressedBytes, {
|
||||
compressionFlag: 1,
|
||||
compressionMethod: 0
|
||||
})
|
||||
const buffer = createPngWithCompressedITXt(
|
||||
'workflow',
|
||||
compressedBytes,
|
||||
'',
|
||||
''
|
||||
)
|
||||
const result = await getFromPngBuffer(buffer)
|
||||
expect(result['workflow']).toBe(workflow)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFromPngFile', () => {
|
||||
it('reads metadata from a File object', async () => {
|
||||
const workflow = '{"nodes":[]}'
|
||||
const buffer = createPngWithChunk('tEXt', 'workflow', workflow)
|
||||
const file = new File([buffer], 'test.png', { type: 'image/png' })
|
||||
function createPngWithCompressedITXt(
|
||||
keyword: string,
|
||||
compressedContent: Uint8Array,
|
||||
languageTag: string,
|
||||
translatedKeyword: string
|
||||
): ArrayBuffer {
|
||||
const signature = new Uint8Array([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a
|
||||
])
|
||||
const typeBytes = new TextEncoder().encode('iTXt')
|
||||
const keywordBytes = new TextEncoder().encode(keyword)
|
||||
const langBytes = new TextEncoder().encode(languageTag)
|
||||
const transBytes = new TextEncoder().encode(translatedKeyword)
|
||||
|
||||
const result = await getFromPngFile(file)
|
||||
const totalLength =
|
||||
keywordBytes.length +
|
||||
1 +
|
||||
2 +
|
||||
langBytes.length +
|
||||
1 +
|
||||
transBytes.length +
|
||||
1 +
|
||||
compressedContent.length
|
||||
|
||||
expect(result['workflow']).toBe(workflow)
|
||||
})
|
||||
const chunkData = new Uint8Array(totalLength)
|
||||
let pos = 0
|
||||
chunkData.set(keywordBytes, pos)
|
||||
pos += keywordBytes.length
|
||||
chunkData[pos++] = 0
|
||||
chunkData[pos++] = 1
|
||||
chunkData[pos++] = 0
|
||||
chunkData.set(langBytes, pos)
|
||||
pos += langBytes.length
|
||||
chunkData[pos++] = 0
|
||||
chunkData.set(transBytes, pos)
|
||||
pos += transBytes.length
|
||||
chunkData[pos++] = 0
|
||||
chunkData.set(compressedContent, pos)
|
||||
|
||||
it('returns empty for an invalid PNG File', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const file = new File([new ArrayBuffer(8)], 'bad.png', {
|
||||
type: 'image/png'
|
||||
})
|
||||
const lengthBytes = new Uint8Array(4)
|
||||
new DataView(lengthBytes.buffer).setUint32(0, chunkData.length, false)
|
||||
|
||||
const result = await getFromPngFile(file)
|
||||
const crc = new Uint8Array(4)
|
||||
const iendType = new TextEncoder().encode('IEND')
|
||||
const iendLength = new Uint8Array(4)
|
||||
const iendCrc = new Uint8Array(4)
|
||||
|
||||
expect(result).toEqual({})
|
||||
expect(console.error).toHaveBeenCalledWith('Not a valid PNG file')
|
||||
})
|
||||
})
|
||||
const total = signature.length + 4 + 4 + chunkData.length + 4 + 4 + 4 + 0 + 4
|
||||
const result = new Uint8Array(total)
|
||||
|
||||
let offset = 0
|
||||
result.set(signature, offset)
|
||||
offset += signature.length
|
||||
result.set(lengthBytes, offset)
|
||||
offset += 4
|
||||
result.set(typeBytes, offset)
|
||||
offset += 4
|
||||
result.set(chunkData, offset)
|
||||
offset += chunkData.length
|
||||
result.set(crc, offset)
|
||||
offset += 4
|
||||
result.set(iendLength, offset)
|
||||
offset += 4
|
||||
result.set(iendType, offset)
|
||||
offset += 4
|
||||
result.set(iendCrc, offset)
|
||||
|
||||
return result.buffer
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getSvgMetadata } from './svg'
|
||||
|
||||
function svgFile(content: string): File {
|
||||
return new File([content], 'test.svg', { type: 'image/svg+xml' })
|
||||
}
|
||||
|
||||
describe('getSvgMetadata', () => {
|
||||
it('extracts workflow and prompt from CDATA in <metadata>', async () => {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<metadata><![CDATA[${JSON.stringify({
|
||||
workflow: { nodes: [] },
|
||||
prompt: { '1': {} }
|
||||
})}]]></metadata>
|
||||
<rect width="1" height="1"/>
|
||||
</svg>`
|
||||
|
||||
const result = await getSvgMetadata(svgFile(svg))
|
||||
|
||||
expect(result).toEqual({
|
||||
workflow: { nodes: [] },
|
||||
prompt: { '1': {} }
|
||||
})
|
||||
})
|
||||
|
||||
it('returns empty when SVG has no metadata element', async () => {
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
|
||||
|
||||
const result = await getSvgMetadata(svgFile(svg))
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('returns empty when CDATA contains invalid JSON', async () => {
|
||||
const svg = `<svg><metadata><![CDATA[not valid json]]></metadata></svg>`
|
||||
|
||||
const result = await getSvgMetadata(svgFile(svg))
|
||||
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
})
|
||||
@@ -1,185 +1,67 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getLatentMetadata, getWebpMetadata } from './pnginfo'
|
||||
import { getWebpMetadata } from './pnginfo'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
function buildExifPayload(workflowJson: string): Uint8Array {
|
||||
const fullStr = `workflow:${workflowJson}\0`
|
||||
const strBytes = new TextEncoder().encode(fullStr)
|
||||
|
||||
const fixturesDir = path.resolve(__dirname, 'metadata/__fixtures__')
|
||||
|
||||
type AsciiIfdEntry = { tag: number; value: string }
|
||||
|
||||
function encodeAsciiIfd(entries: AsciiIfdEntry[]): Uint8Array {
|
||||
const tableSize = 10 + 12 * entries.length
|
||||
const strings = entries.map((e) => new TextEncoder().encode(`${e.value}\0`))
|
||||
const totalStringBytes = strings.reduce((sum, s) => sum + s.length, 0)
|
||||
|
||||
const buf = new Uint8Array(tableSize + totalStringBytes)
|
||||
const headerSize = 22
|
||||
const buf = new Uint8Array(headerSize + strBytes.length)
|
||||
const dv = new DataView(buf.buffer)
|
||||
|
||||
buf.set([0x49, 0x49], 0)
|
||||
dv.setUint16(2, 0x002a, true)
|
||||
dv.setUint32(4, 8, true)
|
||||
dv.setUint16(8, entries.length, true)
|
||||
|
||||
let stringOffset = tableSize
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entryOffset = 10 + i * 12
|
||||
dv.setUint16(entryOffset, entries[i].tag, true)
|
||||
dv.setUint16(entryOffset + 2, 2, true)
|
||||
dv.setUint32(entryOffset + 4, strings[i].length, true)
|
||||
dv.setUint32(entryOffset + 8, stringOffset, true)
|
||||
buf.set(strings[i], stringOffset)
|
||||
stringOffset += strings[i].length
|
||||
}
|
||||
dv.setUint16(8, 1, true)
|
||||
dv.setUint16(10, 0, true)
|
||||
dv.setUint16(12, 2, true)
|
||||
dv.setUint32(14, strBytes.length, true)
|
||||
dv.setUint32(18, 22, true)
|
||||
buf.set(strBytes, 22)
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
type WebpChunk = { type: string; payload: Uint8Array }
|
||||
function buildWebp(precedingChunkLength: number, workflowJson: string): File {
|
||||
const exifPayload = buildExifPayload(workflowJson)
|
||||
const precedingPadded = precedingChunkLength + (precedingChunkLength % 2)
|
||||
const totalSize = 12 + (8 + precedingPadded) + (8 + exifPayload.length)
|
||||
|
||||
function wrapInWebp(chunks: WebpChunk[]): File {
|
||||
let payloadSize = 0
|
||||
for (const c of chunks) {
|
||||
payloadSize += 8 + c.payload.length + (c.payload.length % 2)
|
||||
}
|
||||
const totalSize = 12 + payloadSize
|
||||
const buf = new Uint8Array(totalSize)
|
||||
const dv = new DataView(buf.buffer)
|
||||
const buffer = new Uint8Array(totalSize)
|
||||
const dv = new DataView(buffer.buffer)
|
||||
|
||||
buf.set([0x52, 0x49, 0x46, 0x46], 0)
|
||||
buffer.set([0x52, 0x49, 0x46, 0x46], 0)
|
||||
dv.setUint32(4, totalSize - 8, true)
|
||||
buf.set([0x57, 0x45, 0x42, 0x50], 8)
|
||||
buffer.set([0x57, 0x45, 0x42, 0x50], 8)
|
||||
|
||||
let offset = 12
|
||||
for (const c of chunks) {
|
||||
for (let i = 0; i < 4; i++) {
|
||||
buf[offset + i] = c.type.charCodeAt(i)
|
||||
}
|
||||
dv.setUint32(offset + 4, c.payload.length, true)
|
||||
buf.set(c.payload, offset + 8)
|
||||
offset += 8 + c.payload.length + (c.payload.length % 2)
|
||||
}
|
||||
buffer.set([0x56, 0x50, 0x38, 0x20], 12)
|
||||
dv.setUint32(16, precedingChunkLength, true)
|
||||
|
||||
return new File([buf], 'test.webp', { type: 'image/webp' })
|
||||
}
|
||||
const exifStart = 20 + precedingPadded
|
||||
buffer.set([0x45, 0x58, 0x49, 0x46], exifStart)
|
||||
dv.setUint32(exifStart + 4, exifPayload.length, true)
|
||||
buffer.set(exifPayload, exifStart + 8)
|
||||
|
||||
function exifChunk(
|
||||
entries: AsciiIfdEntry[],
|
||||
options: { withExifPrefix?: boolean } = {}
|
||||
): WebpChunk {
|
||||
const ifd = encodeAsciiIfd(entries)
|
||||
if (!options.withExifPrefix) {
|
||||
return { type: 'EXIF', payload: ifd }
|
||||
}
|
||||
const prefixed = new Uint8Array(6 + ifd.length)
|
||||
prefixed.set(new TextEncoder().encode('Exif\0\0'), 0)
|
||||
prefixed.set(ifd, 6)
|
||||
return { type: 'EXIF', payload: prefixed }
|
||||
return new File([buffer], 'test.webp', { type: 'image/webp' })
|
||||
}
|
||||
|
||||
describe('getWebpMetadata', () => {
|
||||
it('returns empty when the file is not a valid WEBP', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const file = new File([new Uint8Array(12)], 'fake.webp')
|
||||
it('finds workflow when a preceding chunk has odd length (RIFF padding)', async () => {
|
||||
const workflow = '{"nodes":[]}'
|
||||
const file = buildWebp(3, workflow)
|
||||
|
||||
const metadata = await getWebpMetadata(file)
|
||||
|
||||
expect(metadata).toEqual({})
|
||||
expect(console.error).toHaveBeenCalledWith('Not a valid WEBP file')
|
||||
expect(metadata.workflow).toBe(workflow)
|
||||
})
|
||||
|
||||
it('returns empty when a valid WEBP has no EXIF chunk', async () => {
|
||||
const file = wrapInWebp([
|
||||
{ type: 'VP8 ', payload: new Uint8Array([0, 0, 0, 0]) }
|
||||
])
|
||||
it('finds workflow when preceding chunk has even length (no padding)', async () => {
|
||||
const workflow = '{"nodes":[1]}'
|
||||
const file = buildWebp(4, workflow)
|
||||
|
||||
const metadata = await getWebpMetadata(file)
|
||||
|
||||
expect(metadata).toEqual({})
|
||||
})
|
||||
|
||||
it('extracts workflow and prompt from EXIF without prefix', async () => {
|
||||
const bytes = fs.readFileSync(path.join(fixturesDir, 'with_metadata.webp'))
|
||||
const file = new File([bytes], 'test.webp', { type: 'image/webp' })
|
||||
|
||||
const metadata = await getWebpMetadata(file)
|
||||
|
||||
expect(metadata).toEqual({
|
||||
workflow:
|
||||
'{"nodes":[{"id":1,"type":"KSampler","pos":[100,100],"size":[200,200]}]}',
|
||||
prompt: '{"1":{"class_type":"KSampler","inputs":{}}}'
|
||||
})
|
||||
})
|
||||
|
||||
it('extracts workflow and prompt from EXIF with Exif\\0\\0 prefix', async () => {
|
||||
const bytes = fs.readFileSync(
|
||||
path.join(fixturesDir, 'with_metadata_exif_prefix.webp')
|
||||
)
|
||||
const file = new File([bytes], 'test.webp', { type: 'image/webp' })
|
||||
|
||||
const metadata = await getWebpMetadata(file)
|
||||
|
||||
expect(metadata).toEqual({
|
||||
workflow:
|
||||
'{"nodes":[{"id":1,"type":"KSampler","pos":[100,100],"size":[200,200]}]}',
|
||||
prompt: '{"1":{"class_type":"KSampler","inputs":{}}}'
|
||||
})
|
||||
})
|
||||
|
||||
it('walks past odd-length preceding chunks (RIFF padding)', async () => {
|
||||
const file = wrapInWebp([
|
||||
{ type: 'VP8 ', payload: new Uint8Array(3) },
|
||||
exifChunk([{ tag: 0, value: 'workflow:{"a":1}' }])
|
||||
])
|
||||
|
||||
const metadata = await getWebpMetadata(file)
|
||||
|
||||
expect(metadata).toEqual({ workflow: '{"a":1}' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLatentMetadata', () => {
|
||||
function buildSafetensors(headerObj: object): File {
|
||||
const headerBytes = new TextEncoder().encode(JSON.stringify(headerObj))
|
||||
const buf = new Uint8Array(8 + headerBytes.length)
|
||||
const dv = new DataView(buf.buffer)
|
||||
dv.setUint32(0, headerBytes.length, true)
|
||||
dv.setUint32(4, 0, true)
|
||||
buf.set(headerBytes, 8)
|
||||
return new File([buf], 'test.safetensors')
|
||||
}
|
||||
|
||||
it('extracts __metadata__ from a safetensors header', async () => {
|
||||
const workflow =
|
||||
'{"nodes":[{"id":1,"type":"KSampler","pos":[100,100],"size":[200,200]}]}'
|
||||
const prompt = '{"1":{"class_type":"KSampler","inputs":{}}}'
|
||||
const file = buildSafetensors({
|
||||
__metadata__: { workflow, prompt },
|
||||
'tensor.weight': { dtype: 'F32', shape: [1], data_offsets: [0, 4] }
|
||||
})
|
||||
|
||||
const metadata = await getLatentMetadata(file)
|
||||
|
||||
expect(metadata).toEqual({ workflow, prompt })
|
||||
})
|
||||
|
||||
it('returns undefined when the safetensors header has no __metadata__', async () => {
|
||||
const file = buildSafetensors({
|
||||
'tensor.weight': { dtype: 'F32', shape: [1], data_offsets: [0, 4] }
|
||||
})
|
||||
|
||||
const metadata = await getLatentMetadata(file)
|
||||
|
||||
expect(metadata).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns undefined for a truncated or malformed file', async () => {
|
||||
const file = new File([new Uint8Array(4)], 'bad.safetensors')
|
||||
|
||||
const metadata = await getLatentMetadata(file)
|
||||
|
||||
expect(metadata).toBeUndefined()
|
||||
expect(metadata.workflow).toBe(workflow)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,17 +105,14 @@ export function getWebpMetadata(file: File) {
|
||||
...webp.slice(offset, offset + 4)
|
||||
)
|
||||
if (chunk_type === 'EXIF') {
|
||||
let exifOffset = offset + 8
|
||||
let exifLength = chunk_length
|
||||
if (
|
||||
String.fromCharCode(...webp.slice(exifOffset, exifOffset + 6)) ==
|
||||
String.fromCharCode(...webp.slice(offset + 8, offset + 8 + 6)) ==
|
||||
'Exif\0\0'
|
||||
) {
|
||||
exifOffset += 6
|
||||
exifLength -= 6
|
||||
offset += 6
|
||||
}
|
||||
const data = parseExifData(
|
||||
webp.slice(exifOffset, exifOffset + exifLength)
|
||||
let data = parseExifData(
|
||||
webp.slice(offset + 8, offset + 8 + chunk_length)
|
||||
)
|
||||
for (const key in data) {
|
||||
const value = data[Number(key)]
|
||||
@@ -139,31 +136,25 @@ export function getWebpMetadata(file: File) {
|
||||
})
|
||||
}
|
||||
|
||||
export function getLatentMetadata(
|
||||
file: File
|
||||
): Promise<Record<string, string> | undefined> {
|
||||
export function getLatentMetadata(file: File): Promise<Record<string, string>> {
|
||||
return new Promise((r) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const safetensorsData = new Uint8Array(
|
||||
event.target?.result as ArrayBuffer
|
||||
const safetensorsData = new Uint8Array(
|
||||
event.target?.result as ArrayBuffer
|
||||
)
|
||||
const dataView = new DataView(safetensorsData.buffer)
|
||||
let header_size = dataView.getUint32(0, true)
|
||||
let offset = 8
|
||||
let header = JSON.parse(
|
||||
new TextDecoder().decode(
|
||||
safetensorsData.slice(offset, offset + header_size)
|
||||
)
|
||||
const dataView = new DataView(safetensorsData.buffer)
|
||||
const headerSize = dataView.getUint32(0, true)
|
||||
const offset = 8
|
||||
const header = JSON.parse(
|
||||
new TextDecoder().decode(
|
||||
safetensorsData.slice(offset, offset + headerSize)
|
||||
)
|
||||
)
|
||||
r(header.__metadata__)
|
||||
} catch {
|
||||
r(undefined)
|
||||
}
|
||||
)
|
||||
r(header.__metadata__)
|
||||
}
|
||||
|
||||
const slice = file.slice(0, 1024 * 1024 * 4)
|
||||
var slice = file.slice(0, 1024 * 1024 * 4)
|
||||
reader.readAsArrayBuffer(slice)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -364,29 +364,29 @@ describe('appModeStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('calls captureCanvasState when input is selected', async () => {
|
||||
it('calls checkState when input is selected', async () => {
|
||||
const workflow = createBuilderWorkflow()
|
||||
workflowStore.activeWorkflow = workflow
|
||||
await nextTick()
|
||||
vi.mocked(workflow.changeTracker!.captureCanvasState).mockClear()
|
||||
vi.mocked(workflow.changeTracker!.checkState).mockClear()
|
||||
|
||||
store.selectedInputs.push([42, 'prompt'])
|
||||
await nextTick()
|
||||
|
||||
expect(workflow.changeTracker!.captureCanvasState).toHaveBeenCalled()
|
||||
expect(workflow.changeTracker!.checkState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls captureCanvasState when input is deselected', async () => {
|
||||
it('calls checkState when input is deselected', async () => {
|
||||
const workflow = createBuilderWorkflow()
|
||||
workflowStore.activeWorkflow = workflow
|
||||
store.selectedInputs.push([42, 'prompt'])
|
||||
await nextTick()
|
||||
vi.mocked(workflow.changeTracker!.captureCanvasState).mockClear()
|
||||
vi.mocked(workflow.changeTracker!.checkState).mockClear()
|
||||
|
||||
store.selectedInputs.splice(0, 1)
|
||||
await nextTick()
|
||||
|
||||
expect(workflow.changeTracker!.captureCanvasState).toHaveBeenCalled()
|
||||
expect(workflow.changeTracker!.checkState).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reflects input changes in linearData', async () => {
|
||||
|
||||
@@ -93,7 +93,7 @@ export const useAppModeStore = defineStore('appMode', () => {
|
||||
inputs: [...data.inputs],
|
||||
outputs: [...data.outputs]
|
||||
}
|
||||
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
|
||||
workflowStore.activeWorkflow?.changeTracker?.checkState()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ import type { Subgraph } from '@/lib/litegraph/src/litegraph'
|
||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
||||
import { useWorkflowService } from '@/platform/workflow/core/services/workflowService'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { requestSlotLayoutSyncForAllNodes } from '@/renderer/extensions/vueNodes/composables/useSlotElementTracking'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
|
||||
@@ -144,12 +143,6 @@ export const useSubgraphNavigationStore = defineStore(
|
||||
if (getActiveGraphId() !== graphId) return
|
||||
if (!canvas.graph?.nodes?.length) return
|
||||
useLitegraphService().fitView()
|
||||
// fitView changes scale/offset, so re-sync slot positions for
|
||||
// collapsed nodes whose DOM-relative measurement is now stale.
|
||||
requestAnimationFrame(() => {
|
||||
if (getActiveGraphId() !== graphId) return
|
||||
requestSlotLayoutSyncForAllNodes()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -12,13 +12,10 @@ import {
|
||||
VIEWPORT_CACHE_MAX_SIZE
|
||||
} from '@/stores/subgraphNavigationStore'
|
||||
|
||||
const { mockSetDirty, mockFitView, mockRequestSlotSyncAll } = vi.hoisted(
|
||||
() => ({
|
||||
mockSetDirty: vi.fn(),
|
||||
mockFitView: vi.fn(),
|
||||
mockRequestSlotSyncAll: vi.fn()
|
||||
})
|
||||
)
|
||||
const { mockSetDirty, mockFitView } = vi.hoisted(() => ({
|
||||
mockSetDirty: vi.fn(),
|
||||
mockFitView: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => {
|
||||
const mockCanvas = {
|
||||
@@ -69,13 +66,6 @@ vi.mock('@/services/litegraphService', () => ({
|
||||
useLitegraphService: () => ({ fitView: mockFitView })
|
||||
}))
|
||||
|
||||
vi.mock(
|
||||
'@/renderer/extensions/vueNodes/composables/useSlotElementTracking',
|
||||
() => ({
|
||||
requestSlotLayoutSyncForAllNodes: mockRequestSlotSyncAll
|
||||
})
|
||||
)
|
||||
|
||||
const mockCanvas = app.canvas
|
||||
|
||||
let rafCallbacks: FrameRequestCallback[] = []
|
||||
@@ -96,7 +86,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
mockCanvas.ds.state.offset = [0, 0]
|
||||
mockSetDirty.mockClear()
|
||||
mockFitView.mockClear()
|
||||
mockRequestSlotSyncAll.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -228,53 +217,6 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
|
||||
expect(mockFitView).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-syncs all slot layouts on the frame after fitView', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
store.viewportCache.delete(':root')
|
||||
|
||||
const mockGraph = app.graph as { nodes: unknown[]; _nodes: unknown[] }
|
||||
mockGraph.nodes = [{ pos: [0, 0], size: [100, 100] }]
|
||||
mockGraph._nodes = mockGraph.nodes
|
||||
|
||||
store.restoreViewport('root')
|
||||
expect(rafCallbacks).toHaveLength(1)
|
||||
|
||||
// Outer RAF runs fitView and schedules the inner RAF
|
||||
rafCallbacks[0](performance.now())
|
||||
expect(mockFitView).toHaveBeenCalledOnce()
|
||||
expect(mockRequestSlotSyncAll).not.toHaveBeenCalled()
|
||||
expect(rafCallbacks).toHaveLength(2)
|
||||
|
||||
// Inner RAF re-syncs slots after fitView's transform has been applied
|
||||
rafCallbacks[1](performance.now())
|
||||
expect(mockRequestSlotSyncAll).toHaveBeenCalledOnce()
|
||||
|
||||
mockGraph.nodes = []
|
||||
mockGraph._nodes = []
|
||||
})
|
||||
|
||||
it('skips slot re-sync if active graph changed between fitView and inner RAF', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
store.viewportCache.delete(':root')
|
||||
|
||||
const mockGraph = app.graph as { nodes: unknown[]; _nodes: unknown[] }
|
||||
mockGraph.nodes = [{ pos: [0, 0], size: [100, 100] }]
|
||||
mockGraph._nodes = mockGraph.nodes
|
||||
|
||||
store.restoreViewport('root')
|
||||
rafCallbacks[0](performance.now())
|
||||
expect(mockFitView).toHaveBeenCalledOnce()
|
||||
|
||||
// User navigated away before the inner RAF fired
|
||||
mockCanvas.subgraph = { id: 'different-graph' } as never
|
||||
rafCallbacks[1](performance.now())
|
||||
|
||||
expect(mockRequestSlotSyncAll).not.toHaveBeenCalled()
|
||||
|
||||
mockGraph.nodes = []
|
||||
mockGraph._nodes = []
|
||||
})
|
||||
|
||||
it('skips fitView if active graph changed before rAF fires', () => {
|
||||
const store = useSubgraphNavigationStore()
|
||||
store.viewportCache.delete(':root')
|
||||
|
||||
@@ -256,10 +256,7 @@ export function createMockChangeTracker(
|
||||
undoQueue: [],
|
||||
redoQueue: [],
|
||||
changeCount: 0,
|
||||
captureCanvasState: vi.fn(),
|
||||
checkState: vi.fn(),
|
||||
deactivate: vi.fn(),
|
||||
prepareForSave: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
store: vi.fn(),
|
||||
|
||||
Reference in New Issue
Block a user