Merge branch 'main' into austin/fix-linked-widget-promotion
@@ -122,7 +122,7 @@ echo " pnpm build - Build for production"
|
|||||||
echo " pnpm test:unit - Run unit tests"
|
echo " pnpm test:unit - Run unit tests"
|
||||||
echo " pnpm typecheck - Run TypeScript checks"
|
echo " pnpm typecheck - Run TypeScript checks"
|
||||||
echo " pnpm lint - Run ESLint"
|
echo " pnpm lint - Run ESLint"
|
||||||
echo " pnpm format - Format code with Prettier"
|
echo " pnpm format - Format code with oxfmt"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Next steps:"
|
echo "Next steps:"
|
||||||
echo "1. Run 'pnpm dev' to start developing"
|
echo "1. Run 'pnpm dev' to start developing"
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
---
|
|
||||||
description: Creating unit tests
|
|
||||||
globs:
|
|
||||||
alwaysApply: false
|
|
||||||
---
|
|
||||||
|
|
||||||
# Creating unit tests
|
|
||||||
|
|
||||||
- This project uses `vitest` for unit testing
|
|
||||||
- Tests are stored in the `test/` directory
|
|
||||||
- Tests should be cross-platform compatible; able to run on Windows, macOS, and linux
|
|
||||||
- e.g. the use of `path.resolve`, or `path.join` and `path.sep` to ensure that tests work the same on all platforms
|
|
||||||
- Tests should be mocked properly
|
|
||||||
- Mocks should be cleanly written and easy to understand
|
|
||||||
- Mocks should be re-usable where possible
|
|
||||||
|
|
||||||
## Unit test style
|
|
||||||
|
|
||||||
- Prefer the use of `test.extend` over loose variables
|
|
||||||
- To achieve this, import `test as baseTest` from `vitest`
|
|
||||||
- Never use `it`; `test` should be used in place of this
|
|
||||||
14
.github/AGENTS.md
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# PR Review Context
|
||||||
|
|
||||||
|
Context for automated PR review system.
|
||||||
|
|
||||||
|
## Review Scope
|
||||||
|
|
||||||
|
This automated review performs comprehensive analysis:
|
||||||
|
- Architecture and design patterns
|
||||||
|
- Security vulnerabilities
|
||||||
|
- Performance implications
|
||||||
|
- Code quality and maintainability
|
||||||
|
- Integration concerns
|
||||||
|
|
||||||
|
For implementation details, see `.claude/commands/comprehensive-pr-review.md`.
|
||||||
39
.github/CLAUDE.md
vendored
@@ -1,36 +1,3 @@
|
|||||||
# ComfyUI Frontend - Claude Review Context
|
<!-- A rose by any other name would smell as sweet,
|
||||||
|
But Claude insists on files named for its own conceit. -->
|
||||||
This file provides additional context for the automated PR review system.
|
@AGENTS.md
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
### PrimeVue Component Migrations
|
|
||||||
|
|
||||||
When reviewing, flag these deprecated components:
|
|
||||||
- `Dropdown` → Use `Select` from 'primevue/select'
|
|
||||||
- `OverlayPanel` → Use `Popover` from 'primevue/popover'
|
|
||||||
- `Calendar` → Use `DatePicker` from 'primevue/datepicker'
|
|
||||||
- `InputSwitch` → Use `ToggleSwitch` from 'primevue/toggleswitch'
|
|
||||||
- `Sidebar` → Use `Drawer` from 'primevue/drawer'
|
|
||||||
- `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled
|
|
||||||
- `TabMenu` → Use `Tabs` without panels
|
|
||||||
- `Steps` → Use `Stepper` without panels
|
|
||||||
- `InlineMessage` → Use `Message` component
|
|
||||||
|
|
||||||
### API Utilities Reference
|
|
||||||
|
|
||||||
- `api.apiURL()` - Backend API calls (/prompt, /queue, /view, etc.)
|
|
||||||
- `api.fileURL()` - Static file access (templates, extensions)
|
|
||||||
- `$t()` / `i18n.global.t()` - Internationalization
|
|
||||||
- `DOMPurify.sanitize()` - HTML sanitization
|
|
||||||
|
|
||||||
## Review Scope
|
|
||||||
|
|
||||||
This automated review performs comprehensive analysis including:
|
|
||||||
- Architecture and design patterns
|
|
||||||
- Security vulnerabilities
|
|
||||||
- Performance implications
|
|
||||||
- Code quality and maintainability
|
|
||||||
- Integration concerns
|
|
||||||
|
|
||||||
For implementation details, see `.claude/commands/comprehensive-pr-review.md`.
|
|
||||||
|
|||||||
6
.github/workflows/ci-lint-format.yaml
vendored
@@ -42,7 +42,7 @@ jobs:
|
|||||||
- name: Run Stylelint with auto-fix
|
- name: Run Stylelint with auto-fix
|
||||||
run: pnpm stylelint:fix
|
run: pnpm stylelint:fix
|
||||||
|
|
||||||
- name: Run Prettier with auto-format
|
- name: Run oxfmt with auto-format
|
||||||
run: pnpm format
|
run: pnpm format
|
||||||
|
|
||||||
- name: Check for changes
|
- name: Check for changes
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
git config --local user.email "action@github.com"
|
git config --local user.email "action@github.com"
|
||||||
git config --local user.name "GitHub Action"
|
git config --local user.name "GitHub Action"
|
||||||
git add .
|
git add .
|
||||||
git commit -m "[automated] Apply ESLint and Prettier fixes"
|
git commit -m "[automated] Apply ESLint and Oxfmt fixes"
|
||||||
git push
|
git push
|
||||||
|
|
||||||
- name: Final validation
|
- name: Final validation
|
||||||
@@ -80,7 +80,7 @@ jobs:
|
|||||||
issue_number: context.issue.number,
|
issue_number: context.issue.number,
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Prettier formatting'
|
body: '## 🔧 Auto-fixes Applied\n\nThis PR has been automatically updated to fix linting and formatting issues.\n\n**⚠️ Important**: Your local branch is now behind. Run `git pull` before making additional changes to avoid conflicts.\n\n### Changes made:\n- ESLint auto-fixes\n- Oxfmt formatting'
|
||||||
})
|
})
|
||||||
|
|
||||||
- name: Comment on PR about manual fix needed
|
- name: Comment on PR about manual fix needed
|
||||||
|
|||||||
18
.i18nrc.cjs
@@ -1,7 +1,7 @@
|
|||||||
// This file is intentionally kept in CommonJS format (.cjs)
|
// This file is intentionally kept in CommonJS format (.cjs)
|
||||||
// to resolve compatibility issues with dependencies that require CommonJS.
|
// to resolve compatibility issues with dependencies that require CommonJS.
|
||||||
// Do not convert this file to ESModule format unless all dependencies support it.
|
// Do not convert this file to ESModule format unless all dependencies support it.
|
||||||
const { defineConfig } = require('@lobehub/i18n-cli');
|
const { defineConfig } = require('@lobehub/i18n-cli')
|
||||||
|
|
||||||
module.exports = defineConfig({
|
module.exports = defineConfig({
|
||||||
modelName: 'gpt-4.1',
|
modelName: 'gpt-4.1',
|
||||||
@@ -10,7 +10,19 @@ module.exports = defineConfig({
|
|||||||
entry: 'src/locales/en',
|
entry: 'src/locales/en',
|
||||||
entryLocale: 'en',
|
entryLocale: 'en',
|
||||||
output: 'src/locales',
|
output: 'src/locales',
|
||||||
outputLocales: ['zh', 'zh-TW', 'ru', 'ja', 'ko', 'fr', 'es', 'ar', 'tr', 'pt-BR', 'fa'],
|
outputLocales: [
|
||||||
|
'zh',
|
||||||
|
'zh-TW',
|
||||||
|
'ru',
|
||||||
|
'ja',
|
||||||
|
'ko',
|
||||||
|
'fr',
|
||||||
|
'es',
|
||||||
|
'ar',
|
||||||
|
'tr',
|
||||||
|
'pt-BR',
|
||||||
|
'fa'
|
||||||
|
],
|
||||||
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
|
reference: `Special names to keep untranslated: flux, photomaker, clip, vae, cfg, stable audio, stable cascade, stable zero, controlnet, lora, HiDream, Civitai, Hugging Face.
|
||||||
'latent' is the short form of 'latent space'.
|
'latent' is the short form of 'latent space'.
|
||||||
'mask' is in the context of image processing.
|
'mask' is in the context of image processing.
|
||||||
@@ -26,4 +38,4 @@ module.exports = defineConfig({
|
|||||||
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
|
- Use Arabic-Indic numerals (۰-۹) for numbers where appropriate.
|
||||||
- Maintain consistency with terminology used in Persian software and design applications.
|
- Maintain consistency with terminology used in Persian software and design applications.
|
||||||
`
|
`
|
||||||
});
|
})
|
||||||
|
|||||||
20
.oxfmtrc.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"semi": false,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 80,
|
||||||
|
"ignorePatterns": [
|
||||||
|
"packages/registry-types/src/comfyRegistryTypes.ts",
|
||||||
|
"src/types/generatedManagerTypes.ts",
|
||||||
|
"**/*.md",
|
||||||
|
"**/*.json",
|
||||||
|
"**/*.css",
|
||||||
|
"**/*.yaml",
|
||||||
|
"**/*.yml",
|
||||||
|
"**/*.html",
|
||||||
|
"**/*.svg",
|
||||||
|
"**/*.xml"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
packages/registry-types/src/comfyRegistryTypes.ts
|
|
||||||
src/types/generatedManagerTypes.ts
|
|
||||||
11
.prettierrc
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"singleQuote": true,
|
|
||||||
"tabWidth": 2,
|
|
||||||
"semi": false,
|
|
||||||
"trailingComma": "none",
|
|
||||||
"printWidth": 80,
|
|
||||||
"importOrder": ["^@core/(.*)$", "<THIRD_PARTY_MODULES>", "^@/(.*)$", "^[./]"],
|
|
||||||
"importOrderSeparation": true,
|
|
||||||
"importOrderSortSpecifiers": true,
|
|
||||||
"plugins": ["@prettier/plugin-oxc", "@trivago/prettier-plugin-sort-imports"]
|
|
||||||
}
|
|
||||||
17
.storybook/AGENTS.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Storybook Guidelines
|
||||||
|
|
||||||
|
See `@docs/guidance/storybook.md` for story patterns (auto-loaded for `*.stories.ts`).
|
||||||
|
|
||||||
|
## Available Context
|
||||||
|
|
||||||
|
Stories have access to:
|
||||||
|
- All ComfyUI stores
|
||||||
|
- PrimeVue with ComfyUI theming
|
||||||
|
- i18n system
|
||||||
|
- CSS variables and styling
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
1. **Import Errors**: Verify `@/` alias works
|
||||||
|
2. **Missing Styles**: Check CSS imports in `preview.ts`
|
||||||
|
3. **Store Errors**: Check store initialization in setup
|
||||||
@@ -1,197 +1,3 @@
|
|||||||
# Storybook Development Guidelines for Claude
|
<!-- Though standards bloom in open fields so wide,
|
||||||
|
Anthropic walks a path of lonely pride. -->
|
||||||
## Quick Commands
|
@AGENTS.md
|
||||||
|
|
||||||
- `pnpm storybook`: Start Storybook development server
|
|
||||||
- `pnpm build-storybook`: Build static Storybook
|
|
||||||
- `pnpm test:unit`: Run unit tests (includes Storybook components)
|
|
||||||
|
|
||||||
## Development Workflow for Storybook
|
|
||||||
|
|
||||||
1. **Creating New Stories**:
|
|
||||||
- Place `*.stories.ts` files alongside components
|
|
||||||
- Follow the naming pattern: `ComponentName.stories.ts`
|
|
||||||
- Use realistic mock data that matches ComfyUI schemas
|
|
||||||
|
|
||||||
2. **Testing Stories**:
|
|
||||||
- Verify stories render correctly in Storybook UI
|
|
||||||
- Test different component states and edge cases
|
|
||||||
- Ensure proper theming and styling
|
|
||||||
|
|
||||||
3. **Code Quality**:
|
|
||||||
- Run `pnpm typecheck` to verify TypeScript
|
|
||||||
- Run `pnpm lint` to check for linting issues
|
|
||||||
- Follow existing story patterns and conventions
|
|
||||||
|
|
||||||
## Story Creation Guidelines
|
|
||||||
|
|
||||||
### Basic Story Structure
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
|
||||||
import ComponentName from './ComponentName.vue'
|
|
||||||
|
|
||||||
const meta: Meta<typeof ComponentName> = {
|
|
||||||
title: 'Category/ComponentName',
|
|
||||||
component: ComponentName,
|
|
||||||
parameters: {
|
|
||||||
layout: 'centered' // or 'fullscreen', 'padded'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default meta
|
|
||||||
type Story = StoryObj<typeof meta>
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
// Component props
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Mock Data Patterns
|
|
||||||
|
|
||||||
For ComfyUI components, use realistic mock data:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Node definition mock
|
|
||||||
const mockNodeDef = {
|
|
||||||
input: {
|
|
||||||
required: {
|
|
||||||
prompt: ["STRING", { multiline: true }]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
output: ["CONDITIONING"],
|
|
||||||
output_is_list: [false],
|
|
||||||
category: "conditioning"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Component instance mock
|
|
||||||
const mockComponent = {
|
|
||||||
id: "1",
|
|
||||||
type: "CLIPTextEncode",
|
|
||||||
// ... other properties
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Common Story Variants
|
|
||||||
|
|
||||||
Always include these story variants when applicable:
|
|
||||||
|
|
||||||
- **Default**: Basic component with minimal props
|
|
||||||
- **WithData**: Component with realistic data
|
|
||||||
- **Loading**: Component in loading state
|
|
||||||
- **Error**: Component with error state
|
|
||||||
- **LongContent**: Component with edge case content
|
|
||||||
- **Empty**: Component with no data
|
|
||||||
|
|
||||||
### Storybook-Specific Code Patterns
|
|
||||||
|
|
||||||
#### Store Access
|
|
||||||
```typescript
|
|
||||||
// In stories, access stores through the setup function
|
|
||||||
export const WithStore: Story = {
|
|
||||||
render: () => ({
|
|
||||||
setup() {
|
|
||||||
const store = useMyStore()
|
|
||||||
return { store }
|
|
||||||
},
|
|
||||||
template: '<MyComponent :data="store.data" />'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Event Testing
|
|
||||||
```typescript
|
|
||||||
export const WithEvents: Story = {
|
|
||||||
args: {
|
|
||||||
onUpdate: fn() // Use Storybook's fn() for action logging
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration Notes
|
|
||||||
|
|
||||||
### Vue App Setup
|
|
||||||
The Storybook preview is configured with:
|
|
||||||
- Pinia stores initialized
|
|
||||||
- PrimeVue with ComfyUI theme
|
|
||||||
- i18n internationalization
|
|
||||||
- All necessary CSS imports
|
|
||||||
|
|
||||||
### Build Configuration
|
|
||||||
- Vite integration with proper alias resolution
|
|
||||||
- Manual chunking for better performance
|
|
||||||
- TypeScript support with strict checking
|
|
||||||
- CSS processing for Vue components
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues
|
|
||||||
|
|
||||||
1. **Import Errors**: Verify `@/` alias is working correctly
|
|
||||||
2. **Missing Styles**: Ensure CSS imports are in `preview.ts`
|
|
||||||
3. **Store Errors**: Check store initialization in setup
|
|
||||||
4. **Type Errors**: Use proper TypeScript types for story args
|
|
||||||
|
|
||||||
### Debug Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check TypeScript issues
|
|
||||||
pnpm typecheck
|
|
||||||
|
|
||||||
# Lint Storybook files
|
|
||||||
pnpm lint .storybook/
|
|
||||||
|
|
||||||
# Build to check for production issues
|
|
||||||
pnpm build-storybook
|
|
||||||
```
|
|
||||||
|
|
||||||
## File Organization
|
|
||||||
|
|
||||||
```
|
|
||||||
.storybook/
|
|
||||||
├── main.ts # Core configuration
|
|
||||||
├── preview.ts # Global setup and decorators
|
|
||||||
├── README.md # User documentation
|
|
||||||
└── CLAUDE.md # This file - Claude guidelines
|
|
||||||
|
|
||||||
src/
|
|
||||||
├── components/
|
|
||||||
│ └── MyComponent/
|
|
||||||
│ ├── MyComponent.vue
|
|
||||||
│ └── MyComponent.stories.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration with ComfyUI
|
|
||||||
|
|
||||||
### Available Context
|
|
||||||
|
|
||||||
Stories have access to:
|
|
||||||
- All ComfyUI stores (widgetStore, colorPaletteStore, etc.)
|
|
||||||
- PrimeVue components with ComfyUI theming
|
|
||||||
- Internationalization system
|
|
||||||
- ComfyUI CSS variables and styling
|
|
||||||
|
|
||||||
### Testing Components
|
|
||||||
|
|
||||||
When testing ComfyUI-specific components:
|
|
||||||
1. Use realistic node definitions and data structures
|
|
||||||
2. Test with different node types (sampling, conditioning, etc.)
|
|
||||||
3. Verify proper CSS theming and dark/light modes
|
|
||||||
4. Check component behavior with various input combinations
|
|
||||||
|
|
||||||
### Performance Considerations
|
|
||||||
|
|
||||||
- Use manual chunking for large dependencies
|
|
||||||
- Minimize bundle size by avoiding unnecessary imports
|
|
||||||
- Leverage Storybook's lazy loading capabilities
|
|
||||||
- Profile build times and optimize as needed
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
1. **Keep Stories Focused**: Each story should demonstrate one specific use case
|
|
||||||
2. **Use Descriptive Names**: Story names should clearly indicate what they show
|
|
||||||
3. **Document Complex Props**: Use JSDoc comments for complex prop types
|
|
||||||
4. **Test Edge Cases**: Create stories for unusual but valid use cases
|
|
||||||
5. **Maintain Consistency**: Follow established patterns in existing stories
|
|
||||||
|
|||||||
@@ -96,15 +96,15 @@ const config: StorybookConfig = {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
esbuild: {
|
|
||||||
// Prevent minification of identifiers to preserve _sfc_main
|
|
||||||
minifyIdentifiers: false,
|
|
||||||
keepNames: true
|
|
||||||
},
|
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rolldownOptions: {
|
||||||
// Disable tree-shaking for Storybook to prevent Vue SFC exports from being removed
|
experimental: {
|
||||||
|
strictExecutionOrder: true
|
||||||
|
},
|
||||||
treeshake: false,
|
treeshake: false,
|
||||||
|
output: {
|
||||||
|
keepNames: true
|
||||||
|
},
|
||||||
onwarn: (warning, warn) => {
|
onwarn: (warning, warn) => {
|
||||||
// Suppress specific warnings
|
// Suppress specific warnings
|
||||||
if (
|
if (
|
||||||
|
|||||||
15
.vscode/extensions.json
vendored
@@ -1,25 +1,22 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
|
"antfu.vite",
|
||||||
"austenc.tailwind-docs",
|
"austenc.tailwind-docs",
|
||||||
"bradlc.vscode-tailwindcss",
|
"bradlc.vscode-tailwindcss",
|
||||||
"davidanson.vscode-markdownlint",
|
"davidanson.vscode-markdownlint",
|
||||||
"dbaeumer.vscode-eslint",
|
"dbaeumer.vscode-eslint",
|
||||||
|
"donjayamanne.githistory",
|
||||||
"eamodio.gitlens",
|
"eamodio.gitlens",
|
||||||
"esbenp.prettier-vscode",
|
|
||||||
"figma.figma-vscode-extension",
|
|
||||||
"github.vscode-github-actions",
|
"github.vscode-github-actions",
|
||||||
"github.vscode-pull-request-github",
|
"github.vscode-pull-request-github",
|
||||||
"hbenl.vscode-test-explorer",
|
"hbenl.vscode-test-explorer",
|
||||||
|
"kisstkondoros.vscode-codemetrics",
|
||||||
"lokalise.i18n-ally",
|
"lokalise.i18n-ally",
|
||||||
"ms-playwright.playwright",
|
"ms-playwright.playwright",
|
||||||
|
"oxc.oxc-vscode",
|
||||||
|
"sonarsource.sonarlint-vscode",
|
||||||
"vitest.explorer",
|
"vitest.explorer",
|
||||||
"vue.volar",
|
"vue.volar",
|
||||||
"sonarsource.sonarlint-vscode",
|
"wix.vscode-import-cost"
|
||||||
"deque-systems.vscode-axe-linter",
|
|
||||||
"kisstkondoros.vscode-codemetrics",
|
|
||||||
"donjayamanne.githistory",
|
|
||||||
"wix.vscode-import-cost",
|
|
||||||
"prograhammer.tslint-vue",
|
|
||||||
"antfu.vite"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
25
AGENTS.md
@@ -1,5 +1,7 @@
|
|||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
|
|
||||||
|
See @docs/guidance/*.md for file-type-specific conventions (auto-loaded by glob).
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
- Source: `src/`
|
- Source: `src/`
|
||||||
@@ -25,10 +27,10 @@
|
|||||||
- Build output: `dist/`
|
- Build output: `dist/`
|
||||||
- Configs
|
- Configs
|
||||||
- `vite.config.mts`
|
- `vite.config.mts`
|
||||||
- `vitest.config.ts`
|
|
||||||
- `playwright.config.ts`
|
- `playwright.config.ts`
|
||||||
- `eslint.config.ts`
|
- `eslint.config.ts`
|
||||||
- `.prettierrc`
|
- `.oxfmtrc.json`
|
||||||
|
- `.oxlintrc.json`
|
||||||
- etc.
|
- etc.
|
||||||
|
|
||||||
## Monorepo Architecture
|
## Monorepo Architecture
|
||||||
@@ -44,8 +46,23 @@ The project uses **Nx** for build orchestration and task management
|
|||||||
- `pnpm test:unit`: Run Vitest unit tests
|
- `pnpm test:unit`: Run Vitest unit tests
|
||||||
- `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`)
|
- `pnpm test:browser`: Run Playwright E2E tests (`browser_tests/`)
|
||||||
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
|
- `pnpm lint` / `pnpm lint:fix`: Lint (ESLint)
|
||||||
- `pnpm format` / `pnpm format:check`: Prettier
|
- `pnpm format` / `pnpm format:check`: oxfmt
|
||||||
- `pnpm typecheck`: Vue TSC type checking
|
- `pnpm typecheck`: Vue TSC type checking
|
||||||
|
- `pnpm storybook`: Start Storybook development server
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
1. Make code changes
|
||||||
|
2. Run relevant tests
|
||||||
|
3. Run `pnpm typecheck`, `pnpm lint`, `pnpm format`
|
||||||
|
4. Check if README updates are needed
|
||||||
|
5. Suggest docs.comfy.org updates for user-facing changes
|
||||||
|
|
||||||
|
## Git Conventions
|
||||||
|
|
||||||
|
- Use `prefix:` format: `feat:`, `fix:`, `test:`
|
||||||
|
- Add "Fixes #n" to PR descriptions
|
||||||
|
- Never mention Claude/AI in commits
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
@@ -55,7 +72,7 @@ The project uses **Nx** for build orchestration and task management
|
|||||||
- Composition API only
|
- Composition API only
|
||||||
- Tailwind 4 styling
|
- Tailwind 4 styling
|
||||||
- Avoid `<style>` blocks
|
- Avoid `<style>` blocks
|
||||||
- Style: (see `.prettierrc`)
|
- Style: (see `.oxfmtrc.json`)
|
||||||
- Indent 2 spaces
|
- Indent 2 spaces
|
||||||
- single quotes
|
- single quotes
|
||||||
- no trailing semicolons
|
- no trailing semicolons
|
||||||
|
|||||||
31
CLAUDE.md
@@ -1,30 +1 @@
|
|||||||
# Claude Code specific instructions
|
@AGENTS.md
|
||||||
|
|
||||||
@Agents.md
|
|
||||||
|
|
||||||
## Repository Setup
|
|
||||||
|
|
||||||
For first-time setup, use the Claude command:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/setup_repo
|
|
||||||
```
|
|
||||||
|
|
||||||
This bootstraps the monorepo with dependencies, builds, tests, and dev server verification.
|
|
||||||
|
|
||||||
**Prerequisites:** Node.js >= 24, Git repository, available ports for dev server, storybook, etc.
|
|
||||||
|
|
||||||
## Development Workflow
|
|
||||||
|
|
||||||
1. **First-time setup**: Run `/setup_repo` Claude command
|
|
||||||
2. Make code changes
|
|
||||||
3. Run tests (see subdirectory CLAUDE.md files)
|
|
||||||
4. Run typecheck, lint, format
|
|
||||||
5. Check README updates
|
|
||||||
6. Consider docs.comfy.org updates
|
|
||||||
|
|
||||||
## Git Conventions
|
|
||||||
|
|
||||||
- Use `prefix:` format: `feat:`, `fix:`, `test:`
|
|
||||||
- Add "Fixes #n" to PR descriptions
|
|
||||||
- Never mention Claude/AI in commits
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
/src/components/graph/selectionToolbox/ @Myestery
|
/src/components/graph/selectionToolbox/ @Myestery
|
||||||
|
|
||||||
# Minimap
|
# Minimap
|
||||||
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
/src/renderer/extensions/minimap/ @jtydhr88 @Myestery
|
||||||
|
|
||||||
# Workflow Templates
|
# Workflow Templates
|
||||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||||
@@ -55,8 +55,7 @@
|
|||||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
|
/src/locales/ @Comfy-Org/comfy_maintainer @Comfy-org/comfy_frontend_devs
|
||||||
/src/locales/pt-BR/ @JonatanAtila @Yorha4D @KarryCharon @shinshin86
|
|
||||||
|
|
||||||
# LLM Instructions (blank on purpose)
|
# LLM Instructions (blank on purpose)
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default defineConfig(() => {
|
|||||||
})
|
})
|
||||||
],
|
],
|
||||||
build: {
|
build: {
|
||||||
minify: SHOULD_MINIFY ? ('esbuild' as const) : false,
|
minify: SHOULD_MINIFY,
|
||||||
target: 'es2022',
|
target: 'es2022',
|
||||||
sourcemap: true
|
sourcemap: true
|
||||||
}
|
}
|
||||||
|
|||||||
8
browser_tests/AGENTS.md
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# E2E Testing Guidelines
|
||||||
|
|
||||||
|
See `@docs/guidance/playwright.md` for Playwright best practices (auto-loaded for `*.spec.ts`).
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
- `assets/` - Test data (JSON workflows, fixtures)
|
||||||
|
- Tests use premade JSON workflows to load desired graph state
|
||||||
@@ -1,17 +1,3 @@
|
|||||||
# E2E Testing Guidelines
|
<!-- In gardens where the agents freely play,
|
||||||
|
One stubborn flower turns the other way. -->
|
||||||
## Browser Tests
|
@AGENTS.md
|
||||||
- Test user workflows
|
|
||||||
- Use Playwright fixtures
|
|
||||||
- Follow naming conventions
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
- Check assets/ for test data
|
|
||||||
- Prefer specific selectors
|
|
||||||
- Test across viewports
|
|
||||||
|
|
||||||
## Testing Process
|
|
||||||
After code changes:
|
|
||||||
1. Create browser tests as appropriate
|
|
||||||
2. Run tests until passing
|
|
||||||
3. Then run typecheck, lint, format
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
import { Topbar } from './components/Topbar'
|
import { Topbar } from './components/Topbar'
|
||||||
import type { Position, Size } from './types'
|
import type { Position, Size } from './types'
|
||||||
import { NodeReference, SubgraphSlotReference } from './utils/litegraphUtils'
|
import { NodeReference, SubgraphSlotReference } from './utils/litegraphUtils'
|
||||||
import TaskHistory from './utils/taskHistory'
|
|
||||||
|
|
||||||
dotenv.config()
|
dotenv.config()
|
||||||
|
|
||||||
@@ -146,8 +145,6 @@ class ConfirmDialog {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ComfyPage {
|
export class ComfyPage {
|
||||||
private _history: TaskHistory | null = null
|
|
||||||
|
|
||||||
public readonly url: string
|
public readonly url: string
|
||||||
// All canvas position operations are based on default view of canvas.
|
// All canvas position operations are based on default view of canvas.
|
||||||
public readonly canvas: Locator
|
public readonly canvas: Locator
|
||||||
@@ -301,11 +298,6 @@ export class ComfyPage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupHistory(): TaskHistory {
|
|
||||||
this._history ??= new TaskHistory(this)
|
|
||||||
return this._history
|
|
||||||
}
|
|
||||||
|
|
||||||
async setup({
|
async setup({
|
||||||
clearStorage = true,
|
clearStorage = true,
|
||||||
mockReleases = true
|
mockReleases = true
|
||||||
|
|||||||
@@ -79,48 +79,15 @@ export class SubgraphSlotReference {
|
|||||||
|
|
||||||
const node =
|
const node =
|
||||||
type === 'input' ? currentGraph.inputNode : currentGraph.outputNode
|
type === 'input' ? currentGraph.inputNode : currentGraph.outputNode
|
||||||
const slots =
|
|
||||||
type === 'input' ? currentGraph.inputs : currentGraph.outputs
|
|
||||||
|
|
||||||
if (!node) {
|
if (!node) {
|
||||||
throw new Error(`No ${type} node found in subgraph`)
|
throw new Error(`No ${type} node found in subgraph`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate position for next available slot
|
|
||||||
// const nextSlotIndex = slots?.length || 0
|
|
||||||
// const slotHeight = 20
|
|
||||||
// const slotY = node.pos[1] + 30 + nextSlotIndex * slotHeight
|
|
||||||
|
|
||||||
// Find last slot position
|
|
||||||
const lastSlot = slots.at(-1)
|
|
||||||
let slotX: number
|
|
||||||
let slotY: number
|
|
||||||
|
|
||||||
if (lastSlot) {
|
|
||||||
// If there are existing slots, position the new one below the last one
|
|
||||||
const gapHeight = 20
|
|
||||||
slotX = lastSlot.pos[0]
|
|
||||||
slotY = lastSlot.pos[1] + gapHeight
|
|
||||||
} else {
|
|
||||||
// No existing slots - use slotAnchorX if available, otherwise calculate from node position
|
|
||||||
if (currentGraph.slotAnchorX !== undefined) {
|
|
||||||
// The actual slot X position seems to be slotAnchorX - 10
|
|
||||||
slotX = currentGraph.slotAnchorX - 10
|
|
||||||
} else {
|
|
||||||
// Fallback: calculate from node edge
|
|
||||||
slotX =
|
|
||||||
type === 'input'
|
|
||||||
? node.pos[0] + node.size[0] - 10 // Right edge for input node
|
|
||||||
: node.pos[0] + 10 // Left edge for output node
|
|
||||||
}
|
|
||||||
// For Y position when no slots exist, use middle of node
|
|
||||||
slotY = node.pos[1] + node.size[1] / 2
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert from offset to canvas coordinates
|
// Convert from offset to canvas coordinates
|
||||||
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
|
const canvasPos = window['app'].canvas.ds.convertOffsetToCanvas([
|
||||||
slotX,
|
node.emptySlot.pos[0],
|
||||||
slotY
|
node.emptySlot.pos[1]
|
||||||
])
|
])
|
||||||
return canvasPos
|
return canvasPos
|
||||||
},
|
},
|
||||||
@@ -152,8 +119,7 @@ class NodeSlotReference {
|
|||||||
window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
|
window['app'].canvas.ds.convertOffsetToCanvas(rawPos)
|
||||||
|
|
||||||
// Debug logging - convert Float64Arrays to regular arrays for visibility
|
// Debug logging - convert Float64Arrays to regular arrays for visibility
|
||||||
// eslint-disable-next-line no-console
|
console.warn(
|
||||||
console.log(
|
|
||||||
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
|
`NodeSlotReference debug for ${type} slot ${index} on node ${id}:`,
|
||||||
{
|
{
|
||||||
nodePos: [node.pos[0], node.pos[1]],
|
nodePos: [node.pos[0], node.pos[1]],
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
import type { Request, Route } from '@playwright/test'
|
|
||||||
import _ from 'es-toolkit/compat'
|
|
||||||
import fs from 'fs'
|
|
||||||
import path from 'path'
|
|
||||||
import { v4 as uuidv4 } from 'uuid'
|
|
||||||
|
|
||||||
import type {
|
|
||||||
HistoryTaskItem,
|
|
||||||
TaskItem,
|
|
||||||
TaskOutput
|
|
||||||
} from '../../../src/schemas/apiSchema'
|
|
||||||
import type { ComfyPage } from '../ComfyPage'
|
|
||||||
|
|
||||||
/** keyof TaskOutput[string] */
|
|
||||||
type OutputFileType = 'images' | 'audio' | 'animated'
|
|
||||||
|
|
||||||
const DEFAULT_IMAGE = 'example.webp'
|
|
||||||
|
|
||||||
const getFilenameParam = (request: Request) => {
|
|
||||||
const url = new URL(request.url())
|
|
||||||
return url.searchParams.get('filename') || DEFAULT_IMAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
const getContentType = (filename: string, fileType: OutputFileType) => {
|
|
||||||
const subtype = path.extname(filename).slice(1)
|
|
||||||
switch (fileType) {
|
|
||||||
case 'images':
|
|
||||||
return `image/${subtype}`
|
|
||||||
case 'audio':
|
|
||||||
return `audio/${subtype}`
|
|
||||||
case 'animated':
|
|
||||||
return `video/${subtype}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setQueueIndex = (task: TaskItem) => {
|
|
||||||
task.prompt[0] = TaskHistory.queueIndex++
|
|
||||||
}
|
|
||||||
|
|
||||||
const setPromptId = (task: TaskItem) => {
|
|
||||||
task.prompt[1] = uuidv4()
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class TaskHistory {
|
|
||||||
static queueIndex = 0
|
|
||||||
static readonly defaultTask: Readonly<HistoryTaskItem> = {
|
|
||||||
prompt: [0, 'prompt-id', {}, { client_id: uuidv4() }, []],
|
|
||||||
outputs: {},
|
|
||||||
status: {
|
|
||||||
status_str: 'success',
|
|
||||||
completed: true,
|
|
||||||
messages: []
|
|
||||||
},
|
|
||||||
taskType: 'History'
|
|
||||||
}
|
|
||||||
private tasks: HistoryTaskItem[] = []
|
|
||||||
private outputContentTypes: Map<string, string> = new Map()
|
|
||||||
|
|
||||||
constructor(readonly comfyPage: ComfyPage) {}
|
|
||||||
|
|
||||||
private loadAsset: (filename: string) => Buffer = _.memoize(
|
|
||||||
(filename: string) => {
|
|
||||||
const filePath = this.comfyPage.assetPath(filename)
|
|
||||||
return fs.readFileSync(filePath)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
private async handleGetHistory(route: Route) {
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: 'application/json',
|
|
||||||
body: JSON.stringify(this.tasks)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleGetView(route: Route) {
|
|
||||||
const fileName = getFilenameParam(route.request())
|
|
||||||
if (!this.outputContentTypes.has(fileName)) {
|
|
||||||
return route.continue()
|
|
||||||
}
|
|
||||||
|
|
||||||
const asset = this.loadAsset(fileName)
|
|
||||||
return route.fulfill({
|
|
||||||
status: 200,
|
|
||||||
contentType: this.outputContentTypes.get(fileName),
|
|
||||||
body: asset,
|
|
||||||
headers: {
|
|
||||||
'Cache-Control': 'public, max-age=31536000',
|
|
||||||
'Content-Length': asset.byteLength.toString()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async setupRoutes() {
|
|
||||||
return this.comfyPage.page.route(
|
|
||||||
/.*\/api\/(view|history)(\?.*)?$/,
|
|
||||||
async (route) => {
|
|
||||||
const request = route.request()
|
|
||||||
const method = request.method()
|
|
||||||
|
|
||||||
const isViewReq = request.url().includes('view') && method === 'GET'
|
|
||||||
if (isViewReq) return this.handleGetView(route)
|
|
||||||
|
|
||||||
const isHistoryPath = request.url().includes('history')
|
|
||||||
const isGetHistoryReq = isHistoryPath && method === 'GET'
|
|
||||||
if (isGetHistoryReq) return this.handleGetHistory(route)
|
|
||||||
|
|
||||||
const isClearReq =
|
|
||||||
method === 'POST' &&
|
|
||||||
isHistoryPath &&
|
|
||||||
request.postDataJSON()?.clear === true
|
|
||||||
if (isClearReq) return this.clearTasks()
|
|
||||||
|
|
||||||
return route.continue()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private createOutputs(
|
|
||||||
filenames: string[],
|
|
||||||
filetype: OutputFileType
|
|
||||||
): TaskOutput {
|
|
||||||
return filenames.reduce((outputs, filename, i) => {
|
|
||||||
const nodeId = `${i + 1}`
|
|
||||||
outputs[nodeId] = {
|
|
||||||
[filetype]: [{ filename, subfolder: '', type: 'output' }]
|
|
||||||
}
|
|
||||||
const contentType = getContentType(filename, filetype)
|
|
||||||
this.outputContentTypes.set(filename, contentType)
|
|
||||||
return outputs
|
|
||||||
}, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
private addTask(task: HistoryTaskItem) {
|
|
||||||
setPromptId(task)
|
|
||||||
setQueueIndex(task)
|
|
||||||
this.tasks.unshift(task) // Tasks are added to the front of the queue
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTasks(): this {
|
|
||||||
this.tasks = []
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
withTask(
|
|
||||||
outputFilenames: string[],
|
|
||||||
outputFiletype: OutputFileType = 'images',
|
|
||||||
overrides: Partial<HistoryTaskItem> = {}
|
|
||||||
): this {
|
|
||||||
this.addTask({
|
|
||||||
...TaskHistory.defaultTask,
|
|
||||||
outputs: this.createOutputs(outputFilenames, outputFiletype),
|
|
||||||
...overrides
|
|
||||||
})
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Repeats the last task in the task history a specified number of times. */
|
|
||||||
repeat(n: number): this {
|
|
||||||
for (let i = 0; i < n; i++)
|
|
||||||
this.addTask(structuredClone(this.tasks.at(0)) as HistoryTaskItem)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,7 @@ test.describe('Feature Flags', () => {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data)
|
const parsed = JSON.parse(data)
|
||||||
if (parsed.type === 'feature_flags') {
|
if (parsed.type === 'feature_flags') {
|
||||||
window.__capturedMessages.clientFeatureFlags = parsed
|
window.__capturedMessages!.clientFeatureFlags = parsed
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Not JSON, ignore
|
// Not JSON, ignore
|
||||||
@@ -41,7 +41,7 @@ test.describe('Feature Flags', () => {
|
|||||||
window['app']?.api?.serverFeatureFlags &&
|
window['app']?.api?.serverFeatureFlags &&
|
||||||
Object.keys(window['app'].api.serverFeatureFlags).length > 0
|
Object.keys(window['app'].api.serverFeatureFlags).length > 0
|
||||||
) {
|
) {
|
||||||
window.__capturedMessages.serverFeatureFlags =
|
window.__capturedMessages!.serverFeatureFlags =
|
||||||
window['app'].api.serverFeatureFlags
|
window['app'].api.serverFeatureFlags
|
||||||
clearInterval(checkInterval)
|
clearInterval(checkInterval)
|
||||||
}
|
}
|
||||||
@@ -57,8 +57,8 @@ test.describe('Feature Flags', () => {
|
|||||||
// Wait for both client and server feature flags
|
// Wait for both client and server feature flags
|
||||||
await newPage.waitForFunction(
|
await newPage.waitForFunction(
|
||||||
() =>
|
() =>
|
||||||
window.__capturedMessages.clientFeatureFlags !== null &&
|
window.__capturedMessages!.clientFeatureFlags !== null &&
|
||||||
window.__capturedMessages.serverFeatureFlags !== null,
|
window.__capturedMessages!.serverFeatureFlags !== null,
|
||||||
{ timeout: 10000 }
|
{ timeout: 10000 }
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,27 +66,27 @@ test.describe('Feature Flags', () => {
|
|||||||
const messages = await newPage.evaluate(() => window.__capturedMessages)
|
const messages = await newPage.evaluate(() => window.__capturedMessages)
|
||||||
|
|
||||||
// Verify client sent feature flags
|
// Verify client sent feature flags
|
||||||
expect(messages.clientFeatureFlags).toBeTruthy()
|
expect(messages!.clientFeatureFlags).toBeTruthy()
|
||||||
expect(messages.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
|
expect(messages!.clientFeatureFlags).toHaveProperty('type', 'feature_flags')
|
||||||
expect(messages.clientFeatureFlags).toHaveProperty('data')
|
expect(messages!.clientFeatureFlags).toHaveProperty('data')
|
||||||
expect(messages.clientFeatureFlags.data).toHaveProperty(
|
expect(messages!.clientFeatureFlags!.data).toHaveProperty(
|
||||||
'supports_preview_metadata'
|
'supports_preview_metadata'
|
||||||
)
|
)
|
||||||
expect(
|
expect(
|
||||||
typeof messages.clientFeatureFlags.data.supports_preview_metadata
|
typeof messages!.clientFeatureFlags!.data.supports_preview_metadata
|
||||||
).toBe('boolean')
|
).toBe('boolean')
|
||||||
|
|
||||||
// Verify server sent feature flags back
|
// Verify server sent feature flags back
|
||||||
expect(messages.serverFeatureFlags).toBeTruthy()
|
expect(messages!.serverFeatureFlags).toBeTruthy()
|
||||||
expect(messages.serverFeatureFlags).toHaveProperty(
|
expect(messages!.serverFeatureFlags).toHaveProperty(
|
||||||
'supports_preview_metadata'
|
'supports_preview_metadata'
|
||||||
)
|
)
|
||||||
expect(typeof messages.serverFeatureFlags.supports_preview_metadata).toBe(
|
expect(typeof messages!.serverFeatureFlags!.supports_preview_metadata).toBe(
|
||||||
'boolean'
|
'boolean'
|
||||||
)
|
)
|
||||||
expect(messages.serverFeatureFlags).toHaveProperty('max_upload_size')
|
expect(messages!.serverFeatureFlags).toHaveProperty('max_upload_size')
|
||||||
expect(typeof messages.serverFeatureFlags.max_upload_size).toBe('number')
|
expect(typeof messages!.serverFeatureFlags!.max_upload_size).toBe('number')
|
||||||
expect(Object.keys(messages.serverFeatureFlags).length).toBeGreaterThan(0)
|
expect(Object.keys(messages!.serverFeatureFlags!).length).toBeGreaterThan(0)
|
||||||
|
|
||||||
await newPage.close()
|
await newPage.close()
|
||||||
})
|
})
|
||||||
@@ -96,7 +96,7 @@ test.describe('Feature Flags', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
// Get the actual server feature flags from the backend
|
// Get the actual server feature flags from the backend
|
||||||
const serverFlags = await comfyPage.page.evaluate(() => {
|
const serverFlags = await comfyPage.page.evaluate(() => {
|
||||||
return window['app'].api.serverFeatureFlags
|
return window['app']!.api.serverFeatureFlags
|
||||||
})
|
})
|
||||||
|
|
||||||
// Verify we received real feature flags from the backend
|
// Verify we received real feature flags from the backend
|
||||||
@@ -115,7 +115,7 @@ test.describe('Feature Flags', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
// Test serverSupportsFeature with real backend flags
|
// Test serverSupportsFeature with real backend flags
|
||||||
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
|
const supportsPreviewMetadata = await comfyPage.page.evaluate(() => {
|
||||||
return window['app'].api.serverSupportsFeature(
|
return window['app']!.api.serverSupportsFeature(
|
||||||
'supports_preview_metadata'
|
'supports_preview_metadata'
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -124,15 +124,17 @@ test.describe('Feature Flags', () => {
|
|||||||
|
|
||||||
// Test non-existent feature - should always return false
|
// Test non-existent feature - should always return false
|
||||||
const supportsNonExistent = await comfyPage.page.evaluate(() => {
|
const supportsNonExistent = await comfyPage.page.evaluate(() => {
|
||||||
return window['app'].api.serverSupportsFeature('non_existent_feature_xyz')
|
return window['app']!.api.serverSupportsFeature(
|
||||||
|
'non_existent_feature_xyz'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
expect(supportsNonExistent).toBe(false)
|
expect(supportsNonExistent).toBe(false)
|
||||||
|
|
||||||
// Test that the method only returns true for boolean true values
|
// Test that the method only returns true for boolean true values
|
||||||
const testResults = await comfyPage.page.evaluate(() => {
|
const testResults = await comfyPage.page.evaluate(() => {
|
||||||
// Temporarily modify serverFeatureFlags to test behavior
|
// Temporarily modify serverFeatureFlags to test behavior
|
||||||
const original = window['app'].api.serverFeatureFlags
|
const original = window['app']!.api.serverFeatureFlags
|
||||||
window['app'].api.serverFeatureFlags = {
|
window['app']!.api.serverFeatureFlags = {
|
||||||
bool_true: true,
|
bool_true: true,
|
||||||
bool_false: false,
|
bool_false: false,
|
||||||
string_value: 'yes',
|
string_value: 'yes',
|
||||||
@@ -141,15 +143,15 @@ test.describe('Feature Flags', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const results = {
|
const results = {
|
||||||
bool_true: window['app'].api.serverSupportsFeature('bool_true'),
|
bool_true: window['app']!.api.serverSupportsFeature('bool_true'),
|
||||||
bool_false: window['app'].api.serverSupportsFeature('bool_false'),
|
bool_false: window['app']!.api.serverSupportsFeature('bool_false'),
|
||||||
string_value: window['app'].api.serverSupportsFeature('string_value'),
|
string_value: window['app']!.api.serverSupportsFeature('string_value'),
|
||||||
number_value: window['app'].api.serverSupportsFeature('number_value'),
|
number_value: window['app']!.api.serverSupportsFeature('number_value'),
|
||||||
null_value: window['app'].api.serverSupportsFeature('null_value')
|
null_value: window['app']!.api.serverSupportsFeature('null_value')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore original
|
// Restore original
|
||||||
window['app'].api.serverFeatureFlags = original
|
window['app']!.api.serverFeatureFlags = original
|
||||||
return results
|
return results
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -166,20 +168,20 @@ test.describe('Feature Flags', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
// Test getServerFeature method
|
// Test getServerFeature method
|
||||||
const previewMetadataValue = await comfyPage.page.evaluate(() => {
|
const previewMetadataValue = await comfyPage.page.evaluate(() => {
|
||||||
return window['app'].api.getServerFeature('supports_preview_metadata')
|
return window['app']!.api.getServerFeature('supports_preview_metadata')
|
||||||
})
|
})
|
||||||
expect(typeof previewMetadataValue).toBe('boolean')
|
expect(typeof previewMetadataValue).toBe('boolean')
|
||||||
|
|
||||||
// Test getting max_upload_size
|
// Test getting max_upload_size
|
||||||
const maxUploadSize = await comfyPage.page.evaluate(() => {
|
const maxUploadSize = await comfyPage.page.evaluate(() => {
|
||||||
return window['app'].api.getServerFeature('max_upload_size')
|
return window['app']!.api.getServerFeature('max_upload_size')
|
||||||
})
|
})
|
||||||
expect(typeof maxUploadSize).toBe('number')
|
expect(typeof maxUploadSize).toBe('number')
|
||||||
expect(maxUploadSize).toBeGreaterThan(0)
|
expect(maxUploadSize).toBeGreaterThan(0)
|
||||||
|
|
||||||
// Test getServerFeature with default value for non-existent feature
|
// Test getServerFeature with default value for non-existent feature
|
||||||
const defaultValue = await comfyPage.page.evaluate(() => {
|
const defaultValue = await comfyPage.page.evaluate(() => {
|
||||||
return window['app'].api.getServerFeature(
|
return window['app']!.api.getServerFeature(
|
||||||
'non_existent_feature_xyz',
|
'non_existent_feature_xyz',
|
||||||
'default'
|
'default'
|
||||||
)
|
)
|
||||||
@@ -192,7 +194,7 @@ test.describe('Feature Flags', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
// Test getServerFeatures returns all flags
|
// Test getServerFeatures returns all flags
|
||||||
const allFeatures = await comfyPage.page.evaluate(() => {
|
const allFeatures = await comfyPage.page.evaluate(() => {
|
||||||
return window['app'].api.getServerFeatures()
|
return window['app']!.api.getServerFeatures()
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(allFeatures).toBeTruthy()
|
expect(allFeatures).toBeTruthy()
|
||||||
@@ -205,14 +207,14 @@ test.describe('Feature Flags', () => {
|
|||||||
test('Client feature flags are immutable', async ({ comfyPage }) => {
|
test('Client feature flags are immutable', async ({ comfyPage }) => {
|
||||||
// Test that getClientFeatureFlags returns a copy
|
// Test that getClientFeatureFlags returns a copy
|
||||||
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
||||||
const flags1 = window['app'].api.getClientFeatureFlags()
|
const flags1 = window['app']!.api.getClientFeatureFlags()
|
||||||
const flags2 = window['app'].api.getClientFeatureFlags()
|
const flags2 = window['app']!.api.getClientFeatureFlags()
|
||||||
|
|
||||||
// Modify the first object
|
// Modify the first object
|
||||||
flags1.test_modification = true
|
flags1.test_modification = true
|
||||||
|
|
||||||
// Get flags again to check if original was modified
|
// Get flags again to check if original was modified
|
||||||
const flags3 = window['app'].api.getClientFeatureFlags()
|
const flags3 = window['app']!.api.getClientFeatureFlags()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
areEqual: flags1 === flags2,
|
areEqual: flags1 === flags2,
|
||||||
@@ -238,14 +240,14 @@ test.describe('Feature Flags', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
const immutabilityTest = await comfyPage.page.evaluate(() => {
|
||||||
// Get a copy of server features
|
// Get a copy of server features
|
||||||
const features1 = window['app'].api.getServerFeatures()
|
const features1 = window['app']!.api.getServerFeatures()
|
||||||
|
|
||||||
// Try to modify it
|
// Try to modify it
|
||||||
features1.supports_preview_metadata = false
|
features1.supports_preview_metadata = false
|
||||||
features1.new_feature = 'added'
|
features1.new_feature = 'added'
|
||||||
|
|
||||||
// Get another copy
|
// Get another copy
|
||||||
const features2 = window['app'].api.getServerFeatures()
|
const features2 = window['app']!.api.getServerFeatures()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
modifiedValue: features1.supports_preview_metadata,
|
modifiedValue: features1.supports_preview_metadata,
|
||||||
@@ -274,7 +276,8 @@ test.describe('Feature Flags', () => {
|
|||||||
// Set up monitoring before navigation
|
// Set up monitoring before navigation
|
||||||
await newPage.addInitScript(() => {
|
await newPage.addInitScript(() => {
|
||||||
// Track when various app components are ready
|
// Track when various app components are ready
|
||||||
;(window as any).__appReadiness = {
|
|
||||||
|
window.__appReadiness = {
|
||||||
featureFlagsReceived: false,
|
featureFlagsReceived: false,
|
||||||
apiInitialized: false,
|
apiInitialized: false,
|
||||||
appInitialized: false
|
appInitialized: false
|
||||||
@@ -286,7 +289,10 @@ test.describe('Feature Flags', () => {
|
|||||||
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
|
window['app']?.api?.serverFeatureFlags?.supports_preview_metadata !==
|
||||||
undefined
|
undefined
|
||||||
) {
|
) {
|
||||||
;(window as any).__appReadiness.featureFlagsReceived = true
|
window.__appReadiness = {
|
||||||
|
...window.__appReadiness,
|
||||||
|
featureFlagsReceived: true
|
||||||
|
}
|
||||||
clearInterval(checkFeatureFlags)
|
clearInterval(checkFeatureFlags)
|
||||||
}
|
}
|
||||||
}, 10)
|
}, 10)
|
||||||
@@ -294,7 +300,10 @@ test.describe('Feature Flags', () => {
|
|||||||
// Monitor API initialization
|
// Monitor API initialization
|
||||||
const checkApi = setInterval(() => {
|
const checkApi = setInterval(() => {
|
||||||
if (window['app']?.api) {
|
if (window['app']?.api) {
|
||||||
;(window as any).__appReadiness.apiInitialized = true
|
window.__appReadiness = {
|
||||||
|
...window.__appReadiness,
|
||||||
|
apiInitialized: true
|
||||||
|
}
|
||||||
clearInterval(checkApi)
|
clearInterval(checkApi)
|
||||||
}
|
}
|
||||||
}, 10)
|
}, 10)
|
||||||
@@ -302,7 +311,10 @@ test.describe('Feature Flags', () => {
|
|||||||
// Monitor app initialization
|
// Monitor app initialization
|
||||||
const checkApp = setInterval(() => {
|
const checkApp = setInterval(() => {
|
||||||
if (window['app']?.graph) {
|
if (window['app']?.graph) {
|
||||||
;(window as any).__appReadiness.appInitialized = true
|
window.__appReadiness = {
|
||||||
|
...window.__appReadiness,
|
||||||
|
appInitialized: true
|
||||||
|
}
|
||||||
clearInterval(checkApp)
|
clearInterval(checkApp)
|
||||||
}
|
}
|
||||||
}, 10)
|
}, 10)
|
||||||
@@ -331,8 +343,8 @@ test.describe('Feature Flags', () => {
|
|||||||
// Get readiness state
|
// Get readiness state
|
||||||
const readiness = await newPage.evaluate(() => {
|
const readiness = await newPage.evaluate(() => {
|
||||||
return {
|
return {
|
||||||
...(window as any).__appReadiness,
|
...window.__appReadiness,
|
||||||
currentFlags: window['app'].api.serverFeatureFlags
|
currentFlags: window['app']!.api.serverFeatureFlags
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -2,15 +2,17 @@ import {
|
|||||||
comfyExpect as expect,
|
comfyExpect as expect,
|
||||||
comfyPageFixture as test
|
comfyPageFixture as test
|
||||||
} from '../fixtures/ComfyPage'
|
} from '../fixtures/ComfyPage'
|
||||||
|
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||||
import { fitToViewInstant } from '../helpers/fitToView'
|
import { fitToViewInstant } from '../helpers/fitToView'
|
||||||
|
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||||
|
|
||||||
// TODO: there might be a better solution for this
|
// TODO: there might be a better solution for this
|
||||||
// Helper function to pan canvas and select node
|
// Helper function to pan canvas and select node
|
||||||
async function selectNodeWithPan(comfyPage: any, nodeRef: any) {
|
async function selectNodeWithPan(comfyPage: ComfyPage, nodeRef: NodeReference) {
|
||||||
const nodePos = await nodeRef.getPosition()
|
const nodePos = await nodeRef.getPosition()
|
||||||
|
|
||||||
await comfyPage.page.evaluate((pos) => {
|
await comfyPage.page.evaluate((pos) => {
|
||||||
const app = window['app']
|
const app = window['app']!
|
||||||
const canvas = app.canvas
|
const canvas = app.canvas
|
||||||
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
|
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
|
||||||
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
|
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
|
||||||
@@ -345,7 +347,7 @@ This is documentation for a custom node.
|
|||||||
|
|
||||||
// Find and select a custom/group node
|
// Find and select a custom/group node
|
||||||
const nodeRefs = await comfyPage.page.evaluate(() => {
|
const nodeRefs = await comfyPage.page.evaluate(() => {
|
||||||
return window['app'].graph.nodes.map((n: any) => n.id)
|
return window['app']!.graph!.nodes.map((n) => n.id)
|
||||||
})
|
})
|
||||||
if (nodeRefs.length > 0) {
|
if (nodeRefs.length > 0) {
|
||||||
const firstNode = await comfyPage.getNodeRefById(nodeRefs[0])
|
const firstNode = await comfyPage.getNodeRefById(nodeRefs[0])
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
@@ -1,6 +1,7 @@
|
|||||||
import { expect } from '@playwright/test'
|
import { expect } from '@playwright/test'
|
||||||
|
|
||||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||||
|
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||||
|
|
||||||
test.beforeEach(async ({ comfyPage }) => {
|
test.beforeEach(async ({ comfyPage }) => {
|
||||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||||
@@ -15,7 +16,7 @@ test.describe('Selection Toolbox - More Options Submenus', () => {
|
|||||||
await comfyPage.nextFrame()
|
await comfyPage.nextFrame()
|
||||||
})
|
})
|
||||||
|
|
||||||
const openMoreOptions = async (comfyPage: any) => {
|
const openMoreOptions = async (comfyPage: ComfyPage) => {
|
||||||
const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler')
|
const ksamplerNodes = await comfyPage.getNodeRefsByTitle('KSampler')
|
||||||
if (ksamplerNodes.length === 0) {
|
if (ksamplerNodes.length === 0) {
|
||||||
throw new Error('No KSampler nodes found')
|
throw new Error('No KSampler nodes found')
|
||||||
|
|||||||
@@ -189,9 +189,7 @@ test.describe('Templates', () => {
|
|||||||
const templateGrid = comfyPage.page.locator(
|
const templateGrid = comfyPage.page.locator(
|
||||||
'[data-testid="template-workflows-content"]'
|
'[data-testid="template-workflows-content"]'
|
||||||
)
|
)
|
||||||
const nav = comfyPage.page
|
const nav = comfyPage.page.locator('header', { hasText: 'Templates' })
|
||||||
.locator('header')
|
|
||||||
.filter({ hasText: 'Templates' })
|
|
||||||
|
|
||||||
await comfyPage.templates.waitForMinimumCardCount(1)
|
await comfyPage.templates.waitForMinimumCardCount(1)
|
||||||
await expect(templateGrid).toBeVisible()
|
await expect(templateGrid).toBeVisible()
|
||||||
@@ -201,7 +199,8 @@ test.describe('Templates', () => {
|
|||||||
await comfyPage.page.setViewportSize(mobileSize)
|
await comfyPage.page.setViewportSize(mobileSize)
|
||||||
await comfyPage.templates.waitForMinimumCardCount(1)
|
await comfyPage.templates.waitForMinimumCardCount(1)
|
||||||
await expect(templateGrid).toBeVisible()
|
await expect(templateGrid).toBeVisible()
|
||||||
await expect(nav).not.toBeVisible() // Nav should collapse at mobile size
|
// Nav header is clipped by overflow-hidden parent at mobile size
|
||||||
|
await expect(nav).not.toBeInViewport()
|
||||||
|
|
||||||
const tabletSize = { width: 1024, height: 800 }
|
const tabletSize = { width: 1024, height: 800 }
|
||||||
await comfyPage.page.setViewportSize(tabletSize)
|
await comfyPage.page.setViewportSize(tabletSize)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
@@ -419,7 +419,7 @@ test.describe('Vue Node Link Interaction', () => {
|
|||||||
// This avoids relying on an exact path hit-test position.
|
// This avoids relying on an exact path hit-test position.
|
||||||
await comfyPage.page.evaluate(
|
await comfyPage.page.evaluate(
|
||||||
([targetNodeId, targetSlot, clientPoint]) => {
|
([targetNodeId, targetSlot, clientPoint]) => {
|
||||||
const app = (window as any)['app']
|
const app = window['app']
|
||||||
const graph = app?.canvas?.graph ?? app?.graph
|
const graph = app?.canvas?.graph ?? app?.graph
|
||||||
if (!graph) throw new Error('Graph not available')
|
if (!graph) throw new Error('Graph not available')
|
||||||
const node = graph.getNodeById(targetNodeId)
|
const node = graph.getNodeById(targetNodeId)
|
||||||
@@ -505,7 +505,7 @@ test.describe('Vue Node Link Interaction', () => {
|
|||||||
// This avoids relying on an exact path hit-test position.
|
// This avoids relying on an exact path hit-test position.
|
||||||
await comfyPage.page.evaluate(
|
await comfyPage.page.evaluate(
|
||||||
([targetNodeId, targetSlot, clientPoint]) => {
|
([targetNodeId, targetSlot, clientPoint]) => {
|
||||||
const app = (window as any)['app']
|
const app = window['app']
|
||||||
const graph = app?.canvas?.graph ?? app?.graph
|
const graph = app?.canvas?.graph ?? app?.graph
|
||||||
if (!graph) throw new Error('Graph not available')
|
if (!graph) throw new Error('Graph not available')
|
||||||
const node = graph.getNodeById(targetNodeId)
|
const node = graph.getNodeById(targetNodeId)
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
@@ -76,6 +76,7 @@ function getModuleName(id: string): string {
|
|||||||
export function comfyAPIPlugin(isDev: boolean): Plugin {
|
export function comfyAPIPlugin(isDev: boolean): Plugin {
|
||||||
return {
|
return {
|
||||||
name: 'comfy-api-plugin',
|
name: 'comfy-api-plugin',
|
||||||
|
apply: 'build',
|
||||||
transform(code: string, id: string) {
|
transform(code: string, id: string) {
|
||||||
if (isDev) return null
|
if (isDev) return null
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
|
|||||||
33
docs/guidance/playwright.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
globs:
|
||||||
|
- '**/*.spec.ts'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Playwright E2E Test Conventions
|
||||||
|
|
||||||
|
See `docs/testing/*.md` for detailed patterns.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Follow [Playwright Best Practices](https://playwright.dev/docs/best-practices)
|
||||||
|
- Do NOT use `waitForTimeout` - use Locator actions and retrying assertions
|
||||||
|
- Prefer specific selectors (role, label, test-id)
|
||||||
|
- Test across viewports
|
||||||
|
|
||||||
|
## Test Tags
|
||||||
|
|
||||||
|
Tags are respected by config:
|
||||||
|
- `@mobile` - Mobile viewport tests
|
||||||
|
- `@2x` - High DPI tests
|
||||||
|
|
||||||
|
## Test Data
|
||||||
|
|
||||||
|
- Check `browser_tests/assets/` for test data and fixtures
|
||||||
|
- Use realistic ComfyUI workflows for E2E tests
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test:browser # Run all E2E tests
|
||||||
|
pnpm test:browser -- --ui # Interactive UI mode
|
||||||
|
```
|
||||||
55
docs/guidance/storybook.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
globs:
|
||||||
|
- '**/*.stories.ts'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Storybook Conventions
|
||||||
|
|
||||||
|
## File Placement
|
||||||
|
|
||||||
|
Place `*.stories.ts` files alongside their components:
|
||||||
|
```
|
||||||
|
src/components/MyComponent/
|
||||||
|
├── MyComponent.vue
|
||||||
|
└── MyComponent.stories.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Story Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||||
|
import ComponentName from './ComponentName.vue'
|
||||||
|
|
||||||
|
const meta: Meta<typeof ComponentName> = {
|
||||||
|
title: 'Category/ComponentName',
|
||||||
|
component: ComponentName,
|
||||||
|
parameters: { layout: 'centered' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: { /* props */ }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required Story Variants
|
||||||
|
|
||||||
|
Include when applicable:
|
||||||
|
- **Default** - Minimal props
|
||||||
|
- **WithData** - Realistic data
|
||||||
|
- **Loading** - Loading state
|
||||||
|
- **Error** - Error state
|
||||||
|
- **Empty** - No data
|
||||||
|
|
||||||
|
## Mock Data
|
||||||
|
|
||||||
|
Use realistic ComfyUI schemas for mocks (node definitions, components).
|
||||||
|
|
||||||
|
## Running Storybook
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm storybook # Development server
|
||||||
|
pnpm build-storybook # Production build
|
||||||
|
```
|
||||||
37
docs/guidance/typescript.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
globs:
|
||||||
|
- '**/*.ts'
|
||||||
|
- '**/*.tsx'
|
||||||
|
- '**/*.vue'
|
||||||
|
---
|
||||||
|
|
||||||
|
# TypeScript Conventions
|
||||||
|
|
||||||
|
## Type Safety
|
||||||
|
|
||||||
|
- Never use `any` type - use proper TypeScript types
|
||||||
|
- Never use `as any` type assertions - fix the underlying type issue
|
||||||
|
- Type assertions are a last resort; they lead to brittle code
|
||||||
|
- Avoid `@ts-expect-error` - fix the underlying issue instead
|
||||||
|
|
||||||
|
## Utility Libraries
|
||||||
|
|
||||||
|
- Use `es-toolkit` for utility functions (not lodash)
|
||||||
|
|
||||||
|
## API Utilities
|
||||||
|
|
||||||
|
When making API calls in `src/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Correct - use api helpers
|
||||||
|
const response = await api.get(api.apiURL('/prompt'))
|
||||||
|
const template = await fetch(api.fileURL('/templates/default.json'))
|
||||||
|
|
||||||
|
// ❌ Wrong - direct URL construction
|
||||||
|
const response = await fetch('/api/prompt')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Sanitize HTML with `DOMPurify.sanitize()`
|
||||||
|
- Never log secrets or sensitive data
|
||||||
36
docs/guidance/vitest.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
globs:
|
||||||
|
- '**/*.test.ts'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vitest Unit Test Conventions
|
||||||
|
|
||||||
|
See `docs/testing/*.md` for detailed patterns.
|
||||||
|
|
||||||
|
## Test Quality
|
||||||
|
|
||||||
|
- Do not write change detector tests (tests that just assert defaults)
|
||||||
|
- Do not write tests dependent on non-behavioral features (styles, classes)
|
||||||
|
- Do not write tests that just test mocks - ensure real code is exercised
|
||||||
|
- Be parsimonious; avoid redundant tests
|
||||||
|
|
||||||
|
## Mocking
|
||||||
|
|
||||||
|
- Use Vitest's mocking utilities (`vi.mock`, `vi.spyOn`)
|
||||||
|
- Keep module mocks contained - no global mutable state
|
||||||
|
- Use `vi.hoisted()` for per-test mock manipulation
|
||||||
|
- Don't mock what you don't own
|
||||||
|
|
||||||
|
## Component Testing
|
||||||
|
|
||||||
|
- Use Vue Test Utils for component tests
|
||||||
|
- Follow advice about making components easy to test
|
||||||
|
- Wait for reactivity with `await nextTick()` after state changes
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test:unit # Run all unit tests
|
||||||
|
pnpm test:unit -- path/to/file # Run specific test
|
||||||
|
pnpm test:unit -- --watch # Watch mode
|
||||||
|
```
|
||||||
46
docs/guidance/vue-components.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
globs:
|
||||||
|
- '**/*.vue'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vue Component Conventions
|
||||||
|
|
||||||
|
Applies to all `.vue` files anywhere in the codebase.
|
||||||
|
|
||||||
|
## Vue 3 Composition API
|
||||||
|
|
||||||
|
- Use `<script setup lang="ts">` for component logic
|
||||||
|
- Destructure props (Vue 3.5 style with defaults) like `const { color = 'blue' } = defineProps<...>()`
|
||||||
|
- Use `ref`/`reactive` for state
|
||||||
|
- Use `computed()` for derived state
|
||||||
|
- Use lifecycle hooks: `onMounted`, `onUpdated`, etc.
|
||||||
|
|
||||||
|
## Component Communication
|
||||||
|
|
||||||
|
- Prefer `emit/@event-name` for state changes (promotes loose coupling)
|
||||||
|
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
|
||||||
|
- Proper props and emits definitions
|
||||||
|
|
||||||
|
## VueUse Composables
|
||||||
|
|
||||||
|
Prefer VueUse composables over manual event handling:
|
||||||
|
|
||||||
|
- `useElementHover` instead of manual mouseover/mouseout listeners
|
||||||
|
- `useIntersectionObserver` for visibility detection instead of scroll handlers
|
||||||
|
- `useFocusTrap` for modal/dialog focus management
|
||||||
|
- `useEventListener` for auto-cleanup event listeners
|
||||||
|
|
||||||
|
Prefer Vue native options when available:
|
||||||
|
|
||||||
|
- `defineModel` instead of `useVModel` for two-way binding with props
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
- Use inline Tailwind CSS only (no `<style>` blocks)
|
||||||
|
- Use `cn()` from `@/utils/tailwindUtil` for conditional classes
|
||||||
|
- Refer to packages/design-system/src/css/style.css for design tokens and tailwind configuration
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Extract complex conditionals to `computed`
|
||||||
|
- In unmounted hooks, implement cleanup for async operations
|
||||||
@@ -30,6 +30,10 @@ describe('MyStore', () => {
|
|||||||
|
|
||||||
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
|
**Why `stubActions: false`?** By default, testing pinia stubs all actions. Set to `false` when testing actual store behavior.
|
||||||
|
|
||||||
|
## i18n in Component Tests
|
||||||
|
|
||||||
|
Use real `createI18n` with empty messages instead of mocking `vue-i18n`. See `SearchBox.test.ts` for example.
|
||||||
|
|
||||||
## Mock Patterns
|
## Mock Patterns
|
||||||
|
|
||||||
### Reset all mocks at once
|
### Reset all mocks at once
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
|
|||||||
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
|
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'
|
||||||
import { importX } from 'eslint-plugin-import-x'
|
import { importX } from 'eslint-plugin-import-x'
|
||||||
import oxlint from 'eslint-plugin-oxlint'
|
import oxlint from 'eslint-plugin-oxlint'
|
||||||
// WORKAROUND: eslint-plugin-prettier causes segfault on Node.js 24 + Windows
|
// eslint-config-prettier disables ESLint rules that conflict with formatters (oxfmt)
|
||||||
// See: https://github.com/nodejs/node/issues/58690
|
|
||||||
// Prettier is still run separately in lint-staged, so this is safe to disable
|
|
||||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||||
import { configs as storybookConfigs } from 'eslint-plugin-storybook'
|
import { configs as storybookConfigs } from 'eslint-plugin-storybook'
|
||||||
import unusedImports from 'eslint-plugin-unused-imports'
|
import unusedImports from 'eslint-plugin-unused-imports'
|
||||||
@@ -111,7 +109,7 @@ export default defineConfig([
|
|||||||
tseslintConfigs.recommended,
|
tseslintConfigs.recommended,
|
||||||
// Difference in typecheck on CI vs Local
|
// Difference in typecheck on CI vs Local
|
||||||
pluginVue.configs['flat/recommended'],
|
pluginVue.configs['flat/recommended'],
|
||||||
// Use eslint-config-prettier instead of eslint-plugin-prettier to avoid Node 24 segfault
|
// Disables ESLint rules that conflict with formatters
|
||||||
eslintConfigPrettier,
|
eslintConfigPrettier,
|
||||||
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types
|
// @ts-expect-error Type incompatibility between storybook plugin and ESLint config types
|
||||||
storybookConfigs['flat/recommended'],
|
storybookConfigs['flat/recommended'],
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
'tests-ui/**': () =>
|
||||||
|
'echo "Files in tests-ui/ are deprecated. Colocate tests with source files." && exit 1',
|
||||||
|
|
||||||
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
|
'./**/*.js': (stagedFiles: string[]) => formatAndEslint(stagedFiles),
|
||||||
|
|
||||||
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => [
|
'./**/*.{ts,tsx,vue,mts}': (stagedFiles: string[]) => [
|
||||||
@@ -14,7 +17,7 @@ function formatAndEslint(fileNames: string[]) {
|
|||||||
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
|
const relativePaths = fileNames.map((f) => path.relative(process.cwd(), f))
|
||||||
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
|
const joinedPaths = relativePaths.map((p) => `"${p}"`).join(' ')
|
||||||
return [
|
return [
|
||||||
`pnpm exec prettier --cache --write ${joinedPaths}`,
|
`pnpm exec oxfmt --write ${joinedPaths}`,
|
||||||
`pnpm exec oxlint --fix ${joinedPaths}`,
|
`pnpm exec oxlint --fix ${joinedPaths}`,
|
||||||
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
`pnpm exec eslint --cache --fix --no-warn-ignored ${joinedPaths}`
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
"short_name": "ComfyUI",
|
"short_name": "ComfyUI",
|
||||||
"description": "ComfyUI: AI image generation platform",
|
"description": "ComfyUI: AI image generation platform",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/assets/images/comfy-logo-single.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml"
|
||||||
|
}
|
||||||
|
],
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
"theme_color": "#000000"
|
"theme_color": "#000000"
|
||||||
|
|||||||
18
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@comfyorg/comfyui-frontend",
|
"name": "@comfyorg/comfyui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.38.1",
|
"version": "1.38.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||||
"homepage": "https://comfy.org",
|
"homepage": "https://comfy.org",
|
||||||
@@ -22,10 +22,8 @@
|
|||||||
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
|
"dev:no-vue": "cross-env DISABLE_VUE_PLUGINS=true nx serve",
|
||||||
"dev": "nx serve",
|
"dev": "nx serve",
|
||||||
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
"devtools:pycheck": "python3 -m compileall -q tools/devtools",
|
||||||
"format:check:no-cache": "prettier --check './**/*.{js,ts,tsx,vue,mts}'",
|
"format:check": "oxfmt --check",
|
||||||
"format:check": "prettier --check './**/*.{js,ts,tsx,vue,mts}' --cache",
|
"format": "oxfmt --write",
|
||||||
"format:no-cache": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --list-different",
|
|
||||||
"format": "prettier --write './**/*.{js,ts,tsx,vue,mts}' --cache --list-different",
|
|
||||||
"json-schema": "tsx scripts/generate-json-schema.ts",
|
"json-schema": "tsx scripts/generate-json-schema.ts",
|
||||||
"knip:no-cache": "knip",
|
"knip:no-cache": "knip",
|
||||||
"knip": "knip --cache",
|
"knip": "knip --cache",
|
||||||
@@ -63,14 +61,12 @@
|
|||||||
"@nx/vite": "catalog:",
|
"@nx/vite": "catalog:",
|
||||||
"@pinia/testing": "catalog:",
|
"@pinia/testing": "catalog:",
|
||||||
"@playwright/test": "catalog:",
|
"@playwright/test": "catalog:",
|
||||||
"@prettier/plugin-oxc": "catalog:",
|
|
||||||
"@sentry/vite-plugin": "catalog:",
|
"@sentry/vite-plugin": "catalog:",
|
||||||
"@storybook/addon-docs": "catalog:",
|
"@storybook/addon-docs": "catalog:",
|
||||||
"@storybook/addon-mcp": "catalog:",
|
"@storybook/addon-mcp": "catalog:",
|
||||||
"@storybook/vue3": "catalog:",
|
"@storybook/vue3": "catalog:",
|
||||||
"@storybook/vue3-vite": "catalog:",
|
"@storybook/vue3-vite": "catalog:",
|
||||||
"@tailwindcss/vite": "catalog:",
|
"@tailwindcss/vite": "catalog:",
|
||||||
"@trivago/prettier-plugin-sort-imports": "catalog:",
|
|
||||||
"@types/fs-extra": "catalog:",
|
"@types/fs-extra": "catalog:",
|
||||||
"@types/jsdom": "catalog:",
|
"@types/jsdom": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
@@ -101,11 +97,11 @@
|
|||||||
"markdown-table": "catalog:",
|
"markdown-table": "catalog:",
|
||||||
"mixpanel-browser": "catalog:",
|
"mixpanel-browser": "catalog:",
|
||||||
"nx": "catalog:",
|
"nx": "catalog:",
|
||||||
|
"oxfmt": "catalog:",
|
||||||
"oxlint": "catalog:",
|
"oxlint": "catalog:",
|
||||||
"oxlint-tsgolint": "catalog:",
|
"oxlint-tsgolint": "catalog:",
|
||||||
"picocolors": "catalog:",
|
"picocolors": "catalog:",
|
||||||
"postcss-html": "catalog:",
|
"postcss-html": "catalog:",
|
||||||
"prettier": "catalog:",
|
|
||||||
"pretty-bytes": "catalog:",
|
"pretty-bytes": "catalog:",
|
||||||
"rollup-plugin-visualizer": "catalog:",
|
"rollup-plugin-visualizer": "catalog:",
|
||||||
"storybook": "catalog:",
|
"storybook": "catalog:",
|
||||||
@@ -173,6 +169,7 @@
|
|||||||
"firebase": "catalog:",
|
"firebase": "catalog:",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
"glob": "^11.0.3",
|
"glob": "^11.0.3",
|
||||||
|
"jsonata": "catalog:",
|
||||||
"jsondiffpatch": "^0.6.0",
|
"jsondiffpatch": "^0.6.0",
|
||||||
"loglevel": "^1.9.2",
|
"loglevel": "^1.9.2",
|
||||||
"marked": "^15.0.11",
|
"marked": "^15.0.11",
|
||||||
@@ -192,5 +189,10 @@
|
|||||||
"yjs": "catalog:",
|
"yjs": "catalog:",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
"zod-validation-error": "catalog:"
|
"zod-validation-error": "catalog:"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"vite": "^8.0.0-beta.8"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,7 +282,7 @@
|
|||||||
--modal-card-border-highlighted: var(--secondary-background-selected);
|
--modal-card-border-highlighted: var(--secondary-background-selected);
|
||||||
--modal-card-button-surface: var(--color-smoke-300);
|
--modal-card-button-surface: var(--color-smoke-300);
|
||||||
--modal-card-placeholder-background: var(--color-smoke-600);
|
--modal-card-placeholder-background: var(--color-smoke-600);
|
||||||
--modal-card-tag-background: var(--color-smoke-400);
|
--modal-card-tag-background: var(--color-smoke-200);
|
||||||
--modal-card-tag-foreground: var(--base-foreground);
|
--modal-card-tag-foreground: var(--base-foreground);
|
||||||
--modal-panel-background: var(--color-white);
|
--modal-panel-background: var(--color-white);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<defs>
|
|
||||||
<clipPath id="hollow">
|
|
||||||
<path
|
|
||||||
d="M -50 50
|
|
||||||
A 100 100, 0, 0, 1, 150 50
|
|
||||||
A 100 100, 0, 0, 1, -50 50
|
|
||||||
M 30 50
|
|
||||||
A 20 20, 0, 0, 0, 70 50
|
|
||||||
A 20 20, 0, 0, 0, 30 50"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
<g clip-path="var(--shape)" stroke-width="4">
|
|
||||||
<path d="M 50 0 A 50 50, 0, 0, 1, 50 100" fill="var(--type1, red)"/>
|
|
||||||
<path d="M 50 100 A 50 50, 0, 0, 1, 50 0" fill="var(--type2, blue)"/>
|
|
||||||
<path d="M50 0L50 100" stroke="var(--inner-stroke, black)"/>
|
|
||||||
<path d="M50 2A48 48 0 0 1 50 98A48 48 0 0 1 50 2" fill="transparent" stroke="var(--outer-stroke, transparent)"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 693 B |
@@ -1,20 +0,0 @@
|
|||||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<defs>
|
|
||||||
<clipPath id="hollow">
|
|
||||||
<path
|
|
||||||
d="M-50 50
|
|
||||||
A100 100 0 0 1 150 50
|
|
||||||
A100 100 0 0 1 -50 50
|
|
||||||
M30 50
|
|
||||||
A20 20 0 0 0 70 50
|
|
||||||
A20 20 0 0 0 30 50"/>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
<g clip-path="var(--shape)" stroke-width="4">
|
|
||||||
<path d="M50 0A50 50 0 0 1 93 75L50 50" fill="var(--type1, red)"/>
|
|
||||||
<path d="M93 75A50 50 0 0 1 7 75L50 50" fill="var(--type2, blue)"/>
|
|
||||||
<path d="M7 75A50 50 0 0 1 50 0L50 50" fill="var(--type3, green)"/>
|
|
||||||
<path d="M50 50L50 0M50 50L93 75M50 50L7 75" stroke="var(--inner-stroke, black)"/>
|
|
||||||
<path d="M50 2A48 48 0 0 1 50 98A48 48 0 0 1 50 2" fill="transparent" stroke="var(--outer-stroke, transparent)"/>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 763 B |
@@ -120,8 +120,8 @@ describe('formatUtil', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should handle null and undefined gracefully', () => {
|
it('should handle null and undefined gracefully', () => {
|
||||||
expect(getMediaTypeFromFilename(null as any)).toBe('image')
|
expect(getMediaTypeFromFilename(null)).toBe('image')
|
||||||
expect(getMediaTypeFromFilename(undefined as any)).toBe('image')
|
expect(getMediaTypeFromFilename(undefined)).toBe('image')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle special characters in filenames', () => {
|
it('should handle special characters in filenames', () => {
|
||||||
|
|||||||
@@ -537,7 +537,9 @@ export function truncateFilename(
|
|||||||
* @param filename The filename to analyze
|
* @param filename The filename to analyze
|
||||||
* @returns The media type: 'image', 'video', 'audio', or '3D'
|
* @returns The media type: 'image', 'video', 'audio', or '3D'
|
||||||
*/
|
*/
|
||||||
export function getMediaTypeFromFilename(filename: string): MediaType {
|
export function getMediaTypeFromFilename(
|
||||||
|
filename: string | null | undefined
|
||||||
|
): MediaType {
|
||||||
if (!filename) return 'image'
|
if (!filename) return 'image'
|
||||||
const ext = filename.split('.').pop()?.toLowerCase()
|
const ext = filename.split('.').pop()?.toLowerCase()
|
||||||
if (!ext) return 'image'
|
if (!ext) return 'image'
|
||||||
|
|||||||
867
pnpm-lock.yaml
generated
@@ -17,7 +17,6 @@ catalog:
|
|||||||
'@nx/vite': 22.2.6
|
'@nx/vite': 22.2.6
|
||||||
'@pinia/testing': ^1.0.3
|
'@pinia/testing': ^1.0.3
|
||||||
'@playwright/test': ^1.57.0
|
'@playwright/test': ^1.57.0
|
||||||
'@prettier/plugin-oxc': ^0.1.3
|
|
||||||
'@primeuix/forms': 0.0.2
|
'@primeuix/forms': 0.0.2
|
||||||
'@primeuix/styled': 0.3.2
|
'@primeuix/styled': 0.3.2
|
||||||
'@primeuix/utils': ^0.3.2
|
'@primeuix/utils': ^0.3.2
|
||||||
@@ -33,7 +32,6 @@ catalog:
|
|||||||
'@storybook/vue3': ^10.1.9
|
'@storybook/vue3': ^10.1.9
|
||||||
'@storybook/vue3-vite': ^10.1.9
|
'@storybook/vue3-vite': ^10.1.9
|
||||||
'@tailwindcss/vite': ^4.1.12
|
'@tailwindcss/vite': ^4.1.12
|
||||||
'@trivago/prettier-plugin-sort-imports': ^5.2.0
|
|
||||||
'@types/fs-extra': ^11.0.4
|
'@types/fs-extra': ^11.0.4
|
||||||
'@types/jsdom': ^21.1.7
|
'@types/jsdom': ^21.1.7
|
||||||
'@types/node': ^24.1.0
|
'@types/node': ^24.1.0
|
||||||
@@ -64,18 +62,19 @@ catalog:
|
|||||||
happy-dom: ^20.0.11
|
happy-dom: ^20.0.11
|
||||||
husky: ^9.1.7
|
husky: ^9.1.7
|
||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
|
jsonata: ^2.1.0
|
||||||
jsdom: ^27.4.0
|
jsdom: ^27.4.0
|
||||||
knip: ^5.75.1
|
knip: ^5.75.1
|
||||||
lint-staged: ^16.2.7
|
lint-staged: ^16.2.7
|
||||||
markdown-table: ^3.0.4
|
markdown-table: ^3.0.4
|
||||||
mixpanel-browser: ^2.71.0
|
mixpanel-browser: ^2.71.0
|
||||||
nx: 22.2.6
|
nx: 22.2.6
|
||||||
|
oxfmt: ^0.26.0
|
||||||
oxlint: ^1.33.0
|
oxlint: ^1.33.0
|
||||||
oxlint-tsgolint: ^0.9.1
|
oxlint-tsgolint: ^0.9.1
|
||||||
picocolors: ^1.1.1
|
picocolors: ^1.1.1
|
||||||
pinia: ^3.0.4
|
pinia: ^3.0.4
|
||||||
postcss-html: ^1.8.0
|
postcss-html: ^1.8.0
|
||||||
prettier: ^3.7.4
|
|
||||||
pretty-bytes: ^7.1.0
|
pretty-bytes: ^7.1.0
|
||||||
primeicons: ^7.0.0
|
primeicons: ^7.0.0
|
||||||
primevue: ^4.2.5
|
primevue: ^4.2.5
|
||||||
@@ -93,7 +92,7 @@ catalog:
|
|||||||
unplugin-icons: ^22.5.0
|
unplugin-icons: ^22.5.0
|
||||||
unplugin-typegpu: 0.8.0
|
unplugin-typegpu: 0.8.0
|
||||||
unplugin-vue-components: ^30.0.0
|
unplugin-vue-components: ^30.0.0
|
||||||
vite: ^7.3.0
|
vite: ^8.0.0-beta.8
|
||||||
vite-plugin-dts: ^4.5.4
|
vite-plugin-dts: ^4.5.4
|
||||||
vite-plugin-html: ^3.2.2
|
vite-plugin-html: ^3.2.2
|
||||||
vite-plugin-vue-devtools: ^8.0.0
|
vite-plugin-vue-devtools: ^8.0.0
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ declare global {
|
|||||||
const __ALGOLIA_API_KEY__: string
|
const __ALGOLIA_API_KEY__: string
|
||||||
const __USE_PROD_CONFIG__: boolean
|
const __USE_PROD_CONFIG__: boolean
|
||||||
const __DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
|
const __DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
|
||||||
|
const __IS_NIGHTLY__: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type GlobalWithDefines = typeof globalThis & {
|
type GlobalWithDefines = typeof globalThis & {
|
||||||
@@ -22,6 +23,7 @@ type GlobalWithDefines = typeof globalThis & {
|
|||||||
__ALGOLIA_API_KEY__: string
|
__ALGOLIA_API_KEY__: string
|
||||||
__USE_PROD_CONFIG__: boolean
|
__USE_PROD_CONFIG__: boolean
|
||||||
__DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
|
__DISTRIBUTION__: 'desktop' | 'localhost' | 'cloud'
|
||||||
|
__IS_NIGHTLY__: boolean
|
||||||
window?: Record<string, unknown>
|
window?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +38,7 @@ globalWithDefines.__ALGOLIA_APP_ID__ = ''
|
|||||||
globalWithDefines.__ALGOLIA_API_KEY__ = ''
|
globalWithDefines.__ALGOLIA_API_KEY__ = ''
|
||||||
globalWithDefines.__USE_PROD_CONFIG__ = false
|
globalWithDefines.__USE_PROD_CONFIG__ = false
|
||||||
globalWithDefines.__DISTRIBUTION__ = 'localhost'
|
globalWithDefines.__DISTRIBUTION__ = 'localhost'
|
||||||
|
globalWithDefines.__IS_NIGHTLY__ = false
|
||||||
|
|
||||||
// Provide a minimal window shim for Node environment
|
// Provide a minimal window shim for Node environment
|
||||||
// This is needed for code that checks window existence during imports
|
// This is needed for code that checks window existence during imports
|
||||||
|
|||||||
26
src/AGENTS.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Source Code Guidelines
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- User-friendly and actionable messages
|
||||||
|
- Proper error propagation
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Sanitize HTML with DOMPurify
|
||||||
|
- Validate trusted sources
|
||||||
|
- Never log secrets
|
||||||
|
|
||||||
|
## State Management (Stores)
|
||||||
|
|
||||||
|
- Follow domain-driven design for organizing files/folders
|
||||||
|
- Clear public interfaces
|
||||||
|
- Restrict extension access
|
||||||
|
- Clean up subscriptions
|
||||||
|
|
||||||
|
## General Guidelines
|
||||||
|
|
||||||
|
- Use `es-toolkit` for utility functions
|
||||||
|
- Use TypeScript for type safety
|
||||||
|
- Avoid `@ts-expect-error` - fix the underlying issue
|
||||||
|
- Use `vue-i18n` for ALL user-facing strings (`src/locales/en/main.json`)
|
||||||
31
src/App.vue
@@ -9,6 +9,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { captureException } from '@sentry/vue'
|
||||||
import { useEventListener } from '@vueuse/core'
|
import { useEventListener } from '@vueuse/core'
|
||||||
import BlockUI from 'primevue/blockui'
|
import BlockUI from 'primevue/blockui'
|
||||||
import ProgressSpinner from 'primevue/progressspinner'
|
import ProgressSpinner from 'primevue/progressspinner'
|
||||||
@@ -16,10 +17,6 @@ import { computed, onMounted } from 'vue'
|
|||||||
|
|
||||||
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
|
||||||
import config from '@/config'
|
import config from '@/config'
|
||||||
import { t } from '@/i18n'
|
|
||||||
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
|
|
||||||
import { app } from '@/scripts/app'
|
|
||||||
import { useDialogService } from '@/services/dialogService'
|
|
||||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||||
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
import { useConflictDetection } from '@/workbench/extensions/manager/composables/useConflictDetection'
|
||||||
|
|
||||||
@@ -27,8 +24,6 @@ import { electronAPI, isElectron } from './utils/envUtil'
|
|||||||
|
|
||||||
const workspaceStore = useWorkspaceStore()
|
const workspaceStore = useWorkspaceStore()
|
||||||
const conflictDetection = useConflictDetection()
|
const conflictDetection = useConflictDetection()
|
||||||
const workflowStore = useWorkflowStore()
|
|
||||||
const dialogService = useDialogService()
|
|
||||||
const isLoading = computed<boolean>(() => workspaceStore.spinner)
|
const isLoading = computed<boolean>(() => workspaceStore.spinner)
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
workspaceStore.shiftDown = e.shiftKey
|
workspaceStore.shiftDown = e.shiftKey
|
||||||
@@ -54,23 +49,15 @@ onMounted(() => {
|
|||||||
document.addEventListener('contextmenu', showContextMenu)
|
document.addEventListener('contextmenu', showContextMenu)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Vite preload errors (e.g., when assets are deleted after deployment)
|
window.addEventListener('vite:preloadError', (event) => {
|
||||||
window.addEventListener('vite:preloadError', async (_event) => {
|
event.preventDefault()
|
||||||
// Auto-reload if app is not ready or there are no unsaved changes
|
// eslint-disable-next-line no-undef
|
||||||
if (!app.vueAppReady || !workflowStore.activeWorkflow?.isModified) {
|
if (__DISTRIBUTION__ === 'cloud') {
|
||||||
window.location.reload()
|
captureException(event.payload, {
|
||||||
|
tags: { error_type: 'vite_preload_error' }
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
// Show confirmation dialog if there are unsaved changes
|
console.error('[vite:preloadError]', event.payload)
|
||||||
await dialogService
|
|
||||||
.confirm({
|
|
||||||
title: t('g.vitePreloadErrorTitle'),
|
|
||||||
message: t('g.vitePreloadErrorMessage')
|
|
||||||
})
|
|
||||||
.then((confirmed) => {
|
|
||||||
if (confirmed) {
|
|
||||||
window.location.reload()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +1,3 @@
|
|||||||
# Source Code Guidelines
|
<!-- We forked the path, yet here we are again—
|
||||||
|
Maintaining two files where one would have been sane. -->
|
||||||
## Service Layer
|
@AGENTS.md
|
||||||
|
|
||||||
### API Calls
|
|
||||||
|
|
||||||
- Use `api.apiURL()` for backend endpoints
|
|
||||||
- Use `api.fileURL()` for static files
|
|
||||||
|
|
||||||
#### ✅ Correct Usage
|
|
||||||
```typescript
|
|
||||||
// Backend API call
|
|
||||||
const response = await api.get(api.apiURL('/prompt'))
|
|
||||||
|
|
||||||
// Static file
|
|
||||||
const template = await fetch(api.fileURL('/templates/default.json'))
|
|
||||||
```
|
|
||||||
|
|
||||||
#### ❌ Incorrect Usage
|
|
||||||
```typescript
|
|
||||||
// WRONG - Direct URL construction
|
|
||||||
const response = await fetch('/api/prompt')
|
|
||||||
const template = await fetch('/templates/default.json')
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
|
|
||||||
- User-friendly and actionable messages
|
|
||||||
- Proper error propagation
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- Sanitize HTML with DOMPurify
|
|
||||||
- Validate trusted sources
|
|
||||||
- Never log secrets
|
|
||||||
|
|
||||||
## State Management (Stores)
|
|
||||||
|
|
||||||
### Store Design
|
|
||||||
|
|
||||||
- Follow domain-driven design
|
|
||||||
- Clear public interfaces
|
|
||||||
- Restrict extension access
|
|
||||||
|
|
||||||
### Best Practices
|
|
||||||
|
|
||||||
- Use TypeScript for type safety
|
|
||||||
- Implement proper error handling
|
|
||||||
- Clean up subscriptions
|
|
||||||
- Avoid @ts-expect-error
|
|
||||||
|
|
||||||
## General Guidelines
|
|
||||||
|
|
||||||
- Use es-toolkit for utility functions
|
|
||||||
- Implement proper TypeScript types
|
|
||||||
- Follow Vue 3 composition API style guide
|
|
||||||
- Use vue-i18n for ALL user-facing strings in `src/locales/en/main.json`
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ interface IdleDeadline {
|
|||||||
interface IDisposable {
|
interface IDisposable {
|
||||||
dispose(): void
|
dispose(): void
|
||||||
}
|
}
|
||||||
|
type GlobalWindow = typeof globalThis
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal implementation function that handles the actual scheduling logic.
|
* Internal implementation function that handles the actual scheduling logic.
|
||||||
@@ -21,7 +22,7 @@ interface IDisposable {
|
|||||||
* or fall back to setTimeout-based implementation.
|
* or fall back to setTimeout-based implementation.
|
||||||
*/
|
*/
|
||||||
let _runWhenIdle: (
|
let _runWhenIdle: (
|
||||||
targetWindow: any,
|
targetWindow: GlobalWindow,
|
||||||
callback: (idle: IdleDeadline) => void,
|
callback: (idle: IdleDeadline) => void,
|
||||||
timeout?: number
|
timeout?: number
|
||||||
) => IDisposable
|
) => IDisposable
|
||||||
@@ -37,7 +38,7 @@ export let runWhenGlobalIdle: (
|
|||||||
|
|
||||||
// Self-invoking function to set up the idle callback implementation
|
// Self-invoking function to set up the idle callback implementation
|
||||||
;(function () {
|
;(function () {
|
||||||
const safeGlobal: any = globalThis
|
const safeGlobal: GlobalWindow = globalThis as GlobalWindow
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof safeGlobal.requestIdleCallback !== 'function' ||
|
typeof safeGlobal.requestIdleCallback !== 'function' ||
|
||||||
|
|||||||
6
src/components/AGENTS.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Component Guidelines
|
||||||
|
|
||||||
|
## Component Communication
|
||||||
|
|
||||||
|
- Prefer `emit/@event-name` for state changes
|
||||||
|
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
|
||||||
@@ -1,45 +1,3 @@
|
|||||||
# Component Guidelines
|
<!-- "Play nice with others," mother always said,
|
||||||
|
But Claude prefers its own file name instead. -->
|
||||||
## Vue 3 Composition API
|
@AGENTS.md
|
||||||
|
|
||||||
- Use setup() function
|
|
||||||
- Destructure props (Vue 3.5 style)
|
|
||||||
- Use ref/reactive for state
|
|
||||||
- Implement computed() for derived state
|
|
||||||
- Use provide/inject for dependency injection
|
|
||||||
|
|
||||||
## Component Communication
|
|
||||||
|
|
||||||
- Prefer `emit/@event-name` for state changes
|
|
||||||
- Use `defineExpose` only for imperative operations (`form.validate()`, `modal.open()`)
|
|
||||||
- Events promote loose coupling
|
|
||||||
|
|
||||||
## UI Framework
|
|
||||||
|
|
||||||
- Deprecated PrimeVue component replacements:
|
|
||||||
- Dropdown → Select
|
|
||||||
- OverlayPanel → Popover
|
|
||||||
- Calendar → DatePicker
|
|
||||||
- InputSwitch → ToggleSwitch
|
|
||||||
- Sidebar → Drawer
|
|
||||||
- Chips → AutoComplete with multiple enabled
|
|
||||||
- TabMenu → Tabs without panels
|
|
||||||
- Steps → Stepper without panels
|
|
||||||
- InlineMessage → Message
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
|
|
||||||
- Use Tailwind CSS only (no custom CSS)
|
|
||||||
- Use the correct tokens from style.css in the design system package
|
|
||||||
- For common operations, try to use existing VueUse composables that automatically handle effect scope
|
|
||||||
- Example: Use `useElementHover` instead of manually managing mouseover/mouseout event listeners
|
|
||||||
- Example: Use `useIntersectionObserver` for visibility detection instead of custom scroll handlers
|
|
||||||
|
|
||||||
## Best Practices
|
|
||||||
|
|
||||||
- Extract complex conditionals to computed
|
|
||||||
- Implement cleanup for async operations
|
|
||||||
- Use vue-i18n for ALL UI strings
|
|
||||||
- Use lifecycle hooks: onMounted, onUpdated
|
|
||||||
- Use Teleport/Suspense when needed
|
|
||||||
- Proper props and emits definitions
|
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { createTestingPinia } from '@pinia/testing'
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
|
import type { MenuItem } from 'primevue/menuitem'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { computed } from 'vue'
|
import { computed, nextTick } from 'vue'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||||
|
import type {
|
||||||
|
JobListItem,
|
||||||
|
JobStatus
|
||||||
|
} from '@/platform/remote/comfyui/jobs/jobTypes'
|
||||||
|
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
|
||||||
import { isElectron } from '@/utils/envUtil'
|
import { isElectron } from '@/utils/envUtil'
|
||||||
|
|
||||||
const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
|
const mockData = vi.hoisted(() => ({ isLoggedIn: false }))
|
||||||
@@ -36,7 +42,9 @@ function createWrapper() {
|
|||||||
sideToolbar: {
|
sideToolbar: {
|
||||||
queueProgressOverlay: {
|
queueProgressOverlay: {
|
||||||
viewJobHistory: 'View job history',
|
viewJobHistory: 'View job history',
|
||||||
expandCollapsedQueue: 'Expand collapsed queue'
|
expandCollapsedQueue: 'Expand collapsed queue',
|
||||||
|
activeJobsShort: '{count} active | {count} active',
|
||||||
|
clearQueueTooltip: 'Clear queue'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,7 +58,12 @@ function createWrapper() {
|
|||||||
SubgraphBreadcrumb: true,
|
SubgraphBreadcrumb: true,
|
||||||
QueueProgressOverlay: true,
|
QueueProgressOverlay: true,
|
||||||
CurrentUserButton: true,
|
CurrentUserButton: true,
|
||||||
LoginButton: true
|
LoginButton: true,
|
||||||
|
ContextMenu: {
|
||||||
|
name: 'ContextMenu',
|
||||||
|
props: ['model'],
|
||||||
|
template: '<div />'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
directives: {
|
directives: {
|
||||||
tooltip: () => {}
|
tooltip: () => {}
|
||||||
@@ -59,6 +72,19 @@ function createWrapper() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createJob(id: string, status: JobStatus): JobListItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
create_time: 0,
|
||||||
|
priority: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTask(id: string, status: JobStatus): TaskItemImpl {
|
||||||
|
return new TaskItemImpl(createJob(id, status))
|
||||||
|
}
|
||||||
|
|
||||||
describe('TopMenuSection', () => {
|
describe('TopMenuSection', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks()
|
vi.resetAllMocks()
|
||||||
@@ -100,4 +126,39 @@ describe('TopMenuSection', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows the active jobs label with the current count', async () => {
|
||||||
|
const wrapper = createWrapper()
|
||||||
|
const queueStore = useQueueStore()
|
||||||
|
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
|
||||||
|
queueStore.runningTasks = [
|
||||||
|
createTask('running-1', 'in_progress'),
|
||||||
|
createTask('running-2', 'in_progress')
|
||||||
|
]
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||||
|
expect(queueButton.text()).toContain('3 active')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('disables the clear queue context menu item when no queued jobs exist', () => {
|
||||||
|
const wrapper = createWrapper()
|
||||||
|
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||||
|
const model = menu.props('model') as MenuItem[]
|
||||||
|
expect(model[0]?.label).toBe('Clear queue')
|
||||||
|
expect(model[0]?.disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enables the clear queue context menu item when queued jobs exist', async () => {
|
||||||
|
const wrapper = createWrapper()
|
||||||
|
const queueStore = useQueueStore()
|
||||||
|
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||||
|
const model = menu.props('model') as MenuItem[]
|
||||||
|
expect(model[0]?.disabled).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -44,21 +44,21 @@
|
|||||||
<Button
|
<Button
|
||||||
v-tooltip.bottom="queueHistoryTooltipConfig"
|
v-tooltip.bottom="queueHistoryTooltipConfig"
|
||||||
type="destructive"
|
type="destructive"
|
||||||
size="icon"
|
size="md"
|
||||||
:aria-pressed="isQueueOverlayExpanded"
|
:aria-pressed="isQueueOverlayExpanded"
|
||||||
:aria-label="
|
class="px-3"
|
||||||
t('sideToolbar.queueProgressOverlay.expandCollapsedQueue')
|
data-testid="queue-overlay-toggle"
|
||||||
"
|
|
||||||
@click="toggleQueueOverlay"
|
@click="toggleQueueOverlay"
|
||||||
|
@contextmenu.stop.prevent="showQueueContextMenu"
|
||||||
>
|
>
|
||||||
<i class="icon-[lucide--history] size-4" />
|
<span class="text-sm font-normal tabular-nums">
|
||||||
<span
|
{{ activeJobsLabel }}
|
||||||
v-if="queuedCount > 0"
|
</span>
|
||||||
class="absolute -top-1 -right-1 min-w-[16px] rounded-full bg-primary-background py-0.25 text-[10px] font-medium leading-[14px] text-base-foreground"
|
<span class="sr-only">
|
||||||
>
|
{{ t('sideToolbar.queueProgressOverlay.expandCollapsedQueue') }}
|
||||||
{{ queuedCount }}
|
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
<ContextMenu ref="queueContextMenu" :model="queueContextMenuItems" />
|
||||||
<CurrentUserButton
|
<CurrentUserButton
|
||||||
v-if="isLoggedIn && !isIntegratedTabBar"
|
v-if="isLoggedIn && !isIntegratedTabBar"
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
@@ -86,6 +86,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
import ContextMenu from 'primevue/contextmenu'
|
||||||
|
import type { MenuItem } from 'primevue/menuitem'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
@@ -103,6 +105,7 @@ import { useSettingStore } from '@/platform/settings/settingStore'
|
|||||||
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
import { useReleaseStore } from '@/platform/updates/common/releaseStore'
|
||||||
import { app } from '@/scripts/app'
|
import { app } from '@/scripts/app'
|
||||||
import { useCommandStore } from '@/stores/commandStore'
|
import { useCommandStore } from '@/stores/commandStore'
|
||||||
|
import { useExecutionStore } from '@/stores/executionStore'
|
||||||
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
import { useQueueStore, useQueueUIStore } from '@/stores/queueStore'
|
||||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||||
@@ -117,18 +120,27 @@ const rightSidePanelStore = useRightSidePanelStore()
|
|||||||
const managerState = useManagerState()
|
const managerState = useManagerState()
|
||||||
const { isLoggedIn } = useCurrentUser()
|
const { isLoggedIn } = useCurrentUser()
|
||||||
const isDesktop = isElectron()
|
const isDesktop = isElectron()
|
||||||
const { t } = useI18n()
|
const { t, n } = useI18n()
|
||||||
const { toastErrorHandler } = useErrorHandling()
|
const { toastErrorHandler } = useErrorHandling()
|
||||||
const commandStore = useCommandStore()
|
const commandStore = useCommandStore()
|
||||||
const queueStore = useQueueStore()
|
const queueStore = useQueueStore()
|
||||||
|
const executionStore = useExecutionStore()
|
||||||
const queueUIStore = useQueueUIStore()
|
const queueUIStore = useQueueUIStore()
|
||||||
|
const { activeJobsCount } = storeToRefs(queueStore)
|
||||||
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
const { isOverlayExpanded: isQueueOverlayExpanded } = storeToRefs(queueUIStore)
|
||||||
const releaseStore = useReleaseStore()
|
const releaseStore = useReleaseStore()
|
||||||
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
const { shouldShowRedDot: showReleaseRedDot } = storeToRefs(releaseStore)
|
||||||
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
const { shouldShowRedDot: shouldShowConflictRedDot } =
|
||||||
useConflictAcknowledgment()
|
useConflictAcknowledgment()
|
||||||
const isTopMenuHovered = ref(false)
|
const isTopMenuHovered = ref(false)
|
||||||
const queuedCount = computed(() => queueStore.pendingTasks.length)
|
const activeJobsLabel = computed(() => {
|
||||||
|
const count = activeJobsCount.value
|
||||||
|
return t(
|
||||||
|
'sideToolbar.queueProgressOverlay.activeJobsShort',
|
||||||
|
{ count: n(count) },
|
||||||
|
count
|
||||||
|
)
|
||||||
|
})
|
||||||
const isIntegratedTabBar = computed(
|
const isIntegratedTabBar = computed(
|
||||||
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
() => settingStore.get('Comfy.UI.TabBarLayout') === 'Integrated'
|
||||||
)
|
)
|
||||||
@@ -138,6 +150,18 @@ const queueHistoryTooltipConfig = computed(() =>
|
|||||||
const customNodesManagerTooltipConfig = computed(() =>
|
const customNodesManagerTooltipConfig = computed(() =>
|
||||||
buildTooltipConfig(t('menu.customNodesManager'))
|
buildTooltipConfig(t('menu.customNodesManager'))
|
||||||
)
|
)
|
||||||
|
const queueContextMenu = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||||
|
const queueContextMenuItems = computed<MenuItem[]>(() => [
|
||||||
|
{
|
||||||
|
label: t('sideToolbar.queueProgressOverlay.clearQueueTooltip'),
|
||||||
|
icon: 'icon-[lucide--list-x] text-destructive-background',
|
||||||
|
class: '*:text-destructive-background',
|
||||||
|
disabled: queueStore.pendingTasks.length === 0,
|
||||||
|
command: () => {
|
||||||
|
void handleClearQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
// Use either release red dot or conflict red dot
|
// Use either release red dot or conflict red dot
|
||||||
const shouldShowRedDot = computed((): boolean => {
|
const shouldShowRedDot = computed((): boolean => {
|
||||||
@@ -164,6 +188,19 @@ const toggleQueueOverlay = () => {
|
|||||||
commandStore.execute('Comfy.Queue.ToggleOverlay')
|
commandStore.execute('Comfy.Queue.ToggleOverlay')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showQueueContextMenu = (event: MouseEvent) => {
|
||||||
|
queueContextMenu.value?.show(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClearQueue = async () => {
|
||||||
|
const pendingPromptIds = queueStore.pendingTasks
|
||||||
|
.map((task) => task.promptId)
|
||||||
|
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||||
|
|
||||||
|
await commandStore.execute('Comfy.ClearPendingTasks')
|
||||||
|
executionStore.clearInitializationByPromptIds(pendingPromptIds)
|
||||||
|
}
|
||||||
|
|
||||||
const openCustomNodeManager = async () => {
|
const openCustomNodeManager = async () => {
|
||||||
try {
|
try {
|
||||||
await managerState.openManager({
|
await managerState.openManager({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex h-full items-center">
|
<div class="flex h-full items-center" :class="cn(!isDocked && '-ml-2')">
|
||||||
<div
|
<div
|
||||||
v-if="isDragging && !isDocked"
|
v-if="isDragging && !isDocked"
|
||||||
:class="actionbarClass"
|
:class="actionbarClass"
|
||||||
@@ -77,7 +77,6 @@ const { isIdle: isExecutionIdle } = storeToRefs(useExecutionStore())
|
|||||||
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
const position = computed(() => settingsStore.get('Comfy.UseNewMenu'))
|
||||||
const visible = computed(() => position.value !== 'Disabled')
|
const visible = computed(() => position.value !== 'Disabled')
|
||||||
|
|
||||||
const tabContainer = document.querySelector('.workflow-tabs-container')
|
|
||||||
const panelRef = ref<HTMLElement | null>(null)
|
const panelRef = ref<HTMLElement | null>(null)
|
||||||
const dragHandleRef = ref<HTMLElement | null>(null)
|
const dragHandleRef = ref<HTMLElement | null>(null)
|
||||||
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
|
const isDocked = useLocalStorage('Comfy.MenuPosition.Docked', true)
|
||||||
@@ -88,14 +87,7 @@ const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
|
|||||||
const { x, y, style, isDragging } = useDraggable(panelRef, {
|
const { x, y, style, isDragging } = useDraggable(panelRef, {
|
||||||
initialValue: { x: 0, y: 0 },
|
initialValue: { x: 0, y: 0 },
|
||||||
handle: dragHandleRef,
|
handle: dragHandleRef,
|
||||||
containerElement: document.body,
|
containerElement: document.body
|
||||||
onMove: (event) => {
|
|
||||||
// Prevent dragging the menu over the top of the tabs
|
|
||||||
const minY = tabContainer?.getBoundingClientRect().bottom ?? 40
|
|
||||||
if (event.y < minY) {
|
|
||||||
event.y = minY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update storedPosition when x or y changes
|
// Update storedPosition when x or y changes
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createTestingPinia } from '@pinia/testing'
|
import { createTestingPinia } from '@pinia/testing'
|
||||||
import type { VueWrapper } from '@vue/test-utils'
|
import type { VueWrapper } from '@vue/test-utils'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
|
import type { Mock } from 'vitest'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
import { createI18n } from 'vue-i18n'
|
import { createI18n } from 'vue-i18n'
|
||||||
@@ -151,8 +152,8 @@ describe('BaseTerminal', () => {
|
|||||||
// Trigger the selection change callback that was registered during mount
|
// Trigger the selection change callback that was registered during mount
|
||||||
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
|
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
|
||||||
// Access the mock calls - TypeScript can't infer the mock structure dynamically
|
// Access the mock calls - TypeScript can't infer the mock structure dynamically
|
||||||
const selectionCallback = (mockTerminal.onSelectionChange as any).mock
|
const mockCalls = (mockTerminal.onSelectionChange as Mock).mock.calls
|
||||||
.calls[0][0]
|
const selectionCallback = mockCalls[0][0] as () => void
|
||||||
selectionCallback()
|
selectionCallback()
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
|
|||||||
82
src/components/boundingbox/WidgetBoundingBox.vue
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-[auto_1fr] gap-x-2 gap-y-1">
|
||||||
|
<label class="content-center text-xs text-node-component-slot-text">
|
||||||
|
{{ $t('boundingBox.x') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="x"
|
||||||
|
type="number"
|
||||||
|
:min="0"
|
||||||
|
step="1"
|
||||||
|
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||||
|
/>
|
||||||
|
<label class="content-center text-xs text-node-component-slot-text">
|
||||||
|
{{ $t('boundingBox.y') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="y"
|
||||||
|
type="number"
|
||||||
|
:min="0"
|
||||||
|
step="1"
|
||||||
|
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||||
|
/>
|
||||||
|
<label class="content-center text-xs text-node-component-slot-text">
|
||||||
|
{{ $t('boundingBox.width') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="width"
|
||||||
|
type="number"
|
||||||
|
:min="1"
|
||||||
|
step="1"
|
||||||
|
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||||
|
/>
|
||||||
|
<label class="content-center text-xs text-node-component-slot-text">
|
||||||
|
{{ $t('boundingBox.height') }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="height"
|
||||||
|
type="number"
|
||||||
|
:min="1"
|
||||||
|
step="1"
|
||||||
|
class="h-7 rounded-lg border-none bg-component-node-widget-background px-2 text-xs text-component-node-foreground focus:outline-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type { Bounds } from '@/renderer/core/layout/types'
|
||||||
|
|
||||||
|
const modelValue = defineModel<Bounds>({
|
||||||
|
default: () => ({ x: 0, y: 0, width: 512, height: 512 })
|
||||||
|
})
|
||||||
|
|
||||||
|
const x = computed({
|
||||||
|
get: () => modelValue.value.x,
|
||||||
|
set: (x) => {
|
||||||
|
modelValue.value = { ...modelValue.value, x }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const y = computed({
|
||||||
|
get: () => modelValue.value.y,
|
||||||
|
set: (y) => {
|
||||||
|
modelValue.value = { ...modelValue.value, y }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const width = computed({
|
||||||
|
get: () => modelValue.value.width,
|
||||||
|
set: (width) => {
|
||||||
|
modelValue.value = { ...modelValue.value, width }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const height = computed({
|
||||||
|
get: () => modelValue.value.height,
|
||||||
|
set: (height) => {
|
||||||
|
modelValue.value = { ...modelValue.value, height }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -51,7 +51,7 @@ describe('EditableText', () => {
|
|||||||
isEditing: true
|
isEditing: true
|
||||||
})
|
})
|
||||||
await wrapper.findComponent(InputText).setValue('New Text')
|
await wrapper.findComponent(InputText).setValue('New Text')
|
||||||
await wrapper.findComponent(InputText).trigger('keyup.enter')
|
await wrapper.findComponent(InputText).trigger('keydown.enter')
|
||||||
// Blur event should have been triggered
|
// Blur event should have been triggered
|
||||||
expect(wrapper.findComponent(InputText).element).not.toBe(
|
expect(wrapper.findComponent(InputText).element).not.toBe(
|
||||||
document.activeElement
|
document.activeElement
|
||||||
@@ -79,7 +79,7 @@ describe('EditableText', () => {
|
|||||||
await wrapper.findComponent(InputText).setValue('Modified Text')
|
await wrapper.findComponent(InputText).setValue('Modified Text')
|
||||||
|
|
||||||
// Press escape
|
// Press escape
|
||||||
await wrapper.findComponent(InputText).trigger('keyup.escape')
|
await wrapper.findComponent(InputText).trigger('keydown.escape')
|
||||||
|
|
||||||
// Should emit cancel event
|
// Should emit cancel event
|
||||||
expect(wrapper.emitted('cancel')).toBeTruthy()
|
expect(wrapper.emitted('cancel')).toBeTruthy()
|
||||||
@@ -103,7 +103,7 @@ describe('EditableText', () => {
|
|||||||
await wrapper.findComponent(InputText).setValue('Modified Text')
|
await wrapper.findComponent(InputText).setValue('Modified Text')
|
||||||
|
|
||||||
// Press escape (which triggers blur internally)
|
// Press escape (which triggers blur internally)
|
||||||
await wrapper.findComponent(InputText).trigger('keyup.escape')
|
await wrapper.findComponent(InputText).trigger('keydown.escape')
|
||||||
|
|
||||||
// Manually trigger blur to simulate the blur that happens after escape
|
// Manually trigger blur to simulate the blur that happens after escape
|
||||||
await wrapper.findComponent(InputText).trigger('blur')
|
await wrapper.findComponent(InputText).trigger('blur')
|
||||||
@@ -120,7 +120,7 @@ describe('EditableText', () => {
|
|||||||
isEditing: true
|
isEditing: true
|
||||||
})
|
})
|
||||||
await enterWrapper.findComponent(InputText).setValue('Saved Text')
|
await enterWrapper.findComponent(InputText).setValue('Saved Text')
|
||||||
await enterWrapper.findComponent(InputText).trigger('keyup.enter')
|
await enterWrapper.findComponent(InputText).trigger('keydown.enter')
|
||||||
// Trigger blur that happens after enter
|
// Trigger blur that happens after enter
|
||||||
await enterWrapper.findComponent(InputText).trigger('blur')
|
await enterWrapper.findComponent(InputText).trigger('blur')
|
||||||
expect(enterWrapper.emitted('edit')).toBeTruthy()
|
expect(enterWrapper.emitted('edit')).toBeTruthy()
|
||||||
@@ -133,7 +133,7 @@ describe('EditableText', () => {
|
|||||||
isEditing: true
|
isEditing: true
|
||||||
})
|
})
|
||||||
await escapeWrapper.findComponent(InputText).setValue('Cancelled Text')
|
await escapeWrapper.findComponent(InputText).setValue('Cancelled Text')
|
||||||
await escapeWrapper.findComponent(InputText).trigger('keyup.escape')
|
await escapeWrapper.findComponent(InputText).trigger('keydown.escape')
|
||||||
expect(escapeWrapper.emitted('cancel')).toBeTruthy()
|
expect(escapeWrapper.emitted('cancel')).toBeTruthy()
|
||||||
expect(escapeWrapper.emitted('edit')).toBeFalsy()
|
expect(escapeWrapper.emitted('edit')).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<span v-if="!isEditing">
|
<span v-if="!isEditing">
|
||||||
{{ modelValue }}
|
{{ modelValue }}
|
||||||
</span>
|
</span>
|
||||||
<!-- Avoid double triggering finishEditing event when keyup.enter is triggered -->
|
<!-- Avoid double triggering finishEditing event when keydown.enter is triggered -->
|
||||||
<InputText
|
<InputText
|
||||||
v-else
|
v-else
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
...inputAttrs
|
...inputAttrs
|
||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
@keyup.enter.capture.stop="blurInputElement"
|
@keydown.enter.capture.stop="blurInputElement"
|
||||||
@keyup.escape.stop="cancelEditing"
|
@keydown.escape.capture.stop="cancelEditing"
|
||||||
@click.stop
|
@click.stop
|
||||||
@contextmenu.stop
|
@contextmenu.stop
|
||||||
@pointerdown.stop.capture
|
@pointerdown.stop.capture
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { createApp } from 'vue'
|
|||||||
import type { SettingOption } from '@/platform/settings/types'
|
import type { SettingOption } from '@/platform/settings/types'
|
||||||
|
|
||||||
import FormRadioGroup from './FormRadioGroup.vue'
|
import FormRadioGroup from './FormRadioGroup.vue'
|
||||||
|
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||||
|
|
||||||
describe('FormRadioGroup', () => {
|
describe('FormRadioGroup', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -14,7 +15,8 @@ describe('FormRadioGroup', () => {
|
|||||||
app.use(PrimeVue)
|
app.use(PrimeVue)
|
||||||
})
|
})
|
||||||
|
|
||||||
const mountComponent = (props: any, options = {}) => {
|
type FormRadioGroupProps = ComponentProps<typeof FormRadioGroup>
|
||||||
|
const mountComponent = (props: FormRadioGroupProps, options = {}) => {
|
||||||
return mount(FormRadioGroup, {
|
return mount(FormRadioGroup, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [PrimeVue],
|
plugins: [PrimeVue],
|
||||||
@@ -92,9 +94,9 @@ describe('FormRadioGroup', () => {
|
|||||||
|
|
||||||
it('handles custom object with optionLabel and optionValue', () => {
|
it('handles custom object with optionLabel and optionValue', () => {
|
||||||
const options = [
|
const options = [
|
||||||
{ name: 'First Option', id: 1 },
|
{ name: 'First Option', id: '1' },
|
||||||
{ name: 'Second Option', id: 2 },
|
{ name: 'Second Option', id: '2' },
|
||||||
{ name: 'Third Option', id: 3 }
|
{ name: 'Third Option', id: '3' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const wrapper = mountComponent({
|
const wrapper = mountComponent({
|
||||||
@@ -108,9 +110,9 @@ describe('FormRadioGroup', () => {
|
|||||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||||
expect(radioButtons).toHaveLength(3)
|
expect(radioButtons).toHaveLength(3)
|
||||||
|
|
||||||
expect(radioButtons[0].props('value')).toBe(1)
|
expect(radioButtons[0].props('value')).toBe('1')
|
||||||
expect(radioButtons[1].props('value')).toBe(2)
|
expect(radioButtons[1].props('value')).toBe('2')
|
||||||
expect(radioButtons[2].props('value')).toBe(3)
|
expect(radioButtons[2].props('value')).toBe('3')
|
||||||
|
|
||||||
const labels = wrapper.findAll('label')
|
const labels = wrapper.findAll('label')
|
||||||
expect(labels[0].text()).toBe('First Option')
|
expect(labels[0].text()).toBe('First Option')
|
||||||
@@ -167,10 +169,7 @@ describe('FormRadioGroup', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('handles object with missing properties gracefully', () => {
|
it('handles object with missing properties gracefully', () => {
|
||||||
const options = [
|
const options = [{ label: 'Option 1', val: 'opt1' }]
|
||||||
{ label: 'Option 1', val: 'opt1' },
|
|
||||||
{ text: 'Option 2', value: 'opt2' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const wrapper = mountComponent({
|
const wrapper = mountComponent({
|
||||||
modelValue: 'opt1',
|
modelValue: 'opt1',
|
||||||
@@ -179,11 +178,10 @@ describe('FormRadioGroup', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const radioButtons = wrapper.findAllComponents(RadioButton)
|
const radioButtons = wrapper.findAllComponents(RadioButton)
|
||||||
expect(radioButtons).toHaveLength(2)
|
expect(radioButtons).toHaveLength(1)
|
||||||
|
|
||||||
const labels = wrapper.findAll('label')
|
const labels = wrapper.findAll('label')
|
||||||
expect(labels[0].text()).toBe('Unknown')
|
expect(labels[0].text()).toBe('Unknown')
|
||||||
expect(labels[1].text()).toBe('Option 2')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import type { SettingOption } from '@/platform/settings/types'
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: any
|
modelValue: any
|
||||||
options: (SettingOption | string)[]
|
options?: (string | SettingOption | Record<string, string>)[]
|
||||||
optionLabel?: string
|
optionLabel?: string
|
||||||
optionValue?: string
|
optionValue?: string
|
||||||
id?: string
|
id?: string
|
||||||
|
|||||||
95
src/components/common/StatusBadge.stories.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
|
import StatusBadge from './StatusBadge.vue'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Common/StatusBadge',
|
||||||
|
component: StatusBadge,
|
||||||
|
tags: ['autodocs'],
|
||||||
|
argTypes: {
|
||||||
|
label: { control: 'text' },
|
||||||
|
severity: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['label', 'dot', 'circle']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
label: 'Status',
|
||||||
|
severity: 'default'
|
||||||
|
}
|
||||||
|
} satisfies Meta<typeof StatusBadge>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {}
|
||||||
|
|
||||||
|
export const Failed: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Failed',
|
||||||
|
severity: 'danger'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Finished: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Finished',
|
||||||
|
severity: 'contrast'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Dot: Story = {
|
||||||
|
args: {
|
||||||
|
label: undefined,
|
||||||
|
variant: 'dot',
|
||||||
|
severity: 'danger'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Circle: Story = {
|
||||||
|
args: {
|
||||||
|
label: '3',
|
||||||
|
variant: 'circle'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllSeverities: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { StatusBadge },
|
||||||
|
template: `
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<StatusBadge label="Default" severity="default" />
|
||||||
|
<StatusBadge label="Secondary" severity="secondary" />
|
||||||
|
<StatusBadge label="Warn" severity="warn" />
|
||||||
|
<StatusBadge label="Danger" severity="danger" />
|
||||||
|
<StatusBadge label="Contrast" severity="contrast" />
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AllVariants: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { StatusBadge },
|
||||||
|
template: `
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<div class="flex flex-col items-center gap-1">
|
||||||
|
<StatusBadge label="Label" variant="label" />
|
||||||
|
<span class="text-xs text-muted">label</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center gap-1">
|
||||||
|
<StatusBadge variant="dot" severity="danger" />
|
||||||
|
<span class="text-xs text-muted">dot</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-center gap-1">
|
||||||
|
<StatusBadge label="5" variant="circle" />
|
||||||
|
<span class="text-xs text-muted">circle</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,30 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
type Severity = 'default' | 'secondary' | 'warn' | 'danger' | 'contrast'
|
import { statusBadgeVariants } from './statusBadge.variants'
|
||||||
|
import type { StatusBadgeVariants } from './statusBadge.variants'
|
||||||
|
|
||||||
const { label, severity = 'default' } = defineProps<{
|
const {
|
||||||
label: string
|
label,
|
||||||
severity?: Severity
|
severity = 'default',
|
||||||
|
variant
|
||||||
|
} = defineProps<{
|
||||||
|
label?: string | number
|
||||||
|
severity?: StatusBadgeVariants['severity']
|
||||||
|
variant?: StatusBadgeVariants['variant']
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function badgeClasses(sev: Severity): string {
|
|
||||||
const baseClasses =
|
|
||||||
'inline-flex h-3.5 items-center justify-center rounded-full px-1 text-xxxs font-semibold uppercase'
|
|
||||||
|
|
||||||
switch (sev) {
|
|
||||||
case 'danger':
|
|
||||||
return `${baseClasses} bg-destructive-background text-white`
|
|
||||||
case 'contrast':
|
|
||||||
return `${baseClasses} bg-base-foreground text-base-background`
|
|
||||||
case 'warn':
|
|
||||||
return `${baseClasses} bg-warning-background text-base-background`
|
|
||||||
case 'secondary':
|
|
||||||
return `${baseClasses} bg-secondary-background text-base-foreground`
|
|
||||||
default:
|
|
||||||
return `${baseClasses} bg-primary-background text-base-foreground`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span :class="badgeClasses(severity)">{{ label }}</span>
|
<span
|
||||||
|
:class="
|
||||||
|
statusBadgeVariants({
|
||||||
|
severity,
|
||||||
|
variant: variant ?? (label == null ? 'dot' : 'label')
|
||||||
|
})
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="node-actions touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
|
class="node-actions flex gap-1 touch:opacity-100 motion-safe:opacity-0 motion-safe:group-hover/tree-node:opacity-100"
|
||||||
>
|
>
|
||||||
<slot name="actions" :node="props.node" />
|
<slot name="actions" :node="props.node" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
|||||||
import { createApp, nextTick } from 'vue'
|
import { createApp, nextTick } from 'vue'
|
||||||
|
|
||||||
import UrlInput from './UrlInput.vue'
|
import UrlInput from './UrlInput.vue'
|
||||||
|
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||||
|
|
||||||
describe('UrlInput', () => {
|
describe('UrlInput', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -14,7 +15,13 @@ describe('UrlInput', () => {
|
|||||||
app.use(PrimeVue)
|
app.use(PrimeVue)
|
||||||
})
|
})
|
||||||
|
|
||||||
const mountComponent = (props: any, options = {}) => {
|
const mountComponent = (
|
||||||
|
props: ComponentProps<typeof UrlInput> & {
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
},
|
||||||
|
options = {}
|
||||||
|
) => {
|
||||||
return mount(UrlInput, {
|
return mount(UrlInput, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [PrimeVue],
|
plugins: [PrimeVue],
|
||||||
@@ -169,25 +176,25 @@ describe('UrlInput', () => {
|
|||||||
await input.setValue(' https://leading-space.com')
|
await input.setValue(' https://leading-space.com')
|
||||||
await input.trigger('input')
|
await input.trigger('input')
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(wrapper.vm.internalValue).toBe('https://leading-space.com')
|
expect(input.element.value).toBe('https://leading-space.com')
|
||||||
|
|
||||||
// Test trailing whitespace
|
// Test trailing whitespace
|
||||||
await input.setValue('https://trailing-space.com ')
|
await input.setValue('https://trailing-space.com ')
|
||||||
await input.trigger('input')
|
await input.trigger('input')
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(wrapper.vm.internalValue).toBe('https://trailing-space.com')
|
expect(input.element.value).toBe('https://trailing-space.com')
|
||||||
|
|
||||||
// Test both leading and trailing whitespace
|
// Test both leading and trailing whitespace
|
||||||
await input.setValue(' https://both-spaces.com ')
|
await input.setValue(' https://both-spaces.com ')
|
||||||
await input.trigger('input')
|
await input.trigger('input')
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(wrapper.vm.internalValue).toBe('https://both-spaces.com')
|
expect(input.element.value).toBe('https://both-spaces.com')
|
||||||
|
|
||||||
// Test whitespace in the middle of the URL
|
// Test whitespace in the middle of the URL
|
||||||
await input.setValue('https:// middle-space.com')
|
await input.setValue('https:// middle-space.com')
|
||||||
await input.trigger('input')
|
await input.trigger('input')
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(wrapper.vm.internalValue).toBe('https://middle-space.com')
|
expect(input.element.value).toBe('https://middle-space.com')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('trims whitespace when value set externally', async () => {
|
it('trims whitespace when value set externally', async () => {
|
||||||
@@ -196,15 +203,17 @@ describe('UrlInput', () => {
|
|||||||
placeholder: 'Enter URL'
|
placeholder: 'Enter URL'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const input = wrapper.find('input')
|
||||||
|
|
||||||
// Check initial value is trimmed
|
// Check initial value is trimmed
|
||||||
expect(wrapper.vm.internalValue).toBe('https://initial-value.com')
|
expect(input.element.value).toBe('https://initial-value.com')
|
||||||
|
|
||||||
// Update props with whitespace
|
// Update props with whitespace
|
||||||
await wrapper.setProps({ modelValue: ' https://updated-value.com ' })
|
await wrapper.setProps({ modelValue: ' https://updated-value.com ' })
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
// Check updated value is trimmed
|
// Check updated value is trimmed
|
||||||
expect(wrapper.vm.internalValue).toBe('https://updated-value.com')
|
expect(input.element.value).toBe('https://updated-value.com')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||||
|
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount } from '@vue/test-utils'
|
||||||
import Avatar from 'primevue/avatar'
|
import Avatar from 'primevue/avatar'
|
||||||
import PrimeVue from 'primevue/config'
|
import PrimeVue from 'primevue/config'
|
||||||
@@ -27,7 +29,7 @@ describe('UserAvatar', () => {
|
|||||||
app.use(PrimeVue)
|
app.use(PrimeVue)
|
||||||
})
|
})
|
||||||
|
|
||||||
const mountComponent = (props: any = {}) => {
|
const mountComponent = (props: ComponentProps<typeof UserAvatar> = {}) => {
|
||||||
return mount(UserAvatar, {
|
return mount(UserAvatar, {
|
||||||
global: {
|
global: {
|
||||||
plugins: [PrimeVue, i18n],
|
plugins: [PrimeVue, i18n],
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="container" class="scroll-container">
|
<div
|
||||||
<div :style="{ height: `${(state.start / cols) * itemHeight}px` }" />
|
ref="container"
|
||||||
<div :style="gridStyle">
|
class="h-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-(--dialog-surface)"
|
||||||
<div v-for="item in renderedItems" :key="item.key" data-virtual-grid-item>
|
>
|
||||||
|
<div :style="topSpacerStyle" />
|
||||||
|
<div :style="mergedGridStyle">
|
||||||
|
<div
|
||||||
|
v-for="item in renderedItems"
|
||||||
|
:key="item.key"
|
||||||
|
class="transition-[width] duration-150 ease-out"
|
||||||
|
data-virtual-grid-item
|
||||||
|
>
|
||||||
<slot name="item" :item="item" />
|
<slot name="item" :item="item" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div :style="bottomSpacerStyle" />
|
||||||
:style="{
|
|
||||||
height: `${((items.length - state.end) / cols) * itemHeight}px`
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -28,19 +32,22 @@ type GridState = {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
items,
|
items,
|
||||||
|
gridStyle,
|
||||||
bufferRows = 1,
|
bufferRows = 1,
|
||||||
scrollThrottle = 64,
|
scrollThrottle = 64,
|
||||||
resizeDebounce = 64,
|
resizeDebounce = 64,
|
||||||
defaultItemHeight = 200,
|
defaultItemHeight = 200,
|
||||||
defaultItemWidth = 200
|
defaultItemWidth = 200,
|
||||||
|
maxColumns = Infinity
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
items: (T & { key: string })[]
|
items: (T & { key: string })[]
|
||||||
gridStyle: Partial<CSSProperties>
|
gridStyle: CSSProperties
|
||||||
bufferRows?: number
|
bufferRows?: number
|
||||||
scrollThrottle?: number
|
scrollThrottle?: number
|
||||||
resizeDebounce?: number
|
resizeDebounce?: number
|
||||||
defaultItemHeight?: number
|
defaultItemHeight?: number
|
||||||
defaultItemWidth?: number
|
defaultItemWidth?: number
|
||||||
|
maxColumns?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -59,7 +66,18 @@ const { y: scrollY } = useScroll(container, {
|
|||||||
eventListenerOptions: { passive: true }
|
eventListenerOptions: { passive: true }
|
||||||
})
|
})
|
||||||
|
|
||||||
const cols = computed(() => Math.floor(width.value / itemWidth.value) || 1)
|
const cols = computed(() =>
|
||||||
|
Math.min(Math.floor(width.value / itemWidth.value) || 1, maxColumns)
|
||||||
|
)
|
||||||
|
|
||||||
|
const mergedGridStyle = computed<CSSProperties>(() => {
|
||||||
|
if (maxColumns === Infinity) return gridStyle
|
||||||
|
return {
|
||||||
|
...gridStyle,
|
||||||
|
gridTemplateColumns: `repeat(${maxColumns}, minmax(0, 1fr))`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
|
const viewRows = computed(() => Math.ceil(height.value / itemHeight.value))
|
||||||
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
|
const offsetRows = computed(() => Math.floor(scrollY.value / itemHeight.value))
|
||||||
const isValidGrid = computed(() => height.value && width.value && items?.length)
|
const isValidGrid = computed(() => height.value && width.value && items?.length)
|
||||||
@@ -83,6 +101,16 @@ const renderedItems = computed(() =>
|
|||||||
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
|
isValidGrid.value ? items.slice(state.value.start, state.value.end) : []
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function rowsToHeight(rows: number): string {
|
||||||
|
return `${(rows / cols.value) * itemHeight.value}px`
|
||||||
|
}
|
||||||
|
const topSpacerStyle = computed<CSSProperties>(() => ({
|
||||||
|
height: rowsToHeight(state.value.start)
|
||||||
|
}))
|
||||||
|
const bottomSpacerStyle = computed<CSSProperties>(() => ({
|
||||||
|
height: rowsToHeight(items.length - state.value.end)
|
||||||
|
}))
|
||||||
|
|
||||||
whenever(
|
whenever(
|
||||||
() => state.value.isNearEnd,
|
() => state.value.isNearEnd,
|
||||||
() => {
|
() => {
|
||||||
@@ -109,15 +137,6 @@ const onResize = debounce(updateItemSize, resizeDebounce)
|
|||||||
watch([width, height], onResize, { flush: 'post' })
|
watch([width, height], onResize, { flush: 'post' })
|
||||||
whenever(() => items, updateItemSize, { flush: 'post' })
|
whenever(() => items, updateItemSize, { flush: 'post' })
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
onResize.cancel() // Clear pending debounced calls
|
onResize.cancel()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.scroll-container {
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--dialog-surface) transparent;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||