Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed2df9b280 | ||
|
|
608b151a4b | ||
|
|
b348a53dd5 | ||
|
|
9a95dd4aa5 | ||
|
|
d37f77cf94 | ||
|
|
cf2cf7f888 | ||
|
|
a53f352ff0 | ||
|
|
3ea6c83391 | ||
|
|
b2a1108340 | ||
|
|
dae5301f4d | ||
|
|
0ce0c679ef | ||
|
|
50ddd904e7 | ||
|
|
e6a59dcdc2 | ||
|
|
924da682ab | ||
|
|
0722a39ba3 | ||
|
|
ae0cf28fc3 | ||
|
|
addd369d85 | ||
|
|
8e08877530 |
90
.github/workflows/ci-vercel-website-preview.yaml
vendored
Normal file
@@ -0,0 +1,90 @@
|
||||
---
|
||||
name: 'CI: Vercel Website Preview'
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/website/**'
|
||||
- 'packages/design-system/**'
|
||||
- 'packages/tailwind-utils/**'
|
||||
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_WEBSITE_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_WEBSITE_PROJECT_ID }}
|
||||
|
||||
jobs:
|
||||
deploy-preview:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=preview --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
|
||||
- name: Build project artifacts
|
||||
run: vercel build --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
id: deploy
|
||||
run: |
|
||||
URL=$(vercel deploy --prebuilt --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add deployment URL to summary
|
||||
run: echo "**Preview:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Save PR metadata
|
||||
run: |
|
||||
mkdir -p temp/vercel-preview
|
||||
echo "${{ steps.deploy.outputs.url }}" > temp/vercel-preview/url.txt
|
||||
|
||||
- name: Upload preview metadata
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: vercel-preview
|
||||
path: temp/vercel-preview
|
||||
|
||||
deploy-production:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
|
||||
|
||||
- name: Install Vercel CLI
|
||||
run: npm install --global vercel@latest
|
||||
|
||||
- name: Pull Vercel environment information
|
||||
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
|
||||
- name: Build project artifacts
|
||||
run: vercel build --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }}
|
||||
|
||||
- name: Deploy project artifacts to Vercel
|
||||
id: deploy
|
||||
run: |
|
||||
URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_WEBSITE_TOKEN }})
|
||||
echo "url=$URL" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Add deployment URL to summary
|
||||
run: echo "**Production:** ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
74
.github/workflows/pr-vercel-website-preview.yaml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: 'PR: Vercel Website Preview'
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI: Vercel Website Preview']
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
comment:
|
||||
runs-on: ubuntu-latest
|
||||
if: >
|
||||
github.repository == 'Comfy-Org/ComfyUI_frontend' &&
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Download preview metadata
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: vercel-preview
|
||||
run_id: ${{ github.event.workflow_run.id }}
|
||||
path: temp/vercel-preview
|
||||
|
||||
- name: Resolve PR number from workflow_run context
|
||||
id: pr-meta
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
let pr = context.payload.workflow_run.pull_requests?.[0];
|
||||
if (!pr) {
|
||||
const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
commit_sha: context.payload.workflow_run.head_sha,
|
||||
});
|
||||
pr = prs.find(p => p.state === 'open');
|
||||
}
|
||||
|
||||
if (!pr) {
|
||||
core.info('No open PR found for this workflow run — skipping.');
|
||||
core.setOutput('skip', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
core.setOutput('skip', 'false');
|
||||
core.setOutput('number', String(pr.number));
|
||||
|
||||
- name: Read preview URL
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
id: meta
|
||||
run: |
|
||||
echo "url=$(cat temp/vercel-preview/url.txt)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Write report
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
run: |
|
||||
echo "**Website Preview:** ${{ steps.meta.outputs.url }}" > preview-report.md
|
||||
|
||||
- name: Post PR comment
|
||||
if: steps.pr-meta.outputs.skip != 'true'
|
||||
uses: ./.github/actions/post-pr-report-comment
|
||||
with:
|
||||
pr-number: ${{ steps.pr-meta.outputs.number }}
|
||||
report-file: ./preview-report.md
|
||||
comment-marker: '<!-- VERCEL_WEBSITE_PREVIEW -->'
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -64,6 +64,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-self-assign": "allow",
|
||||
"no-unused-expressions": "off",
|
||||
"no-unused-private-class-members": "off",
|
||||
@@ -104,8 +105,7 @@
|
||||
"allowInterfaces": "always"
|
||||
}
|
||||
],
|
||||
"vue/no-import-compiler-macros": "error",
|
||||
"vue/no-dupe-keys": "error"
|
||||
"vue/no-import-compiler-macros": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
|
||||
@@ -318,6 +318,9 @@ When referencing Comfy-Org repos:
|
||||
- Find existing `!important` classes that are interfering with the styling and propose corrections of those instead.
|
||||
- NEVER use arbitrary percentage values like `w-[80%]` when a Tailwind fraction utility exists
|
||||
- Use `w-4/5` instead of `w-[80%]`, `w-1/2` instead of `w-[50%]`, etc.
|
||||
- NEVER use font-size classes (`text-xs`, `text-sm`, etc.) to size `icon-[...]` (iconify) icons
|
||||
- Iconify icons size via `width`/`height: 1.2em`, so font-size produces unpredictable results
|
||||
- Use `size-*` classes for explicit sizing, or set font-size on the **parent** container and let `1.2em` scale naturally
|
||||
|
||||
## Agent-only rules
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"outputDirectory": "apps/website/dist",
|
||||
"installCommand": "pnpm install --frozen-lockfile",
|
||||
"framework": null,
|
||||
"github": {
|
||||
"enabled": false
|
||||
},
|
||||
"redirects": [
|
||||
{
|
||||
"source": "/pricing",
|
||||
|
||||
34
browser_tests/assets/missing/missing_media_bypassed.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "IMAGE", "type": "IMAGE", "links": null },
|
||||
{ "name": "MASK", "type": "MASK", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["nonexistent_test_image_12345.png", "image"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"offset": [0, 0],
|
||||
"scale": 1
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
@@ -31,7 +31,7 @@
|
||||
{
|
||||
"id": 11,
|
||||
"type": "LoadImage",
|
||||
"pos": [450, 50],
|
||||
"pos": [450, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{
|
||||
"id": 10,
|
||||
"type": "LoadImage",
|
||||
"pos": [50, 50],
|
||||
"pos": [50, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
{
|
||||
"last_node_id": 0,
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [256, 256],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
@@ -15,7 +35,7 @@
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "text_encoders"
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
|
||||
42
browser_tests/assets/missing/missing_models_bypassed.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [256, 256],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
66
browser_tests/assets/missing/missing_models_distinct.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model_a.safetensors"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [500, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": null },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model_b.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model_a.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
},
|
||||
{
|
||||
"name": "fake_model_b.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -34,7 +34,7 @@
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "text_encoders"
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"id": "test-missing-models-in-bypassed-subgraph",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [100, 100],
|
||||
"size": [400, 262],
|
||||
"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 }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-with-missing-model",
|
||||
"pos": [450, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 4,
|
||||
"inputs": [{ "name": "model", "type": "MODEL", "link": null }],
|
||||
"outputs": [{ "name": "MODEL", "type": "MODEL", "links": null }],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-with-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Subgraph with Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "input1-id",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "output1-id",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"linkIds": [2],
|
||||
"pos": { "0": 520, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [250, 180],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": [2] },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
141
browser_tests/assets/missing/missing_models_in_subgraph.json
Normal file
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"id": "test-missing-models-in-subgraph",
|
||||
"revision": 0,
|
||||
"last_node_id": 2,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "KSampler",
|
||||
"pos": [100, 100],
|
||||
"size": [270, 262],
|
||||
"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 }
|
||||
],
|
||||
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [] }],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "subgraph-with-missing-model",
|
||||
"pos": [450, 100],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [{ "name": "model", "type": "MODEL", "link": null }],
|
||||
"outputs": [{ "name": "MODEL", "type": "MODEL", "links": null }],
|
||||
"properties": {},
|
||||
"widgets_values": []
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"definitions": {
|
||||
"subgraphs": [
|
||||
{
|
||||
"id": "subgraph-with-missing-model",
|
||||
"version": 1,
|
||||
"state": {
|
||||
"lastGroupId": 0,
|
||||
"lastNodeId": 1,
|
||||
"lastLinkId": 2,
|
||||
"lastRerouteId": 0
|
||||
},
|
||||
"revision": 0,
|
||||
"config": {},
|
||||
"name": "Subgraph with Missing Model",
|
||||
"inputNode": {
|
||||
"id": -10,
|
||||
"bounding": [100, 200, 120, 60]
|
||||
},
|
||||
"outputNode": {
|
||||
"id": -20,
|
||||
"bounding": [500, 200, 120, 60]
|
||||
},
|
||||
"inputs": [
|
||||
{
|
||||
"id": "input1-id",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"linkIds": [1],
|
||||
"pos": { "0": 150, "1": 220 }
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"id": "output1-id",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"linkIds": [2],
|
||||
"pos": { "0": 520, "1": 220 }
|
||||
}
|
||||
],
|
||||
"widgets": [],
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [250, 180],
|
||||
"size": [315, 98],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{ "name": "MODEL", "type": "MODEL", "links": [2] },
|
||||
{ "name": "CLIP", "type": "CLIP", "links": null },
|
||||
{ "name": "VAE", "type": "VAE", "links": null }
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": ["fake_model.safetensors"]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
{
|
||||
"id": 1,
|
||||
"origin_id": -10,
|
||||
"origin_slot": 0,
|
||||
"target_id": 1,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"origin_id": 1,
|
||||
"origin_slot": 0,
|
||||
"target_id": -20,
|
||||
"target_slot": 0,
|
||||
"type": "MODEL"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -78,7 +78,7 @@
|
||||
{
|
||||
"name": "fake_model.safetensors",
|
||||
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
|
||||
"directory": "text_encoders"
|
||||
"directory": "checkpoints"
|
||||
}
|
||||
],
|
||||
"version": 0.4
|
||||
|
||||
@@ -2,9 +2,13 @@ import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class TemplatesDialog {
|
||||
public readonly root: Locator
|
||||
public readonly modelFilter: Locator
|
||||
public readonly resultsCount: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.root = page.getByRole('dialog')
|
||||
this.modelFilter = this.root.getByRole('button', { name: /Model Filter/ })
|
||||
this.resultsCount = this.root.getByText(/Showing.*of.*templates/i)
|
||||
}
|
||||
|
||||
filterByHeading(name: string): Locator {
|
||||
@@ -16,4 +20,10 @@ export class TemplatesDialog {
|
||||
getCombobox(name: RegExp | string): Locator {
|
||||
return this.root.getByRole('combobox', { name })
|
||||
}
|
||||
|
||||
async selectModelOption(name: string): Promise<void> {
|
||||
await this.modelFilter.click()
|
||||
await this.page.getByRole('option', { name }).click()
|
||||
await this.page.keyboard.press('Escape')
|
||||
}
|
||||
}
|
||||
|
||||
28
browser_tests/fixtures/data/templateFixtures.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type {
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/platform/workflow/templates/types/template'
|
||||
|
||||
export function makeTemplate(
|
||||
overrides: Partial<TemplateInfo> & Pick<TemplateInfo, 'name'>
|
||||
): TemplateInfo {
|
||||
return {
|
||||
description: overrides.name,
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'webp',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
export function mockTemplateIndex(
|
||||
templates: TemplateInfo[]
|
||||
): WorkflowTemplates[] {
|
||||
return [
|
||||
{
|
||||
moduleName: 'default',
|
||||
title: 'Test Templates',
|
||||
type: 'image',
|
||||
templates
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -79,7 +79,8 @@ export const TestIds = {
|
||||
bookmarksSection: 'node-library-bookmarks-section'
|
||||
},
|
||||
propertiesPanel: {
|
||||
root: 'properties-panel'
|
||||
root: 'properties-panel',
|
||||
errorsTab: 'panel-tab-errors'
|
||||
},
|
||||
subgraphEditor: {
|
||||
toggle: 'subgraph-editor-toggle',
|
||||
|
||||
@@ -131,6 +131,38 @@ test.describe('Settings dialog', { tag: '@ui' }, () => {
|
||||
expect(switched).toBe(true)
|
||||
})
|
||||
|
||||
test('Boolean setting persists after page reload', async ({ comfyPage }) => {
|
||||
const settingId = 'Comfy.Node.MiddleClickRerouteNode'
|
||||
const initialValue = await comfyPage.settings.getSetting<boolean>(settingId)
|
||||
|
||||
try {
|
||||
await comfyPage.settings.setSetting(settingId, !initialValue)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
|
||||
.toBe(!initialValue)
|
||||
|
||||
await comfyPage.page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await comfyPage.page.waitForFunction(
|
||||
() => window.app && window.app.extensionManager
|
||||
)
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.settings.getSetting<boolean>(settingId))
|
||||
.toBe(!initialValue)
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
() => window.LiteGraph!.middle_click_slot_add_default_node
|
||||
)
|
||||
)
|
||||
.toBe(!initialValue)
|
||||
} finally {
|
||||
await comfyPage.settings.setSetting(settingId, initialValue)
|
||||
}
|
||||
})
|
||||
|
||||
test('Dropdown setting can be changed and persists', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { cleanupFakeModel } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -47,11 +48,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
test('Should display "Show missing models" button for missing model errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
|
||||
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
|
||||
return response.ok
|
||||
}, comfyPage.url)
|
||||
expect(cleanupOk).toBeTruthy()
|
||||
await cleanupFakeModel(comfyPage)
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models')
|
||||
|
||||
@@ -95,7 +92,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await comfyPage.nextFrame()
|
||||
@@ -107,10 +104,37 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.redo()
|
||||
await expect(errorOverlay).not.toBeVisible({ timeout: 5000 })
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
})
|
||||
|
||||
test('Does not resurface error overlay when switching back to workflow with missing nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
const errorOverlay = getOverlay(comfyPage.page)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
|
||||
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
|
||||
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -151,6 +175,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
|
||||
await expect(overlay).toBeHidden()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -162,7 +187,7 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
await expect(overlay).toBeHidden()
|
||||
})
|
||||
|
||||
test('"Dismiss" closes overlay without opening panel', async ({
|
||||
@@ -175,10 +200,8 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
|
||||
await overlay.getByTestId(TestIds.dialogs.errorOverlayDismiss).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId('properties-panel')
|
||||
).not.toBeVisible()
|
||||
await expect(overlay).toBeHidden()
|
||||
await expect(comfyPage.page.getByTestId('properties-panel')).toBeHidden()
|
||||
})
|
||||
|
||||
test('Close button (X) dismisses overlay', async ({ comfyPage }) => {
|
||||
@@ -189,7 +212,37 @@ test.describe('Error overlay', { tag: '@ui' }, () => {
|
||||
|
||||
await overlay.getByRole('button', { name: /close/i }).click()
|
||||
|
||||
await expect(overlay).not.toBeVisible()
|
||||
await expect(overlay).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Count independence from node selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test('missing model count stays constant when a node is selected', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: ErrorOverlay previously read the selection-filtered
|
||||
// missingModelGroups from useErrorGroups, so selecting one of two
|
||||
// missing-model nodes would shrink the overlay label from
|
||||
// "2 required models are missing" to "1". The overlay must show
|
||||
// the workflow total regardless of canvas selection.
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models_distinct')
|
||||
|
||||
const overlay = getOverlay(comfyPage.page)
|
||||
await expect(overlay).toBeVisible()
|
||||
await expect(overlay).toContainText(/2 required models are missing/i)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
|
||||
await expect(overlay).toContainText(/2 required models are missing/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
105
browser_tests/tests/nodeContextMenuOverflow.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
|
||||
test.describe(
|
||||
'Node context menu viewport overflow (#10824)',
|
||||
{ tag: '@ui' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Keep the viewport well below the menu content height so overflow is guaranteed.
|
||||
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
await comfyPage.nextFrame()
|
||||
})
|
||||
|
||||
async function openMoreOptions(comfyPage: ComfyPage) {
|
||||
const ksamplerNodes =
|
||||
await comfyPage.nodeOps.getNodeRefsByTitle('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found')
|
||||
}
|
||||
|
||||
// Drag the KSampler toward the lower-left so the menu has limited space below it.
|
||||
const nodePos = await ksamplerNodes[0].getPosition()
|
||||
const viewportSize = comfyPage.page.viewportSize()!
|
||||
const centerX = viewportSize.width / 3
|
||||
const centerY = viewportSize.height * 0.75
|
||||
await comfyPage.canvasOps.dragAndDrop(
|
||||
{ x: nodePos.x, y: nodePos.y },
|
||||
{ x: centerX, y: centerY }
|
||||
)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await ksamplerNodes[0].click('title')
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible({
|
||||
timeout: 5000
|
||||
})
|
||||
|
||||
const moreOptionsBtn = comfyPage.page.locator(
|
||||
'[data-testid="more-options-button"]'
|
||||
)
|
||||
await expect(moreOptionsBtn).toBeVisible({ timeout: 3000 })
|
||||
await moreOptionsBtn.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
const menu = comfyPage.page.locator('.p-contextmenu')
|
||||
await expect(menu).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Wait for constrainMenuHeight (runs via requestAnimationFrame in onMenuShow)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
return menu
|
||||
}
|
||||
|
||||
test('last menu item "Remove" is reachable via scroll', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
const rootList = menu.locator(':scope > ul')
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => rootList.evaluate((el) => el.scrollHeight > el.clientHeight),
|
||||
{
|
||||
message:
|
||||
'Menu should overflow vertically so this test exercises the viewport clamp',
|
||||
timeout: 3000
|
||||
}
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// "Remove" is the last item in the More Options menu.
|
||||
// It must become reachable by scrolling the bounded menu list.
|
||||
const removeItem = menu.getByText('Remove', { exact: true })
|
||||
const didScroll = await rootList.evaluate((el) => {
|
||||
const previousScrollTop = el.scrollTop
|
||||
el.scrollTo({ top: el.scrollHeight })
|
||||
return el.scrollTop > previousScrollTop
|
||||
})
|
||||
expect(didScroll).toBe(true)
|
||||
await expect(removeItem).toBeVisible()
|
||||
})
|
||||
|
||||
test('last menu item "Remove" is clickable and removes the node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const menu = await openMoreOptions(comfyPage)
|
||||
|
||||
const removeItem = menu.getByText('Remove', { exact: true })
|
||||
await removeItem.scrollIntoViewIfNeeded()
|
||||
await removeItem.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
// The node should be removed from the graph
|
||||
await expect
|
||||
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 3000 })
|
||||
.toBe(0)
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -2,8 +2,9 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { PropertiesPanelHelper } from '@e2e/tests/propertiesPanel/PropertiesPanelHelper'
|
||||
|
||||
export async function openErrorsTabViaSeeErrors(
|
||||
export async function loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage: ComfyPage,
|
||||
workflow: string
|
||||
) {
|
||||
@@ -15,3 +16,30 @@ export async function openErrorsTabViaSeeErrors(
|
||||
await errorOverlay.getByTestId(TestIds.dialogs.errorOverlaySeeErrors).click()
|
||||
await expect(errorOverlay).not.toBeVisible()
|
||||
}
|
||||
|
||||
export async function openErrorsTab(comfyPage: ComfyPage) {
|
||||
const panel = new PropertiesPanelHelper(comfyPage.page)
|
||||
await panel.open(comfyPage.actionbar.propertiesButton)
|
||||
|
||||
const errorsTab = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.errorsTab
|
||||
)
|
||||
await expect(errorsTab).toBeVisible()
|
||||
await errorsTab.click()
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the fake model file from the backend so it is detected as missing.
|
||||
* Fixture URLs (e.g. http://localhost:8188/...) are not actually downloaded
|
||||
* during tests — they only serve as metadata for the missing model UI.
|
||||
*/
|
||||
export async function cleanupFakeModel(comfyPage: ComfyPage) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(async (url: string) => {
|
||||
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
|
||||
return response.ok
|
||||
}, comfyPage.url)
|
||||
)
|
||||
.toBeTruthy()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
|
||||
const dropzone = comfyPage.page.getByTestId(
|
||||
@@ -47,7 +47,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
|
||||
test.describe('Detection', () => {
|
||||
test('Shows missing media group in errors tab', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
|
||||
@@ -57,7 +60,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Shows correct number of missing media rows', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_multiple'
|
||||
)
|
||||
@@ -68,7 +71,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Shows upload dropzone and library select for each missing item', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
await expect(getDropzone(comfyPage)).toBeVisible()
|
||||
await expect(
|
||||
@@ -81,7 +87,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Upload via file picker shows status card then allows confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
@@ -95,7 +104,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Selecting from library shows status card then allows confirm', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
const librarySelect = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaLibrarySelect
|
||||
@@ -121,7 +133,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Cancelling pending selection returns to upload/library UI', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
|
||||
await expect(getStatusCard(comfyPage)).toBeVisible()
|
||||
@@ -140,7 +155,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Missing Inputs group disappears when all items are resolved', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
await uploadFileViaDropzone(comfyPage)
|
||||
await confirmPendingSelection(comfyPage)
|
||||
|
||||
@@ -154,7 +172,10 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
|
||||
test('Locate button navigates canvas to the missing media node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_media_single')
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
const offsetBefore = await comfyPage.page.evaluate(() => {
|
||||
const canvas = window['app']?.canvas
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
interceptClipboardWrite,
|
||||
getClipboardText
|
||||
} from '@e2e/helpers/clipboardSpy'
|
||||
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -15,17 +18,13 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
const cleanupOk = await comfyPage.page.evaluate(async (url: string) => {
|
||||
const response = await fetch(`${url}/api/devtools/cleanup_fake_model`)
|
||||
return response.ok
|
||||
}, comfyPage.url)
|
||||
expect(cleanupOk).toBeTruthy()
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test('Should show missing models group in errors tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingModelsGroup)
|
||||
@@ -35,7 +34,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test('Should display model name with referencing node count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const modelsGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
@@ -46,7 +45,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test('Should expand model row to show referencing nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_models_with_nodes'
|
||||
)
|
||||
@@ -54,7 +53,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
const locateButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelLocate
|
||||
)
|
||||
await expect(locateButton.first()).not.toBeVisible()
|
||||
await expect(locateButton.first()).toBeHidden()
|
||||
|
||||
const expandButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelExpand
|
||||
@@ -66,14 +65,14 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Should copy model name to clipboard', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
await interceptClipboardWrite(comfyPage.page)
|
||||
|
||||
const copyButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyName
|
||||
)
|
||||
await expect(copyButton.first()).toBeVisible()
|
||||
await copyButton.first().click()
|
||||
await copyButton.first().dispatchEvent('click')
|
||||
|
||||
const copiedText = await getClipboardText(comfyPage.page)
|
||||
expect(copiedText).toContain('fake_model.safetensors')
|
||||
@@ -83,7 +82,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test('Should show Copy URL button for non-asset models', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
@@ -94,7 +93,7 @@ test.describe('Errors tab - Missing models', { tag: '@ui' }, () => {
|
||||
test('Should show Download button for downloadable models', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_models')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const downloadButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelDownload
|
||||
|
||||
@@ -2,7 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { openErrorsTabViaSeeErrors } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
import { loadWorkflowAndOpenErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -14,7 +14,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Should show MissingNodeCard in errors tab', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodeCard)
|
||||
@@ -22,7 +22,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Should show missing node packs group', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(comfyPage, 'missing/missing_nodes')
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
|
||||
@@ -32,7 +32,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test('Should expand pack group to reveal node type names', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
@@ -52,7 +52,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
})
|
||||
|
||||
test('Should collapse expanded pack group', async ({ comfyPage }) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
@@ -80,7 +80,7 @@ test.describe('Errors tab - Missing nodes', { tag: '@ui' }, () => {
|
||||
test('Locate node button is visible for expanded pack nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await openErrorsTabViaSeeErrors(
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_nodes_in_subgraph'
|
||||
)
|
||||
|
||||
599
browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts
Normal file
@@ -0,0 +1,599 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import {
|
||||
cleanupFakeModel,
|
||||
openErrorsTab,
|
||||
loadWorkflowAndOpenErrorsTab
|
||||
} from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.RightSidePanel.ShowErrorsTab',
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Missing nodes', () => {
|
||||
test('Deleting a missing node removes its error from the errors tab', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.delete()
|
||||
|
||||
await expect(missingNodeGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Undo after bypass restores error without showing overlay', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_nodes')
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingNodeGroup).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
|
||||
await comfyPage.keyboard.redo()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingNodeGroup).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Missing models', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test('Loading a workflow with all nodes bypassed shows no errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_models_bypassed')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Bypassing a node hides its error, un-bypassing restores it', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Bypass/un-bypass cycle preserves Copy URL button on the restored row', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: on un-bypass, the realtime scan produced a fresh
|
||||
// candidate without url/hash/directory — those fields were only
|
||||
// attached by the full pipeline's enrichWithEmbeddedMetadata. The
|
||||
// row's Copy URL button (v-if gated on representative.url) then
|
||||
// disappeared. Per-node scan now enriches from node.properties.models
|
||||
// which persists across mode toggles. Uses the `_from_node_properties`
|
||||
// fixture because the enrichment source is per-node metadata, not
|
||||
// the workflow-level `models[]` array (which the realtime scan
|
||||
// path does not see).
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_models_from_node_properties'
|
||||
)
|
||||
|
||||
const copyUrlButton = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelCopyUrl
|
||||
)
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(copyUrlButton.first()).toBeVisible()
|
||||
})
|
||||
|
||||
test('Pasting a node with missing model increases referencing node count', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(1\)/
|
||||
)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expect(missingModelGroup).toContainText(
|
||||
/fake_model\.safetensors\s*\(2\)/
|
||||
)
|
||||
})
|
||||
|
||||
test('Pasting a bypassed node does not add a new error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Deleting a node with missing model removes its error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.delete()
|
||||
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Undo after bypass restores error without showing overlay', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(comfyPage, 'missing/missing_models')
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.undo()
|
||||
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
|
||||
await comfyPage.keyboard.redo()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Selecting a node filters errors tab to only that node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_models_with_nodes'
|
||||
)
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await expect(missingModelGroup).toContainText(/\(2\)/)
|
||||
|
||||
const node1 = await comfyPage.nodeOps.getNodeRefById('1')
|
||||
await node1.click('title')
|
||||
await expect(missingModelGroup).toContainText(/\(1\)/)
|
||||
|
||||
await comfyPage.canvas.click()
|
||||
await expect(missingModelGroup).toContainText(/\(2\)/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Missing media', () => {
|
||||
test('Loading a workflow with all nodes bypassed shows no errors', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_bypassed')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Bypassing a node hides its error, un-bypassing restores it', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
const missingMediaGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaGroup
|
||||
)
|
||||
await expect(missingMediaGroup).toBeVisible()
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingMediaGroup).toBeHidden()
|
||||
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeFalsy()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingMediaGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Pasting a bypassed node does not add a new error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await loadWorkflowAndOpenErrorsTab(
|
||||
comfyPage,
|
||||
'missing/missing_media_single'
|
||||
)
|
||||
|
||||
const missingMediaGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaGroup
|
||||
)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await node.click('title')
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => node.isBypassed()).toBeTruthy()
|
||||
await expect(missingMediaGroup).toBeHidden()
|
||||
|
||||
await comfyPage.clipboard.copy()
|
||||
await comfyPage.clipboard.paste()
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toBe(2)
|
||||
await expect(missingMediaGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Selecting a node filters errors tab to only that node', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_media_multiple')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const mediaRows = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingMediaRow
|
||||
)
|
||||
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(mediaRows).toHaveCount(2)
|
||||
|
||||
const node = await comfyPage.nodeOps.getNodeRefById('10')
|
||||
await node.click('title')
|
||||
await expect(mediaRows).toHaveCount(1)
|
||||
|
||||
await comfyPage.canvas.click({ position: { x: 400, y: 600 } })
|
||||
await expect(mediaRows).toHaveCount(2)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Subgraph', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await cleanupFakeModel(comfyPage)
|
||||
})
|
||||
|
||||
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_subgraph'
|
||||
)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
const errorsTab = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.errorsTab
|
||||
)
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => subgraphNode.isBypassed()).toBeTruthy()
|
||||
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(errorsTab).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.keyboard.bypass()
|
||||
await expect.poll(() => subgraphNode.isBypassed()).toBeFalsy()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Deleting a node inside a subgraph removes its missing model error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: before the execId fix, onNodeRemoved fell back to the
|
||||
// interior node's local id (e.g. "1") when node.graph was already
|
||||
// null, so the error keyed under "2:1" was never removed.
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_subgraph'
|
||||
)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Select-all + Delete: interior node IDs may be reassigned during
|
||||
// subgraph configure when they collide with root-graph IDs, so
|
||||
// looking up by static id can fail.
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
|
||||
await expect(missingModelGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Deleting a node inside a subgraph removes its missing node-type error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
// Select-all + Delete: interior node IDs may be reassigned during
|
||||
// subgraph configure when they collide with root-graph IDs, so
|
||||
// looking up by static id can fail.
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.page.keyboard.press('Delete')
|
||||
|
||||
await expect(missingNodeGroup).toBeHidden()
|
||||
})
|
||||
|
||||
test('Bypassing a node inside a subgraph hides its error, un-bypassing restores it', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_subgraph'
|
||||
)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const missingModelGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingModelsGroup
|
||||
)
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.keyboard.bypass()
|
||||
|
||||
const errorsTab = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.errorsTab
|
||||
)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(errorsTab).toBeHidden()
|
||||
|
||||
await comfyPage.keyboard.selectAll()
|
||||
await comfyPage.keyboard.bypass()
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingModelGroup).toBeVisible()
|
||||
})
|
||||
|
||||
test('Loading a workflow with bypassed subgraph suppresses interior missing model error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: the initial scan pipeline only checked each node's
|
||||
// own mode, so interior nodes of a bypassed subgraph container
|
||||
// surfaced errors even though the container was excluded from
|
||||
// execution. The pipeline now post-filters candidates whose
|
||||
// ancestor path is not fully active.
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_bypassed_subgraph'
|
||||
)
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.propertiesPanel.errorsTab)
|
||||
).toBeHidden()
|
||||
})
|
||||
|
||||
test('Entering a bypassed subgraph does not resurface interior missing model error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Regression: useGraphNodeManager replays graph.onNodeAdded for
|
||||
// each interior node on subgraph entry; without an ancestor-aware
|
||||
// guard in scanSingleNodeErrors, that re-scan reintroduced the
|
||||
// error that the initial pipeline had correctly suppressed.
|
||||
await comfyPage.workflow.loadWorkflow(
|
||||
'missing/missing_models_in_bypassed_subgraph'
|
||||
)
|
||||
|
||||
const errorsTab = comfyPage.page.getByTestId(
|
||||
TestIds.propertiesPanel.errorsTab
|
||||
)
|
||||
await comfyPage.actionbar.propertiesButton.click()
|
||||
await expect(errorsTab).toBeHidden()
|
||||
|
||||
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
|
||||
await subgraphNode.navigateIntoSubgraph()
|
||||
|
||||
await expect(errorsTab).toBeHidden()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Workflow switching', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Workflow.WorkflowTabsPosition',
|
||||
'Sidebar'
|
||||
)
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
})
|
||||
|
||||
test('Restores missing nodes in errors tab when switching back to workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
|
||||
|
||||
const errorOverlay = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.errorOverlay
|
||||
)
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await errorOverlay
|
||||
.getByTestId(TestIds.dialogs.errorOverlayDismiss)
|
||||
.click()
|
||||
|
||||
const missingNodeGroup = comfyPage.page.getByTestId(
|
||||
TestIds.dialogs.missingNodePacksGroup
|
||||
)
|
||||
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
await expect(missingNodeGroup).toBeHidden()
|
||||
|
||||
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(missingNodeGroup).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
@@ -2,6 +2,7 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import { openErrorsTab } from '@e2e/tests/propertiesPanel/ErrorsTabHelper'
|
||||
|
||||
test.describe('Workflows sidebar', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
@@ -232,7 +233,7 @@ test.describe('Workflows sidebar', () => {
|
||||
.toEqual('workflow1')
|
||||
})
|
||||
|
||||
test('Reports missing nodes warning again when switching back to workflow', async ({
|
||||
test('Restores missing nodes errors silently when switching back to workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting(
|
||||
@@ -254,11 +255,17 @@ test.describe('Workflows sidebar', () => {
|
||||
await comfyPage.menu.workflowsTab.open()
|
||||
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
|
||||
|
||||
// Switch back to the missing_nodes workflow — overlay should reappear
|
||||
// so users can install missing node packs without a page reload
|
||||
// Switch back to the missing_nodes workflow — overlay should NOT
|
||||
// reappear (silent restore), but errors tab should have content
|
||||
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
|
||||
|
||||
await expect(errorOverlay).toBeVisible()
|
||||
await expect(errorOverlay).toBeHidden()
|
||||
|
||||
// Errors tab should still show missing nodes after silent restore
|
||||
await openErrorsTab(comfyPage)
|
||||
await expect(
|
||||
comfyPage.page.getByTestId(TestIds.dialogs.missingNodePacksGroup)
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can close saved-workflows from the open workflows section', async ({
|
||||
|
||||
290
browser_tests/tests/templateFilteringCount.spec.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
import {
|
||||
makeTemplate,
|
||||
mockTemplateIndex
|
||||
} from '@e2e/fixtures/data/templateFixtures'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
|
||||
const Cloud = TemplateIncludeOnDistributionEnum.Cloud
|
||||
const Desktop = TemplateIncludeOnDistributionEnum.Desktop
|
||||
const Local = TemplateIncludeOnDistributionEnum.Local
|
||||
|
||||
test.describe(
|
||||
'Template distribution filtering count',
|
||||
{ tag: '@cloud' },
|
||||
() => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedModels', [])
|
||||
await comfyPage.settings.setSetting(
|
||||
'Comfy.Templates.SelectedUseCases',
|
||||
[]
|
||||
)
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SelectedRunsOn', [])
|
||||
await comfyPage.settings.setSetting('Comfy.Templates.SortBy', 'default')
|
||||
|
||||
await comfyPage.page.route('**/templates/**.webp', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
path: 'browser_tests/assets/example.webp',
|
||||
headers: {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('displayed count matches visible cards when distribution filter excludes templates', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'cloud-1',
|
||||
title: 'Cloud One',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'cloud-2',
|
||||
title: 'Cloud Two',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-hidden',
|
||||
title: 'Desktop Hidden',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'universal',
|
||||
title: 'Universal'
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
await expect(comfyPage.templates.allTemplateCards).toHaveCount(3)
|
||||
|
||||
const desktopCard = comfyPage.templatesDialog.root.getByTestId(
|
||||
TestIds.templates.workflowCard('desktop-hidden')
|
||||
)
|
||||
await expect(desktopCard).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('filtered count reflects distribution + model filter together', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-1',
|
||||
title: 'Wan Cloud 1',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-cloud-2',
|
||||
title: 'Wan Cloud 2',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
await comfyPage.templatesDialog.selectModelOption('Wan 2.2')
|
||||
|
||||
await expect(comfyPage.templates.allTemplateCards).toHaveCount(2)
|
||||
|
||||
const wanDesktopCard = comfyPage.templatesDialog.root.getByTestId(
|
||||
TestIds.templates.workflowCard('wan-desktop')
|
||||
)
|
||||
await expect(wanDesktopCard).toHaveCount(0)
|
||||
|
||||
await expect(comfyPage.templatesDialog.resultsCount).toHaveText(
|
||||
/Showing 2 of 3 templates/i
|
||||
)
|
||||
})
|
||||
|
||||
test('desktop-only templates never leak into DOM on cloud distribution', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'cloud-visible',
|
||||
title: 'Cloud Visible',
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'desktop-leak-check',
|
||||
title: 'Desktop Leak Check',
|
||||
includeOnDistributions: [Desktop]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'local-leak-check',
|
||||
title: 'Local Leak Check',
|
||||
includeOnDistributions: [Local]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
await expect(comfyPage.templates.allTemplateCards).toHaveCount(1)
|
||||
|
||||
await expect(
|
||||
comfyPage.templatesDialog.root.getByTestId(
|
||||
TestIds.templates.workflowCard('cloud-visible')
|
||||
)
|
||||
).toBeVisible()
|
||||
|
||||
await expect(
|
||||
comfyPage.templatesDialog.root.getByTestId(
|
||||
TestIds.templates.workflowCard('desktop-leak-check')
|
||||
)
|
||||
).toHaveCount(0)
|
||||
|
||||
await expect(
|
||||
comfyPage.templatesDialog.root.getByTestId(
|
||||
TestIds.templates.workflowCard('local-leak-check')
|
||||
)
|
||||
).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('templates without includeOnDistributions are visible on cloud', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({ name: 'unrestricted-1', title: 'Unrestricted 1' }),
|
||||
makeTemplate({ name: 'unrestricted-2', title: 'Unrestricted 2' }),
|
||||
makeTemplate({
|
||||
name: 'cloud-only',
|
||||
title: 'Cloud Only',
|
||||
includeOnDistributions: [Cloud]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
await expect(comfyPage.templates.allTemplateCards).toHaveCount(3)
|
||||
|
||||
await expect(comfyPage.templatesDialog.resultsCount).toHaveText(
|
||||
/Showing 3 of 3 templates/i
|
||||
)
|
||||
})
|
||||
|
||||
test('clear filters button resets to correct distribution-filtered total', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const templates: TemplateInfo[] = [
|
||||
makeTemplate({
|
||||
name: 'wan-cloud',
|
||||
title: 'Wan Cloud',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'flux-cloud',
|
||||
title: 'Flux Cloud',
|
||||
models: ['Flux'],
|
||||
includeOnDistributions: [Cloud]
|
||||
}),
|
||||
makeTemplate({
|
||||
name: 'wan-desktop',
|
||||
title: 'Wan Desktop',
|
||||
models: ['Wan 2.2'],
|
||||
includeOnDistributions: [Desktop]
|
||||
})
|
||||
]
|
||||
|
||||
await comfyPage.page.route('**/templates/index.json', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: JSON.stringify(mockTemplateIndex(templates)),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
|
||||
await expect(comfyPage.templates.content).toBeVisible()
|
||||
|
||||
await comfyPage.templatesDialog.selectModelOption('Wan 2.2')
|
||||
|
||||
await expect(comfyPage.templates.allTemplateCards).toHaveCount(1)
|
||||
|
||||
const clearButton = comfyPage.templatesDialog.root.getByRole('button', {
|
||||
name: /Clear Filters/i
|
||||
})
|
||||
await clearButton.click()
|
||||
|
||||
await expect(comfyPage.templates.allTemplateCards).toHaveCount(2)
|
||||
|
||||
await expect(comfyPage.templatesDialog.resultsCount).toHaveText(
|
||||
/Showing 2 of 2 templates/i
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 138 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
@@ -1,6 +1,7 @@
|
||||
import type { KnipConfig } from 'knip'
|
||||
|
||||
const config: KnipConfig = {
|
||||
treatConfigHintsAsErrors: true,
|
||||
workspaces: {
|
||||
'.': {
|
||||
entry: [
|
||||
@@ -33,11 +34,9 @@ const config: KnipConfig = {
|
||||
'src/pages/**/*.astro',
|
||||
'src/layouts/**/*.astro',
|
||||
'src/components/**/*.vue',
|
||||
'src/styles/global.css',
|
||||
'astro.config.ts'
|
||||
'src/styles/global.css'
|
||||
],
|
||||
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}'],
|
||||
ignoreDependencies: ['@comfyorg/design-system', '@vercel/analytics']
|
||||
project: ['src/**/*.{astro,vue,ts}', '*.{js,ts,mjs}']
|
||||
}
|
||||
},
|
||||
ignoreBinaries: ['python3'],
|
||||
@@ -54,8 +53,6 @@ const config: KnipConfig = {
|
||||
// Auto generated API types
|
||||
'src/workbench/extensions/manager/types/generatedManagerTypes.ts',
|
||||
'packages/ingest-types/src/zod.gen.ts',
|
||||
// Used by stacked PR (feat/glsl-live-preview)
|
||||
'src/renderer/glsl/useGLSLRenderer.ts',
|
||||
// Workflow files contain license names that knip misinterprets as binaries
|
||||
'.github/workflows/ci-oss-assets-validation.yaml',
|
||||
// Pending integration in stacked PR
|
||||
|
||||
627
pnpm-lock.yaml
generated
@@ -74,7 +74,7 @@ catalog:
|
||||
eslint-import-resolver-typescript: ^4.4.4
|
||||
eslint-plugin-better-tailwindcss: ^4.3.1
|
||||
eslint-plugin-import-x: ^4.16.1
|
||||
eslint-plugin-oxlint: 1.55.0
|
||||
eslint-plugin-oxlint: 1.59.0
|
||||
eslint-plugin-storybook: ^10.2.10
|
||||
eslint-plugin-testing-library: ^7.16.1
|
||||
eslint-plugin-unused-imports: ^4.3.0
|
||||
@@ -89,14 +89,14 @@ catalog:
|
||||
jsdom: ^27.4.0
|
||||
jsonata: ^2.1.0
|
||||
jsondiffpatch: ^0.7.3
|
||||
knip: ^6.0.1
|
||||
knip: ^6.3.1
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
mixpanel-browser: ^2.71.0
|
||||
nx: 22.6.1
|
||||
oxfmt: ^0.40.0
|
||||
oxlint: ^1.55.0
|
||||
oxlint-tsgolint: ^0.17.0
|
||||
oxfmt: ^0.44.0
|
||||
oxlint: ^1.59.0
|
||||
oxlint-tsgolint: ^0.20.0
|
||||
picocolors: ^1.1.1
|
||||
pinia: ^3.0.4
|
||||
postcss-html: ^1.8.0
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
/* eslint-disable testing-library/no-container */
|
||||
/* eslint-disable testing-library/no-node-access */
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, defineComponent, h, nextTick, onMounted, ref } from 'vue'
|
||||
@@ -8,8 +11,6 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
import TopMenuSection from '@/components/TopMenuSection.vue'
|
||||
import QueueNotificationBannerHost from '@/components/queue/QueueNotificationBannerHost.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import LoginButton from '@/components/topbar/LoginButton.vue'
|
||||
import type {
|
||||
JobListItem,
|
||||
JobStatus
|
||||
@@ -114,8 +115,9 @@ function createWrapper({
|
||||
}
|
||||
})
|
||||
|
||||
return mount(TopMenuSection, {
|
||||
attachTo,
|
||||
const user = userEvent.setup()
|
||||
|
||||
const renderOptions: Record<string, unknown> = {
|
||||
global: {
|
||||
plugins: [pinia, i18n],
|
||||
stubs: {
|
||||
@@ -128,7 +130,8 @@ function createWrapper({
|
||||
ContextMenu: {
|
||||
name: 'ContextMenu',
|
||||
props: ['model'],
|
||||
template: '<div />'
|
||||
template:
|
||||
'<div data-testid="context-menu" :data-model="JSON.stringify(model)" />'
|
||||
},
|
||||
...stubs
|
||||
},
|
||||
@@ -136,15 +139,23 @@ function createWrapper({
|
||||
tooltip: () => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (attachTo) {
|
||||
renderOptions.container = attachTo.appendChild(
|
||||
document.createElement('div')
|
||||
)
|
||||
}
|
||||
|
||||
const { container, unmount } = render(TopMenuSection, renderOptions)
|
||||
|
||||
return { container, unmount, user }
|
||||
}
|
||||
|
||||
function getLegacyCommandsContainer(
|
||||
wrapper: ReturnType<typeof createWrapper>
|
||||
): HTMLElement {
|
||||
const legacyContainer = wrapper.find(
|
||||
function getLegacyCommandsContainer(container: Element): HTMLElement {
|
||||
const legacyContainer = container.querySelector(
|
||||
'[data-testid="legacy-topbar-container"]'
|
||||
).element
|
||||
)
|
||||
if (!(legacyContainer instanceof HTMLElement)) {
|
||||
throw new Error('Expected legacy commands container to be present')
|
||||
}
|
||||
@@ -201,9 +212,11 @@ describe('TopMenuSection', () => {
|
||||
})
|
||||
|
||||
it('should display CurrentUserButton and not display LoginButton', () => {
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
||||
const { container } = createLegacyTabBarWrapper()
|
||||
expect(
|
||||
container.querySelector('current-user-button-stub')
|
||||
).not.toBeNull()
|
||||
expect(container.querySelector('login-button-stub')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -215,24 +228,24 @@ describe('TopMenuSection', () => {
|
||||
describe('on desktop platform', () => {
|
||||
it('should display LoginButton and not display CurrentUserButton', () => {
|
||||
mockData.isDesktop = true
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
||||
const { container } = createLegacyTabBarWrapper()
|
||||
expect(container.querySelector('login-button-stub')).not.toBeNull()
|
||||
expect(container.querySelector('current-user-button-stub')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('on web platform', () => {
|
||||
it('should not display CurrentUserButton and not display LoginButton', () => {
|
||||
const wrapper = createLegacyTabBarWrapper()
|
||||
expect(wrapper.findComponent(CurrentUserButton).exists()).toBe(false)
|
||||
expect(wrapper.findComponent(LoginButton).exists()).toBe(false)
|
||||
const { container } = createLegacyTabBarWrapper()
|
||||
expect(container.querySelector('current-user-button-stub')).toBeNull()
|
||||
expect(container.querySelector('login-button-stub')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the active jobs label with the current count', async () => {
|
||||
const wrapper = createWrapper()
|
||||
createWrapper()
|
||||
const queueStore = useQueueStore()
|
||||
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
|
||||
queueStore.runningTasks = [
|
||||
@@ -242,19 +255,15 @@ describe('TopMenuSection', () => {
|
||||
|
||||
await nextTick()
|
||||
|
||||
const queueButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||
expect(queueButton.text()).toContain('3 active')
|
||||
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
const queueButton = screen.getByTestId('queue-overlay-toggle')
|
||||
expect(queueButton.textContent).toContain('3 active')
|
||||
expect(screen.getByTestId('active-jobs-indicator')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides the active jobs indicator when no jobs are active', () => {
|
||||
const wrapper = createWrapper()
|
||||
createWrapper()
|
||||
|
||||
expect(wrapper.find('[data-testid="active-jobs-indicator"]').exists()).toBe(
|
||||
false
|
||||
)
|
||||
expect(screen.queryByTestId('active-jobs-indicator')).toBeNull()
|
||||
})
|
||||
|
||||
it('hides queue progress overlay when QPO V2 is enabled', async () => {
|
||||
@@ -263,16 +272,12 @@ describe('TopMenuSection', () => {
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||
)
|
||||
const wrapper = createWrapper({ pinia })
|
||||
const { container } = createWrapper({ pinia })
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('[data-testid="queue-overlay-toggle"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'QueueProgressOverlay' }).exists()
|
||||
).toBe(false)
|
||||
expect(screen.getByTestId('queue-overlay-toggle')).toBeTruthy()
|
||||
expect(container.querySelector('queue-progress-overlay-stub')).toBeNull()
|
||||
})
|
||||
|
||||
it('toggles the queue progress overlay when QPO V2 is disabled', async () => {
|
||||
@@ -281,10 +286,10 @@ describe('TopMenuSection', () => {
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? false : undefined
|
||||
)
|
||||
const wrapper = createWrapper({ pinia })
|
||||
const { user } = createWrapper({ pinia })
|
||||
const commandStore = useCommandStore(pinia)
|
||||
|
||||
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
|
||||
await user.click(screen.getByTestId('queue-overlay-toggle'))
|
||||
|
||||
expect(commandStore.execute).toHaveBeenCalledWith(
|
||||
'Comfy.Queue.ToggleOverlay'
|
||||
@@ -297,10 +302,10 @@ describe('TopMenuSection', () => {
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||
)
|
||||
const wrapper = createWrapper({ pinia })
|
||||
const { user } = createWrapper({ pinia })
|
||||
const sidebarTabStore = useSidebarTabStore(pinia)
|
||||
|
||||
await wrapper.find('[data-testid="queue-overlay-toggle"]').trigger('click')
|
||||
await user.click(screen.getByTestId('queue-overlay-toggle'))
|
||||
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
|
||||
})
|
||||
@@ -311,14 +316,14 @@ describe('TopMenuSection', () => {
|
||||
vi.mocked(settingStore.get).mockImplementation((key) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? true : undefined
|
||||
)
|
||||
const wrapper = createWrapper({ pinia })
|
||||
const { user } = createWrapper({ pinia })
|
||||
const sidebarTabStore = useSidebarTabStore(pinia)
|
||||
const toggleButton = wrapper.find('[data-testid="queue-overlay-toggle"]')
|
||||
const toggleButton = screen.getByTestId('queue-overlay-toggle')
|
||||
|
||||
await toggleButton.trigger('click')
|
||||
await user.click(toggleButton)
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe('job-history')
|
||||
|
||||
await toggleButton.trigger('click')
|
||||
await user.click(toggleButton)
|
||||
expect(sidebarTabStore.activeSidebarTabId).toBe(null)
|
||||
})
|
||||
|
||||
@@ -341,39 +346,39 @@ describe('TopMenuSection', () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true)
|
||||
|
||||
const wrapper = createWrapper({ pinia })
|
||||
const { container } = createWrapper({ pinia })
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
|
||||
).toBe(true)
|
||||
container.querySelector('queue-inline-progress-summary-stub')
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not render inline progress summary when QPO V2 is disabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, false)
|
||||
|
||||
const wrapper = createWrapper({ pinia })
|
||||
const { container } = createWrapper({ pinia })
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
|
||||
).toBe(false)
|
||||
container.querySelector('queue-inline-progress-summary-stub')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render inline progress summary when run progress bar is disabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true, false)
|
||||
|
||||
const wrapper = createWrapper({ pinia })
|
||||
const { container } = createWrapper({ pinia })
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'QueueInlineProgressSummary' }).exists()
|
||||
).toBe(false)
|
||||
container.querySelector('queue-inline-progress-summary-stub')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('teleports inline progress summary when actionbar is floating', async () => {
|
||||
@@ -387,7 +392,7 @@ describe('TopMenuSection', () => {
|
||||
|
||||
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
|
||||
|
||||
const wrapper = createWrapper({
|
||||
const { unmount } = createWrapper({
|
||||
pinia,
|
||||
attachTo: document.body,
|
||||
stubs: {
|
||||
@@ -401,7 +406,7 @@ describe('TopMenuSection', () => {
|
||||
|
||||
expect(actionbarTarget.querySelector('[role="status"]')).not.toBeNull()
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
actionbarTarget.remove()
|
||||
}
|
||||
})
|
||||
@@ -424,36 +429,36 @@ describe('TopMenuSection', () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true)
|
||||
|
||||
const wrapper = createWrapper({ pinia })
|
||||
const { container } = createWrapper({ pinia })
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists()
|
||||
).toBe(true)
|
||||
container.querySelector('queue-notification-banner-host-stub')
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders queue notification banners when QPO V2 is disabled', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, false)
|
||||
|
||||
const wrapper = createWrapper({ pinia })
|
||||
const { container } = createWrapper({ pinia })
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(
|
||||
wrapper.findComponent({ name: 'QueueNotificationBannerHost' }).exists()
|
||||
).toBe(true)
|
||||
container.querySelector('queue-notification-banner-host-stub')
|
||||
).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders inline summary above banners when both are visible', async () => {
|
||||
const pinia = createTestingPinia({ createSpy: vi.fn })
|
||||
configureSettings(pinia, true)
|
||||
const wrapper = createWrapper({ pinia })
|
||||
const { container } = createWrapper({ pinia })
|
||||
|
||||
await nextTick()
|
||||
|
||||
const html = wrapper.html()
|
||||
const html = container.innerHTML
|
||||
const inlineSummaryIndex = html.indexOf(
|
||||
'queue-inline-progress-summary-stub'
|
||||
)
|
||||
@@ -477,7 +482,7 @@ describe('TopMenuSection', () => {
|
||||
|
||||
const ComfyActionbarStub = createComfyActionbarStub(actionbarTarget)
|
||||
|
||||
const wrapper = createWrapper({
|
||||
const { container, unmount } = createWrapper({
|
||||
pinia,
|
||||
attachTo: document.body,
|
||||
stubs: {
|
||||
@@ -493,47 +498,49 @@ describe('TopMenuSection', () => {
|
||||
actionbarTarget.querySelector('queue-notification-banner-host-stub')
|
||||
).toBeNull()
|
||||
expect(
|
||||
wrapper
|
||||
.findComponent({ name: 'QueueNotificationBannerHost' })
|
||||
.exists()
|
||||
).toBe(true)
|
||||
container.querySelector('queue-notification-banner-host-stub')
|
||||
).not.toBeNull()
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
actionbarTarget.remove()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('disables the clear queue context menu item when no queued jobs exist', () => {
|
||||
const wrapper = createWrapper()
|
||||
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||
const model = menu.props('model') as MenuItem[]
|
||||
const { container } = createWrapper()
|
||||
const menuEl = container.querySelector('[data-testid="context-menu"]')
|
||||
const model = JSON.parse(
|
||||
menuEl?.getAttribute('data-model') ?? '[]'
|
||||
) as MenuItem[]
|
||||
expect(model[0]?.label).toBe('Clear queue')
|
||||
expect(model[0]?.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('enables the clear queue context menu item when queued jobs exist', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const { container } = createWrapper()
|
||||
const queueStore = useQueueStore()
|
||||
queueStore.pendingTasks = [createTask('pending-1', 'pending')]
|
||||
|
||||
await nextTick()
|
||||
|
||||
const menu = wrapper.findComponent({ name: 'ContextMenu' })
|
||||
const model = menu.props('model') as MenuItem[]
|
||||
const menuEl = container.querySelector('[data-testid="context-menu"]')
|
||||
const model = JSON.parse(
|
||||
menuEl?.getAttribute('data-model') ?? '[]'
|
||||
) as MenuItem[]
|
||||
expect(model[0]?.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('shows manager red dot only for manager conflicts', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const { container } = createWrapper()
|
||||
|
||||
// Release red dot is mocked as true globally for this test file.
|
||||
expect(wrapper.find('span.bg-red-500').exists()).toBe(false)
|
||||
expect(container.querySelector('span.bg-red-500')).toBeNull()
|
||||
|
||||
mockData.setShowConflictRedDot(true)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('span.bg-red-500').exists()).toBe(true)
|
||||
expect(container.querySelector('span.bg-red-500')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('coalesces legacy topbar mutation scans to one check per frame', async () => {
|
||||
@@ -555,15 +562,19 @@ describe('TopMenuSection', () => {
|
||||
return undefined
|
||||
})
|
||||
|
||||
const wrapper = createWrapper({ pinia, attachTo: document.body })
|
||||
const { container, unmount } = createWrapper({
|
||||
pinia,
|
||||
attachTo: document.body
|
||||
})
|
||||
|
||||
try {
|
||||
await nextTick()
|
||||
|
||||
const actionbarContainer = wrapper.find('.actionbar-container')
|
||||
expect(actionbarContainer.classes()).toContain('w-0')
|
||||
const actionbarContainer = container.querySelector('.actionbar-container')
|
||||
expect(actionbarContainer).not.toBeNull()
|
||||
expect(actionbarContainer!.classList).toContain('w-0')
|
||||
|
||||
const legacyContainer = getLegacyCommandsContainer(wrapper)
|
||||
const legacyContainer = getLegacyCommandsContainer(container)
|
||||
const querySpy = vi.spyOn(legacyContainer, 'querySelector')
|
||||
|
||||
if (rafCallbacks.length > 0) {
|
||||
@@ -594,9 +605,9 @@ describe('TopMenuSection', () => {
|
||||
await nextTick()
|
||||
|
||||
expect(querySpy).toHaveBeenCalledTimes(1)
|
||||
expect(actionbarContainer.classes()).toContain('px-2')
|
||||
expect(actionbarContainer!.classList).toContain('px-2')
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import EssentialsPanel from '@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue'
|
||||
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
// Mock ShortcutsList component
|
||||
@@ -12,7 +10,7 @@ vi.mock('@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue', () => ({
|
||||
name: 'ShortcutsList',
|
||||
props: ['commands', 'subcategories', 'columns'],
|
||||
template:
|
||||
'<div class="shortcuts-list-mock">{{ commands.length }} commands</div>'
|
||||
'<div data-testid="shortcuts-list">{{ JSON.stringify(subcategories) }}</div>'
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -56,25 +54,34 @@ describe('EssentialsPanel', () => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('should render ShortcutsList with essentials commands', () => {
|
||||
const wrapper = mount(EssentialsPanel)
|
||||
it('should render ShortcutsList with essentials commands', async () => {
|
||||
const { default: EssentialsPanel } =
|
||||
await import('@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue')
|
||||
render(EssentialsPanel)
|
||||
|
||||
const shortcutsList = wrapper.findComponent(ShortcutsList)
|
||||
expect(shortcutsList.exists()).toBe(true)
|
||||
expect(screen.getByTestId('shortcuts-list')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should categorize commands into subcategories', () => {
|
||||
const wrapper = mount(EssentialsPanel)
|
||||
it('should categorize commands into subcategories', async () => {
|
||||
const { default: EssentialsPanel } =
|
||||
await import('@/components/bottomPanel/tabs/shortcuts/EssentialsPanel.vue')
|
||||
render(EssentialsPanel)
|
||||
|
||||
const shortcutsList = wrapper.findComponent(ShortcutsList)
|
||||
const subcategories = shortcutsList.props('subcategories')
|
||||
const el = screen.getByTestId('shortcuts-list')
|
||||
const subcategories = JSON.parse(el.textContent ?? '{}')
|
||||
|
||||
expect(subcategories).toHaveProperty('workflow')
|
||||
expect(subcategories).toHaveProperty('node')
|
||||
expect(subcategories).toHaveProperty('queue')
|
||||
|
||||
expect(subcategories.workflow).toContain(mockCommands[0])
|
||||
expect(subcategories.node).toContain(mockCommands[1])
|
||||
expect(subcategories.queue).toContain(mockCommands[2])
|
||||
expect(subcategories.workflow).toContainEqual(
|
||||
expect.objectContaining({ id: 'Workflow.New' })
|
||||
)
|
||||
expect(subcategories.node).toContainEqual(
|
||||
expect.objectContaining({ id: 'Node.Add' })
|
||||
)
|
||||
expect(subcategories.queue).toContainEqual(
|
||||
expect.objectContaining({ id: 'Queue.Clear' })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
|
||||
import ShortcutsList from '@/components/bottomPanel/tabs/shortcuts/ShortcutsList.vue'
|
||||
import type { ComfyCommandImpl } from '@/stores/commandStore'
|
||||
|
||||
@@ -64,36 +65,31 @@ describe('ShortcutsList', () => {
|
||||
}
|
||||
|
||||
it('should render shortcuts organized by subcategories', () => {
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
render(ShortcutsList, {
|
||||
props: {
|
||||
commands: mockCommands,
|
||||
subcategories: mockSubcategories
|
||||
}
|
||||
})
|
||||
|
||||
// Check that subcategories are rendered
|
||||
expect(wrapper.text()).toContain('Workflow')
|
||||
expect(wrapper.text()).toContain('Node')
|
||||
expect(wrapper.text()).toContain('Queue')
|
||||
|
||||
// Check that commands are rendered
|
||||
expect(wrapper.text()).toContain('New Blank Workflow')
|
||||
expect(screen.getByText('Workflow')).toBeInTheDocument()
|
||||
expect(screen.getByText('Node')).toBeInTheDocument()
|
||||
expect(screen.getByText('Queue')).toBeInTheDocument()
|
||||
expect(screen.getByText('New Blank Workflow')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should format keyboard shortcuts correctly', () => {
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
const { container } = render(ShortcutsList, {
|
||||
props: {
|
||||
commands: mockCommands,
|
||||
subcategories: mockSubcategories
|
||||
}
|
||||
})
|
||||
|
||||
// Check for formatted keys
|
||||
expect(wrapper.text()).toContain('Ctrl')
|
||||
expect(wrapper.text()).toContain('n')
|
||||
expect(wrapper.text()).toContain('Shift')
|
||||
expect(wrapper.text()).toContain('a')
|
||||
expect(wrapper.text()).toContain('c')
|
||||
const text = container.textContent!
|
||||
expect(text).toContain('Ctrl')
|
||||
expect(text).toContain('n')
|
||||
expect(text).toContain('Shift')
|
||||
expect(text).toContain('a')
|
||||
expect(text).toContain('c')
|
||||
})
|
||||
|
||||
it('should filter out commands without keybindings', () => {
|
||||
@@ -107,9 +103,8 @@ describe('ShortcutsList', () => {
|
||||
} as ComfyCommandImpl
|
||||
]
|
||||
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
render(ShortcutsList, {
|
||||
props: {
|
||||
commands: commandsWithoutKeybinding,
|
||||
subcategories: {
|
||||
...mockSubcategories,
|
||||
other: [commandsWithoutKeybinding[3]]
|
||||
@@ -117,7 +112,7 @@ describe('ShortcutsList', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).not.toContain('No Keybinding')
|
||||
expect(screen.queryByText('No Keybinding')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle special key formatting', () => {
|
||||
@@ -132,16 +127,15 @@ describe('ShortcutsList', () => {
|
||||
}
|
||||
} as ComfyCommandImpl
|
||||
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
const { container } = render(ShortcutsList, {
|
||||
props: {
|
||||
commands: [specialKeyCommand],
|
||||
subcategories: {
|
||||
special: [specialKeyCommand]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const text = wrapper.text()
|
||||
const text = container.textContent!
|
||||
expect(text).toContain('Cmd') // Meta -> Cmd
|
||||
expect(text).toContain('↑') // ArrowUp -> ↑
|
||||
expect(text).toContain('↵') // Enter -> ↵
|
||||
@@ -150,15 +144,14 @@ describe('ShortcutsList', () => {
|
||||
})
|
||||
|
||||
it('should use fallback subcategory titles', () => {
|
||||
const wrapper = mount(ShortcutsList, {
|
||||
render(ShortcutsList, {
|
||||
props: {
|
||||
commands: mockCommands,
|
||||
subcategories: {
|
||||
unknown: [mockCommands[0]]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('unknown')
|
||||
expect(screen.getByText('unknown')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* eslint-disable testing-library/no-node-access */
|
||||
/* eslint-disable testing-library/prefer-user-event */
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import type { Mock } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -67,9 +68,10 @@ vi.mock('@/platform/distribution/types', () => ({
|
||||
}))
|
||||
|
||||
// Mock clipboard API
|
||||
const mockWriteText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined)
|
||||
writeText: mockWriteText
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
@@ -87,8 +89,9 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
const mountBaseTerminal = () => {
|
||||
return mount(BaseTerminal, {
|
||||
function renderBaseTerminal(props: Record<string, unknown> = {}) {
|
||||
return render(BaseTerminal, {
|
||||
props,
|
||||
global: {
|
||||
plugins: [
|
||||
createTestingPinia({
|
||||
@@ -107,68 +110,60 @@ const mountBaseTerminal = () => {
|
||||
}
|
||||
|
||||
describe('BaseTerminal', () => {
|
||||
let wrapper: VueWrapper<InstanceType<typeof BaseTerminal>> | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
})
|
||||
|
||||
it('emits created event on mount', () => {
|
||||
wrapper = mountBaseTerminal()
|
||||
const onCreated = vi.fn()
|
||||
renderBaseTerminal({ onCreated })
|
||||
|
||||
expect(wrapper.emitted('created')).toBeTruthy()
|
||||
expect(wrapper.emitted('created')![0]).toHaveLength(2)
|
||||
expect(onCreated).toHaveBeenCalled()
|
||||
expect(onCreated.mock.calls[0]).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('emits unmounted event on unmount', () => {
|
||||
wrapper = mountBaseTerminal()
|
||||
wrapper.unmount()
|
||||
const onUnmounted = vi.fn()
|
||||
const { unmount } = renderBaseTerminal({ onUnmounted })
|
||||
unmount()
|
||||
|
||||
expect(wrapper.emitted('unmounted')).toBeTruthy()
|
||||
expect(onUnmounted).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('button exists and has correct initial state', async () => {
|
||||
wrapper = mountBaseTerminal()
|
||||
it('button exists and has correct initial state', () => {
|
||||
renderBaseTerminal()
|
||||
|
||||
const button = wrapper.find('button[aria-label]')
|
||||
expect(button.exists()).toBe(true)
|
||||
|
||||
expect(button.classes()).toContain('opacity-0')
|
||||
expect(button.classes()).toContain('pointer-events-none')
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('opacity-0', 'pointer-events-none')
|
||||
})
|
||||
|
||||
it('shows correct tooltip when no selection', async () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(false)
|
||||
wrapper = mountBaseTerminal()
|
||||
const { container } = renderBaseTerminal()
|
||||
|
||||
await wrapper.trigger('mouseenter')
|
||||
await fireEvent.mouseEnter(container.firstElementChild!)
|
||||
await nextTick()
|
||||
|
||||
const button = wrapper.find('button[aria-label]')
|
||||
expect(button.attributes('aria-label')).toBe('Copy all')
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveAttribute('aria-label', 'Copy all')
|
||||
})
|
||||
|
||||
it('shows correct tooltip when selection exists', async () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(true)
|
||||
wrapper = mountBaseTerminal()
|
||||
const { container } = renderBaseTerminal()
|
||||
|
||||
// Trigger the selection change callback that was registered during mount
|
||||
expect(mockTerminal.onSelectionChange).toHaveBeenCalled()
|
||||
// Access the mock calls - TypeScript can't infer the mock structure dynamically
|
||||
const mockCalls = (mockTerminal.onSelectionChange as Mock).mock.calls
|
||||
const selectionCallback = mockCalls[0][0] as () => void
|
||||
selectionCallback()
|
||||
await nextTick()
|
||||
|
||||
await wrapper.trigger('mouseenter')
|
||||
await fireEvent.mouseEnter(container.firstElementChild!)
|
||||
await nextTick()
|
||||
|
||||
const button = wrapper.find('button[aria-label]')
|
||||
expect(button.attributes('aria-label')).toBe('Copy selection')
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveAttribute('aria-label', 'Copy selection')
|
||||
})
|
||||
|
||||
it('copies selected text when selection exists', async () => {
|
||||
@@ -176,16 +171,17 @@ describe('BaseTerminal', () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(true)
|
||||
mockTerminal.getSelection.mockReturnValue(selectedText)
|
||||
|
||||
wrapper = mountBaseTerminal()
|
||||
const { container } = renderBaseTerminal()
|
||||
|
||||
await wrapper.trigger('mouseenter')
|
||||
await fireEvent.mouseEnter(container.firstElementChild!)
|
||||
await nextTick()
|
||||
|
||||
const button = wrapper.find('button[aria-label]')
|
||||
await button.trigger('click')
|
||||
const button = screen.getByRole('button')
|
||||
await fireEvent.click(button)
|
||||
await nextTick()
|
||||
|
||||
expect(mockTerminal.selectAll).not.toHaveBeenCalled()
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(selectedText)
|
||||
expect(mockWriteText).toHaveBeenCalledWith(selectedText)
|
||||
expect(mockTerminal.clearSelection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -196,16 +192,17 @@ describe('BaseTerminal', () => {
|
||||
.mockReturnValueOnce('') // First call returns empty (no selection)
|
||||
.mockReturnValueOnce(allText) // Second call after selectAll returns all text
|
||||
|
||||
wrapper = mountBaseTerminal()
|
||||
const { container } = renderBaseTerminal()
|
||||
|
||||
await wrapper.trigger('mouseenter')
|
||||
await fireEvent.mouseEnter(container.firstElementChild!)
|
||||
await nextTick()
|
||||
|
||||
const button = wrapper.find('button[aria-label]')
|
||||
await button.trigger('click')
|
||||
const button = screen.getByRole('button')
|
||||
await fireEvent.click(button)
|
||||
await nextTick()
|
||||
|
||||
expect(mockTerminal.selectAll).toHaveBeenCalled()
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(allText)
|
||||
expect(mockWriteText).toHaveBeenCalledWith(allText)
|
||||
expect(mockTerminal.clearSelection).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -213,15 +210,16 @@ describe('BaseTerminal', () => {
|
||||
mockTerminal.hasSelection.mockReturnValue(false)
|
||||
mockTerminal.getSelection.mockReturnValue('')
|
||||
|
||||
wrapper = mountBaseTerminal()
|
||||
const { container } = renderBaseTerminal()
|
||||
|
||||
await wrapper.trigger('mouseenter')
|
||||
await fireEvent.mouseEnter(container.firstElementChild!)
|
||||
await nextTick()
|
||||
|
||||
const button = wrapper.find('button[aria-label]')
|
||||
await button.trigger('click')
|
||||
const button = screen.getByRole('button')
|
||||
await fireEvent.click(button)
|
||||
await nextTick()
|
||||
|
||||
expect(mockTerminal.selectAll).toHaveBeenCalled()
|
||||
expect(navigator.clipboard.writeText).not.toHaveBeenCalled()
|
||||
expect(mockWriteText).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
|
||||
import Badge from './Badge.vue'
|
||||
import { badgeVariants } from './badge.variants'
|
||||
|
||||
describe('Badge', () => {
|
||||
it('renders label text', () => {
|
||||
const wrapper = mount(Badge, { props: { label: 'NEW' } })
|
||||
expect(wrapper.text()).toBe('NEW')
|
||||
render(Badge, { props: { label: 'NEW' } })
|
||||
expect(screen.getByText('NEW')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders numeric label', () => {
|
||||
const wrapper = mount(Badge, { props: { label: 5 } })
|
||||
expect(wrapper.text()).toBe('5')
|
||||
render(Badge, { props: { label: 5 } })
|
||||
expect(screen.getByText('5')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defaults to dot variant when no label is provided', () => {
|
||||
const wrapper = mount(Badge)
|
||||
expect(wrapper.classes()).toContain('size-2')
|
||||
const { container } = render(Badge)
|
||||
// eslint-disable-next-line testing-library/no-node-access -- dot badge has no text/role to query
|
||||
expect(container.firstElementChild).toHaveClass('size-2')
|
||||
})
|
||||
|
||||
it('defaults to label variant when label is provided', () => {
|
||||
const wrapper = mount(Badge, { props: { label: 'NEW' } })
|
||||
expect(wrapper.classes()).toContain('font-semibold')
|
||||
expect(wrapper.classes()).toContain('uppercase')
|
||||
render(Badge, { props: { label: 'NEW' } })
|
||||
const el = screen.getByText('NEW')
|
||||
expect(el).toHaveClass('font-semibold')
|
||||
expect(el).toHaveClass('uppercase')
|
||||
})
|
||||
|
||||
it('applies circle variant', () => {
|
||||
const wrapper = mount(Badge, {
|
||||
props: { label: '3', variant: 'circle' }
|
||||
})
|
||||
expect(wrapper.classes()).toContain('size-3.5')
|
||||
render(Badge, { props: { label: '3', variant: 'circle' } })
|
||||
expect(screen.getByText('3')).toHaveClass('size-3.5')
|
||||
})
|
||||
|
||||
it('merges custom class via cn()', () => {
|
||||
const wrapper = mount(Badge, {
|
||||
props: { label: 'Test', class: 'ml-2' }
|
||||
})
|
||||
expect(wrapper.classes()).toContain('ml-2')
|
||||
expect(wrapper.classes()).toContain('rounded-full')
|
||||
render(Badge, { props: { label: 'Test', class: 'ml-2' } })
|
||||
const el = screen.getByText('Test')
|
||||
expect(el).toHaveClass('ml-2')
|
||||
expect(el).toHaveClass('rounded-full')
|
||||
})
|
||||
|
||||
describe('twMerge preserves color alongside text-3xs font size', () => {
|
||||
@@ -58,12 +58,10 @@ describe('Badge', () => {
|
||||
)
|
||||
|
||||
it('cn() does not clobber text-white when merging with text-3xs', () => {
|
||||
const wrapper = mount(Badge, {
|
||||
props: { label: 'Test', severity: 'danger' }
|
||||
})
|
||||
const classList = wrapper.classes()
|
||||
expect(classList).toContain('text-white')
|
||||
expect(classList).toContain('text-3xs')
|
||||
render(Badge, { props: { label: 'Test', severity: 'danger' } })
|
||||
const el = screen.getByText('Test')
|
||||
expect(el).toHaveClass('text-white')
|
||||
expect(el).toHaveClass('text-3xs')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
|
||||
import MarqueeLine from './MarqueeLine.vue'
|
||||
|
||||
describe(MarqueeLine, () => {
|
||||
it('renders slot content', () => {
|
||||
const wrapper = mount(MarqueeLine, {
|
||||
render(MarqueeLine, {
|
||||
slots: { default: 'Hello World' }
|
||||
})
|
||||
expect(wrapper.text()).toBe('Hello World')
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders content inside a span within the container', () => {
|
||||
const wrapper = mount(MarqueeLine, {
|
||||
render(MarqueeLine, {
|
||||
slots: { default: 'Test Text' }
|
||||
})
|
||||
const span = wrapper.find('span')
|
||||
expect(span.exists()).toBe(true)
|
||||
expect(span.text()).toBe('Test Text')
|
||||
const el = screen.getByText('Test Text')
|
||||
expect(el.tagName).toBe('SPAN')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import NotificationPopup from './NotificationPopup.vue'
|
||||
@@ -13,13 +13,11 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
function mountPopup(
|
||||
props: ComponentProps<typeof NotificationPopup> = {
|
||||
title: 'Test'
|
||||
},
|
||||
function renderPopup(
|
||||
props: { title: string; [key: string]: unknown } = { title: 'Test' },
|
||||
slots: Record<string, string> = {}
|
||||
) {
|
||||
return mount(NotificationPopup, {
|
||||
return render(NotificationPopup, {
|
||||
global: { plugins: [i18n] },
|
||||
props,
|
||||
slots
|
||||
@@ -28,51 +26,58 @@ function mountPopup(
|
||||
|
||||
describe('NotificationPopup', () => {
|
||||
it('renders title', () => {
|
||||
const wrapper = mountPopup({ title: 'Hello World' })
|
||||
expect(wrapper.text()).toContain('Hello World')
|
||||
renderPopup({ title: 'Hello World' })
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Hello World')
|
||||
})
|
||||
|
||||
it('has role="status" for accessibility', () => {
|
||||
const wrapper = mountPopup()
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
renderPopup()
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders subtitle when provided', () => {
|
||||
const wrapper = mountPopup({ title: 'T', subtitle: 'v1.2.3' })
|
||||
expect(wrapper.text()).toContain('v1.2.3')
|
||||
renderPopup({ title: 'T', subtitle: 'v1.2.3' })
|
||||
expect(screen.getByRole('status')).toHaveTextContent('v1.2.3')
|
||||
})
|
||||
|
||||
it('renders icon when provided', () => {
|
||||
const wrapper = mountPopup({
|
||||
const { container } = renderPopup({
|
||||
title: 'T',
|
||||
icon: 'icon-[lucide--rocket]'
|
||||
})
|
||||
expect(wrapper.find('i.icon-\\[lucide--rocket\\]').exists()).toBe(true)
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const icon = container.querySelector('i.icon-\\[lucide--rocket\\]')
|
||||
expect(icon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits close when close button clicked', async () => {
|
||||
const wrapper = mountPopup({ title: 'T', showClose: true })
|
||||
await wrapper.find('[aria-label="Close"]').trigger('click')
|
||||
expect(wrapper.emitted('close')).toHaveLength(1)
|
||||
const user = userEvent.setup()
|
||||
const closeSpy = vi.fn()
|
||||
renderPopup({ title: 'T', showClose: true, onClose: closeSpy })
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(closeSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders default slot content', () => {
|
||||
const wrapper = mountPopup({ title: 'T' }, { default: 'Body text here' })
|
||||
expect(wrapper.text()).toContain('Body text here')
|
||||
renderPopup({ title: 'T' }, { default: 'Body text here' })
|
||||
expect(screen.getByRole('status')).toHaveTextContent('Body text here')
|
||||
})
|
||||
|
||||
it('renders footer slots', () => {
|
||||
const wrapper = mountPopup(
|
||||
renderPopup(
|
||||
{ title: 'T' },
|
||||
{ 'footer-start': 'Left side', 'footer-end': 'Right side' }
|
||||
)
|
||||
expect(wrapper.text()).toContain('Left side')
|
||||
expect(wrapper.text()).toContain('Right side')
|
||||
const status = screen.getByRole('status')
|
||||
expect(status).toHaveTextContent('Left side')
|
||||
expect(status).toHaveTextContent('Right side')
|
||||
})
|
||||
|
||||
it('positions bottom-right when specified', () => {
|
||||
const wrapper = mountPopup({ title: 'T', position: 'bottom-right' })
|
||||
const root = wrapper.find('[role="status"]')
|
||||
expect(root.attributes('data-position')).toBe('bottom-right')
|
||||
renderPopup({ title: 'T', position: 'bottom-right' })
|
||||
expect(screen.getByRole('status')).toHaveAttribute(
|
||||
'data-position',
|
||||
'bottom-right'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -13,7 +14,8 @@ function mockScrollWidth(el: HTMLElement, scrollWidth: number) {
|
||||
|
||||
describe(TextTicker, () => {
|
||||
let rafCallbacks: ((time: number) => void)[]
|
||||
let wrapper: ReturnType<typeof mount>
|
||||
let user: ReturnType<typeof userEvent.setup>
|
||||
let cleanup: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
@@ -23,32 +25,35 @@ describe(TextTicker, () => {
|
||||
return rafCallbacks.length
|
||||
})
|
||||
vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {})
|
||||
user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
cleanup?.()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renders slot content', () => {
|
||||
wrapper = mount(TextTicker, {
|
||||
const { unmount } = render(TextTicker, {
|
||||
slots: { default: 'Hello World' }
|
||||
})
|
||||
expect(wrapper.text()).toBe('Hello World')
|
||||
cleanup = unmount
|
||||
expect(screen.getByText('Hello World')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('scrolls on hover after delay', async () => {
|
||||
wrapper = mount(TextTicker, {
|
||||
const { unmount } = render(TextTicker, {
|
||||
slots: { default: 'Very long text that overflows' },
|
||||
props: { speed: 100 }
|
||||
})
|
||||
cleanup = unmount
|
||||
|
||||
const el = wrapper.element as HTMLElement
|
||||
const el = screen.getByText('Very long text that overflows')
|
||||
mockScrollWidth(el, 300)
|
||||
|
||||
await nextTick()
|
||||
await wrapper.trigger('mouseenter')
|
||||
await user.hover(el)
|
||||
await nextTick()
|
||||
|
||||
expect(rafCallbacks.length).toBe(0)
|
||||
@@ -62,19 +67,21 @@ describe(TextTicker, () => {
|
||||
})
|
||||
|
||||
it('cancels delayed scroll on mouse leave before delay elapses', async () => {
|
||||
wrapper = mount(TextTicker, {
|
||||
const { unmount } = render(TextTicker, {
|
||||
slots: { default: 'Very long text that overflows' },
|
||||
props: { speed: 100 }
|
||||
})
|
||||
cleanup = unmount
|
||||
|
||||
mockScrollWidth(wrapper.element as HTMLElement, 300)
|
||||
const el = screen.getByText('Very long text that overflows')
|
||||
mockScrollWidth(el, 300)
|
||||
|
||||
await nextTick()
|
||||
await wrapper.trigger('mouseenter')
|
||||
await user.hover(el)
|
||||
await nextTick()
|
||||
|
||||
vi.advanceTimersByTime(200)
|
||||
await wrapper.trigger('mouseleave')
|
||||
await user.unhover(el)
|
||||
await nextTick()
|
||||
|
||||
vi.advanceTimersByTime(350)
|
||||
@@ -83,16 +90,17 @@ describe(TextTicker, () => {
|
||||
})
|
||||
|
||||
it('resets scroll position on mouse leave', async () => {
|
||||
wrapper = mount(TextTicker, {
|
||||
const { unmount } = render(TextTicker, {
|
||||
slots: { default: 'Very long text that overflows' },
|
||||
props: { speed: 100 }
|
||||
})
|
||||
cleanup = unmount
|
||||
|
||||
const el = wrapper.element as HTMLElement
|
||||
const el = screen.getByText('Very long text that overflows')
|
||||
mockScrollWidth(el, 300)
|
||||
|
||||
await nextTick()
|
||||
await wrapper.trigger('mouseenter')
|
||||
await user.hover(el)
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(350)
|
||||
await nextTick()
|
||||
@@ -100,19 +108,22 @@ describe(TextTicker, () => {
|
||||
rafCallbacks[0](performance.now() + 500)
|
||||
expect(el.scrollLeft).toBeGreaterThan(0)
|
||||
|
||||
await wrapper.trigger('mouseleave')
|
||||
await user.unhover(el)
|
||||
await nextTick()
|
||||
|
||||
expect(el.scrollLeft).toBe(0)
|
||||
})
|
||||
|
||||
it('does not scroll when content fits', async () => {
|
||||
wrapper = mount(TextTicker, {
|
||||
const { unmount } = render(TextTicker, {
|
||||
slots: { default: 'Short' }
|
||||
})
|
||||
cleanup = unmount
|
||||
|
||||
const el = screen.getByText('Short')
|
||||
|
||||
await nextTick()
|
||||
await wrapper.trigger('mouseenter')
|
||||
await user.hover(el)
|
||||
await nextTick()
|
||||
vi.advanceTimersByTime(350)
|
||||
await nextTick()
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import MarqueeLine from './MarqueeLine.vue'
|
||||
import TextTickerMultiLine from './TextTickerMultiLine.vue'
|
||||
|
||||
type Callback = () => void
|
||||
@@ -41,23 +40,38 @@ function mockElementSize(
|
||||
}
|
||||
|
||||
describe(TextTickerMultiLine, () => {
|
||||
let wrapper: ReturnType<typeof mount>
|
||||
let unmountFn: () => void
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
unmountFn?.()
|
||||
resizeCallbacks.length = 0
|
||||
mutationCallbacks.length = 0
|
||||
})
|
||||
|
||||
function mountComponent(text: string) {
|
||||
wrapper = mount(TextTickerMultiLine, {
|
||||
function renderComponent(text: string) {
|
||||
const result = render(TextTickerMultiLine, {
|
||||
slots: { default: text }
|
||||
})
|
||||
return wrapper
|
||||
unmountFn = result.unmount
|
||||
return {
|
||||
...result,
|
||||
container: result.container as HTMLElement
|
||||
}
|
||||
}
|
||||
|
||||
function getMeasureEl(): HTMLElement {
|
||||
return wrapper.find('[aria-hidden="true"]').element as HTMLElement
|
||||
function getMeasureEl(container: HTMLElement): HTMLElement {
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
return container.querySelector('[aria-hidden="true"]') as HTMLElement
|
||||
}
|
||||
|
||||
function getVisibleLines(container: HTMLElement): HTMLElement[] {
|
||||
/* eslint-disable testing-library/no-node-access */
|
||||
return Array.from(
|
||||
container.querySelectorAll<HTMLElement>(
|
||||
'div.overflow-hidden:not([aria-hidden])'
|
||||
)
|
||||
)
|
||||
/* eslint-enable testing-library/no-node-access */
|
||||
}
|
||||
|
||||
async function triggerSplitLines() {
|
||||
@@ -66,40 +80,42 @@ describe(TextTickerMultiLine, () => {
|
||||
}
|
||||
|
||||
it('renders slot content', () => {
|
||||
mountComponent('Load Checkpoint')
|
||||
expect(wrapper.text()).toContain('Load Checkpoint')
|
||||
renderComponent('Load Checkpoint')
|
||||
expect(
|
||||
screen.getAllByText('Load Checkpoint').length
|
||||
).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('renders a single MarqueeLine when text fits', async () => {
|
||||
mountComponent('Short')
|
||||
mockElementSize(getMeasureEl(), 200, 100)
|
||||
it('renders a single line when text fits', async () => {
|
||||
const { container } = renderComponent('Short')
|
||||
mockElementSize(getMeasureEl(container), 200, 100)
|
||||
await triggerSplitLines()
|
||||
|
||||
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(1)
|
||||
expect(getVisibleLines(container)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders two MarqueeLines when text overflows', async () => {
|
||||
mountComponent('Load Checkpoint Loader Simple')
|
||||
mockElementSize(getMeasureEl(), 100, 300)
|
||||
it('renders two lines when text overflows', async () => {
|
||||
const { container } = renderComponent('Load Checkpoint Loader Simple')
|
||||
mockElementSize(getMeasureEl(container), 100, 300)
|
||||
await triggerSplitLines()
|
||||
|
||||
expect(wrapper.findAllComponents(MarqueeLine)).toHaveLength(2)
|
||||
expect(getVisibleLines(container)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('splits text at word boundary when overflowing', async () => {
|
||||
mountComponent('Load Checkpoint Loader')
|
||||
mockElementSize(getMeasureEl(), 100, 200)
|
||||
const { container } = renderComponent('Load Checkpoint Loader')
|
||||
mockElementSize(getMeasureEl(container), 100, 200)
|
||||
await triggerSplitLines()
|
||||
|
||||
const lines = wrapper.findAllComponents(MarqueeLine)
|
||||
expect(lines[0].text()).toBe('Load')
|
||||
expect(lines[1].text()).toBe('Checkpoint Loader')
|
||||
const lines = getVisibleLines(container)
|
||||
expect(lines[0].textContent).toBe('Load')
|
||||
expect(lines[1].textContent).toBe('Checkpoint Loader')
|
||||
})
|
||||
|
||||
it('has hidden measurement element with aria-hidden', () => {
|
||||
mountComponent('Test')
|
||||
const measureEl = wrapper.find('[aria-hidden="true"]')
|
||||
expect(measureEl.exists()).toBe(true)
|
||||
expect(measureEl.classes()).toContain('invisible')
|
||||
const { container } = renderComponent('Test')
|
||||
const measureEl = getMeasureEl(container)
|
||||
expect(measureEl).toBeInTheDocument()
|
||||
expect(measureEl).toHaveClass('invisible')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { FlattenedItem } from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -92,7 +93,7 @@ describe('TreeExplorerV2Node', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function mountComponent(
|
||||
function renderComponent(
|
||||
props: Record<string, unknown> = {},
|
||||
options: {
|
||||
provide?: Record<string, unknown>
|
||||
@@ -100,68 +101,76 @@ describe('TreeExplorerV2Node', () => {
|
||||
} = {}
|
||||
) {
|
||||
const treeItemStub = options.treeItemStub ?? createTreeItemStub()
|
||||
return {
|
||||
wrapper: mount(TreeExplorerV2Node, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
TreeItem: treeItemStub.stub,
|
||||
Teleport: { template: '<div />' }
|
||||
},
|
||||
provide: {
|
||||
...options.provide
|
||||
}
|
||||
const onNodeClick = vi.fn()
|
||||
const { container } = render(TreeExplorerV2Node, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
TreeItem: treeItemStub.stub,
|
||||
Teleport: { template: '<div />' }
|
||||
},
|
||||
props: {
|
||||
item: createMockItem('node'),
|
||||
...props
|
||||
provide: {
|
||||
...options.provide
|
||||
}
|
||||
}),
|
||||
treeItemStub
|
||||
}
|
||||
},
|
||||
props: {
|
||||
item: createMockItem('node'),
|
||||
onNodeClick,
|
||||
...props
|
||||
}
|
||||
})
|
||||
return { container, treeItemStub, onNodeClick }
|
||||
}
|
||||
|
||||
function getTreeNode(container: Element) {
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
return container.querySelector('div.group\\/tree-node')! as HTMLElement
|
||||
}
|
||||
|
||||
describe('handleClick', () => {
|
||||
it('emits nodeClick event when clicked', async () => {
|
||||
const { wrapper } = mountComponent({
|
||||
const user = userEvent.setup()
|
||||
const { container, onNodeClick } = renderComponent({
|
||||
item: createMockItem('node')
|
||||
})
|
||||
|
||||
const nodeDiv = wrapper.find('div.group\\/tree-node')
|
||||
await nodeDiv.trigger('click')
|
||||
const nodeDiv = getTreeNode(container)
|
||||
await user.click(nodeDiv)
|
||||
|
||||
expect(wrapper.emitted('nodeClick')).toBeTruthy()
|
||||
expect(wrapper.emitted('nodeClick')?.[0]?.[0]).toMatchObject({
|
||||
expect(onNodeClick).toHaveBeenCalled()
|
||||
expect(onNodeClick.mock.calls[0][0]).toMatchObject({
|
||||
type: 'node',
|
||||
label: 'Test Label'
|
||||
})
|
||||
})
|
||||
|
||||
it('calls handleToggle for folder items', async () => {
|
||||
const user = userEvent.setup()
|
||||
const treeItemStub = createTreeItemStub()
|
||||
const { wrapper } = mountComponent(
|
||||
const { container, onNodeClick } = renderComponent(
|
||||
{ item: createMockItem('folder') },
|
||||
{ treeItemStub }
|
||||
)
|
||||
|
||||
const folderDiv = wrapper.find('div.group\\/tree-node')
|
||||
await folderDiv.trigger('click')
|
||||
const folderDiv = getTreeNode(container)
|
||||
await user.click(folderDiv)
|
||||
|
||||
expect(wrapper.emitted('nodeClick')).toBeTruthy()
|
||||
expect(onNodeClick).toHaveBeenCalled()
|
||||
expect(treeItemStub.handleToggle).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call handleToggle for node items', async () => {
|
||||
const user = userEvent.setup()
|
||||
const treeItemStub = createTreeItemStub()
|
||||
const { wrapper } = mountComponent(
|
||||
const { container, onNodeClick } = renderComponent(
|
||||
{ item: createMockItem('node') },
|
||||
{ treeItemStub }
|
||||
)
|
||||
|
||||
const nodeDiv = wrapper.find('div.group\\/tree-node')
|
||||
await nodeDiv.trigger('click')
|
||||
const nodeDiv = getTreeNode(container)
|
||||
await user.click(nodeDiv)
|
||||
|
||||
expect(wrapper.emitted('nodeClick')).toBeTruthy()
|
||||
expect(onNodeClick).toHaveBeenCalled()
|
||||
expect(treeItemStub.handleToggle).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -171,7 +180,7 @@ describe('TreeExplorerV2Node', () => {
|
||||
const contextMenuNode = ref<RenderedTreeExplorerNode | null>(null)
|
||||
const nodeItem = createMockItem('node')
|
||||
|
||||
const { wrapper } = mountComponent(
|
||||
const { container } = renderComponent(
|
||||
{ item: nodeItem },
|
||||
{
|
||||
provide: {
|
||||
@@ -180,8 +189,8 @@ describe('TreeExplorerV2Node', () => {
|
||||
}
|
||||
)
|
||||
|
||||
const nodeDiv = wrapper.find('div.group\\/tree-node')
|
||||
await nodeDiv.trigger('contextmenu')
|
||||
const nodeDiv = getTreeNode(container)
|
||||
await fireEvent.contextMenu(nodeDiv)
|
||||
|
||||
expect(contextMenuNode.value).toEqual(nodeItem.value)
|
||||
})
|
||||
@@ -193,7 +202,7 @@ describe('TreeExplorerV2Node', () => {
|
||||
label: 'Stale'
|
||||
} as RenderedTreeExplorerNode)
|
||||
|
||||
const { wrapper } = mountComponent(
|
||||
const { container } = renderComponent(
|
||||
{ item: createMockItem('folder') },
|
||||
{
|
||||
provide: {
|
||||
@@ -202,8 +211,8 @@ describe('TreeExplorerV2Node', () => {
|
||||
}
|
||||
)
|
||||
|
||||
const folderDiv = wrapper.find('div.group\\/tree-node')
|
||||
await folderDiv.trigger('contextmenu')
|
||||
const folderDiv = getTreeNode(container)
|
||||
await fireEvent.contextMenu(folderDiv)
|
||||
|
||||
expect(contextMenuNode.value).toBeNull()
|
||||
})
|
||||
@@ -216,47 +225,53 @@ describe('TreeExplorerV2Node', () => {
|
||||
|
||||
it('shows delete button for user blueprints', () => {
|
||||
mockIsUserBlueprint.mockReturnValue(true)
|
||||
const { wrapper } = mountComponent({
|
||||
renderComponent({
|
||||
item: createMockItem('node', {
|
||||
data: { name: 'SubgraphBlueprint.test' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(true)
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides delete button for non-blueprint nodes', () => {
|
||||
mockIsUserBlueprint.mockReturnValue(false)
|
||||
const { wrapper } = mountComponent({
|
||||
renderComponent({
|
||||
item: createMockItem('node', {
|
||||
data: { name: 'KSampler' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-label="Delete"]').exists()).toBe(false)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Delete' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('always shows bookmark button', () => {
|
||||
mockIsUserBlueprint.mockReturnValue(true)
|
||||
const { wrapper } = mountComponent({
|
||||
renderComponent({
|
||||
item: createMockItem('node', {
|
||||
data: { name: 'SubgraphBlueprint.test' }
|
||||
})
|
||||
})
|
||||
|
||||
expect(wrapper.find('[aria-label="icon.bookmark"]').exists()).toBe(true)
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'icon.bookmark' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls deleteBlueprint when delete button is clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockIsUserBlueprint.mockReturnValue(true)
|
||||
const nodeName = 'SubgraphBlueprint.test'
|
||||
const { wrapper } = mountComponent({
|
||||
renderComponent({
|
||||
item: createMockItem('node', {
|
||||
data: { name: nodeName }
|
||||
})
|
||||
})
|
||||
|
||||
await wrapper.find('[aria-label="Delete"]').trigger('click')
|
||||
const deleteButton = screen.getByRole('button', { name: 'Delete' })
|
||||
await user.click(deleteButton)
|
||||
|
||||
expect(mockDeleteBlueprint).toHaveBeenCalledWith(nodeName)
|
||||
})
|
||||
@@ -264,40 +279,47 @@ describe('TreeExplorerV2Node', () => {
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders node icon for node type', () => {
|
||||
const { wrapper } = mountComponent({
|
||||
const { container } = renderComponent({
|
||||
item: createMockItem('node')
|
||||
})
|
||||
|
||||
expect(wrapper.find('i.icon-\\[comfy--node\\]').exists()).toBe(true)
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('i.icon-\\[comfy--node\\]')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders folder icon for folder type', () => {
|
||||
const { wrapper } = mountComponent({
|
||||
const { container } = renderComponent({
|
||||
item: createMockItem('folder', { icon: 'icon-[lucide--folder]' })
|
||||
})
|
||||
|
||||
expect(wrapper.find('i.icon-\\[lucide--folder\\]').exists()).toBe(true)
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
expect(
|
||||
container.querySelector('i.icon-\\[lucide--folder\\]')
|
||||
).toBeTruthy()
|
||||
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
|
||||
})
|
||||
|
||||
it('renders label text', () => {
|
||||
const { wrapper } = mountComponent({
|
||||
renderComponent({
|
||||
item: createMockItem('node', { label: 'My Node' })
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('My Node')
|
||||
expect(screen.getByText('My Node')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders chevron for folder with children', () => {
|
||||
const { wrapper } = mountComponent({
|
||||
const { container } = renderComponent({
|
||||
item: {
|
||||
...createMockItem('folder'),
|
||||
hasChildren: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.find('i.icon-\\[lucide--chevron-down\\]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
expect(
|
||||
container.querySelector('i.icon-\\[lucide--chevron-down\\]')
|
||||
).toBeTruthy()
|
||||
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
|
||||
})
|
||||
})
|
||||
|
||||
@@ -307,75 +329,75 @@ describe('TreeExplorerV2Node', () => {
|
||||
})
|
||||
|
||||
it('sets draggable attribute on node items', () => {
|
||||
const { wrapper } = mountComponent({
|
||||
const { container } = renderComponent({
|
||||
item: createMockItem('node')
|
||||
})
|
||||
|
||||
const nodeDiv = wrapper.find('div.group\\/tree-node')
|
||||
expect(nodeDiv.attributes('draggable')).toBe('true')
|
||||
const nodeDiv = getTreeNode(container)
|
||||
expect(nodeDiv.getAttribute('draggable')).toBe('true')
|
||||
})
|
||||
|
||||
it('does not set draggable on folder items', () => {
|
||||
const { wrapper } = mountComponent({
|
||||
const { container } = renderComponent({
|
||||
item: createMockItem('folder')
|
||||
})
|
||||
|
||||
const folderDiv = wrapper.find('div.group\\/tree-node')
|
||||
expect(folderDiv.attributes('draggable')).toBeUndefined()
|
||||
const folderDiv = getTreeNode(container)
|
||||
expect(folderDiv.getAttribute('draggable')).toBeNull()
|
||||
})
|
||||
|
||||
it('calls startDrag with native mode on dragstart', async () => {
|
||||
const mockData = { name: 'TestNode' }
|
||||
const { wrapper } = mountComponent({
|
||||
const { container } = renderComponent({
|
||||
item: createMockItem('node', { data: mockData })
|
||||
})
|
||||
|
||||
const nodeDiv = wrapper.find('div.group\\/tree-node')
|
||||
await nodeDiv.trigger('dragstart')
|
||||
const nodeDiv = getTreeNode(container)
|
||||
await fireEvent.dragStart(nodeDiv)
|
||||
|
||||
expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native')
|
||||
})
|
||||
|
||||
it('does not call startDrag for folder items on dragstart', async () => {
|
||||
const { wrapper } = mountComponent({
|
||||
const { container } = renderComponent({
|
||||
item: createMockItem('folder')
|
||||
})
|
||||
|
||||
const folderDiv = wrapper.find('div.group\\/tree-node')
|
||||
await folderDiv.trigger('dragstart')
|
||||
const folderDiv = getTreeNode(container)
|
||||
await fireEvent.dragStart(folderDiv)
|
||||
|
||||
expect(mockStartDrag).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls handleNativeDrop on dragend with drop coordinates', async () => {
|
||||
const mockData = { name: 'TestNode' }
|
||||
const { wrapper } = mountComponent({
|
||||
const { container } = renderComponent({
|
||||
item: createMockItem('node', { data: mockData })
|
||||
})
|
||||
|
||||
const nodeDiv = wrapper.find('div.group\\/tree-node')
|
||||
const nodeDiv = getTreeNode(container)
|
||||
|
||||
await nodeDiv.trigger('dragstart')
|
||||
await fireEvent.dragStart(nodeDiv)
|
||||
|
||||
const dragEndEvent = new DragEvent('dragend', { bubbles: true })
|
||||
Object.defineProperty(dragEndEvent, 'clientX', { value: 100 })
|
||||
Object.defineProperty(dragEndEvent, 'clientY', { value: 200 })
|
||||
|
||||
await nodeDiv.element.dispatchEvent(dragEndEvent)
|
||||
await wrapper.vm.$nextTick()
|
||||
nodeDiv.dispatchEvent(dragEndEvent)
|
||||
await nextTick()
|
||||
|
||||
expect(mockHandleNativeDrop).toHaveBeenCalledWith(100, 200)
|
||||
})
|
||||
|
||||
it('calls handleNativeDrop regardless of dropEffect', async () => {
|
||||
const mockData = { name: 'TestNode' }
|
||||
const { wrapper } = mountComponent({
|
||||
const { container } = renderComponent({
|
||||
item: createMockItem('node', { data: mockData })
|
||||
})
|
||||
|
||||
const nodeDiv = wrapper.find('div.group\\/tree-node')
|
||||
const nodeDiv = getTreeNode(container)
|
||||
|
||||
await nodeDiv.trigger('dragstart')
|
||||
await fireEvent.dragStart(nodeDiv)
|
||||
mockHandleNativeDrop.mockClear()
|
||||
|
||||
const dragEndEvent = new DragEvent('dragend', { bubbles: true })
|
||||
@@ -385,8 +407,8 @@ describe('TreeExplorerV2Node', () => {
|
||||
value: { dropEffect: 'none' }
|
||||
})
|
||||
|
||||
await nodeDiv.element.dispatchEvent(dragEndEvent)
|
||||
await wrapper.vm.$nextTick()
|
||||
nodeDiv.dispatchEvent(dragEndEvent)
|
||||
await nextTick()
|
||||
|
||||
expect(mockHandleNativeDrop).toHaveBeenCalledWith(300, 400)
|
||||
})
|
||||
|
||||
@@ -32,21 +32,14 @@
|
||||
:aria-label="$t('g.delete')"
|
||||
@click.stop="deleteBlueprint"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] text-xs" />
|
||||
<i class="icon-[lucide--trash-2]" />
|
||||
</button>
|
||||
<button
|
||||
:class="cn(ACTION_BTN_CLASS, 'text-muted-foreground')"
|
||||
:aria-label="$t('icon.bookmark')"
|
||||
@click.stop="toggleBookmark"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
cn(
|
||||
isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark',
|
||||
'text-xs'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<i :class="isBookmarked ? 'pi pi-bookmark-fill' : 'pi pi-bookmark'" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,7 +108,7 @@ const ROW_CLASS =
|
||||
'group/tree-node flex w-full min-w-0 cursor-pointer select-none items-center gap-3 overflow-hidden py-2 outline-none hover:bg-comfy-input rounded'
|
||||
|
||||
const ACTION_BTN_CLASS =
|
||||
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
|
||||
'flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-sm border-none bg-transparent text-sm opacity-0 group-hover/tree-node:opacity-100 hover:text-foreground'
|
||||
|
||||
const { item } = defineProps<{
|
||||
item: FlattenedItem<RenderedTreeExplorerNode<ComfyNodeDefImpl>>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Ref } from 'vue'
|
||||
import { nextTick, ref } from 'vue'
|
||||
@@ -46,7 +46,7 @@ describe('VirtualGrid', () => {
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
render(VirtualGrid, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
@@ -60,16 +60,14 @@ describe('VirtualGrid', () => {
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
attachTo: document.body
|
||||
container: document.body.appendChild(document.createElement('div'))
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const renderedItems = wrapper.findAll('.test-item')
|
||||
const renderedItems = screen.getAllByText(/^Item \d+$/)
|
||||
expect(renderedItems.length).toBeGreaterThan(0)
|
||||
expect(renderedItems.length).toBeLessThan(items.length)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('provides correct index in slot props', async () => {
|
||||
@@ -79,7 +77,7 @@ describe('VirtualGrid', () => {
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
render(VirtualGrid, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
@@ -94,7 +92,7 @@ describe('VirtualGrid', () => {
|
||||
return null
|
||||
}
|
||||
},
|
||||
attachTo: document.body
|
||||
container: document.body.appendChild(document.createElement('div'))
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
@@ -104,8 +102,6 @@ describe('VirtualGrid', () => {
|
||||
for (let i = 1; i < receivedIndices.length; i++) {
|
||||
expect(receivedIndices[i]).toBe(receivedIndices[i - 1] + 1)
|
||||
}
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('respects maxColumns prop', async () => {
|
||||
@@ -114,28 +110,29 @@ describe('VirtualGrid', () => {
|
||||
mockedHeight.value = 200
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
const { container } = render(VirtualGrid, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
maxColumns: 2
|
||||
},
|
||||
attachTo: document.body
|
||||
container: document.body.appendChild(document.createElement('div'))
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const gridElement = wrapper.find('[style*="display: grid"]')
|
||||
expect(gridElement.exists()).toBe(true)
|
||||
|
||||
const gridEl = gridElement.element as HTMLElement
|
||||
expect(gridEl.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))')
|
||||
|
||||
wrapper.unmount()
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const gridElement = container.querySelector(
|
||||
'[style*="display: grid"]'
|
||||
) as HTMLElement
|
||||
expect(gridElement).not.toBeNull()
|
||||
expect(gridElement.style.gridTemplateColumns).toBe(
|
||||
'repeat(2, minmax(0, 1fr))'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders empty when no items provided', async () => {
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
render(VirtualGrid, {
|
||||
props: {
|
||||
items: [],
|
||||
gridStyle: defaultGridStyle
|
||||
@@ -149,10 +146,8 @@ describe('VirtualGrid', () => {
|
||||
|
||||
await nextTick()
|
||||
|
||||
const renderedItems = wrapper.findAll('.test-item')
|
||||
const renderedItems = screen.queryAllByText(/^Item \d+$/)
|
||||
expect(renderedItems.length).toBe(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('emits approach-end for single-column list when scrolled near bottom', async () => {
|
||||
@@ -161,7 +156,9 @@ describe('VirtualGrid', () => {
|
||||
mockedHeight.value = 600
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
const onApproachEnd = vi.fn()
|
||||
|
||||
render(VirtualGrid, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: {
|
||||
@@ -171,19 +168,20 @@ describe('VirtualGrid', () => {
|
||||
defaultItemHeight: 48,
|
||||
defaultItemWidth: 200,
|
||||
maxColumns: 1,
|
||||
bufferRows: 1
|
||||
bufferRows: 1,
|
||||
onApproachEnd
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
attachTo: document.body
|
||||
container: document.body.appendChild(document.createElement('div'))
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('approach-end')).toBeUndefined()
|
||||
expect(onApproachEnd).not.toHaveBeenCalled()
|
||||
|
||||
// Scroll near the end: 50 items * 48px = 2400px total
|
||||
// viewRows = ceil(600/48) = 13, buffer = 1
|
||||
@@ -195,9 +193,7 @@ describe('VirtualGrid', () => {
|
||||
mockedScrollY.value = 1680
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.emitted('approach-end')).toBeDefined()
|
||||
|
||||
wrapper.unmount()
|
||||
expect(onApproachEnd).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not emit approach-end without maxColumns in single-column layout', async () => {
|
||||
@@ -208,7 +204,9 @@ describe('VirtualGrid', () => {
|
||||
mockedHeight.value = 600
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
const onApproachEnd = vi.fn()
|
||||
|
||||
render(VirtualGrid, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: {
|
||||
@@ -218,14 +216,15 @@ describe('VirtualGrid', () => {
|
||||
defaultItemHeight: 48,
|
||||
defaultItemWidth: 200,
|
||||
// No maxColumns — cols will be floor(400/200) = 2
|
||||
bufferRows: 1
|
||||
bufferRows: 1,
|
||||
onApproachEnd
|
||||
},
|
||||
slots: {
|
||||
item: `<template #item="{ item }">
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
attachTo: document.body
|
||||
container: document.body.appendChild(document.createElement('div'))
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
@@ -237,9 +236,7 @@ describe('VirtualGrid', () => {
|
||||
// With cols=2, toCol = (35+1+13)*2 = 98, which exceeds items.length (50)
|
||||
// remainingCol = 50-98 = -48, hasMoreToRender = false → isNearEnd = false
|
||||
// The approach-end never fires at the correct scroll position
|
||||
expect(wrapper.emitted('approach-end')).toBeUndefined()
|
||||
|
||||
wrapper.unmount()
|
||||
expect(onApproachEnd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forces cols to maxColumns when maxColumns is finite', async () => {
|
||||
@@ -248,7 +245,7 @@ describe('VirtualGrid', () => {
|
||||
mockedScrollY.value = 0
|
||||
|
||||
const items = createItems(20)
|
||||
const wrapper = mount(VirtualGrid<TestItem>, {
|
||||
render(VirtualGrid, {
|
||||
props: {
|
||||
items,
|
||||
gridStyle: defaultGridStyle,
|
||||
@@ -262,15 +259,13 @@ describe('VirtualGrid', () => {
|
||||
<div class="test-item">{{ item.name }}</div>
|
||||
</template>`
|
||||
},
|
||||
attachTo: document.body
|
||||
container: document.body.appendChild(document.createElement('div'))
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
|
||||
const renderedItems = wrapper.findAll('.test-item')
|
||||
const renderedItems = screen.getAllByText(/^Item \d+$/)
|
||||
expect(renderedItems.length).toBeGreaterThan(0)
|
||||
expect(renderedItems.length % 4).toBe(0)
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { shallowMount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import WorkflowActionsList from '@/components/common/WorkflowActionsList.vue'
|
||||
@@ -7,10 +8,23 @@ import type {
|
||||
WorkflowMenuItem
|
||||
} from '@/types/workflowMenuItem'
|
||||
|
||||
function createWrapper(items: WorkflowMenuItem[]) {
|
||||
return shallowMount(WorkflowActionsList, {
|
||||
props: { items },
|
||||
global: { renderStubDefaultSlot: true }
|
||||
const MenuItemStub = {
|
||||
template:
|
||||
'<div data-testid="menu-item" @click="$emit(\'select\')"><slot /></div>',
|
||||
emits: ['select']
|
||||
}
|
||||
|
||||
const SeparatorStub = {
|
||||
template: '<hr data-testid="menu-separator" />'
|
||||
}
|
||||
|
||||
function renderList(items: WorkflowMenuItem[]) {
|
||||
return render(WorkflowActionsList, {
|
||||
props: {
|
||||
items,
|
||||
itemComponent: MenuItemStub,
|
||||
separatorComponent: SeparatorStub
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,10 +34,9 @@ describe('WorkflowActionsList', () => {
|
||||
{ id: 'save', label: 'Save', icon: 'pi pi-save', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
renderList(items)
|
||||
|
||||
expect(wrapper.text()).toContain('Save')
|
||||
expect(wrapper.find('.pi-save').exists()).toBe(true)
|
||||
expect(screen.getByText('Save')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders separator items', () => {
|
||||
@@ -33,24 +46,23 @@ describe('WorkflowActionsList', () => {
|
||||
{ id: 'after', label: 'After', icon: 'pi pi-b', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
const html = wrapper.html()
|
||||
renderList(items)
|
||||
|
||||
expect(html).toContain('dropdown-menu-separator-stub')
|
||||
expect(wrapper.text()).toContain('Before')
|
||||
expect(wrapper.text()).toContain('After')
|
||||
screen.getByTestId('menu-separator')
|
||||
screen.getByText('Before')
|
||||
screen.getByText('After')
|
||||
})
|
||||
|
||||
it('dispatches command on select', async () => {
|
||||
const user = userEvent.setup()
|
||||
const command = vi.fn()
|
||||
const items: WorkflowMenuItem[] = [
|
||||
{ id: 'action', label: 'Action', icon: 'pi pi-play', command }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
const item = wrapper.findComponent({ name: 'DropdownMenuItem' })
|
||||
await item.vm.$emit('select')
|
||||
renderList(items)
|
||||
|
||||
await user.click(screen.getByTestId('menu-item'))
|
||||
expect(command).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
@@ -65,9 +77,9 @@ describe('WorkflowActionsList', () => {
|
||||
}
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
renderList(items)
|
||||
|
||||
expect(wrapper.text()).toContain('NEW')
|
||||
screen.getByText('NEW')
|
||||
})
|
||||
|
||||
it('does not render items with visible set to false', () => {
|
||||
@@ -82,10 +94,10 @@ describe('WorkflowActionsList', () => {
|
||||
{ id: 'shown', label: 'Shown Item', icon: 'pi pi-eye', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
renderList(items)
|
||||
|
||||
expect(wrapper.text()).not.toContain('Hidden Item')
|
||||
expect(wrapper.text()).toContain('Shown Item')
|
||||
expect(screen.queryByText('Hidden Item')).toBeNull()
|
||||
screen.getByText('Shown Item')
|
||||
})
|
||||
|
||||
it('does not render badge when absent', () => {
|
||||
@@ -93,8 +105,8 @@ describe('WorkflowActionsList', () => {
|
||||
{ id: 'plain', label: 'Plain', icon: 'pi pi-check', command: vi.fn() }
|
||||
]
|
||||
|
||||
const wrapper = createWrapper(items)
|
||||
renderList(items)
|
||||
|
||||
expect(wrapper.text()).not.toContain('NEW')
|
||||
expect(screen.queryByText('NEW')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,86 +1,91 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CurvePoint } from './types'
|
||||
|
||||
import CurveEditor from './CurveEditor.vue'
|
||||
|
||||
function mountEditor(points: CurvePoint[], extraProps = {}) {
|
||||
return mount(CurveEditor, {
|
||||
function renderEditor(points: CurvePoint[], extraProps = {}) {
|
||||
const { container } = render(CurveEditor, {
|
||||
props: { modelValue: points, ...extraProps }
|
||||
})
|
||||
return { container }
|
||||
}
|
||||
|
||||
function getCurvePath(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.find('[data-testid="curve-path"]')
|
||||
function getCurvePath() {
|
||||
return screen.getByTestId('curve-path')
|
||||
}
|
||||
|
||||
describe('CurveEditor', () => {
|
||||
it('renders SVG with curve path', () => {
|
||||
const wrapper = mountEditor([
|
||||
const { container } = renderEditor([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(wrapper.find('svg').exists()).toBe(true)
|
||||
const curvePath = getCurvePath(wrapper)
|
||||
expect(curvePath.exists()).toBe(true)
|
||||
expect(curvePath.attributes('d')).toBeTruthy()
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
|
||||
const curvePath = getCurvePath()
|
||||
expect(curvePath).toBeInTheDocument()
|
||||
expect(curvePath.getAttribute('d')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders a circle for each control point', () => {
|
||||
const wrapper = mountEditor([
|
||||
const { container } = renderEditor([
|
||||
[0, 0],
|
||||
[0.5, 0.7],
|
||||
[1, 1]
|
||||
])
|
||||
expect(wrapper.findAll('circle')).toHaveLength(3)
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access */
|
||||
expect(container.querySelectorAll('circle')).toHaveLength(3)
|
||||
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
|
||||
})
|
||||
|
||||
it('renders histogram path when provided', () => {
|
||||
const histogram = new Uint32Array(256)
|
||||
for (let i = 0; i < 256; i++) histogram[i] = i + 1
|
||||
const wrapper = mountEditor(
|
||||
renderEditor(
|
||||
[
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
],
|
||||
{ histogram }
|
||||
)
|
||||
const histogramPath = wrapper.find('[data-testid="histogram-path"]')
|
||||
expect(histogramPath.exists()).toBe(true)
|
||||
expect(histogramPath.attributes('d')).toContain('M0,1')
|
||||
const histogramPath = screen.getByTestId('histogram-path')
|
||||
expect(histogramPath).toBeInTheDocument()
|
||||
expect(histogramPath.getAttribute('d')).toContain('M0,1')
|
||||
})
|
||||
|
||||
it('does not render histogram path when not provided', () => {
|
||||
const wrapper = mountEditor([
|
||||
renderEditor([
|
||||
[0, 0],
|
||||
[1, 1]
|
||||
])
|
||||
expect(wrapper.find('[data-testid="histogram-path"]').exists()).toBe(false)
|
||||
expect(screen.queryByTestId('histogram-path')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('returns empty path with fewer than 2 points', () => {
|
||||
const wrapper = mountEditor([[0.5, 0.5]])
|
||||
expect(getCurvePath(wrapper).attributes('d')).toBe('')
|
||||
renderEditor([[0.5, 0.5]])
|
||||
expect(getCurvePath().getAttribute('d')).toBe('')
|
||||
})
|
||||
|
||||
it('generates path starting with M and containing L segments', () => {
|
||||
const wrapper = mountEditor([
|
||||
renderEditor([
|
||||
[0, 0],
|
||||
[0.5, 0.8],
|
||||
[1, 1]
|
||||
])
|
||||
const d = getCurvePath(wrapper).attributes('d')!
|
||||
const d = getCurvePath().getAttribute('d')!
|
||||
expect(d).toMatch(/^M/)
|
||||
expect(d).toContain('L')
|
||||
})
|
||||
|
||||
it('curve path only spans the x-range of control points', () => {
|
||||
const wrapper = mountEditor([
|
||||
renderEditor([
|
||||
[0.2, 0.3],
|
||||
[0.8, 0.9]
|
||||
])
|
||||
const d = getCurvePath(wrapper).attributes('d')!
|
||||
const d = getCurvePath().getAttribute('d')!
|
||||
const xValues = d
|
||||
.split(/[ML]/)
|
||||
.filter(Boolean)
|
||||
@@ -95,19 +100,22 @@ describe('CurveEditor', () => {
|
||||
[0.5, 0.5],
|
||||
[1, 1]
|
||||
]
|
||||
const wrapper = mountEditor(points)
|
||||
expect(wrapper.findAll('circle')).toHaveLength(3)
|
||||
const { container } = renderEditor(points)
|
||||
|
||||
await wrapper.findAll('circle')[1].trigger('pointerdown', {
|
||||
/* eslint-disable testing-library/no-container, testing-library/no-node-access, testing-library/prefer-user-event */
|
||||
expect(container.querySelectorAll('circle')).toHaveLength(3)
|
||||
|
||||
await fireEvent.pointerDown(container.querySelectorAll('circle')[1], {
|
||||
button: 2,
|
||||
pointerId: 1
|
||||
})
|
||||
expect(wrapper.findAll('circle')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('circle')).toHaveLength(2)
|
||||
|
||||
await wrapper.findAll('circle')[0].trigger('pointerdown', {
|
||||
await fireEvent.pointerDown(container.querySelectorAll('circle')[0], {
|
||||
button: 2,
|
||||
pointerId: 1
|
||||
})
|
||||
expect(wrapper.findAll('circle')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('circle')).toHaveLength(2)
|
||||
/* eslint-enable testing-library/no-container, testing-library/no-node-access */
|
||||
})
|
||||
})
|
||||
|
||||
@@ -175,7 +175,6 @@
|
||||
<!-- Actual Template Cards -->
|
||||
<CardContainer
|
||||
v-for="template in isLoading ? [] : displayTemplates"
|
||||
v-show="isTemplateVisibleOnDistribution(template)"
|
||||
:key="template.name"
|
||||
ref="cardRefs"
|
||||
size="tall"
|
||||
@@ -423,8 +422,6 @@ import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useTemplateWorkflows } from '@/platform/workflow/templates/composables/useTemplateWorkflows'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { useWorkflowTemplatesStore } from '@/platform/workflow/templates/repositories/workflowTemplatesStore'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import type { NavGroupData, NavItemData } from '@/types/navTypes'
|
||||
import { OnCloseKey } from '@/types/widgetTypes'
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
@@ -444,29 +441,6 @@ onMounted(() => {
|
||||
sessionStartTime.value = Date.now()
|
||||
})
|
||||
|
||||
const systemStatsStore = useSystemStatsStore()
|
||||
|
||||
const distributions = computed(() => {
|
||||
switch (__DISTRIBUTION__) {
|
||||
case 'cloud':
|
||||
return [TemplateIncludeOnDistributionEnum.Cloud]
|
||||
case 'localhost':
|
||||
return [TemplateIncludeOnDistributionEnum.Local]
|
||||
case 'desktop':
|
||||
default:
|
||||
if (systemStatsStore.systemStats?.system.os === 'darwin') {
|
||||
return [
|
||||
TemplateIncludeOnDistributionEnum.Desktop,
|
||||
TemplateIncludeOnDistributionEnum.Mac
|
||||
]
|
||||
}
|
||||
return [
|
||||
TemplateIncludeOnDistributionEnum.Desktop,
|
||||
TemplateIncludeOnDistributionEnum.Windows
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Wrap onClose to track session end
|
||||
const onClose = () => {
|
||||
if (isCloud) {
|
||||
@@ -586,7 +560,7 @@ const {
|
||||
totalCount,
|
||||
resetFilters,
|
||||
loadFuseOptions
|
||||
} = useTemplateFiltering(navigationFilteredTemplates, selectedNavItem)
|
||||
} = useTemplateFiltering(navigationFilteredTemplates)
|
||||
|
||||
/**
|
||||
* Coordinates state between the selected navigation item and the sort order to
|
||||
@@ -852,14 +826,6 @@ const { isLoading } = useAsyncState(
|
||||
}
|
||||
)
|
||||
|
||||
const isTemplateVisibleOnDistribution = (template: TemplateInfo) => {
|
||||
return (template.includeOnDistributions?.length ?? 0) > 0
|
||||
? distributions.value.some((d) =>
|
||||
template.includeOnDistributions?.includes(d)
|
||||
)
|
||||
: true
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cardRefs.value = [] // Release DOM refs
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import CreditTopUpOption from '@/components/dialog/content/credit/CreditTopUpOption.vue'
|
||||
|
||||
@@ -10,10 +11,16 @@ const i18n = createI18n({
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
const mountOption = (
|
||||
props?: Partial<{ credits: number; description: string; selected: boolean }>
|
||||
) =>
|
||||
mount(CreditTopUpOption, {
|
||||
function renderOption(
|
||||
props?: Partial<{
|
||||
credits: number
|
||||
description: string
|
||||
selected: boolean
|
||||
onSelect: () => void
|
||||
}>
|
||||
) {
|
||||
const user = userEvent.setup()
|
||||
const result = render(CreditTopUpOption, {
|
||||
props: {
|
||||
credits: 1000,
|
||||
description: '~100 videos*',
|
||||
@@ -24,25 +31,30 @@ const mountOption = (
|
||||
plugins: [i18n]
|
||||
}
|
||||
})
|
||||
return { user, ...result }
|
||||
}
|
||||
|
||||
describe('CreditTopUpOption', () => {
|
||||
it('renders credit amount and description', () => {
|
||||
const wrapper = mountOption({ credits: 5000, description: '~500 videos*' })
|
||||
expect(wrapper.text()).toContain('5,000')
|
||||
expect(wrapper.text()).toContain('~500 videos*')
|
||||
renderOption({ credits: 5000, description: '~500 videos*' })
|
||||
expect(screen.getByText('5,000')).toBeInTheDocument()
|
||||
expect(screen.getByText('~500 videos*')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies unselected styling when not selected', () => {
|
||||
const wrapper = mountOption({ selected: false })
|
||||
expect(wrapper.find('div').classes()).toContain(
|
||||
'bg-component-node-disabled'
|
||||
const { container } = renderOption({ selected: false })
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const rootDiv = container.firstElementChild as HTMLElement
|
||||
expect(rootDiv).toHaveClass(
|
||||
'bg-component-node-disabled',
|
||||
'border-transparent'
|
||||
)
|
||||
expect(wrapper.find('div').classes()).toContain('border-transparent')
|
||||
})
|
||||
|
||||
it('emits select event when clicked', async () => {
|
||||
const wrapper = mountOption()
|
||||
await wrapper.find('div').trigger('click')
|
||||
expect(wrapper.emitted('select')).toHaveLength(1)
|
||||
const selectSpy = vi.fn()
|
||||
const { user } = renderOption({ onSelect: selectSpy })
|
||||
await user.click(screen.getByText('1,000'))
|
||||
expect(selectSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render } from '@testing-library/vue'
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { createPinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tag from 'primevue/tag'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { defineComponent, h } from 'vue'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SettingItem from '@/platform/settings/components/SettingItem.vue'
|
||||
import type { SettingParams } from '@/platform/settings/types'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
@@ -17,60 +20,72 @@ vi.mock('@/utils/formatUtil', () => ({
|
||||
normalizeI18nKey: vi.fn()
|
||||
}))
|
||||
|
||||
const FormItemStub = defineComponent({
|
||||
name: 'FormItem',
|
||||
props: {
|
||||
item: { type: Object, default: () => ({}) },
|
||||
id: { type: String, default: undefined },
|
||||
formValue: { type: null, default: undefined }
|
||||
},
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('div', { 'data-testid': 'form-item-data' }, JSON.stringify(props.item))
|
||||
}
|
||||
})
|
||||
|
||||
describe('SettingItem', () => {
|
||||
const mountComponent = (props: Record<string, unknown>, options = {}) => {
|
||||
return mount(SettingItem, {
|
||||
function renderComponent(setting: SettingParams) {
|
||||
return render(SettingItem, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n, createPinia()],
|
||||
components: {
|
||||
Tag
|
||||
},
|
||||
directives: {
|
||||
tooltip: Tooltip
|
||||
},
|
||||
components: { Tag },
|
||||
stubs: {
|
||||
FormItem: FormItemStub,
|
||||
'i-material-symbols:experiment-outline': true
|
||||
}
|
||||
},
|
||||
directives: { tooltip: Tooltip }
|
||||
},
|
||||
// @ts-expect-error - Test utility accepts flexible props for testing edge cases
|
||||
props,
|
||||
...options
|
||||
props: { setting }
|
||||
})
|
||||
}
|
||||
|
||||
function getFormItemData(container: Element) {
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const el = container.querySelector('[data-testid="form-item-data"]')
|
||||
return JSON.parse(el!.textContent!)
|
||||
}
|
||||
|
||||
it('translates options that use legacy type', () => {
|
||||
const wrapper = mountComponent({
|
||||
setting: {
|
||||
const { container } = renderComponent(
|
||||
fromAny({
|
||||
id: 'Comfy.NodeInputConversionSubmenus',
|
||||
name: 'Node Input Conversion Submenus',
|
||||
type: 'combo',
|
||||
value: 'Top',
|
||||
defaultValue: 'Top',
|
||||
options: () => ['Correctly Translated']
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// Check the FormItem component's item prop for the options
|
||||
const formItem = wrapper.findComponent({ name: 'FormItem' })
|
||||
const options = formItem.props('item').options
|
||||
expect(options).toEqual([
|
||||
const data = getFormItemData(container)
|
||||
expect(data.options).toEqual([
|
||||
{ text: 'Correctly Translated', value: 'Correctly Translated' }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles tooltips with @ symbols without errors', () => {
|
||||
const wrapper = mountComponent({
|
||||
setting: {
|
||||
id: 'TestSetting',
|
||||
const { container } = renderComponent(
|
||||
fromAny({
|
||||
id: 'Comfy.NodeInputConversionSubmenus',
|
||||
name: 'Test Setting',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
tooltip:
|
||||
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
|
||||
}
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// Should not throw an error and tooltip should be preserved as-is
|
||||
const formItem = wrapper.findComponent({ name: 'FormItem' })
|
||||
expect(formItem.props('item').tooltip).toBe(
|
||||
const data = getFormItemData(container)
|
||||
expect(data.tooltip).toBe(
|
||||
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,40 +1,17 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import Badge from 'primevue/badge'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import Column from 'primevue/column'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Message from 'primevue/message'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { defineComponent, onMounted, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
|
||||
import type { AuditLog } from '@/services/customerEventsService'
|
||||
import { EventType } from '@/services/customerEventsService'
|
||||
|
||||
import UsageLogsTable from './UsageLogsTable.vue'
|
||||
|
||||
type ComponentInstance = InstanceType<typeof UsageLogsTable> & {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
events: Partial<AuditLog>[]
|
||||
pagination: {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
dataTableFirst: number
|
||||
tooltipContentMap: Map<string, string>
|
||||
loadEvents: () => Promise<void>
|
||||
refresh: () => Promise<void>
|
||||
onPageChange: (event: { page: number }) => void
|
||||
}
|
||||
|
||||
// Mock the customerEventsService
|
||||
const mockCustomerEventsService = vi.hoisted(() => ({
|
||||
getMyEvents: vi.fn(),
|
||||
formatEventType: vi.fn(),
|
||||
@@ -43,7 +20,7 @@ const mockCustomerEventsService = vi.hoisted(() => ({
|
||||
formatDate: vi.fn(),
|
||||
hasAdditionalInfo: vi.fn(),
|
||||
getTooltipContent: vi.fn(),
|
||||
error: { value: null },
|
||||
error: { value: null as string | null },
|
||||
isLoading: { value: false }
|
||||
}))
|
||||
|
||||
@@ -57,7 +34,10 @@ vi.mock('@/services/customerEventsService', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
// Create i18n instance
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => null
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
@@ -76,78 +56,115 @@ const i18n = createI18n({
|
||||
}
|
||||
})
|
||||
|
||||
describe('UsageLogsTable', () => {
|
||||
const mockEventsResponse = {
|
||||
events: [
|
||||
{
|
||||
event_id: 'event-1',
|
||||
event_type: 'credit_added',
|
||||
params: {
|
||||
amount: 1000,
|
||||
transaction_id: 'txn-123'
|
||||
},
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
},
|
||||
{
|
||||
event_id: 'event-2',
|
||||
event_type: 'api_usage_completed',
|
||||
params: {
|
||||
api_name: 'Image Generation',
|
||||
model: 'sdxl-base',
|
||||
duration: 5000
|
||||
},
|
||||
createdAt: '2024-01-02T10:00:00Z'
|
||||
}
|
||||
],
|
||||
total: 2,
|
||||
const globalConfig = {
|
||||
plugins: [PrimeVue, i18n, createTestingPinia()],
|
||||
directives: { tooltip: Tooltip }
|
||||
}
|
||||
|
||||
/**
|
||||
* The component starts with loading=true and only loads data when refresh()
|
||||
* is called via template ref. This wrapper auto-calls refresh on mount.
|
||||
*/
|
||||
const AutoRefreshWrapper = defineComponent({
|
||||
components: { UsageLogsTable },
|
||||
setup() {
|
||||
const tableRef = ref<InstanceType<typeof UsageLogsTable> | null>(null)
|
||||
onMounted(async () => {
|
||||
await tableRef.value?.refresh()
|
||||
})
|
||||
return { tableRef }
|
||||
},
|
||||
template: '<UsageLogsTable ref="tableRef" />'
|
||||
})
|
||||
|
||||
function makeEventsResponse(
|
||||
events: Partial<AuditLog>[],
|
||||
overrides: Record<string, unknown> = {}
|
||||
) {
|
||||
return {
|
||||
events,
|
||||
total: events.length,
|
||||
page: 1,
|
||||
limit: 7,
|
||||
totalPages: 1
|
||||
totalPages: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('UsageLogsTable', () => {
|
||||
const mockEventsResponse = makeEventsResponse([
|
||||
{
|
||||
event_id: 'event-1',
|
||||
event_type: 'credit_added',
|
||||
params: {
|
||||
amount: 1000,
|
||||
transaction_id: 'txn-123'
|
||||
},
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
},
|
||||
{
|
||||
event_id: 'event-2',
|
||||
event_type: 'api_usage_completed',
|
||||
params: {
|
||||
api_name: 'Image Generation',
|
||||
model: 'sdxl-base',
|
||||
duration: 5000
|
||||
},
|
||||
createdAt: '2024-01-02T10:00:00Z'
|
||||
}
|
||||
])
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Setup default service mock implementations
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(mockEventsResponse)
|
||||
mockCustomerEventsService.formatEventType.mockImplementation((type) => {
|
||||
switch (type) {
|
||||
case EventType.CREDIT_ADDED:
|
||||
return 'Credits Added'
|
||||
case EventType.ACCOUNT_CREATED:
|
||||
return 'Account Created'
|
||||
case EventType.API_USAGE_COMPLETED:
|
||||
return 'API Usage'
|
||||
default:
|
||||
return type
|
||||
mockCustomerEventsService.formatEventType.mockImplementation(
|
||||
(type: string) => {
|
||||
switch (type) {
|
||||
case EventType.CREDIT_ADDED:
|
||||
return 'Credits Added'
|
||||
case EventType.ACCOUNT_CREATED:
|
||||
return 'Account Created'
|
||||
case EventType.API_USAGE_COMPLETED:
|
||||
return 'API Usage'
|
||||
default:
|
||||
return type
|
||||
}
|
||||
}
|
||||
})
|
||||
mockCustomerEventsService.getEventSeverity.mockImplementation((type) => {
|
||||
switch (type) {
|
||||
case EventType.CREDIT_ADDED:
|
||||
return 'success'
|
||||
case EventType.ACCOUNT_CREATED:
|
||||
return 'info'
|
||||
case EventType.API_USAGE_COMPLETED:
|
||||
return 'warning'
|
||||
default:
|
||||
return 'info'
|
||||
)
|
||||
mockCustomerEventsService.getEventSeverity.mockImplementation(
|
||||
(type: string) => {
|
||||
switch (type) {
|
||||
case EventType.CREDIT_ADDED:
|
||||
return 'success'
|
||||
case EventType.ACCOUNT_CREATED:
|
||||
return 'info'
|
||||
case EventType.API_USAGE_COMPLETED:
|
||||
return 'warning'
|
||||
default:
|
||||
return 'info'
|
||||
}
|
||||
}
|
||||
})
|
||||
mockCustomerEventsService.formatAmount.mockImplementation((amount) => {
|
||||
if (!amount) return '0.00'
|
||||
return (amount / 100).toFixed(2)
|
||||
})
|
||||
mockCustomerEventsService.formatDate.mockImplementation((dateString) => {
|
||||
return new Date(dateString).toLocaleDateString()
|
||||
})
|
||||
mockCustomerEventsService.hasAdditionalInfo.mockImplementation((event) => {
|
||||
const { amount, api_name, model, ...otherParams } = event.params || {}
|
||||
return Object.keys(otherParams).length > 0
|
||||
})
|
||||
mockCustomerEventsService.getTooltipContent.mockImplementation(() => {
|
||||
return '<strong>Transaction Id:</strong> txn-123'
|
||||
})
|
||||
)
|
||||
mockCustomerEventsService.formatAmount.mockImplementation(
|
||||
(amount: number) => {
|
||||
if (!amount) return '0.00'
|
||||
return (amount / 100).toFixed(2)
|
||||
}
|
||||
)
|
||||
mockCustomerEventsService.formatDate.mockImplementation(
|
||||
(dateString: string) => new Date(dateString).toLocaleDateString()
|
||||
)
|
||||
mockCustomerEventsService.hasAdditionalInfo.mockImplementation(
|
||||
(event: AuditLog) => {
|
||||
const { amount, api_name, model, ...otherParams } =
|
||||
(event.params as Record<string, unknown>) ?? {}
|
||||
return Object.keys(otherParams).length > 0
|
||||
}
|
||||
)
|
||||
mockCustomerEventsService.getTooltipContent.mockImplementation(
|
||||
() => '<strong>Transaction Id:</strong> txn-123'
|
||||
)
|
||||
mockCustomerEventsService.error.value = null
|
||||
mockCustomerEventsService.isLoading.value = false
|
||||
})
|
||||
@@ -156,200 +173,146 @@ describe('UsageLogsTable', () => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const mountComponent = (options = {}) => {
|
||||
return mount(UsageLogsTable, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n, createTestingPinia()],
|
||||
components: {
|
||||
DataTable,
|
||||
Column,
|
||||
Badge,
|
||||
Button,
|
||||
Message,
|
||||
ProgressSpinner
|
||||
},
|
||||
directives: {
|
||||
tooltip: Tooltip
|
||||
}
|
||||
},
|
||||
...options
|
||||
function renderComponent() {
|
||||
return render(UsageLogsTable, { global: globalConfig })
|
||||
}
|
||||
|
||||
function renderWithAutoRefresh() {
|
||||
return render(AutoRefreshWrapper, { global: globalConfig })
|
||||
}
|
||||
|
||||
async function renderLoaded() {
|
||||
const result = renderWithAutoRefresh()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('table')).toBeInTheDocument()
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
describe('loading states', () => {
|
||||
it('shows loading spinner when loading is true', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = true
|
||||
await nextTick()
|
||||
it('shows loading spinner before refresh is called', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(DataTable).exists()).toBe(false)
|
||||
expect(screen.getByRole('progressbar')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('table')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows error message when error exists', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.error = 'Failed to load events'
|
||||
vm.loading = false
|
||||
await nextTick()
|
||||
it('shows error message when service returns null', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(null)
|
||||
mockCustomerEventsService.error.value = 'Failed to load events'
|
||||
|
||||
const messageComponent = wrapper.findComponent(Message)
|
||||
expect(messageComponent.exists()).toBe(true)
|
||||
expect(messageComponent.props('severity')).toBe('error')
|
||||
expect(messageComponent.text()).toContain('Failed to load events')
|
||||
renderWithAutoRefresh()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load events')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows data table when loaded successfully', async () => {
|
||||
const wrapper = mountComponent()
|
||||
it('shows error message when service throws', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockRejectedValue(
|
||||
new Error('Network error')
|
||||
)
|
||||
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
// Wait for component to mount and load data
|
||||
await wrapper.vm.$nextTick()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
renderWithAutoRefresh()
|
||||
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Network error')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
expect(wrapper.findComponent(DataTable).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
|
||||
expect(wrapper.findComponent(Message).exists()).toBe(false)
|
||||
it('shows data table after loading completes', async () => {
|
||||
await renderLoaded()
|
||||
|
||||
expect(
|
||||
screen.queryByText('Failed to load events')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('data rendering', () => {
|
||||
it('renders events data correctly', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
it('renders event type badges', async () => {
|
||||
await renderLoaded()
|
||||
|
||||
const dataTable = wrapper.findComponent(DataTable)
|
||||
expect(dataTable.props('value')).toEqual(mockEventsResponse.events)
|
||||
expect(dataTable.props('rows')).toBe(7)
|
||||
expect(dataTable.props('paginator')).toBe(true)
|
||||
expect(dataTable.props('lazy')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders badge for event types correctly', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
const badges = wrapper.findAllComponents(Badge)
|
||||
expect(badges.length).toBeGreaterThan(0)
|
||||
|
||||
// Check if formatEventType and getEventSeverity are called
|
||||
expect(mockCustomerEventsService.formatEventType).toHaveBeenCalled()
|
||||
expect(mockCustomerEventsService.getEventSeverity).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders different event details based on event type', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
it('renders credit added details with formatted amount', async () => {
|
||||
await renderLoaded()
|
||||
|
||||
// Check if formatAmount is called for credit_added events
|
||||
expect(screen.getByText(/Added \$/)).toBeInTheDocument()
|
||||
expect(mockCustomerEventsService.formatAmount).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders tooltip buttons for events with additional info', async () => {
|
||||
it('renders API usage details with api name and model', async () => {
|
||||
await renderLoaded()
|
||||
|
||||
expect(screen.getByText('Image Generation')).toBeInTheDocument()
|
||||
expect(screen.getByText(/sdxl-base/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders account created details', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'event-3',
|
||||
event_type: 'account_created',
|
||||
params: {},
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
renderWithAutoRefresh()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Account initialized')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders formatted dates', async () => {
|
||||
await renderLoaded()
|
||||
|
||||
expect(mockCustomerEventsService.formatDate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders info buttons for events with additional info', async () => {
|
||||
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(true)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
await renderLoaded()
|
||||
|
||||
expect(mockCustomerEventsService.hasAdditionalInfo).toHaveBeenCalled()
|
||||
const infoButtons = screen.getAllByRole('button', {
|
||||
name: 'Additional Info'
|
||||
})
|
||||
expect(infoButtons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('does not render info buttons when no additional info', async () => {
|
||||
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(false)
|
||||
|
||||
await renderLoaded()
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Additional Info' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pagination', () => {
|
||||
it('handles page change correctly', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
it('calls getMyEvents with initial page params', async () => {
|
||||
await renderLoaded()
|
||||
|
||||
// Simulate page change
|
||||
const dataTable = wrapper.findComponent(DataTable)
|
||||
await dataTable.vm.$emit('page', { page: 1 })
|
||||
|
||||
expect(vm.pagination.page).toBe(1) // page + 1
|
||||
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
|
||||
page: 2,
|
||||
page: 1,
|
||||
limit: 7
|
||||
})
|
||||
})
|
||||
|
||||
it('calculates dataTableFirst correctly', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
vm.pagination = { page: 2, limit: 7, total: 20, totalPages: 3 }
|
||||
await nextTick()
|
||||
|
||||
expect(vm.dataTableFirst).toBe(7) // (2-1) * 7
|
||||
})
|
||||
})
|
||||
|
||||
describe('tooltip functionality', () => {
|
||||
it('generates tooltip content map correctly', async () => {
|
||||
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(true)
|
||||
mockCustomerEventsService.getTooltipContent.mockReturnValue(
|
||||
'<strong>Test:</strong> value'
|
||||
)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
const tooltipMap = vm.tooltipContentMap
|
||||
expect(tooltipMap.get('event-1')).toBe('<strong>Test:</strong> value')
|
||||
})
|
||||
|
||||
it('excludes events without additional info from tooltip map', async () => {
|
||||
mockCustomerEventsService.hasAdditionalInfo.mockReturnValue(false)
|
||||
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
|
||||
vm.loading = false
|
||||
vm.events = mockEventsResponse.events
|
||||
await nextTick()
|
||||
|
||||
const tooltipMap = vm.tooltipContentMap
|
||||
expect(tooltipMap.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('component methods', () => {
|
||||
it('exposes refresh method', () => {
|
||||
const wrapper = mountComponent()
|
||||
it('calls getMyEvents on refresh with page 1', async () => {
|
||||
await renderLoaded()
|
||||
|
||||
expect(typeof wrapper.vm.refresh).toBe('function')
|
||||
})
|
||||
|
||||
it('resets to first page on refresh', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
|
||||
vm.pagination.page = 3
|
||||
|
||||
await vm.refresh()
|
||||
|
||||
expect(vm.pagination.page).toBe(1)
|
||||
expect(mockCustomerEventsService.getMyEvents).toHaveBeenCalledWith({
|
||||
page: 1,
|
||||
limit: 7
|
||||
@@ -357,44 +320,41 @@ describe('UsageLogsTable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('component lifecycle', () => {
|
||||
it('initializes with correct default values', () => {
|
||||
const wrapper = mountComponent()
|
||||
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
|
||||
expect(vm.events).toEqual([])
|
||||
expect(vm.loading).toBe(true)
|
||||
expect(vm.error).toBeNull()
|
||||
expect(vm.pagination).toEqual({
|
||||
page: 1,
|
||||
limit: 7,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventType integration', () => {
|
||||
it('uses EventType enum in template conditions', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const vm = wrapper.vm as ComponentInstance
|
||||
it('renders credit_added event with correct detail template', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'event-1',
|
||||
event_type: EventType.CREDIT_ADDED,
|
||||
params: { amount: 1000 },
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
vm.loading = false
|
||||
vm.events = [
|
||||
{
|
||||
event_id: 'event-1',
|
||||
event_type: EventType.CREDIT_ADDED,
|
||||
params: { amount: 1000 },
|
||||
createdAt: '2024-01-01T10:00:00Z'
|
||||
}
|
||||
]
|
||||
await nextTick()
|
||||
await renderLoaded()
|
||||
|
||||
// Verify that the component can access EventType enum
|
||||
expect(EventType.CREDIT_ADDED).toBe('credit_added')
|
||||
expect(EventType.ACCOUNT_CREATED).toBe('account_created')
|
||||
expect(EventType.API_USAGE_COMPLETED).toBe('api_usage_completed')
|
||||
expect(screen.getByText(/Added \$/)).toBeInTheDocument()
|
||||
expect(mockCustomerEventsService.formatAmount).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders api_usage_completed event with correct detail template', async () => {
|
||||
mockCustomerEventsService.getMyEvents.mockResolvedValue(
|
||||
makeEventsResponse([
|
||||
{
|
||||
event_id: 'event-2',
|
||||
event_type: EventType.API_USAGE_COMPLETED,
|
||||
params: { api_name: 'Test API', model: 'test-model' },
|
||||
createdAt: '2024-01-02T10:00:00Z'
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await renderLoaded()
|
||||
|
||||
expect(screen.getByText('Test API')).toBeInTheDocument()
|
||||
expect(screen.getByText(/test-model/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
|
||||
import { Form } from '@primevue/forms'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
@@ -16,11 +14,13 @@ import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
|
||||
import ApiKeyForm from './ApiKeyForm.vue'
|
||||
|
||||
const mockStoreApiKey = vi.fn()
|
||||
const mockLoading = vi.fn(() => false)
|
||||
const mockLoadingRef = ref(false)
|
||||
|
||||
vi.mock('@/stores/authStore', () => ({
|
||||
useAuthStore: vi.fn(() => ({
|
||||
loading: mockLoading()
|
||||
get loading() {
|
||||
return mockLoadingRef.value
|
||||
}
|
||||
}))
|
||||
}))
|
||||
|
||||
@@ -58,62 +58,57 @@ const i18n = createI18n({
|
||||
|
||||
describe('ApiKeyForm', () => {
|
||||
beforeEach(() => {
|
||||
const app = createApp({})
|
||||
app.use(PrimeVue)
|
||||
vi.clearAllMocks()
|
||||
mockStoreApiKey.mockReset()
|
||||
mockLoading.mockReset()
|
||||
mockLoadingRef.value = false
|
||||
})
|
||||
|
||||
const mountComponent = (props: ComponentProps<typeof ApiKeyForm> = {}) => {
|
||||
return mount(ApiKeyForm, {
|
||||
function renderComponent(props: Record<string, unknown> = {}) {
|
||||
const user = userEvent.setup()
|
||||
const result = render(ApiKeyForm, {
|
||||
global: {
|
||||
plugins: [PrimeVue, createPinia(), i18n],
|
||||
plugins: [PrimeVue, i18n],
|
||||
components: { Button, Form, InputText, Message }
|
||||
},
|
||||
props
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
it('renders correctly with all required elements', () => {
|
||||
const wrapper = mountComponent()
|
||||
renderComponent()
|
||||
|
||||
expect(wrapper.find('h1').text()).toBe('API Key')
|
||||
expect(wrapper.find('label').text()).toBe('API Key')
|
||||
expect(wrapper.findComponent(InputText).exists()).toBe(true)
|
||||
expect(wrapper.findComponent(Button).exists()).toBe(true)
|
||||
expect(screen.getByRole('heading', { name: 'API Key' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('API Key')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits back event when back button is clicked', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const onBack = vi.fn()
|
||||
const { user } = renderComponent({ onBack })
|
||||
|
||||
await wrapper.findComponent(Button).trigger('click')
|
||||
expect(wrapper.emitted('back')).toBeTruthy()
|
||||
await user.click(screen.getByRole('button', { name: 'Back' }))
|
||||
|
||||
expect(onBack).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows loading state when submitting', async () => {
|
||||
mockLoading.mockReturnValue(true)
|
||||
const wrapper = mountComponent()
|
||||
const input = wrapper.findComponent(InputText)
|
||||
it('shows loading state when submitting', () => {
|
||||
mockLoadingRef.value = true
|
||||
const { container } = renderComponent()
|
||||
|
||||
await input.setValue(
|
||||
'comfyui-123456789012345678901234567890123456789012345678901234567890123456789012'
|
||||
)
|
||||
await wrapper.find('form').trigger('submit')
|
||||
|
||||
const buttons = wrapper.findAllComponents(Button)
|
||||
const submitButton = buttons.find(
|
||||
(btn) => btn.attributes('type') === 'submit'
|
||||
)
|
||||
expect(submitButton?.props('loading')).toBe(true)
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const submitButton = container.querySelector('button[type="submit"]')
|
||||
expect(submitButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('displays help text and links correctly', () => {
|
||||
const wrapper = mountComponent()
|
||||
renderComponent()
|
||||
|
||||
const helpText = wrapper.find('small')
|
||||
expect(helpText.text()).toContain('Need an API key?')
|
||||
expect(helpText.find('a').attributes('href')).toBe(
|
||||
expect(
|
||||
screen.getByText('Need an API key?', { exact: false })
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Get one here' })).toHaveAttribute(
|
||||
'href',
|
||||
`${getComfyPlatformBaseUrl()}/login`
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import GradientSlider from './GradientSlider.vue'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
|
||||
import type { ColorStop } from '@/lib/litegraph/src/interfaces'
|
||||
|
||||
import GradientSlider from './GradientSlider.vue'
|
||||
import { interpolateStops, stopsToGradient } from './gradients'
|
||||
|
||||
const TEST_STOPS: ColorStop[] = [
|
||||
@@ -10,40 +12,44 @@ const TEST_STOPS: ColorStop[] = [
|
||||
{ offset: 1, color: [255, 255, 255] }
|
||||
]
|
||||
|
||||
function mountSlider(props: {
|
||||
function renderSlider(props: {
|
||||
stops?: ColorStop[]
|
||||
modelValue: number
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
}) {
|
||||
return mount(GradientSlider, {
|
||||
return render(GradientSlider, {
|
||||
props: { stops: TEST_STOPS, ...props }
|
||||
})
|
||||
}
|
||||
|
||||
describe('GradientSlider', () => {
|
||||
it('passes min, max, step to SliderRoot', () => {
|
||||
const wrapper = mountSlider({
|
||||
it('passes min and max to SliderRoot', () => {
|
||||
renderSlider({
|
||||
modelValue: 50,
|
||||
min: -100,
|
||||
max: 100,
|
||||
step: 5
|
||||
})
|
||||
const thumb = wrapper.find('[role="slider"]')
|
||||
expect(thumb.attributes('aria-valuemin')).toBe('-100')
|
||||
expect(thumb.attributes('aria-valuemax')).toBe('100')
|
||||
const thumb = screen.getByRole('slider', { hidden: true })
|
||||
expect(thumb).toBeInTheDocument()
|
||||
expect(thumb).toHaveAttribute('aria-valuemin', '-100')
|
||||
expect(thumb).toHaveAttribute('aria-valuemax', '100')
|
||||
})
|
||||
|
||||
it('renders slider root with track and thumb', () => {
|
||||
const wrapper = mountSlider({ modelValue: 0 })
|
||||
expect(wrapper.find('[data-slider-impl]').exists()).toBe(true)
|
||||
expect(wrapper.find('[role="slider"]').exists()).toBe(true)
|
||||
const { container } = renderSlider({ modelValue: 0 })
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('[data-slider-impl]')).toBeInTheDocument()
|
||||
expect(screen.getByRole('slider', { hidden: true })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render SliderRange', () => {
|
||||
const wrapper = mountSlider({ modelValue: 50 })
|
||||
expect(wrapper.find('[data-slot="slider-range"]').exists()).toBe(false)
|
||||
const { container } = renderSlider({ modelValue: 50 })
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const range = container.querySelector('[data-slot="slider-range"]')
|
||||
expect(range).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CanvasModeSelector from '@/components/graph/CanvasModeSelector.vue'
|
||||
@@ -44,8 +45,9 @@ const i18n = createI18n({
|
||||
|
||||
const mockPopoverHide = vi.fn()
|
||||
|
||||
function createWrapper() {
|
||||
return mount(CanvasModeSelector, {
|
||||
function renderComponent() {
|
||||
const user = userEvent.setup()
|
||||
render(CanvasModeSelector, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
stubs: {
|
||||
@@ -59,94 +61,98 @@ function createWrapper() {
|
||||
}
|
||||
}
|
||||
})
|
||||
return { user }
|
||||
}
|
||||
|
||||
describe('CanvasModeSelector', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render menu with menuitemradio roles and aria-checked', () => {
|
||||
const wrapper = createWrapper()
|
||||
renderComponent()
|
||||
|
||||
const menu = wrapper.find('[role="menu"]')
|
||||
expect(menu.exists()).toBe(true)
|
||||
expect(screen.getByRole('menu')).toBeInTheDocument()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
const menuItems = screen.getAllByRole('menuitemradio')
|
||||
expect(menuItems).toHaveLength(2)
|
||||
|
||||
// Select mode is active (read_only: false), so select is checked
|
||||
expect(menuItems[0].attributes('aria-checked')).toBe('true')
|
||||
expect(menuItems[1].attributes('aria-checked')).toBe('false')
|
||||
expect(menuItems[0]).toHaveAttribute('aria-checked', 'true')
|
||||
expect(menuItems[1]).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('should render menu items as buttons with aria-labels', () => {
|
||||
const wrapper = createWrapper()
|
||||
renderComponent()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
menuItems.forEach((btn) => {
|
||||
expect(btn.element.tagName).toBe('BUTTON')
|
||||
expect(btn.attributes('type')).toBe('button')
|
||||
const menuItems = screen.getAllByRole('menuitemradio')
|
||||
menuItems.forEach((item) => {
|
||||
expect(item.tagName).toBe('BUTTON')
|
||||
expect(item).toHaveAttribute('type', 'button')
|
||||
})
|
||||
expect(menuItems[0].attributes('aria-label')).toBe('Select')
|
||||
expect(menuItems[1].attributes('aria-label')).toBe('Hand')
|
||||
expect(menuItems[0]).toHaveAttribute('aria-label', 'Select')
|
||||
expect(menuItems[1]).toHaveAttribute('aria-label', 'Hand')
|
||||
})
|
||||
|
||||
it('should use roving tabindex based on active mode', () => {
|
||||
const wrapper = createWrapper()
|
||||
renderComponent()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
// Select is active (read_only: false) → tabindex 0
|
||||
expect(menuItems[0].attributes('tabindex')).toBe('0')
|
||||
// Hand is inactive → tabindex -1
|
||||
expect(menuItems[1].attributes('tabindex')).toBe('-1')
|
||||
const menuItems = screen.getAllByRole('menuitemradio')
|
||||
expect(menuItems[0]).toHaveAttribute('tabindex', '0')
|
||||
expect(menuItems[1]).toHaveAttribute('tabindex', '-1')
|
||||
})
|
||||
|
||||
it('should mark icons as aria-hidden', () => {
|
||||
const wrapper = createWrapper()
|
||||
renderComponent()
|
||||
|
||||
const icons = wrapper.findAll('[role="menuitemradio"] i')
|
||||
icons.forEach((icon) => {
|
||||
expect(icon.attributes('aria-hidden')).toBe('true')
|
||||
const menuItems = screen.getAllByRole('menuitemradio')
|
||||
menuItems.forEach((item) => {
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const icons = item.querySelectorAll('i')
|
||||
icons.forEach((icon) => {
|
||||
expect(icon).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should expose trigger button with aria-haspopup and aria-expanded', () => {
|
||||
const wrapper = createWrapper()
|
||||
renderComponent()
|
||||
|
||||
const trigger = wrapper.find('[aria-haspopup="menu"]')
|
||||
expect(trigger.exists()).toBe(true)
|
||||
expect(trigger.attributes('aria-label')).toBe('Canvas Mode')
|
||||
expect(trigger.attributes('aria-expanded')).toBe('false')
|
||||
const trigger = screen.getByRole('button', { name: 'Canvas Mode' })
|
||||
expect(trigger).toHaveAttribute('aria-haspopup', 'menu')
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
it('should call focus on next item when ArrowDown is pressed', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const { user } = renderComponent()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
const secondItemEl = menuItems[1].element as HTMLElement
|
||||
const focusSpy = vi.spyOn(secondItemEl, 'focus')
|
||||
const menuItems = screen.getAllByRole('menuitemradio')
|
||||
const focusSpy = vi.spyOn(menuItems[1], 'focus')
|
||||
|
||||
await menuItems[0].trigger('keydown', { key: 'ArrowDown' })
|
||||
menuItems[0].focus()
|
||||
await user.keyboard('{ArrowDown}')
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should call focus on previous item when ArrowUp is pressed', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const { user } = renderComponent()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
const firstItemEl = menuItems[0].element as HTMLElement
|
||||
const focusSpy = vi.spyOn(firstItemEl, 'focus')
|
||||
const menuItems = screen.getAllByRole('menuitemradio')
|
||||
const focusSpy = vi.spyOn(menuItems[0], 'focus')
|
||||
|
||||
await menuItems[1].trigger('keydown', { key: 'ArrowUp' })
|
||||
menuItems[1].focus()
|
||||
await user.keyboard('{ArrowUp}')
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close popover on Escape and restore focus to trigger', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const { user } = renderComponent()
|
||||
|
||||
const menuItems = wrapper.findAll('[role="menuitemradio"]')
|
||||
const trigger = wrapper.find('[aria-haspopup="menu"]')
|
||||
const triggerEl = trigger.element as HTMLElement
|
||||
const focusSpy = vi.spyOn(triggerEl, 'focus')
|
||||
const menuItems = screen.getAllByRole('menuitemradio')
|
||||
const trigger = screen.getByRole('button', { name: 'Canvas Mode' })
|
||||
const focusSpy = vi.spyOn(trigger, 'focus')
|
||||
|
||||
await menuItems[0].trigger('keydown', { key: 'Escape' })
|
||||
menuItems[0].focus()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(mockPopoverHide).toHaveBeenCalled()
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render } from '@testing-library/vue'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('DomWidgets transition grace characterization', () => {
|
||||
const canvas = createCanvas(graphA)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
mount(DomWidgets, {
|
||||
render(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
@@ -134,7 +134,7 @@ describe('DomWidgets transition grace characterization', () => {
|
||||
const canvas = createCanvas(graphB)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
mount(DomWidgets, {
|
||||
render(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
@@ -160,7 +160,7 @@ describe('DomWidgets transition grace characterization', () => {
|
||||
const canvas = createCanvas(graphA)
|
||||
canvasStore.canvas = canvas
|
||||
|
||||
mount(DomWidgets, {
|
||||
render(DomWidgets, {
|
||||
global: {
|
||||
stubs: {
|
||||
DomWidget: true
|
||||
|
||||
@@ -260,8 +260,26 @@ function handleColorSelect(subOption: SubMenuOption) {
|
||||
hide()
|
||||
}
|
||||
|
||||
function constrainMenuHeight() {
|
||||
const menuInstance = contextMenu.value as unknown as {
|
||||
container?: HTMLElement
|
||||
}
|
||||
const rootList = menuInstance?.container?.querySelector(
|
||||
':scope > ul'
|
||||
) as HTMLElement | null
|
||||
if (!rootList) return
|
||||
|
||||
const rect = rootList.getBoundingClientRect()
|
||||
const maxHeight = window.innerHeight - rect.top - 8
|
||||
if (maxHeight > 0) {
|
||||
rootList.style.maxHeight = `${maxHeight}px`
|
||||
rootList.style.overflowY = 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
function onMenuShow() {
|
||||
isOpen.value = true
|
||||
requestAnimationFrame(constrainMenuHeight)
|
||||
}
|
||||
|
||||
function onMenuHide() {
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ZoomControlsModal from '@/components/graph/modals/ZoomControlsModal.vue'
|
||||
|
||||
// Mock functions
|
||||
const mockExecute = vi.fn()
|
||||
const mockGetCommand = vi.fn().mockReturnValue({
|
||||
const mockGetCommand = vi.fn().mockImplementation((commandId: string) => ({
|
||||
keybinding: {
|
||||
combo: {
|
||||
getKeySequences: () => ['Ctrl', '+']
|
||||
getKeySequences: () => [
|
||||
'Ctrl',
|
||||
commandId === 'Comfy.Canvas.ZoomIn'
|
||||
? '+'
|
||||
: commandId === 'Comfy.Canvas.ZoomOut'
|
||||
? '-'
|
||||
: '0'
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
const mockFormatKeySequence = vi.fn().mockReturnValue('Ctrl+')
|
||||
}))
|
||||
const mockFormatKeySequence = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(command: {
|
||||
keybinding: { combo: { getKeySequences: () => string[] } }
|
||||
}) => {
|
||||
const seq = command.keybinding.combo.getKeySequences()
|
||||
if (seq.includes('+')) return 'Ctrl+'
|
||||
if (seq.includes('-')) return 'Ctrl-'
|
||||
return 'Ctrl+0'
|
||||
}
|
||||
)
|
||||
const mockSetAppZoom = vi.fn()
|
||||
const mockSettingGet = vi.fn().mockReturnValue(true)
|
||||
|
||||
@@ -23,11 +41,11 @@ const i18n = createI18n({
|
||||
messages: { en: {} }
|
||||
})
|
||||
|
||||
// Mock dependencies
|
||||
|
||||
vi.mock('@/renderer/extensions/minimap/composables/useMinimap', () => ({
|
||||
useMinimap: () => ({
|
||||
containerStyles: { value: { backgroundColor: '#fff', borderRadius: '8px' } }
|
||||
containerStyles: {
|
||||
value: { backgroundColor: '#fff', borderRadius: '8px' }
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -52,8 +70,8 @@ vi.mock('@/platform/settings/settingStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(ZoomControlsModal, {
|
||||
function renderComponent(props = {}) {
|
||||
return render(ZoomControlsModal, {
|
||||
props: {
|
||||
visible: true,
|
||||
...props
|
||||
@@ -70,90 +88,89 @@ const createWrapper = (props = {}) => {
|
||||
|
||||
describe('ZoomControlsModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should execute zoom in command when zoom in button is clicked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
const buttons = wrapper.findAll('.cursor-pointer')
|
||||
const zoomInButton = buttons.find((btn) =>
|
||||
btn.text().includes('graphCanvasMenu.zoomIn')
|
||||
)
|
||||
|
||||
expect(zoomInButton).toBeDefined()
|
||||
await zoomInButton!.trigger('mousedown')
|
||||
const zoomInButton = screen.getByTestId('zoom-in-action')
|
||||
await user.click(zoomInButton)
|
||||
|
||||
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.ZoomIn')
|
||||
})
|
||||
|
||||
it('should execute zoom out command when zoom out button is clicked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
const buttons = wrapper.findAll('.cursor-pointer')
|
||||
const zoomOutButton = buttons.find((btn) =>
|
||||
btn.text().includes('graphCanvasMenu.zoomOut')
|
||||
)
|
||||
|
||||
expect(zoomOutButton).toBeDefined()
|
||||
await zoomOutButton!.trigger('mousedown')
|
||||
const zoomOutButton = screen.getByTestId('zoom-out-action')
|
||||
await user.click(zoomOutButton)
|
||||
|
||||
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.ZoomOut')
|
||||
})
|
||||
|
||||
it('should execute fit view command when fit view button is clicked', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
const buttons = wrapper.findAll('.cursor-pointer')
|
||||
const fitViewButton = buttons.find((btn) =>
|
||||
btn.text().includes('zoomControls.zoomToFit')
|
||||
)
|
||||
|
||||
expect(fitViewButton).toBeDefined()
|
||||
await fitViewButton!.trigger('click')
|
||||
const fitViewButton = screen.getByTestId('zoom-to-fit-action')
|
||||
await user.click(fitViewButton)
|
||||
|
||||
expect(mockExecute).toHaveBeenCalledWith('Comfy.Canvas.FitView')
|
||||
})
|
||||
|
||||
it('should call setAppZoomFromPercentage with valid zoom input values', async () => {
|
||||
const wrapper = createWrapper()
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
const inputNumber = wrapper.findComponent({ name: 'InputNumber' })
|
||||
expect(inputNumber.exists()).toBe(true)
|
||||
|
||||
// Emit the input event with PrimeVue's InputNumberInputEvent structure
|
||||
await inputNumber.vm.$emit('input', { value: 150 })
|
||||
const input = screen.getByRole('spinbutton')
|
||||
await user.tripleClick(input)
|
||||
await user.keyboard('150')
|
||||
|
||||
expect(mockSetAppZoom).toHaveBeenCalledWith(150)
|
||||
})
|
||||
|
||||
it('should not call setAppZoomFromPercentage with invalid zoom input values', async () => {
|
||||
const wrapper = createWrapper()
|
||||
it('should not call setAppZoomFromPercentage when value is below minimum', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
const inputNumber = wrapper.findComponent({ name: 'InputNumber' })
|
||||
expect(inputNumber.exists()).toBe(true)
|
||||
const input = screen.getByRole('spinbutton')
|
||||
await user.tripleClick(input)
|
||||
await user.keyboard('0')
|
||||
|
||||
// Test out of range values
|
||||
await inputNumber.vm.$emit('input', { value: 0 })
|
||||
expect(mockSetAppZoom).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not apply zoom values exceeding the maximum', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
const input = screen.getByRole('spinbutton')
|
||||
await user.tripleClick(input)
|
||||
await user.keyboard('100')
|
||||
mockSetAppZoom.mockClear()
|
||||
|
||||
await user.keyboard('1')
|
||||
|
||||
await inputNumber.vm.$emit('input', { value: 1001 })
|
||||
expect(mockSetAppZoom).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should display keyboard shortcuts for commands', () => {
|
||||
const wrapper = createWrapper()
|
||||
renderComponent()
|
||||
|
||||
const buttons = wrapper.findAll('.cursor-pointer')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
|
||||
// Each command button should show the keyboard shortcut
|
||||
expect(mockFormatKeySequence).toHaveBeenCalled()
|
||||
expect(screen.getByText('Ctrl+')).toBeInTheDocument()
|
||||
expect(screen.getByText('Ctrl-')).toBeInTheDocument()
|
||||
expect(screen.getByText('Ctrl+0')).toBeInTheDocument()
|
||||
expect(mockGetCommand).toHaveBeenCalledWith('Comfy.Canvas.ZoomIn')
|
||||
expect(mockGetCommand).toHaveBeenCalledWith('Comfy.Canvas.ZoomOut')
|
||||
expect(mockGetCommand).toHaveBeenCalledWith('Comfy.Canvas.FitView')
|
||||
})
|
||||
|
||||
it('should not be visible when visible prop is false', () => {
|
||||
const wrapper = createWrapper({ visible: false })
|
||||
renderComponent({ visible: false })
|
||||
|
||||
expect(wrapper.find('.absolute').exists()).toBe(false)
|
||||
expect(screen.queryByTestId('zoom-in-action')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Mock } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
@@ -105,8 +106,10 @@ describe('ColorPickerButton', () => {
|
||||
workflowStore.activeWorkflow = createMockWorkflow()
|
||||
})
|
||||
|
||||
const createWrapper = () => {
|
||||
return mount(ColorPickerButton, {
|
||||
function renderComponent() {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(ColorPickerButton, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n],
|
||||
directives: {
|
||||
@@ -114,28 +117,30 @@ describe('ColorPickerButton', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { user }
|
||||
}
|
||||
|
||||
it('should render when nodes are selected', () => {
|
||||
// Add a mock node to selectedItems
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = createWrapper()
|
||||
expect(wrapper.find('button').exists()).toBe(true)
|
||||
renderComponent()
|
||||
expect(screen.getByTestId('color-picker-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should toggle color picker visibility on button click', async () => {
|
||||
canvasStore.selectedItems = [createMockPositionable()]
|
||||
const wrapper = createWrapper()
|
||||
const button = wrapper.find('button')
|
||||
const { user } = renderComponent()
|
||||
const button = screen.getByTestId('color-picker-button')
|
||||
|
||||
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
|
||||
expect(screen.queryByTestId('noColor')).not.toBeInTheDocument()
|
||||
|
||||
await button.trigger('click')
|
||||
const picker = wrapper.findComponent({ name: 'SelectButton' })
|
||||
expect(picker.exists()).toBe(true)
|
||||
expect(picker.findAll('button').length).toBeGreaterThan(0)
|
||||
await user.click(button)
|
||||
expect(screen.getByTestId('noColor')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('red')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('green')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('blue')).toBeInTheDocument()
|
||||
|
||||
await button.trigger('click')
|
||||
expect(wrapper.findComponent({ name: 'SelectButton' }).exists()).toBe(false)
|
||||
await user.click(button)
|
||||
expect(screen.queryByTestId('noColor')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
@@ -12,7 +13,6 @@ import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
|
||||
// Mock the utils
|
||||
vi.mock('@/utils/litegraphUtil', () => ({
|
||||
isLGraphNode: vi.fn((node) => !!node?.type)
|
||||
}))
|
||||
@@ -21,7 +21,6 @@ vi.mock('@/utils/nodeFilterUtil', () => ({
|
||||
isOutputNode: vi.fn((node) => !!node?.constructor?.nodeData?.output_node)
|
||||
}))
|
||||
|
||||
// Mock the composables
|
||||
vi.mock('@/composables/graph/useSelectionState', () => ({
|
||||
useSelectionState: vi.fn(() => ({
|
||||
selectedNodes: {
|
||||
@@ -49,14 +48,12 @@ describe('ExecuteButton', () => {
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
// Set up Pinia with testing utilities
|
||||
setActivePinia(
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn
|
||||
})
|
||||
)
|
||||
|
||||
// Reset mocks
|
||||
const partialCanvas: Partial<LGraphCanvas> = {
|
||||
setDirty: vi.fn()
|
||||
}
|
||||
@@ -64,14 +61,12 @@ describe('ExecuteButton', () => {
|
||||
|
||||
mockSelectedNodes = []
|
||||
|
||||
// Get store instances and mock methods
|
||||
const canvasStore = useCanvasStore()
|
||||
const commandStore = useCommandStore()
|
||||
|
||||
vi.spyOn(canvasStore, 'getCanvas').mockReturnValue(mockCanvas)
|
||||
vi.spyOn(commandStore, 'execute').mockResolvedValue()
|
||||
|
||||
// Update the useSelectionState mock
|
||||
vi.mocked(useSelectionState).mockReturnValue({
|
||||
selectedNodes: {
|
||||
value: mockSelectedNodes
|
||||
@@ -81,33 +76,33 @@ describe('ExecuteButton', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const mountComponent = () => {
|
||||
return mount(ExecuteButton, {
|
||||
const renderComponent = () => {
|
||||
return render(ExecuteButton, {
|
||||
global: {
|
||||
plugins: [i18n, PrimeVue],
|
||||
directives: { tooltip: Tooltip },
|
||||
stubs: {
|
||||
'i-lucide:play': { template: '<div class="play-icon" />' }
|
||||
}
|
||||
directives: { tooltip: Tooltip }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should be able to render', () => {
|
||||
const wrapper = mountComponent()
|
||||
const button = wrapper.find('button')
|
||||
expect(button.exists()).toBe(true)
|
||||
renderComponent()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Execute selected nodes' })
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Click Handler', () => {
|
||||
it('should execute Comfy.QueueSelectedOutputNodes command on click', async () => {
|
||||
const commandStore = useCommandStore()
|
||||
const wrapper = mountComponent()
|
||||
const button = wrapper.find('button')
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await button.trigger('click')
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Execute selected nodes' })
|
||||
)
|
||||
|
||||
expect(commandStore.execute).toHaveBeenCalledWith(
|
||||
'Comfy.QueueSelectedOutputNodes'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
@@ -18,6 +19,12 @@ vi.mock('@/stores/workspace/rightSidePanelStore', () => ({
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: () => ({
|
||||
trackUiButtonClicked: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
describe('InfoButton', () => {
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
@@ -36,8 +43,8 @@ describe('InfoButton', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
const mountComponent = () => {
|
||||
return mount(InfoButton, {
|
||||
const renderComponent = () => {
|
||||
return render(InfoButton, {
|
||||
global: {
|
||||
plugins: [i18n, PrimeVue],
|
||||
directives: { tooltip: Tooltip },
|
||||
@@ -47,9 +54,11 @@ describe('InfoButton', () => {
|
||||
}
|
||||
|
||||
it('should open the info panel on click', async () => {
|
||||
const wrapper = mountComponent()
|
||||
const button = wrapper.find('[data-testid="info-button"]')
|
||||
await button.trigger('click')
|
||||
const user = userEvent.setup()
|
||||
renderComponent()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Node Info' }))
|
||||
|
||||
expect(openPanelMock).toHaveBeenCalledWith('info')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render } from '@testing-library/vue'
|
||||
import { fromPartial } from '@total-typescript/shoehorn'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { reactive } from 'vue'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
import type { BaseDOMWidget } from '@/scripts/domWidget'
|
||||
import type { DomWidgetState } from '@/stores/domWidgetStore'
|
||||
@@ -100,16 +100,17 @@ describe('DomWidget disabled style', () => {
|
||||
|
||||
it('uses disabled style when promoted override widget is computedDisabled', async () => {
|
||||
const widgetState = createWidgetState(true)
|
||||
const wrapper = mount(DomWidget, {
|
||||
const { container } = render(DomWidget, {
|
||||
props: {
|
||||
widgetState
|
||||
}
|
||||
})
|
||||
|
||||
widgetState.zIndex = 3
|
||||
await wrapper.vm.$nextTick()
|
||||
await nextTick()
|
||||
|
||||
const root = wrapper.get('.dom-widget').element as HTMLElement
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const root = container.querySelector('.dom-widget') as HTMLElement
|
||||
expect(root.style.pointerEvents).toBe('none')
|
||||
expect(root.style.opacity).toBe('0.5')
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { VueWrapper } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, nextTick, ref } from 'vue'
|
||||
|
||||
@@ -11,10 +11,11 @@ describe('HoneyToast', () => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
function mountComponent(
|
||||
function renderComponent(
|
||||
props: { visible: boolean; expanded?: boolean } = { visible: true }
|
||||
): VueWrapper {
|
||||
return mount(HoneyToast, {
|
||||
) {
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = render(HoneyToast, {
|
||||
props,
|
||||
slots: {
|
||||
default: (slotProps: { isExpanded: boolean }) =>
|
||||
@@ -33,48 +34,45 @@ describe('HoneyToast', () => {
|
||||
slotProps.isExpanded ? 'Collapse' : 'Expand'
|
||||
)
|
||||
},
|
||||
attachTo: document.body
|
||||
container: document.body.appendChild(document.createElement('div'))
|
||||
})
|
||||
return { user, unmount }
|
||||
}
|
||||
|
||||
it('renders when visible is true', async () => {
|
||||
const wrapper = mountComponent({ visible: true })
|
||||
const { unmount } = renderComponent({ visible: true })
|
||||
await nextTick()
|
||||
|
||||
const toast = document.body.querySelector('[role="status"]')
|
||||
expect(toast).toBeTruthy()
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not render when visible is false', async () => {
|
||||
const wrapper = mountComponent({ visible: false })
|
||||
const { unmount } = renderComponent({ visible: false })
|
||||
await nextTick()
|
||||
|
||||
const toast = document.body.querySelector('[role="status"]')
|
||||
expect(toast).toBeFalsy()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('passes is-expanded=false to slots by default', async () => {
|
||||
const wrapper = mountComponent({ visible: true })
|
||||
const { unmount } = renderComponent({ visible: true })
|
||||
await nextTick()
|
||||
|
||||
const content = document.body.querySelector('[data-testid="content"]')
|
||||
expect(content?.textContent).toBe('collapsed')
|
||||
expect(screen.getByTestId('content')).toHaveTextContent('collapsed')
|
||||
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('has aria-live="polite" for accessibility', async () => {
|
||||
const wrapper = mountComponent({ visible: true })
|
||||
const { unmount } = renderComponent({ visible: true })
|
||||
await nextTick()
|
||||
|
||||
const toast = document.body.querySelector('[role="status"]')
|
||||
expect(toast?.getAttribute('aria-live')).toBe('polite')
|
||||
expect(screen.getByRole('status')).toHaveAttribute('aria-live', 'polite')
|
||||
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('supports v-model:expanded with reactive parent state', async () => {
|
||||
@@ -98,23 +96,21 @@ describe('HoneyToast', () => {
|
||||
`
|
||||
})
|
||||
|
||||
const wrapper = mount(TestWrapper, { attachTo: document.body })
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = render(TestWrapper, {
|
||||
container: document.body.appendChild(document.createElement('div'))
|
||||
})
|
||||
await nextTick()
|
||||
|
||||
const content = document.body.querySelector('[data-testid="content"]')
|
||||
expect(content?.textContent).toBe('collapsed')
|
||||
expect(screen.getByTestId('content')).toHaveTextContent('collapsed')
|
||||
expect(screen.getByTestId('toggle-btn')).toHaveTextContent('Expand')
|
||||
|
||||
const toggleBtn = document.body.querySelector(
|
||||
'[data-testid="toggle-btn"]'
|
||||
) as HTMLButtonElement
|
||||
expect(toggleBtn?.textContent?.trim()).toBe('Expand')
|
||||
|
||||
toggleBtn?.click()
|
||||
await user.click(screen.getByTestId('toggle-btn'))
|
||||
await nextTick()
|
||||
|
||||
expect(content?.textContent).toBe('expanded')
|
||||
expect(toggleBtn?.textContent?.trim()).toBe('Collapse')
|
||||
expect(screen.getByTestId('content')).toHaveTextContent('expanded')
|
||||
expect(screen.getByTestId('toggle-btn')).toHaveTextContent('Collapse')
|
||||
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -27,7 +28,7 @@ const options = [
|
||||
{ name: 'Option C', value: 'c' }
|
||||
]
|
||||
|
||||
function mountInParent(
|
||||
function renderInParent(
|
||||
multiSelectProps: Record<string, unknown> = {},
|
||||
modelValue: { name: string; value: string }[] = []
|
||||
) {
|
||||
@@ -49,12 +50,12 @@ function mountInParent(
|
||||
}
|
||||
}
|
||||
|
||||
const wrapper = mount(Parent, {
|
||||
attachTo: document.body,
|
||||
const { unmount } = render(Parent, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
return { wrapper, parentEscapeCount }
|
||||
return { unmount, parentEscapeCount }
|
||||
}
|
||||
|
||||
function dispatchEscape(element: Element) {
|
||||
@@ -73,30 +74,32 @@ function findContentElement(): HTMLElement | null {
|
||||
|
||||
describe('MultiSelect', () => {
|
||||
it('keeps open-state border styling available while the dropdown is open', async () => {
|
||||
const { wrapper } = mountInParent()
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = renderInParent()
|
||||
|
||||
const trigger = wrapper.get('button[aria-haspopup="listbox"]')
|
||||
const trigger = screen.getByRole('button')
|
||||
|
||||
expect(trigger.classes()).toContain(
|
||||
expect(trigger).toHaveClass(
|
||||
'data-[state=open]:border-node-component-border'
|
||||
)
|
||||
expect(trigger.attributes('aria-expanded')).toBe('false')
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await trigger.trigger('click')
|
||||
await user.click(trigger)
|
||||
await nextTick()
|
||||
|
||||
expect(trigger.attributes('aria-expanded')).toBe('true')
|
||||
expect(trigger.attributes('data-state')).toBe('open')
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(trigger).toHaveAttribute('data-state', 'open')
|
||||
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
})
|
||||
|
||||
describe('Escape key propagation', () => {
|
||||
it('stops Escape from propagating to parent when popover is open', async () => {
|
||||
const { wrapper, parentEscapeCount } = mountInParent()
|
||||
const user = userEvent.setup()
|
||||
const { unmount, parentEscapeCount } = renderInParent()
|
||||
|
||||
const trigger = wrapper.find('button[aria-haspopup="listbox"]')
|
||||
await trigger.trigger('click')
|
||||
const trigger = screen.getByRole('button')
|
||||
await user.click(trigger)
|
||||
await nextTick()
|
||||
|
||||
const content = findContentElement()
|
||||
@@ -107,48 +110,46 @@ describe('MultiSelect', () => {
|
||||
|
||||
expect(parentEscapeCount.value).toBe(0)
|
||||
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('closes the popover when Escape is pressed', async () => {
|
||||
const { wrapper } = mountInParent()
|
||||
const user = userEvent.setup()
|
||||
const { unmount } = renderInParent()
|
||||
|
||||
const trigger = wrapper.find('button[aria-haspopup="listbox"]')
|
||||
await trigger.trigger('click')
|
||||
const trigger = screen.getByRole('button')
|
||||
await user.click(trigger)
|
||||
await nextTick()
|
||||
expect(trigger.attributes('data-state')).toBe('open')
|
||||
expect(trigger).toHaveAttribute('data-state', 'open')
|
||||
|
||||
const content = findContentElement()
|
||||
dispatchEscape(content!)
|
||||
await nextTick()
|
||||
|
||||
expect(trigger.attributes('data-state')).toBe('closed')
|
||||
expect(trigger).toHaveAttribute('data-state', 'closed')
|
||||
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('selected count badge', () => {
|
||||
it('shows selected count when items are selected', () => {
|
||||
const { wrapper } = mountInParent({}, [
|
||||
const { unmount } = renderInParent({}, [
|
||||
{ name: 'Option A', value: 'a' },
|
||||
{ name: 'Option B', value: 'b' }
|
||||
])
|
||||
|
||||
expect(wrapper.text()).toContain('2')
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not show count badge when no items are selected', () => {
|
||||
const { wrapper } = mountInParent()
|
||||
const multiSelect = wrapper.findComponent(MultiSelect)
|
||||
const spans = multiSelect.findAll('span')
|
||||
const countBadge = spans.find((s) => /^\d+$/.test(s.text().trim()))
|
||||
const { unmount } = renderInParent()
|
||||
|
||||
expect(countBadge).toBeUndefined()
|
||||
expect(screen.queryByText(/^\d+$/)).not.toBeInTheDocument()
|
||||
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -37,7 +37,7 @@ function findContentElement(): HTMLElement | null {
|
||||
return document.querySelector('[data-dismissable-layer]')
|
||||
}
|
||||
|
||||
function mountInParent(modelValue?: string) {
|
||||
function renderInParent(modelValue?: string) {
|
||||
const parentEscapeCount = { value: 0 }
|
||||
|
||||
const Parent = {
|
||||
@@ -55,12 +55,12 @@ function mountInParent(modelValue?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const wrapper = mount(Parent, {
|
||||
attachTo: document.body,
|
||||
const { unmount } = render(Parent, {
|
||||
container: document.body.appendChild(document.createElement('div')),
|
||||
global: { plugins: [i18n] }
|
||||
})
|
||||
|
||||
return { wrapper, parentEscapeCount }
|
||||
return { unmount, parentEscapeCount }
|
||||
}
|
||||
|
||||
async function openSelect(triggerEl: HTMLElement) {
|
||||
@@ -81,10 +81,10 @@ async function openSelect(triggerEl: HTMLElement) {
|
||||
describe('SingleSelect', () => {
|
||||
describe('Escape key propagation', () => {
|
||||
it('stops Escape from propagating to parent when popover is open', async () => {
|
||||
const { wrapper, parentEscapeCount } = mountInParent()
|
||||
const { unmount, parentEscapeCount } = renderInParent()
|
||||
|
||||
const trigger = wrapper.find('button[role="combobox"]')
|
||||
await openSelect(trigger.element as HTMLElement)
|
||||
const trigger = screen.getByRole('combobox')
|
||||
await openSelect(trigger)
|
||||
|
||||
const content = findContentElement()
|
||||
expect(content).not.toBeNull()
|
||||
@@ -94,23 +94,23 @@ describe('SingleSelect', () => {
|
||||
|
||||
expect(parentEscapeCount.value).toBe(0)
|
||||
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('closes the popover when Escape is pressed', async () => {
|
||||
const { wrapper } = mountInParent()
|
||||
const { unmount } = renderInParent()
|
||||
|
||||
const trigger = wrapper.find('button[role="combobox"]')
|
||||
await openSelect(trigger.element as HTMLElement)
|
||||
expect(trigger.attributes('data-state')).toBe('open')
|
||||
const trigger = screen.getByRole('combobox')
|
||||
await openSelect(trigger)
|
||||
expect(trigger).toHaveAttribute('data-state', 'open')
|
||||
|
||||
const content = findContentElement()
|
||||
dispatchEscape(content!)
|
||||
await nextTick()
|
||||
|
||||
expect(trigger.attributes('data-state')).toBe('closed')
|
||||
expect(trigger).toHaveAttribute('data-state', 'closed')
|
||||
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia } from 'pinia'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { createApp } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
|
||||
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import * as markdownRendererUtil from '@/utils/markdownRendererUtil'
|
||||
|
||||
@@ -54,13 +55,11 @@ describe('NodePreview', () => {
|
||||
description: 'Test node description'
|
||||
}
|
||||
|
||||
const mountComponent = (nodeDef: ComfyNodeDefV2 = mockNodeDef) => {
|
||||
return mount(NodePreview, {
|
||||
function renderComponent(nodeDef: ComfyNodeDefV2 = mockNodeDef) {
|
||||
return render(NodePreview, {
|
||||
global: {
|
||||
plugins: [PrimeVue, i18n, pinia],
|
||||
stubs: {
|
||||
// Stub stores if needed
|
||||
}
|
||||
stubs: {}
|
||||
},
|
||||
props: {
|
||||
nodeDef
|
||||
@@ -69,18 +68,18 @@ describe('NodePreview', () => {
|
||||
}
|
||||
|
||||
it('renders node preview with correct structure', () => {
|
||||
const wrapper = mountComponent()
|
||||
renderComponent()
|
||||
|
||||
expect(wrapper.find('._sb_node_preview').exists()).toBe(true)
|
||||
expect(wrapper.find('.node_header').exists()).toBe(true)
|
||||
expect(wrapper.find('._sb_preview_badge').text()).toBe('Preview')
|
||||
expect(screen.getByTestId('node-preview')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('node-header')).toBeInTheDocument()
|
||||
expect(screen.getByText('Preview')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sets title attribute on node header with full display name', () => {
|
||||
const wrapper = mountComponent()
|
||||
const nodeHeader = wrapper.find('.node_header')
|
||||
renderComponent()
|
||||
const nodeHeader = screen.getByTestId('node-header')
|
||||
|
||||
expect(nodeHeader.attributes('title')).toBe(mockNodeDef.display_name)
|
||||
expect(nodeHeader).toHaveAttribute('title', mockNodeDef.display_name)
|
||||
})
|
||||
|
||||
it('displays truncated long node names with ellipsis', () => {
|
||||
@@ -90,17 +89,11 @@ describe('NodePreview', () => {
|
||||
'This Is An Extremely Long Node Name That Should Definitely Be Truncated With Ellipsis To Prevent Layout Issues'
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(longNameNodeDef)
|
||||
const nodeHeader = wrapper.find('.node_header')
|
||||
renderComponent(longNameNodeDef)
|
||||
const nodeHeader = screen.getByTestId('node-header')
|
||||
|
||||
// Verify the title attribute contains the full name
|
||||
expect(nodeHeader.attributes('title')).toBe(longNameNodeDef.display_name)
|
||||
|
||||
// Verify overflow handling classes are applied
|
||||
expect(nodeHeader.classes()).toContain('text-ellipsis')
|
||||
|
||||
// The actual text content should still be the full name (CSS handles truncation)
|
||||
expect(nodeHeader.text()).toContain(longNameNodeDef.display_name)
|
||||
expect(nodeHeader).toHaveAttribute('title', longNameNodeDef.display_name)
|
||||
expect(nodeHeader).toHaveTextContent(longNameNodeDef.display_name!)
|
||||
})
|
||||
|
||||
it('handles short node names without issues', () => {
|
||||
@@ -109,18 +102,18 @@ describe('NodePreview', () => {
|
||||
display_name: 'Short'
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(shortNameNodeDef)
|
||||
const nodeHeader = wrapper.find('.node_header')
|
||||
renderComponent(shortNameNodeDef)
|
||||
const nodeHeader = screen.getByTestId('node-header')
|
||||
|
||||
expect(nodeHeader.attributes('title')).toBe('Short')
|
||||
expect(nodeHeader.text()).toContain('Short')
|
||||
expect(nodeHeader).toHaveAttribute('title', 'Short')
|
||||
expect(nodeHeader).toHaveTextContent('Short')
|
||||
})
|
||||
|
||||
it('applies proper spacing to the dot element', () => {
|
||||
const wrapper = mountComponent()
|
||||
const headdot = wrapper.find('.headdot')
|
||||
renderComponent()
|
||||
const headdot = screen.getByTestId('head-dot')
|
||||
|
||||
expect(headdot.classes()).toContain('pr-3')
|
||||
expect(headdot).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('Description Rendering', () => {
|
||||
@@ -130,11 +123,13 @@ describe('NodePreview', () => {
|
||||
description: 'This is a plain text description'
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(plainTextNodeDef)
|
||||
const description = wrapper.find('._sb_description')
|
||||
renderComponent(plainTextNodeDef)
|
||||
const description = screen.getByTestId('node-description')
|
||||
|
||||
expect(description.exists()).toBe(true)
|
||||
expect(description.html()).toContain('This is a plain text description')
|
||||
expect(description).toBeInTheDocument()
|
||||
expect(description.innerHTML).toContain(
|
||||
'This is a plain text description'
|
||||
)
|
||||
})
|
||||
|
||||
it('renders markdown description with formatting', () => {
|
||||
@@ -143,13 +138,13 @@ describe('NodePreview', () => {
|
||||
description: '**Bold text** and *italic text* with `code`'
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(markdownNodeDef)
|
||||
const description = wrapper.find('._sb_description')
|
||||
renderComponent(markdownNodeDef)
|
||||
const description = screen.getByTestId('node-description')
|
||||
|
||||
expect(description.exists()).toBe(true)
|
||||
expect(description.html()).toContain('<strong>Bold text</strong>')
|
||||
expect(description.html()).toContain('<em>italic text</em>')
|
||||
expect(description.html()).toContain('<code>code</code>')
|
||||
expect(description).toBeInTheDocument()
|
||||
expect(description.innerHTML).toContain('<strong>Bold text</strong>')
|
||||
expect(description.innerHTML).toContain('<em>italic text</em>')
|
||||
expect(description.innerHTML).toContain('<code>code</code>')
|
||||
})
|
||||
|
||||
it('does not render description element when description is empty', () => {
|
||||
@@ -158,20 +153,16 @@ describe('NodePreview', () => {
|
||||
description: ''
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(noDescriptionNodeDef)
|
||||
const description = wrapper.find('._sb_description')
|
||||
renderComponent(noDescriptionNodeDef)
|
||||
|
||||
expect(description.exists()).toBe(false)
|
||||
expect(screen.queryByTestId('node-description')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render description element when description is undefined', () => {
|
||||
const { description, ...nodeDefWithoutDescription } = mockNodeDef
|
||||
const wrapper = mountComponent(
|
||||
nodeDefWithoutDescription as ComfyNodeDefV2
|
||||
)
|
||||
const descriptionElement = wrapper.find('._sb_description')
|
||||
renderComponent(nodeDefWithoutDescription as ComfyNodeDefV2)
|
||||
|
||||
expect(descriptionElement.exists()).toBe(false)
|
||||
expect(screen.queryByTestId('node-description')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls renderMarkdownToHtml utility function', () => {
|
||||
@@ -183,7 +174,7 @@ describe('NodePreview', () => {
|
||||
description: testDescription
|
||||
}
|
||||
|
||||
mountComponent(nodeDefWithDescription)
|
||||
renderComponent(nodeDefWithDescription)
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(testDescription)
|
||||
spy.mockRestore()
|
||||
@@ -196,21 +187,13 @@ describe('NodePreview', () => {
|
||||
'Safe **markdown** content <script>alert("xss")</script> with `code` blocks'
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(unsafeNodeDef)
|
||||
const description = wrapper.find('._sb_description')
|
||||
renderComponent(unsafeNodeDef)
|
||||
const description = screen.getByTestId('node-description')
|
||||
|
||||
// The description should still exist because there's safe content
|
||||
if (description.exists()) {
|
||||
// Should not contain script tags (sanitized by DOMPurify)
|
||||
expect(description.html()).not.toContain('<script>')
|
||||
expect(description.html()).not.toContain('alert("xss")')
|
||||
// Should contain the safe markdown content rendered as HTML
|
||||
expect(description.html()).toContain('<strong>markdown</strong>')
|
||||
expect(description.html()).toContain('<code>code</code>')
|
||||
} else {
|
||||
// If DOMPurify removes everything, that's also acceptable for security
|
||||
expect(description.exists()).toBe(false)
|
||||
}
|
||||
expect(description.innerHTML).not.toContain('<script>')
|
||||
expect(description.innerHTML).not.toContain('alert("xss")')
|
||||
expect(description.innerHTML).toContain('<strong>markdown</strong>')
|
||||
expect(description.innerHTML).toContain('<code>code</code>')
|
||||
})
|
||||
|
||||
it('handles markdown with line breaks', () => {
|
||||
@@ -219,12 +202,11 @@ describe('NodePreview', () => {
|
||||
description: 'Line 1\n\nLine 3 after empty line'
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(multilineNodeDef)
|
||||
const description = wrapper.find('._sb_description')
|
||||
renderComponent(multilineNodeDef)
|
||||
const description = screen.getByTestId('node-description')
|
||||
|
||||
expect(description.exists()).toBe(true)
|
||||
// Should contain paragraph tags for proper line break handling
|
||||
expect(description.html()).toContain('<p>')
|
||||
expect(description).toBeInTheDocument()
|
||||
expect(description.innerHTML).toContain('<p>')
|
||||
})
|
||||
|
||||
it('handles markdown lists', () => {
|
||||
@@ -233,19 +215,19 @@ describe('NodePreview', () => {
|
||||
description: '- Item 1\n- Item 2\n- Item 3'
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(listNodeDef)
|
||||
const description = wrapper.find('._sb_description')
|
||||
renderComponent(listNodeDef)
|
||||
const description = screen.getByTestId('node-description')
|
||||
|
||||
expect(description.exists()).toBe(true)
|
||||
expect(description.html()).toContain('<ul>')
|
||||
expect(description.html()).toContain('<li>')
|
||||
expect(description).toBeInTheDocument()
|
||||
expect(description.innerHTML).toContain('<ul>')
|
||||
expect(description.innerHTML).toContain('<li>')
|
||||
})
|
||||
|
||||
it('applies correct styling classes to description', () => {
|
||||
const wrapper = mountComponent()
|
||||
const description = wrapper.find('._sb_description')
|
||||
it('renders description element', () => {
|
||||
renderComponent()
|
||||
const description = screen.getByTestId('node-description')
|
||||
|
||||
expect(description.classes()).toContain('_sb_description')
|
||||
expect(description).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses v-html directive for rendered content', () => {
|
||||
@@ -254,12 +236,11 @@ describe('NodePreview', () => {
|
||||
description: 'Content with **bold** text'
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(htmlNodeDef)
|
||||
const description = wrapper.find('._sb_description')
|
||||
renderComponent(htmlNodeDef)
|
||||
const description = screen.getByTestId('node-description')
|
||||
|
||||
// The component should render the HTML, not escape it
|
||||
expect(description.html()).toContain('<strong>bold</strong>')
|
||||
expect(description.html()).not.toContain('<strong>')
|
||||
expect(description.innerHTML).toContain('<strong>bold</strong>')
|
||||
expect(description.innerHTML).not.toContain('<strong>')
|
||||
})
|
||||
|
||||
it('prevents XSS attacks by sanitizing dangerous HTML elements', () => {
|
||||
@@ -269,17 +250,12 @@ describe('NodePreview', () => {
|
||||
'Normal text <img src="x" onerror="alert(\'XSS\')" /> and **bold** text'
|
||||
}
|
||||
|
||||
const wrapper = mountComponent(maliciousNodeDef)
|
||||
const description = wrapper.find('._sb_description')
|
||||
renderComponent(maliciousNodeDef)
|
||||
const description = screen.getByTestId('node-description')
|
||||
|
||||
if (description.exists()) {
|
||||
// Should not contain dangerous event handlers
|
||||
expect(description.html()).not.toContain('onerror')
|
||||
expect(description.html()).not.toContain('alert(')
|
||||
// Should still contain safe markdown content
|
||||
expect(description.html()).toContain('<strong>bold</strong>')
|
||||
// May or may not contain img tag depending on DOMPurify config
|
||||
}
|
||||
expect(description.innerHTML).not.toContain('onerror')
|
||||
expect(description.innerHTML).not.toContain('alert(')
|
||||
expect(description.innerHTML).toContain('<strong>bold</strong>')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,17 +7,22 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
|
||||
:node-def="nodeDef"
|
||||
:position="position"
|
||||
/>
|
||||
<div v-else class="_sb_node_preview bg-component-node-background">
|
||||
<div
|
||||
v-else
|
||||
class="_sb_node_preview bg-component-node-background"
|
||||
data-testid="node-preview"
|
||||
>
|
||||
<div class="_sb_table">
|
||||
<div
|
||||
class="node_header text-ellipsis"
|
||||
data-testid="node-header"
|
||||
:title="nodeDef.display_name"
|
||||
:style="{
|
||||
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR,
|
||||
color: litegraphColors.NODE_TITLE_COLOR
|
||||
}"
|
||||
>
|
||||
<div class="_sb_dot headdot pr-3" />
|
||||
<div class="_sb_dot headdot pr-3" data-testid="head-dot" />
|
||||
{{ nodeDef.display_name }}
|
||||
</div>
|
||||
<div class="_sb_preview_badge">{{ $t('g.preview') }}</div>
|
||||
@@ -76,6 +81,7 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
|
||||
<div
|
||||
v-if="renderedDescription"
|
||||
class="_sb_description"
|
||||
data-testid="node-description"
|
||||
:style="{
|
||||
color: litegraphColors.WIDGET_SECONDARY_TEXT_COLOR,
|
||||
backgroundColor: litegraphColors.WIDGET_BGCOLOR
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
|
||||
|
||||
const popoverCloseSpy = vi.fn()
|
||||
|
||||
@@ -52,8 +52,10 @@ vi.mock('@/stores/workspace/sidebarTabStore', () => ({
|
||||
useSidebarTabStore: () => mockSidebarTabStore
|
||||
}))
|
||||
|
||||
const mountMenu = () =>
|
||||
mount(JobHistoryActionsMenu, {
|
||||
import JobHistoryActionsMenu from '@/components/queue/JobHistoryActionsMenu.vue'
|
||||
|
||||
const renderMenu = () =>
|
||||
render(JobHistoryActionsMenu, {
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
@@ -75,12 +77,11 @@ describe('JobHistoryActionsMenu', () => {
|
||||
})
|
||||
|
||||
it('toggles show run progress bar setting from the menu', async () => {
|
||||
const wrapper = mountMenu()
|
||||
const user = userEvent.setup()
|
||||
|
||||
const showRunProgressBarButton = wrapper.get(
|
||||
'[data-testid="show-run-progress-bar-action"]'
|
||||
)
|
||||
await showRunProgressBarButton.trigger('click')
|
||||
renderMenu()
|
||||
|
||||
await user.click(screen.getByTestId('show-run-progress-bar-action'))
|
||||
|
||||
expect(mockSetSetting).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetSetting).toHaveBeenCalledWith(
|
||||
@@ -90,17 +91,16 @@ describe('JobHistoryActionsMenu', () => {
|
||||
})
|
||||
|
||||
it('opens docked job history sidebar when enabling from the menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockGetSetting.mockImplementation((key: string) => {
|
||||
if (key === 'Comfy.Queue.QPOV2') return false
|
||||
if (key === 'Comfy.Queue.ShowRunProgressBar') return true
|
||||
return undefined
|
||||
})
|
||||
const wrapper = mountMenu()
|
||||
|
||||
const dockedJobHistoryButton = wrapper.get(
|
||||
'[data-testid="docked-job-history-action"]'
|
||||
)
|
||||
await dockedJobHistoryButton.trigger('click')
|
||||
renderMenu()
|
||||
|
||||
await user.click(screen.getByTestId('docked-job-history-action'))
|
||||
|
||||
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetSetting).toHaveBeenCalledTimes(1)
|
||||
@@ -110,14 +110,20 @@ describe('JobHistoryActionsMenu', () => {
|
||||
})
|
||||
|
||||
it('emits clear history from the menu', async () => {
|
||||
const wrapper = mountMenu()
|
||||
const user = userEvent.setup()
|
||||
const clearHistorySpy = vi.fn()
|
||||
|
||||
const clearHistoryButton = wrapper.get(
|
||||
'[data-testid="clear-history-action"]'
|
||||
)
|
||||
await clearHistoryButton.trigger('click')
|
||||
render(JobHistoryActionsMenu, {
|
||||
props: { onClearHistory: clearHistorySpy },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
await user.click(screen.getByTestId('clear-history-action'))
|
||||
|
||||
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
|
||||
expect(clearHistorySpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -32,30 +33,20 @@ const tooltipDirectiveStub = {
|
||||
updated: vi.fn()
|
||||
}
|
||||
|
||||
const SELECTORS = {
|
||||
interruptAllButton: 'button[aria-label="Interrupt all running jobs"]',
|
||||
clearQueuedButton: 'button[aria-label="Clear queued"]',
|
||||
summaryRow: '.flex.items-center.gap-2',
|
||||
currentNodeRow: '.flex.items-center.gap-1.text-text-secondary'
|
||||
const defaultProps = {
|
||||
totalProgressStyle: { width: '65%' },
|
||||
currentNodeProgressStyle: { width: '40%' },
|
||||
totalPercentFormatted: '65%',
|
||||
currentNodePercentFormatted: '40%',
|
||||
currentNodeName: 'Sampler',
|
||||
runningCount: 1,
|
||||
queuedCount: 2,
|
||||
bottomRowClass: 'flex custom-bottom-row'
|
||||
}
|
||||
|
||||
const COPY = {
|
||||
viewAllJobs: 'View all jobs'
|
||||
}
|
||||
|
||||
const mountComponent = (props: Record<string, unknown> = {}) =>
|
||||
mount(QueueOverlayActive, {
|
||||
props: {
|
||||
totalProgressStyle: { width: '65%' },
|
||||
currentNodeProgressStyle: { width: '40%' },
|
||||
totalPercentFormatted: '65%',
|
||||
currentNodePercentFormatted: '40%',
|
||||
currentNodeName: 'Sampler',
|
||||
runningCount: 1,
|
||||
queuedCount: 2,
|
||||
bottomRowClass: 'flex custom-bottom-row',
|
||||
...props
|
||||
},
|
||||
const renderComponent = (props: Record<string, unknown> = {}) =>
|
||||
render(QueueOverlayActive, {
|
||||
props: { ...defaultProps, ...props },
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: {
|
||||
@@ -66,58 +57,65 @@ const mountComponent = (props: Record<string, unknown> = {}) =>
|
||||
|
||||
describe('QueueOverlayActive', () => {
|
||||
it('renders progress metrics and emits actions when buttons clicked', async () => {
|
||||
const wrapper = mountComponent({ runningCount: 2, queuedCount: 3 })
|
||||
const user = userEvent.setup()
|
||||
const interruptAllSpy = vi.fn()
|
||||
const clearQueuedSpy = vi.fn()
|
||||
const viewAllJobsSpy = vi.fn()
|
||||
|
||||
const progressBars = wrapper.findAll('.absolute.inset-0')
|
||||
expect(progressBars[0].attributes('style')).toContain('width: 65%')
|
||||
expect(progressBars[1].attributes('style')).toContain('width: 40%')
|
||||
const { container } = renderComponent({
|
||||
runningCount: 2,
|
||||
queuedCount: 3,
|
||||
onInterruptAll: interruptAllSpy,
|
||||
onClearQueued: clearQueuedSpy,
|
||||
onViewAllJobs: viewAllJobsSpy
|
||||
})
|
||||
|
||||
const content = wrapper.text().replace(/\s+/g, ' ')
|
||||
expect(content).toContain('Total: 65%')
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const progressBars = container.querySelectorAll('.absolute.inset-0')
|
||||
expect(progressBars[0]).toHaveStyle({ width: '65%' })
|
||||
expect(progressBars[1]).toHaveStyle({ width: '40%' })
|
||||
|
||||
const [runningSection, queuedSection] = wrapper.findAll(
|
||||
SELECTORS.summaryRow
|
||||
expect(screen.getByText('65%')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
expect(screen.getByText('running')).toBeInTheDocument()
|
||||
expect(screen.getByText('3')).toBeInTheDocument()
|
||||
expect(screen.getByText('queued')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByText('Current node:')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sampler')).toBeInTheDocument()
|
||||
expect(screen.getByText('40%')).toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Interrupt all running jobs' })
|
||||
)
|
||||
expect(runningSection.text()).toContain('2')
|
||||
expect(runningSection.text()).toContain('running')
|
||||
expect(queuedSection.text()).toContain('3')
|
||||
expect(queuedSection.text()).toContain('queued')
|
||||
expect(interruptAllSpy).toHaveBeenCalledOnce()
|
||||
|
||||
const currentNodeSection = wrapper.find(SELECTORS.currentNodeRow)
|
||||
expect(currentNodeSection.text()).toContain('Current node:')
|
||||
expect(currentNodeSection.text()).toContain('Sampler')
|
||||
expect(currentNodeSection.text()).toContain('40%')
|
||||
await user.click(screen.getByRole('button', { name: 'Clear queued' }))
|
||||
expect(clearQueuedSpy).toHaveBeenCalledOnce()
|
||||
|
||||
const interruptButton = wrapper.get(SELECTORS.interruptAllButton)
|
||||
await interruptButton.trigger('click')
|
||||
expect(wrapper.emitted('interruptAll')).toHaveLength(1)
|
||||
await user.click(screen.getByRole('button', { name: 'View all jobs' }))
|
||||
expect(viewAllJobsSpy).toHaveBeenCalledOnce()
|
||||
|
||||
const clearQueuedButton = wrapper.get(SELECTORS.clearQueuedButton)
|
||||
await clearQueuedButton.trigger('click')
|
||||
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
|
||||
|
||||
const buttons = wrapper.findAll('button')
|
||||
const viewAllButton = buttons.find((btn) =>
|
||||
btn.text().includes(COPY.viewAllJobs)
|
||||
)
|
||||
expect(viewAllButton).toBeDefined()
|
||||
await viewAllButton!.trigger('click')
|
||||
expect(wrapper.emitted('viewAllJobs')).toHaveLength(1)
|
||||
|
||||
expect(wrapper.find('.custom-bottom-row').exists()).toBe(true)
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
expect(container.querySelector('.custom-bottom-row')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('hides action buttons when counts are zero', () => {
|
||||
const wrapper = mountComponent({ runningCount: 0, queuedCount: 0 })
|
||||
renderComponent({ runningCount: 0, queuedCount: 0 })
|
||||
|
||||
expect(wrapper.find(SELECTORS.interruptAllButton).exists()).toBe(false)
|
||||
expect(wrapper.find(SELECTORS.clearQueuedButton).exists()).toBe(false)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Interrupt all running jobs' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Clear queued' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('builds tooltip configs with translated strings', () => {
|
||||
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
|
||||
|
||||
mountComponent()
|
||||
renderComponent()
|
||||
|
||||
expect(spy).toHaveBeenCalledWith('Cancel job')
|
||||
expect(spy).toHaveBeenCalledWith('Clear queue')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h } from 'vue'
|
||||
|
||||
@@ -55,8 +56,8 @@ const tooltipDirectiveStub = {
|
||||
updated: vi.fn()
|
||||
}
|
||||
|
||||
const mountHeader = (props = {}) =>
|
||||
mount(QueueOverlayHeader, {
|
||||
const renderHeader = (props = {}) =>
|
||||
render(QueueOverlayHeader, {
|
||||
props: {
|
||||
headerTitle: 'Job queue',
|
||||
queuedCount: 3,
|
||||
@@ -81,54 +82,53 @@ describe('QueueOverlayHeader', () => {
|
||||
})
|
||||
|
||||
it('renders header title', () => {
|
||||
const wrapper = mountHeader()
|
||||
expect(wrapper.text()).toContain('Job queue')
|
||||
renderHeader()
|
||||
expect(screen.getByText('Job queue')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows clear queue text and emits clear queued', async () => {
|
||||
const wrapper = mountHeader({ queuedCount: 4 })
|
||||
const user = userEvent.setup()
|
||||
const clearQueuedSpy = vi.fn()
|
||||
|
||||
expect(wrapper.text()).toContain('Clear queue')
|
||||
expect(wrapper.text()).not.toContain('4 queued')
|
||||
renderHeader({ queuedCount: 4, onClearQueued: clearQueuedSpy })
|
||||
|
||||
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
|
||||
await clearQueuedButton.trigger('click')
|
||||
expect(wrapper.emitted('clearQueued')).toHaveLength(1)
|
||||
expect(screen.getByText('Clear queue')).toBeInTheDocument()
|
||||
expect(screen.queryByText('4 queued')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Clear queued' }))
|
||||
expect(clearQueuedSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables clear queued button when queued count is zero', () => {
|
||||
const wrapper = mountHeader({ queuedCount: 0 })
|
||||
const clearQueuedButton = wrapper.get('button[aria-label="Clear queued"]')
|
||||
renderHeader({ queuedCount: 0 })
|
||||
|
||||
expect(clearQueuedButton.attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.text()).toContain('Clear queue')
|
||||
expect(screen.getByRole('button', { name: 'Clear queued' })).toBeDisabled()
|
||||
expect(screen.getByText('Clear queue')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits clear history from the menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
const spy = vi.spyOn(tooltipConfig, 'buildTooltipConfig')
|
||||
const clearHistorySpy = vi.fn()
|
||||
|
||||
const wrapper = mountHeader()
|
||||
renderHeader({ onClearHistory: clearHistorySpy })
|
||||
|
||||
expect(wrapper.find('button[aria-label="More options"]').exists()).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'More options' })
|
||||
).toBeInTheDocument()
|
||||
expect(spy).toHaveBeenCalledWith('More')
|
||||
|
||||
const clearHistoryButton = wrapper.get(
|
||||
'[data-testid="clear-history-action"]'
|
||||
)
|
||||
await clearHistoryButton.trigger('click')
|
||||
await user.click(screen.getByTestId('clear-history-action'))
|
||||
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.emitted('clearHistory')).toHaveLength(1)
|
||||
expect(clearHistorySpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('opens floating queue progress overlay when disabling from the menu', async () => {
|
||||
const wrapper = mountHeader()
|
||||
const user = userEvent.setup()
|
||||
|
||||
const dockedJobHistoryButton = wrapper.get(
|
||||
'[data-testid="docked-job-history-action"]'
|
||||
)
|
||||
await dockedJobHistoryButton.trigger('click')
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByTestId('docked-job-history-action'))
|
||||
|
||||
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetMany).toHaveBeenCalledTimes(1)
|
||||
@@ -141,15 +141,14 @@ describe('QueueOverlayHeader', () => {
|
||||
})
|
||||
|
||||
it('opens docked job history sidebar when enabling from the menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockGetSetting.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? false : undefined
|
||||
)
|
||||
const wrapper = mountHeader()
|
||||
|
||||
const dockedJobHistoryButton = wrapper.get(
|
||||
'[data-testid="docked-job-history-action"]'
|
||||
)
|
||||
await dockedJobHistoryButton.trigger('click')
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByTestId('docked-job-history-action'))
|
||||
|
||||
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetSetting).toHaveBeenCalledTimes(1)
|
||||
@@ -159,16 +158,15 @@ describe('QueueOverlayHeader', () => {
|
||||
})
|
||||
|
||||
it('keeps docked target open even when enabling persistence fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockGetSetting.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Queue.QPOV2' ? false : undefined
|
||||
)
|
||||
mockSetSetting.mockRejectedValueOnce(new Error('persistence failed'))
|
||||
const wrapper = mountHeader()
|
||||
|
||||
const dockedJobHistoryButton = wrapper.get(
|
||||
'[data-testid="docked-job-history-action"]'
|
||||
)
|
||||
await dockedJobHistoryButton.trigger('click')
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByTestId('docked-job-history-action'))
|
||||
|
||||
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetSetting).toHaveBeenCalledWith('Comfy.Queue.QPOV2', true)
|
||||
@@ -176,13 +174,12 @@ describe('QueueOverlayHeader', () => {
|
||||
})
|
||||
|
||||
it('closes the menu when disabling persistence fails', async () => {
|
||||
const user = userEvent.setup()
|
||||
mockSetMany.mockRejectedValueOnce(new Error('persistence failed'))
|
||||
const wrapper = mountHeader()
|
||||
|
||||
const dockedJobHistoryButton = wrapper.get(
|
||||
'[data-testid="docked-job-history-action"]'
|
||||
)
|
||||
await dockedJobHistoryButton.trigger('click')
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByTestId('docked-job-history-action'))
|
||||
|
||||
expect(popoverCloseSpy).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetMany).toHaveBeenCalledWith({
|
||||
@@ -192,12 +189,11 @@ describe('QueueOverlayHeader', () => {
|
||||
})
|
||||
|
||||
it('toggles show run progress bar setting from the menu', async () => {
|
||||
const wrapper = mountHeader()
|
||||
const user = userEvent.setup()
|
||||
|
||||
const showRunProgressBarButton = wrapper.get(
|
||||
'[data-testid="show-run-progress-bar-action"]'
|
||||
)
|
||||
await showRunProgressBarButton.trigger('click')
|
||||
renderHeader()
|
||||
|
||||
await user.click(screen.getByTestId('show-run-progress-bar-action'))
|
||||
|
||||
expect(mockSetSetting).toHaveBeenCalledTimes(1)
|
||||
expect(mockSetSetting).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
import { defineComponent, nextTick, ref } from 'vue'
|
||||
|
||||
import JobContextMenu from '@/components/queue/job/JobContextMenu.vue'
|
||||
import type { MenuEntry } from '@/composables/queue/useJobMenu'
|
||||
@@ -28,7 +29,6 @@ const popoverStub = defineComponent({
|
||||
this.hide()
|
||||
return
|
||||
}
|
||||
|
||||
this.show(event, target)
|
||||
},
|
||||
show(event: Event, target?: EventTarget | null) {
|
||||
@@ -43,7 +43,7 @@ const popoverStub = defineComponent({
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div v-if="visible" ref="container" class="popover-stub">
|
||||
<div v-if="visible" ref="container" data-testid="popover">
|
||||
<slot />
|
||||
</div>
|
||||
`
|
||||
@@ -51,21 +51,18 @@ const popoverStub = defineComponent({
|
||||
|
||||
const buttonStub = {
|
||||
props: {
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
disabled: { type: Boolean, default: false },
|
||||
ariaLabel: { type: String, default: undefined }
|
||||
},
|
||||
template: `
|
||||
<div
|
||||
class="button-stub"
|
||||
:data-disabled="String(disabled)"
|
||||
>
|
||||
<button :disabled="disabled" :aria-label="ariaLabel">
|
||||
<slot />
|
||||
</div>
|
||||
</button>
|
||||
`
|
||||
}
|
||||
|
||||
type MenuHandle = { open: (e: Event) => Promise<void>; hide: () => void }
|
||||
|
||||
const createEntries = (): MenuEntry[] => [
|
||||
{ key: 'enabled', label: 'Enabled action', onClick: vi.fn() },
|
||||
{
|
||||
@@ -77,17 +74,6 @@ const createEntries = (): MenuEntry[] => [
|
||||
{ kind: 'divider', key: 'divider-1' }
|
||||
]
|
||||
|
||||
const mountComponent = (entries: MenuEntry[]) =>
|
||||
mount(JobContextMenu, {
|
||||
props: { entries },
|
||||
global: {
|
||||
stubs: {
|
||||
Popover: popoverStub,
|
||||
Button: buttonStub
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const createTriggerEvent = (type: string, currentTarget: EventTarget) =>
|
||||
({
|
||||
type,
|
||||
@@ -95,13 +81,37 @@ const createTriggerEvent = (type: string, currentTarget: EventTarget) =>
|
||||
target: currentTarget
|
||||
}) as Event
|
||||
|
||||
const openMenu = async (
|
||||
wrapper: ReturnType<typeof mountComponent>,
|
||||
function renderMenu(entries: MenuEntry[], onAction?: ReturnType<typeof vi.fn>) {
|
||||
const menuRef = ref<MenuHandle | null>(null)
|
||||
|
||||
const Wrapper = {
|
||||
components: { JobContextMenu },
|
||||
setup() {
|
||||
return { menuRef, entries }
|
||||
},
|
||||
template:
|
||||
'<JobContextMenu ref="menuRef" :entries="entries" @action="$emit(\'action\', $event)" />'
|
||||
}
|
||||
|
||||
const user = userEvent.setup()
|
||||
const actionSpy = onAction ?? vi.fn()
|
||||
const { unmount } = render(Wrapper, {
|
||||
props: { onAction: actionSpy },
|
||||
global: {
|
||||
stubs: { Popover: popoverStub, Button: buttonStub }
|
||||
}
|
||||
})
|
||||
|
||||
return { user, menuRef, onAction: actionSpy, unmount }
|
||||
}
|
||||
|
||||
async function openMenu(
|
||||
menuRef: ReturnType<typeof ref<MenuHandle | null>>,
|
||||
type: string = 'click'
|
||||
) => {
|
||||
) {
|
||||
const trigger = document.createElement('button')
|
||||
document.body.append(trigger)
|
||||
await wrapper.vm.open(createTriggerEvent(type, trigger))
|
||||
await menuRef.value!.open(createTriggerEvent(type, trigger))
|
||||
await nextTick()
|
||||
return trigger
|
||||
}
|
||||
@@ -112,31 +122,33 @@ afterEach(() => {
|
||||
|
||||
describe('JobContextMenu', () => {
|
||||
it('passes disabled state to action buttons', async () => {
|
||||
const wrapper = mountComponent(createEntries())
|
||||
await openMenu(wrapper)
|
||||
const { menuRef, unmount } = renderMenu(createEntries())
|
||||
await openMenu(menuRef)
|
||||
|
||||
const buttons = wrapper.findAll('.button-stub')
|
||||
expect(buttons).toHaveLength(2)
|
||||
expect(buttons[0].attributes('data-disabled')).toBe('false')
|
||||
expect(buttons[1].attributes('data-disabled')).toBe('true')
|
||||
const enabledBtn = screen.getByRole('button', { name: 'Enabled action' })
|
||||
const disabledBtn = screen.getByRole('button', {
|
||||
name: 'Disabled action'
|
||||
})
|
||||
expect(enabledBtn).not.toBeDisabled()
|
||||
expect(disabledBtn).toBeDisabled()
|
||||
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('emits action for enabled entries', async () => {
|
||||
const entries = createEntries()
|
||||
const wrapper = mountComponent(entries)
|
||||
await openMenu(wrapper)
|
||||
const { user, menuRef, onAction, unmount } = renderMenu(entries)
|
||||
await openMenu(menuRef)
|
||||
|
||||
await wrapper.findAll('.button-stub')[0].trigger('click')
|
||||
await user.click(screen.getByRole('button', { name: 'Enabled action' }))
|
||||
|
||||
expect(wrapper.emitted('action')).toEqual([[entries[0]]])
|
||||
expect(onAction).toHaveBeenCalledWith(entries[0])
|
||||
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('does not emit action for disabled entries', async () => {
|
||||
const wrapper = mountComponent([
|
||||
const { user, menuRef, onAction, unmount } = renderMenu([
|
||||
{
|
||||
key: 'disabled',
|
||||
label: 'Disabled action',
|
||||
@@ -144,52 +156,54 @@ describe('JobContextMenu', () => {
|
||||
onClick: vi.fn()
|
||||
}
|
||||
])
|
||||
await openMenu(wrapper)
|
||||
await openMenu(menuRef)
|
||||
|
||||
await wrapper.get('.button-stub').trigger('click')
|
||||
await user.click(screen.getByRole('button', { name: 'Disabled action' }))
|
||||
|
||||
expect(wrapper.emitted('action')).toBeUndefined()
|
||||
expect(onAction).not.toHaveBeenCalled()
|
||||
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('hides on pointerdown outside the popover', async () => {
|
||||
const wrapper = mountComponent(createEntries())
|
||||
const { menuRef, unmount } = renderMenu(createEntries())
|
||||
|
||||
const trigger = document.createElement('button')
|
||||
const outside = document.createElement('div')
|
||||
document.body.append(trigger, outside)
|
||||
|
||||
await wrapper.vm.open(createTriggerEvent('contextmenu', trigger))
|
||||
await menuRef.value!.open(createTriggerEvent('contextmenu', trigger))
|
||||
await nextTick()
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(true)
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
|
||||
outside.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.pointerDown(outside)
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(false)
|
||||
expect(screen.queryByTestId('popover')).not.toBeInTheDocument()
|
||||
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
})
|
||||
|
||||
it('keeps the menu open through trigger pointerdown and closes on same trigger click', async () => {
|
||||
const wrapper = mountComponent(createEntries())
|
||||
const { menuRef, unmount } = renderMenu(createEntries())
|
||||
|
||||
const trigger = document.createElement('button')
|
||||
document.body.append(trigger)
|
||||
|
||||
await wrapper.vm.open(createTriggerEvent('click', trigger))
|
||||
await menuRef.value!.open(createTriggerEvent('click', trigger))
|
||||
await nextTick()
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(true)
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
|
||||
trigger.dispatchEvent(new Event('pointerdown', { bubbles: true }))
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.pointerDown(trigger)
|
||||
await nextTick()
|
||||
expect(screen.getByTestId('popover')).toBeInTheDocument()
|
||||
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(true)
|
||||
|
||||
await wrapper.vm.open(createTriggerEvent('click', trigger))
|
||||
await menuRef.value!.open(createTriggerEvent('click', trigger))
|
||||
await nextTick()
|
||||
expect(screen.queryByTestId('popover')).not.toBeInTheDocument()
|
||||
|
||||
expect(wrapper.find('.popover-stub').exists()).toBe(false)
|
||||
|
||||
wrapper.unmount()
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { defineComponent } from 'vue'
|
||||
@@ -56,12 +57,16 @@ const i18n = createI18n({
|
||||
|
||||
describe('JobFiltersBar', () => {
|
||||
it('emits showAssets when the assets icon button is clicked', async () => {
|
||||
const wrapper = mount(JobFiltersBar, {
|
||||
const user = userEvent.setup()
|
||||
const showAssetsSpy = vi.fn()
|
||||
|
||||
render(JobFiltersBar, {
|
||||
props: {
|
||||
selectedJobTab: 'All',
|
||||
selectedWorkflowFilter: 'all',
|
||||
selectedSortMode: 'mostRecent',
|
||||
hasFailedJobs: false
|
||||
hasFailedJobs: false,
|
||||
onShowAssets: showAssetsSpy
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
@@ -69,16 +74,13 @@ describe('JobFiltersBar', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const showAssetsButton = wrapper.get(
|
||||
'button[aria-label="Show assets panel"]'
|
||||
)
|
||||
await showAssetsButton.trigger('click')
|
||||
await user.click(screen.getByRole('button', { name: 'Show assets panel' }))
|
||||
|
||||
expect(wrapper.emitted('showAssets')).toHaveLength(1)
|
||||
expect(showAssetsSpy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides the assets icon button when hideShowAssetsAction is true', () => {
|
||||
const wrapper = mount(JobFiltersBar, {
|
||||
render(JobFiltersBar, {
|
||||
props: {
|
||||
selectedJobTab: 'All',
|
||||
selectedWorkflowFilter: 'all',
|
||||
@@ -93,7 +95,7 @@ describe('JobFiltersBar', () => {
|
||||
})
|
||||
|
||||
expect(
|
||||
wrapper.find('button[aria-label="Show assets panel"]').exists()
|
||||
).toBe(false)
|
||||
screen.queryByRole('button', { name: 'Show assets panel' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { fireEvent, render, screen } from '@testing-library/vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, nextTick } from 'vue'
|
||||
|
||||
@@ -23,7 +23,12 @@ const QueueJobItemStub = defineComponent({
|
||||
runningNodeName: { type: String, default: undefined },
|
||||
activeDetailsId: { type: String, default: null }
|
||||
},
|
||||
template: '<div class="queue-job-item-stub"></div>'
|
||||
template: `
|
||||
<div class="queue-job-item-stub" :data-job-id="jobId" :data-active-details-id="activeDetailsId">
|
||||
<div :data-testid="'enter-' + jobId" @click="$emit('details-enter', jobId)" />
|
||||
<div :data-testid="'leave-' + jobId" @click="$emit('details-leave', jobId)" />
|
||||
</div>
|
||||
`
|
||||
})
|
||||
|
||||
const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => {
|
||||
@@ -46,8 +51,16 @@ const createJobItem = (overrides: Partial<JobListItem> = {}): JobListItem => {
|
||||
}
|
||||
}
|
||||
|
||||
const mountComponent = (groups: JobGroup[]) =>
|
||||
mount(JobGroupsList, {
|
||||
function getActiveDetailsId(container: Element, jobId: string): string | null {
|
||||
return (
|
||||
container
|
||||
.querySelector(`[data-job-id="${jobId}"]`)
|
||||
?.getAttribute('data-active-details-id') ?? null
|
||||
)
|
||||
}
|
||||
|
||||
const renderComponent = (groups: JobGroup[]) =>
|
||||
render(JobGroupsList, {
|
||||
props: { displayedJobGroups: groups },
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -64,64 +77,60 @@ describe('JobGroupsList hover behavior', () => {
|
||||
it('delays showing and hiding details while hovering over job rows', async () => {
|
||||
vi.useFakeTimers()
|
||||
const job = createJobItem({ id: 'job-d' })
|
||||
const wrapper = mountComponent([
|
||||
const { container } = renderComponent([
|
||||
{ key: 'today', label: 'Today', items: [job] }
|
||||
])
|
||||
const jobItem = wrapper.findComponent(QueueJobItemStub)
|
||||
|
||||
jobItem.vm.$emit('details-enter', job.id)
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('enter-job-d'))
|
||||
vi.advanceTimersByTime(199)
|
||||
await nextTick()
|
||||
expect(
|
||||
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
|
||||
).toBeNull()
|
||||
expect(getActiveDetailsId(container, 'job-d')).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await nextTick()
|
||||
expect(
|
||||
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
|
||||
).toBe(job.id)
|
||||
expect(getActiveDetailsId(container, 'job-d')).toBe(job.id)
|
||||
|
||||
wrapper.findComponent(QueueJobItemStub).vm.$emit('details-leave', job.id)
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('leave-job-d'))
|
||||
vi.advanceTimersByTime(149)
|
||||
await nextTick()
|
||||
expect(
|
||||
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
|
||||
).toBe(job.id)
|
||||
expect(getActiveDetailsId(container, 'job-d')).toBe(job.id)
|
||||
|
||||
vi.advanceTimersByTime(1)
|
||||
await nextTick()
|
||||
expect(
|
||||
wrapper.findComponent(QueueJobItemStub).props('activeDetailsId')
|
||||
).toBeNull()
|
||||
expect(getActiveDetailsId(container, 'job-d')).toBeNull()
|
||||
})
|
||||
|
||||
it('clears the previous popover when hovering a new row briefly and leaving', async () => {
|
||||
vi.useFakeTimers()
|
||||
const firstJob = createJobItem({ id: 'job-1', title: 'First job' })
|
||||
const secondJob = createJobItem({ id: 'job-2', title: 'Second job' })
|
||||
const wrapper = mountComponent([
|
||||
const { container } = renderComponent([
|
||||
{ key: 'today', label: 'Today', items: [firstJob, secondJob] }
|
||||
])
|
||||
const jobItems = wrapper.findAllComponents(QueueJobItemStub)
|
||||
|
||||
jobItems[0].vm.$emit('details-enter', firstJob.id)
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('enter-job-1'))
|
||||
vi.advanceTimersByTime(200)
|
||||
await nextTick()
|
||||
expect(jobItems[0].props('activeDetailsId')).toBe(firstJob.id)
|
||||
expect(getActiveDetailsId(container, 'job-1')).toBe(firstJob.id)
|
||||
|
||||
jobItems[0].vm.$emit('details-leave', firstJob.id)
|
||||
jobItems[1].vm.$emit('details-enter', secondJob.id)
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('leave-job-1'))
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('enter-job-2'))
|
||||
vi.advanceTimersByTime(100)
|
||||
await nextTick()
|
||||
jobItems[1].vm.$emit('details-leave', secondJob.id)
|
||||
// eslint-disable-next-line testing-library/prefer-user-event
|
||||
await fireEvent.click(screen.getByTestId('leave-job-2'))
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
expect(jobItems[0].props('activeDetailsId')).toBeNull()
|
||||
expect(getActiveDetailsId(container, 'job-1')).toBeNull()
|
||||
|
||||
vi.advanceTimersByTime(50)
|
||||
await nextTick()
|
||||
expect(jobItems[1].props('activeDetailsId')).toBeNull()
|
||||
expect(getActiveDetailsId(container, 'job-2')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
||||
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
|
||||
@@ -41,6 +42,7 @@ import TabErrors from './errors/TabErrors.vue'
|
||||
const canvasStore = useCanvasStore()
|
||||
const executionErrorStore = useExecutionErrorStore()
|
||||
const missingModelStore = useMissingModelStore()
|
||||
const missingMediaStore = useMissingMediaStore()
|
||||
const missingNodesErrorStore = useMissingNodesErrorStore()
|
||||
const rightSidePanelStore = useRightSidePanelStore()
|
||||
const settingStore = useSettingStore()
|
||||
@@ -58,6 +60,7 @@ const activeMissingNodeGraphIds = computed<Set<string>>(() => {
|
||||
})
|
||||
|
||||
const { activeMissingModelGraphIds } = storeToRefs(missingModelStore)
|
||||
const { activeMissingMediaGraphIds } = storeToRefs(missingMediaStore)
|
||||
|
||||
const { findParentGroup } = useGraphHierarchy()
|
||||
|
||||
@@ -142,13 +145,22 @@ const hasMissingModelSelected = computed(
|
||||
)
|
||||
)
|
||||
|
||||
const hasMissingMediaSelected = computed(
|
||||
() =>
|
||||
hasSelection.value &&
|
||||
selectedNodes.value.some((node) =>
|
||||
activeMissingMediaGraphIds.value.has(String(node.id))
|
||||
)
|
||||
)
|
||||
|
||||
const hasRelevantErrors = computed(() => {
|
||||
if (!hasSelection.value) return hasAnyError.value
|
||||
return (
|
||||
hasDirectNodeError.value ||
|
||||
hasContainerInternalError.value ||
|
||||
hasMissingNodeSelected.value ||
|
||||
hasMissingModelSelected.value
|
||||
hasMissingModelSelected.value ||
|
||||
hasMissingMediaSelected.value
|
||||
)
|
||||
})
|
||||
|
||||
@@ -287,11 +299,14 @@ function handleTitleCancel() {
|
||||
@cancel="handleTitleCancel"
|
||||
@click="isEditing = true"
|
||||
/>
|
||||
<i
|
||||
<button
|
||||
v-if="!isEditing"
|
||||
class="relative top-[2px] ml-2 icon-[lucide--pencil] size-4 shrink-0 cursor-pointer content-center text-muted-foreground hover:text-base-foreground"
|
||||
:aria-label="t('rightSidePanel.editTitle')"
|
||||
class="relative top-[2px] ml-2 size-4 shrink-0 cursor-pointer content-center text-muted-foreground hover:text-base-foreground"
|
||||
@click="isEditing = true"
|
||||
/>
|
||||
>
|
||||
<i aria-hidden="true" class="icon-[lucide--pencil] size-4" />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ panelTitle }}
|
||||
@@ -304,6 +319,7 @@ function handleTitleCancel() {
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
data-testid="subgraph-editor-toggle"
|
||||
:aria-label="t('rightSidePanel.editSubgraph')"
|
||||
:class="cn(isEditingSubgraph && 'bg-secondary-background-selected')"
|
||||
@click="
|
||||
rightSidePanelStore.openPanel(
|
||||
@@ -338,6 +354,7 @@ function handleTitleCancel() {
|
||||
:key="tab.value"
|
||||
class="px-2 py-1 font-inter text-sm transition-all active:scale-95"
|
||||
:value="tab.value"
|
||||
:data-testid="`panel-tab-${tab.value}`"
|
||||
>
|
||||
{{ tab.label() }}
|
||||
<i
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen, waitFor } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
@@ -89,16 +90,21 @@ describe('ErrorNodeCard.vue', () => {
|
||||
})
|
||||
})
|
||||
|
||||
function mountCard(card: ErrorCardData) {
|
||||
return mount(ErrorNodeCard, {
|
||||
props: { card },
|
||||
function renderCard(
|
||||
card: ErrorCardData,
|
||||
options: { initialState?: Record<string, unknown> } = {}
|
||||
) {
|
||||
const user = userEvent.setup()
|
||||
const onCopyToClipboard = vi.fn()
|
||||
render(ErrorNodeCard, {
|
||||
props: { card, onCopyToClipboard },
|
||||
global: {
|
||||
plugins: [
|
||||
PrimeVue,
|
||||
i18n,
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
initialState: {
|
||||
initialState: options.initialState ?? {
|
||||
systemStats: {
|
||||
systemStats: {
|
||||
system: {
|
||||
@@ -132,6 +138,7 @@ describe('ErrorNodeCard.vue', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
return { user, onCopyToClipboard }
|
||||
}
|
||||
|
||||
let cardIdCounter = 0
|
||||
@@ -173,76 +180,82 @@ describe('ErrorNodeCard.vue', () => {
|
||||
'# ComfyUI Error Report\n## System Information\n- OS: Linux'
|
||||
mockGenerateErrorReport.mockReturnValue(reportText)
|
||||
|
||||
const wrapper = mountCard(makeRuntimeErrorCard())
|
||||
await flushPromises()
|
||||
renderCard(makeRuntimeErrorCard())
|
||||
|
||||
expect(wrapper.text()).toContain('ComfyUI Error Report')
|
||||
expect(wrapper.text()).toContain('System Information')
|
||||
expect(wrapper.text()).toContain('OS: Linux')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText(/System Information/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/OS: Linux/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not generate report for non-runtime errors', async () => {
|
||||
mountCard(makeValidationErrorCard())
|
||||
await flushPromises()
|
||||
renderCard(makeValidationErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Input: text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(mockGetLogs).not.toHaveBeenCalled()
|
||||
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('displays original details for non-runtime errors', async () => {
|
||||
const wrapper = mountCard(makeValidationErrorCard())
|
||||
await flushPromises()
|
||||
renderCard(makeValidationErrorCard())
|
||||
|
||||
expect(wrapper.text()).toContain('Input: text')
|
||||
expect(wrapper.text()).not.toContain('ComfyUI Error Report')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Input: text')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('copies enriched report when copy button is clicked for runtime error', async () => {
|
||||
const reportText = '# Full Report Content'
|
||||
mockGenerateErrorReport.mockReturnValue(reportText)
|
||||
|
||||
const wrapper = mountCard(makeRuntimeErrorCard())
|
||||
await flushPromises()
|
||||
const { user, onCopyToClipboard } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
const copyButton = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text().includes('Copy'))!
|
||||
expect(copyButton.exists()).toBe(true)
|
||||
await copyButton.trigger('click')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Full Report Content/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const emitted = wrapper.emitted('copyToClipboard')
|
||||
expect(emitted).toHaveLength(1)
|
||||
expect(emitted![0][0]).toContain('# Full Report Content')
|
||||
await user.click(screen.getByRole('button', { name: /Copy/ }))
|
||||
|
||||
expect(onCopyToClipboard).toHaveBeenCalledTimes(1)
|
||||
expect(onCopyToClipboard.mock.calls[0][0]).toContain(
|
||||
'# Full Report Content'
|
||||
)
|
||||
})
|
||||
|
||||
it('copies original details when copy button is clicked for validation error', async () => {
|
||||
const wrapper = mountCard(makeValidationErrorCard())
|
||||
await flushPromises()
|
||||
const { user, onCopyToClipboard } = renderCard(makeValidationErrorCard())
|
||||
|
||||
const copyButton = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text().includes('Copy'))!
|
||||
await copyButton.trigger('click')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Input: text')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
const emitted = wrapper.emitted('copyToClipboard')
|
||||
expect(emitted).toHaveLength(1)
|
||||
expect(emitted![0][0]).toBe('Required input is missing\n\nInput: text')
|
||||
await user.click(screen.getByRole('button', { name: /Copy/ }))
|
||||
|
||||
expect(onCopyToClipboard).toHaveBeenCalledTimes(1)
|
||||
expect(onCopyToClipboard.mock.calls[0][0]).toBe(
|
||||
'Required input is missing\n\nInput: text'
|
||||
)
|
||||
})
|
||||
|
||||
it('generates report with fallback logs when getLogs fails', async () => {
|
||||
mockGetLogs.mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const wrapper = mountCard(makeRuntimeErrorCard())
|
||||
await flushPromises()
|
||||
renderCard(makeRuntimeErrorCard())
|
||||
|
||||
// Report is still generated with fallback log message
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
await waitFor(() => {
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
})
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
serverLogs: 'Failed to retrieve server logs'
|
||||
})
|
||||
)
|
||||
expect(wrapper.text()).toContain('ComfyUI Error Report')
|
||||
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to original details when generateErrorReport throws', async () => {
|
||||
@@ -250,24 +263,25 @@ describe('ErrorNodeCard.vue', () => {
|
||||
throw new Error('Serialization error')
|
||||
})
|
||||
|
||||
const wrapper = mountCard(makeRuntimeErrorCard())
|
||||
await flushPromises()
|
||||
renderCard(makeRuntimeErrorCard())
|
||||
|
||||
expect(wrapper.text()).toContain('Traceback line 1')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('opens GitHub issues search when Find Issue button is clicked', async () => {
|
||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
|
||||
const wrapper = mountCard(makeRuntimeErrorCard())
|
||||
await flushPromises()
|
||||
const { user } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
const findIssuesButton = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text().includes('Find on GitHub'))!
|
||||
expect(findIssuesButton.exists()).toBe(true)
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Find on GitHub/ })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await findIssuesButton.trigger('click')
|
||||
await user.click(screen.getByRole('button', { name: /Find on GitHub/ }))
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('github.com/comfyanonymous/ComfyUI/issues?q='),
|
||||
@@ -284,15 +298,15 @@ describe('ErrorNodeCard.vue', () => {
|
||||
})
|
||||
|
||||
it('executes ContactSupport command when Get Help button is clicked', async () => {
|
||||
const wrapper = mountCard(makeRuntimeErrorCard())
|
||||
await flushPromises()
|
||||
const { user } = renderCard(makeRuntimeErrorCard())
|
||||
|
||||
const getHelpButton = wrapper
|
||||
.findAll('button')
|
||||
.find((btn) => btn.text().includes('Get Help'))!
|
||||
expect(getHelpButton.exists()).toBe(true)
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Get Help/ })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await getHelpButton.trigger('click')
|
||||
await user.click(screen.getByRole('button', { name: /Get Help/ }))
|
||||
|
||||
expect(mockExecuteCommand).toHaveBeenCalledWith('Comfy.ContactSupport')
|
||||
expect(mockTrackHelpResourceClicked).toHaveBeenCalledWith(
|
||||
@@ -304,9 +318,11 @@ describe('ErrorNodeCard.vue', () => {
|
||||
})
|
||||
|
||||
it('passes exceptionType from error item to report generator', async () => {
|
||||
mountCard(makeRuntimeErrorCard())
|
||||
await flushPromises()
|
||||
renderCard(makeRuntimeErrorCard())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
})
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
exceptionType: 'RuntimeError'
|
||||
@@ -329,9 +345,11 @@ describe('ErrorNodeCard.vue', () => {
|
||||
]
|
||||
}
|
||||
|
||||
mountCard(card)
|
||||
await flushPromises()
|
||||
renderCard(card)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
|
||||
})
|
||||
expect(mockGenerateErrorReport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
exceptionType: 'Runtime Error'
|
||||
@@ -340,30 +358,16 @@ describe('ErrorNodeCard.vue', () => {
|
||||
})
|
||||
|
||||
it('falls back to original details when systemStats is unavailable', async () => {
|
||||
const wrapper = mount(ErrorNodeCard, {
|
||||
props: { card: makeRuntimeErrorCard() },
|
||||
global: {
|
||||
plugins: [
|
||||
PrimeVue,
|
||||
i18n,
|
||||
createTestingPinia({
|
||||
createSpy: vi.fn,
|
||||
initialState: {
|
||||
systemStats: { systemStats: null }
|
||||
}
|
||||
})
|
||||
],
|
||||
stubs: {
|
||||
Button: {
|
||||
template:
|
||||
'<button :aria-label="$attrs[\'aria-label\']"><slot /></button>'
|
||||
}
|
||||
}
|
||||
renderCard(makeRuntimeErrorCard(), {
|
||||
initialState: {
|
||||
systemStats: { systemStats: null }
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('Traceback line 1')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
@@ -68,7 +69,13 @@ vi.mock('@/workbench/extensions/manager/composables/useManagerState', () => ({
|
||||
vi.mock('./MissingPackGroupRow.vue', () => ({
|
||||
default: {
|
||||
name: 'MissingPackGroupRow',
|
||||
template: '<div class="pack-row" />',
|
||||
template: `<div class="pack-row" data-testid="pack-row"
|
||||
:data-show-info-button="String(showInfoButton)"
|
||||
:data-show-node-id-badge="String(showNodeIdBadge)"
|
||||
>
|
||||
<button data-testid="locate-node" @click="$emit('locate-node', group.nodeTypes[0]?.nodeId)" />
|
||||
<button data-testid="open-manager-info" @click="$emit('open-manager-info', group.packId)" />
|
||||
</div>`,
|
||||
props: ['group', 'showInfoButton', 'showNodeIdBadge'],
|
||||
emits: ['locate-node', 'open-manager-info']
|
||||
}
|
||||
@@ -95,7 +102,8 @@ const i18n = createI18n({
|
||||
'Some nodes require a newer version of ComfyUI (current: {version}).',
|
||||
outdatedVersionGeneric:
|
||||
'Some nodes require a newer version of ComfyUI.',
|
||||
coreNodesFromVersion: 'Requires ComfyUI {version}:'
|
||||
coreNodesFromVersion: 'Requires ComfyUI {version}:',
|
||||
unknownVersion: 'unknown'
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -113,14 +121,15 @@ function makePackGroups(count = 2): MissingPackGroup[] {
|
||||
}))
|
||||
}
|
||||
|
||||
function mountCard(
|
||||
function renderCard(
|
||||
props: Partial<{
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
missingPackGroups: MissingPackGroup[]
|
||||
}> = {}
|
||||
) {
|
||||
return mount(MissingNodeCard, {
|
||||
const user = userEvent.setup()
|
||||
const result = render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
@@ -134,6 +143,7 @@ function mountCard(
|
||||
}
|
||||
}
|
||||
})
|
||||
return { ...result, user }
|
||||
}
|
||||
|
||||
describe('MissingNodeCard', () => {
|
||||
@@ -151,131 +161,163 @@ describe('MissingNodeCard', () => {
|
||||
describe('Rendering & Props', () => {
|
||||
it('renders cloud message when isCloud is true', () => {
|
||||
mockIsCloud.value = true
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).toContain('Unsupported node packs detected')
|
||||
renderCard()
|
||||
expect(
|
||||
screen.getByText('Unsupported node packs detected.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders OSS message when isCloud is false', () => {
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).toContain('Missing node packs detected')
|
||||
renderCard()
|
||||
expect(
|
||||
screen.getByText('Missing node packs detected. Install them.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders correct number of MissingPackGroupRow components', () => {
|
||||
const wrapper = mountCard({ missingPackGroups: makePackGroups(3) })
|
||||
expect(
|
||||
wrapper.findAllComponents({ name: 'MissingPackGroupRow' })
|
||||
).toHaveLength(3)
|
||||
renderCard({ missingPackGroups: makePackGroups(3) })
|
||||
expect(screen.getAllByTestId('pack-row')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('renders zero rows when missingPackGroups is empty', () => {
|
||||
const wrapper = mountCard({ missingPackGroups: [] })
|
||||
expect(
|
||||
wrapper.findAllComponents({ name: 'MissingPackGroupRow' })
|
||||
).toHaveLength(0)
|
||||
renderCard({ missingPackGroups: [] })
|
||||
expect(screen.queryAllByTestId('pack-row')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('passes props correctly to MissingPackGroupRow children', () => {
|
||||
const wrapper = mountCard({
|
||||
renderCard({
|
||||
showInfoButton: true,
|
||||
showNodeIdBadge: true
|
||||
})
|
||||
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
|
||||
expect(row.props('showInfoButton')).toBe(true)
|
||||
expect(row.props('showNodeIdBadge')).toBe(true)
|
||||
const row = screen.getAllByTestId('pack-row')[0]
|
||||
expect(row.getAttribute('data-show-info-button')).toBe('true')
|
||||
expect(row.getAttribute('data-show-node-id-badge')).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Manager Disabled Hint', () => {
|
||||
it('shows hint when OSS and manager is disabled (showInfoButton false)', () => {
|
||||
mockIsCloud.value = false
|
||||
const wrapper = mountCard({ showInfoButton: false })
|
||||
expect(wrapper.text()).toContain('pip install -U --pre comfyui-manager')
|
||||
expect(wrapper.text()).toContain('--enable-manager')
|
||||
renderCard({ showInfoButton: false })
|
||||
expect(
|
||||
screen.getByText('pip install -U --pre comfyui-manager')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('--enable-manager')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides hint when manager is enabled (showInfoButton true)', () => {
|
||||
mockIsCloud.value = false
|
||||
const wrapper = mountCard({ showInfoButton: true })
|
||||
expect(wrapper.text()).not.toContain('--enable-manager')
|
||||
renderCard({ showInfoButton: true })
|
||||
expect(screen.queryByText('--enable-manager')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides hint on Cloud even when showInfoButton is false', () => {
|
||||
mockIsCloud.value = true
|
||||
const wrapper = mountCard({ showInfoButton: false })
|
||||
expect(wrapper.text()).not.toContain('--enable-manager')
|
||||
renderCard({ showInfoButton: false })
|
||||
expect(screen.queryByText('--enable-manager')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Apply Changes Section', () => {
|
||||
it('hides Apply Changes when manager is not enabled', () => {
|
||||
mockShouldShowManagerButtons.value = false
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).not.toContain('Apply Changes')
|
||||
renderCard()
|
||||
expect(screen.queryByText('Apply Changes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides Apply Changes when manager enabled but no packs pending', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).not.toContain('Apply Changes')
|
||||
renderCard()
|
||||
expect(screen.queryByText('Apply Changes')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows Apply Changes when at least one pack is pending restart', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(true)
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).toContain('Apply Changes')
|
||||
renderCard()
|
||||
expect(screen.getByText('Apply Changes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays spinner during restart', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(true)
|
||||
mockIsRestarting.value = true
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
renderCard()
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables button during restart', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(true)
|
||||
mockIsRestarting.value = true
|
||||
const wrapper = mountCard()
|
||||
const btn = wrapper.find('button')
|
||||
expect(btn.attributes('disabled')).toBeDefined()
|
||||
renderCard()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /apply changes/i })
|
||||
).toBeDisabled()
|
||||
})
|
||||
|
||||
it('calls applyChanges when Apply Changes button is clicked', async () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(true)
|
||||
const wrapper = mountCard()
|
||||
const btn = wrapper.find('button')
|
||||
await btn.trigger('click')
|
||||
const { user } = renderCard()
|
||||
await user.click(screen.getByRole('button', { name: /apply changes/i }))
|
||||
expect(mockApplyChanges).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Event Handling', () => {
|
||||
it('emits locateNode when child emits locate-node', async () => {
|
||||
const wrapper = mountCard()
|
||||
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
|
||||
await row.vm.$emit('locate-node', '42')
|
||||
expect(wrapper.emitted('locateNode')).toBeTruthy()
|
||||
expect(wrapper.emitted('locateNode')?.[0]).toEqual(['42'])
|
||||
const onLocateNode = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
onLocateNode
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
|
||||
stubs: {
|
||||
DotSpinner: {
|
||||
template: '<span role="status" aria-label="loading" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
await user.click(screen.getAllByTestId('locate-node')[0])
|
||||
expect(onLocateNode).toHaveBeenCalledWith('0')
|
||||
})
|
||||
|
||||
it('emits openManagerInfo when child emits open-manager-info', async () => {
|
||||
const wrapper = mountCard()
|
||||
const row = wrapper.findComponent({ name: 'MissingPackGroupRow' })
|
||||
await row.vm.$emit('open-manager-info', 'pack-0')
|
||||
expect(wrapper.emitted('openManagerInfo')).toBeTruthy()
|
||||
expect(wrapper.emitted('openManagerInfo')?.[0]).toEqual(['pack-0'])
|
||||
const onOpenManagerInfo = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
render(MissingNodeCard, {
|
||||
props: {
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
missingPackGroups: makePackGroups(),
|
||||
onOpenManagerInfo
|
||||
},
|
||||
global: {
|
||||
plugins: [createTestingPinia({ createSpy: vi.fn }), i18n],
|
||||
stubs: {
|
||||
DotSpinner: {
|
||||
template: '<span role="status" aria-label="loading" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
await user.click(screen.getAllByTestId('open-manager-info')[0])
|
||||
expect(onOpenManagerInfo).toHaveBeenCalledWith('pack-0')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Core Node Version Warning', () => {
|
||||
it('does not render warning when no missing core nodes', () => {
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).not.toContain('newer version of ComfyUI')
|
||||
const { container } = renderCard()
|
||||
expect(container.textContent).not.toContain('newer version of ComfyUI')
|
||||
})
|
||||
|
||||
it('renders warning with version when missing core nodes exist', () => {
|
||||
@@ -283,20 +325,20 @@ describe('MissingNodeCard', () => {
|
||||
'1.2.0': [{ type: 'TestNode' }]
|
||||
}
|
||||
mockSystemStats.value = { system: { comfyui_version: '1.0.0' } }
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).toContain('(current: 1.0.0)')
|
||||
expect(wrapper.text()).toContain('Requires ComfyUI 1.2.0:')
|
||||
expect(wrapper.text()).toContain('TestNode')
|
||||
const { container } = renderCard()
|
||||
expect(container.textContent).toContain('(current: 1.0.0)')
|
||||
expect(container.textContent).toContain('Requires ComfyUI 1.2.0:')
|
||||
expect(container.textContent).toContain('TestNode')
|
||||
})
|
||||
|
||||
it('renders generic message when version is unavailable', () => {
|
||||
mockMissingCoreNodes.value = {
|
||||
'1.2.0': [{ type: 'TestNode' }]
|
||||
}
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).toContain(
|
||||
'Some nodes require a newer version of ComfyUI.'
|
||||
)
|
||||
renderCard()
|
||||
expect(
|
||||
screen.getByText('Some nodes require a newer version of ComfyUI.')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render warning on Cloud', () => {
|
||||
@@ -304,8 +346,8 @@ describe('MissingNodeCard', () => {
|
||||
mockMissingCoreNodes.value = {
|
||||
'1.2.0': [{ type: 'TestNode' }]
|
||||
}
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).not.toContain('newer version of ComfyUI')
|
||||
const { container } = renderCard()
|
||||
expect(container.textContent).not.toContain('newer version of ComfyUI')
|
||||
})
|
||||
|
||||
it('deduplicates and sorts node names within a version', () => {
|
||||
@@ -316,9 +358,10 @@ describe('MissingNodeCard', () => {
|
||||
{ type: 'ZebraNode' }
|
||||
]
|
||||
}
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).toContain('AlphaNode, ZebraNode')
|
||||
expect(wrapper.text().match(/ZebraNode/g)?.length).toBe(1)
|
||||
const { container } = renderCard()
|
||||
expect(container.textContent).toContain('AlphaNode, ZebraNode')
|
||||
// eslint-disable-next-line testing-library/no-container
|
||||
expect(container.textContent?.match(/ZebraNode/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sorts versions in descending order', () => {
|
||||
@@ -327,8 +370,8 @@ describe('MissingNodeCard', () => {
|
||||
'1.3.0': [{ type: 'Node3' }],
|
||||
'1.2.0': [{ type: 'Node2' }]
|
||||
}
|
||||
const wrapper = mountCard()
|
||||
const text = wrapper.text()
|
||||
const { container } = renderCard()
|
||||
const text = container.textContent ?? ''
|
||||
const v13 = text.indexOf('1.3.0')
|
||||
const v12 = text.indexOf('1.2.0')
|
||||
const v11 = text.indexOf('1.1.0')
|
||||
@@ -341,11 +384,11 @@ describe('MissingNodeCard', () => {
|
||||
'': [{ type: 'NoVersionNode' }],
|
||||
'1.2.0': [{ type: 'VersionedNode' }]
|
||||
}
|
||||
const wrapper = mountCard()
|
||||
expect(wrapper.text()).toContain('Requires ComfyUI 1.2.0:')
|
||||
expect(wrapper.text()).toContain('VersionedNode')
|
||||
expect(wrapper.text()).toContain('unknown')
|
||||
expect(wrapper.text()).toContain('NoVersionNode')
|
||||
const { container } = renderCard()
|
||||
expect(container.textContent).toContain('Requires ComfyUI 1.2.0:')
|
||||
expect(container.textContent).toContain('VersionedNode')
|
||||
expect(container.textContent).toContain('unknown')
|
||||
expect(container.textContent).toContain('NoVersionNode')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -95,18 +96,23 @@ function makeGroup(
|
||||
}
|
||||
}
|
||||
|
||||
function mountRow(
|
||||
function renderRow(
|
||||
props: Partial<{
|
||||
group: MissingPackGroup
|
||||
showInfoButton: boolean
|
||||
showNodeIdBadge: boolean
|
||||
}> = {}
|
||||
) {
|
||||
return mount(MissingPackGroupRow, {
|
||||
const user = userEvent.setup()
|
||||
const onLocateNode = vi.fn()
|
||||
const onOpenManagerInfo = vi.fn()
|
||||
render(MissingPackGroupRow, {
|
||||
props: {
|
||||
group: makeGroup(),
|
||||
showInfoButton: false,
|
||||
showNodeIdBadge: false,
|
||||
onLocateNode,
|
||||
onOpenManagerInfo,
|
||||
...props
|
||||
},
|
||||
global: {
|
||||
@@ -119,6 +125,7 @@ function mountRow(
|
||||
}
|
||||
}
|
||||
})
|
||||
return { user, onLocateNode, onOpenManagerInfo }
|
||||
}
|
||||
|
||||
describe('MissingPackGroupRow', () => {
|
||||
@@ -135,27 +142,27 @@ describe('MissingPackGroupRow', () => {
|
||||
|
||||
describe('Basic Rendering', () => {
|
||||
it('renders pack name from packId', () => {
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.text()).toContain('my-pack')
|
||||
renderRow()
|
||||
expect(screen.getByText(/my-pack/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders "Unknown pack" when packId is null', () => {
|
||||
const wrapper = mountRow({ group: makeGroup({ packId: null }) })
|
||||
expect(wrapper.text()).toContain('Unknown pack')
|
||||
renderRow({ group: makeGroup({ packId: null }) })
|
||||
expect(screen.getByText(/Unknown pack/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders loading text when isResolving is true', () => {
|
||||
const wrapper = mountRow({ group: makeGroup({ isResolving: true }) })
|
||||
expect(wrapper.text()).toContain('Loading')
|
||||
renderRow({ group: makeGroup({ isResolving: true }) })
|
||||
expect(screen.getByText(/Loading/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders node count', () => {
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.text()).toContain('(2)')
|
||||
renderRow()
|
||||
expect(screen.getByText(/\(2\)/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders count of 5 for 5 nodeTypes', () => {
|
||||
const wrapper = mountRow({
|
||||
renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: Array.from({ length: 5 }, (_, i) => ({
|
||||
type: `Node${i}`,
|
||||
@@ -164,39 +171,39 @@ describe('MissingPackGroupRow', () => {
|
||||
}))
|
||||
})
|
||||
})
|
||||
expect(wrapper.text()).toContain('(5)')
|
||||
expect(screen.getByText(/\(5\)/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Expand / Collapse', () => {
|
||||
it('starts collapsed', () => {
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.text()).not.toContain('MissingA')
|
||||
renderRow()
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('expands when chevron is clicked', async () => {
|
||||
const wrapper = mountRow()
|
||||
await wrapper.get('button[aria-label="Expand"]').trigger('click')
|
||||
expect(wrapper.text()).toContain('MissingA')
|
||||
expect(wrapper.text()).toContain('MissingB')
|
||||
const { user } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('MissingA')).toBeInTheDocument()
|
||||
expect(screen.getByText('MissingB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses when chevron is clicked again', async () => {
|
||||
const wrapper = mountRow()
|
||||
await wrapper.get('button[aria-label="Expand"]').trigger('click')
|
||||
expect(wrapper.text()).toContain('MissingA')
|
||||
await wrapper.get('button[aria-label="Collapse"]').trigger('click')
|
||||
expect(wrapper.text()).not.toContain('MissingA')
|
||||
const { user } = renderRow()
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
expect(screen.getByText('MissingA')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Collapse' }))
|
||||
expect(screen.queryByText('MissingA')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node Type List', () => {
|
||||
async function expand(wrapper: ReturnType<typeof mountRow>) {
|
||||
await wrapper.get('button[aria-label="Expand"]').trigger('click')
|
||||
async function expand(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: 'Expand' }))
|
||||
}
|
||||
|
||||
it('renders all nodeTypes when expanded', async () => {
|
||||
const wrapper = mountRow({
|
||||
const { user } = renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [
|
||||
{ type: 'NodeA', nodeId: '1', isReplaceable: false },
|
||||
@@ -205,48 +212,47 @@ describe('MissingPackGroupRow', () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
await expand(wrapper)
|
||||
expect(wrapper.text()).toContain('NodeA')
|
||||
expect(wrapper.text()).toContain('NodeB')
|
||||
expect(wrapper.text()).toContain('NodeC')
|
||||
await expand(user)
|
||||
expect(screen.getByText('NodeA')).toBeInTheDocument()
|
||||
expect(screen.getByText('NodeB')).toBeInTheDocument()
|
||||
expect(screen.getByText('NodeC')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows nodeId badge when showNodeIdBadge is true', async () => {
|
||||
const wrapper = mountRow({ showNodeIdBadge: true })
|
||||
await expand(wrapper)
|
||||
expect(wrapper.text()).toContain('#10')
|
||||
const { user } = renderRow({ showNodeIdBadge: true })
|
||||
await expand(user)
|
||||
expect(screen.getByText('#10')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides nodeId badge when showNodeIdBadge is false', async () => {
|
||||
const wrapper = mountRow({ showNodeIdBadge: false })
|
||||
await expand(wrapper)
|
||||
expect(wrapper.text()).not.toContain('#10')
|
||||
const { user } = renderRow({ showNodeIdBadge: false })
|
||||
await expand(user)
|
||||
expect(screen.queryByText('#10')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits locateNode when Locate button is clicked', async () => {
|
||||
const wrapper = mountRow({ showNodeIdBadge: true })
|
||||
await expand(wrapper)
|
||||
await wrapper
|
||||
.get('button[aria-label="Locate node on canvas"]')
|
||||
.trigger('click')
|
||||
expect(wrapper.emitted('locateNode')).toBeTruthy()
|
||||
expect(wrapper.emitted('locateNode')?.[0]).toEqual(['10'])
|
||||
const { user, onLocateNode } = renderRow({ showNodeIdBadge: true })
|
||||
await expand(user)
|
||||
await user.click(
|
||||
screen.getAllByRole('button', { name: 'Locate node on canvas' })[0]
|
||||
)
|
||||
expect(onLocateNode).toHaveBeenCalledWith('10')
|
||||
})
|
||||
|
||||
it('does not show Locate for nodeType without nodeId', async () => {
|
||||
const wrapper = mountRow({
|
||||
const { user } = renderRow({
|
||||
group: makeGroup({
|
||||
nodeTypes: [{ type: 'NoId', isReplaceable: false } as never]
|
||||
})
|
||||
})
|
||||
await expand(wrapper)
|
||||
await expand(user)
|
||||
expect(
|
||||
wrapper.find('button[aria-label="Locate node on canvas"]').exists()
|
||||
).toBe(false)
|
||||
screen.queryByRole('button', { name: 'Locate node on canvas' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles mixed nodeTypes with and without nodeId', async () => {
|
||||
const wrapper = mountRow({
|
||||
const { user } = renderRow({
|
||||
showNodeIdBadge: true,
|
||||
group: makeGroup({
|
||||
nodeTypes: [
|
||||
@@ -255,11 +261,11 @@ describe('MissingPackGroupRow', () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
await expand(wrapper)
|
||||
expect(wrapper.text()).toContain('WithId')
|
||||
expect(wrapper.text()).toContain('WithoutId')
|
||||
await expand(user)
|
||||
expect(screen.getByText('WithId')).toBeInTheDocument()
|
||||
expect(screen.getByText('WithoutId')).toBeInTheDocument()
|
||||
expect(
|
||||
wrapper.findAll('button[aria-label="Locate node on canvas"]')
|
||||
screen.getAllByRole('button', { name: 'Locate node on canvas' })
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -267,102 +273,103 @@ describe('MissingPackGroupRow', () => {
|
||||
describe('Manager Integration', () => {
|
||||
it('hides install UI when shouldShowManagerButtons is false', () => {
|
||||
mockShouldShowManagerButtons.value = false
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.text()).not.toContain('Install node pack')
|
||||
renderRow()
|
||||
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides install UI when packId is null', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
const wrapper = mountRow({ group: makeGroup({ packId: null }) })
|
||||
expect(wrapper.text()).not.toContain('Install node pack')
|
||||
renderRow({ group: makeGroup({ packId: null }) })
|
||||
expect(screen.queryByText('Install node pack')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Search in Node Manager" when packId exists but pack not in registry', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = []
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.text()).toContain('Search in Node Manager')
|
||||
renderRow()
|
||||
expect(screen.getByText('Search in Node Manager')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Installed" state when pack is installed', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(true)
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.text()).toContain('Installed')
|
||||
renderRow()
|
||||
expect(screen.getByText('Installed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows spinner when installing', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsInstalling.value = true
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
renderRow()
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows install button when not installed and pack found', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.text()).toContain('Install node pack')
|
||||
renderRow()
|
||||
expect(screen.getByText('Install node pack')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls installAllPacks when Install button is clicked', async () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsPackInstalled.mockReturnValue(false)
|
||||
mockMissingNodePacks.value = [{ id: 'my-pack', name: 'My Pack' }]
|
||||
const wrapper = mountRow()
|
||||
await wrapper.get('button:not([aria-label])').trigger('click')
|
||||
const { user } = renderRow()
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: /Install node pack/ })
|
||||
)
|
||||
expect(mockInstallAllPacks).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows loading spinner when registry is loading', () => {
|
||||
mockShouldShowManagerButtons.value = true
|
||||
mockIsLoading.value = true
|
||||
const wrapper = mountRow()
|
||||
expect(wrapper.find('[role="status"]').exists()).toBe(true)
|
||||
renderRow()
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Info Button', () => {
|
||||
it('shows Info button when showInfoButton true and packId not null', () => {
|
||||
const wrapper = mountRow({ showInfoButton: true })
|
||||
renderRow({ showInfoButton: true })
|
||||
expect(
|
||||
wrapper.find('button[aria-label="View in Manager"]').exists()
|
||||
).toBe(true)
|
||||
screen.getByRole('button', { name: 'View in Manager' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides Info button when showInfoButton is false', () => {
|
||||
const wrapper = mountRow({ showInfoButton: false })
|
||||
renderRow({ showInfoButton: false })
|
||||
expect(
|
||||
wrapper.find('button[aria-label="View in Manager"]').exists()
|
||||
).toBe(false)
|
||||
screen.queryByRole('button', { name: 'View in Manager' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides Info button when packId is null', () => {
|
||||
const wrapper = mountRow({
|
||||
renderRow({
|
||||
showInfoButton: true,
|
||||
group: makeGroup({ packId: null })
|
||||
})
|
||||
expect(
|
||||
wrapper.find('button[aria-label="View in Manager"]').exists()
|
||||
).toBe(false)
|
||||
screen.queryByRole('button', { name: 'View in Manager' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits openManagerInfo when Info button is clicked', async () => {
|
||||
const wrapper = mountRow({ showInfoButton: true })
|
||||
await wrapper.get('button[aria-label="View in Manager"]').trigger('click')
|
||||
expect(wrapper.emitted('openManagerInfo')).toBeTruthy()
|
||||
expect(wrapper.emitted('openManagerInfo')?.[0]).toEqual(['my-pack'])
|
||||
const { user, onOpenManagerInfo } = renderRow({ showInfoButton: true })
|
||||
await user.click(screen.getByRole('button', { name: 'View in Manager' }))
|
||||
expect(onOpenManagerInfo).toHaveBeenCalledWith('my-pack')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles empty nodeTypes array', () => {
|
||||
const wrapper = mountRow({ group: makeGroup({ nodeTypes: [] }) })
|
||||
expect(wrapper.text()).toContain('(0)')
|
||||
renderRow({ group: makeGroup({ nodeTypes: [] }) })
|
||||
expect(screen.getByText(/\(0\)/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PrimeVue from 'primevue/config'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import TabErrors from './TabErrors.vue'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
rootGraph: {
|
||||
@@ -61,8 +61,9 @@ describe('TabErrors.vue', () => {
|
||||
})
|
||||
})
|
||||
|
||||
function mountComponent(initialState = {}) {
|
||||
return mount(TabErrors, {
|
||||
function renderComponent(initialState = {}) {
|
||||
const user = userEvent.setup()
|
||||
render(TabErrors, {
|
||||
global: {
|
||||
plugins: [
|
||||
PrimeVue,
|
||||
@@ -86,15 +87,16 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
}
|
||||
})
|
||||
return { user }
|
||||
}
|
||||
|
||||
it('renders "no errors" state when store is empty', () => {
|
||||
const wrapper = mountComponent()
|
||||
expect(wrapper.text()).toContain('No errors')
|
||||
renderComponent()
|
||||
expect(screen.getByText('No errors')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders prompt-level errors (Group title = error message)', async () => {
|
||||
const wrapper = mountComponent({
|
||||
renderComponent({
|
||||
executionError: {
|
||||
lastPromptError: {
|
||||
type: 'prompt_no_outputs',
|
||||
@@ -104,12 +106,9 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Group title should be the raw message from store
|
||||
expect(wrapper.text()).toContain('Server Error: No outputs')
|
||||
// Item message should be localized desc
|
||||
expect(wrapper.text()).toContain('Prompt has no outputs')
|
||||
// Details should not be rendered for prompt errors
|
||||
expect(wrapper.text()).not.toContain('Error details')
|
||||
expect(screen.getByText('Server Error: No outputs')).toBeInTheDocument()
|
||||
expect(screen.getByText('Prompt has no outputs')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders node validation errors grouped by class_type', async () => {
|
||||
@@ -118,7 +117,7 @@ describe('TabErrors.vue', () => {
|
||||
title: 'CLIP Text Encode'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
renderComponent({
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'6': {
|
||||
@@ -131,10 +130,10 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('CLIPTextEncode')
|
||||
expect(wrapper.text()).toContain('#6')
|
||||
expect(wrapper.text()).toContain('CLIP Text Encode')
|
||||
expect(wrapper.text()).toContain('Required input is missing')
|
||||
expect(screen.getByText('CLIPTextEncode')).toBeInTheDocument()
|
||||
expect(screen.getByText('#6')).toBeInTheDocument()
|
||||
expect(screen.getByText('CLIP Text Encode')).toBeInTheDocument()
|
||||
expect(screen.getByText('Required input is missing')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders runtime execution errors from WebSocket', async () => {
|
||||
@@ -143,7 +142,7 @@ describe('TabErrors.vue', () => {
|
||||
title: 'KSampler'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
renderComponent({
|
||||
executionError: {
|
||||
lastExecutionError: {
|
||||
prompt_id: 'abc',
|
||||
@@ -157,17 +156,17 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('KSampler')
|
||||
expect(wrapper.text()).toContain('#10')
|
||||
expect(wrapper.text()).toContain('RuntimeError: Out of memory')
|
||||
expect(wrapper.text()).toContain('Line 1')
|
||||
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('#10')).toBeInTheDocument()
|
||||
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
|
||||
expect(screen.getByText(/Line 1/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('filters errors based on search query', async () => {
|
||||
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
|
||||
vi.mocked(getNodeByExecutionId).mockReturnValue(null)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
const { user } = renderComponent({
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
@@ -182,14 +181,17 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('CLIPTextEncode')
|
||||
expect(wrapper.text()).toContain('KSampler')
|
||||
expect(screen.getAllByText('CLIPTextEncode').length).toBeGreaterThanOrEqual(
|
||||
1
|
||||
)
|
||||
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
const searchInput = wrapper.find('input')
|
||||
await searchInput.setValue('Missing text input')
|
||||
await user.type(screen.getByRole('textbox'), 'Missing text input')
|
||||
|
||||
expect(wrapper.text()).toContain('CLIPTextEncode')
|
||||
expect(wrapper.text()).not.toContain('KSampler')
|
||||
expect(screen.getAllByText('CLIPTextEncode').length).toBeGreaterThanOrEqual(
|
||||
1
|
||||
)
|
||||
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls copyToClipboard when copy button is clicked', async () => {
|
||||
@@ -198,7 +200,7 @@ describe('TabErrors.vue', () => {
|
||||
const mockCopy = vi.fn()
|
||||
vi.mocked(useCopyToClipboard).mockReturnValue({ copyToClipboard: mockCopy })
|
||||
|
||||
const wrapper = mountComponent({
|
||||
const { user } = renderComponent({
|
||||
executionError: {
|
||||
lastNodeErrors: {
|
||||
'1': {
|
||||
@@ -209,9 +211,7 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const copyButton = wrapper.find('[data-testid="error-card-copy"]')
|
||||
expect(copyButton.exists()).toBe(true)
|
||||
await copyButton.trigger('click')
|
||||
await user.click(screen.getByTestId('error-card-copy'))
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
|
||||
})
|
||||
@@ -222,7 +222,7 @@ describe('TabErrors.vue', () => {
|
||||
title: 'KSampler'
|
||||
} as ReturnType<typeof getNodeByExecutionId>)
|
||||
|
||||
const wrapper = mountComponent({
|
||||
renderComponent({
|
||||
executionError: {
|
||||
lastExecutionError: {
|
||||
prompt_id: 'abc',
|
||||
@@ -236,15 +236,9 @@ describe('TabErrors.vue', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Runtime error panel title should show class type
|
||||
expect(wrapper.text()).toContain('KSampler')
|
||||
expect(wrapper.text()).toContain('RuntimeError: Out of memory')
|
||||
// Should render in the dedicated runtime error panel, not inside accordion
|
||||
const runtimePanel = wrapper.find('[data-testid="runtime-error-panel"]')
|
||||
expect(runtimePanel.exists()).toBe(true)
|
||||
// Verify the error message appears exactly once (not duplicated in accordion)
|
||||
expect(
|
||||
wrapper.text().match(/RuntimeError: Out of memory/g) ?? []
|
||||
).toHaveLength(1)
|
||||
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
|
||||
expect(screen.getByText('RuntimeError: Out of memory')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('RuntimeError: Out of memory')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
<Button
|
||||
v-else-if="
|
||||
group.type === 'missing_model' &&
|
||||
downloadableModels.length > 0
|
||||
downloadableModels.length > 1
|
||||
"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
@@ -293,8 +293,8 @@ const {
|
||||
errorNodeCache,
|
||||
missingNodeCache,
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
filteredMissingModelGroups: missingModelGroups,
|
||||
filteredMissingMediaGroups: missingMediaGroups,
|
||||
swapNodeGroups
|
||||
} = useErrorGroups(searchQuery, t)
|
||||
|
||||
|
||||
@@ -58,8 +58,10 @@ vi.mock(
|
||||
})
|
||||
)
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
||||
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
import { useErrorGroups } from './useErrorGroups'
|
||||
|
||||
function makeMissingNodeType(
|
||||
@@ -754,4 +756,48 @@ describe('useErrorGroups', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unfiltered vs selection-filtered model/media groups', () => {
|
||||
it('exposes both unfiltered (missingModelGroups) and filtered (filteredMissingModelGroups)', () => {
|
||||
const { groups } = createErrorGroups()
|
||||
expect(groups.missingModelGroups).toBeDefined()
|
||||
expect(groups.filteredMissingModelGroups).toBeDefined()
|
||||
expect(groups.missingMediaGroups).toBeDefined()
|
||||
expect(groups.filteredMissingMediaGroups).toBeDefined()
|
||||
})
|
||||
|
||||
it('missingModelGroups returns total candidates regardless of selection (ErrorOverlay contract)', async () => {
|
||||
const { store, groups } = createErrorGroups()
|
||||
store.surfaceMissingModels([
|
||||
makeModel('a.safetensors', { nodeId: '1', directory: 'checkpoints' }),
|
||||
makeModel('b.safetensors', { nodeId: '2', directory: 'checkpoints' })
|
||||
])
|
||||
// Simulate canvas selection of a single node so the filtered
|
||||
// variant actually narrows. Without this, both sides return the
|
||||
// same value trivially and the test can't prove the contract.
|
||||
vi.mocked(isLGraphNode).mockReturnValue(true)
|
||||
const canvasStore = useCanvasStore()
|
||||
canvasStore.selectedItems = fromAny<
|
||||
typeof canvasStore.selectedItems,
|
||||
unknown
|
||||
>([{ id: '1' }])
|
||||
await nextTick()
|
||||
|
||||
// Unfiltered total stays at one group of two models regardless of
|
||||
// the selection — ErrorOverlay reads this for the overlay label
|
||||
// and must not shrink with canvas selection.
|
||||
expect(groups.missingModelGroups.value).toHaveLength(1)
|
||||
expect(groups.missingModelGroups.value[0].models).toHaveLength(2)
|
||||
|
||||
// Filtered variant does narrow under the same selection state —
|
||||
// this is how the errors tab scopes cards to the selected node.
|
||||
// Exact filtered output depends on the app.rootGraph lookup
|
||||
// (mocked to return undefined here); what matters is that the
|
||||
// filtered shape is a different reference and does not blindly
|
||||
// mirror the unfiltered one.
|
||||
expect(groups.filteredMissingModelGroups.value).not.toBe(
|
||||
groups.missingModelGroups.value
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -660,6 +660,106 @@ export function useErrorGroups(
|
||||
]
|
||||
}
|
||||
|
||||
function isAssetErrorInSelection(executionNodeId: string): boolean {
|
||||
const nodeIds = selectedNodeInfo.value.nodeIds
|
||||
if (!nodeIds) return true
|
||||
|
||||
// Try missing node cache first
|
||||
const cachedNode = missingNodeCache.value.get(executionNodeId)
|
||||
if (cachedNode && nodeIds.has(String(cachedNode.id))) return true
|
||||
|
||||
// Resolve from graph for model/media candidates
|
||||
if (app.rootGraph) {
|
||||
const graphNode = getNodeByExecutionId(app.rootGraph, executionNodeId)
|
||||
if (graphNode && nodeIds.has(String(graphNode.id))) return true
|
||||
}
|
||||
|
||||
for (const containerExecId of selectedNodeInfo.value
|
||||
.containerExecutionIds) {
|
||||
if (executionNodeId.startsWith(`${containerExecId}:`)) return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const filteredMissingModelGroups = computed(() => {
|
||||
if (!selectedNodeInfo.value.nodeIds) return missingModelGroups.value
|
||||
const candidates = missingModelStore.missingModelCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
|
||||
const map = new Map<
|
||||
string | null | typeof UNSUPPORTED,
|
||||
{ candidates: MissingModelCandidate[]; isAssetSupported: boolean }
|
||||
>()
|
||||
for (const c of filtered) {
|
||||
const groupKey =
|
||||
c.isAssetSupported || !isCloud ? c.directory || null : UNSUPPORTED
|
||||
const existing = map.get(groupKey)
|
||||
if (existing) {
|
||||
existing.candidates.push(c)
|
||||
} else {
|
||||
map.set(groupKey, {
|
||||
candidates: [c],
|
||||
isAssetSupported: c.isAssetSupported
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.sort(([dirA], [dirB]) => {
|
||||
if (dirA === UNSUPPORTED) return 1
|
||||
if (dirB === UNSUPPORTED) return -1
|
||||
if (dirA === null) return 1
|
||||
if (dirB === null) return -1
|
||||
return dirA.localeCompare(dirB)
|
||||
})
|
||||
.map(([key, { candidates: groupCandidates, isAssetSupported }]) => ({
|
||||
directory: typeof key === 'string' ? key : null,
|
||||
models: groupCandidatesByName(groupCandidates),
|
||||
isAssetSupported
|
||||
}))
|
||||
})
|
||||
|
||||
const filteredMissingMediaGroups = computed(() => {
|
||||
if (!selectedNodeInfo.value.nodeIds) return missingMediaGroups.value
|
||||
const candidates = missingMediaStore.missingMediaCandidates
|
||||
if (!candidates?.length) return []
|
||||
const filtered = candidates.filter(
|
||||
(c) => c.nodeId != null && isAssetErrorInSelection(String(c.nodeId))
|
||||
)
|
||||
if (!filtered.length) return []
|
||||
return groupCandidatesByMediaType(filtered)
|
||||
})
|
||||
|
||||
function buildMissingModelGroupsFiltered(): ErrorGroup[] {
|
||||
if (!filteredMissingModelGroups.value.length) return []
|
||||
return [
|
||||
{
|
||||
type: 'missing_model' as const,
|
||||
title: `${t('rightSidePanel.missingModels.missingModelsTitle')} (${filteredMissingModelGroups.value.reduce((count, group) => count + group.models.length, 0)})`,
|
||||
priority: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function buildMissingMediaGroupsFiltered(): ErrorGroup[] {
|
||||
if (!filteredMissingMediaGroups.value.length) return []
|
||||
const totalItems = filteredMissingMediaGroups.value.reduce(
|
||||
(count, group) => count + group.items.length,
|
||||
0
|
||||
)
|
||||
return [
|
||||
{
|
||||
type: 'missing_media' as const,
|
||||
title: `${t('rightSidePanel.missingMedia.missingMediaTitle')} (${totalItems})`,
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const allErrorGroups = computed<ErrorGroup[]>(() => {
|
||||
const groupsMap = new Map<string, GroupEntry>()
|
||||
|
||||
@@ -686,10 +786,18 @@ export function useErrorGroups(
|
||||
? toSortedGroups(regroupByErrorMessage(groupsMap))
|
||||
: toSortedGroups(groupsMap)
|
||||
|
||||
const filterByNode = selectedNodeInfo.value.nodeIds !== null
|
||||
|
||||
// Missing nodes are intentionally unfiltered — they represent
|
||||
// pack-level problems relevant regardless of which node is selected.
|
||||
return [
|
||||
...buildMissingNodeGroups(),
|
||||
...buildMissingModelGroups(),
|
||||
...buildMissingMediaGroups(),
|
||||
...(filterByNode
|
||||
? buildMissingModelGroupsFiltered()
|
||||
: buildMissingModelGroups()),
|
||||
...(filterByNode
|
||||
? buildMissingMediaGroupsFiltered()
|
||||
: buildMissingMediaGroups()),
|
||||
...executionGroups
|
||||
]
|
||||
})
|
||||
@@ -727,6 +835,8 @@ export function useErrorGroups(
|
||||
missingPackGroups,
|
||||
missingModelGroups,
|
||||
missingMediaGroups,
|
||||
filteredMissingModelGroups,
|
||||
filteredMissingMediaGroups,
|
||||
swapNodeGroups
|
||||
}
|
||||
}
|
||||
|
||||