Merge branch 'main' into bl-selective-snapshot-update
Resolved conflicts in update-playwright-expectations.yaml by: - Keeping main's improvements: concurrency control, comment reactions, better branch checkout - Keeping our selective snapshot update logic with validation - Keeping our workflow summary generation - Combined both sets of improvements for a robust solution Fixed eslint configuration issue where vite.config.mts was in both allowDefaultProject and tsconfig.json
55
.github/actions/setup-comfyui-server/action.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Setup ComfyUI Server
|
||||
description: 'Setup ComfyUI server for continuous integration (with ComfyUI_devtools node installed)'
|
||||
inputs:
|
||||
extra_server_params:
|
||||
description: 'Additional parameters to pass to ComfyUI server'
|
||||
required: false
|
||||
default: ''
|
||||
launch_server:
|
||||
description: 'Whether to launch the server after setup'
|
||||
required: false
|
||||
default: 'false'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
# Note: this workflow assume frontend repo is checked out and is built in ../dist
|
||||
|
||||
# Checkout ComfyUI repo, install the dev_tools node and start server
|
||||
- name: Checkout ComfyUI
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: 'comfyanonymous/ComfyUI'
|
||||
path: 'ComfyUI'
|
||||
|
||||
- name: Install ComfyUI_devtools from frontend repo
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
|
||||
if ! cp -r ./tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/; then
|
||||
echo "::error::Failed to copy ComfyUI_devtools from ./tools/devtools/"
|
||||
echo "::error::This action assumes the ComfyUI_frontend repository is checked out in the current working directory."
|
||||
echo "::error::Please ensure you have run 'actions/checkout@v5' before calling this action."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install Python requirements
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements.txt
|
||||
pip install wait-for-it
|
||||
|
||||
- name: Start ComfyUI server
|
||||
if: ${{ inputs.launch_server == 'true' }}
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
run: |
|
||||
python main.py --cpu --multi-user --front-end-root ../dist ${{ inputs.extra_server_params }} &
|
||||
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||
68
.github/actions/setup-frontend/action.yml
vendored
@@ -1,31 +1,16 @@
|
||||
name: Setup Frontend
|
||||
description: 'Setup ComfyUI frontend development environment'
|
||||
name: Setup ComfyUI Frontend
|
||||
description: 'Install nodejs/pnpm/dependencies and optionally build ComfyUI_frontend'
|
||||
inputs:
|
||||
extra_server_params:
|
||||
description: 'Additional parameters to pass to ComfyUI server'
|
||||
include_build_step:
|
||||
description: 'Include the build step to build the frontend. Set to true for workflows that need a built frontend'
|
||||
required: false
|
||||
default: ''
|
||||
default: 'false'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Checkout ComfyUI
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'comfyanonymous/ComfyUI'
|
||||
path: 'ComfyUI'
|
||||
|
||||
- name: Checkout ComfyUI_frontend
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'Comfy-Org/ComfyUI_frontend'
|
||||
path: 'ComfyUI_frontend'
|
||||
|
||||
- name: Copy ComfyUI_devtools from frontend repo
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
|
||||
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
# Note: this workflow assume frontend repo is checked out in the root of the workspace
|
||||
|
||||
# Install pnpm, Node.js, build frontend
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
@@ -36,32 +21,25 @@ runs:
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'ComfyUI_frontend/pnpm-lock.yaml'
|
||||
cache-dependency-path: './pnpm-lock.yaml'
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
# Restore tool caches before running any build/lint operations
|
||||
- name: Restore tool output cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
path: |
|
||||
./.cache
|
||||
./tsconfig.tsbuildinfo
|
||||
key: tool-cache-${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml') }}-${{ hashFiles('./src/**/*.{ts,vue,js,mts}', './*.config.*') }}
|
||||
restore-keys: |
|
||||
tool-cache-${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml') }}-
|
||||
tool-cache-${{ runner.os }}-
|
||||
|
||||
- name: Install Python requirements
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements.txt
|
||||
pip install wait-for-it
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build & Install ComfyUI_frontend
|
||||
- name: Build ComfyUI_frontend
|
||||
if: ${{ inputs.include_build_step == 'true' }}
|
||||
shell: bash
|
||||
working-directory: ComfyUI_frontend
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
|
||||
- name: Start ComfyUI server
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
run: |
|
||||
python main.py --cpu --multi-user --front-end-root ../ComfyUI_frontend/dist ${{ inputs.extra_server_params }} &
|
||||
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||
run: pnpm build
|
||||
|
||||
3
.github/actions/setup-playwright/action.yml
vendored
@@ -6,7 +6,6 @@ runs:
|
||||
- name: Detect Playwright version
|
||||
id: detect-version
|
||||
shell: bash
|
||||
working-directory: ComfyUI_frontend
|
||||
run: |
|
||||
PLAYWRIGHT_VERSION=$(pnpm ls @playwright/test --json | jq --raw-output '.[0].devDependencies["@playwright/test"].version')
|
||||
echo "playwright-version=$PLAYWRIGHT_VERSION" >> $GITHUB_OUTPUT
|
||||
@@ -22,10 +21,8 @@ runs:
|
||||
if: steps.cache-playwright-browsers.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: pnpm exec playwright install chromium --with-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- name: Install Playwright Browsers (operating system dependencies)
|
||||
if: steps.cache-playwright-browsers.outputs.cache-hit == 'true'
|
||||
shell: bash
|
||||
run: pnpm exec playwright install-deps
|
||||
working-directory: ComfyUI_frontend
|
||||
18
.github/workflows/claude-pr-review.yml
vendored
@@ -11,6 +11,10 @@ on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
wait-for-ci:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -73,10 +77,10 @@ jobs:
|
||||
with:
|
||||
label_trigger: "claude-review"
|
||||
prompt: |
|
||||
Read the file .claude/commands/comprehensive-pr-review.md and follow ALL the instructions exactly.
|
||||
|
||||
CRITICAL: You must post individual inline comments using the gh api commands shown in the file.
|
||||
DO NOT create a summary comment.
|
||||
Read the file .claude/commands/comprehensive-pr-review.md and follow ALL the instructions exactly.
|
||||
|
||||
CRITICAL: You must post individual inline comments using the gh api commands shown in the file.
|
||||
DO NOT create a summary comment.
|
||||
Each issue must be posted as a separate inline comment on the specific line of code.
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
claude_args: "--max-turns 256 --allowedTools 'Bash(git:*),Bash(gh api:*),Bash(gh pr:*),Bash(gh repo:*),Bash(jq:*),Bash(echo:*),Read,Write,Edit,Glob,Grep,WebFetch'"
|
||||
@@ -86,3 +90,9 @@ jobs:
|
||||
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
|
||||
- name: Remove claude-review label
|
||||
if: always()
|
||||
run: gh pr edit ${{ github.event.pull_request.number }} --remove-label "claude-review"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
4
.github/workflows/lint-and-format.yaml
vendored
@@ -4,6 +4,10 @@ on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
203
.github/workflows/tests-ci.yaml
vendored
@@ -7,70 +7,37 @@ on:
|
||||
branches-ignore:
|
||||
[wip/*, draft/*, temp/*, vue-nodes-migration, sno-playwright-*]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
cache-key: ${{ steps.cache-key.outputs.key }}
|
||||
steps:
|
||||
- name: Checkout ComfyUI
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# Setup Test Environment, build frontend but do not start server yet
|
||||
- name: Setup ComfyUI server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
repository: 'comfyanonymous/ComfyUI'
|
||||
path: 'ComfyUI'
|
||||
ref: master
|
||||
|
||||
- name: Checkout ComfyUI_frontend
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: 'Comfy-Org/ComfyUI_frontend'
|
||||
path: 'ComfyUI_frontend'
|
||||
|
||||
- name: Copy ComfyUI_devtools from frontend repo
|
||||
run: |
|
||||
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
|
||||
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'ComfyUI_frontend/pnpm-lock.yaml'
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
ComfyUI_frontend/.cache
|
||||
ComfyUI_frontend/tsconfig.tsbuildinfo
|
||||
key: playwright-setup-cache-${{ runner.os }}-${{ hashFiles('ComfyUI_frontend/pnpm-lock.yaml') }}-${{ hashFiles('ComfyUI_frontend/src/**/*.{ts,vue,js}', 'ComfyUI_frontend/*.config.*') }}
|
||||
restore-keys: |
|
||||
playwright-setup-cache-${{ runner.os }}-${{ hashFiles('ComfyUI_frontend/pnpm-lock.yaml') }}-
|
||||
playwright-setup-cache-${{ runner.os }}-
|
||||
playwright-tools-cache-${{ runner.os }}-
|
||||
|
||||
- name: Build ComfyUI_frontend
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
working-directory: ComfyUI_frontend
|
||||
include_build_step: true
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright # Setup Playwright and cache browsers
|
||||
|
||||
# Save the entire workspace as cache for later test jobs to restore
|
||||
- name: Generate cache key
|
||||
id: cache-key
|
||||
run: echo "key=$(date +%s)" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
- name: Save cache
|
||||
uses: actions/cache/save@5a3ec84eff668545956fd18022155c47e93e2684
|
||||
with:
|
||||
path: |
|
||||
ComfyUI
|
||||
ComfyUI_frontend
|
||||
path: .
|
||||
key: comfyui-setup-${{ steps.cache-key.outputs.key }}
|
||||
|
||||
# Sharded chromium tests
|
||||
@@ -85,54 +52,35 @@ jobs:
|
||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shardTotal: [8]
|
||||
steps:
|
||||
# download built frontend repo from setup job
|
||||
- name: Wait for cache propagation
|
||||
run: sleep 10
|
||||
|
||||
- name: Restore cached setup
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
|
||||
with:
|
||||
fail-on-cache-miss: true
|
||||
path: |
|
||||
ComfyUI
|
||||
ComfyUI_frontend
|
||||
path: .
|
||||
key: comfyui-setup-${{ needs.setup.outputs.cache-key }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
# Setup Test Environment for this runner, start server, use cached built frontend ./dist from 'setup' job
|
||||
- name: Setup ComfyUI server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install requirements
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements.txt
|
||||
pip install wait-for-it
|
||||
working-directory: ComfyUI
|
||||
|
||||
|
||||
launch_server: true
|
||||
- name: Setup nodejs, pnpm, reuse built frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
- name: Setup Playwright
|
||||
uses: ./ComfyUI_frontend/.github/actions/setup-playwright
|
||||
|
||||
- name: Start ComfyUI server
|
||||
run: |
|
||||
python main.py --cpu --multi-user --front-end-root ../ComfyUI_frontend/dist &
|
||||
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||
working-directory: ComfyUI
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
# Run sharded tests and upload sharded reports
|
||||
- name: Run Playwright tests (Shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
id: playwright
|
||||
run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
|
||||
working-directory: ComfyUI_frontend
|
||||
env:
|
||||
PLAYWRIGHT_BLOB_OUTPUT_DIR: ../blob-report
|
||||
PLAYWRIGHT_BLOB_OUTPUT_DIR: ./blob-report
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- name: Upload blob report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: blob-report-chromium-${{ matrix.shardIndex }}
|
||||
@@ -151,45 +99,27 @@ jobs:
|
||||
matrix:
|
||||
browser: [chromium-2x, chromium-0.5x, mobile-chrome]
|
||||
steps:
|
||||
# download built frontend repo from setup job
|
||||
- name: Wait for cache propagation
|
||||
run: sleep 10
|
||||
|
||||
- name: Restore cached setup
|
||||
uses: actions/cache/restore@v4
|
||||
uses: actions/cache/restore@5a3ec84eff668545956fd18022155c47e93e2684
|
||||
with:
|
||||
fail-on-cache-miss: true
|
||||
path: |
|
||||
ComfyUI
|
||||
ComfyUI_frontend
|
||||
path: .
|
||||
key: comfyui-setup-${{ needs.setup.outputs.cache-key }}
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
# Setup Test Environment for this runner, start server, use cached built frontend ./dist from 'setup' job
|
||||
- name: Setup ComfyUI server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install requirements
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements.txt
|
||||
pip install wait-for-it
|
||||
working-directory: ComfyUI
|
||||
|
||||
launch_server: true
|
||||
- name: Setup nodejs, pnpm, reuse built frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
- name: Setup Playwright
|
||||
uses: ./ComfyUI_frontend/.github/actions/setup-playwright
|
||||
|
||||
- name: Start ComfyUI server
|
||||
run: |
|
||||
python main.py --cpu --multi-user --front-end-root ../ComfyUI_frontend/dist &
|
||||
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||
working-directory: ComfyUI
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
# Run tests and upload reports
|
||||
- name: Run Playwright tests (${{ matrix.browser }})
|
||||
id: playwright
|
||||
run: |
|
||||
@@ -199,13 +129,13 @@ jobs:
|
||||
--reporter=list \
|
||||
--reporter=html \
|
||||
--reporter=json
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report-${{ matrix.browser }}
|
||||
path: ComfyUI_frontend/playwright-report/
|
||||
path: ./playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
# Merge sharded test reports
|
||||
@@ -214,32 +144,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ always() && !cancelled() }}
|
||||
steps:
|
||||
- name: Checkout ComfyUI_frontend
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: 'Comfy-Org/ComfyUI_frontend'
|
||||
path: 'ComfyUI_frontend'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'ComfyUI_frontend/pnpm-lock.yaml'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
working-directory: ComfyUI_frontend
|
||||
# Setup Test Environment, we only need playwright to merge reports
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
- name: Download blob reports
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ComfyUI_frontend/all-blob-reports
|
||||
path: ./all-blob-reports
|
||||
pattern: blob-report-chromium-*
|
||||
merge-multiple: true
|
||||
|
||||
@@ -250,7 +167,6 @@ jobs:
|
||||
# Generate JSON report separately with explicit output path
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
|
||||
pnpm exec playwright merge-reports --reporter=json ./all-blob-reports
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- name: Build failed screenshot manifest
|
||||
if: ${{ needs.playwright-tests-chromium-sharded.result == 'failure' }}
|
||||
@@ -276,7 +192,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report-chromium
|
||||
path: ComfyUI_frontend/playwright-report/
|
||||
path: ./playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
#### BEGIN Deployment and commenting (non-forked PRs only)
|
||||
@@ -292,11 +208,11 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
|
||||
- name: Get start time
|
||||
id: start-time
|
||||
run: echo "time=$(date -u '+%m/%d/%Y, %I:%M:%S %p')" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
- name: Post starting comment
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
@@ -307,7 +223,7 @@ jobs:
|
||||
"${{ github.head_ref }}" \
|
||||
"starting" \
|
||||
"${{ steps.start-time.outputs.time }}"
|
||||
|
||||
|
||||
# Deploy and comment for non-forked PRs only
|
||||
deploy-and-comment:
|
||||
needs: [playwright-tests, merge-reports]
|
||||
@@ -319,23 +235,20 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
|
||||
- name: Download all playwright reports
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: playwright-report-*
|
||||
path: reports
|
||||
|
||||
- name: Make deployment script executable
|
||||
run: chmod +x scripts/cicd/pr-playwright-deploy-and-comment.sh
|
||||
|
||||
|
||||
- name: Deploy reports and comment on PR
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
bash ./scripts/cicd/pr-playwright-deploy-and-comment.sh \
|
||||
"${{ github.event.pull_request.number }}" \
|
||||
"${{ github.head_ref }}" \
|
||||
"completed"
|
||||
|
||||
@@ -21,90 +21,64 @@ jobs:
|
||||
update-locales:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout ComfyUI
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
# Setup playwright environment with custom node repository
|
||||
- name: Setup ComfyUI Server (without launching)
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
repository: comfyanonymous/ComfyUI
|
||||
path: ComfyUI
|
||||
ref: master
|
||||
- name: Checkout ComfyUI_frontend
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: Comfy-Org/ComfyUI_frontend
|
||||
path: ComfyUI_frontend
|
||||
- name: Copy ComfyUI_devtools from frontend repo
|
||||
run: |
|
||||
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
|
||||
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
include_build_step: 'true'
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
# Install the custom node repository
|
||||
- name: Checkout custom node repository
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: ${{ inputs.owner }}/${{ inputs.repository }}
|
||||
path: 'ComfyUI/custom_nodes/${{ inputs.repository }}'
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
cache: 'pnpm'
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
- name: Install ComfyUI requirements
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cpu
|
||||
pip install -r requirements.txt
|
||||
pip install wait-for-it
|
||||
working-directory: ComfyUI
|
||||
- name: Install custom node requirements
|
||||
- name: Install custom node Python requirements
|
||||
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
|
||||
run: |
|
||||
if [ -f "requirements.txt" ]; then
|
||||
pip install -r requirements.txt
|
||||
fi
|
||||
working-directory: ComfyUI/custom_nodes/${{ inputs.repository }}
|
||||
- name: Build & Install ComfyUI_frontend
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build
|
||||
rm -rf ../ComfyUI/web/*
|
||||
mv dist/* ../ComfyUI/web/
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Start ComfyUI server
|
||||
run: |
|
||||
python main.py --cpu --multi-user &
|
||||
wait-for-it --service 127.0.0.1:8188 -t 600
|
||||
|
||||
# Start ComfyUI Server
|
||||
- name: Start ComfyUI Server
|
||||
shell: bash
|
||||
working-directory: ComfyUI
|
||||
- name: Setup Playwright
|
||||
uses: ./ComfyUI_frontend/.github/actions/setup-playwright
|
||||
run: |
|
||||
python main.py --cpu --multi-user --front-end-root ../dist --custom-node-path ../ComfyUI/custom_nodes/${{ inputs.repository }} &
|
||||
wait-for-it --service
|
||||
|
||||
- name: Start dev server
|
||||
# Run electron dev server as it is a superset of the web dev server
|
||||
# We do want electron specific UIs to be translated.
|
||||
run: pnpm dev:electron &
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
- name: Capture base i18n
|
||||
run: pnpm exec tsx scripts/diff-i18n capture
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Update en.json
|
||||
run: pnpm collect-i18n
|
||||
env:
|
||||
PLAYWRIGHT_TEST_URL: http://localhost:5173
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Update translations
|
||||
run: pnpm locale
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Diff base vs updated i18n
|
||||
run: pnpm exec tsx scripts/diff-i18n diff
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Update i18n in custom node repository
|
||||
run: |
|
||||
LOCALE_DIR=ComfyUI/custom_nodes/${{ inputs.repository }}/locales/
|
||||
install -d "$LOCALE_DIR"
|
||||
cp -rf ComfyUI_frontend/temp/diff/* "$LOCALE_DIR"
|
||||
|
||||
# Git ops for pushing changes and creating PR
|
||||
- name: Check and create fork of custom node repository
|
||||
run: |
|
||||
# Try to fork the repository
|
||||
|
||||
26
.github/workflows/update-locales.yaml
vendored
@@ -16,36 +16,33 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Frontend
|
||||
|
||||
# Setup playwright environment
|
||||
- name: Setup ComfyUI Frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Cache tool outputs
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
ComfyUI_frontend/.cache
|
||||
ComfyUI_frontend/.cache
|
||||
key: i18n-tools-cache-${{ runner.os }}-${{ hashFiles('ComfyUI_frontend/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
i18n-tools-cache-${{ runner.os }}-
|
||||
include_build_step: true
|
||||
- name: Setup ComfyUI Server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
- name: Start dev server
|
||||
# Run electron dev server as it is a superset of the web dev server
|
||||
# We do want electron specific UIs to be translated.
|
||||
run: pnpm dev:electron &
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
# Update locales, collect new strings and update translations using OpenAI, then commit changes
|
||||
- name: Update en.json
|
||||
run: pnpm collect-i18n
|
||||
env:
|
||||
PLAYWRIGHT_TEST_URL: http://localhost:5173
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Update translations
|
||||
run: pnpm locale
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Commit updated locales
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
@@ -59,4 +56,3 @@ jobs:
|
||||
git add src/locales/
|
||||
git diff --staged --quiet || git commit -m "Update locales [skip ci]"
|
||||
git push origin HEAD:${{ github.head_ref }}
|
||||
working-directory: ComfyUI_frontend
|
||||
|
||||
@@ -13,24 +13,32 @@ jobs:
|
||||
update-locales:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: Comfy-Org/ComfyUI_frontend_setup_action@v3
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
# Setup playwright environment
|
||||
- name: Setup ComfyUI Server (and start)
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: true
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
|
||||
- name: Start dev server
|
||||
# Run electron dev server as it is a superset of the web dev server
|
||||
# We do want electron specific UIs to be translated.
|
||||
run: pnpm dev:electron &
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Update en.json
|
||||
run: pnpm collect-i18n -- scripts/collect-i18n-node-defs.ts
|
||||
env:
|
||||
PLAYWRIGHT_TEST_URL: http://localhost:5173
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Update translations
|
||||
run: pnpm locale
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
@@ -44,4 +52,3 @@ jobs:
|
||||
branch: update-locales-node-defs-${{ github.event.inputs.trigger_type }}-${{ github.run_id }}
|
||||
base: main
|
||||
labels: dependencies
|
||||
path: ComfyUI_frontend
|
||||
@@ -15,12 +15,16 @@ on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
( github.event_name == 'pull_request' && github.event.label.name == 'New Browser Test Expectations' ) ||
|
||||
( github.event.issue.pull_request &&
|
||||
( github.event.issue.pull_request &&
|
||||
github.event_name == 'issue_comment' &&
|
||||
(
|
||||
github.event.comment.author_association == 'OWNER' ||
|
||||
@@ -29,17 +33,46 @@ jobs:
|
||||
) &&
|
||||
startsWith(github.event.comment.body, '/update-playwright') )
|
||||
steps:
|
||||
- name: Find Update Comment
|
||||
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad
|
||||
id: "find-update-comment"
|
||||
with:
|
||||
issue-number: ${{ github.event.number || github.event.issue.number }}
|
||||
comment-author: "github-actions[bot]"
|
||||
body-includes: "Updating Playwright Expectations"
|
||||
|
||||
- name: Add Starting Reaction
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
|
||||
with:
|
||||
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.number || github.event.issue.number }}
|
||||
body: |
|
||||
Updating Playwright Expectations
|
||||
edit-mode: replace
|
||||
reactions: eyes
|
||||
|
||||
- name: Get Branch SHA
|
||||
id: "get-branch"
|
||||
run: echo ::set-output name=branch::$(gh pr view $PR_NO --repo $REPO --json headRefName --jq '.headRefName')
|
||||
env:
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NO: ${{ github.event.number || github.event.issue.number }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Initial Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Pull Request Checkout
|
||||
if: github.event.issue.pull_request && github.event_name == 'issue_comment'
|
||||
run: gh pr checkout ${{ github.event.issue.number }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
ref: ${{ steps.get-branch.outputs.branch }}
|
||||
|
||||
- name: Setup Frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
with:
|
||||
include_build_step: true
|
||||
|
||||
- name: Setup ComfyUI Server
|
||||
uses: ./.github/actions/setup-comfyui-server
|
||||
with:
|
||||
launch_server: true
|
||||
|
||||
- name: Setup Playwright
|
||||
uses: ./.github/actions/setup-playwright
|
||||
@@ -96,12 +129,10 @@ jobs:
|
||||
with:
|
||||
run-id: ${{ steps.locate-manifest.outputs.run_id }}
|
||||
name: failed-screenshot-tests
|
||||
path: ComfyUI_frontend/ci-rerun
|
||||
path: ci-rerun
|
||||
|
||||
- name: Re-run failed screenshot tests and update snapshots
|
||||
id: playwright-tests
|
||||
shell: bash
|
||||
working-directory: ComfyUI_frontend
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -183,27 +214,20 @@ jobs:
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: ComfyUI_frontend/playwright-report/
|
||||
path: ./playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
- name: Debugging info
|
||||
working-directory: ComfyUI_frontend
|
||||
run: |
|
||||
echo "Branch: ${{ github.head_ref }}"
|
||||
echo "PR: ${{ github.event.issue.number }}"
|
||||
echo "Branch: ${{ steps.get-branch.outputs.branch }}"
|
||||
git status
|
||||
|
||||
- name: Commit updated expectations
|
||||
id: commit
|
||||
working-directory: ComfyUI_frontend
|
||||
run: |
|
||||
git config --global user.name 'github-actions'
|
||||
git config --global user.email 'github-actions@github.com'
|
||||
if [ "${{ github.event_name }}" = "issue_comment" ]; then
|
||||
true
|
||||
else
|
||||
git fetch origin ${{ github.head_ref }}
|
||||
git checkout -B ${{ github.head_ref }} origin/${{ github.head_ref }}
|
||||
fi
|
||||
git add browser_tests
|
||||
if git diff --cached --quiet; then
|
||||
echo "No expectation updates detected; skipping commit."
|
||||
@@ -215,16 +239,11 @@ jobs:
|
||||
echo "count=$changed_count" >> $GITHUB_OUTPUT
|
||||
|
||||
git commit -m "[automated] Update test expectations"
|
||||
if [ "${{ github.event_name }}" = "issue_comment" ]; then
|
||||
git push
|
||||
else
|
||||
git push origin HEAD:${{ github.head_ref }}
|
||||
fi
|
||||
git push origin ${{ steps.get-branch.outputs.branch }}
|
||||
fi
|
||||
|
||||
- name: Generate workflow summary
|
||||
if: always()
|
||||
working-directory: ComfyUI_frontend
|
||||
run: |
|
||||
echo "## Snapshot Update Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
@@ -248,3 +267,18 @@ jobs:
|
||||
echo "---" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Strategy:** Selective snapshot update (only failed tests re-run)" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Add Done Reaction
|
||||
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9
|
||||
if: github.event_name == 'issue_comment'
|
||||
with:
|
||||
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.number || github.event.issue.number }}
|
||||
reactions: +1
|
||||
reactions-edit-mode: replace
|
||||
|
||||
- name: Remove New Browser Test Expectations label
|
||||
if: always() && github.event_name == 'pull_request'
|
||||
run: gh pr edit ${{ github.event.pull_request.number }} --remove-label "New Browser Test Expectations"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
4
.github/workflows/vitest-tests.yaml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
pull_request:
|
||||
branches-ignore: [wip/*, draft/*, temp/*]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- Routing/i18n/entry: `src/router.ts`, `src/i18n.ts`, `src/main.ts`.
|
||||
- Tests: unit/component in `tests-ui/` and `src/components/**/*.{test,spec}.ts`; E2E in `browser_tests/`.
|
||||
- Public assets: `public/`. Build output: `dist/`.
|
||||
- Config: `vite.config.mts`, `vitest.config.ts`, `playwright.config.ts`, `eslint.config.js`, `.prettierrc`.
|
||||
- Config: `vite.config.mts`, `vitest.config.ts`, `playwright.config.ts`, `eslint.config.ts`, `.prettierrc`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `pnpm dev`: Start Vite dev server.
|
||||
|
||||
@@ -126,6 +126,5 @@ const value = api.getServerFeature('config_name', defaultValue) // Get config
|
||||
- NEVER use `--no-verify` flag when committing
|
||||
- NEVER delete or disable tests to make them pass
|
||||
- NEVER circumvent quality checks
|
||||
- NEVER use `dark:` prefix - always use `dark-theme:` for dark mode styles, for example: `dark-theme:text-white dark-theme:bg-black`
|
||||
- NEVER use `:class="[]"` to merge class names - always use `import { cn } from '@/utils/tailwindUtil'`, for example: `<div :class="cn('bg-red-500', { 'bg-blue-500': condition })" />`
|
||||
|
||||
- NEVER use `dark:` or `dark-theme:` tailwind variants. Instead use a semantic value from the `style.css` theme, e.g. `bg-node-component-surface`
|
||||
- NEVER use `:class="[]"` to merge class names - always use `import { cn } from '@/utils/tailwindUtil'`, for example: `<div :class="cn('text-node-component-header-icon', hasError && 'text-danger')" />`
|
||||
|
||||
@@ -54,3 +54,10 @@
|
||||
|
||||
# Translations
|
||||
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer
|
||||
|
||||
# LLM Instructions (blank on purpose)
|
||||
.claude/
|
||||
.cursor/
|
||||
.cursorrules
|
||||
**/AGENTS.md
|
||||
**/CLAUDE.md
|
||||
90
browser_tests/assets/vueNodes/linked-int-widget.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"id": "95ea19ba-456c-46e8-aa40-dc3ff135b746",
|
||||
"revision": 0,
|
||||
"last_node_id": 11,
|
||||
"last_link_id": 10,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "KSampler",
|
||||
"pos": [494.3333740234375, 142.3333282470703],
|
||||
"size": [444, 399],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [67, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "PrimitiveInt",
|
||||
"pos": [24.333343505859375, 149.6666717529297],
|
||||
"size": [444, 125],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "INT",
|
||||
"type": "INT",
|
||||
"links": [10]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PrimitiveInt"
|
||||
},
|
||||
"widgets_values": [67, "randomize"]
|
||||
}
|
||||
],
|
||||
"links": [[10, 11, 0, 10, 4, "INT"]],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
},
|
||||
"frontendVersion": "1.28.6"
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { APIRequestContext, Locator, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import dotenv from 'dotenv'
|
||||
import * as fs from 'fs'
|
||||
|
||||
@@ -130,7 +129,8 @@ export class ComfyPage {
|
||||
|
||||
// Buttons
|
||||
public readonly resetViewButton: Locator
|
||||
public readonly queueButton: Locator
|
||||
public readonly queueButton: Locator // Run button in Legacy UI
|
||||
public readonly runButton: Locator // Run button (renamed "Queue" -> "Run")
|
||||
|
||||
// Inputs
|
||||
public readonly workflowUploadInput: Locator
|
||||
@@ -165,6 +165,9 @@ export class ComfyPage {
|
||||
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
|
||||
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
|
||||
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
|
||||
this.runButton = page
|
||||
.getByTestId('queue-button')
|
||||
.getByRole('button', { name: 'Run' })
|
||||
this.workflowUploadInput = page.locator('#comfy-file-input')
|
||||
this.visibleToasts = page.locator('.p-toast-message:visible')
|
||||
|
||||
@@ -1086,12 +1089,6 @@ export class ComfyPage {
|
||||
|
||||
const targetPosition = await targetSlot.getPosition()
|
||||
|
||||
// Debug: Log the positions we're trying to use
|
||||
console.log('Drag positions:', {
|
||||
source: sourcePosition,
|
||||
target: targetPosition
|
||||
})
|
||||
|
||||
await this.dragAndDrop(sourcePosition, targetPosition)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -119,4 +119,24 @@ export class VueNodeHelpers {
|
||||
await this.page.waitForSelector('[data-node-id]')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific widget by node title and widget name
|
||||
*/
|
||||
getWidgetByName(nodeTitle: string, widgetName: string): Locator {
|
||||
return this.getNodeByTitle(nodeTitle).locator(
|
||||
`_vue=[widget.name="${widgetName}"]`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get controls for input number widgets (increment/decrement buttons and input)
|
||||
*/
|
||||
getInputNumberControls(widget: Locator) {
|
||||
return {
|
||||
input: widget.locator('input'),
|
||||
incrementButton: widget.locator('button').first(),
|
||||
decrementButton: widget.locator('button').last()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,12 +66,22 @@ test.describe('Minimap', () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimapContainer).not.toBeVisible()
|
||||
|
||||
// Open zoom controls dropdown again
|
||||
await zoomControlsButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(toggleButton).toContainText('Show Minimap')
|
||||
|
||||
await toggleButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(minimapContainer).toBeVisible()
|
||||
|
||||
// Open zoom controls dropdown again to verify button text
|
||||
await zoomControlsButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(toggleButton).toContainText('Hide Minimap')
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
@@ -788,4 +788,171 @@ test.describe('Vue Node Link Interaction', () => {
|
||||
targetSlot: 2
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Release actions (Shift-drop)', () => {
|
||||
test('Context menu opens and endpoint is pinned on Shift-drop', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'context menu'
|
||||
)
|
||||
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
|
||||
const dropPos = { x: outputCenter.x + 180, y: outputCenter.y - 140 }
|
||||
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
try {
|
||||
await comfyMouse.drag(dropPos)
|
||||
await comfyMouse.drop()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
|
||||
// Context menu should be visible
|
||||
const contextMenu = comfyPage.page.locator('.litecontextmenu')
|
||||
await expect(contextMenu).toBeVisible()
|
||||
|
||||
// Pinned endpoint should not change with mouse movement while menu is open
|
||||
const before = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(before).not.toBeNull()
|
||||
|
||||
// Move mouse elsewhere and verify snap position is unchanged
|
||||
await comfyMouse.move({ x: dropPos.x + 160, y: dropPos.y + 100 })
|
||||
const after = await comfyPage.page.evaluate(() => {
|
||||
const snap = window['app']?.canvas?.linkConnector?.state?.snapLinksPos
|
||||
return Array.isArray(snap) ? [snap[0], snap[1]] : null
|
||||
})
|
||||
expect(after).toEqual(before)
|
||||
})
|
||||
|
||||
test('Context menu -> Search pre-filters by link type and connects after selection', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await comfyPage.setSetting(
|
||||
'Comfy.LinkRelease.ActionShift',
|
||||
'context menu'
|
||||
)
|
||||
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
|
||||
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const dropPos = { x: outputCenter.x + 200, y: outputCenter.y - 120 }
|
||||
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
try {
|
||||
await comfyMouse.drag(dropPos)
|
||||
await comfyMouse.drop()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
|
||||
// Open Search from the context menu
|
||||
await comfyPage.clickContextMenuItem('Search')
|
||||
|
||||
// Search box opens with prefilled type filter based on link type (LATENT)
|
||||
await expect(comfyPage.searchBox.input).toBeVisible()
|
||||
const chips = comfyPage.searchBox.filterChips
|
||||
// Ensure at least one filter chip exists and it matches the link type
|
||||
const chipCount = await chips.count()
|
||||
expect(chipCount).toBeGreaterThan(0)
|
||||
await expect(chips.first()).toContainText('LATENT')
|
||||
|
||||
// Choose a compatible node and verify it auto-connects
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('VAEDecode')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// KSampler output should now have an outgoing link
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
expect(await samplerOutput.getLinkCount()).toBe(1)
|
||||
|
||||
// One of the VAEDecode nodes should have an incoming link on input[0]
|
||||
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
|
||||
let linked = false
|
||||
for (const vae of vaeNodes) {
|
||||
const details = await getInputLinkDetails(comfyPage.page, vae.id, 0)
|
||||
if (details) {
|
||||
expect(details.originId).toBe(samplerNode.id)
|
||||
linked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(linked).toBe(true)
|
||||
})
|
||||
|
||||
test('Search box opens on Shift-drop and connects after selection', async ({
|
||||
comfyPage,
|
||||
comfyMouse
|
||||
}) => {
|
||||
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'search box')
|
||||
|
||||
const samplerNode = (await comfyPage.getNodeRefsByType('KSampler'))[0]
|
||||
expect(samplerNode).toBeTruthy()
|
||||
|
||||
const outputCenter = await getSlotCenter(
|
||||
comfyPage.page,
|
||||
samplerNode.id,
|
||||
0,
|
||||
false
|
||||
)
|
||||
const dropPos = { x: outputCenter.x + 140, y: outputCenter.y - 100 }
|
||||
|
||||
await comfyMouse.move(outputCenter)
|
||||
await comfyPage.page.keyboard.down('Shift')
|
||||
try {
|
||||
await comfyMouse.drag(dropPos)
|
||||
await comfyMouse.drop()
|
||||
} finally {
|
||||
await comfyPage.page.keyboard.up('Shift').catch(() => {})
|
||||
}
|
||||
|
||||
// Search box should open directly
|
||||
await expect(comfyPage.searchBox.input).toBeVisible()
|
||||
await expect(comfyPage.searchBox.filterChips.first()).toContainText(
|
||||
'LATENT'
|
||||
)
|
||||
|
||||
// Select a compatible node and verify connection
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode('VAEDecode')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const samplerOutput = await samplerNode.getOutput(0)
|
||||
expect(await samplerOutput.getLinkCount()).toBe(1)
|
||||
|
||||
const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode')
|
||||
let linked = false
|
||||
for (const vae of vaeNodes) {
|
||||
const details = await getInputLinkDetails(comfyPage.page, vae.id, 0)
|
||||
if (details) {
|
||||
expect(details.originId).toBe(samplerNode.id)
|
||||
linked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
expect(linked).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 9.3 KiB |
@@ -49,4 +49,36 @@ test.describe('Vue Node Selection', () => {
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
|
||||
})
|
||||
}
|
||||
|
||||
test('should select pinned node without dragging', async ({ comfyPage }) => {
|
||||
const PIN_HOTKEY = 'p'
|
||||
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
|
||||
|
||||
// Select a node by clicking its title
|
||||
const checkpointNodeHeader = comfyPage.page.getByText('Load Checkpoint')
|
||||
await checkpointNodeHeader.click()
|
||||
|
||||
// Pin it using the hotkey (as a user would)
|
||||
await comfyPage.page.keyboard.press(PIN_HOTKEY)
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
const pinIndicator = checkpointNode.locator(PIN_INDICATOR)
|
||||
await expect(pinIndicator).toBeVisible()
|
||||
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
|
||||
const initialPos = await checkpointNodeHeader.boundingBox()
|
||||
if (!initialPos) throw new Error('Failed to get header position')
|
||||
|
||||
await comfyPage.dragAndDrop(
|
||||
{ x: initialPos.x + 10, y: initialPos.y + 10 },
|
||||
{ x: initialPos.x + 100, y: initialPos.y + 100 }
|
||||
)
|
||||
|
||||
const finalPos = await checkpointNodeHeader.boundingBox()
|
||||
if (!finalPos) throw new Error('Failed to get header position after drag')
|
||||
expect(finalPos).toEqual(initialPos)
|
||||
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,9 @@ test.describe('Vue Node Bypass', () => {
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-node-bypassed-state.png'
|
||||
)
|
||||
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
|
||||
|
||||
|
After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 76 KiB |
@@ -3,7 +3,7 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
const ERROR_CLASS = /border-error/
|
||||
const ERROR_CLASS = /border-node-stroke-error/
|
||||
|
||||
test.describe('Vue Node Error', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -17,16 +17,21 @@ test.describe('Vue Node Error', () => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
// Close missing nodes warning dialog
|
||||
await comfyPage.page.getByRole('button', { name: 'Close' }).click()
|
||||
await comfyPage.page.waitForSelector('.comfy-missing-nodes', {
|
||||
state: 'hidden'
|
||||
})
|
||||
|
||||
// Expect error state on missing unknown node
|
||||
const unknownNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'UNKNOWN NODE'
|
||||
})
|
||||
await expect(unknownNode).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
|
||||
test('should display error state when node causes execution error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('nodes/execution_error')
|
||||
await comfyPage.runButton.click()
|
||||
|
||||
const raiseErrorNode = comfyPage.vueNodes.getNodeByTitle('Raise Error')
|
||||
await expect(raiseErrorNode).toHaveClass(ERROR_CLASS)
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 94 KiB |
@@ -4,7 +4,7 @@ import {
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
const MUTE_HOTKEY = 'Control+m'
|
||||
const MUTE_CLASS = /opacity-50/
|
||||
const MUTE_OPACITY = '0.5'
|
||||
|
||||
test.describe('Vue Node Mute', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -19,10 +19,11 @@ test.describe('Vue Node Mute', () => {
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
|
||||
const checkpointNode = comfyPage.vueNodes.getNodeByTitle('Load Checkpoint')
|
||||
await expect(checkpointNode).toHaveClass(MUTE_CLASS)
|
||||
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-node-muted-state.png')
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(MUTE_CLASS)
|
||||
await expect(checkpointNode).not.toHaveCSS('opacity', MUTE_OPACITY)
|
||||
})
|
||||
|
||||
test('should allow toggling mute on multiple selected nodes with hotkey', async ({
|
||||
@@ -35,11 +36,11 @@ test.describe('Vue Node Mute', () => {
|
||||
const ksamplerNode = comfyPage.vueNodes.getNodeByTitle('KSampler')
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).toHaveClass(MUTE_CLASS)
|
||||
await expect(ksamplerNode).toHaveClass(MUTE_CLASS)
|
||||
await expect(checkpointNode).toHaveCSS('opacity', MUTE_OPACITY)
|
||||
await expect(ksamplerNode).toHaveCSS('opacity', MUTE_OPACITY)
|
||||
|
||||
await comfyPage.page.keyboard.press(MUTE_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(MUTE_CLASS)
|
||||
await expect(ksamplerNode).not.toHaveClass(MUTE_CLASS)
|
||||
await expect(checkpointNode).not.toHaveCSS('opacity', MUTE_OPACITY)
|
||||
await expect(ksamplerNode).not.toHaveCSS('opacity', MUTE_OPACITY)
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 78 KiB |
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Integer Widget', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
})
|
||||
|
||||
test('should be disabled and not allow changing value when link connected to slot', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.loadWorkflow('vueNodes/linked-int-widget')
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const seedWidget = comfyPage.vueNodes.getWidgetByName('KSampler', 'seed')
|
||||
const controls = comfyPage.vueNodes.getInputNumberControls(seedWidget)
|
||||
const initialValue = Number(await controls.input.inputValue())
|
||||
|
||||
// Verify widget is disabled when linked
|
||||
await controls.incrementButton.click({ force: true })
|
||||
await expect(controls.input).toHaveValue(initialValue.toString())
|
||||
|
||||
await controls.decrementButton.click({ force: true })
|
||||
await expect(controls.input).toHaveValue(initialValue.toString())
|
||||
|
||||
await expect(seedWidget).toBeVisible()
|
||||
|
||||
// Delete the node that is linked to the slot (freeing up the widget)
|
||||
await comfyPage.vueNodes.getNodeByTitle('Int').click()
|
||||
await comfyPage.vueNodes.deleteSelected()
|
||||
|
||||
// Test widget works when unlinked
|
||||
await controls.incrementButton.click()
|
||||
await expect(controls.input).toHaveValue((initialValue + 1).toString())
|
||||
|
||||
await controls.decrementButton.click()
|
||||
await expect(controls.input).toHaveValue(initialValue.toString())
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 66 KiB |
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.describe('Vue Widget Reactivity', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
test('Should display added widgets', async ({ comfyPage }) => {
|
||||
const loadCheckpointNode = comfyPage.page.locator(
|
||||
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
|
||||
)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['4']
|
||||
node.widgets.push(node.widgets[0])
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(2)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['4']
|
||||
node.widgets[2] = node.widgets[0]
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['4']
|
||||
node.widgets.splice(0, 0, node.widgets[0])
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(4)
|
||||
})
|
||||
test('Should hide removed widgets', async ({ comfyPage }) => {
|
||||
const loadCheckpointNode = comfyPage.page.locator(
|
||||
'css=[data-testid="node-body-3"] > .lg-node-widgets > div'
|
||||
)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['3']
|
||||
node.widgets.pop()
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(5)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['3']
|
||||
node.widgets.length--
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(4)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window['graph']._nodes_by_id['3']
|
||||
node.widgets.splice(0, 1)
|
||||
})
|
||||
await expect(loadCheckpointNode).toHaveCount(3)
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 45 KiB |
755
docs/PLAYWRIGHT_SELECTIVE_RERUN_ALTERNATIVES.md
Normal file
@@ -0,0 +1,755 @@
|
||||
# Playwright Selective Test Rerun Alternatives
|
||||
|
||||
This document analyzes alternatives for selectively re-running only failed Playwright tests for snapshot updates, comparing native Playwright features with the current custom manifest approach used in this project.
|
||||
|
||||
## Table of Contents
|
||||
- [Current Approach](#current-approach)
|
||||
- [Native Playwright Features](#native-playwright-features)
|
||||
- [Playwright Reporter Options](#playwright-reporter-options)
|
||||
- [GitHub Actions Integration Patterns](#github-actions-integration-patterns)
|
||||
- [Third-Party Solutions](#third-party-solutions)
|
||||
- [Comparison and Recommendations](#comparison-and-recommendations)
|
||||
|
||||
---
|
||||
|
||||
## Current Approach
|
||||
|
||||
### Implementation
|
||||
The project currently uses a **custom manifest-based approach** that:
|
||||
|
||||
1. **Generates a manifest** of failed screenshot tests after CI runs
|
||||
- Script: `scripts/cicd/build-failed-screenshot-manifest.ts`
|
||||
- Parses JSON report to find tests with failed screenshot assertions
|
||||
- Creates per-project text files: `ci-rerun/{project}.txt`
|
||||
- Format: `file_path:line_number` (e.g., `browser_tests/menu.test.ts:42`)
|
||||
|
||||
2. **Stores manifest as GitHub artifact**
|
||||
- Artifact name: `failed-screenshot-tests`
|
||||
- Retention: 7 days
|
||||
- Only uploaded when chromium sharded tests fail
|
||||
|
||||
3. **Downloads manifest in update workflow**
|
||||
- Workflow: `.github/workflows/update-playwright-expectations.yaml`
|
||||
- Triggered by: PR label "New Browser Test Expectations" or `/update-playwright` comment
|
||||
- Falls back to full test suite if manifest not found
|
||||
|
||||
4. **Re-runs only failed tests**
|
||||
```bash
|
||||
for f in ci-rerun/*.txt; do
|
||||
project="$(basename "$f" .txt)"
|
||||
mapfile -t lines < "$f"
|
||||
# Filter empty lines
|
||||
pnpm exec playwright test --project="$project" --update-snapshots "${filtered[@]}"
|
||||
done
|
||||
```
|
||||
|
||||
### Advantages
|
||||
- ✅ Works across workflow runs and different trigger mechanisms
|
||||
- ✅ Survives beyond single workflow execution
|
||||
- ✅ Precise control over which tests to re-run
|
||||
- ✅ Supports multiple projects with separate manifests
|
||||
- ✅ Works with sharded test runs (merged report)
|
||||
- ✅ Platform-agnostic approach (works on any CI/CD platform)
|
||||
|
||||
### Disadvantages
|
||||
- ❌ Custom implementation requires maintenance
|
||||
- ❌ Requires parsing JSON report format (could break with Playwright updates)
|
||||
- ❌ Additional artifact storage needed
|
||||
- ❌ More complex than native solutions
|
||||
|
||||
---
|
||||
|
||||
## Native Playwright Features
|
||||
|
||||
### 1. `--last-failed` CLI Flag
|
||||
|
||||
**Availability:** Playwright v1.44.0+ (May 2024)
|
||||
|
||||
#### How It Works
|
||||
```bash
|
||||
# First run - execute all tests
|
||||
npx playwright test
|
||||
|
||||
# Second run - only re-run failed tests
|
||||
npx playwright test --last-failed
|
||||
```
|
||||
|
||||
Playwright maintains a `.last-run.json` file in the `test-results/` directory that tracks failed tests.
|
||||
|
||||
#### CLI Examples
|
||||
```bash
|
||||
# Run only failed tests from last run
|
||||
npx playwright test --last-failed
|
||||
|
||||
# Update snapshots for only failed tests
|
||||
npx playwright test --last-failed --update-snapshots
|
||||
|
||||
# Combine with project filtering
|
||||
npx playwright test --last-failed --project=chromium
|
||||
|
||||
# Debug failed tests
|
||||
npx playwright test --last-failed --debug
|
||||
```
|
||||
|
||||
#### File Location and Format
|
||||
- **Location:** `test-results/.last-run.json`
|
||||
- **Format:** JSON object containing failed test information
|
||||
- **Structure:** Contains a `failedTests: []` array with test identifiers
|
||||
- **Persistence:** Cleared when all tests pass on subsequent run
|
||||
|
||||
#### Advantages
|
||||
- ✅ Built into Playwright (no custom code)
|
||||
- ✅ Simple CLI flag
|
||||
- ✅ Automatically maintained by Playwright
|
||||
- ✅ Works with all Playwright features (debug, UI mode, etc.)
|
||||
|
||||
#### Limitations
|
||||
- ❌ **Not designed for CI/CD distributed testing** (per Playwright maintainers)
|
||||
- ❌ **Intended for local development only** ("inner loop scenario")
|
||||
- ❌ Cleared on new test runs (doesn't persist across clean environments)
|
||||
- ❌ **GitHub Actions starts with clean environment** - `.last-run.json` not available on retry
|
||||
- ❌ **Doesn't work with sharded tests** - each shard creates its own `.last-run.json`
|
||||
- ❌ No native way to merge `.last-run.json` across shards
|
||||
- ❌ Not designed for cross-workflow persistence
|
||||
|
||||
#### CI/CD Workaround (Not Recommended)
|
||||
To use `--last-failed` in GitHub Actions, you would need to:
|
||||
|
||||
```yaml
|
||||
- name: Run Playwright tests
|
||||
id: playwright-test
|
||||
run: npx playwright test
|
||||
|
||||
- name: Upload last run state
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: last-run-state
|
||||
path: test-results/.last-run.json
|
||||
|
||||
# In retry workflow:
|
||||
- name: Download last run state
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: last-run-state
|
||||
path: test-results/
|
||||
|
||||
- name: Rerun failed tests
|
||||
run: npx playwright test --last-failed --update-snapshots
|
||||
```
|
||||
|
||||
**Why This Isn't Ideal:**
|
||||
- Playwright maintainers explicitly state this is not the intended use case
|
||||
- Doesn't work well with sharded tests (multiple `.last-run.json` files)
|
||||
- Requires manual artifact management
|
||||
- More complex than the current custom approach for this use case
|
||||
|
||||
### 2. File:Line Syntax for Specific Tests
|
||||
|
||||
Playwright supports running tests at specific line numbers:
|
||||
|
||||
```bash
|
||||
# Run a specific test at line 42
|
||||
npx playwright test tests/example.spec.ts:42
|
||||
|
||||
# Multiple tests
|
||||
npx playwright test tests/file1.spec.ts:10 tests/file2.spec.ts:25
|
||||
|
||||
# With snapshot updates
|
||||
npx playwright test tests/example.spec.ts:42 --update-snapshots
|
||||
|
||||
# With project selection
|
||||
npx playwright test --project=chromium tests/example.spec.ts:42
|
||||
```
|
||||
|
||||
This is **exactly the format** the current custom manifest uses, making it compatible with Playwright's native CLI.
|
||||
|
||||
### 3. Test Filtering Options
|
||||
|
||||
```bash
|
||||
# Filter by grep pattern
|
||||
npx playwright test -g "screenshot"
|
||||
|
||||
# Inverse grep
|
||||
npx playwright test --grep-invert "mobile"
|
||||
|
||||
# By project
|
||||
npx playwright test --project=chromium
|
||||
|
||||
# Multiple projects
|
||||
npx playwright test --project=chromium --project=firefox
|
||||
|
||||
# Specific directory
|
||||
npx playwright test tests/screenshots/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Playwright Reporter Options
|
||||
|
||||
### 1. JSON Reporter
|
||||
|
||||
**Purpose:** Machine-readable test results
|
||||
|
||||
#### Configuration
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
reporter: [
|
||||
['json', { outputFile: 'results.json' }]
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
Or via environment variable:
|
||||
```bash
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME=results.json npx playwright test --reporter=json
|
||||
```
|
||||
|
||||
#### Output Structure
|
||||
```json
|
||||
{
|
||||
"stats": {
|
||||
"expected": 100,
|
||||
"unexpected": 5,
|
||||
"flaky": 2,
|
||||
"skipped": 3
|
||||
},
|
||||
"suites": [
|
||||
{
|
||||
"title": "Test Suite",
|
||||
"specs": [
|
||||
{
|
||||
"file": "browser_tests/example.test.ts",
|
||||
"line": 42,
|
||||
"tests": [
|
||||
{
|
||||
"projectId": "chromium",
|
||||
"results": [
|
||||
{
|
||||
"status": "failed",
|
||||
"attachments": [
|
||||
{ "contentType": "image/png" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**This is the format** the current `build-failed-screenshot-manifest.ts` script parses.
|
||||
|
||||
#### Advantages
|
||||
- ✅ Stable, documented JSON schema (`@playwright/test/reporter`)
|
||||
- ✅ Includes all test metadata (file, line, project, status, attachments)
|
||||
- ✅ Can be used programmatically
|
||||
- ✅ Supports multiple reporters simultaneously
|
||||
|
||||
#### Current Project Usage
|
||||
```yaml
|
||||
# In tests-ci.yaml
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
|
||||
pnpm exec playwright test --project=${{ matrix.browser }} \
|
||||
--reporter=list \
|
||||
--reporter=html \
|
||||
--reporter=json
|
||||
```
|
||||
|
||||
### 2. Blob Reporter
|
||||
|
||||
**Purpose:** Merging sharded test reports
|
||||
|
||||
#### Configuration
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
export default defineConfig({
|
||||
reporter: process.env.CI ? 'blob' : 'html'
|
||||
})
|
||||
```
|
||||
|
||||
#### Usage with Sharding
|
||||
```bash
|
||||
# Run sharded test with blob output
|
||||
npx playwright test --shard=1/4 --reporter=blob
|
||||
|
||||
# Merge blob reports
|
||||
npx playwright merge-reports --reporter=html ./all-blob-reports
|
||||
npx playwright merge-reports --reporter=json ./all-blob-reports
|
||||
```
|
||||
|
||||
#### Current Project Usage
|
||||
```yaml
|
||||
# Sharded chromium tests
|
||||
- run: pnpm exec playwright test --project=chromium --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=blob
|
||||
env:
|
||||
PLAYWRIGHT_BLOB_OUTPUT_DIR: ../blob-report
|
||||
|
||||
# Merge reports job
|
||||
- run: |
|
||||
pnpm exec playwright merge-reports --reporter=html ./all-blob-reports
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME=playwright-report/report.json \
|
||||
pnpm exec playwright merge-reports --reporter=json ./all-blob-reports
|
||||
```
|
||||
|
||||
#### Advantages
|
||||
- ✅ Designed for distributed testing
|
||||
- ✅ Can merge into any reporter format (HTML, JSON, etc.)
|
||||
- ✅ Preserves all test information across shards
|
||||
|
||||
#### Blob Reporter and `--last-failed`
|
||||
- ❌ Blob reports **do not contain** a merged `.last-run.json`
|
||||
- ❌ Each shard creates its own `.last-run.json` that isn't included in blob
|
||||
- ❌ GitHub issue [#30924](https://github.com/microsoft/playwright/issues/30924) requests this feature (currently unsupported)
|
||||
|
||||
### 3. Multiple Reporters
|
||||
|
||||
You can use multiple reporters simultaneously:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
reporter: [
|
||||
['list'], // Terminal output
|
||||
['html'], // Browse results
|
||||
['json', { outputFile: 'results.json' }], // Programmatic parsing
|
||||
['junit', { outputFile: 'results.xml' }] // CI integration
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
Or via CLI:
|
||||
```bash
|
||||
npx playwright test --reporter=list --reporter=html --reporter=json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GitHub Actions Integration Patterns
|
||||
|
||||
### Pattern 1: Comment-Triggered Workflow (JupyterLab Approach)
|
||||
|
||||
**Example:** [jupyterlab/jupyterlab-git](https://github.com/jupyterlab/jupyterlab-git/blob/main/.github/workflows/update-integration-tests.yml)
|
||||
|
||||
```yaml
|
||||
name: Update Playwright Snapshots
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update-snapshots:
|
||||
# Only run for authorized users on PRs with specific comment
|
||||
if: >
|
||||
(github.event.issue.author_association == 'OWNER' ||
|
||||
github.event.issue.author_association == 'COLLABORATOR' ||
|
||||
github.event.issue.author_association == 'MEMBER'
|
||||
) && github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, 'please update snapshots')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: React to the triggering comment
|
||||
run: gh api repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions --raw-field 'content=+1'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Checkout PR branch
|
||||
run: gh pr checkout ${{ github.event.issue.number }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup and run tests
|
||||
run: |
|
||||
npm ci
|
||||
npx playwright install --with-deps
|
||||
npx playwright test --update-snapshots
|
||||
|
||||
- name: Commit and push
|
||||
run: |
|
||||
git config user.name 'github-actions'
|
||||
git config user.email 'github-actions@github.com'
|
||||
git add .
|
||||
git diff --cached --quiet || git commit -m "Update snapshots"
|
||||
git push
|
||||
```
|
||||
|
||||
#### Advantages
|
||||
- ✅ Simple comment-based trigger
|
||||
- ✅ Visual feedback (reaction on comment)
|
||||
- ✅ Authorization checks built-in
|
||||
- ✅ Auto-commits to PR branch
|
||||
|
||||
#### Limitations
|
||||
- ❌ Runs **all** tests with `--update-snapshots` (not selective)
|
||||
- ❌ No integration with failed test information from CI
|
||||
|
||||
### Pattern 2: Label-Based Trigger + Manifest (Current Approach)
|
||||
|
||||
```yaml
|
||||
name: Update Playwright Expectations
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: >
|
||||
( github.event_name == 'pull_request' &&
|
||||
github.event.label.name == 'New Browser Test Expectations' ) ||
|
||||
( github.event.issue.pull_request &&
|
||||
startsWith(github.event.comment.body, '/update-playwright') )
|
||||
|
||||
steps:
|
||||
# ... setup steps ...
|
||||
|
||||
- name: Locate failed screenshot manifest artifact
|
||||
id: locate-manifest
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo
|
||||
let headSha = ''
|
||||
if (context.eventName === 'pull_request') {
|
||||
headSha = context.payload.pull_request.head.sha
|
||||
} else if (context.eventName === 'issue_comment') {
|
||||
const prNumber = context.payload.issue.number
|
||||
const pr = await github.rest.pulls.get({ owner, repo, pull_number: prNumber })
|
||||
headSha = pr.data.head.sha
|
||||
}
|
||||
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
owner, repo,
|
||||
workflow_id: 'tests-ci.yaml',
|
||||
head_sha: headSha,
|
||||
per_page: 1,
|
||||
})
|
||||
const run = data.workflow_runs?.[0]
|
||||
|
||||
let has = 'false'
|
||||
if (run) {
|
||||
const { data: { artifacts = [] } } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner, repo, run_id: run.id
|
||||
})
|
||||
if (artifacts.some(a => a.name === 'failed-screenshot-tests' && !a.expired))
|
||||
has = 'true'
|
||||
}
|
||||
core.setOutput('has_manifest', has)
|
||||
|
||||
- name: Download failed screenshot manifest
|
||||
if: steps.locate-manifest.outputs.has_manifest == 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
run-id: ${{ steps.locate-manifest.outputs.run_id }}
|
||||
name: failed-screenshot-tests
|
||||
path: ComfyUI_frontend/ci-rerun
|
||||
|
||||
- name: Re-run failed screenshot tests
|
||||
run: |
|
||||
if [ ! -d ci-rerun ]; then
|
||||
echo "No manifest found; running full suite"
|
||||
pnpm exec playwright test --update-snapshots
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for f in ci-rerun/*.txt; do
|
||||
project="$(basename "$f" .txt)"
|
||||
mapfile -t lines < "$f"
|
||||
filtered=()
|
||||
for l in "${lines[@]}"; do
|
||||
[ -n "$l" ] && filtered+=("$l")
|
||||
done
|
||||
|
||||
if [ ${#filtered[@]} -gt 0 ]; then
|
||||
echo "Re-running ${#filtered[@]} tests for project $project"
|
||||
pnpm exec playwright test --project="$project" --update-snapshots "${filtered[@]}"
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
#### Advantages
|
||||
- ✅ **Selective** - only re-runs failed screenshot tests
|
||||
- ✅ Works across different trigger mechanisms (label or comment)
|
||||
- ✅ Fallback to full suite if manifest not found
|
||||
- ✅ Per-project manifests support multiple browser configurations
|
||||
- ✅ Handles sharded tests via merged report
|
||||
|
||||
### Pattern 3: WordPress/Openverse Approach (Always Update)
|
||||
|
||||
Proposed pattern (not fully implemented):
|
||||
1. CI always runs with `--update-snapshots` flag
|
||||
2. If snapshots change, create/update a secondary branch
|
||||
3. Open PR targeting the original PR branch
|
||||
4. Developer reviews snapshot changes before merging
|
||||
|
||||
#### Advantages
|
||||
- ✅ Always generates correct snapshots
|
||||
- ✅ Snapshot changes are visible in separate PR
|
||||
- ✅ No test failures due to mismatched snapshots
|
||||
|
||||
#### Limitations
|
||||
- ❌ Creates multiple PRs
|
||||
- ❌ More complex merge workflow
|
||||
- ❌ Potential for snapshot changes to mask real issues
|
||||
|
||||
### Pattern 4: Manual Workflow Dispatch
|
||||
|
||||
```yaml
|
||||
name: Update Snapshots
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
update-snapshots:
|
||||
description: 'Update snapshots'
|
||||
type: boolean
|
||||
default: false
|
||||
test-pattern:
|
||||
description: 'Test pattern (optional)'
|
||||
type: string
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup
|
||||
run: |
|
||||
npm ci
|
||||
npx playwright install --with-deps
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
if [ "${{ inputs.update-snapshots }}" = "true" ]; then
|
||||
FLAGS="--update-snapshots"
|
||||
fi
|
||||
|
||||
PATTERN="${{ inputs.test-pattern }}"
|
||||
npx playwright test ${PATTERN} ${FLAGS}
|
||||
```
|
||||
|
||||
#### Advantages
|
||||
- ✅ Full manual control
|
||||
- ✅ Can specify test patterns
|
||||
- ✅ Simple to understand
|
||||
|
||||
#### Limitations
|
||||
- ❌ Requires manual triggering
|
||||
- ❌ Not integrated with CI failures
|
||||
|
||||
---
|
||||
|
||||
## Third-Party Solutions
|
||||
|
||||
### Currents.dev - Last Failed GitHub Action
|
||||
|
||||
**Repository:** [currents-dev/playwright-last-failed](https://github.com/currents-dev/playwright-last-failed)
|
||||
|
||||
#### Purpose
|
||||
Helps run last failed Playwright tests using Currents' cloud-based caching service.
|
||||
|
||||
#### Usage
|
||||
```yaml
|
||||
- name: Playwright Last Failed action
|
||||
id: last-failed-action
|
||||
uses: currents-dev/playwright-last-failed@v1
|
||||
with:
|
||||
pw-output-dir: test-results
|
||||
matrix-index: ${{ matrix.shard }}
|
||||
matrix-total: ${{ strategy.job-total }}
|
||||
```
|
||||
|
||||
#### How It Works
|
||||
- Uses Currents' cloud service to persist failed test information
|
||||
- Supports sharded tests via matrix parameters
|
||||
- Enables selective rerun of failed tests across workflow retries
|
||||
|
||||
#### Advantages
|
||||
- ✅ Works with sharded tests
|
||||
- ✅ Persists across workflow runs
|
||||
- ✅ Supports GitHub Actions retry mechanism
|
||||
- ✅ Handles distributed testing
|
||||
|
||||
#### Limitations
|
||||
- ❌ **Requires Currents subscription** (third-party paid service)
|
||||
- ❌ Dependency on external service
|
||||
- ❌ Data sent to third-party cloud
|
||||
- ❌ Additional cost
|
||||
- ❌ Vendor lock-in
|
||||
|
||||
#### Recommendation
|
||||
**Not suitable for this project** due to:
|
||||
- External service dependency
|
||||
- Cost implications
|
||||
- The current custom solution is already working well
|
||||
|
||||
---
|
||||
|
||||
## Comparison and Recommendations
|
||||
|
||||
### Feature Matrix
|
||||
|
||||
| Feature | Current Approach | `--last-failed` | Currents | Comment Trigger Only |
|
||||
|---------|-----------------|-----------------|----------|---------------------|
|
||||
| Works with sharded tests | ✅ Yes | ❌ No | ✅ Yes | ✅ Yes |
|
||||
| Persists across workflows | ✅ Yes | ❌ No | ✅ Yes | N/A |
|
||||
| Selective reruns | ✅ Yes | ✅ Yes | ✅ Yes | ❌ No (runs all) |
|
||||
| No external dependencies | ✅ Yes | ✅ Yes | ❌ No | ✅ Yes |
|
||||
| Simple implementation | ⚠️ Medium | ✅ Simple | ✅ Simple | ✅ Simple |
|
||||
| Maintenance overhead | ⚠️ Medium | ✅ Low | ✅ Low | ✅ Low |
|
||||
| Works in CI/CD | ✅ Yes | ⚠️ Workaround | ✅ Yes | ✅ Yes |
|
||||
| Cost | ✅ Free | ✅ Free | ❌ Paid | ✅ Free |
|
||||
| Supports multiple projects | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes |
|
||||
|
||||
### Why `--last-failed` Isn't Suitable (Currently)
|
||||
|
||||
1. **Not designed for CI/CD:** Playwright maintainers explicitly state it's for "inner loop scenario (local development)"
|
||||
2. **Doesn't work with sharded tests:** Each shard creates its own `.last-run.json` with no native merge
|
||||
3. **Clean environment issue:** GitHub Actions starts fresh, losing `.last-run.json`
|
||||
4. **Feature request pending:** GitHub issue [#30924](https://github.com/microsoft/playwright/issues/30924) requests blob report integration (not yet implemented)
|
||||
|
||||
### Recommendations
|
||||
|
||||
#### Short Term: Keep Current Approach
|
||||
**Verdict: The current custom manifest approach is the best solution for this project's needs.**
|
||||
|
||||
**Reasons:**
|
||||
1. ✅ **Works perfectly with sharded tests** - merges results across 8 shards
|
||||
2. ✅ **Persists across workflows** - artifact storage for 7 days
|
||||
3. ✅ **Selective reruns** - only failed screenshot tests
|
||||
4. ✅ **No external dependencies** - fully self-contained
|
||||
5. ✅ **Uses stable Playwright JSON format** - typed via `@playwright/test/reporter`
|
||||
6. ✅ **Already working well** - proven in production
|
||||
|
||||
**Minor Improvements:**
|
||||
```typescript
|
||||
// Add version check to warn if JSON schema changes
|
||||
import { version } from '@playwright/test/package.json'
|
||||
if (major(version) !== 1) {
|
||||
console.warn('Playwright major version changed - verify JSON schema compatibility')
|
||||
}
|
||||
|
||||
// Add more robust error handling
|
||||
try {
|
||||
const report: JSONReport = JSON.parse(raw)
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse Playwright JSON report: ${error.message}`)
|
||||
}
|
||||
|
||||
// Consider adding tests for the manifest builder
|
||||
// e.g., tests/cicd/build-failed-screenshot-manifest.test.ts
|
||||
```
|
||||
|
||||
#### Long Term: Monitor Playwright Development
|
||||
|
||||
**Watch for these features:**
|
||||
1. **Blob report + `.last-run.json` merge** - GitHub issue [#30924](https://github.com/microsoft/playwright/issues/30924)
|
||||
2. **Native CI/CD support for `--last-failed`** - may never happen (by design)
|
||||
3. **Report merging improvements** - GitHub issue [#33094](https://github.com/microsoft/playwright/issues/33094)
|
||||
|
||||
**Migration path if native support improves:**
|
||||
```yaml
|
||||
# Future potential approach (if Playwright adds this feature)
|
||||
- name: Merge reports with last-run
|
||||
run: |
|
||||
npx playwright merge-reports --reporter=html ./all-blob-reports
|
||||
npx playwright merge-reports --reporter=last-failed ./all-blob-reports
|
||||
|
||||
- name: Upload merged last-run
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: last-run-state
|
||||
path: test-results/.last-run.json
|
||||
|
||||
# In update workflow
|
||||
- name: Download last-run state
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: last-run-state
|
||||
path: test-results/
|
||||
|
||||
- name: Update snapshots for failed tests
|
||||
run: npx playwright test --last-failed --update-snapshots
|
||||
```
|
||||
|
||||
**However, this is speculative** - Playwright maintainers have indicated `--last-failed` is not intended for CI/CD.
|
||||
|
||||
#### Alternative: Simplify to Full Suite Reruns
|
||||
|
||||
If the custom manifest becomes too complex to maintain, consider:
|
||||
|
||||
```yaml
|
||||
- name: Re-run ALL screenshot tests
|
||||
run: |
|
||||
# Simple grep-based filtering for screenshot tests
|
||||
npx playwright test -g "screenshot" --update-snapshots
|
||||
```
|
||||
|
||||
**Trade-offs:**
|
||||
- ✅ Much simpler
|
||||
- ✅ No custom scripts
|
||||
- ❌ Slower (runs all screenshot tests, not just failed ones)
|
||||
- ❌ Potentially updates snapshots that weren't actually failing
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The current custom manifest approach is **well-designed** and **appropriate** for this project's requirements:
|
||||
|
||||
1. **Handles sharded tests** - critical for CI performance
|
||||
2. **Selective reruns** - saves time and resources
|
||||
3. **Stable implementation** - uses documented Playwright JSON schema
|
||||
4. **No external dependencies** - fully controlled
|
||||
|
||||
While `--last-failed` is a nice feature for **local development**, Playwright's own documentation and maintainer comments confirm it's **not suitable for distributed CI/CD testing**, which is exactly what this project needs.
|
||||
|
||||
The only potentially better solution (Currents) requires a paid external service, which adds cost and complexity without significant benefits over the current approach.
|
||||
|
||||
**Recommendation: Keep the current implementation**, with minor improvements to error handling and documentation. Monitor Playwright development for native improvements, but don't expect `--last-failed` to become a viable alternative for this use case.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
### Official Playwright Documentation
|
||||
- [Command Line](https://playwright.dev/docs/test-cli)
|
||||
- [Reporters](https://playwright.dev/docs/test-reporters)
|
||||
- [Test Sharding](https://playwright.dev/docs/test-sharding)
|
||||
- [CI/CD Setup](https://playwright.dev/docs/ci-intro)
|
||||
|
||||
### Community Resources
|
||||
- [Playwright Solutions: How to Run Failures Only](https://playwrightsolutions.com/how-to-run-failures-only-from-the-last-playwright-run/)
|
||||
- [Medium: How to Run Only Last Failed Tests](https://medium.com/@testerstalk/how-to-run-only-last-failed-tests-in-playwright-e5e41472594a)
|
||||
- [Medium: Streamlining Visual Regression Testing](https://medium.com/@haleywardo/streamlining-playwright-visual-regression-testing-with-github-actions-e077fd33c27c)
|
||||
|
||||
### GitHub Issues
|
||||
- [#30924 - Last-failed with blob reports](https://github.com/microsoft/playwright/issues/30924)
|
||||
- [#33094 - Merging main run with --last-failed](https://github.com/microsoft/playwright/issues/33094)
|
||||
- [#28254 - Feature request for --last-failed](https://github.com/microsoft/playwright/issues/28254)
|
||||
|
||||
### Example Implementations
|
||||
- [JupyterLab Git - Update Integration Tests](https://github.com/jupyterlab/jupyterlab-git/blob/main/.github/workflows/update-integration-tests.yml)
|
||||
- [WordPress Openverse - Discussion #4535](https://github.com/WordPress/openverse/issues/4535)
|
||||
|
||||
### Third-Party Tools
|
||||
- [Currents - Playwright Last Failed Action](https://github.com/currents-dev/playwright-last-failed)
|
||||
- [Currents - Re-run Only Failed Tests](https://docs.currents.dev/guides/re-run-only-failed-tests)
|
||||
482
docs/SNAPSHOT_UPDATE_FROM_ACTUALS.md
Normal file
@@ -0,0 +1,482 @@
|
||||
# Snapshot Update from Actual Files (Fast Approach)
|
||||
|
||||
**Date:** 2025-10-08
|
||||
**Status:** Proposed Optimization
|
||||
|
||||
## Overview
|
||||
|
||||
When Playwright snapshot tests fail, Playwright **already generates the new ("actual") snapshots**. Instead of re-running tests with `--update-snapshots`, we can extract these actual snapshots from the `test-results/` directory and copy them to overwrite the expected snapshots.
|
||||
|
||||
**Performance improvement:** ~1-2 minutes → **~10-30 seconds**
|
||||
|
||||
## How Playwright Stores Snapshots
|
||||
|
||||
### Expected (Baseline) Snapshots
|
||||
|
||||
Stored in: `<test-file>-snapshots/<snapshot-name>-<project>-<platform>.png`
|
||||
|
||||
**Example:**
|
||||
```
|
||||
browser_tests/tests/interaction.spec.ts-snapshots/default-chromium-linux.png
|
||||
```
|
||||
|
||||
### Failed Test Artifacts
|
||||
|
||||
When a snapshot test fails, Playwright creates:
|
||||
|
||||
```
|
||||
test-results/<test-hash>/
|
||||
├── <snapshot-name>-actual.png # The NEW screenshot
|
||||
├── <snapshot-name>-expected.png # Copy of baseline
|
||||
└── <snapshot-name>-diff.png # Visual diff
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```
|
||||
test-results/interaction-default-chromium-67af3c/
|
||||
├── default-1-actual.png
|
||||
├── default-1-expected.png
|
||||
└── default-1-diff.png
|
||||
```
|
||||
|
||||
## Current Approach vs. Proposed Approach
|
||||
|
||||
### Current: Re-run Tests with `--update-snapshots`
|
||||
|
||||
```yaml
|
||||
# Current workflow (.github/workflows/update-playwright-expectations.yaml)
|
||||
- name: Re-run failed screenshot tests and update snapshots
|
||||
run: |
|
||||
# Download manifest of failed tests
|
||||
# For each project: chromium, chromium-2x, etc.
|
||||
# Run: playwright test --project="$project" --update-snapshots test1.spec.ts:42 test2.spec.ts:87 ...
|
||||
```
|
||||
|
||||
**Time:** ~2-5 minutes (depends on # of failed tests)
|
||||
|
||||
**Why slow:**
|
||||
- Re-executes tests (browser startup, navigation, interactions)
|
||||
- Waits for elements, animations, etc.
|
||||
- Generates HTML report
|
||||
- Each test takes 5-15 seconds
|
||||
|
||||
### Proposed: Copy Actual → Expected
|
||||
|
||||
```yaml
|
||||
# Proposed workflow
|
||||
- name: Download test artifacts (includes test-results/)
|
||||
- name: Copy actual snapshots to expected locations
|
||||
run: pnpm tsx scripts/cicd/update-snapshots-from-actuals.ts
|
||||
- name: Commit and push
|
||||
```
|
||||
|
||||
**Time:** ~10-30 seconds (just file operations)
|
||||
|
||||
**Why fast:**
|
||||
- No test execution
|
||||
- No browser startup
|
||||
- Just file copying
|
||||
- Parallel file operations
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Modify tests-ci.yaml
|
||||
|
||||
Currently, test artifacts upload only the `playwright-report/` directory.
|
||||
|
||||
**Add test-results/ to artifacts:**
|
||||
|
||||
```yaml
|
||||
# .github/workflows/tests-ci.yaml
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-results-${{ matrix.browser }} # New artifact
|
||||
path: |
|
||||
ComfyUI_frontend/test-results/**/*-actual.png
|
||||
ComfyUI_frontend/test-results/**/*-expected.png
|
||||
ComfyUI_frontend/test-results/**/*-diff.png
|
||||
retention-days: 7
|
||||
```
|
||||
|
||||
**Optimization:** Only upload actual snapshots for failed tests (saves artifact storage)
|
||||
|
||||
### Step 2: Create Script to Map Actuals → Expected
|
||||
|
||||
**File:** `scripts/cicd/update-snapshots-from-actuals.ts`
|
||||
|
||||
```typescript
|
||||
import type { JSONReport, JSONReportTestResult } from '@playwright/test/reporter'
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
interface SnapshotMapping {
|
||||
actualPath: string // test-results/.../snapshot-1-actual.png
|
||||
expectedPath: string // browser_tests/tests/foo.spec.ts-snapshots/snapshot-chromium-linux.png
|
||||
testFile: string
|
||||
testName: string
|
||||
project: string
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const reportPath = path.join('playwright-report', 'report.json')
|
||||
|
||||
if (!fs.existsSync(reportPath)) {
|
||||
console.log('No report.json found - no failed tests to update')
|
||||
return
|
||||
}
|
||||
|
||||
const raw = await fsp.readFile(reportPath, 'utf8')
|
||||
const report: JSONReport = JSON.parse(raw)
|
||||
|
||||
const mappings: SnapshotMapping[] = []
|
||||
|
||||
// Parse JSON report to extract snapshot paths
|
||||
function collectFailedSnapshots(suite: any) {
|
||||
if (!suite) return
|
||||
|
||||
for (const childSuite of suite.suites ?? []) {
|
||||
collectFailedSnapshots(childSuite)
|
||||
}
|
||||
|
||||
for (const spec of suite.specs ?? []) {
|
||||
for (const test of spec.tests) {
|
||||
const lastResult = test.results[test.results.length - 1]
|
||||
|
||||
if (lastResult?.status !== 'failed') continue
|
||||
|
||||
// Check if test has image attachments (indicates screenshot test)
|
||||
const imageAttachments = lastResult.attachments.filter(
|
||||
(att: any) => att?.contentType?.startsWith('image/')
|
||||
)
|
||||
|
||||
if (imageAttachments.length === 0) continue
|
||||
|
||||
// Extract snapshot mapping from attachments
|
||||
for (const attachment of imageAttachments) {
|
||||
const attachmentPath = attachment.path
|
||||
|
||||
if (!attachmentPath || !attachmentPath.includes('-actual.png')) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse test-results path to determine expected location
|
||||
// test-results/interaction-default-chromium-67af3c/default-1-actual.png
|
||||
// → browser_tests/tests/interaction.spec.ts-snapshots/default-chromium-linux.png
|
||||
|
||||
const actualPath = attachmentPath
|
||||
const expectedPath = inferExpectedPath(actualPath, spec.file, test.projectId)
|
||||
|
||||
if (expectedPath) {
|
||||
mappings.push({
|
||||
actualPath,
|
||||
expectedPath,
|
||||
testFile: spec.file,
|
||||
testName: test.annotations[0]?.description || test.title,
|
||||
project: test.projectId
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collectFailedSnapshots(report)
|
||||
|
||||
if (mappings.length === 0) {
|
||||
console.log('No failed snapshot tests found')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`Found ${mappings.length} snapshots to update`)
|
||||
|
||||
// Copy actual → expected
|
||||
let successCount = 0
|
||||
let errorCount = 0
|
||||
|
||||
for (const mapping of mappings) {
|
||||
try {
|
||||
if (!fs.existsSync(mapping.actualPath)) {
|
||||
console.warn(`⚠️ Actual file not found: ${mapping.actualPath}`)
|
||||
errorCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// Ensure expected directory exists
|
||||
const expectedDir = path.dirname(mapping.expectedPath)
|
||||
await fsp.mkdir(expectedDir, { recursive: true })
|
||||
|
||||
// Copy actual → expected
|
||||
await fsp.copyFile(mapping.actualPath, mapping.expectedPath)
|
||||
|
||||
console.log(`✓ Updated: ${path.basename(mapping.expectedPath)}`)
|
||||
successCount++
|
||||
} catch (error) {
|
||||
console.error(`✗ Failed to update ${mapping.expectedPath}:`, error)
|
||||
errorCount++
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ Successfully updated ${successCount} snapshots`)
|
||||
if (errorCount > 0) {
|
||||
console.log(`⚠️ Failed to update ${errorCount} snapshots`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer the expected snapshot path from the actual path
|
||||
*
|
||||
* Actual: test-results/interaction-default-chromium-67af3c/default-1-actual.png
|
||||
* Expected: browser_tests/tests/interaction.spec.ts-snapshots/default-chromium-linux.png
|
||||
*/
|
||||
function inferExpectedPath(actualPath: string, testFile: string, projectId: string): string | null {
|
||||
try {
|
||||
// Extract snapshot name from actual path
|
||||
// "default-1-actual.png" → "default"
|
||||
const actualFilename = path.basename(actualPath)
|
||||
const snapshotName = actualFilename.replace(/-\d+-actual\.png$/, '')
|
||||
|
||||
// Determine platform (linux, darwin, win32)
|
||||
const platform = process.platform === 'linux' ? 'linux'
|
||||
: process.platform === 'darwin' ? 'darwin'
|
||||
: 'win32'
|
||||
|
||||
// Build expected path
|
||||
const testDir = path.dirname(testFile)
|
||||
const testBasename = path.basename(testFile)
|
||||
const snapshotsDir = path.join(testDir, `${testBasename}-snapshots`)
|
||||
const expectedFilename = `${snapshotName}-${projectId}-${platform}.png`
|
||||
|
||||
return path.join(snapshotsDir, expectedFilename)
|
||||
} catch (error) {
|
||||
console.error(`Failed to infer expected path for ${actualPath}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Failed to update snapshots:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
```
|
||||
|
||||
### Step 3: Better Approach - Use Playwright's Attachment Metadata
|
||||
|
||||
The JSON reporter actually includes the **expected snapshot path** in the attachments!
|
||||
|
||||
**Simplified script:**
|
||||
|
||||
```typescript
|
||||
async function main() {
|
||||
const report: JSONReport = JSON.parse(await fsp.readFile('playwright-report/report.json', 'utf8'))
|
||||
|
||||
const updates: Array<{ actual: string; expected: string }> = []
|
||||
|
||||
for (const result of getAllTestResults(report)) {
|
||||
if (result.status !== 'failed') continue
|
||||
|
||||
for (const attachment of result.attachments) {
|
||||
// Playwright includes both actual and expected in attachments
|
||||
if (attachment.name?.includes('-actual') && attachment.path) {
|
||||
const actualPath = attachment.path
|
||||
|
||||
// Find corresponding expected attachment
|
||||
const expectedAttachment = result.attachments.find(
|
||||
att => att.name === attachment.name.replace('-actual', '-expected')
|
||||
)
|
||||
|
||||
if (expectedAttachment?.path) {
|
||||
// The expected path in attachment points to the test-results copy
|
||||
// But we can infer the real expected path from the attachment metadata
|
||||
const expectedPath = inferRealExpectedPath(expectedAttachment)
|
||||
updates.push({ actual: actualPath, expected: expectedPath })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy files
|
||||
for (const { actual, expected } of updates) {
|
||||
await fsp.copyFile(actual, expected)
|
||||
console.log(`✓ Updated: ${path.relative(process.cwd(), expected)}`)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Update GitHub Actions Workflow
|
||||
|
||||
```yaml
|
||||
# .github/workflows/update-playwright-expectations.yaml
|
||||
name: Update Playwright Expectations
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
update:
|
||||
if: |
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/update-snapshots') &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'),
|
||||
github.event.comment.author_association)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: React to comment
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
github.rest.reactions.createForIssueComment({
|
||||
comment_id: context.payload.comment.id,
|
||||
content: '+1'
|
||||
})
|
||||
|
||||
- name: Checkout PR
|
||||
run: gh pr checkout ${{ github.event.issue.number }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Get latest failed test run
|
||||
id: get-run
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const pr = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.issue.number
|
||||
})
|
||||
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'tests-ci.yaml',
|
||||
head_sha: pr.data.head.sha,
|
||||
per_page: 1
|
||||
})
|
||||
|
||||
core.setOutput('run_id', runs.data.workflow_runs[0]?.id || '')
|
||||
|
||||
- name: Download test results
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
run-id: ${{ steps.get-run.outputs.run_id }}
|
||||
pattern: playwright-results-*
|
||||
path: ComfyUI_frontend/test-results
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download JSON report
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
run-id: ${{ steps.get-run.outputs.run_id }}
|
||||
pattern: playwright-report-*
|
||||
path: ComfyUI_frontend/playwright-report
|
||||
merge-multiple: true
|
||||
|
||||
- name: Update snapshots from actuals
|
||||
working-directory: ComfyUI_frontend
|
||||
run: pnpm tsx scripts/cicd/update-snapshots-from-actuals.ts
|
||||
|
||||
- name: Commit and push
|
||||
working-directory: ComfyUI_frontend
|
||||
run: |
|
||||
git config user.name 'github-actions'
|
||||
git config user.email 'github-actions@github.com'
|
||||
git add browser_tests/**/*-snapshots/*.png
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "No snapshot changes detected"
|
||||
else
|
||||
git commit -m "[automated] Update test expectations"
|
||||
git push
|
||||
fi
|
||||
```
|
||||
|
||||
## Performance Comparison
|
||||
|
||||
### Current Approach: Re-run Tests
|
||||
|
||||
| Step | Time |
|
||||
|------|------|
|
||||
| Download manifest | 5s |
|
||||
| Install Playwright browsers | 20s |
|
||||
| Re-run 50 failed tests | 2-3 min |
|
||||
| Generate report | 10s |
|
||||
| Commit and push | 10s |
|
||||
| **Total** | **~3-4 min** |
|
||||
|
||||
### Proposed Approach: Copy Actuals
|
||||
|
||||
| Step | Time |
|
||||
|------|------|
|
||||
| Download test-results artifacts | 10s |
|
||||
| Download JSON report | 2s |
|
||||
| Run copy script | 5s |
|
||||
| Commit and push | 10s |
|
||||
| **Total** | **~30s** |
|
||||
|
||||
**Speedup: 6-8x faster** ⚡
|
||||
|
||||
## Advantages
|
||||
|
||||
✅ **Much faster** - No test re-execution
|
||||
✅ **Simpler** - No need for manifest generation
|
||||
✅ **Fewer dependencies** - No Playwright browser install needed
|
||||
✅ **Less resource usage** - No ComfyUI server, no browser processes
|
||||
✅ **More reliable** - File operations are deterministic
|
||||
✅ **Already tested** - The snapshots were generated during the actual test run
|
||||
|
||||
## Disadvantages / Edge Cases
|
||||
|
||||
❌ **New snapshots** - If a test creates a snapshot for the first time, there's no existing expected file. This is rare and can be handled by fallback to re-running.
|
||||
|
||||
❌ **Deleted tests** - Old snapshots won't be cleaned up automatically. Could add a cleanup step.
|
||||
|
||||
❌ **Multiple projects** - Each project (chromium, chromium-2x, mobile-chrome) generates separate actuals. The script needs to handle all of them.
|
||||
|
||||
❌ **Artifact storage** - Storing test-results/ increases artifact size. Mitigation: Only upload `-actual.png` files, not traces/videos.
|
||||
|
||||
## Hybrid Approach (Recommended)
|
||||
|
||||
Use the fast copy approach **with fallback**:
|
||||
|
||||
```yaml
|
||||
- name: Update snapshots
|
||||
run: |
|
||||
# Try fast approach first
|
||||
if pnpm tsx scripts/cicd/update-snapshots-from-actuals.ts; then
|
||||
echo "✓ Updated snapshots from actuals"
|
||||
else
|
||||
echo "⚠ Fast update failed, falling back to re-running tests"
|
||||
# Fallback to current approach
|
||||
pnpm exec playwright test --update-snapshots --project=chromium ...
|
||||
fi
|
||||
```
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] Create `scripts/cicd/update-snapshots-from-actuals.ts`
|
||||
- [ ] Update `tests-ci.yaml` to upload `test-results/` artifacts
|
||||
- [ ] Update `update-playwright-expectations.yaml` to use new script
|
||||
- [ ] Add fallback logic for edge cases
|
||||
- [ ] Test with actual PR
|
||||
- [ ] Update documentation
|
||||
- [ ] Consider switching from label trigger → comment trigger (`/update-snapshots`)
|
||||
|
||||
## Related Links
|
||||
|
||||
- **Playwright snapshot docs:** https://playwright.dev/docs/test-snapshots
|
||||
- **JSON reporter types:** `@playwright/test/reporter`
|
||||
- **GitHub Actions artifacts:** https://docs.github.com/en/actions/using-workflows/storing-workflow-data-as-artifacts
|
||||
- **Issue #22064:** Playwright feature request for better snapshot file alignment
|
||||
|
||||
## Conclusion
|
||||
|
||||
This approach is **significantly faster** and **simpler** than re-running tests. The main trade-off is artifact storage size, but this can be mitigated by only uploading actual snapshots (not traces/videos).
|
||||
|
||||
**Recommendation:** Implement this as the primary approach with fallback to re-running tests for edge cases.
|
||||
@@ -1,77 +1,104 @@
|
||||
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
|
||||
import pluginJs from '@eslint/js'
|
||||
import pluginI18n from '@intlify/eslint-plugin-vue-i18n'
|
||||
import { importX } from 'eslint-plugin-import-x'
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
|
||||
import storybook from 'eslint-plugin-storybook'
|
||||
import tailwind from 'eslint-plugin-tailwindcss'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import { defineConfig } from 'eslint/config'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import {
|
||||
configs as tseslintConfigs,
|
||||
parser as tseslintParser
|
||||
} from 'typescript-eslint'
|
||||
import vueParser from 'vue-eslint-parser'
|
||||
|
||||
const extraFileExtensions = ['.vue']
|
||||
|
||||
const commonGlobals = {
|
||||
...globals.browser,
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly'
|
||||
} as const
|
||||
|
||||
const settings = {
|
||||
'import/resolver': {
|
||||
typescript: true,
|
||||
node: true
|
||||
},
|
||||
tailwindcss: {
|
||||
config: `${import.meta.dirname}/packages/design-system/src/css/style.css`,
|
||||
functions: ['cn', 'clsx', 'tw']
|
||||
}
|
||||
} as const
|
||||
|
||||
const commonParserOptions = {
|
||||
parser: tseslintParser,
|
||||
projectService: true,
|
||||
tsConfigRootDir: import.meta.dirname,
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
extraFileExtensions
|
||||
} as const
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
ignores: [
|
||||
'src/scripts/*',
|
||||
'src/extensions/core/*',
|
||||
'src/types/vue-shim.d.ts',
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
'src/types/generatedManagerTypes.ts',
|
||||
'.i18nrc.cjs',
|
||||
'components.d.ts',
|
||||
'lint-staged.config.js',
|
||||
'vitest.setup.ts',
|
||||
'**/vite.config.*.timestamp*',
|
||||
'**/vitest.config.*.timestamp*'
|
||||
'**/vitest.config.*.timestamp*',
|
||||
'packages/registry-types/src/comfyRegistryTypes.ts',
|
||||
'src/extensions/core/*',
|
||||
'src/scripts/*',
|
||||
'src/types/generatedManagerTypes.ts',
|
||||
'src/types/vue-shim.d.ts'
|
||||
]
|
||||
},
|
||||
{
|
||||
files: ['./**/*.{ts,mts}'],
|
||||
settings,
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly'
|
||||
},
|
||||
globals: commonGlobals,
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
...commonParserOptions,
|
||||
projectService: {
|
||||
allowDefaultProject: [
|
||||
'vite.config.mts',
|
||||
'vite.electron.config.mts',
|
||||
'vite.types.config.mts',
|
||||
'playwright.config.ts',
|
||||
'playwright.i18n.config.ts'
|
||||
]
|
||||
},
|
||||
tsConfigRootDir: import.meta.dirname,
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
extraFileExtensions
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['./**/*.vue'],
|
||||
settings,
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
__COMFYUI_FRONTEND_VERSION__: 'readonly'
|
||||
},
|
||||
globals: commonGlobals,
|
||||
parser: vueParser,
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
projectService: true,
|
||||
tsConfigRootDir: import.meta.dirname,
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
extraFileExtensions
|
||||
}
|
||||
parserOptions: commonParserOptions
|
||||
}
|
||||
},
|
||||
pluginJs.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
|
||||
tseslintConfigs.recommended,
|
||||
// Difference in typecheck on CI vs Local
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Bad types in the plugin
|
||||
tailwind.configs['flat/recommended'],
|
||||
pluginVue.configs['flat/recommended'],
|
||||
eslintPluginPrettierRecommended,
|
||||
storybook.configs['flat/recommended'],
|
||||
// @ts-expect-error Bad types in the plugin
|
||||
importX.flatConfigs.recommended,
|
||||
// @ts-expect-error Bad types in the plugin
|
||||
importX.flatConfigs.typescript,
|
||||
{
|
||||
plugins: {
|
||||
'unused-imports': unusedImports,
|
||||
@@ -91,13 +118,18 @@ export default defineConfig([
|
||||
allowInterfaces: 'always'
|
||||
}
|
||||
],
|
||||
'import-x/consistent-type-specifier-style': ['error', 'prefer-top-level'],
|
||||
'import-x/no-useless-path-segments': 'error',
|
||||
'import-x/no-relative-packages': 'error',
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
'tailwindcss/no-custom-classname': 'off', // TODO: fix
|
||||
'vue/no-v-html': 'off',
|
||||
// Enforce dark-theme: instead of dark: prefix
|
||||
'vue/no-restricted-class': ['error', '/^dark:/'],
|
||||
'vue/multi-word-component-names': 'off', // TODO: fix
|
||||
'vue/no-template-shadow': 'off', // TODO: fix
|
||||
'vue/match-component-import-name': 'error',
|
||||
/* Toggle on to do additional until we can clean up existing violations.
|
||||
'vue/no-unused-emit-declarations': 'error',
|
||||
'vue/no-unused-properties': 'error',
|
||||
|
||||
13
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.28.6",
|
||||
"version": "1.29.1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -11,7 +11,7 @@
|
||||
"build:desktop": "nx build @comfyorg/desktop-ui",
|
||||
"build-storybook": "storybook build",
|
||||
"build:types": "nx build --config vite.types.config.mts && node scripts/prepare-types.js",
|
||||
"build": "pnpm typecheck && nx build",
|
||||
"build": "cross-env NODE_OPTIONS='--max-old-space-size=8192' pnpm typecheck && nx build",
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"dev:desktop": "nx dev @comfyorg/desktop-ui",
|
||||
"dev:electron": "nx serve --config vite.electron.config.mts",
|
||||
@@ -35,8 +35,8 @@
|
||||
"prepare": "husky || true && git config blame.ignoreRevsFile .git-blame-ignore-revs || true",
|
||||
"preview": "nx preview",
|
||||
"storybook": "nx storybook -p 6006",
|
||||
"stylelint:fix": "stylelint --cache --fix",
|
||||
"stylelint": "stylelint --cache",
|
||||
"stylelint:fix": "stylelint --cache --fix '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"stylelint": "stylelint --cache '{apps,packages,src}/**/*.{css,vue}'",
|
||||
"test:browser": "pnpm exec nx e2e",
|
||||
"test:unit": "nx run test",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
@@ -57,6 +57,7 @@
|
||||
"@storybook/vue3-vite": "catalog:",
|
||||
"@tailwindcss/vite": "catalog:",
|
||||
"@trivago/prettier-plugin-sort-imports": "catalog:",
|
||||
"@types/eslint-plugin-tailwindcss": "catalog:",
|
||||
"@types/fs-extra": "catalog:",
|
||||
"@types/jsdom": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
@@ -66,10 +67,14 @@
|
||||
"@vitest/coverage-v8": "catalog:",
|
||||
"@vitest/ui": "catalog:",
|
||||
"@vue/test-utils": "catalog:",
|
||||
"cross-env": "catalog:",
|
||||
"eslint": "catalog:",
|
||||
"eslint-config-prettier": "catalog:",
|
||||
"eslint-import-resolver-typescript": "catalog:",
|
||||
"eslint-plugin-import-x": "catalog:",
|
||||
"eslint-plugin-prettier": "catalog:",
|
||||
"eslint-plugin-storybook": "catalog:",
|
||||
"eslint-plugin-tailwindcss": "catalog:",
|
||||
"eslint-plugin-unused-imports": "catalog:",
|
||||
"eslint-plugin-vue": "catalog:",
|
||||
"fs-extra": "^11.2.0",
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
|
||||
/* --- */
|
||||
|
||||
--accent-primary: var(--color-charcoal-700);
|
||||
--backdrop: var(--color-white);
|
||||
--dialog-surface: var(--color-neutral-200);
|
||||
--node-component-border: var(--color-gray-400);
|
||||
@@ -154,13 +155,20 @@
|
||||
from var(--color-zinc-500) r g b / 10%
|
||||
);
|
||||
--node-component-widget-skeleton-surface: var(--color-zinc-300);
|
||||
--node-stroke: var(--color-stone-100);
|
||||
--node-stroke: var(--color-gray-400);
|
||||
--node-stroke-selected: var(--color-accent-primary);
|
||||
--node-stroke-error: var(--color-error);
|
||||
--node-stroke-executing: var(--color-blue-100);
|
||||
}
|
||||
|
||||
.dark-theme {
|
||||
--accent-primary: var(--color-pure-white);
|
||||
--backdrop: var(--color-neutral-900);
|
||||
--dialog-surface: var(--color-neutral-700);
|
||||
--node-component-border: var(--color-stone-200);
|
||||
--node-component-border-error: var(--color-danger-100);
|
||||
--node-component-border-executing: var(--color-blue-500);
|
||||
--node-component-border-selected: var(--color-charcoal-200);
|
||||
--node-component-header-icon: var(--color-slate-300);
|
||||
--node-component-header-surface: var(--color-charcoal-800);
|
||||
--node-component-outline: var(--color-white);
|
||||
@@ -176,7 +184,10 @@
|
||||
--node-component-tooltip-border: var(--color-slate-300);
|
||||
--node-component-tooltip-surface: var(--color-charcoal-800);
|
||||
--node-component-widget-skeleton-surface: var(--color-zinc-800);
|
||||
--node-stroke: var(--color-slate-100);
|
||||
--node-stroke: var(--color-stone-200);
|
||||
--node-stroke-selected: var(--color-pure-white);
|
||||
--node-stroke-error: var(--color-error);
|
||||
--node-stroke-executing: var(--color-blue-100);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -214,6 +225,9 @@
|
||||
--node-component-widget-skeleton-surface
|
||||
);
|
||||
--color-node-stroke: var(--node-stroke);
|
||||
--color-node-stroke-selected: var(--node-stroke-selected);
|
||||
--color-node-stroke-error: var(--node-stroke-error);
|
||||
--color-node-stroke-executing: var(--node-stroke-executing);
|
||||
}
|
||||
|
||||
@custom-variant dark-theme {
|
||||
@@ -645,13 +659,10 @@ button.comfy-close-menu-btn {
|
||||
}
|
||||
|
||||
span.drag-handle {
|
||||
width: 10px;
|
||||
height: 20px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
line-height: 5px;
|
||||
padding: 3px 4px;
|
||||
cursor: move;
|
||||
vertical-align: middle;
|
||||
margin-top: -0.4em;
|
||||
margin-left: -0.2em;
|
||||
@@ -1049,6 +1060,11 @@ audio.comfy-audio.empty-audio-widget {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-header {
|
||||
border-radius: 0px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-widgets {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
1
packages/design-system/src/icons/play.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none"><path fill="currentColor" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.3" d="m4 2 9.333 6L4 14V2Z"/></svg>
|
||||
|
After Width: | Height: | Size: 221 B |
@@ -82,7 +82,7 @@ export function formatSize(value?: number) {
|
||||
* - filename: 'file'
|
||||
* - suffix: 'txt'
|
||||
*/
|
||||
function getFilenameDetails(fullFilename: string) {
|
||||
export function getFilenameDetails(fullFilename: string) {
|
||||
if (fullFilename.includes('.')) {
|
||||
return {
|
||||
filename: fullFilename.split('.').slice(0, -1).join('.'),
|
||||
@@ -451,3 +451,26 @@ export function stringToLocale(locale: string): SupportedLocale {
|
||||
? (locale as SupportedLocale)
|
||||
: 'en'
|
||||
}
|
||||
|
||||
export function formatDuration(milliseconds: number): string {
|
||||
if (!milliseconds || milliseconds < 0) return '0s'
|
||||
|
||||
const totalSeconds = Math.floor(milliseconds / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const remainingSeconds = Math.floor(totalSeconds % 60)
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours}h`)
|
||||
}
|
||||
if (minutes > 0) {
|
||||
parts.push(`${minutes}m`)
|
||||
}
|
||||
if (remainingSeconds > 0 || parts.length === 0) {
|
||||
parts.push(`${remainingSeconds}s`)
|
||||
}
|
||||
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
1400
pnpm-lock.yaml
generated
@@ -3,60 +3,19 @@ packages:
|
||||
- packages/**
|
||||
|
||||
catalog:
|
||||
# Core frameworks
|
||||
typescript: ^5.9.2
|
||||
vue: ^3.5.13
|
||||
|
||||
# Build tools
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
'@eslint/js': ^9.35.0
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/json': ^2.2.380
|
||||
'@iconify/tailwind': ^1.1.3
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.0
|
||||
'@lobehub/i18n-cli': ^1.25.1
|
||||
'@nx/eslint': 21.4.1
|
||||
'@nx/playwright': 21.4.1
|
||||
'@nx/storybook': 21.4.1
|
||||
'@nx/vite': 21.4.1
|
||||
nx: 21.4.1
|
||||
tsx: ^4.15.6
|
||||
vite: ^5.4.19
|
||||
'@vitejs/plugin-vue': ^5.1.4
|
||||
'vite-plugin-dts': ^4.5.4
|
||||
vue-tsc: ^3.0.7
|
||||
|
||||
# Testing
|
||||
'happy-dom': ^15.11.0
|
||||
jsdom: ^26.1.0
|
||||
'@pinia/testing': ^0.1.5
|
||||
'@playwright/test': ^1.52.0
|
||||
'@vitest/coverage-v8': ^3.2.4
|
||||
'@vitest/ui': ^3.0.0
|
||||
vitest: ^3.2.4
|
||||
'@vue/test-utils': ^2.4.6
|
||||
|
||||
# Linting & Formatting
|
||||
'@eslint/js': ^9.35.0
|
||||
eslint: ^9.34.0
|
||||
'eslint-config-prettier': ^10.1.8
|
||||
'eslint-plugin-prettier': ^5.5.4
|
||||
'eslint-plugin-storybook': ^9.1.6
|
||||
'eslint-plugin-unused-imports': ^4.2.0
|
||||
'eslint-plugin-vue': ^10.4.0
|
||||
globals: ^15.9.0
|
||||
'@intlify/eslint-plugin-vue-i18n': ^4.1.0
|
||||
postcss-html: ^1.8.0
|
||||
prettier: ^3.3.2
|
||||
stylelint: ^16.24.0
|
||||
'typescript-eslint': ^8.44.0
|
||||
'vue-eslint-parser': ^10.2.0
|
||||
|
||||
# Vue ecosystem
|
||||
'@sentry/vue': ^8.48.0
|
||||
'@vueuse/core': ^11.0.0
|
||||
'@vueuse/integrations': ^13.9.0
|
||||
'vite-plugin-html': ^3.2.2
|
||||
'vite-plugin-vue-devtools': ^7.7.6
|
||||
pinia: ^2.1.7
|
||||
'vue-i18n': ^9.14.3
|
||||
'vue-router': ^4.4.3
|
||||
vuefire: ^3.2.1
|
||||
|
||||
# PrimeVue UI framework
|
||||
'@primeuix/forms': 0.0.2
|
||||
'@primeuix/styled': 0.3.2
|
||||
'@primeuix/utils': ^0.3.2
|
||||
@@ -64,58 +23,87 @@ catalog:
|
||||
'@primevue/forms': ^4.2.5
|
||||
'@primevue/icons': 4.2.5
|
||||
'@primevue/themes': ^4.2.5
|
||||
primeicons: ^7.0.0
|
||||
primevue: ^4.2.5
|
||||
|
||||
# Tailwind CSS and design
|
||||
'@iconify/json': ^2.2.380
|
||||
'@iconify-json/lucide': ^1.1.178
|
||||
'@iconify/tailwind': ^1.1.3
|
||||
'@tailwindcss/vite': ^4.1.12
|
||||
tailwindcss: ^4.1.12
|
||||
'tailwindcss-primeui': ^0.6.1
|
||||
'tw-animate-css': ^1.3.8
|
||||
'unplugin-icons': ^0.22.0
|
||||
'unplugin-vue-components': ^0.28.0
|
||||
|
||||
# Storybook
|
||||
'@sentry/vue': ^8.48.0
|
||||
'@storybook/addon-docs': ^9.1.1
|
||||
storybook: ^9.1.6
|
||||
'@storybook/vue3': ^9.1.1
|
||||
'@storybook/vue3-vite': ^9.1.1
|
||||
|
||||
# Data and validation
|
||||
algoliasearch: ^5.21.0
|
||||
axios: ^1.8.2
|
||||
firebase: ^11.6.0
|
||||
yjs: ^13.6.27
|
||||
zod: ^3.23.8
|
||||
'zod-validation-error': ^3.3.0
|
||||
|
||||
# Dev tools
|
||||
dotenv: ^16.4.5
|
||||
husky: ^9.0.11
|
||||
jiti: 2.4.2
|
||||
knip: ^5.62.0
|
||||
'lint-staged': ^15.2.7
|
||||
|
||||
# Type definitions
|
||||
'@tailwindcss/vite': ^4.1.12
|
||||
'@trivago/prettier-plugin-sort-imports': ^5.2.0
|
||||
'@types/eslint-plugin-tailwindcss': ^3.17.0
|
||||
'@types/fs-extra': ^11.0.4
|
||||
'@types/jsdom': ^21.1.7
|
||||
'@types/node': ^20.14.8
|
||||
'@types/semver': ^7.7.0
|
||||
'@types/three': ^0.169.0
|
||||
'vue-component-type-helpers': ^3.0.7
|
||||
'zod-to-json-schema': ^3.24.1
|
||||
'@vitejs/plugin-vue': ^5.1.4
|
||||
'@vitest/coverage-v8': ^3.2.4
|
||||
'@vitest/ui': ^3.0.0
|
||||
'@vue/test-utils': ^2.4.6
|
||||
'@vueuse/core': ^11.0.0
|
||||
'@vueuse/integrations': ^13.9.0
|
||||
algoliasearch: ^5.21.0
|
||||
axios: ^1.8.2
|
||||
cross-env: ^10.1.0
|
||||
dotenv: ^16.4.5
|
||||
eslint: ^9.34.0
|
||||
eslint-config-prettier: ^10.1.8
|
||||
eslint-import-resolver-typescript: ^4.4.4
|
||||
eslint-plugin-import-x: ^4.16.1
|
||||
eslint-plugin-prettier: ^5.5.4
|
||||
eslint-plugin-storybook: ^9.1.6
|
||||
eslint-plugin-tailwindcss: 4.0.0-beta.0
|
||||
eslint-plugin-unused-imports: ^4.2.0
|
||||
eslint-plugin-vue: ^10.4.0
|
||||
firebase: ^11.6.0
|
||||
globals: ^15.9.0
|
||||
happy-dom: ^15.11.0
|
||||
husky: ^9.0.11
|
||||
jiti: 2.4.2
|
||||
jsdom: ^26.1.0
|
||||
knip: ^5.62.0
|
||||
lint-staged: ^15.2.7
|
||||
nx: 21.4.1
|
||||
pinia: ^2.1.7
|
||||
postcss-html: ^1.8.0
|
||||
prettier: ^3.3.2
|
||||
primeicons: ^7.0.0
|
||||
primevue: ^4.2.5
|
||||
storybook: ^9.1.6
|
||||
stylelint: ^16.24.0
|
||||
tailwindcss: ^4.1.12
|
||||
tailwindcss-primeui: ^0.6.1
|
||||
tsx: ^4.15.6
|
||||
tw-animate-css: ^1.3.8
|
||||
typescript: ^5.9.2
|
||||
typescript-eslint: ^8.44.0
|
||||
unplugin-icons: ^0.22.0
|
||||
unplugin-vue-components: ^0.28.0
|
||||
vite: ^5.4.19
|
||||
vite-plugin-dts: ^4.5.4
|
||||
vite-plugin-html: ^3.2.2
|
||||
vite-plugin-vue-devtools: ^7.7.6
|
||||
vitest: ^3.2.4
|
||||
vue: ^3.5.13
|
||||
vue-component-type-helpers: ^3.0.7
|
||||
vue-eslint-parser: ^10.2.0
|
||||
vue-i18n: ^9.14.3
|
||||
vue-router: ^4.4.3
|
||||
vue-tsc: ^3.0.7
|
||||
vuefire: ^3.2.1
|
||||
yjs: ^13.6.27
|
||||
zod: ^3.23.8
|
||||
zod-to-json-schema: ^3.24.1
|
||||
zod-validation-error: ^3.3.0
|
||||
|
||||
# i18n
|
||||
'@alloc/quick-lru': ^5.2.0
|
||||
'@lobehub/i18n-cli': ^1.25.1
|
||||
'@trivago/prettier-plugin-sort-imports': ^5.2.0
|
||||
cleanupUnusedCatalogs: true
|
||||
|
||||
overrides:
|
||||
'@types/eslint': '-'
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- '@firebase/util'
|
||||
- protobufjs
|
||||
- unrs-resolver
|
||||
- vue-demi
|
||||
|
||||
onlyBuiltDependencies:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<router-view />
|
||||
<ProgressSpinner
|
||||
v-if="isLoading"
|
||||
class="absolute inset-0 flex justify-center items-center h-[unset]"
|
||||
class="absolute inset-0 flex h-[unset] items-center justify-center"
|
||||
/>
|
||||
<GlobalDialog />
|
||||
<BlockUI full-screen :blocked="isLoading" />
|
||||
|
||||
@@ -5,7 +5,15 @@
|
||||
:class="{ 'is-dragging': isDragging, 'is-docked': isDocked }"
|
||||
>
|
||||
<div ref="panelRef" class="actionbar-content flex items-center select-none">
|
||||
<span ref="dragHandleRef" class="drag-handle cursor-move mr-2" />
|
||||
<span
|
||||
ref="dragHandleRef"
|
||||
:class="
|
||||
cn(
|
||||
'drag-handle cursor-grab w-3 h-max mr-2',
|
||||
isDragging && 'cursor-grabbing'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<ComfyQueueButton />
|
||||
</div>
|
||||
</Panel>
|
||||
@@ -26,6 +34,7 @@ import type { Ref } from 'vue'
|
||||
import { computed, inject, nextTick, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import ComfyQueueButton from './ComfyQueueButton.vue'
|
||||
|
||||
@@ -257,8 +266,4 @@ watch([isDragging, isOverlappingWithTopMenu], ([dragging, overlapping]) => {
|
||||
:deep(.p-panel-header) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
@apply w-3 h-max;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex h-full flex-col">
|
||||
<Tabs
|
||||
:key="$i18n.locale"
|
||||
v-model:value="bottomPanelStore.activeBottomPanelTabId"
|
||||
>
|
||||
<TabList pt:tab-list="border-none">
|
||||
<div class="w-full flex justify-between">
|
||||
<div class="flex w-full justify-between">
|
||||
<div class="tabs-container">
|
||||
<Tab
|
||||
v-for="tab in bottomPanelStore.bottomPanelTabs"
|
||||
:key="tab.id"
|
||||
:value="tab.id"
|
||||
class="p-3 border-none"
|
||||
class="border-none p-3"
|
||||
>
|
||||
<span class="font-bold">
|
||||
{{ getTabDisplayTitle(tab) }}
|
||||
@@ -41,7 +41,7 @@
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<!-- h-0 to force the div to grow -->
|
||||
<div class="grow h-0">
|
||||
<div class="h-0 grow">
|
||||
<ExtensionSlot
|
||||
v-if="
|
||||
bottomPanelStore.bottomPanelVisible &&
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col p-4">
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<div class="flex h-full flex-col p-4">
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<ShortcutsList
|
||||
:commands="essentialsCommands"
|
||||
:subcategories="essentialsSubcategories"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<template>
|
||||
<div class="shortcuts-list flex justify-center">
|
||||
<div class="grid gap-4 md:gap-24 h-full grid-cols-1 md:grid-cols-3 w-[90%]">
|
||||
<div class="grid h-full w-[90%] grid-cols-1 gap-4 md:grid-cols-3 md:gap-24">
|
||||
<div
|
||||
v-for="(subcategoryCommands, subcategory) in filteredSubcategories"
|
||||
:key="subcategory"
|
||||
class="flex flex-col"
|
||||
>
|
||||
<h3
|
||||
class="subcategory-title text-xs font-bold uppercase tracking-wide text-surface-600 dark-theme:text-surface-400 mb-4"
|
||||
class="subcategory-title mb-4 text-xs font-bold tracking-wide text-surface-600 uppercase dark-theme:text-surface-400"
|
||||
>
|
||||
{{ getSubcategoryTitle(subcategory) }}
|
||||
</h3>
|
||||
@@ -16,7 +16,7 @@
|
||||
<div
|
||||
v-for="command in subcategoryCommands"
|
||||
:key="command.id"
|
||||
class="shortcut-item flex justify-between items-center py-2 rounded hover:bg-surface-100 dark-theme:hover:bg-surface-700 transition-colors duration-200"
|
||||
class="shortcut-item flex items-center justify-between rounded py-2 transition-colors duration-200 hover:bg-surface-100 dark-theme:hover:bg-surface-700"
|
||||
>
|
||||
<div class="shortcut-info grow pr-4">
|
||||
<div class="shortcut-name text-sm font-medium">
|
||||
@@ -32,7 +32,7 @@
|
||||
<span
|
||||
v-for="key in command.keybinding!.combo.getKeySequences()"
|
||||
:key="key"
|
||||
class="key-badge px-2 py-1 text-xs font-mono bg-surface-200 dark-theme:bg-surface-600 rounded border min-w-6 text-center"
|
||||
class="key-badge min-w-6 rounded border bg-surface-200 px-2 py-1 text-center font-mono text-xs dark-theme:bg-surface-600"
|
||||
>
|
||||
{{ formatKey(key) }}
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col p-4">
|
||||
<div class="flex-1 min-h-0 overflow-auto">
|
||||
<div class="flex h-full flex-col p-4">
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<ShortcutsList
|
||||
:commands="viewControlsCommands"
|
||||
:subcategories="viewControlsSubcategories"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="relative overflow-hidden h-full w-full bg-neutral-900"
|
||||
class="relative h-full w-full overflow-hidden bg-neutral-900"
|
||||
>
|
||||
<div class="p-terminal rounded-none h-full w-full p-2">
|
||||
<div ref="terminalEl" class="h-full terminal-host" />
|
||||
<div class="p-terminal h-full w-full rounded-none p-2">
|
||||
<div ref="terminalEl" class="terminal-host h-full" />
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip.left="{
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="bg-black h-full w-full">
|
||||
<div class="h-full w-full bg-black">
|
||||
<p v-if="errorMessage" class="p-4 text-center">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<ProgressSpinner
|
||||
v-else-if="loading"
|
||||
class="relative inset-0 flex justify-center items-center h-full z-10"
|
||||
class="relative inset-0 z-10 flex h-full items-center justify-center"
|
||||
/>
|
||||
<BaseTerminal v-show="!loading" @created="terminalCreated" />
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
showDelay: 512
|
||||
}"
|
||||
href="#"
|
||||
class="cursor-pointer p-breadcrumb-item-link"
|
||||
class="p-breadcrumb-item-link cursor-pointer"
|
||||
:class="{
|
||||
'flex items-center gap-1': isActive,
|
||||
'p-breadcrumb-item-link-menu-visible': menu?.overlayVisible,
|
||||
@@ -37,7 +37,7 @@
|
||||
v-if="isEditing"
|
||||
ref="itemInputRef"
|
||||
v-model="itemLabel"
|
||||
class="fixed z-10000 text-[.8rem] px-2 py-2"
|
||||
class="fixed z-10000 px-2 py-2 text-[.8rem]"
|
||||
@blur="inputBlur(true)"
|
||||
@click.stop
|
||||
@keydown.enter="inputBlur(true)"
|
||||
|
||||
@@ -12,6 +12,7 @@ const iconGroupClasses = cn(
|
||||
'outline-hidden border-none p-0 rounded-lg',
|
||||
'bg-white dark-theme:bg-zinc-700',
|
||||
'text-neutral-950 dark-theme:text-white',
|
||||
'transition-all duration-200',
|
||||
'cursor-pointer'
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div class="relative inline-flex items-center">
|
||||
<IconButton @click="toggle">
|
||||
<i class="icon-[lucide--more-vertical] text-sm" />
|
||||
<IconButton :size="size" :type="type" @click="toggle">
|
||||
<i v-if="!isVertical" class="icon-[lucide--ellipsis] text-sm" />
|
||||
<i v-else class="icon-[lucide--more-vertical] text-sm" />
|
||||
</IconButton>
|
||||
|
||||
<Popover
|
||||
@@ -13,8 +14,10 @@
|
||||
:close-on-escape="true"
|
||||
unstyled
|
||||
:pt="pt"
|
||||
@show="$emit('menuOpened')"
|
||||
@hide="$emit('menuClosed')"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-2 min-w-40">
|
||||
<div class="flex min-w-40 flex-col gap-2 p-2">
|
||||
<slot :close="hide" />
|
||||
</div>
|
||||
</Popover>
|
||||
@@ -25,12 +28,28 @@
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { BaseButtonProps } from '@/types/buttonTypes'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import IconButton from './IconButton.vue'
|
||||
|
||||
interface MoreButtonProps extends BaseButtonProps {
|
||||
isVertical?: boolean
|
||||
}
|
||||
|
||||
const popover = ref<InstanceType<typeof Popover>>()
|
||||
|
||||
const {
|
||||
size = 'md',
|
||||
type = 'secondary',
|
||||
isVertical = false
|
||||
} = defineProps<MoreButtonProps>()
|
||||
|
||||
defineEmits<{
|
||||
menuOpened: []
|
||||
menuClosed: []
|
||||
}>()
|
||||
|
||||
const toggle = (event: Event) => {
|
||||
popover.value?.toggle(event)
|
||||
}
|
||||
@@ -45,7 +64,7 @@ const pt = computed(() => ({
|
||||
},
|
||||
content: {
|
||||
class: cn(
|
||||
'mt-2 rounded-lg',
|
||||
'mt-1 rounded-lg',
|
||||
'bg-white dark-theme:bg-zinc-800',
|
||||
'text-neutral dark-theme:text-white',
|
||||
'shadow-lg',
|
||||
|
||||
@@ -11,7 +11,15 @@ import CardTop from './CardTop.vue'
|
||||
|
||||
interface CardStoryArgs {
|
||||
// CardContainer props
|
||||
containerRatio: 'square' | 'portrait' | 'tallPortrait'
|
||||
containerSize: 'mini' | 'compact' | 'regular' | 'portrait' | 'tall'
|
||||
variant: 'default' | 'ghost' | 'outline'
|
||||
rounded: 'none' | 'sm' | 'lg' | 'xl'
|
||||
customAspectRatio?: string
|
||||
hasBorder: boolean
|
||||
hasBackground: boolean
|
||||
hasShadow: boolean
|
||||
hasCursor: boolean
|
||||
customClass: string
|
||||
maxWidth: number
|
||||
minWidth: number
|
||||
|
||||
@@ -44,10 +52,44 @@ interface CardStoryArgs {
|
||||
const meta: Meta<CardStoryArgs> = {
|
||||
title: 'Components/Card/Card',
|
||||
argTypes: {
|
||||
containerRatio: {
|
||||
containerSize: {
|
||||
control: 'select',
|
||||
options: ['square', 'portrait', 'tallPortrait'],
|
||||
description: 'Card container aspect ratio'
|
||||
options: ['mini', 'compact', 'regular', 'portrait', 'tall'],
|
||||
description: 'Card container size preset'
|
||||
},
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['default', 'ghost', 'outline'],
|
||||
description: 'Card visual variant'
|
||||
},
|
||||
rounded: {
|
||||
control: 'select',
|
||||
options: ['none', 'sm', 'lg', 'xl'],
|
||||
description: 'Border radius size'
|
||||
},
|
||||
customAspectRatio: {
|
||||
control: 'text',
|
||||
description: 'Custom aspect ratio (e.g., "16/9")'
|
||||
},
|
||||
hasBorder: {
|
||||
control: 'boolean',
|
||||
description: 'Add border styling'
|
||||
},
|
||||
hasBackground: {
|
||||
control: 'boolean',
|
||||
description: 'Add background styling'
|
||||
},
|
||||
hasShadow: {
|
||||
control: 'boolean',
|
||||
description: 'Add shadow styling'
|
||||
},
|
||||
hasCursor: {
|
||||
control: 'boolean',
|
||||
description: 'Add cursor pointer'
|
||||
},
|
||||
customClass: {
|
||||
control: 'text',
|
||||
description: 'Additional custom CSS classes'
|
||||
},
|
||||
topRatio: {
|
||||
control: 'select',
|
||||
@@ -149,8 +191,15 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
template: `
|
||||
<div class="min-h-screen">
|
||||
<CardContainer
|
||||
:ratio="args.containerRatio"
|
||||
class="max-w-[320px] mx-auto"
|
||||
:size="args.containerSize"
|
||||
:variant="args.variant"
|
||||
:rounded="args.rounded"
|
||||
:custom-aspect-ratio="args.customAspectRatio"
|
||||
:has-border="args.hasBorder"
|
||||
:has-background="args.hasBackground"
|
||||
:has-shadow="args.hasShadow"
|
||||
:has-cursor="args.hasCursor"
|
||||
:class="args.customClass || 'max-w-[320px] mx-auto'"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop :ratio="args.topRatio">
|
||||
@@ -205,7 +254,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<CardBottom class="p-3 bg-neutral-100">
|
||||
<CardBottom>
|
||||
<CardTitle v-if="args.showTitle">{{ args.title }}</CardTitle>
|
||||
<CardDescription v-if="args.showDescription">{{ args.description }}</CardDescription>
|
||||
</CardBottom>
|
||||
@@ -218,7 +267,15 @@ const createCardTemplate = (args: CardStoryArgs) => ({
|
||||
export const Default: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'portrait',
|
||||
containerSize: 'portrait',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -243,7 +300,15 @@ export const Default: Story = {
|
||||
export const SquareCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'square',
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -268,7 +333,15 @@ export const SquareCard: Story = {
|
||||
export const TallPortraitCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'tallPortrait',
|
||||
containerSize: 'tall',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: true,
|
||||
showTopRight: true,
|
||||
@@ -293,7 +366,15 @@ export const TallPortraitCard: Story = {
|
||||
export const ImageCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'portrait',
|
||||
containerSize: 'portrait',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
@@ -314,10 +395,50 @@ export const ImageCard: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
export const MiniCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'mini',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: true,
|
||||
showTitle: true,
|
||||
showDescription: false,
|
||||
title: 'Mini Asset',
|
||||
description: '',
|
||||
backgroundColor: '#06b6d4',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['Asset'],
|
||||
showFileSize: true,
|
||||
fileSize: '124 KB',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const MinimalCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'square',
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
@@ -338,10 +459,209 @@ export const MinimalCard: Story = {
|
||||
}
|
||||
}
|
||||
|
||||
export const GhostVariant: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'compact',
|
||||
variant: 'ghost',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: true,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Workflow Template',
|
||||
description: 'Ghost variant for workflow templates',
|
||||
backgroundColor: '#10b981',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['Template'],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const OutlineVariant: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'outline',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: true,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: false,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Outline Card',
|
||||
description: 'Card with outline variant styling',
|
||||
backgroundColor: '#f59e0b',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: [],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomAspectRatio: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
customAspectRatio: '16/9',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'landscape',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: true,
|
||||
showTitle: true,
|
||||
showDescription: false,
|
||||
title: 'Wide Format Card',
|
||||
description: '',
|
||||
backgroundColor: '#8b5cf6',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: ['Wide'],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const RoundedNone: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'none',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: false,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Sharp Corners',
|
||||
description: 'Card with no border radius',
|
||||
backgroundColor: '#dc2626',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: [],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const RoundedXL: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'xl',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: false,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Extra Rounded',
|
||||
description: 'Card with extra large border radius',
|
||||
backgroundColor: '#059669',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: [],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const NoStylesCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerSize: 'regular',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: false,
|
||||
hasBackground: false,
|
||||
hasShadow: false,
|
||||
hasCursor: true,
|
||||
customClass: 'bg-gradient-to-br from-blue-500 to-purple-600',
|
||||
topRatio: 'square',
|
||||
showTopLeft: false,
|
||||
showTopRight: false,
|
||||
showBottomLeft: false,
|
||||
showBottomRight: false,
|
||||
showTitle: true,
|
||||
showDescription: true,
|
||||
title: 'Custom Styled Card',
|
||||
description: 'Card with all default styles removed and custom gradient',
|
||||
backgroundColor: 'transparent',
|
||||
showImage: false,
|
||||
imageUrl: '',
|
||||
tags: [],
|
||||
showFileSize: false,
|
||||
fileSize: '',
|
||||
showFileType: false,
|
||||
fileType: ''
|
||||
}
|
||||
}
|
||||
|
||||
export const FullFeaturedCard: Story = {
|
||||
render: (args: CardStoryArgs) => createCardTemplate(args),
|
||||
args: {
|
||||
containerRatio: 'tallPortrait',
|
||||
containerSize: 'tall',
|
||||
variant: 'default',
|
||||
rounded: 'lg',
|
||||
customAspectRatio: '',
|
||||
hasBorder: true,
|
||||
hasBackground: true,
|
||||
hasShadow: true,
|
||||
hasCursor: true,
|
||||
customClass: '',
|
||||
topRatio: 'square',
|
||||
showTopLeft: true,
|
||||
showTopRight: true,
|
||||
|
||||
@@ -8,26 +8,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { ratio = 'square', type } = defineProps<{
|
||||
ratio?: 'smallSquare' | 'square' | 'portrait' | 'tallPortrait'
|
||||
type?: string
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
size = 'regular',
|
||||
variant = 'default',
|
||||
rounded = 'md',
|
||||
customAspectRatio,
|
||||
hasBorder = true,
|
||||
hasBackground = true,
|
||||
hasShadow = true,
|
||||
hasCursor = true,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
size?: 'mini' | 'compact' | 'regular' | 'portrait' | 'tall'
|
||||
variant?: 'default' | 'ghost' | 'outline'
|
||||
rounded?: 'none' | 'md' | 'lg' | 'xl'
|
||||
customAspectRatio?: string
|
||||
hasBorder?: boolean
|
||||
hasBackground?: boolean
|
||||
hasShadow?: boolean
|
||||
hasCursor?: boolean
|
||||
class?: string
|
||||
}>()
|
||||
|
||||
// Base structure classes
|
||||
const structureClasses = 'flex flex-col overflow-hidden'
|
||||
|
||||
// Rounded corners
|
||||
const roundedClasses = {
|
||||
none: 'rounded-none',
|
||||
md: 'rounded',
|
||||
lg: 'rounded-lg',
|
||||
xl: 'rounded-xl'
|
||||
} as const
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
const baseClasses =
|
||||
'cursor-pointer flex flex-col bg-white dark-theme:bg-zinc-800 rounded-lg shadow-sm border border-zinc-200 dark-theme:border-zinc-700 overflow-hidden'
|
||||
|
||||
if (type === 'workflow-template-card') {
|
||||
return `cursor-pointer p-2 flex flex-col hover:bg-white dark-theme:hover:bg-zinc-800 rounded-lg transition-background duration-200 ease-in-out`
|
||||
// Variant styles
|
||||
const variantClasses = {
|
||||
default: cn(
|
||||
hasBackground && 'bg-white dark-theme:bg-zinc-800',
|
||||
hasBorder && 'border border-zinc-200 dark-theme:border-zinc-700',
|
||||
hasShadow && 'shadow-sm',
|
||||
hasCursor && 'cursor-pointer'
|
||||
),
|
||||
ghost: cn(
|
||||
hasCursor && 'cursor-pointer',
|
||||
'p-2 transition-colors duration-200'
|
||||
),
|
||||
outline: cn(
|
||||
hasBorder && 'border-2 border-zinc-300 dark-theme:border-zinc-600',
|
||||
hasCursor && 'cursor-pointer',
|
||||
'hover:border-zinc-400 dark-theme:hover:border-zinc-500 transition-colors'
|
||||
)
|
||||
}
|
||||
|
||||
const ratioClasses = {
|
||||
smallSquare: 'aspect-240/311',
|
||||
square: 'aspect-256/308',
|
||||
portrait: 'aspect-256/325',
|
||||
tallPortrait: 'aspect-256/353'
|
||||
}
|
||||
// Size/aspect ratio
|
||||
const aspectRatio = customAspectRatio
|
||||
? `aspect-[${customAspectRatio}]`
|
||||
: {
|
||||
mini: 'aspect-100/120',
|
||||
compact: 'aspect-240/311',
|
||||
regular: 'aspect-256/308',
|
||||
portrait: 'aspect-256/325',
|
||||
tall: 'aspect-256/353'
|
||||
}[size]
|
||||
|
||||
return `${baseClasses} ${ratioClasses[ratio]}`
|
||||
return cn(
|
||||
structureClasses,
|
||||
roundedClasses[rounded],
|
||||
variantClasses[variant],
|
||||
aspectRatio,
|
||||
customClass
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="text-zinc-500 dark-theme:text-zinc-400 text-xs line-clamp-2 h-7">
|
||||
<div class="line-clamp-2 h-7 text-xs text-zinc-500 dark-theme:text-zinc-400">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
<template>
|
||||
<div :class="topStyle">
|
||||
<slot class="absolute top-0 left-0 w-full h-full"></slot>
|
||||
<slot class="absolute top-0 left-0 h-full w-full"></slot>
|
||||
|
||||
<div
|
||||
v-if="slots['top-left']"
|
||||
class="absolute top-2 left-2 flex gap-2 flex-wrap justify-start"
|
||||
>
|
||||
<div v-if="slots['top-left']" :class="slotClasses['top-left']">
|
||||
<slot name="top-left"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots['top-right']"
|
||||
class="absolute top-2 right-2 flex gap-2 flex-wrap justify-end"
|
||||
>
|
||||
<div v-if="slots['top-right']" :class="slotClasses['top-right']">
|
||||
<slot name="top-right"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots['bottom-left']"
|
||||
class="absolute bottom-2 left-2 flex gap-2 flex-wrap justify-start"
|
||||
>
|
||||
<div v-if="slots['center-left']" :class="slotClasses['center-left']">
|
||||
<slot name="center-left"></slot>
|
||||
</div>
|
||||
|
||||
<div v-if="slots['center-right']" :class="slotClasses['center-right']">
|
||||
<slot name="center-right"></slot>
|
||||
</div>
|
||||
|
||||
<div v-if="slots['bottom-left']" :class="slotClasses['bottom-left']">
|
||||
<slot name="bottom-left"></slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="slots['bottom-right']"
|
||||
class="absolute bottom-2 right-2 flex gap-2 flex-wrap justify-end"
|
||||
>
|
||||
<div v-if="slots['bottom-right']" :class="slotClasses['bottom-right']">
|
||||
<slot name="bottom-right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
@@ -35,10 +31,26 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, useSlots } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const slots = useSlots()
|
||||
|
||||
const { ratio = 'square' } = defineProps<{
|
||||
const {
|
||||
ratio = 'square',
|
||||
topLeftClass,
|
||||
topRightClass,
|
||||
centerLeftClass,
|
||||
centerRightClass,
|
||||
bottomLeftClass,
|
||||
bottomRightClass
|
||||
} = defineProps<{
|
||||
ratio?: 'square' | 'landscape'
|
||||
topLeftClass?: string
|
||||
topRightClass?: string
|
||||
centerLeftClass?: string
|
||||
centerRightClass?: string
|
||||
bottomLeftClass?: string
|
||||
bottomRightClass?: string
|
||||
}>()
|
||||
|
||||
const topStyle = computed(() => {
|
||||
@@ -51,4 +63,26 @@ const topStyle = computed(() => {
|
||||
|
||||
return `${baseClasses} ${ratioClasses[ratio]}`
|
||||
})
|
||||
|
||||
// Get default classes for each slot position
|
||||
const defaultSlotClasses = {
|
||||
'top-left': 'absolute top-2 left-2 flex flex-wrap justify-start gap-2',
|
||||
'top-right': 'absolute top-2 right-2 flex flex-wrap justify-end gap-2',
|
||||
'center-left':
|
||||
'absolute top-1/2 left-2 flex -translate-y-1/2 flex-wrap justify-start gap-2',
|
||||
'center-right':
|
||||
'absolute top-1/2 right-2 flex -translate-y-1/2 flex-wrap justify-end gap-2',
|
||||
'bottom-left': 'absolute bottom-2 left-2 flex flex-wrap justify-start gap-2',
|
||||
'bottom-right': 'absolute right-2 bottom-2 flex flex-wrap justify-end gap-2'
|
||||
}
|
||||
|
||||
// Compute all slot classes once and cache them
|
||||
const slotClasses = computed(() => ({
|
||||
'top-left': cn(defaultSlotClasses['top-left'], topLeftClass),
|
||||
'top-right': cn(defaultSlotClasses['top-right'], topRightClass),
|
||||
'center-left': cn(defaultSlotClasses['center-left'], centerLeftClass),
|
||||
'center-right': cn(defaultSlotClasses['center-right'], centerRightClass),
|
||||
'bottom-left': cn(defaultSlotClasses['bottom-left'], bottomLeftClass),
|
||||
'bottom-right': cn(defaultSlotClasses['bottom-right'], bottomRightClass)
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
<template>
|
||||
<div
|
||||
class="inline-flex justify-center items-center gap-1 shrink-0 py-1 px-2 text-xs bg-[#D9D9D966]/40 rounded font-bold text-white/90"
|
||||
>
|
||||
<slot name="icon" class="text-xs text-white/90"></slot>
|
||||
<div :class="chipClasses">
|
||||
<slot name="icon"></slot>
|
||||
<span>{{ label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const { label } = defineProps<{
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
const { label, variant = 'dark' } = defineProps<{
|
||||
label: string
|
||||
variant?: 'dark' | 'light'
|
||||
}>()
|
||||
|
||||
const baseClasses =
|
||||
'inline-flex shrink-0 items-center justify-center gap-1 rounded px-2 py-1 text-xs font-bold'
|
||||
|
||||
const variantStyles = {
|
||||
dark: 'bg-zinc-500/40 text-white/90',
|
||||
light: 'backdrop-blur-[2px] bg-white/50 text-zinc-900 dark-theme:text-white'
|
||||
}
|
||||
|
||||
const chipClasses = computed(() => {
|
||||
return cn(baseClasses, variantStyles[variant])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
icon="pi pi-exclamation-triangle"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
class="h-min my-2 px-1 max-w-xs"
|
||||
class="my-2 h-min max-w-xs px-1"
|
||||
:title="props.error"
|
||||
:pt="{
|
||||
text: { class: 'overflow-hidden text-ellipsis' }
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div class="image-upload-wrapper">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="preview-box border rounded p-2 w-16 h-16 flex items-center justify-center"
|
||||
class="preview-box flex h-16 w-16 items-center justify-center rounded border p-2"
|
||||
:class="{ 'bg-gray-100 dark-theme:bg-gray-800': !modelValue }"
|
||||
>
|
||||
<img
|
||||
v-if="modelValue"
|
||||
:src="modelValue"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
class="max-h-full max-w-full object-contain"
|
||||
/>
|
||||
<i v-else class="pi pi-image text-gray-400 text-xl" />
|
||||
<i v-else class="pi pi-image text-xl text-gray-400" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
@@ -34,7 +34,8 @@ import InputNumber from 'primevue/inputnumber'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Select from 'primevue/select'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { type Component, markRaw } from 'vue'
|
||||
import { markRaw } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import BackgroundImageUpload from '@/components/common/BackgroundImageUpload.vue'
|
||||
import CustomFormValue from '@/components/common/CustomFormValue.vue'
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative overflow-hidden w-full h-full flex items-center justify-center"
|
||||
class="relative flex h-full w-full items-center justify-center overflow-hidden"
|
||||
:class="containerClass"
|
||||
>
|
||||
<Skeleton
|
||||
v-if="!isImageLoaded"
|
||||
@@ -22,7 +23,7 @@
|
||||
/>
|
||||
<div
|
||||
v-if="hasError"
|
||||
class="absolute inset-0 flex items-center justify-center bg-surface-50 dark-theme:bg-surface-800 text-muted"
|
||||
class="absolute inset-0 flex items-center justify-center bg-surface-50 text-muted dark-theme:bg-surface-800"
|
||||
>
|
||||
<img
|
||||
src="/assets/images/default-template.png"
|
||||
@@ -41,17 +42,20 @@ import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
|
||||
import { useMediaCache } from '@/services/mediaCacheService'
|
||||
import type { ClassValue } from '@/utils/tailwindUtil'
|
||||
|
||||
const {
|
||||
src,
|
||||
alt = '',
|
||||
containerClass = '',
|
||||
imageClass = '',
|
||||
imageStyle,
|
||||
rootMargin = '300px'
|
||||
} = defineProps<{
|
||||
src: string
|
||||
alt?: string
|
||||
imageClass?: string | string[] | Record<string, boolean>
|
||||
containerClass?: ClassValue
|
||||
imageClass?: ClassValue
|
||||
imageStyle?: Record<string, any>
|
||||
rootMargin?: string
|
||||
}>()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="no-results-placeholder p-8 h-full" :class="props.class">
|
||||
<div class="no-results-placeholder h-full p-8" :class="props.class">
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex flex-col items-center">
|
||||
<i :class="icon" style="font-size: 3rem; margin-bottom: 1rem" />
|
||||
<h3>{{ title }}</h3>
|
||||
<p :class="textClass" class="whitespace-pre-line text-center">
|
||||
<p :class="textClass" class="text-center whitespace-pre-line">
|
||||
{{ message }}
|
||||
</p>
|
||||
<Button
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</IconField>
|
||||
<div
|
||||
v-if="filters?.length"
|
||||
class="search-filters pt-2 flex flex-wrap gap-2"
|
||||
class="search-filters flex flex-wrap gap-2 pt-2"
|
||||
>
|
||||
<SearchFilterChip
|
||||
v-for="filter in filters"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="system-stats">
|
||||
<div class="mb-6">
|
||||
<h2 class="text-2xl font-semibold mb-4">
|
||||
<h2 class="mb-4 text-2xl font-semibold">
|
||||
{{ $t('g.systemInfo') }}
|
||||
</h2>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
@@ -17,7 +17,7 @@
|
||||
<Divider />
|
||||
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold mb-4">
|
||||
<h2 class="mb-4 text-2xl font-semibold">
|
||||
{{ $t('g.devices') }}
|
||||
</h2>
|
||||
<TabView v-if="props.stats.devices.length > 1">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Tree
|
||||
v-model:expanded-keys="expandedKeys"
|
||||
v-model:selection-keys="selectionKeys"
|
||||
class="tree-explorer py-0 px-2 2xl:px-4"
|
||||
class="tree-explorer px-2 py-0 2xl:px-4"
|
||||
:class="props.class"
|
||||
:value="renderedRoot.children"
|
||||
selection-mode="single"
|
||||
@@ -47,9 +47,11 @@ import { useTreeFolderOperations } from '@/composables/tree/useTreeFolderOperati
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import {
|
||||
InjectKeyExpandedKeys,
|
||||
InjectKeyHandleEditLabelFunction,
|
||||
type RenderedTreeExplorerNode,
|
||||
type TreeExplorerNode
|
||||
InjectKeyHandleEditLabelFunction
|
||||
} from '@/types/treeExplorerTypes'
|
||||
import type {
|
||||
RenderedTreeExplorerNode,
|
||||
TreeExplorerNode
|
||||
} from '@/types/treeExplorerTypes'
|
||||
import { combineTrees, findNodeByKey } from '@/utils/treeUtil'
|
||||
|
||||
|
||||
@@ -45,10 +45,10 @@ import {
|
||||
usePragmaticDraggable,
|
||||
usePragmaticDroppable
|
||||
} from '@/composables/usePragmaticDragAndDrop'
|
||||
import {
|
||||
InjectKeyHandleEditLabelFunction,
|
||||
type RenderedTreeExplorerNode,
|
||||
type TreeExplorerDragAndDropData
|
||||
import { InjectKeyHandleEditLabelFunction } from '@/types/treeExplorerTypes'
|
||||
import type {
|
||||
RenderedTreeExplorerNode,
|
||||
TreeExplorerDragAndDropData
|
||||
} from '@/types/treeExplorerTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
:class="{
|
||||
'pi pi-spin pi-spinner text-neutral-400':
|
||||
validationState === ValidationState.LOADING,
|
||||
'pi pi-check text-green-500 cursor-pointer':
|
||||
'pi pi-check cursor-pointer text-green-500':
|
||||
validationState === ValidationState.VALID,
|
||||
'pi pi-times text-red-500 cursor-pointer':
|
||||
'pi pi-times cursor-pointer text-red-500':
|
||||
validationState === ValidationState.INVALID
|
||||
}"
|
||||
@click="validateUrl(props.modelValue)"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
severity="secondary"
|
||||
icon="pi pi-dollar"
|
||||
rounded
|
||||
class="text-amber-400 p-1"
|
||||
class="p-1 text-amber-400"
|
||||
/>
|
||||
<div :class="textClass">{{ formattedBalance }}</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
<script setup lang="ts" generic="T">
|
||||
import { useElementSize, useScroll, whenever } from '@vueuse/core'
|
||||
import { clamp, debounce } from 'es-toolkit/compat'
|
||||
import { type CSSProperties, computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
type GridState = {
|
||||
start: number
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</template>
|
||||
|
||||
<template #header>
|
||||
<SearchBox v-model="searchQuery" class="max-w-[384px]" />
|
||||
<SearchBox v-model="searchQuery" size="lg" class="max-w-[384px]" />
|
||||
</template>
|
||||
|
||||
<template #header-right-area>
|
||||
@@ -36,7 +36,7 @@
|
||||
</template>
|
||||
|
||||
<template #contentFilter>
|
||||
<div class="relative px-6 pt-2 pb-4 flex gap-2 flex-wrap">
|
||||
<div class="relative flex flex-wrap gap-2 px-6 pt-2 pb-4">
|
||||
<!-- Model Filter -->
|
||||
<MultiSelect
|
||||
v-model="selectedModelObjects"
|
||||
@@ -87,7 +87,7 @@
|
||||
v-model="sortBy"
|
||||
:label="$t('templateWorkflows.sorting', 'Sort by')"
|
||||
:options="sortOptions"
|
||||
class="min-w-[270px]"
|
||||
class="w-62.5"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--arrow-up-down]" />
|
||||
@@ -97,7 +97,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="!isLoading"
|
||||
class="px-6 pt-4 pb-2 text-2xl font-semibold text-neutral"
|
||||
class="text-neutral px-6 pt-4 pb-2 text-2xl font-semibold"
|
||||
>
|
||||
<span>
|
||||
{{ pageTitle }}
|
||||
@@ -109,10 +109,10 @@
|
||||
<!-- No Results State (only show when loaded and no results) -->
|
||||
<div
|
||||
v-if="!isLoading && filteredTemplates.length === 0"
|
||||
class="flex flex-col items-center justify-center h-64 text-neutral-500"
|
||||
class="flex h-64 flex-col items-center justify-center text-neutral-500"
|
||||
>
|
||||
<i class="icon-[lucide--search] w-12 h-12 mb-4 opacity-50" />
|
||||
<p class="text-lg mb-2">
|
||||
<i class="mb-4 icon-[lucide--search] h-12 w-12 opacity-50" />
|
||||
<p class="mb-2 text-lg">
|
||||
{{ $t('templateWorkflows.noResults', 'No templates found') }}
|
||||
</p>
|
||||
<p class="text-sm">
|
||||
@@ -128,7 +128,7 @@
|
||||
<!-- Title -->
|
||||
<span
|
||||
v-if="isLoading"
|
||||
class="inline-block h-8 w-48 bg-dialog-surface rounded animate-pulse"
|
||||
class="inline-block h-8 w-48 animate-pulse rounded bg-dialog-surface"
|
||||
></span>
|
||||
|
||||
<!-- Template Cards Grid -->
|
||||
@@ -141,14 +141,16 @@
|
||||
<CardContainer
|
||||
v-for="n in isLoading ? 12 : 0"
|
||||
:key="`initial-skeleton-${n}`"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
class="hover:bg-white dark-theme:hover:bg-zinc-800"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="landscape">
|
||||
<template #default>
|
||||
<div
|
||||
class="w-full h-full bg-dialog-surface animate-pulse"
|
||||
class="h-full w-full animate-pulse bg-dialog-surface"
|
||||
></div>
|
||||
</template>
|
||||
</CardTop>
|
||||
@@ -157,10 +159,10 @@
|
||||
<CardBottom>
|
||||
<div class="px-4 py-3">
|
||||
<div
|
||||
class="h-6 bg-dialog-surface rounded animate-pulse mb-2"
|
||||
class="mb-2 h-6 animate-pulse rounded bg-dialog-surface"
|
||||
></div>
|
||||
<div
|
||||
class="h-4 bg-dialog-surface rounded animate-pulse"
|
||||
class="h-4 animate-pulse rounded bg-dialog-surface"
|
||||
></div>
|
||||
</div>
|
||||
</CardBottom>
|
||||
@@ -172,9 +174,11 @@
|
||||
v-for="template in isLoading ? [] : displayTemplates"
|
||||
:key="template.name"
|
||||
ref="cardRefs"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
:data-testid="`template-workflow-${template.name}`"
|
||||
class="hover:bg-white dark-theme:hover:bg-zinc-800"
|
||||
@mouseenter="hoveredTemplate = template.name"
|
||||
@mouseleave="hoveredTemplate = null"
|
||||
@click="onLoadWorkflow(template)"
|
||||
@@ -184,7 +188,7 @@
|
||||
<template #default>
|
||||
<!-- Template Thumbnail -->
|
||||
<div
|
||||
class="w-full h-full relative rounded-lg overflow-hidden"
|
||||
class="relative h-full w-full overflow-hidden rounded-lg"
|
||||
>
|
||||
<template v-if="template.mediaType === 'audio'">
|
||||
<AudioThumbnail :src="getBaseThumbnailSrc(template)" />
|
||||
@@ -248,7 +252,7 @@
|
||||
</template>
|
||||
<ProgressSpinner
|
||||
v-if="loadingTemplate === template.name"
|
||||
class="absolute inset-0 z-10 w-12 h-12 m-auto"
|
||||
class="absolute inset-0 z-10 m-auto h-12 w-12"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -267,7 +271,7 @@
|
||||
<CardBottom>
|
||||
<div class="flex flex-col gap-2 pt-3">
|
||||
<h3
|
||||
class="line-clamp-1 text-sm m-0"
|
||||
class="m-0 line-clamp-1 text-sm"
|
||||
:title="
|
||||
getTemplateTitle(
|
||||
template,
|
||||
@@ -285,7 +289,7 @@
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="flex-1">
|
||||
<p
|
||||
class="line-clamp-2 text-sm text-muted m-0"
|
||||
class="m-0 line-clamp-2 text-sm text-muted"
|
||||
:title="getTemplateDescription(template)"
|
||||
>
|
||||
{{ getTemplateDescription(template) }}
|
||||
@@ -316,14 +320,16 @@
|
||||
<CardContainer
|
||||
v-for="n in isLoadingMore ? 6 : 0"
|
||||
:key="`skeleton-${n}`"
|
||||
ratio="smallSquare"
|
||||
type="workflow-template-card"
|
||||
size="compact"
|
||||
variant="ghost"
|
||||
rounded="lg"
|
||||
class="hover:bg-white dark-theme:hover:bg-zinc-800"
|
||||
>
|
||||
<template #top>
|
||||
<CardTop ratio="square">
|
||||
<template #default>
|
||||
<div
|
||||
class="w-full h-full bg-dialog-surface animate-pulse"
|
||||
class="h-full w-full animate-pulse bg-dialog-surface"
|
||||
></div>
|
||||
</template>
|
||||
</CardTop>
|
||||
@@ -332,10 +338,10 @@
|
||||
<CardBottom>
|
||||
<div class="px-4 py-3">
|
||||
<div
|
||||
class="h-6 bg-dialog-surface rounded animate-pulse mb-2"
|
||||
class="mb-2 h-6 animate-pulse rounded bg-dialog-surface"
|
||||
></div>
|
||||
<div
|
||||
class="h-4 bg-dialog-surface rounded animate-pulse"
|
||||
class="h-4 animate-pulse rounded bg-dialog-surface"
|
||||
></div>
|
||||
</div>
|
||||
</CardBottom>
|
||||
@@ -348,7 +354,7 @@
|
||||
<div
|
||||
v-if="!isLoading && hasMoreTemplates"
|
||||
ref="loadTrigger"
|
||||
class="w-full h-4 flex justify-center items-center mt-4"
|
||||
class="mt-4 flex h-4 w-full items-center justify-center"
|
||||
>
|
||||
<div v-if="isLoadingMore" class="text-sm text-muted">
|
||||
{{ $t('templateWorkflows.loadingMore', 'Loading more...') }}
|
||||
@@ -620,10 +626,7 @@ const sortOptions = computed(() => [
|
||||
value: 'default'
|
||||
},
|
||||
{
|
||||
name: t(
|
||||
'templateWorkflows.sort.vramLowToHigh',
|
||||
'VRAM Utilization (Low to High)'
|
||||
),
|
||||
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
|
||||
value: 'vram-low-to-high'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 max-w-96 h-110 p-2">
|
||||
<div class="text-2xl font-medium mb-2">
|
||||
<div class="flex h-110 max-w-96 flex-col gap-4 p-2">
|
||||
<div class="mb-2 text-2xl font-medium">
|
||||
{{ t('apiNodesSignInDialog.title') }}
|
||||
</div>
|
||||
|
||||
<div class="text-base mb-4">
|
||||
<div class="mb-4 text-base">
|
||||
{{ t('apiNodesSignInDialog.message') }}
|
||||
</div>
|
||||
|
||||
<ApiNodesList :node-names="apiNodeNames" />
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<Button :label="t('g.learnMore')" link @click="handleLearnMoreClick" />
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<section class="prompt-dialog-content flex flex-col gap-6 m-2 mt-4">
|
||||
<section class="prompt-dialog-content m-2 mt-4 flex flex-col gap-6">
|
||||
<span>{{ message }}</span>
|
||||
<ul v-if="itemList?.length" class="pl-4 m-0 flex flex-col gap-2">
|
||||
<ul v-if="itemList?.length" class="m-0 flex flex-col gap-2 pl-4">
|
||||
<li v-for="item of itemList" :key="item">
|
||||
{{ item }}
|
||||
</li>
|
||||
@@ -15,14 +15,14 @@
|
||||
>
|
||||
{{ hint }}
|
||||
</Message>
|
||||
<div class="flex gap-4 justify-end">
|
||||
<div class="flex justify-end gap-4">
|
||||
<div
|
||||
v-if="type === 'overwriteBlueprint'"
|
||||
class="flex gap-4 justify-start"
|
||||
class="flex justify-start gap-4"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="doNotAskAgain"
|
||||
class="flex gap-4 justify-start"
|
||||
class="flex justify-start gap-4"
|
||||
input-id="doNotAskAgain"
|
||||
binary
|
||||
/>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<span class="font-bold">{{ error.extensionFile }}</span>
|
||||
</template>
|
||||
|
||||
<div class="flex gap-2 justify-center">
|
||||
<div class="flex justify-center gap-2">
|
||||
<Button
|
||||
v-show="!reportOpen"
|
||||
text
|
||||
@@ -29,12 +29,12 @@
|
||||
</div>
|
||||
<template v-if="reportOpen">
|
||||
<Divider />
|
||||
<ScrollPanel class="w-full h-[400px] max-w-[80vw]">
|
||||
<pre class="whitespace-pre-wrap break-words">{{ reportContent }}</pre>
|
||||
<ScrollPanel class="h-[400px] w-full max-w-[80vw]">
|
||||
<pre class="break-words whitespace-pre-wrap">{{ reportContent }}</pre>
|
||||
</ScrollPanel>
|
||||
<Divider />
|
||||
</template>
|
||||
<div class="flex gap-4 justify-end">
|
||||
<div class="flex justify-end gap-4">
|
||||
<FindIssueButton
|
||||
:error-message="error.exceptionMessage"
|
||||
:repo-owner="repoOwner"
|
||||
@@ -65,10 +65,8 @@ import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import {
|
||||
type ErrorReportData,
|
||||
generateErrorReport
|
||||
} from '@/utils/errorReportUtil'
|
||||
import { generateErrorReport } from '@/utils/errorReportUtil'
|
||||
import type { ErrorReportData } from '@/utils/errorReportUtil'
|
||||
|
||||
const { error } = defineProps<{
|
||||
error: Omit<ErrorReportData, 'workflow' | 'systemStats' | 'serverLogs'> & {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
}"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="flex align-items-center">
|
||||
<div class="align-items-center flex">
|
||||
<span class="node-type">{{ slotProps.option.label }}</span>
|
||||
<span v-if="slotProps.option.hint" class="node-hint">{{
|
||||
slotProps.option.hint
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
v-if="hasMissingCoreNodes"
|
||||
severity="info"
|
||||
icon="pi pi-info-circle"
|
||||
class="my-2 mx-2"
|
||||
class="mx-2 my-2"
|
||||
:pt="{
|
||||
root: { class: 'flex-col' },
|
||||
text: { class: 'flex-1' }
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:title="t('missingModelsDialog.missingModels')"
|
||||
:message="t('missingModelsDialog.missingModelsMessage')"
|
||||
/>
|
||||
<div class="flex gap-1 mb-4">
|
||||
<div class="mb-4 flex gap-1">
|
||||
<Checkbox v-model="doNotAskAgain" binary input-id="doNotAskAgain" />
|
||||
<label for="doNotAskAgain">{{
|
||||
t('missingModelsDialog.doNotAskAgain')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-96 p-2 overflow-x-hidden">
|
||||
<div class="w-96 overflow-x-hidden p-2">
|
||||
<ApiKeyForm
|
||||
v-if="showApiKeyForm"
|
||||
@back="showApiKeyForm = false"
|
||||
@@ -7,11 +7,11 @@
|
||||
/>
|
||||
<template v-else>
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-4 mb-8">
|
||||
<h1 class="text-2xl font-medium leading-normal my-0">
|
||||
<div class="mb-8 flex flex-col gap-4">
|
||||
<h1 class="my-0 text-2xl leading-normal font-medium">
|
||||
{{ isSignIn ? t('auth.login.title') : t('auth.signup.title') }}
|
||||
</h1>
|
||||
<p class="text-base my-0">
|
||||
<p class="my-0 text-base">
|
||||
<span class="text-muted">{{
|
||||
isSignIn
|
||||
? t('auth.login.newUser')
|
||||
@@ -88,17 +88,17 @@
|
||||
>
|
||||
<img
|
||||
src="/assets/images/comfy-logo-mono.svg"
|
||||
class="w-5 h-5 mr-2"
|
||||
class="mr-2 h-5 w-5"
|
||||
alt="Comfy"
|
||||
/>
|
||||
{{ t('auth.login.useApiKey') }}
|
||||
</Button>
|
||||
<small class="text-muted text-center">
|
||||
<small class="text-center text-muted">
|
||||
{{ t('auth.apiKey.helpText') }}
|
||||
<a
|
||||
:href="`${COMFY_PLATFORM_BASE_URL}/login`"
|
||||
target="_blank"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
class="cursor-pointer text-blue-500"
|
||||
>
|
||||
{{ t('auth.apiKey.generateKey') }}
|
||||
</a>
|
||||
@@ -115,12 +115,12 @@
|
||||
</div>
|
||||
|
||||
<!-- Terms & Contact -->
|
||||
<p class="text-xs text-muted mt-8">
|
||||
<p class="mt-8 text-xs text-muted">
|
||||
{{ t('auth.login.termsText') }}
|
||||
<a
|
||||
href="https://www.comfy.org/terms-of-service"
|
||||
target="_blank"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
class="cursor-pointer text-blue-500"
|
||||
>
|
||||
{{ t('auth.login.termsLink') }}
|
||||
</a>
|
||||
@@ -128,12 +128,12 @@
|
||||
<a
|
||||
href="https://www.comfy.org/privacy"
|
||||
target="_blank"
|
||||
class="text-blue-500 cursor-pointer"
|
||||
class="cursor-pointer text-blue-500"
|
||||
>
|
||||
{{ t('auth.login.privacyLink') }} </a
|
||||
>.
|
||||
{{ t('auth.login.questionsContactPrefix') }}
|
||||
<a href="mailto:hello@comfy.org" class="text-blue-500 cursor-pointer">
|
||||
<a href="mailto:hello@comfy.org" class="cursor-pointer text-blue-500">
|
||||
hello@comfy.org</a
|
||||
>.
|
||||
</p>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<template>
|
||||
<div class="flex flex-col w-96 p-2 gap-10">
|
||||
<div class="flex w-96 flex-col gap-10 p-2">
|
||||
<div v-if="isInsufficientCredits" class="flex flex-col gap-4">
|
||||
<h1 class="text-2xl font-medium leading-normal my-0">
|
||||
<h1 class="my-0 text-2xl leading-normal font-medium">
|
||||
{{ $t('credits.topUp.insufficientTitle') }}
|
||||
</h1>
|
||||
<p class="text-base my-0">
|
||||
<p class="my-0 text-base">
|
||||
{{ $t('credits.topUp.insufficientMessage') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Balance Section -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<div class="text-muted text-base">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<div class="text-base text-muted">
|
||||
{{ $t('credits.yourCreditBalance') }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<UserCredit text-class="text-2xl" />
|
||||
<Button
|
||||
outlined
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
<!-- Amount Input Section -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-muted text-sm"
|
||||
<span class="text-sm text-muted"
|
||||
>{{ $t('credits.topUp.quickPurchase') }}:</span
|
||||
>
|
||||
<div class="grid grid-cols-[2fr_1fr] gap-2">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<Form
|
||||
class="flex flex-col gap-6 w-96"
|
||||
class="flex w-96 flex-col gap-6"
|
||||
:resolver="zodResolver(updatePasswordSchema)"
|
||||
@submit="onSubmit"
|
||||
>
|
||||
@@ -10,7 +10,7 @@
|
||||
<Button
|
||||
type="submit"
|
||||
:label="$t('userSettings.updatePassword')"
|
||||
class="h-10 font-medium mt-4"
|
||||
class="mt-4 h-10 font-medium"
|
||||
:loading="loading"
|
||||
/>
|
||||
</Form>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
severity="secondary"
|
||||
icon="pi pi-dollar"
|
||||
rounded
|
||||
class="text-amber-400 p-1"
|
||||
class="p-1 text-amber-400"
|
||||
/>
|
||||
<InputNumber
|
||||
v-if="editable"
|
||||
@@ -21,7 +21,7 @@
|
||||
/>
|
||||
<span v-else class="text-xl">{{ amount }}</span>
|
||||
</div>
|
||||
<ProgressSpinner v-if="loading" class="w-8 h-8" />
|
||||
<ProgressSpinner v-if="loading" class="h-8 w-8" />
|
||||
<Button
|
||||
v-else
|
||||
:severity="preselected ? 'primary' : 'secondary'"
|
||||
@@ -33,9 +33,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import InputNumber, {
|
||||
type InputNumberBlurEvent,
|
||||
type InputNumberInputEvent
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import type {
|
||||
InputNumberBlurEvent,
|
||||
InputNumberInputEvent
|
||||
} from 'primevue/inputnumber'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<PanelTemplate value="About" class="about-container">
|
||||
<h2 class="text-2xl font-bold mb-2">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('g.about') }}
|
||||
</h2>
|
||||
<div class="space-y-2">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<TabPanel value="Credits" class="credits-container h-full">
|
||||
<div class="flex flex-col h-full">
|
||||
<h2 class="text-2xl font-bold mb-2">
|
||||
<div class="flex h-full flex-col">
|
||||
<h2 class="mb-2 text-2xl font-bold">
|
||||
{{ $t('credits.credits') }}
|
||||
</h2>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<h3 class="text-sm font-medium text-muted">
|
||||
{{ $t('credits.yourCreditBalance') }}
|
||||
</h3>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<UserCredit text-class="text-3xl font-bold" />
|
||||
<Skeleton v-if="loading" width="2rem" height="2rem" />
|
||||
<Button
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3>{{ $t('credits.activity') }}</h3>
|
||||
<Button
|
||||
:label="$t('credits.invoiceHistory')"
|
||||
@@ -66,7 +66,7 @@
|
||||
<template #body="{ data }">
|
||||
<div
|
||||
:class="[
|
||||
'text-base font-medium text-center',
|
||||
'text-center text-base font-medium',
|
||||
data.isPositive ? 'text-sky-500' : 'text-red-400'
|
||||
]"
|
||||
>
|
||||
|
||||